Add a shared OpenChamber control service with two thin adapters — a native `openchamber` tool injected into managed OpenCode, and new CLI commands — so users can manage parallel sessions, worktrees, and scheduled tasks conversationally through agents or from the terminal. Control plane: - New openchamber-control service owning a fixed action contract: projects.list, models.list, session list/create/send/fork/status/messages, and schedule list/create/run/delete/toggle. Session and worktree deletion and project registration are deliberately not exposed. - New openchamber-sessions module owning create/worktree/prompt orchestration, Goal Mode dispatch, wait semantics (initial idle never counts as completion; timeout and cancellation are failures), and explicit partial-failure results. - Scheduled-task logic extracted into a service shared by routes, CLI, and the agent tool. Agent tool: - Managed OpenCode gets a materialized plugin registering one typed tool with a loopback-only callback, per-child ephemeral bearer (timing-safe, never persisted or logged), and abort propagation into the service. - The ~1.5k-token schema applies progressive disclosure: short descriptions, server-side validation returning actionable usage errors, and intent guardrails — created sessions/tasks are user-facing work (not age self-delegation); worktree/goal/agent/variant/wait are omit-by-default; dispatches produce no completion notification, and later result r to session.messages, which now returns the authoritative sessionStatus. - session.create without a user-named model picks from favorites/re send/fork omit the selection and the service reuses the target session's last user-message model, agent, and variant before falling back t - An "Agent control tool" setting (default on, Save + Reload to apply) disables plugin injection entirely. CLI: - New `openchamber session`, `schedule`, `projects`, and `models` commands with automatic instance targeting, --wait/--timeout/--last-assist worktree flags, and Goal Mode, preserving interactive, non-TTY, --quiet, and --json contracts. The control HTTP timeout derives from the w instead of the 4-second default. UI: - New built-in "Schedule a Task" starter (/schedule-task) running a dialogue that defines a task and offers to create it via the tool after explicit confirmation; Craft a Goal and Feature Planning gain the handoff offer, and guided starters reserve the question tool for concrete option choices. Localized in all 10 locales, migrated into custom starter lists, hidden on VS Code. - Sidebar shows CLI/agent-created sessions live via the control eve - openchamber tool calls render with per-action titles and metadata.
964 lines
30 KiB
TypeScript
964 lines
30 KiB
TypeScript
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 = 'auto' | 'left' | 'right';
|
||
export type DesktopWindowControlsSide = 'left' | 'right';
|
||
|
||
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;
|
||
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[];
|
||
// 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;
|
||
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;
|
||
|
||
/** 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';
|
||
};
|
||
|
||
export const getDefaultDesktopWindowControlsSide = (platform: string | null = getElectronPlatform()): DesktopWindowControlsSide => {
|
||
if (platform === 'linux') {
|
||
return 'left';
|
||
}
|
||
return 'right';
|
||
};
|
||
|
||
export const resolveDesktopWindowControlsSide = (
|
||
preference: DesktopWindowControlsPosition | undefined,
|
||
platform: string | null = getElectronPlatform(),
|
||
): DesktopWindowControlsSide => {
|
||
if (preference === 'left' || preference === 'right') {
|
||
return preference;
|
||
}
|
||
return getDefaultDesktopWindowControlsSide(platform);
|
||
};
|
||
|
||
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 };
|
||
}
|
||
|
||
// Linux desktop does not resolve installed GUI apps; skip the IPC round-trip.
|
||
if (getElectronPlatform() === 'linux') {
|
||
return { apps: [], success: true, 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 };
|
||
}
|
||
};
|