Files
openchamber/packages/ui/src/lib/desktop.ts
T

1016 lines
32 KiB
TypeScript

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<string, string[]>; // Map of providerId -> selected model names
usageCollapsedFamilies?: Record<string, string[]>; // Map of providerId -> collapsed family IDs (UsagePage)
usageExpandedFamilies?: Record<string, string[]>; // Map of providerId -> EXPANDED family IDs (header dropdown - inverted)
usageModelGroups?: Record<string, {
customGroups?: Array<{id: string; label: string; models: string[]; order: number}>;
modelAssignments?: Record<string, string>; // modelName -> groupId
renamedGroups?: Record<string, string>; // 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<string, string>;
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<string, string>;
favoriteModels?: Array<{ providerID: string; modelID: string }>;
hiddenModels?: Array<{ providerID: string; modelID: string }>;
collapsedModelProviders?: string[];
recentModels?: Array<{ providerID: string; modelID: string }>;
recentAgents?: string[];
recentEfforts?: Record<string, string[]>;
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<string, unknown>) => Promise<unknown>;
openDialog?: (options: Record<string, unknown>) => Promise<unknown>;
grantFileAccess?: (path: string) => Promise<unknown>;
openExternal?: (url: string) => Promise<unknown>;
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 <T = unknown>(command: string, args?: Record<string, unknown>): Promise<T | null> => {
const bridge = getDesktopBridge();
if (typeof bridge?.invoke !== 'function') return null;
return bridge.invoke(command, args ?? {}) as Promise<T>;
};
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<LaunchAtLoginStatus | null> => {
if (!canUseElectronDesktopIPC() || !isDesktopLocalOriginActive()) {
return null;
}
try {
const result = await invokeDesktop<LaunchAtLoginStatus>('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<LaunchAtLoginStatus | null> => {
if (!canUseElectronDesktopIPC() || !isDesktopLocalOriginActive()) {
return null;
}
try {
const result = await invokeDesktop<LaunchAtLoginStatus>('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<MinimizeToTrayStatus | null> => {
if (!canUseElectronDesktopIPC() || !isDesktopLocalOriginActive()) {
return null;
}
try {
const result = await invokeDesktop<MinimizeToTrayStatus>('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<MinimizeToTrayStatus | null> => {
if (!canUseElectronDesktopIPC() || !isDesktopLocalOriginActive()) {
return null;
}
try {
const result = await invokeDesktop<MinimizeToTrayStatus>('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<KeepAwakeStatus | null> => {
if (!canUseElectronDesktopIPC() || !isDesktopLocalOriginActive()) {
return null;
}
try {
const result = await invokeDesktop<KeepAwakeStatus>('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<KeepAwakeStatus | null> => {
if (!canUseElectronDesktopIPC() || !isDesktopLocalOriginActive()) {
return null;
}
try {
const result = await invokeDesktop<KeepAwakeStatus>('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<boolean> => {
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<boolean> => {
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<string | null> => {
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<UpdateInfo | null> => {
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<UpdateInfo>('desktop_check_for_updates');
return info as UpdateInfo;
};
export const downloadDesktopUpdate = async (
onProgress?: (progress: UpdateProgress) => void
): Promise<boolean> => {
if (!hasDesktopInvoke()) {
return false;
}
const bridge = getDesktopBridge();
let unlisten: null | (() => void | Promise<void>) = 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<string, unknown>) : 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<boolean> => {
if (!hasDesktopInvoke()) {
return false;
}
return restartDesktopApp();
};
export const restartDesktopApp = async (): Promise<boolean> => {
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<string | null> => {
if (!hasDesktopInvoke() || !isDesktopLocalOriginActive()) {
return null;
}
try {
const result = await invokeDesktop<string>('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<boolean> => {
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<boolean> => {
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<string | null> => {
if (!hasDesktopInvoke() || !isDesktopLocalOriginActive()) {
return null;
}
const trimmedFileName = defaultFileName?.trim();
if (!trimmedFileName) {
return null;
}
try {
const result = await invokeDesktop<string>('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<boolean> => {
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<boolean> => {
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<FetchDesktopInstalledAppsResult> => {
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<unknown>('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 };
}
};