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
This commit is contained in:
Shantur Rathore
2026-04-20 20:28:11 +01:00
committed by GitHub
parent 016c7bda4a
commit 3b411e2e73
17 changed files with 247 additions and 133 deletions

View File

@@ -277,6 +277,7 @@ function createWindow() {
contextIsolation: true, contextIsolation: true,
nodeIntegration: false, nodeIntegration: false,
spellcheck: !isMac, spellcheck: !isMac,
additionalArguments: ["--codenomad-window-context=local"],
}, },
}) })
@@ -440,6 +441,7 @@ async function openRemoteWindow(payload: { id: string; name: string; baseUrl: st
contextIsolation: true, contextIsolation: true,
nodeIntegration: false, nodeIntegration: false,
spellcheck: !isMac, spellcheck: !isMac,
additionalArguments: ["--codenomad-window-context=remote"],
}, },
}) })

View File

@@ -1,6 +1,19 @@
const { contextBridge, ipcRenderer, webUtils } = require("electron") 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) => { onCliStatus: (callback) => {
ipcRenderer.on("cli:status", (_, data) => callback(data)) ipcRenderer.on("cli:status", (_, data) => callback(data))
return () => ipcRenderer.removeAllListeners("cli:status") return () => ipcRenderer.removeAllListeners("cli:status")
@@ -26,4 +39,15 @@ const electronAPI = {
openRemoteWindow: (payload) => ipcRenderer.invoke("remote:openWindow", payload), 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))

View File

@@ -40,6 +40,8 @@ const DEFAULT_ZOOM_LEVEL: f64 = 1.0;
const ZOOM_STEP: f64 = 0.1; const ZOOM_STEP: f64 = 0.1;
const MIN_ZOOM_LEVEL: f64 = 0.2; const MIN_ZOOM_LEVEL: f64 = 0.2;
const MAX_ZOOM_LEVEL: f64 = 5.0; 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)] #[cfg(windows)]
const WINDOWS_APP_USER_MODEL_ID: &str = "ai.neuralnomads.codenomad.client"; 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 initial_url = window_url.clone();
let window = WebviewWindowBuilder::new(&app, label.clone(), WebviewUrl::External(initial_url.clone())) let window = WebviewWindowBuilder::new(&app, label.clone(), WebviewUrl::External(initial_url.clone()))
.initialization_script(REMOTE_WINDOW_CONTEXT_SCRIPT)
.title(title) .title(title)
.inner_size(1400.0, 900.0) .inner_size(1400.0, 900.0)
.min_inner_size(800.0, 600.0) .min_inner_size(800.0, 600.0)
@@ -542,6 +545,9 @@ fn main() {
.setup(|app| { .setup(|app| {
set_windows_app_user_model_id(); set_windows_app_user_model_id();
build_menu(&app.handle())?; 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() { if let Some(shortcut) = fullscreen_shortcut() {
let shortcut_manager = app.handle().global_shortcut(); let shortcut_manager = app.handle().global_shortcut();
let _ = shortcut_manager.register(shortcut.clone()); let _ = shortcut_manager.register(shortcut.clone());

View File

@@ -22,7 +22,7 @@ import { getLogger } from "./lib/logger"
import { launchError, showLaunchError, clearLaunchError } from "./stores/launch-errors" import { launchError, showLaunchError, clearLaunchError } from "./stores/launch-errors"
import { formatLaunchErrorMessage, isMissingBinaryMessage } from "./lib/launch-errors" import { formatLaunchErrorMessage, isMissingBinaryMessage } from "./lib/launch-errors"
import { initReleaseNotifications } from "./stores/releases" 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 { useI18n } from "./lib/i18n"
import { setWakeLockDesired } from "./lib/native/wake-lock" import { setWakeLockDesired } from "./lib/native/wake-lock"
import { import {
@@ -137,7 +137,7 @@ const App: Component = () => {
createEffect(() => { createEffect(() => {
if (typeof document === "undefined") return if (typeof document === "undefined") return
const shouldShow = 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" document.documentElement.dataset.keyboardHints = shouldShow ? "show" : "hide"
}) })
@@ -444,7 +444,7 @@ const App: Component = () => {
// Listen for Tauri menu events // Listen for Tauri menu events
onMount(() => { onMount(() => {
if (runtimeEnv.host === "tauri") { if (isTauriHost()) {
const tauriBridge = (window as { __TAURI__?: { event?: { listen: (event: string, handler: (event: { payload: unknown }) => void) => Promise<() => void> } } }).__TAURI__ const tauriBridge = (window as { __TAURI__?: { event?: { listen: (event: string, handler: (event: { payload: unknown }) => void) => Promise<() => void> } } }).__TAURI__
if (tauriBridge?.event) { if (tauriBridge?.event) {
let unlistenMenu: (() => void) | null = null let unlistenMenu: (() => void) | null = null

View File

@@ -5,7 +5,7 @@ import { Folder, Clock, Trash2, FolderPlus, Settings, ChevronRight, MonitorUp, S
import { useConfig } from "../stores/preferences" import { useConfig } from "../stores/preferences"
import DirectoryBrowserDialog from "./directory-browser-dialog" import DirectoryBrowserDialog from "./directory-browser-dialog"
import Kbd from "./kbd" 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 { useFolderDrop } from "../lib/hooks/use-folder-drop"
import VersionPill from "./version-pill" import VersionPill from "./version-pill"
import { DiscordSymbolIcon, GitHubMarkIcon } from "./brand-icons" import { DiscordSymbolIcon, GitHubMarkIcon } from "./brand-icons"
@@ -16,7 +16,7 @@ import { showAlertDialog } from "../stores/alerts"
import { openSettings, settingsOpen } from "../stores/settings-screen" import { openSettings, settingsOpen } from "../stores/settings-screen"
import { openExternalUrl } from "../lib/external-url" import { openExternalUrl } from "../lib/external-url"
import { serverApi } from "../lib/api-client" 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" import { openRemoteServerWindow } from "../lib/native/remote-window"
const codeNomadLogo = new URL("../images/CodeNomad-Icon.png", import.meta.url).href const codeNomadLogo = new URL("../images/CodeNomad-Icon.png", import.meta.url).href
@@ -58,7 +58,6 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
const [serverDialogError, setServerDialogError] = createSignal<string | null>(null) const [serverDialogError, setServerDialogError] = createSignal<string | null>(null)
const [isSavingServer, setIsSavingServer] = createSignal(false) const [isSavingServer, setIsSavingServer] = createSignal(false)
const [connectingServerId, setConnectingServerId] = createSignal<string | null>(null) const [connectingServerId, setConnectingServerId] = createSignal<string | null>(null)
const nativeDialogsAvailable = supportsNativeDialogs()
let recentListRef: HTMLDivElement | undefined let recentListRef: HTMLDivElement | undefined
type LanguageOption = { value: Locale; label: string } type LanguageOption = { value: Locale; label: string }
@@ -78,6 +77,7 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
const folders = () => recentFolders() const folders = () => recentFolders()
const serverList = () => remoteServers() const serverList = () => remoteServers()
const isLoading = () => Boolean(props.isLoading) const isLoading = () => Boolean(props.isLoading)
const canUseRemoteServerWindows = () => canOpenRemoteWindows()
function getActiveListLength() { function getActiveListLength() {
return activeTab() === "local" ? folders().length : serverList().length return activeTab() === "local" ? folders().length : serverList().length
@@ -231,6 +231,10 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
createEffect(() => { createEffect(() => {
activeTab() activeTab()
if (!canUseRemoteServerWindows() && activeTab() !== "local") {
setActiveTab("local")
return
}
setSelectedIndex(0) setSelectedIndex(0)
setFocusMode("recent") setFocusMode("recent")
}) })
@@ -305,11 +309,16 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
} }
function openServerDialog() { function openServerDialog() {
if (!canUseRemoteServerWindows()) return
resetServerDialog() resetServerDialog()
setIsServerDialogOpen(true) setIsServerDialogOpen(true)
} }
async function probeAndOpenServer(input: { id?: string; name: string; baseUrl: string; skipTlsVerify: boolean }, openWindow: boolean) { 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 trimmedName = input.name.trim()
const trimmedUrl = input.baseUrl.trim() const trimmedUrl = input.baseUrl.trim()
if (!trimmedName || !trimmedUrl) { if (!trimmedName || !trimmedUrl) {
@@ -334,7 +343,7 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
if (openWindow) { if (openWindow) {
const remoteProxySession = const remoteProxySession =
runtimeEnv.host === "tauri" && profile.skipTlsVerify && profile.baseUrl.startsWith("https://") isTauriHost() && profile.skipTlsVerify && profile.baseUrl.startsWith("https://")
? await serverApi.createRemoteProxySession({ ? await serverApi.createRemoteProxySession({
baseUrl: profile.baseUrl, baseUrl: profile.baseUrl,
skipTlsVerify: profile.skipTlsVerify, skipTlsVerify: profile.skipTlsVerify,
@@ -379,6 +388,7 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
} }
async function handleConnectSavedServer(id: string) { async function handleConnectSavedServer(id: string) {
if (!canUseRemoteServerWindows()) return
const target = remoteServers().find((entry) => entry.id === id) const target = remoteServers().find((entry) => entry.id === id)
if (!target || connectingServerId()) return if (!target || connectingServerId()) return
setConnectingServerId(id) setConnectingServerId(id)
@@ -397,7 +407,7 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
async function handleBrowse() { async function handleBrowse() {
if (isLoading()) return if (isLoading()) return
setFocusMode("new") setFocusMode("new")
if (nativeDialogsAvailable) { if (supportsNativeDialogsInCurrentWindow()) {
const fallbackPath = folders()[0]?.path const fallbackPath = folders()[0]?.path
const selected = await openNativeFolderDialog({ const selected = await openNativeFolderDialog({
title: t("folderSelection.dialog.title"), title: t("folderSelection.dialog.title"),
@@ -554,15 +564,17 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
> >
<Settings class="w-4 h-4" /> <Settings class="w-4 h-4" />
</button> </button>
<button <Show when={canUseRemoteServerWindows()}>
type="button" <button
class="selector-button selector-button-secondary w-auto p-2 inline-flex items-center justify-center" type="button"
onClick={() => openSettings("remote")} class="selector-button selector-button-secondary w-auto p-2 inline-flex items-center justify-center"
aria-label={t("instanceTabs.remote.ariaLabel")} onClick={() => openSettings("remote")}
title={t("instanceTabs.remote.title")} aria-label={t("instanceTabs.remote.ariaLabel")}
> title={t("instanceTabs.remote.title")}
<MonitorUp class="w-4 h-4" /> >
</button> <MonitorUp class="w-4 h-4" />
</button>
</Show>
<Show when={props.onClose}> <Show when={props.onClose}>
<button <button
type="button" type="button"
@@ -636,7 +648,7 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
<div class="order-1 lg:order-2 flex flex-col gap-4 flex-1 min-h-0 overflow-hidden"> <div class="order-1 lg:order-2 flex flex-col gap-4 flex-1 min-h-0 overflow-hidden">
<div class="panel flex flex-col flex-1 min-h-0"> <div class="panel flex flex-col flex-1 min-h-0">
<div class="panel-header !gap-0 !p-0"> <div class="panel-header !gap-0 !p-0">
<div class="grid grid-cols-2 gap-0 overflow-hidden border border-base rounded-t-lg rounded-b-none"> <div class={`grid ${canUseRemoteServerWindows() ? "grid-cols-2" : "grid-cols-1"} gap-0 overflow-hidden border border-base rounded-t-lg rounded-b-none`}>
<button <button
type="button" type="button"
class="border-r border-base px-4 py-3 text-left transition-colors" class="border-r border-base px-4 py-3 text-left transition-colors"
@@ -671,35 +683,37 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
)} )}
</p> </p>
</button> </button>
<button <Show when={canUseRemoteServerWindows()}>
type="button" <button
class="px-4 py-3 text-left transition-colors" type="button"
classList={{ class="px-4 py-3 text-left transition-colors"
"text-primary": activeTab() === "servers", classList={{
"text-muted hover:text-secondary": activeTab() !== "servers", "text-primary": activeTab() === "servers",
}} "text-muted hover:text-secondary": activeTab() !== "servers",
style={{
"background-color": "var(--surface-secondary)",
}}
onClick={() => setActiveTab("servers")}
>
<div
class="panel-title text-base"
style={{
color: activeTab() === "servers" ? "var(--text-primary)" : "var(--text-secondary)",
}} }}
>
{t("folderSelection.tabs.servers")}
</div>
<p
class="panel-subtitle mt-1"
style={{ style={{
color: activeTab() === "servers" ? "var(--text-muted)" : "var(--text-secondary)", "background-color": "var(--surface-secondary)",
}} }}
onClick={() => setActiveTab("servers")}
> >
{t("folderSelection.servers.count", { count: remoteServers().length })} <div
</p> class="panel-title text-base"
</button> style={{
color: activeTab() === "servers" ? "var(--text-primary)" : "var(--text-secondary)",
}}
>
{t("folderSelection.tabs.servers")}
</div>
<p
class="panel-subtitle mt-1"
style={{
color: activeTab() === "servers" ? "var(--text-muted)" : "var(--text-secondary)",
}}
>
{t("folderSelection.servers.count", { count: remoteServers().length })}
</p>
</button>
</Show>
</div> </div>
</div> </div>
@@ -707,23 +721,25 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
when={activeTab() === "local"} when={activeTab() === "local"}
fallback={ fallback={
<Show <Show
when={remoteServers().length > 0} when={canUseRemoteServerWindows() && remoteServers().length > 0}
fallback={ fallback={
<div class="panel-empty-state flex-1"> <Show when={canUseRemoteServerWindows()}>
<div class="panel-empty-state-icon"> <div class="panel-empty-state flex-1">
<Globe class="w-12 h-12 mx-auto" /> <div class="panel-empty-state-icon">
<Globe class="w-12 h-12 mx-auto" />
</div>
<p class="panel-empty-state-title">{t("folderSelection.servers.empty.title")}</p>
<p class="panel-empty-state-description">{t("folderSelection.servers.empty.description")}</p>
<button
type="button"
class="button-primary mt-4 w-auto self-center inline-flex items-center justify-center gap-2 px-4"
onClick={openServerDialog}
>
<Globe class="w-4 h-4" />
<span>{t("folderSelection.actions.connectButton")}</span>
</button>
</div> </div>
<p class="panel-empty-state-title">{t("folderSelection.servers.empty.title")}</p> </Show>
<p class="panel-empty-state-description">{t("folderSelection.servers.empty.description")}</p>
<button
type="button"
class="button-primary mt-4 w-auto self-center inline-flex items-center justify-center gap-2 px-4"
onClick={openServerDialog}
>
<Globe class="w-4 h-4" />
<span>{t("folderSelection.actions.connectButton")}</span>
</button>
</div>
} }
> >
<div <div
@@ -891,15 +907,17 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
</div> </div>
</button> </button>
<button <Show when={canUseRemoteServerWindows()}>
onClick={openServerDialog} <button
class="button-primary w-full flex items-center justify-center text-sm" onClick={openServerDialog}
> class="button-primary w-full flex items-center justify-center text-sm"
<div class="flex items-center gap-2"> >
<Globe class="w-4 h-4" /> <div class="flex items-center gap-2">
<span>{t("folderSelection.actions.connectButton")}</span> <Globe class="w-4 h-4" />
</div> <span>{t("folderSelection.actions.connectButton")}</span>
</button> </div>
</button>
</Show>
</div> </div>
{/* OpenCode settings section */} {/* OpenCode settings section */}

View File

@@ -6,6 +6,7 @@ import { Plus, MonitorUp, Bell, BellOff, Settings } from "lucide-solid"
import { keyboardRegistry } from "../lib/keyboard-registry" import { keyboardRegistry } from "../lib/keyboard-registry"
import { useI18n } from "../lib/i18n" import { useI18n } from "../lib/i18n"
import { isOsNotificationSupportedSync } from "../lib/os-notifications" import { isOsNotificationSupportedSync } from "../lib/os-notifications"
import { canOpenRemoteWindows } from "../lib/runtime-env"
import { useConfig } from "../stores/preferences" import { useConfig } from "../stores/preferences"
import { openSettings } from "../stores/settings-screen" import { openSettings } from "../stores/settings-screen"
import type { AppTabRecord } from "../stores/app-tabs" import type { AppTabRecord } from "../stores/app-tabs"
@@ -99,14 +100,16 @@ const InstanceTabs: Component<InstanceTabsProps> = (props) => {
<Dynamic component={notificationIcon()} class="w-4 h-4" /> <Dynamic component={notificationIcon()} class="w-4 h-4" />
</button> </button>
<button <Show when={canOpenRemoteWindows()}>
class="new-tab-button tab-remote-button" <button
onClick={() => openSettings("remote")} class="new-tab-button tab-remote-button"
title={t("instanceTabs.remote.title")} onClick={() => openSettings("remote")}
aria-label={t("instanceTabs.remote.ariaLabel")} title={t("instanceTabs.remote.title")}
> aria-label={t("instanceTabs.remote.ariaLabel")}
<MonitorUp class="w-4 h-4" /> >
</button> <MonitorUp class="w-4 h-4" />
</button>
</Show>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -3,7 +3,7 @@ import { FolderOpen, Trash2, Check, AlertCircle, Loader2, Plus } from "lucide-so
import { useConfig } from "../stores/preferences" import { useConfig } from "../stores/preferences"
import { serverApi } from "../lib/api-client" import { serverApi } from "../lib/api-client"
import FileSystemBrowserDialog from "./filesystem-browser-dialog" 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 { useI18n } from "../lib/i18n"
import { getLogger } from "../lib/logger" import { getLogger } from "../lib/logger"
const log = getLogger("actions") const log = getLogger("actions")
@@ -38,7 +38,6 @@ const OpenCodeBinarySelector: Component<OpenCodeBinarySelectorProps> = (props) =
const [versionInfo, setVersionInfo] = createSignal<Map<string, string>>(new Map<string, string>()) const [versionInfo, setVersionInfo] = createSignal<Map<string, string>>(new Map<string, string>())
const [validatingPaths, setValidatingPaths] = createSignal<Set<string>>(new Set<string>()) const [validatingPaths, setValidatingPaths] = createSignal<Set<string>>(new Set<string>())
const [isBinaryBrowserOpen, setIsBinaryBrowserOpen] = createSignal(false) const [isBinaryBrowserOpen, setIsBinaryBrowserOpen] = createSignal(false)
const nativeDialogsAvailable = supportsNativeDialogs()
const binaries = () => opencodeBinaries() const binaries = () => opencodeBinaries()
@@ -139,7 +138,7 @@ const OpenCodeBinarySelector: Component<OpenCodeBinarySelectorProps> = (props) =
async function handleBrowseBinary() { async function handleBrowseBinary() {
if (props.disabled) return if (props.disabled) return
setValidationError(null) setValidationError(null)
if (nativeDialogsAvailable) { if (supportsNativeDialogsInCurrentWindow()) {
const selected = await openNativeFileDialog({ const selected = await openNativeFileDialog({
title: t("opencodeBinarySelector.dialog.title"), title: t("opencodeBinarySelector.dialog.title"),
}) })

View File

@@ -15,25 +15,33 @@ import { OpenCodeSettingsSection } from "./settings/opencode-settings-section"
import { RemoteAccessSettingsSection } from "./settings/remote-access-settings-section" import { RemoteAccessSettingsSection } from "./settings/remote-access-settings-section"
import { SpeechSettingsSection } from "./settings/speech-settings-section" import { SpeechSettingsSection } from "./settings/speech-settings-section"
import { SideCarsSettingsSection } from "./settings/sidecars-settings-section" import { SideCarsSettingsSection } from "./settings/sidecars-settings-section"
import { canOpenRemoteWindows } from "../lib/runtime-env"
export const SettingsScreen: Component = () => { export const SettingsScreen: Component = () => {
const { t } = useI18n() const { t } = useI18n()
const sections = createMemo(() => [ const sections = createMemo(() => {
{ id: "appearance" as SettingsSectionId, icon: Paintbrush, label: t("settings.nav.appearance") }, const items = [
{ id: "notifications" as SettingsSectionId, icon: Bell, label: t("settings.nav.notifications") }, { id: "appearance" as SettingsSectionId, icon: Paintbrush, label: t("settings.nav.appearance") },
{ id: "remote" as SettingsSectionId, icon: MonitorUp, label: t("settings.nav.remote") }, { id: "notifications" as SettingsSectionId, icon: Bell, label: t("settings.nav.notifications") },
{ id: "speech" as SettingsSectionId, icon: Volume2, label: t("settings.nav.speech") }, { id: "speech" as SettingsSectionId, icon: Volume2, label: t("settings.nav.speech") },
{ id: "sidecars" as SettingsSectionId, icon: Globe, label: t("settings.nav.sidecars") }, { id: "sidecars" as SettingsSectionId, icon: Globe, label: t("settings.nav.sidecars") },
{ id: "opencode" as SettingsSectionId, icon: Terminal, label: t("settings.nav.opencode") }, { 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 = () => { const renderSection = () => {
switch (activeSettingsSection()) { switch (activeSettingsSection()) {
case "notifications": case "notifications":
return <NotificationsSettingsSection /> return <NotificationsSettingsSection />
case "remote": case "remote":
return <RemoteAccessSettingsSection /> return canOpenRemoteWindows() ? <RemoteAccessSettingsSection /> : <AppearanceSettingsSection />
case "speech": case "speech":
return <SpeechSettingsSection /> return <SpeechSettingsSection />
case "sidecars": case "sidecars":

View File

@@ -7,7 +7,7 @@ import {
normalizeDroppedDirectoryPaths, normalizeDroppedDirectoryPaths,
supportsDesktopFolderDrop, supportsDesktopFolderDrop,
} from "../native/desktop-file-drop" } from "../native/desktop-file-drop"
import { runtimeEnv } from "../runtime-env" import { isTauriHost } from "../runtime-env"
interface UseFolderDropOptions { interface UseFolderDropOptions {
enabled: Accessor<boolean> enabled: Accessor<boolean>
@@ -94,7 +94,7 @@ export function useFolderDrop(options: UseFolderDropOptions): {
const bind: FolderDropBindings = { const bind: FolderDropBindings = {
onDragEnter(event) { onDragEnter(event) {
if (!isSupported || runtimeEnv.host === "tauri" || !options.enabled() || !containsFileDrop(event)) { if (!isSupported || isTauriHost() || !options.enabled() || !containsFileDrop(event)) {
return return
} }
event.preventDefault() event.preventDefault()
@@ -102,7 +102,7 @@ export function useFolderDrop(options: UseFolderDropOptions): {
setIsActive(true) setIsActive(true)
}, },
onDragOver(event) { onDragOver(event) {
if (!isSupported || runtimeEnv.host === "tauri" || !options.enabled() || !containsFileDrop(event)) { if (!isSupported || isTauriHost() || !options.enabled() || !containsFileDrop(event)) {
return return
} }
event.preventDefault() event.preventDefault()
@@ -112,7 +112,7 @@ export function useFolderDrop(options: UseFolderDropOptions): {
setIsActive(true) setIsActive(true)
}, },
onDragLeave(event) { onDragLeave(event) {
if (!isSupported || runtimeEnv.host === "tauri" || !containsFileDrop(event)) { if (!isSupported || isTauriHost() || !containsFileDrop(event)) {
return return
} }
event.preventDefault() event.preventDefault()
@@ -134,7 +134,7 @@ export function useFolderDrop(options: UseFolderDropOptions): {
return return
} }
if (runtimeEnv.host === "tauri") { if (isTauriHost()) {
reset() reset()
return return
} }

View File

@@ -1,12 +1,16 @@
import { invoke } from "@tauri-apps/api/core" import { invoke } from "@tauri-apps/api/core"
import { runtimeEnv } from "../runtime-env" import { canRestartCli, isElectronHost, isTauriHost } from "../runtime-env"
import { getLogger } from "../logger" import { getLogger } from "../logger"
const log = getLogger("actions") const log = getLogger("actions")
export async function restartCli(): Promise<boolean> { export async function restartCli(): Promise<boolean> {
if (!canRestartCli()) {
return false
}
try { try {
if (runtimeEnv.host === "electron") { if (isElectronHost()) {
const api = (window as typeof window & { electronAPI?: { restartCli?: () => Promise<unknown> } }).electronAPI const api = (window as typeof window & { electronAPI?: { restartCli?: () => Promise<unknown> } }).electronAPI
if (api?.restartCli) { if (api?.restartCli) {
await api.restartCli() await api.restartCli()
@@ -15,7 +19,7 @@ export async function restartCli(): Promise<boolean> {
return false return false
} }
if (runtimeEnv.host === "tauri") { if (isTauriHost()) {
if (typeof window.__TAURI__?.core?.invoke === "function") { if (typeof window.__TAURI__?.core?.invoke === "function") {
await invoke("cli_restart") await invoke("cli_restart")
return true return true

View File

@@ -1,6 +1,6 @@
import { listen } from "@tauri-apps/api/event" import { listen } from "@tauri-apps/api/event"
import { getLogger } from "../logger" import { getLogger } from "../logger"
import { runtimeEnv } from "../runtime-env" import { canUseDesktopFolderDrop, isElectronHost, isTauriHost, runtimeEnv } from "../runtime-env"
const log = getLogger("actions") const log = getLogger("actions")
@@ -21,7 +21,7 @@ function getFilePath(file: File): string | null {
if (typeof file.path === "string" && file.path.trim().length > 0) { if (typeof file.path === "string" && file.path.trim().length > 0) {
return file.path return file.path
} }
if (runtimeEnv.host === "electron") { if (isElectronHost()) {
const electronPath = (window as Window & { electronAPI?: ElectronAPI }).electronAPI?.getPathForFile?.(file) const electronPath = (window as Window & { electronAPI?: ElectronAPI }).electronAPI?.getPathForFile?.(file)
if (typeof electronPath === "string" && electronPath.trim().length > 0) { if (typeof electronPath === "string" && electronPath.trim().length > 0) {
return electronPath return electronPath
@@ -44,7 +44,7 @@ async function resolveElectronDirectoryPaths(paths: string[]): Promise<string[]>
} }
export function supportsDesktopFolderDrop(): boolean { export function supportsDesktopFolderDrop(): boolean {
return runtimeEnv.platform === "desktop" && runtimeEnv.host !== "web" return runtimeEnv.platform === "desktop" && canUseDesktopFolderDrop()
} }
export function containsFileDrop(event: DragEvent): boolean { export function containsFileDrop(event: DragEvent): boolean {
@@ -97,14 +97,14 @@ export async function normalizeDroppedDirectoryPaths(paths: string[]): Promise<s
if (uniquePaths.length === 0) { if (uniquePaths.length === 0) {
return [] return []
} }
if (runtimeEnv.host === "electron") { if (isElectronHost()) {
return resolveElectronDirectoryPaths(uniquePaths) return resolveElectronDirectoryPaths(uniquePaths)
} }
return uniquePaths return uniquePaths
} }
export async function listenForNativeFolderDrops(onDrop: (paths: string[]) => void): Promise<() => void> { export async function listenForNativeFolderDrops(onDrop: (paths: string[]) => void): Promise<() => void> {
if (runtimeEnv.host !== "tauri") { if (!isTauriHost()) {
return () => {} return () => {}
} }
@@ -126,7 +126,7 @@ export async function listenForNativeFolderDrops(onDrop: (paths: string[]) => vo
} }
export async function listenForNativeFolderDropState(onState: (state: NativeFolderDropState) => void): Promise<() => void> { export async function listenForNativeFolderDropState(onState: (state: NativeFolderDropState) => void): Promise<() => void> {
if (runtimeEnv.host !== "tauri") { if (!isTauriHost()) {
return () => {} return () => {}
} }

View File

@@ -1,4 +1,4 @@
import { runtimeEnv } from "../runtime-env" import { canUseNativeDialogs, isElectronHost, isTauriHost } from "../runtime-env"
import type { NativeDialogOptions } from "./types" import type { NativeDialogOptions } from "./types"
import { openElectronNativeDialog } from "./electron/functions" import { openElectronNativeDialog } from "./electron/functions"
import { openTauriNativeDialog } from "./tauri/functions" import { openTauriNativeDialog } from "./tauri/functions"
@@ -6,20 +6,23 @@ import { openTauriNativeDialog } from "./tauri/functions"
export type { NativeDialogOptions, NativeDialogFilter, NativeDialogMode } from "./types" export type { NativeDialogOptions, NativeDialogFilter, NativeDialogMode } from "./types"
function resolveNativeHandler(): ((options: NativeDialogOptions) => Promise<string | null>) | null { function resolveNativeHandler(): ((options: NativeDialogOptions) => Promise<string | null>) | null {
switch (runtimeEnv.host) { if (isElectronHost()) {
case "electron": return openElectronNativeDialog
return openElectronNativeDialog
case "tauri":
return openTauriNativeDialog
default:
return null
} }
if (isTauriHost()) {
return openTauriNativeDialog
}
return null
} }
export function supportsNativeDialogs(): boolean { export function supportsNativeDialogs(): boolean {
return resolveNativeHandler() !== null return resolveNativeHandler() !== null
} }
export function supportsNativeDialogsInCurrentWindow(): boolean {
return canUseNativeDialogs()
}
async function openNativeDialog(options: NativeDialogOptions): Promise<string | null> { async function openNativeDialog(options: NativeDialogOptions): Promise<string | null> {
const handler = resolveNativeHandler() const handler = resolveNativeHandler()
if (!handler) { if (!handler) {

View File

@@ -2,7 +2,7 @@ import { invoke } from "@tauri-apps/api/core"
import type { RemoteServerProfile } from "../../../../server/src/api-types" import type { RemoteServerProfile } from "../../../../server/src/api-types"
import { showConfirmDialog } from "../../stores/alerts" import { showConfirmDialog } from "../../stores/alerts"
import { tGlobal } from "../i18n" import { tGlobal } from "../i18n"
import { runtimeEnv } from "../runtime-env" import { canOpenRemoteWindows, isElectronHost, isTauriHost } from "../runtime-env"
export interface RemoteWindowOpenPayload { export interface RemoteWindowOpenPayload {
id: string id: string
@@ -18,6 +18,10 @@ export async function openRemoteServerWindow(
entryUrl?: string, entryUrl?: string,
proxySessionId?: string, proxySessionId?: string,
): Promise<void> { ): Promise<void> {
if (!canOpenRemoteWindows()) {
throw new Error("Remote server windows can only be opened from a local desktop window")
}
const payload: RemoteWindowOpenPayload = { const payload: RemoteWindowOpenPayload = {
id: profile.id, id: profile.id,
name: profile.name, name: profile.name,
@@ -27,7 +31,7 @@ export async function openRemoteServerWindow(
skipTlsVerify: profile.skipTlsVerify, skipTlsVerify: profile.skipTlsVerify,
} }
if (runtimeEnv.host === "electron") { if (isElectronHost()) {
const api = (window as Window & { electronAPI?: ElectronAPI }).electronAPI const api = (window as Window & { electronAPI?: ElectronAPI }).electronAPI
if (typeof api?.openRemoteWindow === "function") { if (typeof api?.openRemoteWindow === "function") {
await api.openRemoteWindow(payload) await api.openRemoteWindow(payload)
@@ -35,7 +39,7 @@ export async function openRemoteServerWindow(
} }
} }
if (runtimeEnv.host === "tauri") { if (isTauriHost()) {
const requiresLocalCertificate = const requiresLocalCertificate =
proxySessionId !== undefined && (entryUrl ?? profile.baseUrl).startsWith("https://") proxySessionId !== undefined && (entryUrl ?? profile.baseUrl).startsWith("https://")

View File

@@ -1,5 +1,5 @@
import { invoke } from "@tauri-apps/api/core" import { invoke } from "@tauri-apps/api/core"
import { runtimeEnv } from "../runtime-env" import { isElectronHost, isTauriHost } from "../runtime-env"
import { getLogger } from "../logger" import { getLogger } from "../logger"
const log = getLogger("actions") const log = getLogger("actions")
@@ -56,11 +56,11 @@ async function setWebWakeLock(enabled: boolean): Promise<boolean> {
function hasAnyWakeLockSupport(): boolean { function hasAnyWakeLockSupport(): boolean {
if (typeof window === "undefined") return false if (typeof window === "undefined") return false
if (runtimeEnv.host === "electron") { if (isElectronHost()) {
const api = (window as any).electronAPI const api = (window as any).electronAPI
if (api?.setWakeLock) return true if (api?.setWakeLock) return true
} }
if (runtimeEnv.host === "tauri") { if (isTauriHost()) {
return typeof window.__TAURI__?.core?.invoke === "function" return typeof window.__TAURI__?.core?.invoke === "function"
} }
return Boolean((navigator as any)?.wakeLock?.request) return Boolean((navigator as any)?.wakeLock?.request)
@@ -106,13 +106,13 @@ async function setTauriWakeLock(enabled: boolean): Promise<boolean> {
async function applyWakeLock(enabled: boolean): Promise<boolean> { async function applyWakeLock(enabled: boolean): Promise<boolean> {
if (typeof window === "undefined") return false if (typeof window === "undefined") return false
if (runtimeEnv.host === "electron") { if (isElectronHost()) {
const ok = await setElectronWakeLock(enabled) const ok = await setElectronWakeLock(enabled)
if (ok || !enabled) return ok if (ok || !enabled) return ok
// fallback to web API if electron preload didn't expose it // fallback to web API if electron preload didn't expose it
} }
if (runtimeEnv.host === "tauri") { if (isTauriHost()) {
const ok = await setTauriWakeLock(enabled) const ok = await setTauriWakeLock(enabled)
if (ok || !enabled) return ok if (ok || !enabled) return ok
// fallback to web API if tauri command isn't available // fallback to web API if tauri command isn't available

View File

@@ -2,10 +2,12 @@ import { getLogger } from "./logger"
export type HostRuntime = "electron" | "tauri" | "web" export type HostRuntime = "electron" | "tauri" | "web"
export type PlatformKind = "desktop" | "mobile" export type PlatformKind = "desktop" | "mobile"
export type WindowContextKind = "local" | "remote"
export interface RuntimeEnvironment { export interface RuntimeEnvironment {
host: HostRuntime host: HostRuntime
platform: PlatformKind platform: PlatformKind
windowContext: WindowContextKind
} }
declare global { declare global {
@@ -14,6 +16,7 @@ declare global {
} }
interface Window { interface Window {
__CODENOMAD_WINDOW_CONTEXT__?: WindowContextKind
electronAPI?: unknown electronAPI?: unknown
__TAURI__?: { __TAURI__?: {
core?: TauriCoreModule 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 { function detectHost(): HostRuntime {
if (typeof window === "undefined") { if (typeof window === "undefined") {
return "web" return "web"
} }
const explicitHost = window.__CODENOMAD_RUNTIME_HOST__
if (explicitHost) {
return explicitHost
}
const win = window as Window & { electronAPI?: unknown } const win = window as Window & { electronAPI?: unknown }
if (typeof win.electronAPI !== "undefined") { if (typeof win.electronAPI !== "undefined") {
return "electron" return "electron"
@@ -71,16 +104,24 @@ export function detectRuntimeEnvironment(): RuntimeEnvironment {
cachedEnv = { cachedEnv = {
host: detectHost(), host: detectHost(),
platform: detectPlatform(), platform: detectPlatform(),
windowContext: detectWindowContext(),
} }
if (typeof window !== "undefined") { 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 return cachedEnv
} }
export const runtimeEnv = detectRuntimeEnvironment() export const runtimeEnv = detectRuntimeEnvironment()
export const isElectronHost = () => runtimeEnv.host === "electron" export const isElectronHost = () => detectHost() === "electron"
export const isTauriHost = () => runtimeEnv.host === "tauri" export const isTauriHost = () => detectHost() === "tauri"
export const isWebHost = () => runtimeEnv.host === "web" export const isWebHost = () => detectHost() === "web"
export const isMobilePlatform = () => runtimeEnv.platform === "mobile" 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()

View File

@@ -6,7 +6,7 @@ import type {
} from "../../stores/preferences" } from "../../stores/preferences"
import type { Command } from "../commands" import type { Command } from "../commands"
import { tGlobal } from "../i18n" import { tGlobal } from "../i18n"
import { runtimeEnv } from "../runtime-env" import { isWebHost } from "../runtime-env"
export type BehaviorSettingKind = "toggle" | "enum" export type BehaviorSettingKind = "toggle" | "enum"
@@ -84,7 +84,7 @@ export function getBehaviorSettings(actions: BehaviorRegistryActions): BehaviorS
next, next,
) )
}, },
disabled: () => runtimeEnv.host === "web", disabled: () => isWebHost(),
}, },
{ {
kind: "toggle", kind: "toggle",
@@ -337,13 +337,13 @@ export function getBehaviorCommands(actions: BehaviorRegistryActions): Command[]
), ),
description: () => description: () =>
tGlobal( tGlobal(
runtimeEnv.host === "web" isWebHost()
? "commands.keyboardShortcutHints.description.disabledWeb" ? "commands.keyboardShortcutHints.description.disabledWeb"
: "commands.keyboardShortcutHints.description", : "commands.keyboardShortcutHints.description",
), ),
category: "System", category: "System",
keywords: () => splitKeywords("commands.keyboardShortcutHints.keywords"), keywords: () => splitKeywords("commands.keyboardShortcutHints.keywords"),
disabled: () => runtimeEnv.host === "web", disabled: () => isWebHost(),
action: actions.toggleKeyboardShortcutHints, action: actions.toggleKeyboardShortcutHints,
}, },
{ {

View File

@@ -63,10 +63,12 @@ declare global {
} }
interface Window { interface Window {
__CODENOMAD_API_BASE__?: string __CODENOMAD_API_BASE__?: string
__CODENOMAD_EVENTS_URL__?: string __CODENOMAD_EVENTS_URL__?: string
electronAPI?: ElectronAPI __CODENOMAD_RUNTIME_HOST__?: "electron" | "tauri" | "web"
__TAURI__?: TauriBridge __CODENOMAD_WINDOW_CONTEXT__?: "local" | "remote"
codenomadLogger?: LoggerControls electronAPI?: ElectronAPI
__TAURI__?: TauriBridge
codenomadLogger?: LoggerControls
} }
} }