Files
openchamber/packages/ui/src/lib/desktop.ts
T
Serhii Dziupin c84305bf7e Merge pull request #2501 from openchamber/feat/linux-appimage-tray-icons-b6fc
fix(desktop): Linux AppImage tray Show/Hide/Close and system file-manager icons
2026-07-29 16:22:39 +03:00

981 lines
31 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import type { ProjectEntry, 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';
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;
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;
usageAutoRefresh?: boolean;
usageRefreshIntervalMs?: number;
usageDisplayMode?: 'usage' | 'remaining';
usageShowPredValues?: boolean;
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;
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"
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;
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;
macVibrancy?: boolean;
macVibrancySupported?: boolean;
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;
};
/** Width of the three in-app window control buttons when placed on the left (3 × w-8). */
export const DESKTOP_WINDOW_CONTROLS_WIDTH_PX = 96;
/** 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();
};
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();
};
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 (hasDesktopInvoke() && isDesktopLocalOriginActive()) {
try {
const selected = await getDesktopBridge()?.openDialog?.({
directory: true,
multiple: false,
title: 'Select Working Directory',
});
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: true, path: directoryPath };
};
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 };
}
};