diff --git a/.github/workflows/pr-build.yml b/.github/workflows/pr-build.yml index 05318b3a1..9b1e56ad1 100644 --- a/.github/workflows/pr-build.yml +++ b/.github/workflows/pr-build.yml @@ -110,6 +110,7 @@ jobs: packages/ui/src/lib/hooks/use-foreground-refresh.test.ts packages/ui/src/lib/launch-errors.test.ts packages/ui/src/lib/message-selection-position.test.ts + packages/ui/src/lib/runtime-env.test.ts packages/ui/src/lib/trailing-resync.test.ts packages/ui/src/stores/abort-created-workspace-cleanup.test.ts packages/ui/src/stores/app-session-reconciliation.test.ts diff --git a/packages/tauri-app/src-tauri/capabilities/remote-window-notifications.json b/packages/tauri-app/src-tauri/capabilities/remote-window-notifications.json new file mode 100644 index 000000000..210c8d88b --- /dev/null +++ b/packages/tauri-app/src-tauri/capabilities/remote-window-notifications.json @@ -0,0 +1,15 @@ +{ + "$schema": "https://schema.tauri.app/capabilities.json", + "identifier": "remote-window-notifications", + "description": "Grant remote CodeNomad windows access only to native OS notifications.", + "local": false, + "remote": { + "urls": ["http://*:*", "https://*:*"] + }, + "windows": ["remote-*"], + "permissions": [ + "notification:allow-is-permission-granted", + "notification:allow-request-permission", + "notification:allow-notify" + ] +} diff --git a/packages/tauri-app/src-tauri/gen/schemas/capabilities.json b/packages/tauri-app/src-tauri/gen/schemas/capabilities.json index 3ab025a9e..37b0f153c 100644 --- a/packages/tauri-app/src-tauri/gen/schemas/capabilities.json +++ b/packages/tauri-app/src-tauri/gen/schemas/capabilities.json @@ -1 +1 @@ -{"main-window-native-dialogs":{"identifier":"main-window-native-dialogs","description":"Grant the main window access to required core features and native dialog commands.","remote":{"urls":["http://127.0.0.1:*","http://localhost:*","http://tauri.localhost/*","https://tauri.localhost/*"]},"local":true,"windows":["main"],"permissions":["core:default","core:menu:default","dialog:allow-open","opener:allow-default-urls","opener:allow-open-url","notification:allow-is-permission-granted","notification:allow-request-permission","notification:allow-notify","notification:allow-show","core:webview:allow-set-webview-zoom"]}} \ No newline at end of file +{"main-window-native-dialogs":{"identifier":"main-window-native-dialogs","description":"Grant the main window access to required core features and native dialog commands.","remote":{"urls":["http://127.0.0.1:*","http://localhost:*","http://tauri.localhost/*","https://tauri.localhost/*"]},"local":true,"windows":["main"],"permissions":["core:default","core:menu:default","dialog:allow-open","opener:allow-default-urls","opener:allow-open-url","notification:allow-is-permission-granted","notification:allow-request-permission","notification:allow-notify","notification:allow-show","core:webview:allow-set-webview-zoom"]},"remote-window-notifications":{"identifier":"remote-window-notifications","description":"Grant remote CodeNomad windows access only to native OS notifications.","remote":{"urls":["http://*:*","https://*:*"]},"local":false,"windows":["remote-*"],"permissions":["notification:allow-is-permission-granted","notification:allow-request-permission","notification:allow-notify"]}} \ No newline at end of file diff --git a/packages/tauri-app/src-tauri/src/main.rs b/packages/tauri-app/src-tauri/src/main.rs index 4dc142612..d5c5fd705 100644 --- a/packages/tauri-app/src-tauri/src/main.rs +++ b/packages/tauri-app/src-tauri/src/main.rs @@ -47,7 +47,8 @@ use windows_sys::Win32::UI::Shell::SetCurrentProcessExplicitAppUserModelID; const ZOOM_STEP: f64 = 0.1; const RELEASES_URL: &str = "https://github.com/NeuralNomadsAI/CodeNomad/releases/latest"; const LOCAL_WINDOW_CONTEXT_SCRIPT: &str = "window.__CODENOMAD_WINDOW_CONTEXT__ = 'local';"; -const REMOTE_WINDOW_CONTEXT_SCRIPT: &str = "window.__CODENOMAD_WINDOW_CONTEXT__ = 'remote';"; +const REMOTE_WINDOW_CONTEXT_SCRIPT: &str = + "window.__CODENOMAD_RUNTIME_HOST__ = 'tauri'; window.__CODENOMAD_WINDOW_CONTEXT__ = 'remote';"; #[cfg(windows)] const WINDOWS_APP_USER_MODEL_ID: &str = "ai.neuralnomads.codenomad.client"; @@ -218,19 +219,20 @@ fn should_allow_window_origin( window_label: &str, url: &Url, ) -> bool { - if should_allow_internal(url) { - return true; - } - let state = app_handle.state::(); let Ok(allowed) = state.remote_origins.lock() else { return false; }; - if let Some(origin) = allowed.get(window_label) { - return origin == &url.origin().ascii_serialization(); - } + should_allow_registered_origin(allowed.get(window_label).map(String::as_str), url) +} - false +fn should_allow_registered_origin(registered_origin: Option<&str>, url: &Url) -> bool { + if let Some(origin) = registered_origin { + if matches!(url.scheme(), "http" | "https") { + return origin == url.origin().ascii_serialization(); + } + } + should_allow_internal(url) } fn intercept_navigation(webview: &Webview, url: &Url) -> bool { @@ -1059,8 +1061,13 @@ fn build_about_metadata(version: &str, include_update_link: bool) -> AboutMetada #[cfg(test)] mod menu_tests { - use super::{build_about_metadata, run_update_with_fallback, RELEASES_URL}; + use super::{ + build_about_metadata, run_update_with_fallback, should_allow_registered_origin, + RELEASES_URL, REMOTE_WINDOW_CONTEXT_SCRIPT, + }; + use serde_json::json; use std::sync::atomic::{AtomicBool, Ordering}; + use url::Url; #[test] fn failed_update_uses_release_fallback() { @@ -1091,4 +1098,53 @@ mod menu_tests { assert_eq!(metadata.website, None); assert_eq!(metadata.website_label, None); } + + #[test] + fn remote_windows_identify_as_remote_tauri_windows() { + assert!(REMOTE_WINDOW_CONTEXT_SCRIPT.contains("__CODENOMAD_RUNTIME_HOST__ = 'tauri'")); + assert!(REMOTE_WINDOW_CONTEXT_SCRIPT.contains("__CODENOMAD_WINDOW_CONTEXT__ = 'remote'")); + + let capability: serde_json::Value = serde_json::from_str(include_str!( + "../capabilities/remote-window-notifications.json" + )) + .unwrap(); + assert_eq!(capability["local"], false); + assert_eq!( + capability["remote"]["urls"], + json!(["http://*:*", "https://*:*"]) + ); + assert_eq!(capability["windows"], json!(["remote-*"])); + assert_eq!( + capability["permissions"], + json!([ + "notification:allow-is-permission-granted", + "notification:allow-request-permission", + "notification:allow-notify" + ]) + ); + + let config: serde_json::Value = + serde_json::from_str(include_str!("../tauri.conf.json")).unwrap(); + assert!(config["app"]["security"]["capabilities"] + .as_array() + .unwrap() + .contains(&json!("remote-window-notifications"))); + } + + #[test] + fn remote_windows_stay_on_their_registered_http_origin() { + let origin = "https://remote.example:9898"; + assert!(should_allow_registered_origin( + Some(origin), + &Url::parse("https://remote.example:9898/settings").unwrap() + )); + assert!(!should_allow_registered_origin( + Some(origin), + &Url::parse("http://localhost:9898/").unwrap() + )); + assert!(should_allow_registered_origin( + Some(origin), + &Url::parse("about:blank").unwrap() + )); + } } diff --git a/packages/tauri-app/src-tauri/tauri.conf.json b/packages/tauri-app/src-tauri/tauri.conf.json index e01de7c34..273e4c8d7 100644 --- a/packages/tauri-app/src-tauri/tauri.conf.json +++ b/packages/tauri-app/src-tauri/tauri.conf.json @@ -37,7 +37,8 @@ ] }, "capabilities": [ - "main-window-native-dialogs" + "main-window-native-dialogs", + "remote-window-notifications" ] } }, diff --git a/packages/ui/src/components/provider-auth/provider-manager-modal.tsx b/packages/ui/src/components/provider-auth/provider-manager-modal.tsx index a7f4e5b81..4bbf6af75 100644 --- a/packages/ui/src/components/provider-auth/provider-manager-modal.tsx +++ b/packages/ui/src/components/provider-auth/provider-manager-modal.tsx @@ -6,7 +6,7 @@ import type { OpencodeClient } from "@opencode-ai/sdk/v2/client" import { openExternalUrl } from "../../lib/external-url" import { useI18n } from "../../lib/i18n" import { requestData } from "../../lib/opencode-api" -import { isTauriHost } from "../../lib/runtime-env" +import { isLocalTauriHost } from "../../lib/runtime-env" import { extractProviderAuthErrorMessage, genericApiMethod, @@ -186,7 +186,7 @@ export const ProviderManagerModal: Component = (props } function isBrowserHostForOAuth(): boolean { - return !isTauriHost() && typeof window !== "undefined" + return !isLocalTauriHost() && typeof window !== "undefined" } function prepareOAuthPopupWindow(): Window | null { diff --git a/packages/ui/src/lib/external-url.ts b/packages/ui/src/lib/external-url.ts index f9e7547e8..c8fce4b5a 100644 --- a/packages/ui/src/lib/external-url.ts +++ b/packages/ui/src/lib/external-url.ts @@ -1,11 +1,11 @@ -import { isTauriHost } from "./runtime-env" +import { isLocalTauriHost } from "./runtime-env" export async function openExternalUrl(url: string, context = "ui"): Promise { if (typeof window === "undefined") { return false } - if (isTauriHost()) { + if (isLocalTauriHost()) { try { const { openUrl } = await import("@tauri-apps/plugin-opener") await openUrl(url) diff --git a/packages/ui/src/lib/runtime-env.test.ts b/packages/ui/src/lib/runtime-env.test.ts new file mode 100644 index 000000000..10a305991 --- /dev/null +++ b/packages/ui/src/lib/runtime-env.test.ts @@ -0,0 +1,23 @@ +import assert from "node:assert/strict" +import { describe, it } from "node:test" +import { isLocalTauriHost, type RuntimeEnvironment } from "./runtime-env.ts" + +const environment = (host: RuntimeEnvironment["host"], windowContext: RuntimeEnvironment["windowContext"]) => ({ + host, + windowContext, +}) + +describe("isLocalTauriHost", () => { + it("enables native-only features in the local Tauri window", () => { + assert.equal(isLocalTauriHost(environment("tauri", "local")), true) + }) + + it("keeps native-only features disabled in remote Tauri windows", () => { + assert.equal(isLocalTauriHost(environment("tauri", "remote")), false) + }) + + it("does not classify web or Electron windows as local Tauri", () => { + assert.equal(isLocalTauriHost(environment("web", "remote")), false) + assert.equal(isLocalTauriHost(environment("electron", "local")), false) + }) +}) diff --git a/packages/ui/src/lib/runtime-env.ts b/packages/ui/src/lib/runtime-env.ts index 3f90374dc..747f0938e 100644 --- a/packages/ui/src/lib/runtime-env.ts +++ b/packages/ui/src/lib/runtime-env.ts @@ -117,6 +117,9 @@ export const runtimeEnv = detectRuntimeEnvironment() export const isElectronHost = () => detectHost() === "electron" export const isTauriHost = () => detectHost() === "tauri" export const isWebHost = () => detectHost() === "web" +export const isLocalTauriHost = ( + environment: Pick = detectRuntimeEnvironment(), +) => environment.host === "tauri" && environment.windowContext === "local" export const isDesktopHost = () => isElectronHost() || isTauriHost() export const isMobilePlatform = () => detectPlatform() === "mobile" export const isLocalWindow = () => detectWindowContext() === "local" diff --git a/packages/ui/src/lib/settings/behavior-registry.ts b/packages/ui/src/lib/settings/behavior-registry.ts index 0cca660f7..99e9e3741 100644 --- a/packages/ui/src/lib/settings/behavior-registry.ts +++ b/packages/ui/src/lib/settings/behavior-registry.ts @@ -7,7 +7,7 @@ import type { } from "../../stores/preferences" import type { Command } from "../commands" import { tGlobal } from "../i18n" -import { isTauriHost, isWebHost } from "../runtime-env" +import { isLocalTauriHost, isWebHost } from "../runtime-env" export type BehaviorSettingKind = "toggle" | "enum" @@ -302,7 +302,7 @@ export function getBehaviorSettings(actions: BehaviorRegistryActions): BehaviorS } }, }, - ...(isTauriHost() + ...(isLocalTauriHost() ? [ { kind: "toggle" as const,