From 2567e5a88d278f2717f9dcea03a527f40a3a22c1 Mon Sep 17 00:00:00 2001 From: "Andrew @ Conduit" Date: Sun, 24 May 2026 22:13:26 -0500 Subject: [PATCH] feat(collab): live collaboration IPC, relay client, self-hosting settings --- package-lock.json | 10 +- packages/desktop/package.json | 4 +- packages/desktop/src/main.ts | 46 +++++++ .../desktop/src/main/collaboration/client.ts | 129 ++++++++++++++++++ .../desktop/src/main/collaboration/types.ts | 49 +++++++ packages/desktop/src/main/ipc.ts | 94 ++++++++++++- packages/desktop/src/main/store/settings.ts | 40 ++++++ packages/desktop/src/preload.ts | 48 +++++++ packages/desktop/src/renderer/env.d.ts | 28 ++++ 9 files changed, 440 insertions(+), 8 deletions(-) create mode 100644 packages/desktop/src/main/collaboration/client.ts create mode 100644 packages/desktop/src/main/collaboration/types.ts diff --git a/package-lock.json b/package-lock.json index 3e3be81..e5a2906 100644 --- a/package-lock.json +++ b/package-lock.json @@ -3403,9 +3403,9 @@ } }, "node_modules/@openconduit/core": { - "version": "2.0.0-alpha.12", - "resolved": "https://npm.pkg.github.com/download/@openconduit/core/2.0.0-alpha.12/f99bc8e953492e4787f13ff129cd7b717df51c08", - "integrity": "sha512-WVau7cF4QMYtkGA9ojEhD1GqZPOIyKagP5fbmbEGBf+mGih07S6sl0R3+37goXYiQg3ZsVJjqWASFq1oAVjsBQ==", + "version": "2.0.0-beta.1", + "resolved": "https://npm.pkg.github.com/download/@openconduit/core/2.0.0-beta.1/c6ad82c88b71fc1e04a6ca1beab830db8f4fe482", + "integrity": "sha512-xW6Gv5x6FIwqseBpGbPM4KyN2sEPcsFgDYOZ9wtDQDgiTwAZg+bmNAx1vQk9L5o35RcSrn55avP1nhf946/xvQ==", "license": "AGPL-3.0", "dependencies": { "@codemirror/commands": "^6.10.3", @@ -17894,7 +17894,7 @@ }, "packages/desktop": { "name": "openconduit", - "version": "2.0.0-alpha.6", + "version": "2.0.0-beta.1", "license": "AGPL-3.0-only", "dependencies": { "@anthropic-ai/foundry-sdk": "^0.2.3", @@ -17902,7 +17902,7 @@ "@aws-sdk/client-bedrock-runtime": "^3.1053.0", "@google/genai": "^2.3.0", "@modelcontextprotocol/sdk": "^1.29.0", - "@openconduit/core": "^2.0.0-alpha.12", + "@openconduit/core": "^2.0.0-beta.1", "@types/pdf-parse": "^1.1.5", "electron-squirrel-startup": "^1.0.1", "electron-store": "^11.0.2", diff --git a/packages/desktop/package.json b/packages/desktop/package.json index 98fb530..b0cb192 100644 --- a/packages/desktop/package.json +++ b/packages/desktop/package.json @@ -1,7 +1,7 @@ { "name": "openconduit", "productName": "OpenConduit", - "version": "2.0.0-alpha.6", + "version": "2.0.0-beta.1", "description": "Cross-platform desktop AI chat client — OpenAI, Anthropic, LM Studio, and MCP tool servers", "main": ".vite/build/main.js", "private": true, @@ -58,7 +58,7 @@ "@aws-sdk/client-bedrock-runtime": "^3.1053.0", "@google/genai": "^2.3.0", "@modelcontextprotocol/sdk": "^1.29.0", - "@openconduit/core": "^2.0.0-alpha.12", + "@openconduit/core": "^2.0.0-beta.1", "@types/pdf-parse": "^1.1.5", "electron-squirrel-startup": "^1.0.1", "electron-store": "^11.0.2", diff --git a/packages/desktop/src/main.ts b/packages/desktop/src/main.ts index e3cd443..e87a448 100644 --- a/packages/desktop/src/main.ts +++ b/packages/desktop/src/main.ts @@ -12,6 +12,28 @@ if (started) app.quit(); // Pin userData to a stable name so it never moves when productName changes. app.setPath('userData', path.join(app.getPath('appData'), 'openconduit')); +// Register openconduit:// as a deep-link protocol (e.g. openconduit://join?roomId=xxx) +app.setAsDefaultProtocolClient('openconduit'); + +// Ensure only one instance handles deep links on Windows/Linux. +// On macOS the OS enforces single-instance and fires 'open-url' instead. +const gotLock = app.requestSingleInstanceLock(); +if (!gotLock) app.quit(); + +function handleDeepLink(url: string): void { + try { + const parsed = new URL(url); + if (parsed.hostname === 'join') { + const roomId = parsed.searchParams.get('roomId'); + if (!roomId) return; + for (const win of BrowserWindow.getAllWindows()) { + win.webContents.send('collab:join-invite', roomId); + win.focus(); + } + } + } catch { /* malformed URL — ignore */ } +} + declare const MAIN_WINDOW_VITE_DEV_SERVER_URL: string; declare const MAIN_WINDOW_VITE_NAME: string; @@ -191,6 +213,30 @@ process.on('unhandledRejection', (reason) => { void fireTelemetryCrash(err); }); +// macOS: deep link fired while app is already running +app.on('open-url', (event, url) => { + event.preventDefault(); + handleDeepLink(url); +}); + +// Windows / Linux: second instance was launched with the URL in argv +app.on('second-instance', (_event, argv) => { + const url = argv.find((arg) => arg.startsWith('openconduit://')); + if (url) handleDeepLink(url); + + // Bring existing window to front + const [win] = BrowserWindow.getAllWindows(); + if (win) { if (win.isMinimized()) win.restore(); win.focus(); } +}); + +// macOS: deep link when app is launched cold (URL in process.argv) +app.on('will-finish-launching', () => { + app.on('open-url', (event, url) => { + event.preventDefault(); + handleDeepLink(url); + }); +}); + app.on('window-all-closed', () => { destroyBrowserWindow(); if (process.platform !== 'darwin') app.quit(); diff --git a/packages/desktop/src/main/collaboration/client.ts b/packages/desktop/src/main/collaboration/client.ts new file mode 100644 index 0000000..bbce6df --- /dev/null +++ b/packages/desktop/src/main/collaboration/client.ts @@ -0,0 +1,129 @@ +/** + * CollaborationClient — manages a single WebSocket connection to a + * ConversationRoom Durable Object on share.openconduit.ai. + * + * Lives in the Electron main process. Bridges IPC calls from the renderer + * and pushes room events back via webContents.send('collab:event', event). + */ + +import { BrowserWindow } from 'electron'; +import type { ClientEvent, ServerEvent } from './types'; +import { getSettings } from '../store/settings'; + +export type { ClientEvent, ServerEvent }; + +const DEFAULT_SHARE_BASE = 'https://share.openconduit.ai'; + +/** Returns the configured base HTTP URL (falls back to the hosted service). */ +function shareBase(): string { + return getSettings().selfHosting?.shareServerUrl?.replace(/\/$/, '') || DEFAULT_SHARE_BASE; +} + +/** Derives the WebSocket base URL from the HTTP base URL. */ +function wsBase(): string { + return shareBase().replace(/^https:/, 'wss:').replace(/^http:/, 'ws:'); +} + +/** Maximum reconnect delay in ms (exponential backoff caps here). */ +const MAX_BACKOFF_MS = 16_000; + +interface RoomSession { + roomId: string; + ws: WebSocket; + reconnectDelay: number; + reconnecting: boolean; + destroyed: boolean; +} + +let session: RoomSession | null = null; + +// ─── Public API (called from ipc.ts) ───────────────────────────────────────── + +/** Create a new room, optionally seeding it with an existing conversation. */ +export async function createRoom(seed?: unknown): Promise<{ roomId: string; wsUrl: string; inviteUrl: string }> { + const res = await fetch(`${shareBase()}/rooms`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: seed ? JSON.stringify(seed) : '{}', + }); + if (!res.ok) throw new Error(`Failed to create room: ${res.status}`); + return res.json() as Promise<{ roomId: string; wsUrl: string; inviteUrl: string }>; +} + +/** Connect to an existing room. Triggers a 'sync' event on success. */ +export function joinRoom(roomId: string, name: string, color: string): void { + if (session) destroySession(); + + const ws = new WebSocket(`${wsBase()}/rooms/${roomId}`); + session = { roomId, ws, reconnectDelay: 1_000, reconnecting: false, destroyed: false }; + + ws.addEventListener('open', () => { + session!.reconnectDelay = 1_000; + send({ type: 'join', name, color }); + }); + + ws.addEventListener('message', (ev) => { + try { + const event = JSON.parse(ev.data as string) as ServerEvent; + pushToRenderer(event); + } catch { /* malformed frame — ignore */ } + }); + + ws.addEventListener('close', () => { + if (session?.destroyed) return; + scheduleReconnect(roomId, name, color); + }); + + ws.addEventListener('error', () => { + // 'close' fires right after; reconnect logic lives there + }); +} + +/** Disconnect from the current room. */ +export function leaveRoom(): void { + if (session) { + send({ type: 'leave' }); + destroySession(); + } +} + +/** Send a ClientEvent to the room. No-op if not connected. */ +export function sendToRoom(event: ClientEvent): void { + send(event); +} + +// ─── Internal helpers ───────────────────────────────────────────────────────── + +function send(event: ClientEvent): void { + if (session?.ws.readyState === WebSocket.OPEN) { + session.ws.send(JSON.stringify(event)); + } +} + +function destroySession(): void { + if (!session) return; + session.destroyed = true; + try { session.ws.close(); } catch { /* already closed */ } + session = null; +} + +function scheduleReconnect(roomId: string, name: string, color: string): void { + if (!session || session.destroyed) return; + const delay = session.reconnectDelay; + session.reconnectDelay = Math.min(delay * 2, MAX_BACKOFF_MS); + session.reconnecting = true; + + pushToRenderer({ type: 'error', message: `Disconnected — reconnecting in ${delay / 1000}s` } as ServerEvent); + + setTimeout(() => { + if (!session || session.destroyed) return; + joinRoom(roomId, name, color); + }, delay); +} + +function pushToRenderer(event: ServerEvent): void { + const wins = BrowserWindow.getAllWindows(); + for (const win of wins) { + if (!win.isDestroyed()) win.webContents.send('collab:event', event); + } +} diff --git a/packages/desktop/src/main/collaboration/types.ts b/packages/desktop/src/main/collaboration/types.ts new file mode 100644 index 0000000..edb929e --- /dev/null +++ b/packages/desktop/src/main/collaboration/types.ts @@ -0,0 +1,49 @@ +/** + * Shared types for the collaboration wire protocol. + * Mirrors workers/share/src/room.ts — keep in sync. + */ + +export interface CollabParticipant { + id: string; + name: string; + color: string; +} + +export interface RelayMessage { + id: string; + role: string; + content: string; + timestamp: number; + thinking?: string; + toolCalls?: unknown[]; + usage?: unknown; + model?: string; + providerId?: string; + [key: string]: unknown; +} + +export type ClientEvent = + | { type: 'join'; name: string; color: string } + | { type: 'leave' } + | { type: 'lock_request' } + | { type: 'lock_release' } + | { type: 'message_add'; message: RelayMessage } + | { type: 'stream_start'; messageId: string } + | { type: 'stream_chunk'; messageId: string; delta: string } + | { type: 'stream_end'; messageId: string; message: RelayMessage } + | { type: 'typing'; isTyping: boolean } + | { type: 'set_ai_mode'; mode: 'own' | 'host' }; + +export type ServerEvent = + | { type: 'sync'; messages: RelayMessage[]; participants: CollabParticipant[]; lockHolder: string | null; yourId: string; aiMode: 'own' | 'host'; hostId: string | null } + | { type: 'participant_joined'; participant: CollabParticipant } + | { type: 'participant_left'; participantId: string } + | { type: 'lock_granted'; participantId: string } + | { type: 'lock_denied'; reason: string } + | { type: 'lock_released'; nextHolder: string | null } + | { type: 'message_add'; message: RelayMessage; participantId: string } + | { type: 'stream_start'; messageId: string; participantId: string } + | { type: 'stream_chunk'; messageId: string; delta: string; participantId: string } + | { type: 'stream_end'; messageId: string; message: RelayMessage; participantId: string } + | { type: 'typing'; participantId: string; isTyping: boolean } + | { type: 'error'; message: string }; diff --git a/packages/desktop/src/main/ipc.ts b/packages/desktop/src/main/ipc.ts index bb35489..3f3d26c 100644 --- a/packages/desktop/src/main/ipc.ts +++ b/packages/desktop/src/main/ipc.ts @@ -31,7 +31,7 @@ import { pullFromRemote, getRepoStatus, } from './sync'; -import { getSettings, setSettings, settingsStore, storeLastCrash, getStoredCrash, clearStoredCrash } from './store/settings'; +import { getSettings, setSettings, settingsStore, storeLastCrash, getStoredCrash, clearStoredCrash, getMachineId, addShare, listShares, removeShare, type ShareRecord } from './store/settings'; import { connectMcpServer, disconnectMcpServer, @@ -49,6 +49,13 @@ import { streamGemini } from './providers/gemini'; import { streamBedrock } from './providers/bedrock'; import { streamCopilot, startCopilotAuth, pollCopilotAuth, listCopilotModels, getCopilotUsage } from './providers/copilot'; import { evaluateRouting } from './routing'; +import { + createRoom, + joinRoom, + leaveRoom, + sendToRoom, +} from './collaboration/client'; +import type { ClientEvent } from './collaboration/types'; const EXTENSION_SERVER_ID = '__extension__'; const TELEMETRY_PRIMARY = 'https://updates.openconduit.ai'; @@ -1365,6 +1372,91 @@ export function registerIpcHandlers(): void { const dir = settings.syncRepoPath ?? ''; return getRepoStatus(dir); }); + + // ─── Conversation share (V1) ───────────────────────────────────────────── + + const SHARE_BASE = (getSettings().selfHosting?.shareServerUrl?.replace(/\/$/, '') || 'https://share.openconduit.ai'); + + ipcMain.handle('conversation:share', async (_e, conversation: unknown): Promise<{ id: string; url: string }> => { + const machineId = getMachineId(); + const conv = conversation as { title?: string; messages?: unknown[] }; + const title = conv?.title + ?? (Array.isArray(conv?.messages) && conv.messages.length > 0 + ? String((conv.messages[0] as { content?: string })?.content ?? '').slice(0, 60) + : 'Untitled conversation'); + + const res = await fetch(`${SHARE_BASE}/share`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ conversation, machineId, title }), + }); + if (!res.ok) { + const text = await res.text().catch(() => ''); + throw new Error(`Share failed (${res.status}): ${text}`); + } + const data = await res.json() as { id: string; url: string }; + addShare({ id: data.id, url: data.url, title, createdAt: Date.now() }); + return { id: data.id, url: data.url }; + }); + + ipcMain.handle('conversation:list-shares', (): ShareRecord[] => listShares()); + + ipcMain.handle('conversation:delete-share', async (_e, id: string): Promise => { + const machineId = getMachineId(); + // Best-effort remote delete — don't throw if network fails + try { + await fetch(`${SHARE_BASE}/share/${id}`, { + method: 'DELETE', + headers: { 'X-Machine-Id': machineId }, + }); + } catch { /* offline — still remove locally */ } + removeShare(id); + }); + + ipcMain.handle('conversation:export-html', async (_e, conversation: unknown): Promise => { + const { filePath } = await dialog.showSaveDialog({ + title: 'Export conversation as HTML', + defaultPath: `conversation-${Date.now()}.html`, + filters: [{ name: 'HTML file', extensions: ['html'] }], + }); + if (!filePath) return false; + + // Ask the renderer to generate the HTML (it has the export utility) + const win = BrowserWindow.getAllWindows()[0]; + if (!win) return false; + const html = await win.webContents.executeJavaScript( + `window.__exportConversationHtml?.(${JSON.stringify(JSON.stringify(conversation))})`, + ); + if (!html) return false; + await fs.writeFile(filePath, html, 'utf-8'); + return true; + }); + + // ─── Live collaboration (V2) ────────────────────────────────────────────── + + ipcMain.handle('collab:create', async (_e, seed?: unknown): Promise<{ roomId: string; wsUrl: string; inviteUrl: string }> => { + return createRoom(seed); + }); + + ipcMain.handle('collab:join', (_e, roomId: string, name: string, color: string): void => { + joinRoom(roomId, name, color); + }); + + ipcMain.handle('collab:leave', (): void => { + leaveRoom(); + }); + + ipcMain.handle('collab:send', (_e, event: ClientEvent): void => { + sendToRoom(event); + }); + + ipcMain.handle('collab:lock-request', (): void => { + sendToRoom({ type: 'lock_request' }); + }); + + ipcMain.handle('collab:lock-release', (): void => { + sendToRoom({ type: 'lock_release' }); + }); } function requestApproval( diff --git a/packages/desktop/src/main/store/settings.ts b/packages/desktop/src/main/store/settings.ts index f57be3e..a71baed 100644 --- a/packages/desktop/src/main/store/settings.ts +++ b/packages/desktop/src/main/store/settings.ts @@ -128,6 +128,46 @@ export function getStoredCrash(): StoredCrash | undefined { try { return diagnosticsStore.get('lastCrash') as StoredCrash | undefined; } catch (_e) { return undefined; } } +// ─── Machine identity + share records ─────────────────────────────────────── +// A stable UUID generated once per installation, used to associate shared +// conversations with this machine so they can be listed and deleted later. + +export interface ShareRecord { + /** The KV id returned by the share worker. */ + id: string; + /** Full public URL of the share. */ + url: string; + /** Short display title (conversation title or first message snippet). */ + title: string; + /** Unix timestamp (ms) when the share was created. */ + createdAt: number; +} + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +const sharesStore = new (Store as any)({ name: 'openconduit-shares' }); + +export function getMachineId(): string { + let id = sharesStore.get('machineId') as string | undefined; + if (!id) { + id = crypto.randomUUID(); + sharesStore.set('machineId', id); + } + return id; +} + +export function listShares(): ShareRecord[] { + return (sharesStore.get('shares') as ShareRecord[] | undefined) ?? []; +} + +export function addShare(record: ShareRecord): void { + const existing = listShares(); + sharesStore.set('shares', [record, ...existing]); +} + +export function removeShare(id: string): void { + sharesStore.set('shares', listShares().filter((s) => s.id !== id)); +} + export function clearStoredCrash(): void { try { diagnosticsStore.delete('lastCrash'); } catch (_e) { /* non-fatal */ } } diff --git a/packages/desktop/src/preload.ts b/packages/desktop/src/preload.ts index b53412a..46e0ff3 100644 --- a/packages/desktop/src/preload.ts +++ b/packages/desktop/src/preload.ts @@ -303,4 +303,52 @@ contextBridge.exposeInMainWorld('api', { ipcRenderer.invoke('sync:status'), }, + conversation: { + /** Upload conversation snapshot to share.openconduit.ai; returns the id and public URL. */ + share: (conversation: unknown): Promise<{ id: string; url: string }> => + ipcRenderer.invoke('conversation:share', conversation), + /** Save conversation as self-contained HTML to a user-chosen file. Returns true on success. */ + exportHtml: (conversation: unknown): Promise => + ipcRenderer.invoke('conversation:export-html', conversation), + /** List all shares created from this machine. */ + listShares: (): Promise> => + ipcRenderer.invoke('conversation:list-shares'), + /** Delete a share from the server and local list by its id. */ + deleteShare: (id: string): Promise => + ipcRenderer.invoke('conversation:delete-share', id), + }, + + collab: { + /** Create a new live room; optionally seed it with an existing conversation. */ + create: (seed?: unknown): Promise<{ roomId: string; wsUrl: string; inviteUrl: string }> => + ipcRenderer.invoke('collab:create', seed), + /** Connect to a room and send a join event. */ + join: (roomId: string, name: string, color: string): Promise => + ipcRenderer.invoke('collab:join', roomId, name, color), + /** Disconnect from the current room. */ + leave: (): Promise => + ipcRenderer.invoke('collab:leave'), + /** Send a raw ClientEvent to the room (message_add, stream_*, typing). */ + send: (event: unknown): Promise => + ipcRenderer.invoke('collab:send', event), + /** Request the send lock (turn-based access). */ + lockRequest: (): Promise => + ipcRenderer.invoke('collab:lock-request'), + /** Release the send lock so others can take a turn. */ + lockRelease: (): Promise => + ipcRenderer.invoke('collab:lock-release'), + /** Subscribe to server events pushed from the room. Returns an unsub fn. */ + onEvent: (cb: (event: unknown) => void): UnsubFn => { + const handler = (_: Electron.IpcRendererEvent, event: unknown) => cb(event); + ipcRenderer.on('collab:event', handler); + return () => ipcRenderer.removeListener('collab:event', handler); + }, + /** Subscribe to deep-link join invites (openconduit://join?roomId=…). Returns an unsub fn. */ + onInvite: (cb: (roomId: string) => void): UnsubFn => { + const handler = (_: Electron.IpcRendererEvent, roomId: string) => cb(roomId); + ipcRenderer.on('collab:join-invite', handler); + return () => ipcRenderer.removeListener('collab:join-invite', handler); + }, + }, + }); diff --git a/packages/desktop/src/renderer/env.d.ts b/packages/desktop/src/renderer/env.d.ts index ab6c7fc..16d2e0d 100644 --- a/packages/desktop/src/renderer/env.d.ts +++ b/packages/desktop/src/renderer/env.d.ts @@ -168,6 +168,34 @@ declare global { /** Returns current repo status (initialized, remoteConfigured, lastCommitAt). */ status: () => Promise<{ initialized: boolean; remoteConfigured: boolean; lastCommitAt: number | null }>; }; + conversation: { + /** Upload a conversation snapshot to share.openconduit.ai. Returns the id and public URL. */ + share: (conversation: unknown) => Promise<{ id: string; url: string }>; + /** Save conversation as self-contained HTML to a user-chosen file. */ + exportHtml: (conversation: unknown) => Promise; + /** List all shares created from this machine. */ + listShares: () => Promise; + /** Delete a share from the server and local list. */ + deleteShare: (id: string) => Promise; + }; + collab: { + /** Create a new live room; optionally seed it with an existing conversation. */ + create: (seed?: unknown) => Promise<{ roomId: string; wsUrl: string; inviteUrl: string }>; + /** Connect to a room and send a join event. */ + join: (roomId: string, name: string, color: string) => Promise; + /** Disconnect from the current room. */ + leave: () => Promise; + /** Send a raw ClientEvent to the room. */ + send: (event: import('../main/collaboration/types').ClientEvent) => Promise; + /** Request the send lock (turn-based). */ + lockRequest: () => Promise; + /** Release the send lock. */ + lockRelease: () => Promise; + /** Subscribe to server events pushed from the room. Returns an unsub fn. */ + onEvent: (cb: (event: import('../main/collaboration/types').ServerEvent) => void) => UnsubFn; + /** Subscribe to deep-link join invites (openconduit://join?roomId=…). Returns an unsub fn. */ + onInvite: (cb: (roomId: string) => void) => UnsubFn; + }; }; /** * Global SDK surface exposed for dynamically-loaded extension bundles.