Initial public release

This commit is contained in:
Bohdan Triapitsyn
2025-12-07 19:32:53 +02:00
commit 4b2edf7318
319 changed files with 81600 additions and 0 deletions
+32
View File
@@ -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');
}
},
});
+114
View File
@@ -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');
}
},
});
+230
View File
@@ -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 });
},
});
+57
View File
@@ -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`);
},
};
};
+58
View File
@@ -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;
}
}
});
+49
View File
@@ -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) };
}
},
});
+58
View File
@@ -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)}`);
}
},
});
+123
View File
@@ -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,
});
},
});
+22
View File
@@ -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();
},
});
+105
View File
@@ -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';
}
}
+157
View File
@@ -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;
}
}
+254
View File
@@ -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;
}