Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 5 additions & 5 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 2 additions & 2 deletions packages/desktop/package.json
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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",
Expand Down
46 changes: 46 additions & 0 deletions packages/desktop/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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();
Expand Down
129 changes: 129 additions & 0 deletions packages/desktop/src/main/collaboration/client.ts
Original file line number Diff line number Diff line change
@@ -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);
}
}
49 changes: 49 additions & 0 deletions packages/desktop/src/main/collaboration/types.ts
Original file line number Diff line number Diff line change
@@ -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 };
Loading
Loading