feat: add desktop LAN access setting

This commit is contained in:
Bohdan Triapitsyn
2026-04-17 18:05:53 +03:00
parent 304b14b4b1
commit c494d9f8b9
7 changed files with 281 additions and 32 deletions
@@ -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<string | null>(null);
const [lanAddress, setLanAddress] = React.useState<string | null>(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 (
<div className="mb-8">
<div className="mb-1 px-1">
<h3 className="typography-ui-header font-medium text-foreground">Desktop Network Access</h3>
</div>
<section className="space-y-2 px-2 pb-2 pt-0">
<div
className="group flex cursor-pointer items-start gap-2 py-1.5"
role="button"
tabIndex={0}
onClick={handleToggle}
onKeyDown={(event) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
handleToggle();
}
}}
>
<Checkbox
checked={draftValue}
onChange={handleToggle}
ariaLabel="Allow LAN access to desktop sidecar"
disabled={isLoading || isSaving}
/>
<div className="min-w-0 flex-1">
<div className="typography-ui-label text-foreground">Let other devices on your local network open this app</div>
<div className="typography-micro text-muted-foreground/70">
Restarts the app so phones, tablets, and other computers on your Wi-Fi can open it.
</div>
<div className="typography-micro text-[var(--status-warning)]/85">
Warning: while enabled, the app is reachable by anyone on the same local network.
</div>
</div>
</div>
{error ? (
<div className="px-2 typography-micro text-[var(--status-error)]">{error}</div>
) : null}
{lanUrl ? (
<div className="px-2 typography-micro text-muted-foreground/80">
{isDirty && !savedValue ? 'After restart, open from another device: ' : 'Open from another device: '}
<span className="font-mono text-foreground">{lanUrl}</span>
</div>
) : null}
<div className="flex justify-start py-1.5">
<Button
type="button"
size="xs"
onClick={handleSaveAndRestart}
disabled={isLoading || isSaving || !isDirty}
className="shrink-0 !font-normal"
>
{isSaving ? 'Saving…' : 'Save + Restart'}
</Button>
</div>
</section>
</div>
);
};
@@ -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<OpenChamberPageProps> = ({ 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<OpenChamberPageProps> = ({ section }) =>
<OpenCodeCliSettings />
</div>
)}
{showDesktopNetworkSettings && (
<div className="border-t border-border/40 pt-6">
<DesktopNetworkSettings />
</div>
)}
<div className="border-t border-border/40 pt-6">
<SessionRetentionSettings />
</div>
@@ -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 (
<div className="space-y-6">
<DefaultsSettings />
@@ -136,6 +144,11 @@ const SessionsSectionContent: React.FC = () => {
<OpenCodeCliSettings />
</div>
)}
{showDesktopNetworkSettings && (
<div className="border-t border-border/40 pt-6">
<DesktopNetworkSettings />
</div>
)}
<div className="border-t border-border/40 pt-6">
<SessionRetentionSettings />
</div>
+16
View File
@@ -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<boolean> => {
}
};
export const getDesktopLanAddress = async (): Promise<string | null> => {
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<boolean> => {
if (!isTauriShell() || !isDesktopLocalOriginActive()) {
return false;
+3
View File
@@ -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) {