From 3b411e2e731b2c633ff2f8f5e7d42f2943862b9a Mon Sep 17 00:00:00 2001 From: Shantur Rathore Date: Mon, 20 Apr 2026 20:28:11 +0100 Subject: [PATCH] fix(ui): gate desktop privileges by host and window context (#347) Don't let remote server windows use local features like local file browser etc --- packages/electron-app/electron/main/main.ts | 2 + .../electron-app/electron/preload/index.cjs | 28 +++- packages/tauri-app/src-tauri/src/main.rs | 6 + packages/ui/src/App.tsx | 6 +- .../src/components/folder-selection-view.tsx | 146 ++++++++++-------- packages/ui/src/components/instance-tabs.tsx | 19 ++- .../components/opencode-binary-selector.tsx | 5 +- .../ui/src/components/settings-screen.tsx | 26 ++-- packages/ui/src/lib/hooks/use-folder-drop.ts | 10 +- packages/ui/src/lib/native/cli.ts | 10 +- .../ui/src/lib/native/desktop-file-drop.ts | 12 +- .../ui/src/lib/native/native-functions.ts | 19 ++- packages/ui/src/lib/native/remote-window.ts | 10 +- packages/ui/src/lib/native/wake-lock.ts | 10 +- packages/ui/src/lib/runtime-env.ts | 51 +++++- .../ui/src/lib/settings/behavior-registry.ts | 8 +- packages/ui/src/types/global.d.ts | 12 +- 17 files changed, 247 insertions(+), 133 deletions(-) diff --git a/packages/electron-app/electron/main/main.ts b/packages/electron-app/electron/main/main.ts index eeee81e4..4cdc324e 100644 --- a/packages/electron-app/electron/main/main.ts +++ b/packages/electron-app/electron/main/main.ts @@ -277,6 +277,7 @@ function createWindow() { contextIsolation: true, nodeIntegration: false, spellcheck: !isMac, + additionalArguments: ["--codenomad-window-context=local"], }, }) @@ -440,6 +441,7 @@ async function openRemoteWindow(payload: { id: string; name: string; baseUrl: st contextIsolation: true, nodeIntegration: false, spellcheck: !isMac, + additionalArguments: ["--codenomad-window-context=remote"], }, }) diff --git a/packages/electron-app/electron/preload/index.cjs b/packages/electron-app/electron/preload/index.cjs index 4cfbe2bd..b39fdea0 100644 --- a/packages/electron-app/electron/preload/index.cjs +++ b/packages/electron-app/electron/preload/index.cjs @@ -1,6 +1,19 @@ const { contextBridge, ipcRenderer, webUtils } = require("electron") -const electronAPI = { +function resolveWindowContext() { + const prefix = "--codenomad-window-context=" + const arg = process.argv.find((value) => typeof value === "string" && value.startsWith(prefix)) + const context = arg ? arg.slice(prefix.length) : "local" + return context === "remote" ? "remote" : "local" +} + +function resolveRuntimeHost(windowContext) { + return "electron" +} + +const windowContext = resolveWindowContext() + +const localElectronAPI = { onCliStatus: (callback) => { ipcRenderer.on("cli:status", (_, data) => callback(data)) return () => ipcRenderer.removeAllListeners("cli:status") @@ -26,4 +39,15 @@ const electronAPI = { openRemoteWindow: (payload) => ipcRenderer.invoke("remote:openWindow", payload), } -contextBridge.exposeInMainWorld("electronAPI", electronAPI) +const remoteElectronAPI = { + requestMicrophoneAccess: localElectronAPI.requestMicrophoneAccess, + setWakeLock: localElectronAPI.setWakeLock, + showNotification: localElectronAPI.showNotification, +} + +contextBridge.exposeInMainWorld( + "electronAPI", + windowContext === "local" ? localElectronAPI : remoteElectronAPI, +) +contextBridge.exposeInMainWorld("__CODENOMAD_WINDOW_CONTEXT__", windowContext) +contextBridge.exposeInMainWorld("__CODENOMAD_RUNTIME_HOST__", resolveRuntimeHost(windowContext)) diff --git a/packages/tauri-app/src-tauri/src/main.rs b/packages/tauri-app/src-tauri/src/main.rs index 92a0f634..9dd5d33b 100644 --- a/packages/tauri-app/src-tauri/src/main.rs +++ b/packages/tauri-app/src-tauri/src/main.rs @@ -40,6 +40,8 @@ const DEFAULT_ZOOM_LEVEL: f64 = 1.0; const ZOOM_STEP: f64 = 0.1; const MIN_ZOOM_LEVEL: f64 = 0.2; const MAX_ZOOM_LEVEL: f64 = 5.0; +const LOCAL_WINDOW_CONTEXT_SCRIPT: &str = "window.__CODENOMAD_WINDOW_CONTEXT__ = 'local';"; +const REMOTE_WINDOW_CONTEXT_SCRIPT: &str = "window.__CODENOMAD_WINDOW_CONTEXT__ = 'remote';"; #[cfg(windows)] const WINDOWS_APP_USER_MODEL_ID: &str = "ai.neuralnomads.codenomad.client"; @@ -300,6 +302,7 @@ async fn open_remote_window_impl( let initial_url = window_url.clone(); let window = WebviewWindowBuilder::new(&app, label.clone(), WebviewUrl::External(initial_url.clone())) + .initialization_script(REMOTE_WINDOW_CONTEXT_SCRIPT) .title(title) .inner_size(1400.0, 900.0) .min_inner_size(800.0, 600.0) @@ -542,6 +545,9 @@ fn main() { .setup(|app| { set_windows_app_user_model_id(); build_menu(&app.handle())?; + if let Some(window) = app.get_webview_window("main") { + let _ = window.eval(LOCAL_WINDOW_CONTEXT_SCRIPT); + } if let Some(shortcut) = fullscreen_shortcut() { let shortcut_manager = app.handle().global_shortcut(); let _ = shortcut_manager.register(shortcut.clone()); diff --git a/packages/ui/src/App.tsx b/packages/ui/src/App.tsx index 462c0c16..ed1ae942 100644 --- a/packages/ui/src/App.tsx +++ b/packages/ui/src/App.tsx @@ -22,7 +22,7 @@ import { getLogger } from "./lib/logger" import { launchError, showLaunchError, clearLaunchError } from "./stores/launch-errors" import { formatLaunchErrorMessage, isMissingBinaryMessage } from "./lib/launch-errors" import { initReleaseNotifications } from "./stores/releases" -import { runtimeEnv } from "./lib/runtime-env" +import { isTauriHost, isWebHost, runtimeEnv } from "./lib/runtime-env" import { useI18n } from "./lib/i18n" import { setWakeLockDesired } from "./lib/native/wake-lock" import { @@ -137,7 +137,7 @@ const App: Component = () => { createEffect(() => { if (typeof document === "undefined") return const shouldShow = - runtimeEnv.host !== "web" && runtimeEnv.platform !== "mobile" && (preferences().showKeyboardShortcutHints ?? true) + !isWebHost() && runtimeEnv.platform !== "mobile" && (preferences().showKeyboardShortcutHints ?? true) document.documentElement.dataset.keyboardHints = shouldShow ? "show" : "hide" }) @@ -444,7 +444,7 @@ const App: Component = () => { // Listen for Tauri menu events onMount(() => { - if (runtimeEnv.host === "tauri") { + if (isTauriHost()) { const tauriBridge = (window as { __TAURI__?: { event?: { listen: (event: string, handler: (event: { payload: unknown }) => void) => Promise<() => void> } } }).__TAURI__ if (tauriBridge?.event) { let unlistenMenu: (() => void) | null = null diff --git a/packages/ui/src/components/folder-selection-view.tsx b/packages/ui/src/components/folder-selection-view.tsx index c9a62ddc..a3826879 100644 --- a/packages/ui/src/components/folder-selection-view.tsx +++ b/packages/ui/src/components/folder-selection-view.tsx @@ -5,7 +5,7 @@ import { Folder, Clock, Trash2, FolderPlus, Settings, ChevronRight, MonitorUp, S import { useConfig } from "../stores/preferences" import DirectoryBrowserDialog from "./directory-browser-dialog" import Kbd from "./kbd" -import { openNativeFolderDialog, supportsNativeDialogs } from "../lib/native/native-functions" +import { openNativeFolderDialog, supportsNativeDialogsInCurrentWindow } from "../lib/native/native-functions" import { useFolderDrop } from "../lib/hooks/use-folder-drop" import VersionPill from "./version-pill" import { DiscordSymbolIcon, GitHubMarkIcon } from "./brand-icons" @@ -16,7 +16,7 @@ import { showAlertDialog } from "../stores/alerts" import { openSettings, settingsOpen } from "../stores/settings-screen" import { openExternalUrl } from "../lib/external-url" import { serverApi } from "../lib/api-client" -import { runtimeEnv } from "../lib/runtime-env" +import { canOpenRemoteWindows, isTauriHost } from "../lib/runtime-env" import { openRemoteServerWindow } from "../lib/native/remote-window" const codeNomadLogo = new URL("../images/CodeNomad-Icon.png", import.meta.url).href @@ -58,7 +58,6 @@ const FolderSelectionView: Component = (props) => { const [serverDialogError, setServerDialogError] = createSignal(null) const [isSavingServer, setIsSavingServer] = createSignal(false) const [connectingServerId, setConnectingServerId] = createSignal(null) - const nativeDialogsAvailable = supportsNativeDialogs() let recentListRef: HTMLDivElement | undefined type LanguageOption = { value: Locale; label: string } @@ -78,6 +77,7 @@ const FolderSelectionView: Component = (props) => { const folders = () => recentFolders() const serverList = () => remoteServers() const isLoading = () => Boolean(props.isLoading) + const canUseRemoteServerWindows = () => canOpenRemoteWindows() function getActiveListLength() { return activeTab() === "local" ? folders().length : serverList().length @@ -231,6 +231,10 @@ const FolderSelectionView: Component = (props) => { createEffect(() => { activeTab() + if (!canUseRemoteServerWindows() && activeTab() !== "local") { + setActiveTab("local") + return + } setSelectedIndex(0) setFocusMode("recent") }) @@ -305,11 +309,16 @@ const FolderSelectionView: Component = (props) => { } function openServerDialog() { + if (!canUseRemoteServerWindows()) return resetServerDialog() setIsServerDialogOpen(true) } async function probeAndOpenServer(input: { id?: string; name: string; baseUrl: string; skipTlsVerify: boolean }, openWindow: boolean) { + if (openWindow && !canUseRemoteServerWindows()) { + throw new Error("Remote server windows can only be opened from a local desktop window") + } + const trimmedName = input.name.trim() const trimmedUrl = input.baseUrl.trim() if (!trimmedName || !trimmedUrl) { @@ -334,7 +343,7 @@ const FolderSelectionView: Component = (props) => { if (openWindow) { const remoteProxySession = - runtimeEnv.host === "tauri" && profile.skipTlsVerify && profile.baseUrl.startsWith("https://") + isTauriHost() && profile.skipTlsVerify && profile.baseUrl.startsWith("https://") ? await serverApi.createRemoteProxySession({ baseUrl: profile.baseUrl, skipTlsVerify: profile.skipTlsVerify, @@ -379,6 +388,7 @@ const FolderSelectionView: Component = (props) => { } async function handleConnectSavedServer(id: string) { + if (!canUseRemoteServerWindows()) return const target = remoteServers().find((entry) => entry.id === id) if (!target || connectingServerId()) return setConnectingServerId(id) @@ -397,7 +407,7 @@ const FolderSelectionView: Component = (props) => { async function handleBrowse() { if (isLoading()) return setFocusMode("new") - if (nativeDialogsAvailable) { + if (supportsNativeDialogsInCurrentWindow()) { const fallbackPath = folders()[0]?.path const selected = await openNativeFolderDialog({ title: t("folderSelection.dialog.title"), @@ -554,15 +564,17 @@ const FolderSelectionView: Component = (props) => { > - + + + - +
+ {t("folderSelection.tabs.servers")} +
+

+ {t("folderSelection.servers.count", { count: remoteServers().length })} +

+ +
@@ -707,23 +721,25 @@ const FolderSelectionView: Component = (props) => { when={activeTab() === "local"} fallback={ 0} + when={canUseRemoteServerWindows() && remoteServers().length > 0} fallback={ -
-
- + +
+
+ +
+

{t("folderSelection.servers.empty.title")}

+

{t("folderSelection.servers.empty.description")}

+
-

{t("folderSelection.servers.empty.title")}

-

{t("folderSelection.servers.empty.description")}

- -
+ } >
= (props) => {
- + + +
{/* OpenCode settings section */} diff --git a/packages/ui/src/components/instance-tabs.tsx b/packages/ui/src/components/instance-tabs.tsx index 86e2c498..884f8fb5 100644 --- a/packages/ui/src/components/instance-tabs.tsx +++ b/packages/ui/src/components/instance-tabs.tsx @@ -6,6 +6,7 @@ import { Plus, MonitorUp, Bell, BellOff, Settings } from "lucide-solid" import { keyboardRegistry } from "../lib/keyboard-registry" import { useI18n } from "../lib/i18n" import { isOsNotificationSupportedSync } from "../lib/os-notifications" +import { canOpenRemoteWindows } from "../lib/runtime-env" import { useConfig } from "../stores/preferences" import { openSettings } from "../stores/settings-screen" import type { AppTabRecord } from "../stores/app-tabs" @@ -99,14 +100,16 @@ const InstanceTabs: Component = (props) => { - + + + diff --git a/packages/ui/src/components/opencode-binary-selector.tsx b/packages/ui/src/components/opencode-binary-selector.tsx index 9518c24b..ad906835 100644 --- a/packages/ui/src/components/opencode-binary-selector.tsx +++ b/packages/ui/src/components/opencode-binary-selector.tsx @@ -3,7 +3,7 @@ import { FolderOpen, Trash2, Check, AlertCircle, Loader2, Plus } from "lucide-so import { useConfig } from "../stores/preferences" import { serverApi } from "../lib/api-client" import FileSystemBrowserDialog from "./filesystem-browser-dialog" -import { openNativeFileDialog, supportsNativeDialogs } from "../lib/native/native-functions" +import { openNativeFileDialog, supportsNativeDialogsInCurrentWindow } from "../lib/native/native-functions" import { useI18n } from "../lib/i18n" import { getLogger } from "../lib/logger" const log = getLogger("actions") @@ -38,7 +38,6 @@ const OpenCodeBinarySelector: Component = (props) = const [versionInfo, setVersionInfo] = createSignal>(new Map()) const [validatingPaths, setValidatingPaths] = createSignal>(new Set()) const [isBinaryBrowserOpen, setIsBinaryBrowserOpen] = createSignal(false) - const nativeDialogsAvailable = supportsNativeDialogs() const binaries = () => opencodeBinaries() @@ -139,7 +138,7 @@ const OpenCodeBinarySelector: Component = (props) = async function handleBrowseBinary() { if (props.disabled) return setValidationError(null) - if (nativeDialogsAvailable) { + if (supportsNativeDialogsInCurrentWindow()) { const selected = await openNativeFileDialog({ title: t("opencodeBinarySelector.dialog.title"), }) diff --git a/packages/ui/src/components/settings-screen.tsx b/packages/ui/src/components/settings-screen.tsx index 42cf490e..d65e5653 100644 --- a/packages/ui/src/components/settings-screen.tsx +++ b/packages/ui/src/components/settings-screen.tsx @@ -15,25 +15,33 @@ import { OpenCodeSettingsSection } from "./settings/opencode-settings-section" import { RemoteAccessSettingsSection } from "./settings/remote-access-settings-section" import { SpeechSettingsSection } from "./settings/speech-settings-section" import { SideCarsSettingsSection } from "./settings/sidecars-settings-section" +import { canOpenRemoteWindows } from "../lib/runtime-env" export const SettingsScreen: Component = () => { const { t } = useI18n() - const sections = createMemo(() => [ - { id: "appearance" as SettingsSectionId, icon: Paintbrush, label: t("settings.nav.appearance") }, - { id: "notifications" as SettingsSectionId, icon: Bell, label: t("settings.nav.notifications") }, - { id: "remote" as SettingsSectionId, icon: MonitorUp, label: t("settings.nav.remote") }, - { id: "speech" as SettingsSectionId, icon: Volume2, label: t("settings.nav.speech") }, - { id: "sidecars" as SettingsSectionId, icon: Globe, label: t("settings.nav.sidecars") }, - { id: "opencode" as SettingsSectionId, icon: Terminal, label: t("settings.nav.opencode") }, - ]) + const sections = createMemo(() => { + const items = [ + { id: "appearance" as SettingsSectionId, icon: Paintbrush, label: t("settings.nav.appearance") }, + { id: "notifications" as SettingsSectionId, icon: Bell, label: t("settings.nav.notifications") }, + { id: "speech" as SettingsSectionId, icon: Volume2, label: t("settings.nav.speech") }, + { id: "sidecars" as SettingsSectionId, icon: Globe, label: t("settings.nav.sidecars") }, + { id: "opencode" as SettingsSectionId, icon: Terminal, label: t("settings.nav.opencode") }, + ] + + if (canOpenRemoteWindows()) { + items.splice(2, 0, { id: "remote" as SettingsSectionId, icon: MonitorUp, label: t("settings.nav.remote") }) + } + + return items + }) const renderSection = () => { switch (activeSettingsSection()) { case "notifications": return case "remote": - return + return canOpenRemoteWindows() ? : case "speech": return case "sidecars": diff --git a/packages/ui/src/lib/hooks/use-folder-drop.ts b/packages/ui/src/lib/hooks/use-folder-drop.ts index 66fafb22..0be8b8f2 100644 --- a/packages/ui/src/lib/hooks/use-folder-drop.ts +++ b/packages/ui/src/lib/hooks/use-folder-drop.ts @@ -7,7 +7,7 @@ import { normalizeDroppedDirectoryPaths, supportsDesktopFolderDrop, } from "../native/desktop-file-drop" -import { runtimeEnv } from "../runtime-env" +import { isTauriHost } from "../runtime-env" interface UseFolderDropOptions { enabled: Accessor @@ -94,7 +94,7 @@ export function useFolderDrop(options: UseFolderDropOptions): { const bind: FolderDropBindings = { onDragEnter(event) { - if (!isSupported || runtimeEnv.host === "tauri" || !options.enabled() || !containsFileDrop(event)) { + if (!isSupported || isTauriHost() || !options.enabled() || !containsFileDrop(event)) { return } event.preventDefault() @@ -102,7 +102,7 @@ export function useFolderDrop(options: UseFolderDropOptions): { setIsActive(true) }, onDragOver(event) { - if (!isSupported || runtimeEnv.host === "tauri" || !options.enabled() || !containsFileDrop(event)) { + if (!isSupported || isTauriHost() || !options.enabled() || !containsFileDrop(event)) { return } event.preventDefault() @@ -112,7 +112,7 @@ export function useFolderDrop(options: UseFolderDropOptions): { setIsActive(true) }, onDragLeave(event) { - if (!isSupported || runtimeEnv.host === "tauri" || !containsFileDrop(event)) { + if (!isSupported || isTauriHost() || !containsFileDrop(event)) { return } event.preventDefault() @@ -134,7 +134,7 @@ export function useFolderDrop(options: UseFolderDropOptions): { return } - if (runtimeEnv.host === "tauri") { + if (isTauriHost()) { reset() return } diff --git a/packages/ui/src/lib/native/cli.ts b/packages/ui/src/lib/native/cli.ts index 3d243d56..7d3c9d0d 100644 --- a/packages/ui/src/lib/native/cli.ts +++ b/packages/ui/src/lib/native/cli.ts @@ -1,12 +1,16 @@ import { invoke } from "@tauri-apps/api/core" -import { runtimeEnv } from "../runtime-env" +import { canRestartCli, isElectronHost, isTauriHost } from "../runtime-env" import { getLogger } from "../logger" const log = getLogger("actions") export async function restartCli(): Promise { + if (!canRestartCli()) { + return false + } + try { - if (runtimeEnv.host === "electron") { + if (isElectronHost()) { const api = (window as typeof window & { electronAPI?: { restartCli?: () => Promise } }).electronAPI if (api?.restartCli) { await api.restartCli() @@ -15,7 +19,7 @@ export async function restartCli(): Promise { return false } - if (runtimeEnv.host === "tauri") { + if (isTauriHost()) { if (typeof window.__TAURI__?.core?.invoke === "function") { await invoke("cli_restart") return true diff --git a/packages/ui/src/lib/native/desktop-file-drop.ts b/packages/ui/src/lib/native/desktop-file-drop.ts index d8660fd8..4abc6432 100644 --- a/packages/ui/src/lib/native/desktop-file-drop.ts +++ b/packages/ui/src/lib/native/desktop-file-drop.ts @@ -1,6 +1,6 @@ import { listen } from "@tauri-apps/api/event" import { getLogger } from "../logger" -import { runtimeEnv } from "../runtime-env" +import { canUseDesktopFolderDrop, isElectronHost, isTauriHost, runtimeEnv } from "../runtime-env" const log = getLogger("actions") @@ -21,7 +21,7 @@ function getFilePath(file: File): string | null { if (typeof file.path === "string" && file.path.trim().length > 0) { return file.path } - if (runtimeEnv.host === "electron") { + if (isElectronHost()) { const electronPath = (window as Window & { electronAPI?: ElectronAPI }).electronAPI?.getPathForFile?.(file) if (typeof electronPath === "string" && electronPath.trim().length > 0) { return electronPath @@ -44,7 +44,7 @@ async function resolveElectronDirectoryPaths(paths: string[]): Promise } export function supportsDesktopFolderDrop(): boolean { - return runtimeEnv.platform === "desktop" && runtimeEnv.host !== "web" + return runtimeEnv.platform === "desktop" && canUseDesktopFolderDrop() } export function containsFileDrop(event: DragEvent): boolean { @@ -97,14 +97,14 @@ export async function normalizeDroppedDirectoryPaths(paths: string[]): Promise void): Promise<() => void> { - if (runtimeEnv.host !== "tauri") { + if (!isTauriHost()) { return () => {} } @@ -126,7 +126,7 @@ export async function listenForNativeFolderDrops(onDrop: (paths: string[]) => vo } export async function listenForNativeFolderDropState(onState: (state: NativeFolderDropState) => void): Promise<() => void> { - if (runtimeEnv.host !== "tauri") { + if (!isTauriHost()) { return () => {} } diff --git a/packages/ui/src/lib/native/native-functions.ts b/packages/ui/src/lib/native/native-functions.ts index eb14e22e..2ee18de0 100644 --- a/packages/ui/src/lib/native/native-functions.ts +++ b/packages/ui/src/lib/native/native-functions.ts @@ -1,4 +1,4 @@ -import { runtimeEnv } from "../runtime-env" +import { canUseNativeDialogs, isElectronHost, isTauriHost } from "../runtime-env" import type { NativeDialogOptions } from "./types" import { openElectronNativeDialog } from "./electron/functions" import { openTauriNativeDialog } from "./tauri/functions" @@ -6,20 +6,23 @@ import { openTauriNativeDialog } from "./tauri/functions" export type { NativeDialogOptions, NativeDialogFilter, NativeDialogMode } from "./types" function resolveNativeHandler(): ((options: NativeDialogOptions) => Promise) | null { - switch (runtimeEnv.host) { - case "electron": - return openElectronNativeDialog - case "tauri": - return openTauriNativeDialog - default: - return null + if (isElectronHost()) { + return openElectronNativeDialog } + if (isTauriHost()) { + return openTauriNativeDialog + } + return null } export function supportsNativeDialogs(): boolean { return resolveNativeHandler() !== null } +export function supportsNativeDialogsInCurrentWindow(): boolean { + return canUseNativeDialogs() +} + async function openNativeDialog(options: NativeDialogOptions): Promise { const handler = resolveNativeHandler() if (!handler) { diff --git a/packages/ui/src/lib/native/remote-window.ts b/packages/ui/src/lib/native/remote-window.ts index 54506bda..5237d537 100644 --- a/packages/ui/src/lib/native/remote-window.ts +++ b/packages/ui/src/lib/native/remote-window.ts @@ -2,7 +2,7 @@ import { invoke } from "@tauri-apps/api/core" import type { RemoteServerProfile } from "../../../../server/src/api-types" import { showConfirmDialog } from "../../stores/alerts" import { tGlobal } from "../i18n" -import { runtimeEnv } from "../runtime-env" +import { canOpenRemoteWindows, isElectronHost, isTauriHost } from "../runtime-env" export interface RemoteWindowOpenPayload { id: string @@ -18,6 +18,10 @@ export async function openRemoteServerWindow( entryUrl?: string, proxySessionId?: string, ): Promise { + if (!canOpenRemoteWindows()) { + throw new Error("Remote server windows can only be opened from a local desktop window") + } + const payload: RemoteWindowOpenPayload = { id: profile.id, name: profile.name, @@ -27,7 +31,7 @@ export async function openRemoteServerWindow( skipTlsVerify: profile.skipTlsVerify, } - if (runtimeEnv.host === "electron") { + if (isElectronHost()) { const api = (window as Window & { electronAPI?: ElectronAPI }).electronAPI if (typeof api?.openRemoteWindow === "function") { await api.openRemoteWindow(payload) @@ -35,7 +39,7 @@ export async function openRemoteServerWindow( } } - if (runtimeEnv.host === "tauri") { + if (isTauriHost()) { const requiresLocalCertificate = proxySessionId !== undefined && (entryUrl ?? profile.baseUrl).startsWith("https://") diff --git a/packages/ui/src/lib/native/wake-lock.ts b/packages/ui/src/lib/native/wake-lock.ts index fac4a009..ce77c925 100644 --- a/packages/ui/src/lib/native/wake-lock.ts +++ b/packages/ui/src/lib/native/wake-lock.ts @@ -1,5 +1,5 @@ import { invoke } from "@tauri-apps/api/core" -import { runtimeEnv } from "../runtime-env" +import { isElectronHost, isTauriHost } from "../runtime-env" import { getLogger } from "../logger" const log = getLogger("actions") @@ -56,11 +56,11 @@ async function setWebWakeLock(enabled: boolean): Promise { function hasAnyWakeLockSupport(): boolean { if (typeof window === "undefined") return false - if (runtimeEnv.host === "electron") { + if (isElectronHost()) { const api = (window as any).electronAPI if (api?.setWakeLock) return true } - if (runtimeEnv.host === "tauri") { + if (isTauriHost()) { return typeof window.__TAURI__?.core?.invoke === "function" } return Boolean((navigator as any)?.wakeLock?.request) @@ -106,13 +106,13 @@ async function setTauriWakeLock(enabled: boolean): Promise { async function applyWakeLock(enabled: boolean): Promise { if (typeof window === "undefined") return false - if (runtimeEnv.host === "electron") { + if (isElectronHost()) { const ok = await setElectronWakeLock(enabled) if (ok || !enabled) return ok // fallback to web API if electron preload didn't expose it } - if (runtimeEnv.host === "tauri") { + if (isTauriHost()) { const ok = await setTauriWakeLock(enabled) if (ok || !enabled) return ok // fallback to web API if tauri command isn't available diff --git a/packages/ui/src/lib/runtime-env.ts b/packages/ui/src/lib/runtime-env.ts index e479f735..200d87af 100644 --- a/packages/ui/src/lib/runtime-env.ts +++ b/packages/ui/src/lib/runtime-env.ts @@ -2,10 +2,12 @@ import { getLogger } from "./logger" export type HostRuntime = "electron" | "tauri" | "web" export type PlatformKind = "desktop" | "mobile" +export type WindowContextKind = "local" | "remote" export interface RuntimeEnvironment { host: HostRuntime platform: PlatformKind + windowContext: WindowContextKind } declare global { @@ -14,6 +16,7 @@ declare global { } interface Window { + __CODENOMAD_WINDOW_CONTEXT__?: WindowContextKind electronAPI?: unknown __TAURI__?: { core?: TauriCoreModule @@ -21,11 +24,41 @@ declare global { } } +function detectWindowContext(): WindowContextKind { + if (typeof window === "undefined") { + return "remote" + } + + if (window.__CODENOMAD_WINDOW_CONTEXT__ === "remote") { + return "remote" + } + + if (window.__CODENOMAD_WINDOW_CONTEXT__ === "local") { + return "local" + } + + const win = window as Window & { electronAPI?: unknown } + if (typeof win.electronAPI !== "undefined" || typeof win.__TAURI__ !== "undefined") { + return "local" + } + + if (typeof navigator !== "undefined" && /tauri/i.test(navigator.userAgent)) { + return "local" + } + + return "remote" +} + function detectHost(): HostRuntime { if (typeof window === "undefined") { return "web" } + const explicitHost = window.__CODENOMAD_RUNTIME_HOST__ + if (explicitHost) { + return explicitHost + } + const win = window as Window & { electronAPI?: unknown } if (typeof win.electronAPI !== "undefined") { return "electron" @@ -71,16 +104,24 @@ export function detectRuntimeEnvironment(): RuntimeEnvironment { cachedEnv = { host: detectHost(), platform: detectPlatform(), + windowContext: detectWindowContext(), } if (typeof window !== "undefined") { - log.info(`[runtime] host=${cachedEnv.host} platform=${cachedEnv.platform}`) + log.info(`[runtime] host=${cachedEnv.host} platform=${cachedEnv.platform} context=${cachedEnv.windowContext}`) } return cachedEnv } export const runtimeEnv = detectRuntimeEnvironment() -export const isElectronHost = () => runtimeEnv.host === "electron" -export const isTauriHost = () => runtimeEnv.host === "tauri" -export const isWebHost = () => runtimeEnv.host === "web" -export const isMobilePlatform = () => runtimeEnv.platform === "mobile" +export const isElectronHost = () => detectHost() === "electron" +export const isTauriHost = () => detectHost() === "tauri" +export const isWebHost = () => detectHost() === "web" +export const isDesktopHost = () => isElectronHost() || isTauriHost() +export const isMobilePlatform = () => detectPlatform() === "mobile" +export const isLocalWindow = () => detectWindowContext() === "local" +export const isRemoteWindow = () => detectWindowContext() === "remote" +export const canUseNativeDialogs = () => isDesktopHost() && isLocalWindow() +export const canOpenRemoteWindows = () => isDesktopHost() && isLocalWindow() +export const canRestartCli = () => isDesktopHost() && isLocalWindow() +export const canUseDesktopFolderDrop = () => isDesktopHost() && isLocalWindow() diff --git a/packages/ui/src/lib/settings/behavior-registry.ts b/packages/ui/src/lib/settings/behavior-registry.ts index 573b97f3..30b8701c 100644 --- a/packages/ui/src/lib/settings/behavior-registry.ts +++ b/packages/ui/src/lib/settings/behavior-registry.ts @@ -6,7 +6,7 @@ import type { } from "../../stores/preferences" import type { Command } from "../commands" import { tGlobal } from "../i18n" -import { runtimeEnv } from "../runtime-env" +import { isWebHost } from "../runtime-env" export type BehaviorSettingKind = "toggle" | "enum" @@ -84,7 +84,7 @@ export function getBehaviorSettings(actions: BehaviorRegistryActions): BehaviorS next, ) }, - disabled: () => runtimeEnv.host === "web", + disabled: () => isWebHost(), }, { kind: "toggle", @@ -337,13 +337,13 @@ export function getBehaviorCommands(actions: BehaviorRegistryActions): Command[] ), description: () => tGlobal( - runtimeEnv.host === "web" + isWebHost() ? "commands.keyboardShortcutHints.description.disabledWeb" : "commands.keyboardShortcutHints.description", ), category: "System", keywords: () => splitKeywords("commands.keyboardShortcutHints.keywords"), - disabled: () => runtimeEnv.host === "web", + disabled: () => isWebHost(), action: actions.toggleKeyboardShortcutHints, }, { diff --git a/packages/ui/src/types/global.d.ts b/packages/ui/src/types/global.d.ts index 0d10cfa3..e8a967ef 100644 --- a/packages/ui/src/types/global.d.ts +++ b/packages/ui/src/types/global.d.ts @@ -63,10 +63,12 @@ declare global { } interface Window { - __CODENOMAD_API_BASE__?: string - __CODENOMAD_EVENTS_URL__?: string - electronAPI?: ElectronAPI - __TAURI__?: TauriBridge - codenomadLogger?: LoggerControls + __CODENOMAD_API_BASE__?: string + __CODENOMAD_EVENTS_URL__?: string + __CODENOMAD_RUNTIME_HOST__?: "electron" | "tauri" | "web" + __CODENOMAD_WINDOW_CONTEXT__?: "local" | "remote" + electronAPI?: ElectronAPI + __TAURI__?: TauriBridge + codenomadLogger?: LoggerControls } }