* feat: implement desktop boot outcome architecture
- Add structured DesktopBootOutcome with target/status fields
- Implement boot outcome computation and validation
- Add desktop hosts configuration management (Tauri + TypeScript)
- Add desktop hosts probing with timeout and retry logic
- Support local/remote host classification and health checks
This provides the foundational infrastructure for desktop onboarding
flow to determine whether to show local setup, remote connection,
or recovery screens based on OpenCode availability and remote host
reachability.
* feat: add desktop onboarding UI components
Add comprehensive onboarding flow for desktop app:
- ChooserScreen: First-launch local/remote selection
- LocalSetupScreen: CLI installation guidance and manual detection
- RecoveryScreen: Recovery mode with routing to local/remote
- RemoteConnectionForm: Remote host connection with validation
- DesktopConnectionRecovery: Recovery variants and routing logic
- ConnectionSettingsPage: Manage remote connections
Components handle:
- Local vs remote choice persistence
- Recovery scenarios (unreachable, wrong-service, missing)
- Manual CLI detection (replaced auto-polling)
- Back navigation and state preservation
* feat: integrate desktop onboarding with app shell
- Update App.tsx to handle onboarding routing and recovery
- Add onboarding mode switching (first-launch/local-setup/recovery)
- Integrate desktop hosts in SettingsView
- Update DesktopHostSwitcher with recovery routing
- Add desktop shell utilities for onboarding detection
- Update web manifest for desktop app metadata
Completes the desktop onboarding feature integration,
allowing users to choose local or remote OpenCode on
first launch and recover from connection failures.
* fix: hide back button in remote connection form for first-launch chooser
In first-launch chooser mode, the back button is redundant since users
can simply click the "Local Install" tab. The back button is still shown
in recovery mode where there's no tab interface.
Changes:
- Add showBackButton prop to RemoteConnectionForm (default: true)
- Set showBackButton={false} in ChooserScreen remote tab
- Keep showBackButton={true} in RecoveryScreen for navigation
* refactor: remove Connection Settings page and simplify recovery UI
Remove the Connection Settings page as it was redundant:
- Local server is single-instance (no need to "choose")
- Remote servers are one-time setup (first-launch chooser)
- SSH Instances remain for multi-instance management
Changes:
- Remove ConnectionSettingsPage component and directory
- Remove 'connection' from Settings metadata
- Remove "Open Settings" button from recovery screens
- Remove desktopBootBypassToSettings state and logic
- Update recovery config to use 'local' icon instead of 'settings'
- Update tests to reflect removed showOpenSettings field
This simplifies the UX by focusing on:
- First-launch chooser for initial local/remote decision
- Remote Instances (SSH) for managing multiple remote machines
- No persistent "server management" needed for typical desktop usage
* fix: remove unused enableCliPolling prop and clean up TypeScript errors
Remove the obsolete enableCliPolling prop that was used for auto-
polling CLI detection. We replaced this with manual "Check and Continue"
button in a previous commit, so this prop is no longer needed.
Changes:
- Remove enableCliPolling from OnboardingScreen props and usage
- Remove enableCliPolling from App.tsx calls
- Remove unused 'connection' case from getSettingsNavIcon()
- Remove unused RiGlobalLine import
This resolves all TypeScript compilation errors reported by Copilot.
* fix: remove unused onChooseLocal prop and CLI_MISSING_ERROR_REGEX
These were left over from the refactoring:
- onChooseLocal in RecoveryScreen was defined but never used
- CLI_MISSING_ERROR_REGEX in App.tsx was leftover from removed enableCliPolling code
* fix: remove unused variables and fix React Hook dependency warnings
Remove unused memoized components and variables that were causing
lint errors in packages/ui:
- MainLayout.tsx: Remove unused MemoHeader, MemoChatView, MemoPlanView,
MemoGitView, MemoDiffView, MemoTerminalView, MemoFilesView,
MemoRightSidebarTabs, DesktopLeftSidebar, and DesktopRightPanel
- useGitHubPrStatusStore.ts: Remove unused prVisualPriority function
- useChatScrollManager.ts: Add missing markProgrammaticScroll dependency
to React.useEffect hook
These fixes resolve the CI lint failures in PR 850.
* chore: remove local claude settings from repo
* refactor(desktop): drop vibrancy code from onboarding PR
---------
Co-authored-by: Bohdan Triapitsyn <artmore@protonmail.com>
664 lines
21 KiB
TypeScript
664 lines
21 KiB
TypeScript
import type { ProjectEntry } from '@/lib/api/types';
|
|
|
|
export type AssistantNotificationPayload = {
|
|
title?: string;
|
|
body?: string;
|
|
};
|
|
|
|
export type UpdateInfo = {
|
|
available: boolean;
|
|
version?: string;
|
|
currentVersion: string;
|
|
body?: string;
|
|
date?: 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 ManagedRemoteTunnelPreset = {
|
|
id: string;
|
|
name: string;
|
|
hostname: string;
|
|
};
|
|
|
|
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;
|
|
projects?: ProjectEntry[];
|
|
activeProjectId?: string;
|
|
approvedDirectories?: string[];
|
|
securityScopedBookmarks?: string[];
|
|
pinnedDirectories?: string[];
|
|
showReasoningTraces?: 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';
|
|
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;
|
|
defaultGitIdentityId?: string; // ''/undefined = unset, 'global' or profile id
|
|
openInAppId?: string;
|
|
autoCreateWorktree?: boolean;
|
|
queueModeEnabled?: boolean;
|
|
gitmojiEnabled?: boolean;
|
|
zenModel?: string;
|
|
gitProviderId?: string;
|
|
gitModelId?: string;
|
|
pwaAppName?: string;
|
|
inputSpellcheckEnabled?: boolean;
|
|
showToolFileIcons?: boolean;
|
|
showExpandedBashTools?: boolean;
|
|
showExpandedEditTools?: boolean;
|
|
chatRenderMode?: 'sorted' | 'live';
|
|
activityRenderMode?: 'collapsed' | 'summary';
|
|
mermaidRenderingMode?: 'svg' | 'ascii';
|
|
userMessageRenderingMode?: 'markdown' | 'plain';
|
|
stickyUserHeader?: boolean;
|
|
fontSize?: number;
|
|
terminalFontSize?: number;
|
|
padding?: number;
|
|
cornerRadius?: number;
|
|
inputBarOffset?: number;
|
|
|
|
favoriteModels?: Array<{ providerID: string; modelID: string }>;
|
|
recentModels?: Array<{ providerID: string; modelID: string }>;
|
|
diffLayoutPreference?: 'dynamic' | 'inline' | 'side-by-side';
|
|
diffViewMode?: 'single' | 'stacked';
|
|
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;
|
|
};
|
|
|
|
type TauriGlobal = {
|
|
core?: {
|
|
invoke?: (cmd: string, args?: Record<string, unknown>) => Promise<unknown>;
|
|
};
|
|
dialog?: {
|
|
open?: (options: Record<string, unknown>) => Promise<unknown>;
|
|
};
|
|
event?: {
|
|
listen?: (
|
|
event: string,
|
|
handler: (evt: { payload?: unknown }) => void,
|
|
) => Promise<() => void>;
|
|
};
|
|
};
|
|
|
|
export const isTauriShell = (): boolean => {
|
|
if (typeof window === 'undefined') return false;
|
|
const tauri = (window as unknown as { __TAURI__?: TauriGlobal }).__TAURI__;
|
|
return typeof tauri?.core?.invoke === 'function';
|
|
};
|
|
|
|
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;
|
|
const local = typeof window.__OPENCHAMBER_LOCAL_ORIGIN__ === 'string' ? window.__OPENCHAMBER_LOCAL_ORIGIN__ : '';
|
|
const localUrl = parseUrl(local);
|
|
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;
|
|
return Boolean(localOrigin && currentOrigin && localOrigin === currentOrigin);
|
|
};
|
|
|
|
// Desktop shell detection that doesn't require Tauri IPC availability.
|
|
// (Remote pages can temporarily lose window.__TAURI__ if URL doesn't match remote allowlist.)
|
|
export const isDesktopShell = (): boolean => {
|
|
if (typeof window === 'undefined') return false;
|
|
if (typeof window.__OPENCHAMBER_LOCAL_ORIGIN__ === 'string' && window.__OPENCHAMBER_LOCAL_ORIGIN__.length > 0) {
|
|
return true;
|
|
}
|
|
return isTauriShell();
|
|
};
|
|
|
|
export const startDesktopWindowDrag = async (): Promise<boolean> => {
|
|
if (!isDesktopShell() || !isTauriShell()) {
|
|
return false;
|
|
}
|
|
|
|
try {
|
|
const { getCurrentWindow } = await import('@tauri-apps/api/window');
|
|
const appWindow = getCurrentWindow();
|
|
await appWindow.startDragging();
|
|
return true;
|
|
} catch {
|
|
return false;
|
|
}
|
|
};
|
|
|
|
export const isVSCodeRuntime = (): boolean => {
|
|
if (typeof window === "undefined") return false;
|
|
const apis = (window as { __OPENCHAMBER_RUNTIME_APIS__?: { runtime?: { isVSCode?: boolean } } }).__OPENCHAMBER_RUNTIME_APIS__;
|
|
return apis?.runtime?.isVSCode === true;
|
|
};
|
|
|
|
export const isWebRuntime = (): boolean => {
|
|
if (typeof window === "undefined") return false;
|
|
const apis = (window as { __OPENCHAMBER_RUNTIME_APIS__?: { runtime?: { platform?: string } } }).__OPENCHAMBER_RUNTIME_APIS__;
|
|
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 (isTauriShell() && isDesktopLocalOriginActive()) {
|
|
try {
|
|
const tauri = (window as unknown as { __TAURI__?: TauriGlobal }).__TAURI__;
|
|
const selected = await tauri?.dialog?.open?.({
|
|
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 (tauri)', error);
|
|
return { success: false, error: error instanceof Error ? error.message : String(error) };
|
|
}
|
|
}
|
|
|
|
return { success: true, path: directoryPath };
|
|
};
|
|
|
|
export const requestFileAccess = async (
|
|
options?: { filters?: Array<{ name: string; extensions: string[] }> }
|
|
): Promise<{ success: boolean; path?: string; error?: string }> => {
|
|
if (isTauriShell() && isDesktopLocalOriginActive()) {
|
|
try {
|
|
const tauri = (window as unknown as { __TAURI__?: TauriGlobal }).__TAURI__;
|
|
const selected = await tauri?.dialog?.open?.({
|
|
directory: false,
|
|
multiple: false,
|
|
title: 'Select File',
|
|
...(options?.filters ? { filters: options.filters } : {}),
|
|
});
|
|
if (!selected || typeof selected !== 'string') {
|
|
return { success: false, error: 'File selection cancelled' };
|
|
}
|
|
return { success: true, path: selected };
|
|
} catch (error) {
|
|
console.warn('Failed to request file access (tauri)', error);
|
|
return { success: false, error: error instanceof Error ? error.message : String(error) };
|
|
}
|
|
}
|
|
|
|
return { success: false, error: 'Native file picker not available' };
|
|
};
|
|
|
|
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 sendAssistantCompletionNotification = async (
|
|
payload?: AssistantNotificationPayload
|
|
): Promise<boolean> => {
|
|
if (isTauriShell()) {
|
|
try {
|
|
const tauri = (window as unknown as { __TAURI__?: TauriGlobal }).__TAURI__;
|
|
await tauri?.core?.invoke?.('desktop_notify', {
|
|
payload: {
|
|
title: payload?.title,
|
|
body: payload?.body,
|
|
tag: 'openchamber-agent-complete',
|
|
},
|
|
});
|
|
return true;
|
|
} catch (error) {
|
|
console.warn('Failed to send assistant completion notification (tauri)', error);
|
|
return false;
|
|
}
|
|
}
|
|
|
|
return false;
|
|
};
|
|
|
|
export const checkForDesktopUpdates = async (): Promise<UpdateInfo | null> => {
|
|
if (!isTauriShell() || !isDesktopLocalOriginActive()) {
|
|
return null;
|
|
}
|
|
|
|
try {
|
|
const tauri = (window as unknown as { __TAURI__?: TauriGlobal }).__TAURI__;
|
|
const info = await tauri?.core?.invoke?.('desktop_check_for_updates');
|
|
return info as UpdateInfo;
|
|
} catch (error) {
|
|
console.warn('Failed to check for updates (tauri)', error);
|
|
return null;
|
|
}
|
|
};
|
|
|
|
export const downloadDesktopUpdate = async (
|
|
onProgress?: (progress: UpdateProgress) => void
|
|
): Promise<boolean> => {
|
|
if (!isTauriShell() || !isDesktopLocalOriginActive()) {
|
|
return false;
|
|
}
|
|
|
|
const tauri = (window as unknown as { __TAURI__?: TauriGlobal }).__TAURI__;
|
|
let unlisten: null | (() => void | Promise<void>) = null;
|
|
let downloaded = 0;
|
|
let total: number | undefined;
|
|
|
|
try {
|
|
if (typeof onProgress === 'function' && tauri?.event?.listen) {
|
|
unlisten = await tauri.event.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 tauri?.core?.invoke?.('desktop_download_and_install_update');
|
|
return true;
|
|
} catch (error) {
|
|
console.warn('Failed to download update (tauri)', error);
|
|
return false;
|
|
} finally {
|
|
if (unlisten) {
|
|
try {
|
|
const result = unlisten();
|
|
if (result instanceof Promise) {
|
|
await result;
|
|
}
|
|
} catch {
|
|
// ignored
|
|
}
|
|
}
|
|
}
|
|
};
|
|
|
|
export const restartToApplyUpdate = async (): Promise<boolean> => {
|
|
if (!isTauriShell() || !isDesktopLocalOriginActive()) {
|
|
return false;
|
|
}
|
|
|
|
return restartDesktopApp();
|
|
};
|
|
|
|
export const restartDesktopApp = async (): Promise<boolean> => {
|
|
if (!isTauriShell()) {
|
|
return false;
|
|
}
|
|
|
|
try {
|
|
const tauri = (window as unknown as { __TAURI__?: TauriGlobal }).__TAURI__;
|
|
await tauri?.core?.invoke?.('desktop_restart');
|
|
return true;
|
|
} catch (error) {
|
|
console.warn('Failed to restart desktop app (tauri)', error);
|
|
return false;
|
|
}
|
|
};
|
|
|
|
export const openDesktopPath = async (path: string, app?: string | null): Promise<boolean> => {
|
|
if (!isTauriShell() || !isDesktopLocalOriginActive()) {
|
|
return false;
|
|
}
|
|
|
|
const trimmed = path?.trim();
|
|
if (!trimmed) {
|
|
return false;
|
|
}
|
|
|
|
try {
|
|
const tauri = (window as unknown as { __TAURI__?: TauriGlobal }).__TAURI__;
|
|
await tauri?.core?.invoke?.('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 (tauri)', error);
|
|
return false;
|
|
}
|
|
};
|
|
|
|
export const openDesktopProjectInApp = async (
|
|
projectPath: string,
|
|
appId: string,
|
|
appName: string,
|
|
filePath?: string | null,
|
|
): Promise<boolean> => {
|
|
if (!isTauriShell() || !isDesktopLocalOriginActive()) {
|
|
return false;
|
|
}
|
|
|
|
const trimmedProjectPath = projectPath?.trim();
|
|
const trimmedAppId = appId?.trim();
|
|
const trimmedAppName = appName?.trim();
|
|
const trimmedFilePath = typeof filePath === 'string' ? filePath.trim() : '';
|
|
|
|
if (!trimmedProjectPath || !trimmedAppId || !trimmedAppName) {
|
|
return false;
|
|
}
|
|
|
|
try {
|
|
const tauri = (window as unknown as { __TAURI__?: TauriGlobal }).__TAURI__;
|
|
await tauri?.core?.invoke?.('desktop_open_in_app', {
|
|
projectPath: trimmedProjectPath,
|
|
appId: trimmedAppId,
|
|
appName: trimmedAppName,
|
|
filePath: trimmedFilePath.length > 0 ? trimmedFilePath : undefined,
|
|
});
|
|
return true;
|
|
} catch (error) {
|
|
console.warn('Failed to open project in app (tauri)', error);
|
|
return false;
|
|
}
|
|
};
|
|
|
|
export const filterInstalledDesktopApps = async (apps: string[]): Promise<string[]> => {
|
|
if (!isTauriShell() || !isDesktopLocalOriginActive()) {
|
|
return [];
|
|
}
|
|
|
|
const candidate = Array.isArray(apps) ? apps.filter((value) => typeof value === 'string') : [];
|
|
if (candidate.length === 0) {
|
|
return [];
|
|
}
|
|
|
|
try {
|
|
const tauri = (window as unknown as { __TAURI__?: TauriGlobal }).__TAURI__;
|
|
const result = await tauri?.core?.invoke?.('desktop_filter_installed_apps', {
|
|
apps: candidate,
|
|
});
|
|
return Array.isArray(result) ? result.filter((value) => typeof value === 'string') : [];
|
|
} catch (error) {
|
|
console.warn('Failed to check installed apps (tauri)', error);
|
|
return [];
|
|
}
|
|
};
|
|
|
|
export const fetchDesktopAppIcons = async (apps: string[]): Promise<Record<string, string>> => {
|
|
if (!isTauriShell() || !isDesktopLocalOriginActive()) {
|
|
return {};
|
|
}
|
|
|
|
const candidate = Array.isArray(apps) ? apps.filter((value) => typeof value === 'string') : [];
|
|
if (candidate.length === 0) {
|
|
return {};
|
|
}
|
|
|
|
try {
|
|
const tauri = (window as unknown as { __TAURI__?: TauriGlobal }).__TAURI__;
|
|
const result = await tauri?.core?.invoke?.('desktop_fetch_app_icons', {
|
|
apps: candidate,
|
|
});
|
|
if (!Array.isArray(result)) {
|
|
return {};
|
|
}
|
|
const map: Record<string, string> = {};
|
|
for (const entry of result) {
|
|
if (!entry || typeof entry !== 'object') continue;
|
|
const candidateEntry = entry as { app?: unknown; data_url?: unknown };
|
|
if (typeof candidateEntry.app !== 'string' || typeof candidateEntry.data_url !== 'string') continue;
|
|
map[candidateEntry.app] = candidateEntry.data_url;
|
|
}
|
|
return map;
|
|
} catch (error) {
|
|
console.warn('Failed to fetch installed app icons (tauri)', error);
|
|
return {};
|
|
}
|
|
};
|
|
|
|
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 (!isTauriShell() || !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 tauri = (window as unknown as { __TAURI__?: TauriGlobal }).__TAURI__;
|
|
const result = await tauri?.core?.invoke?.('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 };
|
|
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 (tauri)', error);
|
|
return { apps: [], success: false, hasCache: false, isCacheStale: false };
|
|
}
|
|
};
|
|
|
|
export const clearDesktopCache = async (): Promise<boolean> => {
|
|
if (!isTauriShell() || !isDesktopLocalOriginActive()) {
|
|
return false;
|
|
}
|
|
|
|
try {
|
|
const tauri = (window as unknown as { __TAURI__?: TauriGlobal }).__TAURI__;
|
|
await tauri?.core?.invoke?.('desktop_clear_cache');
|
|
return true;
|
|
} catch (error) {
|
|
console.warn('Failed to clear cache', error);
|
|
return false;
|
|
}
|
|
};
|