Initial public release
This commit is contained in:
@@ -0,0 +1,32 @@
|
||||
|
||||
import type { DiagnosticsAPI } from '@openchamber/ui/lib/api/types';
|
||||
|
||||
type LogResponse = {
|
||||
fileName?: string;
|
||||
content?: string;
|
||||
};
|
||||
|
||||
const normalizePayload = (payload: LogResponse): { fileName: string; content: string } => ({
|
||||
fileName: typeof payload.fileName === 'string' && payload.fileName.trim().length > 0 ? payload.fileName : 'desktop.log',
|
||||
content: typeof payload.content === 'string' ? payload.content : '',
|
||||
});
|
||||
|
||||
export const createDesktopDiagnosticsAPI = (): DiagnosticsAPI => ({
|
||||
async downloadLogs() {
|
||||
try {
|
||||
const { safeInvoke } = await import('../lib/tauriCallbackManager');
|
||||
const result = await safeInvoke<LogResponse>('fetch_desktop_logs', {}, {
|
||||
timeout: 10000,
|
||||
onCancel: () => {
|
||||
console.warn('[DiagnosticsAPI] Fetch desktop logs operation timed out');
|
||||
}
|
||||
});
|
||||
return normalizePayload(result ?? {});
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
throw error;
|
||||
}
|
||||
throw new Error('Failed to download desktop logs');
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,114 @@
|
||||
|
||||
import { safeInvoke } from '../lib/tauriCallbackManager';
|
||||
import type { DirectoryListResult, FileSearchQuery, FileSearchResult, FilesAPI } from '@openchamber/ui/lib/api/types';
|
||||
|
||||
type ListDirectoryResponse = DirectoryListResult & {
|
||||
path?: string;
|
||||
entries: Array<
|
||||
DirectoryListResult['entries'][number] & {
|
||||
isFile?: boolean;
|
||||
isSymbolicLink?: boolean;
|
||||
}
|
||||
>;
|
||||
};
|
||||
|
||||
type SearchFilesResponse = {
|
||||
root: string;
|
||||
count: number;
|
||||
files: Array<{
|
||||
name: string;
|
||||
path: string;
|
||||
relativePath: string;
|
||||
extension?: string;
|
||||
}>;
|
||||
};
|
||||
|
||||
const normalizePath = (path: string): string => path.replace(/\\/g, '/');
|
||||
|
||||
const normalizeDirectoryPayload = (result: ListDirectoryResponse): DirectoryListResult => ({
|
||||
directory: normalizePath(result.directory || result.path || ''),
|
||||
entries: Array.isArray(result.entries)
|
||||
? result.entries.map((entry) => ({
|
||||
name: entry.name || '',
|
||||
path: normalizePath(entry.path || ''),
|
||||
isDirectory: entry.isDirectory ?? false,
|
||||
size: entry.size ?? 0,
|
||||
modified: (entry as { modified?: string }).modified ?? new Date().toISOString(),
|
||||
}))
|
||||
: [],
|
||||
});
|
||||
|
||||
export const createDesktopFilesAPI = (): FilesAPI => ({
|
||||
async listDirectory(path: string): Promise<DirectoryListResult> {
|
||||
try {
|
||||
const result = await safeInvoke<ListDirectoryResponse>('list_directory', {
|
||||
path: normalizePath(path),
|
||||
includeHidden: false
|
||||
}, {
|
||||
timeout: 10000,
|
||||
onCancel: () => {
|
||||
console.warn('[FilesAPI] List directory operation timed out');
|
||||
}
|
||||
});
|
||||
|
||||
return normalizeDirectoryPayload(result);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
throw new Error(message || 'Failed to list directory');
|
||||
}
|
||||
},
|
||||
|
||||
async search(payload: FileSearchQuery): Promise<FileSearchResult[]> {
|
||||
try {
|
||||
const normalizedDirectory =
|
||||
typeof payload.directory === 'string' && payload.directory.length > 0
|
||||
? normalizePath(payload.directory)
|
||||
: undefined;
|
||||
|
||||
const result = await safeInvoke<SearchFilesResponse>('search_files', {
|
||||
directory: normalizedDirectory,
|
||||
query: payload.query,
|
||||
max_results: payload.maxResults || 100
|
||||
}, {
|
||||
timeout: 15000,
|
||||
onCancel: () => {
|
||||
console.warn('[FilesAPI] Search files operation timed out');
|
||||
}
|
||||
});
|
||||
|
||||
if (!result || !Array.isArray(result.files)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return result.files.map<FileSearchResult>((file) => ({
|
||||
path: normalizePath(file.path),
|
||||
preview: file.relativePath ? [normalizePath(file.relativePath)] : undefined,
|
||||
}));
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
throw new Error(message || 'Failed to search files');
|
||||
}
|
||||
},
|
||||
|
||||
async createDirectory(path: string): Promise<{ success: boolean; path: string }> {
|
||||
try {
|
||||
const normalizedPath = normalizePath(path);
|
||||
const result = await safeInvoke<{ success: boolean; path: string }>('create_directory', {
|
||||
path: normalizedPath
|
||||
}, {
|
||||
timeout: 5000,
|
||||
onCancel: () => {
|
||||
console.warn('[FilesAPI] Create directory operation timed out');
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
success: Boolean(result?.success),
|
||||
path: result?.path ? normalizePath(result.path) : normalizedPath,
|
||||
};
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
throw new Error(message || 'Failed to create directory');
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,230 @@
|
||||
|
||||
import { safeInvoke } from '../lib/tauriCallbackManager';
|
||||
import type {
|
||||
GitAPI,
|
||||
GitStatus,
|
||||
GitDiffResponse,
|
||||
GetGitDiffOptions,
|
||||
GitFileDiffResponse,
|
||||
GitBranch,
|
||||
GitDeleteBranchPayload,
|
||||
GitDeleteRemoteBranchPayload,
|
||||
GeneratedCommitMessage,
|
||||
GitWorktreeInfo,
|
||||
GitAddWorktreePayload,
|
||||
GitRemoveWorktreePayload,
|
||||
CreateGitCommitOptions,
|
||||
GitCommitResult,
|
||||
GitPushResult,
|
||||
GitPullResult,
|
||||
GitLogOptions,
|
||||
GitLogResponse,
|
||||
GitCommitFilesResponse,
|
||||
GitIdentitySummary,
|
||||
GitIdentityProfile
|
||||
} from '@openchamber/ui/lib/api/types';
|
||||
|
||||
async function safeGitInvoke<T>(command: string, args?: Record<string, unknown>): Promise<T> {
|
||||
try {
|
||||
return await safeInvoke<T>(command, args, {
|
||||
timeout: 120000,
|
||||
onCancel: () => {
|
||||
console.warn(`[GitAPI] Git operation ${command} did not complete within 120s; it may still be running.`);
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
const message = typeof error === 'string' ? error : (error as Error).message || 'Unknown error';
|
||||
throw new Error(message);
|
||||
}
|
||||
}
|
||||
|
||||
export const createDesktopGitAPI = (): GitAPI => ({
|
||||
async checkIsGitRepository(directory: string): Promise<boolean> {
|
||||
return safeGitInvoke<boolean>('check_is_git_repository', { directory });
|
||||
},
|
||||
|
||||
async getGitStatus(directory: string): Promise<GitStatus> {
|
||||
return safeGitInvoke<GitStatus>('get_git_status', { directory });
|
||||
},
|
||||
|
||||
async getGitDiff(directory: string, options: GetGitDiffOptions): Promise<GitDiffResponse> {
|
||||
const diff = await safeGitInvoke<string>('get_git_diff', {
|
||||
directory,
|
||||
pathStr: options.path,
|
||||
staged: options.staged,
|
||||
contextLines: options.contextLines
|
||||
});
|
||||
return { diff };
|
||||
},
|
||||
|
||||
async getGitFileDiff(directory: string, options: { path: string }): Promise<GitFileDiffResponse> {
|
||||
const [original, modified] = await safeGitInvoke<[string, string]>('get_git_file_diff', {
|
||||
directory,
|
||||
pathStr: options.path,
|
||||
});
|
||||
return {
|
||||
original: original ?? '',
|
||||
modified: modified ?? '',
|
||||
path: options.path,
|
||||
};
|
||||
},
|
||||
|
||||
async revertGitFile(directory: string, filePath: string): Promise<void> {
|
||||
return safeGitInvoke<void>('revert_git_file', { directory, filePath });
|
||||
},
|
||||
|
||||
async isLinkedWorktree(directory: string): Promise<boolean> {
|
||||
return safeGitInvoke<boolean>('is_linked_worktree', { directory });
|
||||
},
|
||||
|
||||
async getGitBranches(directory: string): Promise<GitBranch> {
|
||||
return safeGitInvoke<GitBranch>('get_git_branches', { directory });
|
||||
},
|
||||
|
||||
async deleteGitBranch(directory: string, payload: GitDeleteBranchPayload): Promise<{ success: boolean }> {
|
||||
await safeGitInvoke<void>('delete_git_branch', {
|
||||
directory,
|
||||
branch: payload.branch,
|
||||
force: payload.force
|
||||
});
|
||||
return { success: true };
|
||||
},
|
||||
|
||||
async deleteRemoteBranch(directory: string, payload: GitDeleteRemoteBranchPayload): Promise<{ success: boolean }> {
|
||||
await safeGitInvoke<void>('delete_remote_branch', {
|
||||
directory,
|
||||
branch: payload.branch,
|
||||
remote: payload.remote
|
||||
});
|
||||
return { success: true };
|
||||
},
|
||||
|
||||
async generateCommitMessage(directory: string, files: string[]): Promise<{ message: GeneratedCommitMessage }> {
|
||||
const response = await safeGitInvoke<{ message: GeneratedCommitMessage }>('generate_commit_message', {
|
||||
directory,
|
||||
files
|
||||
});
|
||||
return response;
|
||||
},
|
||||
|
||||
async listGitWorktrees(directory: string): Promise<GitWorktreeInfo[]> {
|
||||
return safeGitInvoke<GitWorktreeInfo[]>('list_git_worktrees', { directory });
|
||||
},
|
||||
|
||||
async addGitWorktree(directory: string, payload: GitAddWorktreePayload): Promise<{ success: boolean; path: string; branch: string }> {
|
||||
await safeGitInvoke<void>('add_git_worktree', {
|
||||
directory,
|
||||
pathStr: payload.path,
|
||||
branch: payload.branch,
|
||||
createBranch: payload.createBranch
|
||||
});
|
||||
return { success: true, path: payload.path, branch: payload.branch };
|
||||
},
|
||||
|
||||
async removeGitWorktree(directory: string, payload: GitRemoveWorktreePayload): Promise<{ success: boolean }> {
|
||||
await safeGitInvoke<void>('remove_git_worktree', {
|
||||
directory,
|
||||
pathStr: payload.path,
|
||||
force: payload.force
|
||||
});
|
||||
return { success: true };
|
||||
},
|
||||
|
||||
async ensureOpenChamberIgnored(directory: string): Promise<void> {
|
||||
return safeGitInvoke<void>('ensure_openchamber_ignored', { directory });
|
||||
},
|
||||
|
||||
async createGitCommit(directory: string, message: string, options?: CreateGitCommitOptions): Promise<GitCommitResult> {
|
||||
return safeGitInvoke<GitCommitResult>('create_git_commit', {
|
||||
directory,
|
||||
message,
|
||||
addAll: options?.addAll,
|
||||
files: options?.files
|
||||
});
|
||||
},
|
||||
|
||||
async gitPush(directory: string, options?: { remote?: string; branch?: string; options?: string[] | Record<string, unknown> }): Promise<GitPushResult> {
|
||||
return safeGitInvoke<GitPushResult>('git_push', {
|
||||
directory,
|
||||
remote: options?.remote,
|
||||
branch: options?.branch,
|
||||
options: options?.options
|
||||
});
|
||||
},
|
||||
|
||||
async gitPull(directory: string, options?: { remote?: string; branch?: string }): Promise<GitPullResult> {
|
||||
return safeGitInvoke<GitPullResult>('git_pull', {
|
||||
directory,
|
||||
remote: options?.remote,
|
||||
branch: options?.branch
|
||||
});
|
||||
},
|
||||
|
||||
async gitFetch(directory: string, options?: { remote?: string; branch?: string }): Promise<{ success: boolean }> {
|
||||
await safeGitInvoke<void>('git_fetch', {
|
||||
directory,
|
||||
remote: options?.remote
|
||||
});
|
||||
return { success: true };
|
||||
},
|
||||
|
||||
async checkoutBranch(directory: string, branch: string): Promise<{ success: boolean; branch: string }> {
|
||||
await safeGitInvoke<void>('checkout_branch', { directory, branch });
|
||||
return { success: true, branch };
|
||||
},
|
||||
|
||||
async createBranch(directory: string, name: string, startPoint?: string): Promise<{ success: boolean; branch: string }> {
|
||||
await safeGitInvoke<void>('create_branch', {
|
||||
directory,
|
||||
name,
|
||||
startPoint
|
||||
});
|
||||
return { success: true, branch: name };
|
||||
},
|
||||
|
||||
async getGitLog(directory: string, options?: GitLogOptions): Promise<GitLogResponse> {
|
||||
return safeGitInvoke<GitLogResponse>('get_git_log', {
|
||||
directory,
|
||||
maxCount: options?.maxCount,
|
||||
from: options?.from,
|
||||
to: options?.to,
|
||||
file: options?.file
|
||||
});
|
||||
},
|
||||
|
||||
async getCommitFiles(directory: string, hash: string): Promise<GitCommitFilesResponse> {
|
||||
return safeGitInvoke<GitCommitFilesResponse>('get_commit_files', {
|
||||
directory,
|
||||
hash
|
||||
});
|
||||
},
|
||||
|
||||
async getCurrentGitIdentity(directory: string): Promise<GitIdentitySummary | null> {
|
||||
try {
|
||||
return await safeGitInvoke<GitIdentitySummary>('get_current_git_identity', { directory });
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
|
||||
async setGitIdentity(directory: string, profileId: string): Promise<{ success: boolean; profile: GitIdentityProfile }> {
|
||||
const profile = await safeGitInvoke<GitIdentityProfile>('set_git_identity', { directory, profileId });
|
||||
return { success: true, profile };
|
||||
},
|
||||
|
||||
async getGitIdentities(): Promise<GitIdentityProfile[]> {
|
||||
return safeGitInvoke<GitIdentityProfile[]>('get_git_identities');
|
||||
},
|
||||
|
||||
async createGitIdentity(profile: GitIdentityProfile): Promise<GitIdentityProfile> {
|
||||
return safeGitInvoke<GitIdentityProfile>('create_git_identity', { profile });
|
||||
},
|
||||
|
||||
async updateGitIdentity(id: string, updates: GitIdentityProfile): Promise<GitIdentityProfile> {
|
||||
return safeGitInvoke<GitIdentityProfile>('update_git_identity', { id, updates });
|
||||
},
|
||||
|
||||
async deleteGitIdentity(id: string): Promise<void> {
|
||||
return safeGitInvoke<void>('delete_git_identity', { id });
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,57 @@
|
||||
import type { RuntimeAPIs, TerminalHandlers } from '@openchamber/ui/lib/api/types';
|
||||
import { createDesktopTerminalAPI } from './terminal';
|
||||
import { createDesktopGitAPI } from './git';
|
||||
import { createDesktopFilesAPI } from './files';
|
||||
import { createDesktopSettingsAPI } from './settings';
|
||||
import { createDesktopPermissionsAPI } from './permissions';
|
||||
import { createDesktopDiagnosticsAPI } from './diagnostics';
|
||||
import { createDesktopNotificationsAPI } from './notifications';
|
||||
import { createDesktopToolsAPI } from './tools';
|
||||
|
||||
const activeTerminalConnections = new Set<string>();
|
||||
|
||||
export const createDesktopAPIs = (): RuntimeAPIs & { cleanup?: () => void } => {
|
||||
const terminalAPI = createDesktopTerminalAPI();
|
||||
const originalConnect = terminalAPI.connect.bind(terminalAPI);
|
||||
|
||||
const wrappedTerminalAPI = {
|
||||
...terminalAPI,
|
||||
connect: (sessionId: string, handlers: TerminalHandlers) => {
|
||||
activeTerminalConnections.add(sessionId);
|
||||
const connection = originalConnect(sessionId, handlers);
|
||||
|
||||
const originalClose = connection.close;
|
||||
return {
|
||||
...connection,
|
||||
close: () => {
|
||||
activeTerminalConnections.delete(sessionId);
|
||||
originalClose();
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
return {
|
||||
runtime: { platform: 'desktop', isDesktop: true, label: 'tauri-bootstrap' },
|
||||
terminal: wrappedTerminalAPI,
|
||||
git: createDesktopGitAPI(),
|
||||
files: createDesktopFilesAPI(),
|
||||
settings: createDesktopSettingsAPI(),
|
||||
permissions: createDesktopPermissionsAPI(),
|
||||
notifications: createDesktopNotificationsAPI(),
|
||||
diagnostics: createDesktopDiagnosticsAPI(),
|
||||
tools: createDesktopToolsAPI(),
|
||||
cleanup: () => {
|
||||
console.info('[DesktopAPIs] Performing cleanup...');
|
||||
|
||||
const activeConnections = Array.from(activeTerminalConnections);
|
||||
activeConnections.forEach(sessionId => {
|
||||
console.info(`[DesktopAPIs] Closing terminal session: ${sessionId}`);
|
||||
|
||||
activeTerminalConnections.delete(sessionId);
|
||||
});
|
||||
|
||||
console.info(`[DesktopAPIs] Cleanup completed, closed ${activeConnections.length} terminal connections`);
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,58 @@
|
||||
|
||||
import type { NotificationsAPI, NotificationPayload } from '@openchamber/ui/lib/api/types';
|
||||
import { isPermissionGranted, requestPermission } from '@tauri-apps/plugin-notification';
|
||||
import { safeInvoke } from '../lib/tauriCallbackManager';
|
||||
|
||||
export const requestInitialNotificationPermission = async (): Promise<void> => {
|
||||
try {
|
||||
if (typeof window !== 'undefined' && 'Notification' in window) {
|
||||
const permission = await Notification.requestPermission();
|
||||
if (permission !== 'granted') {
|
||||
console.warn('[notifications] Notification permission not granted');
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[notifications] Failed to request permission:', error);
|
||||
}
|
||||
};
|
||||
|
||||
export const createDesktopNotificationsAPI = (): NotificationsAPI => ({
|
||||
async notifyAgentCompletion(payload?: NotificationPayload): Promise<boolean> {
|
||||
try {
|
||||
let granted = await isPermissionGranted();
|
||||
if (!granted) {
|
||||
const permission = await requestPermission();
|
||||
granted = permission === 'granted';
|
||||
}
|
||||
|
||||
if (!granted) {
|
||||
console.warn('[notifications] Cannot send notification: Permission denied');
|
||||
return false;
|
||||
}
|
||||
|
||||
await safeInvoke(
|
||||
'desktop_notify',
|
||||
{ payload },
|
||||
{
|
||||
timeout: 5000,
|
||||
onCancel: () => {
|
||||
console.warn('[NotificationsAPI] Notify operation timed out');
|
||||
},
|
||||
},
|
||||
);
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('[notifications] Failed to send notification:', error);
|
||||
return false;
|
||||
}
|
||||
},
|
||||
|
||||
async canNotify(): Promise<boolean> {
|
||||
try {
|
||||
return await isPermissionGranted();
|
||||
} catch (error) {
|
||||
console.warn('[notifications] Failed to check notification permission:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,49 @@
|
||||
import type { DirectoryPermissionRequest, DirectoryPermissionResult, PermissionsAPI, StartAccessingResult } from '@openchamber/ui/lib/api/types';
|
||||
|
||||
export const createDesktopPermissionsAPI = (): PermissionsAPI => ({
|
||||
async requestDirectoryAccess(request: DirectoryPermissionRequest): Promise<DirectoryPermissionResult> {
|
||||
try {
|
||||
const { safeInvoke } = await import('../lib/tauriCallbackManager');
|
||||
const result = await safeInvoke<DirectoryPermissionResult>('request_directory_access', { request }, {
|
||||
timeout: 30000,
|
||||
onCancel: () => {
|
||||
console.warn('[PermissionsAPI] Request directory access operation timed out');
|
||||
}
|
||||
});
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.error('[desktop] Error requesting directory access:', error);
|
||||
return { success: false, error: error instanceof Error ? error.message : String(error) };
|
||||
}
|
||||
},
|
||||
async startAccessingDirectory(path: string): Promise<StartAccessingResult> {
|
||||
try {
|
||||
const { safeInvoke } = await import('../lib/tauriCallbackManager');
|
||||
const result = await safeInvoke<StartAccessingResult>('start_accessing_directory', { path }, {
|
||||
timeout: 10000,
|
||||
onCancel: () => {
|
||||
console.warn('[PermissionsAPI] Start accessing directory operation timed out');
|
||||
}
|
||||
});
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.error('[desktop] Error starting directory access:', error);
|
||||
return { success: false, error: error instanceof Error ? error.message : String(error) };
|
||||
}
|
||||
},
|
||||
async stopAccessingDirectory(path: string): Promise<StartAccessingResult> {
|
||||
try {
|
||||
const { safeInvoke } = await import('../lib/tauriCallbackManager');
|
||||
const result = await safeInvoke<StartAccessingResult>('stop_accessing_directory', { path }, {
|
||||
timeout: 5000,
|
||||
onCancel: () => {
|
||||
console.warn('[PermissionsAPI] Stop accessing directory operation timed out');
|
||||
}
|
||||
});
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.error('[desktop] Error stopping directory access:', error);
|
||||
return { success: false, error: error instanceof Error ? error.message : String(error) };
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,58 @@
|
||||
import type { SettingsAPI, SettingsLoadResult, SettingsPayload } from '@openchamber/ui/lib/api/types';
|
||||
|
||||
const sanitizePayload = (data: unknown): SettingsPayload => {
|
||||
if (!data || typeof data !== 'object') {
|
||||
return {};
|
||||
}
|
||||
return data as SettingsPayload;
|
||||
};
|
||||
|
||||
export const createDesktopSettingsAPI = (): SettingsAPI => ({
|
||||
async load(): Promise<SettingsLoadResult> {
|
||||
try {
|
||||
const { safeInvoke } = await import('../lib/tauriCallbackManager');
|
||||
const result = await safeInvoke<{ settings: unknown; source: 'desktop' | 'web' }>('load_settings', {}, {
|
||||
timeout: 5000,
|
||||
onCancel: () => {
|
||||
console.warn('[SettingsAPI] Load settings operation timed out');
|
||||
}
|
||||
});
|
||||
return {
|
||||
settings: sanitizePayload(result.settings),
|
||||
source: result.source,
|
||||
};
|
||||
} catch (error) {
|
||||
throw new Error(`Failed to load settings: ${error instanceof Error ? error.message : String(error)}`);
|
||||
}
|
||||
},
|
||||
|
||||
async save(changes: Partial<SettingsPayload>): Promise<SettingsPayload> {
|
||||
try {
|
||||
const { safeInvoke } = await import('../lib/tauriCallbackManager');
|
||||
const result = await safeInvoke<unknown>('save_settings', { changes }, {
|
||||
timeout: 5000,
|
||||
onCancel: () => {
|
||||
console.warn('[SettingsAPI] Save settings operation timed out');
|
||||
}
|
||||
});
|
||||
return sanitizePayload(result);
|
||||
} catch (error) {
|
||||
throw new Error(`Failed to save settings: ${error instanceof Error ? error.message : String(error)}`);
|
||||
}
|
||||
},
|
||||
|
||||
async restartOpenCode(): Promise<{ restarted: boolean }> {
|
||||
try {
|
||||
const { safeInvoke } = await import('../lib/tauriCallbackManager');
|
||||
const result = await safeInvoke<{ restarted: boolean }>('restart_opencode', {}, {
|
||||
timeout: 10000,
|
||||
onCancel: () => {
|
||||
console.warn('[SettingsAPI] Restart OpenCode operation timed out');
|
||||
}
|
||||
});
|
||||
return { restarted: result.restarted };
|
||||
} catch (error) {
|
||||
throw new Error(`Failed to restart OpenCode: ${error instanceof Error ? error.message : String(error)}`);
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,123 @@
|
||||
import { safeInvoke, safeListen } from '../lib/tauriCallbackManager';
|
||||
import type {
|
||||
TerminalAPI,
|
||||
TerminalHandlers,
|
||||
CreateTerminalOptions,
|
||||
ResizeTerminalPayload,
|
||||
TerminalSession,
|
||||
TerminalStreamEvent
|
||||
} from '@openchamber/ui/lib/api/types';
|
||||
|
||||
async function safeTerminalInvoke<T>(command: string, args?: Record<string, unknown>): Promise<T> {
|
||||
try {
|
||||
return await safeInvoke<T>(command, args, {
|
||||
timeout: 10000,
|
||||
onCancel: () => {
|
||||
console.warn(`[TerminalAPI] Command ${command} timed out`);
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
const message = typeof error === 'string' ? error : (error as Error).message || 'Unknown error';
|
||||
throw new Error(message);
|
||||
}
|
||||
}
|
||||
|
||||
export const createDesktopTerminalAPI = (): TerminalAPI => ({
|
||||
async createSession(options: CreateTerminalOptions): Promise<TerminalSession> {
|
||||
const cols = options.cols ?? 80;
|
||||
const rows = options.rows ?? 24;
|
||||
|
||||
const res = await safeTerminalInvoke<{ session_id: string }>('create_terminal_session', {
|
||||
payload: {
|
||||
cols,
|
||||
rows,
|
||||
cwd: options.cwd
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
sessionId: res.session_id,
|
||||
cols,
|
||||
rows
|
||||
};
|
||||
},
|
||||
|
||||
connect(sessionId: string, handlers: TerminalHandlers) {
|
||||
let unlistenFn: (() => void) | undefined;
|
||||
let cancelled = false;
|
||||
let isConnected = false;
|
||||
|
||||
const stopListening = () => {
|
||||
if (unlistenFn) {
|
||||
unlistenFn();
|
||||
unlistenFn = undefined;
|
||||
isConnected = false;
|
||||
}
|
||||
};
|
||||
|
||||
const startListening = async () => {
|
||||
try {
|
||||
const unlisten = await safeListen<TerminalStreamEvent>(`terminal://${sessionId}`, (event) => {
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
|
||||
handlers.onEvent(event.payload);
|
||||
|
||||
if (event.payload?.type === 'exit') {
|
||||
stopListening();
|
||||
}
|
||||
});
|
||||
|
||||
if (cancelled) {
|
||||
unlisten();
|
||||
return;
|
||||
}
|
||||
|
||||
unlistenFn = unlisten;
|
||||
isConnected = true;
|
||||
handlers.onEvent({ type: 'connected' });
|
||||
} catch (err) {
|
||||
console.error('Failed to listen to terminal events:', err);
|
||||
if (!cancelled) {
|
||||
handlers.onError?.(err instanceof Error ? err : new Error(String(err)));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
startListening();
|
||||
|
||||
return {
|
||||
close: () => {
|
||||
cancelled = true;
|
||||
stopListening();
|
||||
},
|
||||
isConnected: () => isConnected,
|
||||
};
|
||||
},
|
||||
|
||||
async sendInput(sessionId: string, input: string): Promise<void> {
|
||||
await safeTerminalInvoke('send_terminal_input', {
|
||||
|
||||
sessionId,
|
||||
session_id: sessionId,
|
||||
data: input,
|
||||
});
|
||||
},
|
||||
|
||||
async resize(payload: ResizeTerminalPayload): Promise<void> {
|
||||
await safeTerminalInvoke('resize_terminal', {
|
||||
sessionId: payload.sessionId,
|
||||
session_id: payload.sessionId,
|
||||
cols: payload.cols,
|
||||
rows: payload.rows,
|
||||
});
|
||||
},
|
||||
|
||||
async close(sessionId: string): Promise<void> {
|
||||
await safeTerminalInvoke('close_terminal', {
|
||||
sessionId,
|
||||
session_id: sessionId,
|
||||
});
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,22 @@
|
||||
import type { ToolsAPI } from '@openchamber/ui/lib/api/types';
|
||||
|
||||
export const createDesktopToolsAPI = (): ToolsAPI => ({
|
||||
async getAvailableTools(): Promise<string[]> {
|
||||
|
||||
const response = await fetch('/api/experimental/tool/ids');
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Tools API returned ${response.status} ${response.statusText}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (!Array.isArray(data)) {
|
||||
throw new Error('Tools API returned invalid data format');
|
||||
}
|
||||
|
||||
return data
|
||||
.filter((tool: unknown): tool is string => typeof tool === 'string' && tool !== 'invalid')
|
||||
.sort();
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,105 @@
|
||||
export interface UpdateInfo {
|
||||
available: boolean;
|
||||
version?: string;
|
||||
currentVersion: string;
|
||||
body?: string;
|
||||
date?: string;
|
||||
}
|
||||
|
||||
export interface UpdateProgress {
|
||||
downloaded: number;
|
||||
total?: number;
|
||||
}
|
||||
|
||||
interface Update {
|
||||
version: string;
|
||||
body?: string;
|
||||
date?: string;
|
||||
downloadAndInstall: (
|
||||
onEvent?: (event: DownloadEvent) => void
|
||||
) => Promise<void>;
|
||||
}
|
||||
|
||||
type DownloadEvent =
|
||||
| { event: 'Started'; data: { contentLength?: number } }
|
||||
| { event: 'Progress'; data: { chunkLength: number } }
|
||||
| { event: 'Finished' };
|
||||
|
||||
let cachedUpdate: Update | null = null;
|
||||
|
||||
export async function checkForUpdates(): Promise<UpdateInfo> {
|
||||
try {
|
||||
const { check } = await import('@tauri-apps/plugin-updater');
|
||||
const update = await check();
|
||||
cachedUpdate = update;
|
||||
|
||||
if (!update) {
|
||||
return {
|
||||
available: false,
|
||||
currentVersion: await getCurrentVersion(),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
available: true,
|
||||
version: update.version,
|
||||
currentVersion: await getCurrentVersion(),
|
||||
body: update.body ?? undefined,
|
||||
date: update.date ?? undefined,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('[updater] Failed to check for updates:', error);
|
||||
return {
|
||||
available: false,
|
||||
currentVersion: await getCurrentVersion(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export async function downloadUpdate(
|
||||
onProgress?: (progress: UpdateProgress) => void
|
||||
): Promise<void> {
|
||||
let update = cachedUpdate;
|
||||
if (!update) {
|
||||
const { check } = await import('@tauri-apps/plugin-updater');
|
||||
const checked = await check();
|
||||
if (!checked) {
|
||||
throw new Error('No update available');
|
||||
}
|
||||
update = checked;
|
||||
cachedUpdate = checked;
|
||||
}
|
||||
|
||||
let downloaded = 0;
|
||||
let total: number | undefined;
|
||||
|
||||
await update.downloadAndInstall((event: DownloadEvent) => {
|
||||
switch (event.event) {
|
||||
case 'Started':
|
||||
total = event.data.contentLength;
|
||||
onProgress?.({ downloaded: 0, total });
|
||||
break;
|
||||
case 'Progress':
|
||||
downloaded += event.data.chunkLength;
|
||||
onProgress?.({ downloaded, total });
|
||||
break;
|
||||
case 'Finished':
|
||||
onProgress?.({ downloaded: total ?? downloaded, total });
|
||||
break;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export async function restartToUpdate(): Promise<void> {
|
||||
const { relaunch } = await import('@tauri-apps/plugin-process');
|
||||
await relaunch();
|
||||
}
|
||||
|
||||
async function getCurrentVersion(): Promise<string> {
|
||||
try {
|
||||
const { getVersion } = await import('@tauri-apps/api/app');
|
||||
return await getVersion();
|
||||
} catch {
|
||||
return 'unknown';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
|
||||
import { safeInvoke, cleanupAllTauriCallbacks } from './tauriCallbackManager';
|
||||
|
||||
type ServerInfo = {
|
||||
server_port: number;
|
||||
opencode_port?: number | null;
|
||||
api_prefix?: string | null;
|
||||
cli_available?: boolean;
|
||||
};
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
__OPENCHAMBER_DESKTOP_SERVER__?: {
|
||||
origin: string;
|
||||
opencodePort: number | null;
|
||||
apiPrefix: string;
|
||||
cliAvailable: boolean;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
let bridgePromise: Promise<void> | null = null;
|
||||
|
||||
export function initializeDesktopBridge(): Promise<void> {
|
||||
if (!bridgePromise) {
|
||||
bridgePromise = setupBridge();
|
||||
}
|
||||
return bridgePromise;
|
||||
}
|
||||
|
||||
async function setupBridge(): Promise<void> {
|
||||
try {
|
||||
const info = await safeInvoke<ServerInfo>('desktop_server_info', {}, {
|
||||
timeout: 10000,
|
||||
onCancel: () => {
|
||||
console.warn('[Bridge] Server info request timed out');
|
||||
}
|
||||
});
|
||||
const origin = `http://127.0.0.1:${info.server_port}`;
|
||||
|
||||
window.__OPENCHAMBER_DESKTOP_SERVER__ = {
|
||||
origin,
|
||||
opencodePort: info.opencode_port ?? null,
|
||||
apiPrefix: info.api_prefix ?? '',
|
||||
cliAvailable: info.cli_available ?? false,
|
||||
};
|
||||
|
||||
patchFetch(origin);
|
||||
patchEventSource(origin);
|
||||
|
||||
const cleanupDevtools = registerDevtoolsShortcut();
|
||||
|
||||
if (typeof window !== 'undefined') {
|
||||
(window as { __openchamberCleanup?: () => void }).__openchamberCleanup = () => {
|
||||
cleanupDevtools();
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[bridge] Failed to initialize bridge:', error);
|
||||
|
||||
if (typeof window !== 'undefined' && (window as { __openchamberCleanup?: () => void }).__openchamberCleanup) {
|
||||
try {
|
||||
(window as { __openchamberCleanup?: () => void }).__openchamberCleanup?.();
|
||||
} catch (cleanupError) {
|
||||
console.warn('[bridge] Cleanup during failed initialization failed:', cleanupError);
|
||||
}
|
||||
delete (window as { __openchamberCleanup?: () => void }).__openchamberCleanup;
|
||||
}
|
||||
|
||||
cleanupAllTauriCallbacks();
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function patchFetch(origin: string) {
|
||||
const originalFetch = window.fetch.bind(window);
|
||||
|
||||
const rewrite = (value: string): string => {
|
||||
if (value.startsWith('http://') || value.startsWith('https://')) {
|
||||
return value;
|
||||
}
|
||||
if (value.startsWith('//')) {
|
||||
return `http:${value}`;
|
||||
}
|
||||
if (value.startsWith('/')) {
|
||||
return `${origin}${value}`;
|
||||
}
|
||||
return value;
|
||||
};
|
||||
|
||||
window.fetch = (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
if (typeof input === 'string') {
|
||||
return originalFetch(rewrite(input), init);
|
||||
}
|
||||
|
||||
if (input instanceof Request) {
|
||||
const rewritten = rewrite(input.url);
|
||||
if (rewritten === input.url) {
|
||||
return originalFetch(input, init);
|
||||
}
|
||||
const cloned = new Request(rewritten, input);
|
||||
return originalFetch(cloned, init);
|
||||
}
|
||||
|
||||
if (input instanceof URL) {
|
||||
return originalFetch(rewrite(input.toString()), init);
|
||||
}
|
||||
|
||||
return originalFetch(input, init);
|
||||
};
|
||||
}
|
||||
|
||||
function patchEventSource(origin: string) {
|
||||
if (typeof window.EventSource === 'undefined') {
|
||||
return;
|
||||
}
|
||||
|
||||
const OriginalEventSource = window.EventSource;
|
||||
|
||||
class DesktopEventSource extends OriginalEventSource {
|
||||
constructor(url: string | URL, eventSourceInit?: EventSourceInit) {
|
||||
const normalized = typeof url === 'string' ? url : url.toString();
|
||||
super(normalized.startsWith('/') ? `${origin}${normalized}` : normalized, eventSourceInit);
|
||||
}
|
||||
}
|
||||
|
||||
Object.defineProperty(DesktopEventSource, 'name', { value: 'DesktopEventSource' });
|
||||
Object.setPrototypeOf(DesktopEventSource.prototype, OriginalEventSource.prototype);
|
||||
Object.setPrototypeOf(DesktopEventSource, OriginalEventSource);
|
||||
|
||||
window.EventSource = DesktopEventSource as unknown as typeof EventSource;
|
||||
}
|
||||
|
||||
function registerDevtoolsShortcut() {
|
||||
const handler = (event: KeyboardEvent) => {
|
||||
const key = event.key?.toLowerCase();
|
||||
if ((event.metaKey || event.ctrlKey) && event.altKey && key === 'i') {
|
||||
event.preventDefault();
|
||||
|
||||
const devtoolsPromise = safeInvoke('desktop_open_devtools', {}, {
|
||||
timeout: 2000,
|
||||
onCancel: () => {
|
||||
console.warn('[Bridge] Devtools invocation timed out');
|
||||
}
|
||||
});
|
||||
devtoolsPromise.catch(() => {
|
||||
|
||||
});
|
||||
}
|
||||
};
|
||||
window.addEventListener('keydown', handler);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('keydown', handler);
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,308 @@
|
||||
|
||||
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { listen, type UnlistenFn } from '@tauri-apps/api/event';
|
||||
|
||||
interface PendingCallback {
|
||||
id: string;
|
||||
timestamp: number;
|
||||
type: 'invoke' | 'listen';
|
||||
cleanup?: () => void;
|
||||
timeout?: NodeJS.Timeout;
|
||||
}
|
||||
|
||||
interface CallbackManagerConfig {
|
||||
maxCallbackAge?: number;
|
||||
cleanupInterval?: number;
|
||||
invokeTimeout?: number;
|
||||
listenTimeout?: number;
|
||||
}
|
||||
|
||||
class TauriCallbackManager {
|
||||
private callbacks = new Map<string, PendingCallback>();
|
||||
private isShuttingDown = false;
|
||||
private cleanupTimer?: NodeJS.Timeout;
|
||||
private config: Required<CallbackManagerConfig>;
|
||||
private windowUnloadHandler?: () => void;
|
||||
|
||||
constructor(config: CallbackManagerConfig = {}) {
|
||||
this.config = {
|
||||
maxCallbackAge: 30000,
|
||||
cleanupInterval: 5000,
|
||||
invokeTimeout: 10000,
|
||||
listenTimeout: 30000,
|
||||
...config,
|
||||
};
|
||||
|
||||
this.setupWindowUnloadHandler();
|
||||
this.startCleanupTimer();
|
||||
}
|
||||
|
||||
register(callback: Omit<PendingCallback, 'timestamp'>): string {
|
||||
if (this.isShuttingDown) {
|
||||
console.warn('[TauriCallbackManager] Attempted to register callback during shutdown');
|
||||
return callback.id;
|
||||
}
|
||||
|
||||
const fullCallback: PendingCallback = {
|
||||
...callback,
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
|
||||
this.callbacks.set(callback.id, fullCallback);
|
||||
|
||||
if (callback.type === 'listen' && this.config.listenTimeout > 0) {
|
||||
const timeout = setTimeout(() => {
|
||||
this.cleanupCallback(callback.id, 'timeout');
|
||||
}, this.config.listenTimeout);
|
||||
fullCallback.timeout = timeout;
|
||||
}
|
||||
|
||||
return callback.id;
|
||||
}
|
||||
|
||||
unregister(callbackId: string): void {
|
||||
const callback = this.callbacks.get(callbackId);
|
||||
if (!callback) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (callback.timeout) {
|
||||
clearTimeout(callback.timeout);
|
||||
}
|
||||
|
||||
if (callback.cleanup) {
|
||||
try {
|
||||
callback.cleanup();
|
||||
} catch (error) {
|
||||
console.warn('[TauriCallbackManager] Cleanup function failed:', error);
|
||||
}
|
||||
}
|
||||
|
||||
this.callbacks.delete(callbackId);
|
||||
}
|
||||
|
||||
private cleanupCallback(callbackId: string, reason: 'timeout' | 'shutdown' | 'expired'): void {
|
||||
const callback = this.callbacks.get(callbackId);
|
||||
if (!callback) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (reason === 'expired') {
|
||||
console.warn(`[TauriCallbackManager] Callback ${callbackId} expired and was cleaned up`);
|
||||
}
|
||||
|
||||
this.unregister(callbackId);
|
||||
}
|
||||
|
||||
cleanupAll(): void {
|
||||
this.isShuttingDown = true;
|
||||
|
||||
if (this.cleanupTimer) {
|
||||
clearInterval(this.cleanupTimer);
|
||||
this.cleanupTimer = undefined;
|
||||
}
|
||||
|
||||
const callbackIds = Array.from(this.callbacks.keys());
|
||||
callbackIds.forEach(id => this.cleanupCallback(id, 'shutdown'));
|
||||
|
||||
this.callbacks.clear();
|
||||
}
|
||||
|
||||
private startCleanupTimer(): void {
|
||||
this.cleanupTimer = setInterval(() => {
|
||||
if (this.isShuttingDown) {
|
||||
return;
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
const expiredCallbacks: string[] = [];
|
||||
|
||||
this.callbacks.forEach((callback, id) => {
|
||||
const age = now - callback.timestamp;
|
||||
if (age > this.config.maxCallbackAge) {
|
||||
expiredCallbacks.push(id);
|
||||
}
|
||||
});
|
||||
|
||||
expiredCallbacks.forEach(id => this.cleanupCallback(id, 'expired'));
|
||||
}, this.config.cleanupInterval);
|
||||
}
|
||||
|
||||
private setupWindowUnloadHandler(): void {
|
||||
if (typeof window === 'undefined') {
|
||||
return;
|
||||
}
|
||||
|
||||
this.windowUnloadHandler = () => {
|
||||
console.info('[TauriCallbackManager] Window unloading, cleaning up callbacks...');
|
||||
this.cleanupAll();
|
||||
};
|
||||
|
||||
window.addEventListener('beforeunload', this.windowUnloadHandler);
|
||||
window.addEventListener('pagehide', this.windowUnloadHandler);
|
||||
}
|
||||
|
||||
removeWindowHandlers(): void {
|
||||
if (this.windowUnloadHandler && typeof window !== 'undefined') {
|
||||
window.removeEventListener('beforeunload', this.windowUnloadHandler);
|
||||
window.removeEventListener('pagehide', this.windowUnloadHandler);
|
||||
this.windowUnloadHandler = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
getStats(): { total: number; invoke: number; listen: number } {
|
||||
const stats = { total: 0, invoke: 0, listen: 0 };
|
||||
|
||||
this.callbacks.forEach(callback => {
|
||||
stats.total++;
|
||||
stats[callback.type]++;
|
||||
});
|
||||
|
||||
return stats;
|
||||
}
|
||||
}
|
||||
|
||||
let globalCallbackManager: TauriCallbackManager | null = null;
|
||||
|
||||
export function getTauriCallbackManager(config?: CallbackManagerConfig): TauriCallbackManager {
|
||||
if (!globalCallbackManager) {
|
||||
globalCallbackManager = new TauriCallbackManager(config);
|
||||
}
|
||||
return globalCallbackManager;
|
||||
}
|
||||
|
||||
export async function safeInvoke<T>(
|
||||
command: string,
|
||||
args?: Record<string, unknown>,
|
||||
options?: {
|
||||
timeout?: number;
|
||||
onCancel?: () => void;
|
||||
}
|
||||
): Promise<T> {
|
||||
const manager = getTauriCallbackManager();
|
||||
const callbackId = `invoke:${command}:${Date.now()}:${Math.random().toString(36).slice(2)}`;
|
||||
|
||||
let timeoutHandle: NodeJS.Timeout | undefined;
|
||||
let settled = false;
|
||||
|
||||
manager.register({
|
||||
id: callbackId,
|
||||
type: 'invoke',
|
||||
});
|
||||
|
||||
const clearAndUnregister = () => {
|
||||
if (timeoutHandle) {
|
||||
clearTimeout(timeoutHandle);
|
||||
timeoutHandle = undefined;
|
||||
}
|
||||
manager.unregister(callbackId);
|
||||
};
|
||||
|
||||
if (!options?.timeout || options.timeout <= 0) {
|
||||
try {
|
||||
const result = await invoke<T>(command, args);
|
||||
clearAndUnregister();
|
||||
return result;
|
||||
} catch (error) {
|
||||
clearAndUnregister();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
return new Promise<T>((resolve, reject) => {
|
||||
timeoutHandle = setTimeout(() => {
|
||||
if (settled) {
|
||||
return;
|
||||
}
|
||||
settled = true;
|
||||
|
||||
console.warn(`[safeInvoke] Command ${command} timed out after ${options.timeout}ms`);
|
||||
try {
|
||||
options.onCancel?.();
|
||||
} catch (error) {
|
||||
console.warn('[safeInvoke] onCancel handler threw:', error);
|
||||
}
|
||||
|
||||
clearAndUnregister();
|
||||
reject(new Error(`Command ${command} timed out after ${options.timeout}ms`));
|
||||
}, options.timeout);
|
||||
|
||||
invoke<T>(command, args)
|
||||
.then((result) => {
|
||||
if (settled) {
|
||||
|
||||
return;
|
||||
}
|
||||
settled = true;
|
||||
clearAndUnregister();
|
||||
resolve(result);
|
||||
})
|
||||
.catch((error) => {
|
||||
if (settled) {
|
||||
return;
|
||||
}
|
||||
settled = true;
|
||||
clearAndUnregister();
|
||||
reject(error);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export async function safeListen<T>(
|
||||
event: string,
|
||||
handler: (event: { payload: T }) => void,
|
||||
options?: {
|
||||
timeout?: number;
|
||||
onCancel?: () => void;
|
||||
}
|
||||
): Promise<UnlistenFn> {
|
||||
const manager = getTauriCallbackManager();
|
||||
const callbackId = `listen:${event}:${Date.now()}:${Math.random().toString(36).slice(2)}`;
|
||||
|
||||
try {
|
||||
|
||||
manager.register({
|
||||
id: callbackId,
|
||||
type: 'listen',
|
||||
cleanup: options?.onCancel,
|
||||
});
|
||||
|
||||
const unlisten = await listen<T>(event, (event) => {
|
||||
|
||||
const currentManager = getTauriCallbackManager();
|
||||
if (currentManager.getStats().total === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
handler(event);
|
||||
} catch (error) {
|
||||
console.error(`[safeListen] Handler error for event ${event}:`, error);
|
||||
}
|
||||
});
|
||||
|
||||
const enhancedUnlisten = () => {
|
||||
try {
|
||||
unlisten();
|
||||
} catch (error) {
|
||||
console.warn(`[safeListen] Failed to unlisten from ${event}:`, error);
|
||||
}
|
||||
manager.unregister(callbackId);
|
||||
};
|
||||
|
||||
return enhancedUnlisten;
|
||||
} catch (error) {
|
||||
|
||||
manager.unregister(callbackId);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export function cleanupAllTauriCallbacks(): void {
|
||||
if (globalCallbackManager) {
|
||||
globalCallbackManager.cleanupAll();
|
||||
globalCallbackManager.removeWindowHandlers();
|
||||
globalCallbackManager = null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,254 @@
|
||||
import { createDesktopAPIs } from './api';
|
||||
import { requestInitialNotificationPermission } from './api/notifications';
|
||||
import { checkForUpdates, downloadUpdate, restartToUpdate, type UpdateInfo, type UpdateProgress } from './api/updater';
|
||||
import { initializeDesktopBridge } from './lib/bridge';
|
||||
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { listen } from '@tauri-apps/api/event';
|
||||
import type { RuntimeAPIs } from '@openchamber/ui/lib/api/types';
|
||||
import type { DesktopApi, DesktopSettings } from '@openchamber/ui/lib/desktop';
|
||||
import '@openchamber/ui/index.css';
|
||||
import '@openchamber/ui/styles/fonts';
|
||||
|
||||
if (!(window as typeof globalThis & { process?: unknown }).process) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(window as typeof globalThis & { process?: any }).process = {
|
||||
env: {},
|
||||
platform: 'darwin',
|
||||
version: 'v20.0.0',
|
||||
versions: {},
|
||||
cwd: () => '/',
|
||||
nextTick: (fn: () => void) => Promise.resolve().then(() => fn()),
|
||||
};
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
__OPENCHAMBER_RUNTIME_APIS__?: RuntimeAPIs;
|
||||
__OPENCHAMBER_HOME__?: string;
|
||||
opencodeDesktop?: DesktopApi;
|
||||
}
|
||||
}
|
||||
|
||||
const cleanupFunctions: Array<() => void | Promise<void>> = [];
|
||||
|
||||
try {
|
||||
await initializeDesktopBridge();
|
||||
|
||||
const activityUnlisten = await listen('openchamber:session-activity', (event) => {
|
||||
window.dispatchEvent(new CustomEvent('openchamber:session-activity', { detail: event.payload }));
|
||||
});
|
||||
cleanupFunctions.push(() => activityUnlisten());
|
||||
|
||||
requestInitialNotificationPermission().catch(err => {
|
||||
console.error('[main] Failed to request notification permission:', err);
|
||||
});
|
||||
|
||||
window.__OPENCHAMBER_RUNTIME_APIS__ = createDesktopAPIs();
|
||||
|
||||
cleanupFunctions.push(() => {
|
||||
console.info('[main] Cleaning up runtime APIs');
|
||||
|
||||
if (window.__OPENCHAMBER_RUNTIME_APIS__) {
|
||||
/* cleanup placeholder */
|
||||
}
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('[main] FATAL: Failed to initialize desktop runtime:', error);
|
||||
|
||||
for (const cleanup of cleanupFunctions) {
|
||||
try {
|
||||
const result = cleanup();
|
||||
if (result instanceof Promise) {
|
||||
await result;
|
||||
}
|
||||
} catch (cleanupError) {
|
||||
console.warn('[main] Cleanup function failed during error handling:', cleanupError);
|
||||
}
|
||||
}
|
||||
|
||||
document.body.innerHTML = `
|
||||
<div style="padding: 40px; font-family: monospace; color: #ff6b6b; background: #1a1a1a; height: 100vh;">
|
||||
<h1>Desktop Runtime Initialization Failed</h1>
|
||||
<pre style="background: #2a2a2a; padding: 20px; border-radius: 8px; overflow: auto;">
|
||||
${error instanceof Error ? error.stack : String(error)}
|
||||
</pre>
|
||||
<p style="margin-top: 20px; color: #999;">Press Cmd+Option+I to open DevTools for more details</p>
|
||||
</div>
|
||||
`;
|
||||
throw error;
|
||||
}
|
||||
|
||||
let homeDirectory: string | undefined;
|
||||
try {
|
||||
const { homeDir } = await import('@tauri-apps/api/path');
|
||||
homeDirectory = await homeDir();
|
||||
} catch {
|
||||
homeDirectory = undefined;
|
||||
}
|
||||
|
||||
if (homeDirectory) {
|
||||
window.__OPENCHAMBER_HOME__ = homeDirectory;
|
||||
}
|
||||
|
||||
window.opencodeDesktop = {
|
||||
homeDirectory,
|
||||
async getServerInfo() {
|
||||
const server = window.__OPENCHAMBER_DESKTOP_SERVER__;
|
||||
return {
|
||||
webPort: server?.origin ? parseInt(server.origin.split(':')[2] || '0', 10) : null,
|
||||
openCodePort: server?.opencodePort ?? null,
|
||||
host: '127.0.0.1',
|
||||
ready: true,
|
||||
cliAvailable: server?.cliAvailable ?? false,
|
||||
};
|
||||
},
|
||||
async getSettings(): Promise<DesktopSettings> {
|
||||
try {
|
||||
const result = await invoke<{ settings: DesktopSettings; source: string }>('load_settings');
|
||||
return result.settings;
|
||||
} catch (error) {
|
||||
console.error('[desktop] Error loading settings:', error);
|
||||
return {} as DesktopSettings;
|
||||
}
|
||||
},
|
||||
async updateSettings(changes: Partial<DesktopSettings>): Promise<DesktopSettings> {
|
||||
try {
|
||||
const result = await invoke<DesktopSettings>('save_settings', { changes });
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.error('[desktop] Error updating settings:', error);
|
||||
return {};
|
||||
}
|
||||
},
|
||||
async restartOpenCode() {
|
||||
try {
|
||||
await invoke('restart_opencode');
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
console.error('[desktop] Error restarting OpenCode:', error);
|
||||
return { success: false };
|
||||
}
|
||||
},
|
||||
async shutdown() {
|
||||
return { success: false };
|
||||
},
|
||||
async getHomeDirectory() {
|
||||
return { success: true, path: homeDirectory || null };
|
||||
},
|
||||
markRendererReady() {
|
||||
|
||||
},
|
||||
async requestDirectoryAccess() {
|
||||
try {
|
||||
|
||||
const { open } = await import('@tauri-apps/plugin-dialog');
|
||||
const selected = await open({
|
||||
directory: true,
|
||||
multiple: false,
|
||||
title: 'Select Working Directory'
|
||||
});
|
||||
|
||||
if (!selected || typeof selected !== 'string') {
|
||||
return { success: false, error: 'Directory selection cancelled' };
|
||||
}
|
||||
|
||||
const result = await invoke<{ success: boolean; path?: string; error?: string }>('process_directory_selection', {
|
||||
path: selected
|
||||
});
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.error('[desktop] Error requesting directory access:', error);
|
||||
return { success: false, error: error instanceof Error ? error.message : String(error) };
|
||||
}
|
||||
},
|
||||
async startAccessingDirectory(directoryPath: string) {
|
||||
try {
|
||||
const result = await invoke<{ success: boolean; error?: string }>('start_accessing_directory', { path: directoryPath });
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.error('[desktop] Error starting directory access:', error);
|
||||
return { success: false, error: error instanceof Error ? error.message : String(error) };
|
||||
}
|
||||
},
|
||||
|
||||
async stopAccessingDirectory(directoryPath: string) {
|
||||
try {
|
||||
const result = await invoke<{ success: boolean; error?: string }>('stop_accessing_directory', { path: directoryPath });
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.error('[desktop] Error stopping directory access:', error);
|
||||
return { success: false, error: error instanceof Error ? error.message : String(error) };
|
||||
}
|
||||
},
|
||||
async notifyAssistantCompletion(payload) {
|
||||
try {
|
||||
const { createDesktopNotificationsAPI } = await import('./api/notifications');
|
||||
const result = await createDesktopNotificationsAPI().notifyAgentCompletion(payload);
|
||||
return { success: result };
|
||||
} catch (error) {
|
||||
console.error('[desktop] Error sending notification:', error);
|
||||
return { success: false };
|
||||
}
|
||||
},
|
||||
async checkForUpdates(): Promise<UpdateInfo> {
|
||||
return checkForUpdates();
|
||||
},
|
||||
async downloadUpdate(onProgress?: (progress: UpdateProgress) => void): Promise<void> {
|
||||
return downloadUpdate(onProgress);
|
||||
},
|
||||
async restartToUpdate(): Promise<void> {
|
||||
return restartToUpdate();
|
||||
}
|
||||
};
|
||||
|
||||
console.info('[main] window.opencodeDesktop assigned');
|
||||
|
||||
if (typeof window !== 'undefined') {
|
||||
const handleBeforeUnload = () => {
|
||||
console.info('[main] App is unloading, performing cleanup...');
|
||||
|
||||
cleanupFunctions.forEach((cleanup) => {
|
||||
try {
|
||||
const result = cleanup();
|
||||
if (result instanceof Promise) {
|
||||
|
||||
result.catch(cleanupError => {
|
||||
console.warn('[main] Cleanup function failed during unload:', cleanupError);
|
||||
});
|
||||
}
|
||||
} catch (cleanupError) {
|
||||
console.warn('[main] Cleanup function failed during unload:', cleanupError);
|
||||
}
|
||||
});
|
||||
|
||||
console.info('[main] Cleanup initiated');
|
||||
};
|
||||
|
||||
window.addEventListener('beforeunload', handleBeforeUnload);
|
||||
|
||||
window.addEventListener('pagehide', handleBeforeUnload);
|
||||
|
||||
cleanupFunctions.push(() => {
|
||||
window.removeEventListener('beforeunload', handleBeforeUnload);
|
||||
window.removeEventListener('pagehide', handleBeforeUnload);
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
await import('@openchamber/ui/main');
|
||||
} catch (error) {
|
||||
console.error('[main] FATAL: Failed to load UI module:', error);
|
||||
document.body.innerHTML = `
|
||||
<div style="padding: 40px; font-family: monospace; color: #ff6b6b; background: #1a1a1a; height: 100vh;">
|
||||
<h1>UI Module Load Failed</h1>
|
||||
<pre style="background: #2a2a2a; padding: 20px; border-radius: 8px; overflow: auto;">
|
||||
${error instanceof Error ? error.stack : String(error)}
|
||||
</pre>
|
||||
<p style="margin-top: 20px; color: #999;">Check DevTools console for details</p>
|
||||
</div>
|
||||
`;
|
||||
throw error;
|
||||
}
|
||||
Reference in New Issue
Block a user