2026-08-22 19:26:20 +02:00
|
|
|
import { z } from 'zod';
|
2026-09-07 17:50:55 +03:00
|
|
|
import type { RuntimeAPIs } from '@/lib/api/types';
|
2026-06-02 00:43:05 +03:00
|
|
|
import { getInjectedBootOutcome } from '@/lib/desktopBoot';
|
|
|
|
|
import { getRuntimeApiBaseUrl, getRuntimeKey } from '@/lib/runtime-switch';
|
|
|
|
|
import { getRegisteredRuntimeAPIs } from '@/contexts/runtimeAPIRegistry';
|
2026-07-27 10:58:33 +00:00
|
|
|
import { isVSCodeBootstrapPresent } from '@/lib/vscodeBootstrap';
|
2026-01-06 21:31:04 +02:00
|
|
|
|
2025-12-07 19:32:53 +02:00
|
|
|
export type UpdateInfo = {
|
|
|
|
|
available: boolean;
|
|
|
|
|
version?: string;
|
|
|
|
|
currentVersion: string;
|
|
|
|
|
body?: string;
|
|
|
|
|
date?: string;
|
2026-07-07 13:39:50 +03:00
|
|
|
releaseUrl?: string;
|
|
|
|
|
downloadUrl?: string;
|
2026-03-15 23:14:37 +02:00
|
|
|
nextSuggestedCheckInSec?: number;
|
2025-12-19 19:57:18 +02:00
|
|
|
// Web-specific fields
|
|
|
|
|
packageManager?: string;
|
|
|
|
|
updateCommand?: string;
|
2025-12-07 19:32:53 +02:00
|
|
|
};
|
|
|
|
|
|
|
|
|
|
export type UpdateProgress = {
|
|
|
|
|
downloaded: number;
|
|
|
|
|
total?: number;
|
|
|
|
|
};
|
|
|
|
|
|
2026-09-07 17:50:55 +03:00
|
|
|
export type { SkillCatalogConfig } from '@/lib/settings/parsers';
|
2025-12-30 21:03:16 +02:00
|
|
|
|
2026-07-26 13:39:46 +03:00
|
|
|
export type DesktopWindowControlsPosition = 'left' | 'right';
|
2026-07-13 08:59:31 +03:00
|
|
|
export type DesktopWindowControlsSide = 'left' | 'right';
|
2026-07-26 13:39:46 +03:00
|
|
|
export type DesktopWindowControlAction = 'close' | 'minimize' | 'maximize';
|
2026-07-29 13:49:12 +02:00
|
|
|
// No fixed-width constant: control width depends on the style (classic vs traffic-lights).
|
2026-07-29 00:07:04 +02:00
|
|
|
export type DesktopWindowControlsStyle = 'classic' | 'traffic-lights';
|
2026-07-13 08:59:31 +03:00
|
|
|
|
2026-09-07 17:50:55 +03:00
|
|
|
// The settings document is defined once, in the registry, and re-exported here
|
|
|
|
|
// so the many existing importers keep their path.
|
|
|
|
|
export type { DesktopSettings } from '@/lib/settings/registry';
|
2025-12-07 19:32:53 +02:00
|
|
|
|
2026-06-03 02:42:00 +03:00
|
|
|
type DesktopBridgeGlobal = {
|
|
|
|
|
invoke?: (cmd: string, args?: Record<string, unknown>) => Promise<unknown>;
|
|
|
|
|
openDialog?: (options: Record<string, unknown>) => Promise<unknown>;
|
2026-06-12 18:24:07 +03:00
|
|
|
grantFileAccess?: (path: string) => Promise<unknown>;
|
2026-06-03 02:42:00 +03:00
|
|
|
openExternal?: (url: string) => Promise<unknown>;
|
|
|
|
|
listen?: (
|
|
|
|
|
event: string,
|
|
|
|
|
handler: (evt: { payload?: unknown }) => void,
|
|
|
|
|
) => Promise<() => void>;
|
2025-12-07 19:32:53 +02:00
|
|
|
};
|
|
|
|
|
|
2026-04-20 15:41:15 +03:00
|
|
|
type ElectronRuntimeGlobal = {
|
|
|
|
|
runtime?: string;
|
2026-07-28 14:56:15 +03:00
|
|
|
arch?: string;
|
2026-07-22 10:08:58 +03:00
|
|
|
trayEnabled?: boolean;
|
2026-04-20 15:41:15 +03:00
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const getElectronRuntime = (): ElectronRuntimeGlobal | null => {
|
|
|
|
|
if (typeof window === 'undefined') return null;
|
|
|
|
|
return (window as unknown as { __OPENCHAMBER_ELECTRON__?: ElectronRuntimeGlobal }).__OPENCHAMBER_ELECTRON__ ?? null;
|
|
|
|
|
};
|
|
|
|
|
|
2026-06-03 02:42:00 +03:00
|
|
|
const getDesktopBridge = (): DesktopBridgeGlobal | null => {
|
|
|
|
|
if (typeof window === 'undefined') return null;
|
|
|
|
|
return (window as unknown as { __OPENCHAMBER_DESKTOP__?: DesktopBridgeGlobal }).__OPENCHAMBER_DESKTOP__ ?? null;
|
2026-02-05 01:59:49 +02:00
|
|
|
};
|
|
|
|
|
|
2026-04-20 15:41:15 +03:00
|
|
|
export const isElectronShell = (): boolean => getElectronRuntime()?.runtime === 'electron';
|
|
|
|
|
|
2026-08-13 15:30:54 +03:00
|
|
|
const getElectronPlatform = (): string | null => {
|
2026-07-13 08:59:31 +03:00
|
|
|
if (typeof window === 'undefined') return null;
|
|
|
|
|
const platform = (window as unknown as { __OPENCHAMBER_PLATFORM__?: string }).__OPENCHAMBER_PLATFORM__;
|
|
|
|
|
return typeof platform === 'string' ? platform : null;
|
|
|
|
|
};
|
|
|
|
|
|
2026-07-26 13:39:46 +03:00
|
|
|
/** Default side for in-app window controls (Windows-style, right). */
|
|
|
|
|
export const DEFAULT_DESKTOP_WINDOW_CONTROLS_POSITION: DesktopWindowControlsPosition = 'right';
|
|
|
|
|
|
2026-07-13 08:59:31 +03:00
|
|
|
/** 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';
|
|
|
|
|
};
|
|
|
|
|
|
2026-07-26 13:39:46 +03:00
|
|
|
/** 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;
|
2026-07-13 08:59:31 +03:00
|
|
|
}
|
2026-07-26 13:39:46 +03:00
|
|
|
return undefined;
|
2026-07-13 08:59:31 +03:00
|
|
|
};
|
|
|
|
|
|
|
|
|
|
export const resolveDesktopWindowControlsSide = (
|
|
|
|
|
preference: DesktopWindowControlsPosition | undefined,
|
|
|
|
|
): DesktopWindowControlsSide => {
|
2026-07-26 13:39:46 +03:00
|
|
|
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'];
|
2026-07-13 08:59:31 +03:00
|
|
|
};
|
|
|
|
|
|
2026-05-08 12:22:59 +03:00
|
|
|
export const hasDesktopInvoke = (): boolean => {
|
2026-06-03 02:42:00 +03:00
|
|
|
return typeof getDesktopBridge()?.invoke === 'function';
|
2026-05-08 12:22:59 +03:00
|
|
|
};
|
|
|
|
|
|
|
|
|
|
export const canUseElectronDesktopIPC = (): boolean => isElectronShell() && hasDesktopInvoke();
|
|
|
|
|
|
|
|
|
|
export const invokeDesktop = async <T = unknown>(command: string, args?: Record<string, unknown>): Promise<T | null> => {
|
2026-06-03 02:42:00 +03:00
|
|
|
const bridge = getDesktopBridge();
|
|
|
|
|
if (typeof bridge?.invoke !== 'function') return null;
|
|
|
|
|
return bridge.invoke(command, args ?? {}) as Promise<T>;
|
2026-05-08 12:22:59 +03:00
|
|
|
};
|
|
|
|
|
|
2026-05-26 01:36:11 +03:00
|
|
|
type LaunchAtLoginStatus = {
|
|
|
|
|
supported: boolean;
|
|
|
|
|
enabled: boolean;
|
|
|
|
|
};
|
|
|
|
|
|
2026-07-01 19:07:20 +03:00
|
|
|
type KeepAwakeStatus = {
|
|
|
|
|
supported: boolean;
|
|
|
|
|
enabled: boolean;
|
|
|
|
|
active: boolean;
|
|
|
|
|
};
|
|
|
|
|
|
2026-07-10 17:40:48 +08:00
|
|
|
type MinimizeToTrayStatus = {
|
|
|
|
|
supported: boolean;
|
|
|
|
|
enabled: boolean;
|
|
|
|
|
};
|
|
|
|
|
|
2026-05-26 01:36:11 +03:00
|
|
|
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;
|
2026-07-10 17:40:48 +08:00
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
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;
|
2026-05-26 01:36:11 +03:00
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
2026-07-01 19:07:20 +03:00
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
2026-02-05 01:59:49 +02:00
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
2026-02-28 16:00:10 +02:00
|
|
|
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';
|
|
|
|
|
};
|
|
|
|
|
|
2026-02-05 01:59:49 +02:00
|
|
|
export const isDesktopLocalOriginActive = (): boolean => {
|
|
|
|
|
if (typeof window === 'undefined') return false;
|
2026-04-20 15:41:15 +03:00
|
|
|
if (!isDesktopShell()) return false;
|
|
|
|
|
|
2026-06-02 00:43:05 +03:00
|
|
|
if (getRuntimeKey() === 'local') {
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-05 01:59:49 +02:00
|
|
|
const local = typeof window.__OPENCHAMBER_LOCAL_ORIGIN__ === 'string' ? window.__OPENCHAMBER_LOCAL_ORIGIN__ : '';
|
2026-02-28 16:00:10 +02:00
|
|
|
const localUrl = parseUrl(local);
|
2026-06-02 00:43:05 +03:00
|
|
|
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)
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-28 16:00:10 +02:00
|
|
|
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)
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-05 01:59:49 +02:00
|
|
|
const localOrigin = normalizeOrigin(local);
|
|
|
|
|
const currentOrigin = normalizeOrigin(window.location.origin) || window.location.origin;
|
2026-04-20 15:41:15 +03:00
|
|
|
if (localOrigin && currentOrigin && localOrigin === currentOrigin) {
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return Boolean(currentUrl && isLoopbackHost(currentUrl.hostname));
|
2025-12-07 19:32:53 +02:00
|
|
|
};
|
|
|
|
|
|
2026-02-05 01:59:49 +02:00
|
|
|
export const isDesktopShell = (): boolean => {
|
|
|
|
|
if (typeof window === 'undefined') return false;
|
2026-06-03 02:42:00 +03:00
|
|
|
return isElectronShell();
|
2026-02-05 01:59:49 +02:00
|
|
|
};
|
2025-12-07 19:32:53 +02:00
|
|
|
|
2026-08-09 19:30:25 +03:00
|
|
|
/**
|
|
|
|
|
* 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;
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
2026-08-07 01:49:44 +03:00
|
|
|
export const canRequestNativeDirectoryAccess = (): boolean => (
|
|
|
|
|
isDesktopShell() && hasDesktopInvoke() && isDesktopLocalOriginActive()
|
|
|
|
|
);
|
|
|
|
|
|
2026-04-14 19:33:09 +03:00
|
|
|
export const startDesktopWindowDrag = async (): Promise<boolean> => {
|
2026-06-03 02:42:00 +03:00
|
|
|
if (!isDesktopShell()) {
|
2026-04-14 19:33:09 +03:00
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
try {
|
2026-06-03 02:42:00 +03:00
|
|
|
await invokeDesktop('desktop_start_window_drag');
|
2026-04-14 19:33:09 +03:00
|
|
|
return true;
|
|
|
|
|
} catch {
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
2025-12-13 16:34:17 +02:00
|
|
|
export const isVSCodeRuntime = (): boolean => {
|
2026-07-27 10:58:33 +00:00
|
|
|
// Prefer extension-host bootstrap config: it is injected in webview HTML
|
|
|
|
|
// before any store module evaluates, so startup does not depend on
|
|
|
|
|
// RuntimeAPIs registration order (see #2359).
|
|
|
|
|
if (isVSCodeBootstrapPresent()) {
|
|
|
|
|
return true;
|
|
|
|
|
}
|
2026-06-02 00:43:05 +03:00
|
|
|
const apis = getRegisteredRuntimeAPIs();
|
2025-12-13 16:34:17 +02:00
|
|
|
return apis?.runtime?.isVSCode === true;
|
|
|
|
|
};
|
|
|
|
|
|
2025-12-19 19:57:18 +02:00
|
|
|
export const isWebRuntime = (): boolean => {
|
2026-06-02 00:43:05 +03:00
|
|
|
const apis = getRegisteredRuntimeAPIs();
|
2026-02-05 01:59:49 +02:00
|
|
|
const platform = apis?.runtime?.platform;
|
|
|
|
|
if (platform === 'web') {
|
|
|
|
|
return true;
|
2025-12-07 19:32:53 +02:00
|
|
|
}
|
2026-02-05 01:59:49 +02:00
|
|
|
if (platform === 'desktop' || platform === 'vscode') {
|
|
|
|
|
return false;
|
2025-12-07 19:32:53 +02:00
|
|
|
}
|
2026-02-05 01:59:49 +02:00
|
|
|
// Default: anything that's not VSCode behaves like web (HTTP UI).
|
|
|
|
|
return !isVSCodeRuntime();
|
2025-12-07 19:32:53 +02:00
|
|
|
};
|
|
|
|
|
|
2026-08-03 17:11:41 +03:00
|
|
|
/**
|
|
|
|
|
* 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;
|
|
|
|
|
|
2025-12-07 19:32:53 +02:00
|
|
|
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
|
2026-01-06 21:31:04 +02:00
|
|
|
): Promise<{ success: boolean; path?: string; projectId?: string; error?: string }> => {
|
2026-02-11 20:07:44 +02:00
|
|
|
// Desktop shell on local instance: use native folder picker.
|
2026-08-07 01:49:44 +03:00
|
|
|
if (canRequestNativeDirectoryAccess()) {
|
2026-02-05 01:59:49 +02:00
|
|
|
try {
|
2026-06-03 02:42:00 +03:00
|
|
|
const selected = await getDesktopBridge()?.openDialog?.({
|
2026-02-05 01:59:49 +02:00
|
|
|
directory: true,
|
|
|
|
|
multiple: false,
|
|
|
|
|
title: 'Select Working Directory',
|
2026-08-07 01:49:44 +03:00
|
|
|
...(directoryPath ? { defaultPath: directoryPath } : {}),
|
2026-02-05 01:59:49 +02:00
|
|
|
});
|
|
|
|
|
if (!selected || typeof selected !== 'string') {
|
|
|
|
|
return { success: false, error: 'Directory selection cancelled' };
|
|
|
|
|
}
|
|
|
|
|
return { success: true, path: selected };
|
|
|
|
|
} catch (error) {
|
2026-06-03 02:42:00 +03:00
|
|
|
console.warn('Failed to request directory access', error);
|
2026-02-05 01:59:49 +02:00
|
|
|
return { success: false, error: error instanceof Error ? error.message : String(error) };
|
|
|
|
|
}
|
2025-12-07 19:32:53 +02:00
|
|
|
}
|
2026-02-05 01:59:49 +02:00
|
|
|
|
2026-08-07 01:49:44 +03:00
|
|
|
return { success: false, error: 'Native directory picker not available' };
|
2025-12-07 19:32:53 +02:00
|
|
|
};
|
|
|
|
|
|
2026-06-12 18:24:07 +03:00
|
|
|
const isDesktopFileGrantResult = (
|
|
|
|
|
value: unknown
|
|
|
|
|
): value is { path?: unknown; outsideFileGrant?: unknown } => (
|
|
|
|
|
value !== null && typeof value === 'object' && !Array.isArray(value)
|
|
|
|
|
);
|
|
|
|
|
|
2026-08-22 19:26:20 +02:00
|
|
|
const desktopExistingFileGrantSchema = z.object({
|
|
|
|
|
path: z.string().min(1),
|
|
|
|
|
outsideFileGrant: z.string().min(1),
|
|
|
|
|
expiresAt: z.number().finite(),
|
|
|
|
|
});
|
|
|
|
|
|
2026-03-12 19:40:22 +02:00
|
|
|
export const requestFileAccess = async (
|
2026-05-17 23:26:26 +03:00
|
|
|
options?: { filters?: Array<{ name: string; extensions: string[] }>; defaultPath?: string }
|
2026-06-12 18:24:07 +03:00
|
|
|
): Promise<{ success: boolean; path?: string; outsideFileGrant?: string; error?: string }> => {
|
2026-06-03 02:42:00 +03:00
|
|
|
if (hasDesktopInvoke() && isDesktopLocalOriginActive()) {
|
2026-03-12 19:40:22 +02:00
|
|
|
try {
|
2026-06-03 02:42:00 +03:00
|
|
|
const selected = await getDesktopBridge()?.openDialog?.({
|
2026-03-12 19:40:22 +02:00
|
|
|
directory: false,
|
|
|
|
|
multiple: false,
|
|
|
|
|
title: 'Select File',
|
2026-06-12 18:24:07 +03:00
|
|
|
returnGrant: true,
|
2026-03-12 19:40:22 +02:00
|
|
|
...(options?.filters ? { filters: options.filters } : {}),
|
2026-05-17 23:26:26 +03:00
|
|
|
...(options?.defaultPath ? { defaultPath: options.defaultPath } : {}),
|
2026-03-12 19:40:22 +02:00
|
|
|
});
|
2026-06-12 18:24:07 +03:00
|
|
|
if (!selected) {
|
2026-03-12 19:40:22 +02:00
|
|
|
return { success: false, error: 'File selection cancelled' };
|
|
|
|
|
}
|
2026-06-12 18:24:07 +03:00
|
|
|
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,
|
|
|
|
|
};
|
2026-03-12 19:40:22 +02:00
|
|
|
} catch (error) {
|
2026-06-03 02:42:00 +03:00
|
|
|
console.warn('Failed to request file access', error);
|
2026-03-12 19:40:22 +02:00
|
|
|
return { success: false, error: error instanceof Error ? error.message : String(error) };
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return { success: false, error: 'Native file picker not available' };
|
|
|
|
|
};
|
|
|
|
|
|
2026-06-12 18:24:07 +03:00
|
|
|
export const requestExistingFileAccess = async (
|
|
|
|
|
path: string
|
2026-08-22 19:26:20 +02:00
|
|
|
): Promise<
|
|
|
|
|
| { success: true; path: string; outsideFileGrant: string; expiresAt: number }
|
|
|
|
|
| { success: false; error: string }
|
|
|
|
|
> => {
|
2026-06-12 18:24:07 +03:00
|
|
|
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);
|
2026-08-22 19:26:20 +02:00
|
|
|
const parsed = desktopExistingFileGrantSchema.safeParse(selected);
|
|
|
|
|
if (!parsed.success) {
|
2026-06-12 18:24:07 +03:00
|
|
|
return { success: false, error: 'File access was not granted' };
|
|
|
|
|
}
|
2026-08-22 19:26:20 +02:00
|
|
|
return {
|
|
|
|
|
success: true,
|
|
|
|
|
...parsed.data,
|
|
|
|
|
};
|
2026-06-12 18:24:07 +03:00
|
|
|
} catch (error) {
|
|
|
|
|
console.warn('Failed to request existing file access', error);
|
|
|
|
|
return { success: false, error: error instanceof Error ? error.message : String(error) };
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
2025-12-07 19:32:53 +02:00
|
|
|
export const startAccessingDirectory = async (
|
|
|
|
|
directoryPath: string
|
|
|
|
|
): Promise<{ success: boolean; error?: string }> => {
|
2026-02-05 01:59:49 +02:00
|
|
|
void directoryPath;
|
|
|
|
|
return { success: true };
|
2025-12-07 19:32:53 +02:00
|
|
|
};
|
|
|
|
|
|
|
|
|
|
export const stopAccessingDirectory = async (
|
|
|
|
|
directoryPath: string
|
|
|
|
|
): Promise<{ success: boolean; error?: string }> => {
|
2026-02-05 01:59:49 +02:00
|
|
|
void directoryPath;
|
|
|
|
|
return { success: true };
|
2025-12-07 19:32:53 +02:00
|
|
|
};
|
|
|
|
|
|
|
|
|
|
export const checkForDesktopUpdates = async (): Promise<UpdateInfo | null> => {
|
2026-06-03 02:42:00 +03:00
|
|
|
if (!hasDesktopInvoke()) {
|
2025-12-07 19:32:53 +02:00
|
|
|
return null;
|
|
|
|
|
}
|
2026-02-05 01:59:49 +02:00
|
|
|
|
2026-07-24 09:59:32 +03:00
|
|
|
// 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.
|
2026-07-13 08:59:31 +03:00
|
|
|
const info = await invokeDesktop<UpdateInfo>('desktop_check_for_updates');
|
|
|
|
|
return info as UpdateInfo;
|
2025-12-07 19:32:53 +02:00
|
|
|
};
|
|
|
|
|
|
|
|
|
|
export const downloadDesktopUpdate = async (
|
|
|
|
|
onProgress?: (progress: UpdateProgress) => void
|
|
|
|
|
): Promise<boolean> => {
|
2026-06-03 02:42:00 +03:00
|
|
|
if (!hasDesktopInvoke()) {
|
2025-12-07 19:32:53 +02:00
|
|
|
return false;
|
|
|
|
|
}
|
2026-02-05 01:59:49 +02:00
|
|
|
|
2026-06-03 02:42:00 +03:00
|
|
|
const bridge = getDesktopBridge();
|
2026-02-05 01:59:49 +02:00
|
|
|
let unlisten: null | (() => void | Promise<void>) = null;
|
|
|
|
|
let downloaded = 0;
|
|
|
|
|
let total: number | undefined;
|
|
|
|
|
|
2025-12-07 19:32:53 +02:00
|
|
|
try {
|
2026-06-03 02:42:00 +03:00
|
|
|
if (typeof onProgress === 'function' && bridge?.listen) {
|
|
|
|
|
unlisten = await bridge.listen('openchamber:update-progress', (evt) => {
|
2026-02-05 01:59:49 +02:00
|
|
|
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 });
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-03 02:42:00 +03:00
|
|
|
await invokeDesktop('desktop_download_and_install_update');
|
2025-12-07 19:32:53 +02:00
|
|
|
return true;
|
|
|
|
|
} catch (error) {
|
2026-07-13 08:59:31 +03:00
|
|
|
// Propagate actionable updater capability / install errors to the UI store.
|
|
|
|
|
throw error instanceof Error ? error : new Error(String(error));
|
2026-02-05 01:59:49 +02:00
|
|
|
} finally {
|
|
|
|
|
if (unlisten) {
|
|
|
|
|
try {
|
|
|
|
|
const result = unlisten();
|
|
|
|
|
if (result instanceof Promise) {
|
|
|
|
|
await result;
|
|
|
|
|
}
|
|
|
|
|
} catch {
|
|
|
|
|
// ignored
|
|
|
|
|
}
|
|
|
|
|
}
|
2025-12-07 19:32:53 +02:00
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
export const restartToApplyUpdate = async (): Promise<boolean> => {
|
2026-06-03 02:42:00 +03:00
|
|
|
if (!hasDesktopInvoke()) {
|
2025-12-07 19:32:53 +02:00
|
|
|
return false;
|
|
|
|
|
}
|
2026-02-05 01:59:49 +02:00
|
|
|
|
2026-08-28 14:47:09 +03:00
|
|
|
// Unlike a plain restart, an install failure (rejected signature, disabled
|
|
|
|
|
// updater session) must reach the update dialog instead of being reduced to
|
|
|
|
|
// a boolean the caller cannot explain.
|
|
|
|
|
await invokeDesktop('desktop_restart');
|
|
|
|
|
return true;
|
2026-04-15 01:32:59 +08:00
|
|
|
};
|
|
|
|
|
|
|
|
|
|
export const restartDesktopApp = async (): Promise<boolean> => {
|
2026-06-03 02:42:00 +03:00
|
|
|
if (!hasDesktopInvoke()) {
|
2026-04-15 01:32:59 +08:00
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-07 19:32:53 +02:00
|
|
|
try {
|
2026-06-03 02:42:00 +03:00
|
|
|
await invokeDesktop('desktop_restart');
|
2025-12-07 19:32:53 +02:00
|
|
|
return true;
|
|
|
|
|
} catch (error) {
|
2026-06-03 02:42:00 +03:00
|
|
|
console.warn('Failed to restart desktop app', error);
|
2025-12-07 19:32:53 +02:00
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
};
|
2026-02-08 04:32:14 +02:00
|
|
|
|
2026-04-17 18:05:53 +03:00
|
|
|
export const getDesktopLanAddress = async (): Promise<string | null> => {
|
2026-06-03 02:42:00 +03:00
|
|
|
if (!hasDesktopInvoke() || !isDesktopLocalOriginActive()) {
|
2026-04-17 18:05:53 +03:00
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
try {
|
2026-06-03 02:42:00 +03:00
|
|
|
const result = await invokeDesktop<string>('desktop_get_lan_address');
|
2026-04-17 18:05:53 +03:00
|
|
|
return typeof result === 'string' && result.trim().length > 0 ? result.trim() : null;
|
|
|
|
|
} catch (error) {
|
2026-06-03 02:42:00 +03:00
|
|
|
console.warn('Failed to get desktop LAN address', error);
|
2026-04-17 18:05:53 +03:00
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
2026-02-08 04:32:14 +02:00
|
|
|
export const openDesktopPath = async (path: string, app?: string | null): Promise<boolean> => {
|
2026-06-03 02:42:00 +03:00
|
|
|
if (!hasDesktopInvoke() || !isDesktopLocalOriginActive()) {
|
2026-02-08 04:32:14 +02:00
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const trimmed = path?.trim();
|
|
|
|
|
if (!trimmed) {
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
try {
|
2026-06-03 02:42:00 +03:00
|
|
|
await invokeDesktop('desktop_open_path', {
|
2026-02-08 04:32:14 +02:00
|
|
|
path: trimmed,
|
|
|
|
|
app: typeof app === 'string' && app.trim().length > 0 ? app.trim() : undefined,
|
|
|
|
|
});
|
|
|
|
|
return true;
|
|
|
|
|
} catch (error) {
|
2026-06-03 02:42:00 +03:00
|
|
|
console.warn('Failed to open path', error);
|
2026-02-08 04:32:14 +02:00
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
2026-04-20 15:41:15 +03:00
|
|
|
export const revealDesktopPath = async (path: string): Promise<boolean> => {
|
2026-06-03 02:42:00 +03:00
|
|
|
if (!hasDesktopInvoke() || !isDesktopLocalOriginActive()) {
|
2026-04-20 15:41:15 +03:00
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const trimmed = path?.trim();
|
|
|
|
|
if (!trimmed) {
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
try {
|
2026-06-03 02:42:00 +03:00
|
|
|
await invokeDesktop('desktop_reveal_path', {
|
2026-04-20 15:41:15 +03:00
|
|
|
path: trimmed,
|
|
|
|
|
});
|
|
|
|
|
return true;
|
|
|
|
|
} catch {
|
|
|
|
|
return openDesktopPath(trimmed);
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
2026-04-17 15:33:26 +03:00
|
|
|
export const saveDesktopMarkdownFile = async (
|
|
|
|
|
defaultFileName: string,
|
|
|
|
|
content: string,
|
|
|
|
|
): Promise<string | null> => {
|
2026-06-03 02:42:00 +03:00
|
|
|
if (!hasDesktopInvoke() || !isDesktopLocalOriginActive()) {
|
2026-04-17 15:33:26 +03:00
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const trimmedFileName = defaultFileName?.trim();
|
|
|
|
|
if (!trimmedFileName) {
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
try {
|
2026-06-03 02:42:00 +03:00
|
|
|
const result = await invokeDesktop<string>('desktop_save_markdown_file', {
|
2026-04-17 15:33:26 +03:00
|
|
|
defaultFileName: trimmedFileName,
|
|
|
|
|
content,
|
|
|
|
|
});
|
|
|
|
|
return typeof result === 'string' && result.trim().length > 0 ? result : null;
|
|
|
|
|
} catch (error) {
|
2026-06-03 02:42:00 +03:00
|
|
|
console.warn('Failed to save markdown file', error);
|
2026-04-17 15:33:26 +03:00
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
2026-03-03 01:07:03 +02:00
|
|
|
export const openDesktopProjectInApp = async (
|
|
|
|
|
projectPath: string,
|
|
|
|
|
appId: string,
|
|
|
|
|
appName: string,
|
|
|
|
|
): Promise<boolean> => {
|
2026-06-03 02:42:00 +03:00
|
|
|
if (!hasDesktopInvoke() || !isDesktopLocalOriginActive()) {
|
2026-03-03 01:07:03 +02:00
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const trimmedProjectPath = projectPath?.trim();
|
|
|
|
|
const trimmedAppId = appId?.trim();
|
|
|
|
|
const trimmedAppName = appName?.trim();
|
|
|
|
|
|
|
|
|
|
if (!trimmedProjectPath || !trimmedAppId || !trimmedAppName) {
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
try {
|
2026-06-03 02:42:00 +03:00
|
|
|
await invokeDesktop('desktop_open_in_app', {
|
2026-03-03 01:07:03 +02:00
|
|
|
projectPath: trimmedProjectPath,
|
|
|
|
|
appId: trimmedAppId,
|
|
|
|
|
appName: trimmedAppName,
|
|
|
|
|
});
|
|
|
|
|
return true;
|
|
|
|
|
} catch (error) {
|
2026-04-20 15:41:15 +03:00
|
|
|
console.warn('Failed to open project in app', error);
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
export const openDesktopFileInApp = async (
|
|
|
|
|
filePath: string,
|
|
|
|
|
appId: string,
|
|
|
|
|
appName: string,
|
|
|
|
|
): Promise<boolean> => {
|
2026-06-03 02:42:00 +03:00
|
|
|
if (!hasDesktopInvoke() || !isDesktopLocalOriginActive()) {
|
2026-04-20 15:41:15 +03:00
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const trimmedFilePath = filePath?.trim();
|
|
|
|
|
const trimmedAppId = appId?.trim();
|
|
|
|
|
const trimmedAppName = appName?.trim();
|
|
|
|
|
|
|
|
|
|
if (!trimmedFilePath || !trimmedAppId || !trimmedAppName) {
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
try {
|
2026-06-03 02:42:00 +03:00
|
|
|
await invokeDesktop('desktop_open_file_in_app', {
|
2026-04-20 15:41:15 +03:00
|
|
|
filePath: trimmedFilePath,
|
|
|
|
|
appId: trimmedAppId,
|
|
|
|
|
appName: trimmedAppName,
|
|
|
|
|
});
|
|
|
|
|
return true;
|
|
|
|
|
} catch (error) {
|
|
|
|
|
console.warn('Failed to open file in app', error);
|
2026-03-03 01:07:03 +02:00
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
2026-02-08 04:32:14 +02:00
|
|
|
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> => {
|
2026-06-03 02:42:00 +03:00
|
|
|
if (!hasDesktopInvoke() || !isDesktopLocalOriginActive()) {
|
2026-02-08 04:32:14 +02:00
|
|
|
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 {
|
2026-06-03 02:42:00 +03:00
|
|
|
const result = await invokeDesktop<unknown>('desktop_get_installed_apps', {
|
2026-02-08 04:32:14 +02:00
|
|
|
apps: candidate,
|
|
|
|
|
force: force === true ? true : undefined,
|
|
|
|
|
});
|
|
|
|
|
if (!result || typeof result !== 'object') {
|
|
|
|
|
return { apps: [], success: false, hasCache: false, isCacheStale: false };
|
|
|
|
|
}
|
2026-07-13 08:59:31 +03:00
|
|
|
const payload = result as { apps?: unknown; hasCache?: unknown; isCacheStale?: unknown; supported?: unknown };
|
|
|
|
|
if (payload.supported === false) {
|
|
|
|
|
return { apps: [], success: true, hasCache: false, isCacheStale: false };
|
|
|
|
|
}
|
2026-02-08 04:32:14 +02:00
|
|
|
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) {
|
2026-06-03 02:42:00 +03:00
|
|
|
console.warn('Failed to fetch installed apps', error);
|
2026-02-08 04:32:14 +02:00
|
|
|
return { apps: [], success: false, hasCache: false, isCacheStale: false };
|
|
|
|
|
}
|
|
|
|
|
};
|