import type { ProjectEntry, RuntimeAPIs, TerminalShell } from '@/lib/api/types'; import { getInjectedBootOutcome } from '@/lib/desktopBoot'; import type { DraftStarterRef } from '@/lib/draftStarters'; import type { MobileKeyboardMode } from '@/lib/mobileKeyboardMode'; import { getRuntimeApiBaseUrl, getRuntimeKey } from '@/lib/runtime-switch'; import { getRegisteredRuntimeAPIs } from '@/contexts/runtimeAPIRegistry'; type ManagedRemoteTunnelPreset = { id: string; name: string; hostname: string; }; export type UpdateInfo = { available: boolean; version?: string; currentVersion: string; body?: string; date?: string; releaseUrl?: string; downloadUrl?: string; nextSuggestedCheckInSec?: number; // Web-specific fields packageManager?: string; updateCommand?: string; }; export type UpdateProgress = { downloaded: number; total?: number; }; export type SkillCatalogConfig = { id: string; label: string; source: string; subpath?: string; gitIdentityId?: string; }; export type DesktopWindowControlsPosition = 'left' | 'right'; export type DesktopWindowControlsSide = 'left' | 'right'; export type DesktopWindowControlAction = 'close' | 'minimize' | 'maximize'; // No fixed-width constant: control width depends on the style (classic vs traffic-lights). export type DesktopWindowControlsStyle = 'classic' | 'traffic-lights'; export type DesktopSettings = { themeId?: string; useSystemTheme?: boolean; themeVariant?: 'light' | 'dark'; lightThemeId?: string; darkThemeId?: string; splashBgLight?: string; splashFgLight?: string; splashBgDark?: string; splashFgDark?: string; lastDirectory?: string; homeDirectory?: string; // Optional absolute path to `opencode` binary. opencodeBinary?: string; desktopLanAccessEnabled?: boolean; desktopKeepAwakeEnabled?: boolean; desktopMinimizeToTrayEnabled?: boolean; desktopMacMenuBarEnabled?: boolean; desktopUiPassword?: string; projects?: ProjectEntry[]; activeProjectId?: string; securityScopedBookmarks?: string[]; pinnedDirectories?: string[]; showReasoningTraces?: boolean; /** Whether the in-chat work-status panel may render. */ workStatusPanelEnabled?: boolean; /** Work-status panel sections the user switched off. */ workStatusHiddenSections?: string[]; collapsibleThinkingBlocks?: boolean; showDeletionDialog?: boolean; nativeNotificationsEnabled?: boolean; notificationMode?: 'always' | 'hidden-only'; notifyOnSubtasks?: boolean; // Event toggles (which events trigger notifications) notifyOnCompletion?: boolean; notifyOnError?: boolean; notifyOnQuestion?: boolean; // Per-event notification templates notificationTemplates?: { completion: { title: string; message: string }; error: { title: string; message: string }; question: { title: string; message: string }; subtask: { title: string; message: string }; }; // Summarization settings summarizeLastMessage?: boolean; summaryThreshold?: number; summaryLength?: number; maxLastMessageLength?: number; usageDisplayMode?: 'usage' | 'remaining'; usageDropdownProviders?: string[]; usageSelectedModels?: Record; // Map of providerId -> selected model names usageCollapsedFamilies?: Record; // Map of providerId -> collapsed family IDs (UsagePage) usageExpandedFamilies?: Record; // Map of providerId -> EXPANDED family IDs (header dropdown - inverted) usageModelGroups?: Record; modelAssignments?: Record; // modelName -> groupId renamedGroups?: Record; // groupId -> custom label }>; // Per-provider custom model groups configuration autoDeleteEnabled?: boolean; autoSaveEnabled?: boolean; autoDeleteAfterDays?: number; sessionRetentionAction?: 'archive' | 'delete'; tunnelProvider?: string; tunnelMode?: 'quick' | 'managed-remote' | 'managed-local'; tunnelBootstrapTtlMs?: number | null; tunnelSessionTtlMs?: number; managedLocalTunnelConfigPath?: string | null; managedRemoteTunnelHostname?: string; managedRemoteTunnelToken?: string | null; hasManagedRemoteTunnelToken?: boolean; managedRemoteTunnelPresets?: ManagedRemoteTunnelPreset[]; managedRemoteTunnelSelectedPresetId?: string; managedRemoteTunnelPresetTokens?: Record; defaultModel?: string; // format: "provider/model" defaultVariant?: string; defaultAgent?: string; smallModelUseDefault?: boolean; sessionRecapEnabled?: boolean; sessionSuggestionEnabled?: boolean; sessionGoalEnabled?: boolean; sessionGoalDefaultBudgetEnabled?: boolean; sessionGoalDefaultBudget?: number; smallModelOverride?: string; // format: "provider/model" // The walkthrough needs structured output and a roomy context, which the // small model is often deliberately not chosen for. Unset means "use the // small model"; a value replaces it for this feature only. walkthroughModelOverride?: string; // format: "provider/model" defaultGitIdentityId?: string; // ''/undefined = unset, 'global' or profile id openInAppId?: string; autoCreateWorktree?: boolean; followUpBehavior?: 'steer' | 'queue'; queueModeEnabled?: boolean; gitmojiEnabled?: boolean; defaultFileViewerPreview?: boolean; zenModel?: string; gitProviderId?: string; gitModelId?: string; pwaAppName?: string; pwaOrientation?: 'system' | 'portrait' | 'landscape'; mobileKeyboardMode?: MobileKeyboardMode; desktopWindowControlsPosition?: DesktopWindowControlsPosition; desktopWindowControlsStyle?: DesktopWindowControlsStyle; inputSpellcheckEnabled?: boolean; showOpenCodeUpdateNotifications?: boolean; agentControlToolEnabled?: boolean; optimizeSystemPrompt?: boolean; openCodeUpdateToastDismissedVersion?: string; showToolFileIcons?: boolean; codeBlockLineWrap?: boolean; showTurnChangedFiles?: boolean; showExpandedBashTools?: boolean; showExpandedEditTools?: boolean; timeFormatPreference?: 'auto' | '12h' | '24h'; weekStartPreference?: 'auto' | 'sunday' | 'monday'; chatRenderMode?: 'sorted' | 'live'; messageStreamTransport?: 'auto' | 'ws' | 'sse'; activityRenderMode?: 'collapsed' | 'summary'; mermaidRenderingMode?: 'svg' | 'ascii'; userMessageRenderingMode?: 'markdown' | 'plain'; collapsibleUserMessages?: boolean; stickyUserHeader?: boolean; promptNavigatorEnabled?: boolean; expandedEditorToolbar?: boolean; wideChatLayoutEnabled?: boolean; showSplitAssistantMessageActions?: boolean; fontSize?: number; terminalFontSize?: number; terminalShell?: TerminalShell; terminalLoginShells?: TerminalShell[]; editorFontSize?: number; uiFont?: string; monoFont?: string; padding?: number; cornerRadius?: number; inputBarOffset?: number; shortcutOverrides?: Record; favoriteModels?: Array<{ providerID: string; modelID: string }>; hiddenModels?: Array<{ providerID: string; modelID: string }>; collapsedModelProviders?: string[]; recentModels?: Array<{ providerID: string; modelID: string }>; recentAgents?: string[]; recentEfforts?: Record; diffLayoutPreference?: 'dynamic' | 'inline' | 'side-by-side'; gitChangesViewMode?: 'flat' | 'tree'; directoryShowHidden?: boolean; filesViewShowGitignored?: boolean; // Message limit — controls fetch, trim, and Load More chunk size (default: 200) messageLimit?: number; // User-added skills catalogs (persisted to ~/.config/openchamber/settings.json) skillCatalogs?: SkillCatalogConfig[]; // Opt-in to send anonymous usage reports for update checks (default: true) reportUsage?: boolean; // Global behavior prompt — synced to ~/.config/opencode/AGENTS.md globalBehaviorPrompt?: string; responseStyleEnabled?: boolean; responseStylePreset?: 'concise' | 'detailed' | 'mentor' | 'pushback' | 'noFiller' | 'matchEnergy' | 'warmPeer' | 'custom'; responseStyleCustomInstructions?: string; dictationEnabled?: boolean; sttProvider?: 'local' | 'openai-compatible'; sttServerUrl?: string; sttModel?: string; sttLocalModel?: string; sttLanguage?: string; // Global draft welcome starters (pinned commands/skills), persisted to settings.json draftStarters?: DraftStarterRef[]; draftStartersVisible?: boolean; // One-time migration marker: Craft a Goal was offered in the starter row. draftStartersCraftGoalAdded?: boolean; draftStartersScheduleTaskAdded?: boolean; }; type DesktopBridgeGlobal = { invoke?: (cmd: string, args?: Record) => Promise; openDialog?: (options: Record) => Promise; grantFileAccess?: (path: string) => Promise; openExternal?: (url: string) => Promise; listen?: ( event: string, handler: (evt: { payload?: unknown }) => void, ) => Promise<() => void>; }; type ElectronRuntimeGlobal = { runtime?: string; arch?: string; trayEnabled?: boolean; }; const getElectronRuntime = (): ElectronRuntimeGlobal | null => { if (typeof window === 'undefined') return null; return (window as unknown as { __OPENCHAMBER_ELECTRON__?: ElectronRuntimeGlobal }).__OPENCHAMBER_ELECTRON__ ?? null; }; const getDesktopBridge = (): DesktopBridgeGlobal | null => { if (typeof window === 'undefined') return null; return (window as unknown as { __OPENCHAMBER_DESKTOP__?: DesktopBridgeGlobal }).__OPENCHAMBER_DESKTOP__ ?? null; }; export const isElectronShell = (): boolean => getElectronRuntime()?.runtime === 'electron'; export const getElectronPlatform = (): string | null => { if (typeof window === 'undefined') return null; const platform = (window as unknown as { __OPENCHAMBER_PLATFORM__?: string }).__OPENCHAMBER_PLATFORM__; return typeof platform === 'string' ? platform : null; }; /** Default side for in-app window controls (Windows-style, right). */ export const DEFAULT_DESKTOP_WINDOW_CONTROLS_POSITION: DesktopWindowControlsPosition = 'right'; /** Windows and Linux use frameless windows with in-app minimize/maximize/close controls. */ export const usesFramelessElectronChrome = (): boolean => { if (!isElectronShell()) return false; const platform = getElectronPlatform(); return platform === 'win32' || platform === 'linux'; }; /** Normalize a stored preference; legacy `auto` maps to the right-side default. */ export const normalizeDesktopWindowControlsPosition = ( value: unknown, ): DesktopWindowControlsPosition | undefined => { if (value === 'left' || value === 'right') { return value; } // Legacy "auto" never read OS chrome config; treat it as the right default. if (value === 'auto') { return DEFAULT_DESKTOP_WINDOW_CONTROLS_POSITION; } return undefined; }; export const resolveDesktopWindowControlsSide = ( preference: DesktopWindowControlsPosition | undefined, ): DesktopWindowControlsSide => { return preference === 'left' ? 'left' : DEFAULT_DESKTOP_WINDOW_CONTROLS_POSITION; }; /** * Left matches macOS traffic-light order (close, minimize, maximize). * Right keeps Windows order (minimize, maximize, close). */ export const getDesktopWindowControlsOrder = ( side: DesktopWindowControlsSide, ): DesktopWindowControlAction[] => { return side === 'left' ? ['close', 'minimize', 'maximize'] : ['minimize', 'maximize', 'close']; }; export const hasDesktopInvoke = (): boolean => { return typeof getDesktopBridge()?.invoke === 'function'; }; export const canUseElectronDesktopIPC = (): boolean => isElectronShell() && hasDesktopInvoke(); export const invokeDesktop = async (command: string, args?: Record): Promise => { const bridge = getDesktopBridge(); if (typeof bridge?.invoke !== 'function') return null; return bridge.invoke(command, args ?? {}) as Promise; }; type LaunchAtLoginStatus = { supported: boolean; enabled: boolean; }; type KeepAwakeStatus = { supported: boolean; enabled: boolean; active: boolean; }; type MinimizeToTrayStatus = { supported: boolean; enabled: boolean; }; export const getDesktopLaunchAtLogin = async (): Promise => { if (!canUseElectronDesktopIPC() || !isDesktopLocalOriginActive()) { return null; } try { const result = await invokeDesktop('desktop_get_launch_at_login'); if (!result || typeof result.supported !== 'boolean' || typeof result.enabled !== 'boolean') { return null; } return result; } catch (error) { console.warn('Failed to get launch at login status', error); return null; } }; export const setDesktopLaunchAtLogin = async (enabled: boolean): Promise => { if (!canUseElectronDesktopIPC() || !isDesktopLocalOriginActive()) { return null; } try { const result = await invokeDesktop('desktop_set_launch_at_login', { enabled }); if (!result || typeof result.supported !== 'boolean' || typeof result.enabled !== 'boolean') { return null; } return result; } catch (error) { console.warn('Failed to set launch at login status', error); return null; } }; export const getDesktopMinimizeToTray = async (): Promise => { if (!canUseElectronDesktopIPC() || !isDesktopLocalOriginActive()) { return null; } try { const result = await invokeDesktop('desktop_get_minimize_to_tray'); if (!result || typeof result.supported !== 'boolean' || typeof result.enabled !== 'boolean') { return null; } return result; } catch (error) { console.warn('Failed to get minimize to tray status', error); return null; } }; export const setDesktopMinimizeToTray = async (enabled: boolean): Promise => { if (!canUseElectronDesktopIPC() || !isDesktopLocalOriginActive()) { return null; } try { const result = await invokeDesktop('desktop_set_minimize_to_tray', { enabled }); if (!result || typeof result.supported !== 'boolean' || typeof result.enabled !== 'boolean') { return null; } return result; } catch (error) { console.warn('Failed to set minimize to tray status', error); return null; } }; export const getDesktopKeepAwake = async (): Promise => { if (!canUseElectronDesktopIPC() || !isDesktopLocalOriginActive()) { return null; } try { const result = await invokeDesktop('desktop_get_keep_awake'); if (!result || typeof result.supported !== 'boolean' || typeof result.enabled !== 'boolean' || typeof result.active !== 'boolean') { return null; } return result; } catch (error) { console.warn('Failed to get keep awake status', error); return null; } }; export const setDesktopKeepAwake = async (enabled: boolean): Promise => { if (!canUseElectronDesktopIPC() || !isDesktopLocalOriginActive()) { return null; } try { const result = await invokeDesktop('desktop_set_keep_awake', { enabled }); if (!result || typeof result.supported !== 'boolean' || typeof result.enabled !== 'boolean' || typeof result.active !== 'boolean') { return null; } return result; } catch (error) { console.warn('Failed to set keep awake status', error); return null; } }; const normalizeOrigin = (raw: string): string | null => { const trimmed = raw.trim(); if (!trimmed) return null; try { return new URL(trimmed).origin; } catch { try { return new URL(trimmed.endsWith('/') ? trimmed : `${trimmed}/`).origin; } catch { return null; } } }; const parseUrl = (raw: string): URL | null => { const trimmed = raw.trim(); if (!trimmed) return null; try { return new URL(trimmed); } catch { try { return new URL(trimmed.endsWith('/') ? trimmed : `${trimmed}/`); } catch { return null; } } }; const normalizeHost = (rawHost: string): string => rawHost.replace(/^\[|\]$/g, '').toLowerCase(); const isLoopbackHost = (host: string): boolean => { const normalized = normalizeHost(host); return normalized === 'localhost' || normalized === '127.0.0.1' || normalized === '::1'; }; export const isDesktopLocalOriginActive = (): boolean => { if (typeof window === 'undefined') return false; if (!isDesktopShell()) return false; if (getRuntimeKey() === 'local') { return true; } const local = typeof window.__OPENCHAMBER_LOCAL_ORIGIN__ === 'string' ? window.__OPENCHAMBER_LOCAL_ORIGIN__ : ''; const localUrl = parseUrl(local); const runtimeApiUrl = parseUrl(getRuntimeApiBaseUrl()); if (!runtimeApiUrl && localUrl && getInjectedBootOutcome()?.target === 'local') { return true; } if (localUrl && runtimeApiUrl) { if (localUrl.origin === runtimeApiUrl.origin) { return true; } const localPort = localUrl.port || (localUrl.protocol === 'https:' ? '443' : '80'); const runtimePort = runtimeApiUrl.port || (runtimeApiUrl.protocol === 'https:' ? '443' : '80'); return ( localUrl.protocol === runtimeApiUrl.protocol && localPort === runtimePort && isLoopbackHost(localUrl.hostname) && isLoopbackHost(runtimeApiUrl.hostname) ); } const currentUrl = parseUrl(window.location.origin); if (localUrl && currentUrl) { if (localUrl.origin === currentUrl.origin) { return true; } const localPort = localUrl.port || (localUrl.protocol === 'https:' ? '443' : '80'); const currentPort = currentUrl.port || (currentUrl.protocol === 'https:' ? '443' : '80'); return ( localUrl.protocol === currentUrl.protocol && localPort === currentPort && isLoopbackHost(localUrl.hostname) && isLoopbackHost(currentUrl.hostname) ); } const localOrigin = normalizeOrigin(local); const currentOrigin = normalizeOrigin(window.location.origin) || window.location.origin; if (localOrigin && currentOrigin && localOrigin === currentOrigin) { return true; } return Boolean(currentUrl && isLoopbackHost(currentUrl.hostname)); }; export const isDesktopShell = (): boolean => { if (typeof window === 'undefined') return false; return isElectronShell(); }; /** * Raises the desktop window. * * Used when work finishes somewhere the app cannot be reached from — an MCP * authorization completing in the system browser, for instance. Browsers will * not follow a custom-protocol link back without a user gesture, so the app * brings itself forward instead of asking the page to do it. */ export const focusDesktopWindow = async (): Promise => { if (!isDesktopShell()) return false; try { return Boolean(await invokeDesktop('desktop_focus_window')); } catch { return false; } }; export const canRequestNativeDirectoryAccess = (): boolean => ( isDesktopShell() && hasDesktopInvoke() && isDesktopLocalOriginActive() ); export const startDesktopWindowDrag = async (): Promise => { if (!isDesktopShell()) { return false; } try { await invokeDesktop('desktop_start_window_drag'); return true; } catch { return false; } }; export const isVSCodeRuntime = (): boolean => { const apis = getRegisteredRuntimeAPIs(); return apis?.runtime?.isVSCode === true; }; export const isWebRuntime = (): boolean => { const apis = getRegisteredRuntimeAPIs(); const platform = apis?.runtime?.platform; if (platform === 'web') { return true; } if (platform === 'desktop' || platform === 'vscode') { return false; } // Default: anything that's not VSCode behaves like web (HTTP UI). return !isVSCodeRuntime(); }; /** * Electron reuses the web RuntimeAPIs implementation, so distinguish a browser * client from an Electron renderer with both the runtime descriptor and shell. */ export const isBrowserClientRuntime = ( platform: RuntimeAPIs['runtime']['platform'], desktopShell = isDesktopShell(), ): boolean => platform === 'web' && !desktopShell; export const getDesktopHomeDirectory = async (): Promise => { if (typeof window !== 'undefined') { const embedded = window.__OPENCHAMBER_HOME__; if (embedded && embedded.length > 0) { return embedded; } } return null; }; export const requestDirectoryAccess = async ( directoryPath: string ): Promise<{ success: boolean; path?: string; projectId?: string; error?: string }> => { // Desktop shell on local instance: use native folder picker. if (canRequestNativeDirectoryAccess()) { try { const selected = await getDesktopBridge()?.openDialog?.({ directory: true, multiple: false, title: 'Select Working Directory', ...(directoryPath ? { defaultPath: directoryPath } : {}), }); if (!selected || typeof selected !== 'string') { return { success: false, error: 'Directory selection cancelled' }; } return { success: true, path: selected }; } catch (error) { console.warn('Failed to request directory access', error); return { success: false, error: error instanceof Error ? error.message : String(error) }; } } return { success: false, error: 'Native directory picker not available' }; }; const isDesktopFileGrantResult = ( value: unknown ): value is { path?: unknown; outsideFileGrant?: unknown } => ( value !== null && typeof value === 'object' && !Array.isArray(value) ); export const requestFileAccess = async ( options?: { filters?: Array<{ name: string; extensions: string[] }>; defaultPath?: string } ): Promise<{ success: boolean; path?: string; outsideFileGrant?: string; error?: string }> => { if (hasDesktopInvoke() && isDesktopLocalOriginActive()) { try { const selected = await getDesktopBridge()?.openDialog?.({ directory: false, multiple: false, title: 'Select File', returnGrant: true, ...(options?.filters ? { filters: options.filters } : {}), ...(options?.defaultPath ? { defaultPath: options.defaultPath } : {}), }); if (!selected) { return { success: false, error: 'File selection cancelled' }; } if (typeof selected === 'string') { return { success: true, path: selected }; } if (!isDesktopFileGrantResult(selected)) { return { success: false, error: 'File selection cancelled' }; } const path = typeof selected.path === 'string' ? selected.path : ''; if (!path) { return { success: false, error: 'File selection cancelled' }; } return { success: true, path, outsideFileGrant: typeof selected.outsideFileGrant === 'string' ? selected.outsideFileGrant : undefined, }; } catch (error) { console.warn('Failed to request file access', error); return { success: false, error: error instanceof Error ? error.message : String(error) }; } } return { success: false, error: 'Native file picker not available' }; }; export const requestExistingFileAccess = async ( path: string ): Promise<{ success: boolean; path?: string; outsideFileGrant?: string; error?: string }> => { const targetPath = typeof path === 'string' ? path.trim() : ''; if (!targetPath) { return { success: false, error: 'Path is required' }; } if (!hasDesktopInvoke() || !isDesktopLocalOriginActive()) { return { success: false, error: 'Native file access not available' }; } try { const selected = await getDesktopBridge()?.grantFileAccess?.(targetPath); if (!isDesktopFileGrantResult(selected)) { return { success: false, error: 'File access was not granted' }; } const grantedPath = typeof selected.path === 'string' ? selected.path : ''; const outsideFileGrant = typeof selected.outsideFileGrant === 'string' ? selected.outsideFileGrant : ''; if (!grantedPath || !outsideFileGrant) { return { success: false, error: 'File access was not granted' }; } return { success: true, path: grantedPath, outsideFileGrant }; } catch (error) { console.warn('Failed to request existing file access', error); return { success: false, error: error instanceof Error ? error.message : String(error) }; } }; export const startAccessingDirectory = async ( directoryPath: string ): Promise<{ success: boolean; error?: string }> => { void directoryPath; return { success: true }; }; export const stopAccessingDirectory = async ( directoryPath: string ): Promise<{ success: boolean; error?: string }> => { void directoryPath; return { success: true }; }; export const checkForDesktopUpdates = async (): Promise => { if (!hasDesktopInvoke()) { return null; } // Propagate updater capability / feed errors so the UI can show actionable // messages (missing AppImage, read-only path, network failure). Missing // latest-linux*.yml is already normalized to available:false in main. const info = await invokeDesktop('desktop_check_for_updates'); return info as UpdateInfo; }; export const downloadDesktopUpdate = async ( onProgress?: (progress: UpdateProgress) => void ): Promise => { if (!hasDesktopInvoke()) { return false; } const bridge = getDesktopBridge(); let unlisten: null | (() => void | Promise) = null; let downloaded = 0; let total: number | undefined; try { if (typeof onProgress === 'function' && bridge?.listen) { unlisten = await bridge.listen('openchamber:update-progress', (evt) => { const payload = evt?.payload; if (!payload || typeof payload !== 'object') return; const data = payload as { event?: unknown; data?: unknown }; const eventName = typeof data.event === 'string' ? data.event : null; const eventData = data.data && typeof data.data === 'object' ? (data.data as Record) : null; if (eventName === 'Started') { downloaded = 0; total = typeof eventData?.contentLength === 'number' ? (eventData.contentLength as number) : undefined; onProgress({ downloaded, total }); return; } if (eventName === 'Progress') { const d = eventData?.downloaded; const t = eventData?.total; if (typeof d === 'number') downloaded = d; if (typeof t === 'number') total = t; onProgress({ downloaded, total }); return; } if (eventName === 'Finished') { onProgress({ downloaded, total }); } }); } await invokeDesktop('desktop_download_and_install_update'); return true; } catch (error) { // Propagate actionable updater capability / install errors to the UI store. throw error instanceof Error ? error : new Error(String(error)); } finally { if (unlisten) { try { const result = unlisten(); if (result instanceof Promise) { await result; } } catch { // ignored } } } }; export const restartToApplyUpdate = async (): Promise => { if (!hasDesktopInvoke()) { return false; } return restartDesktopApp(); }; export const restartDesktopApp = async (): Promise => { if (!hasDesktopInvoke()) { return false; } try { await invokeDesktop('desktop_restart'); return true; } catch (error) { console.warn('Failed to restart desktop app', error); return false; } }; export const getDesktopLanAddress = async (): Promise => { if (!hasDesktopInvoke() || !isDesktopLocalOriginActive()) { return null; } try { const result = await invokeDesktop('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', error); return null; } }; export const openDesktopPath = async (path: string, app?: string | null): Promise => { if (!hasDesktopInvoke() || !isDesktopLocalOriginActive()) { return false; } const trimmed = path?.trim(); if (!trimmed) { return false; } try { await invokeDesktop('desktop_open_path', { path: trimmed, app: typeof app === 'string' && app.trim().length > 0 ? app.trim() : undefined, }); return true; } catch (error) { console.warn('Failed to open path', error); return false; } }; export const revealDesktopPath = async (path: string): Promise => { if (!hasDesktopInvoke() || !isDesktopLocalOriginActive()) { return false; } const trimmed = path?.trim(); if (!trimmed) { return false; } try { await invokeDesktop('desktop_reveal_path', { path: trimmed, }); return true; } catch { return openDesktopPath(trimmed); } }; export const saveDesktopMarkdownFile = async ( defaultFileName: string, content: string, ): Promise => { if (!hasDesktopInvoke() || !isDesktopLocalOriginActive()) { return null; } const trimmedFileName = defaultFileName?.trim(); if (!trimmedFileName) { return null; } try { const result = await invokeDesktop('desktop_save_markdown_file', { defaultFileName: trimmedFileName, content, }); return typeof result === 'string' && result.trim().length > 0 ? result : null; } catch (error) { console.warn('Failed to save markdown file', error); return null; } }; export const openDesktopProjectInApp = async ( projectPath: string, appId: string, appName: string, ): Promise => { if (!hasDesktopInvoke() || !isDesktopLocalOriginActive()) { return false; } const trimmedProjectPath = projectPath?.trim(); const trimmedAppId = appId?.trim(); const trimmedAppName = appName?.trim(); if (!trimmedProjectPath || !trimmedAppId || !trimmedAppName) { return false; } try { await invokeDesktop('desktop_open_in_app', { projectPath: trimmedProjectPath, appId: trimmedAppId, appName: trimmedAppName, }); return true; } catch (error) { console.warn('Failed to open project in app', error); return false; } }; export const openDesktopFileInApp = async ( filePath: string, appId: string, appName: string, ): Promise => { if (!hasDesktopInvoke() || !isDesktopLocalOriginActive()) { return false; } const trimmedFilePath = filePath?.trim(); const trimmedAppId = appId?.trim(); const trimmedAppName = appName?.trim(); if (!trimmedFilePath || !trimmedAppId || !trimmedAppName) { return false; } try { await invokeDesktop('desktop_open_file_in_app', { filePath: trimmedFilePath, appId: trimmedAppId, appName: trimmedAppName, }); return true; } catch (error) { console.warn('Failed to open file in app', error); return false; } }; export type InstalledDesktopAppInfo = { name: string; iconDataUrl?: string | null; }; export type FetchDesktopInstalledAppsResult = { apps: InstalledDesktopAppInfo[]; success: boolean; hasCache: boolean; isCacheStale: boolean; }; export const fetchDesktopInstalledApps = async ( apps: string[], force?: boolean ): Promise => { if (!hasDesktopInvoke() || !isDesktopLocalOriginActive()) { return { apps: [], success: false, hasCache: false, isCacheStale: false }; } const candidate = Array.isArray(apps) ? apps.filter((value) => typeof value === 'string') : []; if (candidate.length === 0) { return { apps: [], success: true, hasCache: false, isCacheStale: false }; } try { const result = await invokeDesktop('desktop_get_installed_apps', { apps: candidate, force: force === true ? true : undefined, }); if (!result || typeof result !== 'object') { return { apps: [], success: false, hasCache: false, isCacheStale: false }; } const payload = result as { apps?: unknown; hasCache?: unknown; isCacheStale?: unknown; supported?: unknown }; if (payload.supported === false) { return { apps: [], success: true, hasCache: false, isCacheStale: false }; } if (!Array.isArray(payload.apps)) { return { apps: [], success: false, hasCache: false, isCacheStale: false }; } const installedApps = payload.apps .filter((entry) => entry && typeof entry === 'object') .map((entry) => { const record = entry as { name?: unknown; iconDataUrl?: unknown }; return { name: typeof record.name === 'string' ? record.name : '', iconDataUrl: typeof record.iconDataUrl === 'string' ? record.iconDataUrl : null, }; }) .filter((entry) => entry.name.length > 0); return { apps: installedApps, success: true, hasCache: payload.hasCache === true, isCacheStale: payload.isCacheStale === true, }; } catch (error) { console.warn('Failed to fetch installed apps', error); return { apps: [], success: false, hasCache: false, isCacheStale: false }; } };