Initial public release
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user