From 7714b688453d45ffc79c719ca97fbb150908790c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pascal=20Andr=C3=A9?= Date: Fri, 7 Aug 2026 16:00:23 +0200 Subject: [PATCH 1/3] fix(tauri): enable remote window notifications Mark remote CodeNomad webview windows explicitly as Tauri hosts so notification capability checks use the native plugin instead of the Web Notification API. Grant remote-* windows only the three notification commands used for permission checks, permission requests, and delivery across configured HTTP and HTTPS server origins. Keep native dialogs and other desktop privileges restricted to the main window. Add a regression assertion for the injected runtime context. Validated with the UI production build, UI typecheck, all 85 Tauri Rust tests, and independent capability/runtime reviews. --- .../capabilities/remote-window-notifications.json | 14 ++++++++++++++ .../src-tauri/gen/schemas/capabilities.json | 2 +- packages/tauri-app/src-tauri/src/main.rs | 13 +++++++++++-- packages/tauri-app/src-tauri/tauri.conf.json | 3 ++- 4 files changed, 28 insertions(+), 4 deletions(-) create mode 100644 packages/tauri-app/src-tauri/capabilities/remote-window-notifications.json 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..e024b1fbf --- /dev/null +++ b/packages/tauri-app/src-tauri/capabilities/remote-window-notifications.json @@ -0,0 +1,14 @@ +{ + "$schema": "https://schema.tauri.app/capabilities.json", + "identifier": "remote-window-notifications", + "description": "Grant remote CodeNomad windows access only to native OS notifications.", + "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..b2da20b05 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":true,"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..6385b39b9 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"; @@ -1059,7 +1060,9 @@ 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, RELEASES_URL, REMOTE_WINDOW_CONTEXT_SCRIPT, + }; use std::sync::atomic::{AtomicBool, Ordering}; #[test] @@ -1091,4 +1094,10 @@ 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'")); + } } 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" ] } }, From 42607cc1100a5165c2f6e6dd88a050bd08f813db Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pascal=20Andr=C3=A9?= Date: Fri, 7 Aug 2026 16:39:35 +0200 Subject: [PATCH 2/3] fix(tauri): harden remote notification access Pin remote webview HTTP and HTTPS navigation to each configured server origin before granting native notification IPC. Mark the capability remote-only and assert its exact URL, window, registration, and permission contract. Keep OAuth and external links on the browser popup path in remote Tauri windows, and hide the local-only native event transport setting there. This preserves existing remote behavior while identifying the physical host correctly for notifications. Validated with all 86 Tauri tests, UI typecheck and production build, rustfmt, diff checks, and three Gatekeeper rounds ending with zero findings. --- .../remote-window-notifications.json | 1 + .../src-tauri/gen/schemas/capabilities.json | 2 +- packages/tauri-app/src-tauri/src/main.rs | 65 ++++++++++++++++--- .../provider-auth/provider-manager-modal.tsx | 4 +- packages/ui/src/lib/external-url.ts | 4 +- .../ui/src/lib/settings/behavior-registry.ts | 4 +- 6 files changed, 64 insertions(+), 16 deletions(-) diff --git a/packages/tauri-app/src-tauri/capabilities/remote-window-notifications.json b/packages/tauri-app/src-tauri/capabilities/remote-window-notifications.json index e024b1fbf..210c8d88b 100644 --- a/packages/tauri-app/src-tauri/capabilities/remote-window-notifications.json +++ b/packages/tauri-app/src-tauri/capabilities/remote-window-notifications.json @@ -2,6 +2,7 @@ "$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://*:*"] }, diff --git a/packages/tauri-app/src-tauri/gen/schemas/capabilities.json b/packages/tauri-app/src-tauri/gen/schemas/capabilities.json index b2da20b05..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"]},"remote-window-notifications":{"identifier":"remote-window-notifications","description":"Grant remote CodeNomad windows access only to native OS notifications.","remote":{"urls":["http://*:*","https://*:*"]},"local":true,"windows":["remote-*"],"permissions":["notification:allow-is-permission-granted","notification:allow-request-permission","notification:allow-notify"]}} \ 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 6385b39b9..d5c5fd705 100644 --- a/packages/tauri-app/src-tauri/src/main.rs +++ b/packages/tauri-app/src-tauri/src/main.rs @@ -219,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 { @@ -1061,9 +1062,12 @@ 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, REMOTE_WINDOW_CONTEXT_SCRIPT, + 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() { @@ -1099,5 +1103,48 @@ mod menu_tests { 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/ui/src/components/provider-auth/provider-manager-modal.tsx b/packages/ui/src/components/provider-auth/provider-manager-modal.tsx index a7f4e5b81..8ce7ad4c8 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 { isRemoteWindow, isTauriHost } 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 (!isTauriHost() || isRemoteWindow()) && 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..65b516a89 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 { isLocalWindow, isTauriHost } from "./runtime-env" export async function openExternalUrl(url: string, context = "ui"): Promise { if (typeof window === "undefined") { return false } - if (isTauriHost()) { + if (isTauriHost() && isLocalWindow()) { try { const { openUrl } = await import("@tauri-apps/plugin-opener") await openUrl(url) diff --git a/packages/ui/src/lib/settings/behavior-registry.ts b/packages/ui/src/lib/settings/behavior-registry.ts index 0cca660f7..b21d3c585 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 { isLocalWindow, isTauriHost, isWebHost } from "../runtime-env" export type BehaviorSettingKind = "toggle" | "enum" @@ -302,7 +302,7 @@ export function getBehaviorSettings(actions: BehaviorRegistryActions): BehaviorS } }, }, - ...(isTauriHost() + ...(isTauriHost() && isLocalWindow() ? [ { kind: "toggle" as const, From d846fcd29cfb3559b3f679b95c8d69a9f3cd745b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pascal=20Andr=C3=A9?= Date: Fri, 7 Aug 2026 16:59:10 +0200 Subject: [PATCH 3/3] test(ui): cover remote Tauri feature guards Centralize local-Tauri detection so native opener usage, OAuth popup behavior, and the native event transport setting share one host-context decision. Exercise local and remote Tauri plus web and Electron environments, and add the regression test to the PR workflow's explicit runnable UI suite. Validated with UI typecheck, production build, the exact 173-test workflow command, and Gatekeeper round 6 with zero findings. --- .github/workflows/pr-build.yml | 1 + .../provider-auth/provider-manager-modal.tsx | 4 ++-- packages/ui/src/lib/external-url.ts | 4 ++-- packages/ui/src/lib/runtime-env.test.ts | 23 +++++++++++++++++++ packages/ui/src/lib/runtime-env.ts | 3 +++ .../ui/src/lib/settings/behavior-registry.ts | 4 ++-- 6 files changed, 33 insertions(+), 6 deletions(-) create mode 100644 packages/ui/src/lib/runtime-env.test.ts 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/ui/src/components/provider-auth/provider-manager-modal.tsx b/packages/ui/src/components/provider-auth/provider-manager-modal.tsx index 8ce7ad4c8..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 { isRemoteWindow, 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() || isRemoteWindow()) && 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 65b516a89..c8fce4b5a 100644 --- a/packages/ui/src/lib/external-url.ts +++ b/packages/ui/src/lib/external-url.ts @@ -1,11 +1,11 @@ -import { isLocalWindow, 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() && isLocalWindow()) { + 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 b21d3c585..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 { isLocalWindow, 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() && isLocalWindow() + ...(isLocalTauriHost() ? [ { kind: "toggle" as const,