From c494d9f8b9559c2892ad8f7024c6eab7168b18d1 Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Fri, 17 Apr 2026 18:05:53 +0300 Subject: [PATCH] feat: add desktop LAN access setting --- packages/desktop/src-tauri/src/main.rs | 73 ++++--- .../openchamber/DesktopNetworkSettings.tsx | 192 ++++++++++++++++++ .../sections/openchamber/OpenChamberPage.tsx | 15 +- packages/ui/src/lib/desktop.ts | 16 ++ packages/ui/src/lib/persistence.ts | 3 + .../server/lib/opencode/settings-helpers.js | 3 + .../lib/opencode/settings-helpers.test.js | 11 + 7 files changed, 281 insertions(+), 32 deletions(-) create mode 100644 packages/ui/src/components/sections/openchamber/DesktopNetworkSettings.tsx diff --git a/packages/desktop/src-tauri/src/main.rs b/packages/desktop/src-tauri/src/main.rs index 598dca91..16182c8c 100644 --- a/packages/desktop/src-tauri/src/main.rs +++ b/packages/desktop/src-tauri/src/main.rs @@ -14,7 +14,7 @@ use std::{ path::{Path, PathBuf}, }; use std::{ - net::TcpListener, + net::{TcpListener, UdpSocket}, process::Command, sync::{ atomic::{AtomicU32, AtomicU64, Ordering}, @@ -1630,13 +1630,14 @@ fn settings_file_path() -> PathBuf { .join("settings.json") } +fn read_desktop_settings_json() -> Option { + fs::read_to_string(settings_file_path()) + .ok() + .and_then(|raw| serde_json::from_str::(&raw).ok()) +} + fn read_desktop_local_port_from_disk() -> Option { - let path = settings_file_path(); - let raw = fs::read_to_string(path).ok(); - let parsed = raw - .as_deref() - .and_then(|s| serde_json::from_str::(s).ok()); - parsed + read_desktop_settings_json() .as_ref() .and_then(|v| v.get("desktopLocalPort")) .and_then(|v| v.as_u64()) @@ -2548,27 +2549,10 @@ async fn spawn_local_server(app: &tauri::AppHandle) -> Result { } }); + let desktop_settings = read_desktop_settings_json(); + let opencode_binary_from_settings: Option = (|| { - let data_dir = env::var("OPENCHAMBER_DATA_DIR") - .ok() - .and_then(|v| { - let t = v.trim().to_string(); - if t.is_empty() { - None - } else { - Some(PathBuf::from(t)) - } - }) - .or_else(|| { - resolved_home_dir_path - .as_ref() - .map(|home| home.join(".config").join("openchamber")) - }); - let data_dir = data_dir?; - let settings_path = data_dir.join("settings.json"); - let raw = fs::read_to_string(&settings_path).ok()?; - let json = serde_json::from_str::(&raw).ok()?; - let value = json.get("opencodeBinary")?.as_str()?.trim(); + let value = desktop_settings.as_ref()?.get("opencodeBinary")?.as_str()?.trim(); if value.is_empty() { return None; } @@ -2592,6 +2576,13 @@ async fn spawn_local_server(app: &tauri::AppHandle) -> Result { Some(candidate) })(); + let sidecar_bind_host = desktop_settings + .as_ref() + .and_then(|value| value.get("desktopLanAccessEnabled")) + .and_then(|value| value.as_bool()) + .map(|enabled| if enabled { "0.0.0.0" } else { "127.0.0.1" }) + .unwrap_or("127.0.0.1"); + let mut push_unique = |value: String| { let trimmed = value.trim(); if trimmed.is_empty() { @@ -2668,7 +2659,7 @@ async fn spawn_local_server(app: &tauri::AppHandle) -> Result { .sidecar(SIDECAR_NAME) .map_err(|err| anyhow!("Failed to resolve sidecar '{SIDECAR_NAME}': {err}"))? .args(["--port", &port.to_string()]) - .env("OPENCHAMBER_HOST", "127.0.0.1") + .env("OPENCHAMBER_HOST", sidecar_bind_host) .env("OPENCHAMBER_DIST_DIR", dist_dir.clone()) .env("OPENCHAMBER_RUNTIME", "desktop") .env("OPENCHAMBER_DESKTOP_NOTIFY", "true") @@ -3206,9 +3197,7 @@ fn parse_theme_override(theme_mode: Option<&str>, theme_variant: Option<&str>) - } fn read_desktop_theme_override() -> Option { - let settings = fs::read_to_string(settings_file_path()) - .ok() - .and_then(|raw| serde_json::from_str::(&raw).ok()); + let settings = read_desktop_settings_json(); let use_system_theme = settings .as_ref() @@ -3232,6 +3221,22 @@ fn read_desktop_theme_override() -> Option { parse_theme_override(theme_mode, theme_variant) } +fn detect_desktop_lan_ipv4() -> Option { + let socket = UdpSocket::bind("0.0.0.0:0").ok()?; + socket.connect("8.8.8.8:80").ok()?; + let address = socket.local_addr().ok()?; + let ip = address.ip(); + + if ip.is_loopback() { + return None; + } + + match ip { + std::net::IpAddr::V4(ipv4) => Some(ipv4.to_string()), + std::net::IpAddr::V6(_) => None, + } +} + /// Apply platform-specific window builder configuration. fn apply_platform_window_config>( builder: WebviewWindowBuilder<'_, tauri::Wry, M>, @@ -3268,6 +3273,11 @@ fn desktop_set_window_theme( Ok(()) } +#[tauri::command] +fn desktop_get_lan_address() -> Option { + detect_desktop_lan_ipv4() +} + fn is_window_state_visible(app: &tauri::AppHandle, state: &DesktopWindowState) -> bool { if state.width == 0 || state.height == 0 { return false; @@ -4019,6 +4029,7 @@ fn main() { desktop_hosts_set, desktop_host_probe, desktop_set_window_theme, + desktop_get_lan_address, remote_ssh::desktop_ssh_instances_get, remote_ssh::desktop_ssh_instances_set, remote_ssh::desktop_ssh_import_hosts, diff --git a/packages/ui/src/components/sections/openchamber/DesktopNetworkSettings.tsx b/packages/ui/src/components/sections/openchamber/DesktopNetworkSettings.tsx new file mode 100644 index 00000000..591b2d3b --- /dev/null +++ b/packages/ui/src/components/sections/openchamber/DesktopNetworkSettings.tsx @@ -0,0 +1,192 @@ +import * as React from 'react'; + +import { Button } from '@/components/ui/button'; +import { Checkbox } from '@/components/ui/checkbox'; +import { getDesktopLanAddress, isDesktopLocalOriginActive, isDesktopShell, restartDesktopApp } from '@/lib/desktop'; + +export const DesktopNetworkSettings: React.FC = () => { + const isLocalDesktop = isDesktopShell() && isDesktopLocalOriginActive(); + const [savedValue, setSavedValue] = React.useState(false); + const [draftValue, setDraftValue] = React.useState(false); + const [isLoading, setIsLoading] = React.useState(true); + const [isSaving, setIsSaving] = React.useState(false); + const [error, setError] = React.useState(null); + const [lanAddress, setLanAddress] = React.useState(null); + + React.useEffect(() => { + if (!isLocalDesktop) { + setIsLoading(false); + return; + } + + let cancelled = false; + void (async () => { + try { + const response = await fetch('/api/config/settings', { + method: 'GET', + headers: { Accept: 'application/json' }, + }); + if (!response.ok) { + throw new Error('Failed to load desktop settings'); + } + + const data = (await response.json().catch(() => null)) as null | { desktopLanAccessEnabled?: unknown }; + if (cancelled) { + return; + } + + const enabled = data?.desktopLanAccessEnabled === true; + setSavedValue(enabled); + setDraftValue(enabled); + setError(null); + } catch (cause) { + if (!cancelled) { + setError(cause instanceof Error ? cause.message : 'Failed to load desktop settings'); + } + } finally { + if (!cancelled) { + setIsLoading(false); + } + } + })(); + + return () => { + cancelled = true; + }; + }, [isLocalDesktop]); + + React.useEffect(() => { + if (!isLocalDesktop || !draftValue) { + setLanAddress(null); + return; + } + + let cancelled = false; + + void (async () => { + const address = await getDesktopLanAddress(); + if (!cancelled) { + setLanAddress(address); + } + })(); + + return () => { + cancelled = true; + }; + }, [draftValue, isLocalDesktop]); + + const isDirty = draftValue !== savedValue; + const currentPort = React.useMemo(() => { + if (typeof window === 'undefined') { + return null; + } + + const parsed = Number(window.location.port); + return Number.isFinite(parsed) && parsed > 0 ? parsed : null; + }, []); + const lanUrl = draftValue && lanAddress && currentPort ? `http://${lanAddress}:${currentPort}` : null; + + const handleToggle = React.useCallback(() => { + setDraftValue((current) => !current); + }, []); + + const handleSaveAndRestart = React.useCallback(async () => { + if (!isDirty) { + return; + } + + setIsSaving(true); + setError(null); + + try { + const response = await fetch('/api/config/settings', { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json', + }, + body: JSON.stringify({ desktopLanAccessEnabled: draftValue }), + }); + + if (!response.ok) { + throw new Error('Failed to save desktop settings'); + } + + setSavedValue(draftValue); + + const restarted = await restartDesktopApp(); + if (!restarted) { + throw new Error('Saved, but failed to restart app'); + } + } catch (cause) { + setError(cause instanceof Error ? cause.message : 'Failed to save desktop settings'); + setIsSaving(false); + } + }, [draftValue, isDirty]); + + if (!isLocalDesktop) { + return null; + } + + return ( +
+
+

Desktop Network Access

+
+ +
+
{ + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault(); + handleToggle(); + } + }} + > + +
+
Let other devices on your local network open this app
+
+ Restarts the app so phones, tablets, and other computers on your Wi-Fi can open it. +
+
+ Warning: while enabled, the app is reachable by anyone on the same local network. +
+
+
+ + {error ? ( +
{error}
+ ) : null} + + {lanUrl ? ( +
+ {isDirty && !savedValue ? 'After restart, open from another device: ' : 'Open from another device: '} + {lanUrl} +
+ ) : null} + +
+ +
+
+
+ ); +}; diff --git a/packages/ui/src/components/sections/openchamber/OpenChamberPage.tsx b/packages/ui/src/components/sections/openchamber/OpenChamberPage.tsx index 8fa8c43c..12ae829e 100644 --- a/packages/ui/src/components/sections/openchamber/OpenChamberPage.tsx +++ b/packages/ui/src/components/sections/openchamber/OpenChamberPage.tsx @@ -10,10 +10,11 @@ import { GitHubSettings } from './GitHubSettings'; import { VoiceSettings } from './VoiceSettings'; import { TunnelSettings } from './TunnelSettings'; import { OpenCodeCliSettings } from './OpenCodeCliSettings'; +import { DesktopNetworkSettings } from './DesktopNetworkSettings'; import { KeyboardShortcutsSettings } from './KeyboardShortcutsSettings'; import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay'; import { useDeviceInfo } from '@/lib/device'; -import { isVSCodeRuntime, isWebRuntime } from '@/lib/desktop'; +import { isDesktopLocalOriginActive, isDesktopShell, isVSCodeRuntime, isWebRuntime } from '@/lib/desktop'; import type { OpenChamberSection } from './types'; interface OpenChamberPageProps { @@ -25,6 +26,7 @@ export const OpenChamberPage: React.FC = ({ section }) => const { isMobile } = useDeviceInfo(); const showAbout = isMobile && isWebRuntime(); const isVSCode = isVSCodeRuntime(); + const showDesktopNetworkSettings = isDesktopShell() && isDesktopLocalOriginActive(); // If no section specified, show all (mobile/legacy behavior) if (!section) { @@ -44,6 +46,11 @@ export const OpenChamberPage: React.FC = ({ section }) => )} + {showDesktopNetworkSettings && ( +
+ +
+ )}
@@ -128,6 +135,7 @@ const ChatSectionContent: React.FC = () => { // Sessions section: Default model & agent, Session retention const SessionsSectionContent: React.FC = () => { const isVSCode = isVSCodeRuntime(); + const showDesktopNetworkSettings = isDesktopShell() && isDesktopLocalOriginActive(); return (
@@ -136,6 +144,11 @@ const SessionsSectionContent: React.FC = () => {
)} + {showDesktopNetworkSettings && ( +
+ +
+ )}
diff --git a/packages/ui/src/lib/desktop.ts b/packages/ui/src/lib/desktop.ts index 62a328e0..022e036c 100644 --- a/packages/ui/src/lib/desktop.ts +++ b/packages/ui/src/lib/desktop.ts @@ -50,6 +50,7 @@ export type DesktopSettings = { homeDirectory?: string; // Optional absolute path to `opencode` binary. opencodeBinary?: string; + desktopLanAccessEnabled?: boolean; projects?: ProjectEntry[]; activeProjectId?: string; approvedDirectories?: string[]; @@ -479,6 +480,21 @@ export const restartDesktopApp = async (): Promise => { } }; +export const getDesktopLanAddress = async (): Promise => { + if (!isTauriShell() || !isDesktopLocalOriginActive()) { + return null; + } + + try { + const tauri = (window as unknown as { __TAURI__?: TauriGlobal }).__TAURI__; + const result = await tauri?.core?.invoke?.('desktop_get_lan_address'); + return typeof result === 'string' && result.trim().length > 0 ? result.trim() : null; + } catch (error) { + console.warn('Failed to get desktop LAN address (tauri)', error); + return null; + } +}; + export const openDesktopPath = async (path: string, app?: string | null): Promise => { if (!isTauriShell() || !isDesktopLocalOriginActive()) { return false; diff --git a/packages/ui/src/lib/persistence.ts b/packages/ui/src/lib/persistence.ts index 2d06acaa..6db50f1c 100644 --- a/packages/ui/src/lib/persistence.ts +++ b/packages/ui/src/lib/persistence.ts @@ -519,6 +519,9 @@ const sanitizeWebSettings = (payload: unknown): DesktopSettings | null => { const trimmed = candidate.opencodeBinary.trim(); result.opencodeBinary = trimmed.length > 0 ? trimmed : undefined; } + if (typeof candidate.desktopLanAccessEnabled === 'boolean') { + result.desktopLanAccessEnabled = candidate.desktopLanAccessEnabled; + } const projects = sanitizeProjects(candidate.projects); if (projects) { diff --git a/packages/web/server/lib/opencode/settings-helpers.js b/packages/web/server/lib/opencode/settings-helpers.js index d6ee84c3..5661ba17 100644 --- a/packages/web/server/lib/opencode/settings-helpers.js +++ b/packages/web/server/lib/opencode/settings-helpers.js @@ -85,6 +85,9 @@ export const createSettingsHelpers = (dependencies) => { const normalized = normalizeDirectoryPath(candidate.opencodeBinary).trim(); result.opencodeBinary = normalized; } + if (typeof candidate.desktopLanAccessEnabled === 'boolean') { + result.desktopLanAccessEnabled = candidate.desktopLanAccessEnabled; + } if (Array.isArray(candidate.projects)) { const projects = sanitizeProjects(candidate.projects); if (projects) { diff --git a/packages/web/server/lib/opencode/settings-helpers.test.js b/packages/web/server/lib/opencode/settings-helpers.test.js index d03e68df..1cd1c886 100644 --- a/packages/web/server/lib/opencode/settings-helpers.test.js +++ b/packages/web/server/lib/opencode/settings-helpers.test.js @@ -40,4 +40,15 @@ describe('settings helpers', () => { expect(helpers.sanitizeSettingsUpdate({ messageStreamTransport: 'websocket' })).toEqual({}); }); + + it('accepts desktopLanAccessEnabled as a persisted shared setting', () => { + const helpers = createTestHelpers(); + + expect(helpers.sanitizeSettingsUpdate({ desktopLanAccessEnabled: true })).toEqual({ + desktopLanAccessEnabled: true, + }); + expect(helpers.sanitizeSettingsUpdate({ desktopLanAccessEnabled: false })).toEqual({ + desktopLanAccessEnabled: false, + }); + }); });