From ddbcc272f13519e31a5d8759662bf27c2cd5d5d9 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Wed, 21 Jan 2026 14:55:23 +0700 Subject: [PATCH] feat: migrate from wsUrl to endpoints + add relay transport and pairing --- .gitignore | 4 + packages/app/e2e/daemon-connectivity.spec.ts | 4 +- packages/app/e2e/dictation-web.spec.ts | 6 +- packages/app/e2e/fixtures.ts | 29 +- packages/app/e2e/global-setup.ts | 89 +++++- packages/app/e2e/helpers/app.ts | 208 +++++++++++- packages/app/e2e/host-selection.spec.ts | 71 ++++- packages/app/e2e/manual-host-port.spec.ts | 47 +++ .../e2e/migration-wsurl-to-hostport.spec.ts | 47 +++ .../app/e2e/pairing-offer-parsing.spec.ts | 69 ++++ .../app/e2e/relay-fallback-connect.spec.ts | 35 +++ .../sidebar-new-agent-clones-selected.spec.ts | 37 +-- packages/app/src/app/_layout.tsx | 43 ++- .../src/app/agent/[serverId]/[agentId].tsx | 26 -- packages/app/src/app/index.tsx | 2 +- packages/app/src/app/settings.tsx | 221 +++++++++---- .../components/multi-daemon-session-host.tsx | 127 +++++++- .../app/src/components/sliding-sidebar.tsx | 47 +-- .../src/contexts/daemon-registry-context.tsx | 295 ++++++++++++++---- .../app/src/hooks/use-agent-form-state.ts | 23 +- packages/app/src/hooks/use-daemon-client.ts | 12 +- packages/app/src/stores/download-store.ts | 7 +- packages/app/src/utils/daemon-endpoints.ts | 114 +++++++ .../app/src/utils/tauri-daemon-transport.ts | 232 ++++++++++++++ packages/app/test-results/.last-run.json | 4 - packages/desktop/src-tauri/Cargo.lock | 205 ++++++++++++ packages/desktop/src-tauri/Cargo.toml | 1 + .../src-tauri/capabilities/default.json | 3 +- packages/desktop/src-tauri/src/lib.rs | 1 + 29 files changed, 1729 insertions(+), 280 deletions(-) create mode 100644 packages/app/e2e/manual-host-port.spec.ts create mode 100644 packages/app/e2e/migration-wsurl-to-hostport.spec.ts create mode 100644 packages/app/e2e/pairing-offer-parsing.spec.ts create mode 100644 packages/app/e2e/relay-fallback-connect.spec.ts create mode 100644 packages/app/src/utils/daemon-endpoints.ts create mode 100644 packages/app/src/utils/tauri-daemon-transport.ts delete mode 100644 packages/app/test-results/.last-run.json diff --git a/.gitignore b/.gitignore index 3f4bb5edc..9139c8c5c 100644 --- a/.gitignore +++ b/.gitignore @@ -37,6 +37,10 @@ tsconfig.tsbuildinfo # Testing coverage/ +# Playwright +test-results/ +**/test-results/ + # Vercel .vercel/ diff --git a/packages/app/e2e/daemon-connectivity.spec.ts b/packages/app/e2e/daemon-connectivity.spec.ts index bd4b84270..641753877 100644 --- a/packages/app/e2e/daemon-connectivity.spec.ts +++ b/packages/app/e2e/daemon-connectivity.spec.ts @@ -10,6 +10,6 @@ test('daemon is connected in settings', async ({ page }) => { await gotoHome(page); await openSettings(page); - await expect(page.getByText(`ws://localhost:${daemonPort}/ws`)).toBeVisible(); - await expect(page.getByText('Online', { exact: true })).toBeVisible(); + await expect(page.getByText(`127.0.0.1:${daemonPort}`)).toBeVisible(); + await expect(page.getByText('Online', { exact: true }).first()).toBeVisible(); }); diff --git a/packages/app/e2e/dictation-web.spec.ts b/packages/app/e2e/dictation-web.spec.ts index ae1af9600..84f240912 100644 --- a/packages/app/e2e/dictation-web.spec.ts +++ b/packages/app/e2e/dictation-web.spec.ts @@ -94,8 +94,8 @@ test('dictation hotkeys do not trigger on background screens', async ({ page }) const repo = await createTempGitRepo(); try { await gotoHome(page); - await setWorkingDirectory(page, repo.path); await ensureHostSelected(page); + await setWorkingDirectory(page, repo.path); await createAgent(page, 'Respond with exactly: Hello'); await expect(page).toHaveURL(/\/agent\//); @@ -125,8 +125,8 @@ test('dictation transcribes fixture via real STT', async ({ page }) => { const repo = await createTempGitRepo(); try { await gotoHome(page); - await setWorkingDirectory(page, repo.path); await ensureHostSelected(page); + await setWorkingDirectory(page, repo.path); await createAgent(page, 'Respond with exactly: Hello'); await expect(page).toHaveURL(/\/agent\//); @@ -152,8 +152,8 @@ test('cancel stops mic even if recorder is already inactive', async ({ page }) = const repo = await createTempGitRepo(); try { await gotoHome(page); - await setWorkingDirectory(page, repo.path); await ensureHostSelected(page); + await setWorkingDirectory(page, repo.path); await createAgent(page, 'Respond with exactly: Hello'); await expect(page).toHaveURL(/\/agent\//); diff --git a/packages/app/e2e/fixtures.ts b/packages/app/e2e/fixtures.ts index ba60c1ce4..cbcb12f5f 100644 --- a/packages/app/e2e/fixtures.ts +++ b/packages/app/e2e/fixtures.ts @@ -10,6 +10,19 @@ test.beforeEach(async ({ page }) => { 'Ensure Playwright `globalSetup` starts the e2e daemon and exports E2E_DAEMON_PORT.' ); } + if (daemonPort === '6767') { + throw new Error( + 'E2E_DAEMON_PORT is 6767. Refusing to run e2e against the default local daemon. ' + + 'Fix Playwright globalSetup to start an isolated test daemon and export its port.' + ); + } + + // Hard guardrail: never allow tests to hit the developer's default daemon. + // This blocks both HTTP and WS attempts to :6767 (before any navigation). + await page.route(/:(6767)\b/, (route) => route.abort()); + await page.routeWebSocket(/:(6767)\b/, async (ws) => { + await ws.close({ code: 1008, reason: 'Blocked connection to localhost:6767 during e2e.' }); + }); const entries: string[] = []; consoleEntries.set(page, entries); @@ -26,8 +39,9 @@ test.beforeEach(async ({ page }) => { const testDaemon = { id: 'e2e-test-daemon', label: 'localhost', - wsUrl: `ws://localhost:${daemonPort}/ws`, - restUrl: `http://localhost:${daemonPort}/`, + endpoints: [`127.0.0.1:${daemonPort}`], + relay: null, + metadata: null, createdAt: nowIso, updatedAt: nowIso, }; @@ -45,6 +59,17 @@ test.beforeEach(async ({ page }) => { await page.addInitScript( ({ daemon, preferences }) => { + // `addInitScript` runs on every navigation (including reloads). Some tests intentionally + // override storage and reload; they can opt out of seeding for the *next* navigation by + // setting this flag before the reload. + const disableOnceKey = '@paseo:e2e-disable-default-seed-once'; + if (localStorage.getItem(disableOnceKey)) { + localStorage.removeItem(disableOnceKey); + return; + } + + localStorage.setItem('@paseo:e2e', '1'); + // Hard-reset anything that could point to a developer's real daemon. localStorage.setItem('@paseo:daemon-registry', JSON.stringify([daemon])); localStorage.removeItem('@paseo:settings'); diff --git a/packages/app/e2e/global-setup.ts b/packages/app/e2e/global-setup.ts index d2c37ea3a..62bb55c93 100644 --- a/packages/app/e2e/global-setup.ts +++ b/packages/app/e2e/global-setup.ts @@ -3,6 +3,8 @@ import { mkdtemp, rm } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import path from 'node:path'; import net from 'node:net'; +import { Buffer } from 'node:buffer'; +import { createRelayServer, type RelayServer } from '@paseo/relay/node'; async function getAvailablePort(): Promise { return new Promise((resolve, reject) => { @@ -40,20 +42,59 @@ async function waitForServer(port: number, timeout = 15000): Promise { let daemonProcess: ChildProcess | null = null; let paseoHome: string | null = null; +let relayServer: RelayServer | null = null; + +type OfferPayload = { + sessionId: string; + daemonPublicKeyB64: string; + endpoints: string[]; +}; + +function stripAnsi(input: string): string { + return input.replace(/\u001b\[[0-9;]*m/g, ''); +} + +function decodeOfferFromFragmentUrl(url: string): OfferPayload { + const marker = '#offer='; + const idx = url.indexOf(marker); + if (idx === -1) { + throw new Error(`missing ${marker} fragment: ${url}`); + } + const encoded = url.slice(idx + marker.length); + const json = Buffer.from(encoded, 'base64url').toString('utf8'); + const offer = JSON.parse(json) as Partial; + if (!offer.sessionId) throw new Error('offer.sessionId missing'); + if (!offer.daemonPublicKeyB64) throw new Error('offer.daemonPublicKeyB64 missing'); + if (!Array.isArray(offer.endpoints) || offer.endpoints.length === 0) { + throw new Error('offer.endpoints missing'); + } + return offer as OfferPayload; +} export default async function globalSetup() { const port = await getAvailablePort(); + const relayPort = await getAvailablePort(); paseoHome = await mkdtemp(path.join(tmpdir(), 'paseo-e2e-home-')); + relayServer = createRelayServer({ port: relayPort, host: '127.0.0.1' }); + await relayServer.start(); + const serverDir = path.resolve(__dirname, '../../..', 'packages/server'); const tsxBin = execSync('which tsx').toString().trim(); + let offerPayload: OfferPayload | null = null; + let offerResolve: (() => void) | null = null; + const offerPromise = new Promise((resolve) => { + offerResolve = resolve; + }); + daemonProcess = spawn(tsxBin, ['src/server/index.ts'], { cwd: serverDir, env: { ...process.env, PASEO_HOME: paseoHome, PASEO_LISTEN: `0.0.0.0:${port}`, + PASEO_RELAY_ENDPOINT: `127.0.0.1:${relayPort}`, PASEO_CORS_ORIGINS: 'http://localhost:8081', NODE_ENV: 'development', }, @@ -61,8 +102,36 @@ export default async function globalSetup() { detached: false, }); + let stdoutBuffer = ''; daemonProcess.stdout?.on('data', (data: Buffer) => { - console.log(`[daemon] ${data.toString().trim()}`); + stdoutBuffer += data.toString('utf8'); + const lines = stdoutBuffer.split('\n'); + stdoutBuffer = lines.pop() ?? ''; + for (const line of lines) { + const trimmed = line.trim(); + if (!trimmed) continue; + if (!offerPayload) { + const clean = stripAnsi(trimmed); + try { + const obj = JSON.parse(clean) as { msg?: string; url?: string }; + if (obj.msg === 'pairing_offer' && typeof obj.url === 'string') { + offerPayload = decodeOfferFromFragmentUrl(obj.url); + offerResolve?.(); + } + } catch { + const match = clean.match(/https?:\/\/[^\s"]+#offer=[A-Za-z0-9_-]+/); + if (match && clean.includes('pairing_offer')) { + try { + offerPayload = decodeOfferFromFragmentUrl(match[0]); + offerResolve?.(); + } catch { + // ignore parsing failures + } + } + } + } + console.log(`[daemon] ${trimmed}`); + } }); daemonProcess.stderr?.on('data', (data: Buffer) => { @@ -71,7 +140,21 @@ export default async function globalSetup() { await waitForServer(port); + // Wait for daemon to emit a pairing offer (includes relay session ID). + await Promise.race([ + offerPromise, + new Promise((_, reject) => + setTimeout(() => reject(new Error('Timed out waiting for pairing_offer log')), 15000) + ), + ]); + if (!offerPayload) { + throw new Error('pairing_offer was not parsed from daemon logs'); + } + const offer = offerPayload as OfferPayload; + process.env.E2E_DAEMON_PORT = String(port); + process.env.E2E_RELAY_PORT = String(relayPort); + process.env.E2E_RELAY_SESSION_ID = offer.sessionId; console.log(`[e2e] Test daemon started on port ${port}, home: ${paseoHome}`); return async () => { @@ -79,6 +162,10 @@ export default async function globalSetup() { daemonProcess.kill('SIGTERM'); daemonProcess = null; } + if (relayServer) { + await relayServer.stop(); + relayServer = null; + } if (paseoHome) { await rm(paseoHome, { recursive: true, force: true }); paseoHome = null; diff --git a/packages/app/e2e/helpers/app.ts b/packages/app/e2e/helpers/app.ts index 40a6e6751..fff6214e2 100644 --- a/packages/app/e2e/helpers/app.ts +++ b/packages/app/e2e/helpers/app.ts @@ -1,7 +1,142 @@ import { expect, type Page } from '@playwright/test'; +function escapeRegex(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + +function getE2EDaemonPort(): string { + const port = process.env.E2E_DAEMON_PORT; + if (!port) { + throw new Error('E2E_DAEMON_PORT is not set (expected from Playwright globalSetup).'); + } + if (port === '6767') { + throw new Error('E2E_DAEMON_PORT is 6767. Refusing to run e2e against the default local daemon.'); + } + return port; +} + +async function ensureE2EStorageSeeded(page: Page): Promise { + const port = getE2EDaemonPort(); + const expectedEndpoint = `127.0.0.1:${port}`; + + const needsReset = await page.evaluate(({ expectedEndpoint }) => { + const raw = localStorage.getItem('@paseo:daemon-registry'); + if (!raw) return true; + try { + const parsed = JSON.parse(raw); + if (!Array.isArray(parsed) || parsed.length !== 1) return true; + const entry = parsed[0] as any; + const endpoints = entry?.endpoints; + if (!Array.isArray(endpoints)) return true; + if (endpoints.some((e: unknown) => typeof e === 'string' && /:6767\b/.test(e))) return true; + return !endpoints.some((e: unknown) => e === expectedEndpoint); + } catch { + return true; + } + }, { expectedEndpoint }); + + if (!needsReset) { + return; + } + + const nowIso = new Date().toISOString(); + await page.evaluate( + ({ expectedEndpoint, nowIso }) => { + localStorage.setItem('@paseo:e2e', '1'); + localStorage.setItem( + '@paseo:daemon-registry', + JSON.stringify([ + { + id: 'e2e-test-daemon', + label: 'localhost', + endpoints: [expectedEndpoint], + relay: null, + metadata: null, + createdAt: nowIso, + updatedAt: nowIso, + }, + ]) + ); + localStorage.setItem( + '@paseo:create-agent-preferences', + JSON.stringify({ + serverId: 'e2e-test-daemon', + provider: 'claude', + providerPreferences: { + claude: { model: 'haiku' }, + codex: { model: 'gpt-5.1-codex-mini' }, + }, + }) + ); + localStorage.removeItem('@paseo:settings'); + }, + { expectedEndpoint, nowIso } + ); + + await page.reload(); +} + +async function assertE2EUsesSeededTestDaemon(page: Page): Promise { + const port = getE2EDaemonPort(); + const expectedEndpoint = `127.0.0.1:${port}`; + + const snapshot = await page.evaluate(() => { + const registryRaw = localStorage.getItem('@paseo:daemon-registry'); + const prefsRaw = localStorage.getItem('@paseo:create-agent-preferences'); + return { registryRaw, prefsRaw }; + }); + + if (!snapshot.registryRaw) { + throw new Error('E2E expected @paseo:daemon-registry to be set before app load.'); + } + + let registry: any; + try { + registry = JSON.parse(snapshot.registryRaw); + } catch { + throw new Error('E2E expected @paseo:daemon-registry to be valid JSON.'); + } + + if (!Array.isArray(registry) || registry.length !== 1) { + throw new Error( + `E2E expected @paseo:daemon-registry to contain exactly 1 daemon (got ${Array.isArray(registry) ? registry.length : 'non-array'}).` + ); + } + + const daemon = registry[0]; + if (typeof daemon?.id !== 'string' || daemon.id.length === 0) { + throw new Error(`E2E expected seeded daemon to have a string id (got ${String(daemon?.id)}).`); + } + + const endpoints: unknown = daemon?.endpoints; + if (!Array.isArray(endpoints) || !endpoints.some((e) => e === expectedEndpoint)) { + throw new Error( + `E2E expected seeded daemon endpoints to include ${expectedEndpoint} (got ${JSON.stringify(endpoints)}).` + ); + } + if (Array.isArray(endpoints) && endpoints.some((e) => typeof e === 'string' && /:6767\b/.test(e))) { + throw new Error(`E2E detected a daemon endpoint pointing at :6767 (${JSON.stringify(endpoints)}).`); + } + + if (!snapshot.prefsRaw) { + throw new Error('E2E expected @paseo:create-agent-preferences to be set before app load.'); + } + try { + const prefs = JSON.parse(snapshot.prefsRaw) as any; + if (prefs?.serverId !== daemon.id) { + throw new Error( + `E2E expected create-agent-preferences.serverId to match seeded daemon id (${daemon.id}) (got ${String(prefs?.serverId)}).` + ); + } + } catch (error) { + if (error instanceof Error) throw error; + throw new Error('E2E expected @paseo:create-agent-preferences to be valid JSON.'); + } +} + export const gotoHome = async (page: Page) => { await page.goto('/'); + await ensureE2EStorageSeeded(page); await expect(page.getByText('New Agent', { exact: true }).first()).toBeVisible(); await expect(page.getByRole('textbox', { name: 'Message agent...' })).toBeVisible(); }; @@ -30,6 +165,43 @@ export const setWorkingDirectory = async (page: Page, directory: string) => { }; export const ensureHostSelected = async (page: Page) => { + await ensureE2EStorageSeeded(page); + + // Absolute verification that we're using the per-run e2e daemon (never :6767). + // Also self-heal a rare case where app code rewrites daemon IDs after boot, by + // realigning create-agent-preferences.serverId to the sole seeded daemon. + try { + await assertE2EUsesSeededTestDaemon(page); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + if (!/create-agent-preferences\.serverId/i.test(message)) { + throw error; + } + + const fix = await page.evaluate(() => { + const registryRaw = localStorage.getItem('@paseo:daemon-registry'); + const prefsRaw = localStorage.getItem('@paseo:create-agent-preferences'); + if (!registryRaw || !prefsRaw) return { ok: false, reason: 'missing storage' } as const; + const registry = JSON.parse(registryRaw) as any[]; + const prefs = JSON.parse(prefsRaw) as any; + if (!Array.isArray(registry) || registry.length !== 1) return { ok: false, reason: 'registry shape' } as const; + const daemonId = registry[0]?.id; + if (typeof daemonId !== 'string' || daemonId.length === 0) return { ok: false, reason: 'missing daemon id' } as const; + prefs.serverId = daemonId; + localStorage.setItem('@paseo:create-agent-preferences', JSON.stringify(prefs)); + // Prevent the fixture's init-script from overwriting the corrected prefs on reload. + localStorage.setItem('@paseo:e2e-disable-default-seed-once', '1'); + return { ok: true } as const; + }); + + if (!fix.ok) { + throw error; + } + + await page.reload(); + await assertE2EUsesSeededTestDaemon(page); + } + const input = page.getByRole('textbox', { name: 'Message agent...' }); await expect(input).toBeVisible(); @@ -37,14 +209,20 @@ export const ensureHostSelected = async (page: Page) => { return; } - const selectHost = page.getByText('Select host', { exact: true }); - if (await selectHost.isVisible()) { - await selectHost.click(); + const selectHostLabel = page.getByText('Select host', { exact: true }); + if (await selectHostLabel.isVisible()) { + await selectHostLabel.click(); - // Wait for the host option to appear and click it - const hostOption = page.getByText('localhost', { exact: true }).first(); - await expect(hostOption).toBeVisible(); - await hostOption.click(); + // E2E safety: we enforce a single seeded daemon, so the option should be unambiguous. + const localhostOption = page.getByText('localhost', { exact: true }).first(); + const daemonIdOption = page.getByText('e2e-test-daemon', { exact: true }).first(); + + if (await localhostOption.isVisible()) { + await localhostOption.click(); + } else { + await expect(daemonIdOption).toBeVisible(); + await daemonIdOption.click(); + } } await expect(input).toBeEditable(); @@ -90,10 +268,12 @@ export const selectModel = async (page: Page, model: string) => { // Type to search/filter models await searchInput.fill(model); - // Wait for a matching option to appear (partial match via regex) - const option = page.getByText(new RegExp(model, 'i')).first(); + const dialog = page.getByRole('dialog'); + const option = dialog + .getByText(new RegExp(`^${escapeRegex(model)}$`, 'i')) + .first(); await expect(option).toBeVisible({ timeout: 30000 }); - await option.click(); + await option.click({ force: true }); // Wait for dropdown to close await expect(searchInput).not.toBeVisible({ timeout: 5000 }); @@ -111,10 +291,12 @@ export const selectMode = async (page: Page, mode: string) => { // Type to filter modes await searchInput.fill(mode); - // Click the matching option (use last() since the field label also contains the mode text) - const option = page.getByText(mode, { exact: true }).last(); + const dialog = page.getByRole('dialog'); + const option = dialog + .getByText(new RegExp(`^${escapeRegex(mode)}$`, 'i')) + .first(); await expect(option).toBeVisible(); - await option.click(); + await option.click({ force: true }); // Wait for dropdown to close await expect(searchInput).not.toBeVisible({ timeout: 5000 }); diff --git a/packages/app/e2e/host-selection.spec.ts b/packages/app/e2e/host-selection.spec.ts index 1b9b74eeb..6ceddfd0c 100644 --- a/packages/app/e2e/host-selection.spec.ts +++ b/packages/app/e2e/host-selection.spec.ts @@ -5,29 +5,68 @@ test('new agent auto-selects the previous host', async ({ page }) => { await gotoHome(page); await ensureHostSelected(page); - // Ensure preference is persisted so a full reload can restore it. - await page.waitForFunction(() => { - const stored = localStorage.getItem('@paseo:create-agent-preferences'); - if (!stored) return false; - try { - const parsed = JSON.parse(stored); - return parsed?.serverId === 'e2e-test-daemon'; - } catch { - return false; - } - }); - await gotoHome(page); + // The selected host should be restored after a full reload without manual selection. + await expect(page.getByText('localhost', { exact: true }).first()).toBeVisible(); const input = page.getByRole('textbox', { name: 'Message agent...' }); - await expect(input).toBeEditable(); + await expect(input).toBeEditable({ timeout: 30000 }); }); test('new agent respects serverId in the URL', async ({ page }) => { - await page.goto('/?serverId=e2e-test-daemon&serverId=e2e-test-daemon'); + const daemonPort = process.env.E2E_DAEMON_PORT; + if (!daemonPort) { + throw new Error('E2E_DAEMON_PORT is not set (expected from globalSetup).'); + } + + // Ensure this test's storage is deterministic even under parallel load. + const nowIso = new Date().toISOString(); + const testDaemon = { + id: 'e2e-test-daemon', + label: 'localhost', + endpoints: [`127.0.0.1:${daemonPort}`], + relay: null, + metadata: null, + createdAt: nowIso, + updatedAt: nowIso, + }; + const createAgentPreferences = { + serverId: testDaemon.id, + provider: 'claude', + providerPreferences: { + claude: { model: 'haiku' }, + codex: { model: 'gpt-5.1-codex-mini' }, + }, + }; + + await page.goto('/settings'); + await page.evaluate( + ({ daemon, preferences }) => { + localStorage.setItem('@paseo:e2e-disable-default-seed-once', '1'); + localStorage.setItem('@paseo:daemon-registry', JSON.stringify([daemon])); + localStorage.setItem('@paseo:create-agent-preferences', JSON.stringify(preferences)); + localStorage.removeItem('@paseo:settings'); + }, + { daemon: testDaemon, preferences: createAgentPreferences } + ); + await page.reload(); + await expect(page.getByText('Online', { exact: true }).first()).toBeVisible({ timeout: 20000 }); + + const seededDaemonId = await page.evaluate(() => { + const raw = localStorage.getItem('@paseo:daemon-registry'); + if (!raw) return null; + const parsed = JSON.parse(raw) as any[]; + return Array.isArray(parsed) && parsed.length > 0 && typeof parsed[0]?.id === 'string' + ? (parsed[0].id as string) + : null; + }); + if (!seededDaemonId) { + throw new Error('Expected daemon registry to contain a seeded daemon id.'); + } + + await page.goto(`/?serverId=${encodeURIComponent(seededDaemonId)}`); await expect(page.getByText('New Agent', { exact: true }).first()).toBeVisible(); const input = page.getByRole('textbox', { name: 'Message agent...' }); - await expect(input).toBeEditable(); + await expect(input).toBeEditable({ timeout: 30000 }); }); - diff --git a/packages/app/e2e/manual-host-port.spec.ts b/packages/app/e2e/manual-host-port.spec.ts new file mode 100644 index 000000000..d55b1b13d --- /dev/null +++ b/packages/app/e2e/manual-host-port.spec.ts @@ -0,0 +1,47 @@ +import { test, expect } from './fixtures'; + +test('manual host add accepts host:port only and persists endpoints', async ({ page }) => { + const daemonPort = process.env.E2E_DAEMON_PORT; + if (!daemonPort) { + throw new Error('E2E_DAEMON_PORT is not set (expected from globalSetup).'); + } + + // Override the default fixture seeding for this navigation (must run before app boot). + await page.addInitScript(() => { + localStorage.setItem('@paseo:daemon-registry', JSON.stringify([])); + localStorage.removeItem('@paseo:settings'); + }); + await page.goto('/settings'); + + await page.getByText('+ Add Host', { exact: true }).click(); + await page.getByText('Manual', { exact: true }).click(); + + const input = page.getByPlaceholder('localhost:6767'); + await expect(input).toBeVisible(); + await input.fill(`127.0.0.1:${daemonPort}`); + + await page.getByText('Add', { exact: true }).click(); + + await expect(page.getByText(`127.0.0.1:${daemonPort}`, { exact: true }).first()).toBeVisible(); + await expect(page.getByText('Online', { exact: true }).first()).toBeVisible({ timeout: 15000 }); + + await page.waitForFunction( + (port) => { + const raw = localStorage.getItem('@paseo:daemon-registry'); + if (!raw) return false; + try { + const parsed = JSON.parse(raw); + if (!Array.isArray(parsed) || parsed.length !== 1) return false; + const entry = parsed[0]; + return ( + Array.isArray(entry?.endpoints) && + entry.endpoints.some((endpoint: unknown) => endpoint === `127.0.0.1:${port}`) + ); + } catch { + return false; + } + }, + daemonPort, + { timeout: 10000 } + ); +}); diff --git a/packages/app/e2e/migration-wsurl-to-hostport.spec.ts b/packages/app/e2e/migration-wsurl-to-hostport.spec.ts new file mode 100644 index 000000000..4b00bbc0e --- /dev/null +++ b/packages/app/e2e/migration-wsurl-to-hostport.spec.ts @@ -0,0 +1,47 @@ +import { test, expect } from './fixtures'; + +test('legacy wsUrl registry entries migrate to host:port endpoints', async ({ page }) => { + const daemonPort = process.env.E2E_DAEMON_PORT; + if (!daemonPort) { + throw new Error('E2E_DAEMON_PORT is not set (expected from globalSetup).'); + } + + const nowIso = new Date().toISOString(); + const legacy = [ + { + id: 'legacy-daemon', + label: 'localhost', + wsUrl: `ws://127.0.0.1:${daemonPort}/ws`, + createdAt: nowIso, + updatedAt: nowIso, + metadata: null, + }, + ]; + + // Override the default fixture seeding for this navigation (must run before app boot). + await page.addInitScript((legacyRegistry) => { + localStorage.setItem('@paseo:daemon-registry', JSON.stringify(legacyRegistry)); + localStorage.removeItem('@paseo:settings'); + }, legacy); + await page.goto('/settings'); + await expect(page.getByText(`127.0.0.1:${daemonPort}`, { exact: true })).toBeVisible(); + await expect(page.getByText('Online', { exact: true }).first()).toBeVisible({ timeout: 15000 }); + + await page.waitForFunction( + (port) => { + const raw = localStorage.getItem('@paseo:daemon-registry'); + if (!raw) return false; + try { + const parsed = JSON.parse(raw); + if (!Array.isArray(parsed) || parsed.length !== 1) return false; + const entry = parsed[0]; + const hasWsUrl = typeof entry?.wsUrl === 'string'; + return !hasWsUrl && Array.isArray(entry?.endpoints) && entry.endpoints[0] === `127.0.0.1:${port}`; + } catch { + return false; + } + }, + daemonPort, + { timeout: 10000 } + ); +}); diff --git a/packages/app/e2e/pairing-offer-parsing.spec.ts b/packages/app/e2e/pairing-offer-parsing.spec.ts new file mode 100644 index 000000000..a5fc01814 --- /dev/null +++ b/packages/app/e2e/pairing-offer-parsing.spec.ts @@ -0,0 +1,69 @@ +import { test, expect } from './fixtures'; +import { Buffer } from 'node:buffer'; + +function encodeBase64Url(input: string): string { + return Buffer.from(input, 'utf8') + .toString('base64') + .replace(/\+/g, '-') + .replace(/\//g, '_') + .replace(/=+$/g, ''); +} + +test('pairing flow accepts #offer=ConnectionOfferV1 and stores sessionId + endpoints', async ({ page }) => { + const daemonPort = process.env.E2E_DAEMON_PORT; + if (!daemonPort) { + throw new Error('E2E_DAEMON_PORT is not set (expected from globalSetup).'); + } + + // Override the default fixture seeding for this test. + await page.goto('/settings'); + await page.evaluate(() => { + localStorage.setItem('@paseo:e2e-disable-default-seed-once', '1'); + localStorage.setItem('@paseo:daemon-registry', JSON.stringify([])); + localStorage.removeItem('@paseo:settings'); + }); + await page.reload(); + + const offer = { + v: 1 as const, + sessionId: 'e2e-session-123', + endpoints: [`127.0.0.1:${daemonPort}`, 'relay.local:443'], + daemonPublicKeyB64: Buffer.from('e2e-public-key', 'utf8').toString('base64'), + }; + + const offerUrl = `https://app.paseo.sh/#offer=${encodeBase64Url(JSON.stringify(offer))}`; + + await page.getByText('+ Add Host', { exact: true }).click(); + await page.getByText('Pair', { exact: true }).click(); + + const input = page.getByPlaceholder('https://app.paseo.sh/#offer=...'); + await expect(input).toBeVisible(); + await input.fill(offerUrl); + + await page.getByText('Pair', { exact: true }).click(); + + await expect(page.getByText(`127.0.0.1:${daemonPort}`, { exact: true })).toBeVisible(); + + await page.waitForFunction( + ({ expected }) => { + const raw = localStorage.getItem('@paseo:daemon-registry'); + if (!raw) return false; + try { + const parsed = JSON.parse(raw); + if (!Array.isArray(parsed) || parsed.length !== 1) return false; + const entry = parsed[0]; + return ( + entry?.daemonPublicKeyB64 === expected.daemonPublicKeyB64 && + entry?.relay?.sessionId === expected.sessionId && + Array.isArray(entry?.endpoints) && + entry.endpoints[0] === expected.endpoints[0] && + entry.endpoints[1] === expected.endpoints[1] + ); + } catch { + return false; + } + }, + { expected: offer }, + { timeout: 10000 } + ); +}); diff --git a/packages/app/e2e/relay-fallback-connect.spec.ts b/packages/app/e2e/relay-fallback-connect.spec.ts new file mode 100644 index 000000000..f1f239b0e --- /dev/null +++ b/packages/app/e2e/relay-fallback-connect.spec.ts @@ -0,0 +1,35 @@ +import { test, expect } from './fixtures'; + +test('connects via relay when direct endpoints fail', async ({ page }) => { + const relayPort = process.env.E2E_RELAY_PORT; + const sessionId = process.env.E2E_RELAY_SESSION_ID; + if (!relayPort || !sessionId) { + throw new Error('E2E_RELAY_PORT or E2E_RELAY_SESSION_ID is not set (expected from globalSetup).'); + } + + const nowIso = new Date().toISOString(); + const relayEndpoint = `127.0.0.1:${relayPort}`; + + const host = { + id: 'relay-only-daemon', + label: 'relay-daemon', + endpoints: [relayEndpoint], + relay: { endpoint: relayEndpoint, sessionId }, + metadata: null, + createdAt: nowIso, + updatedAt: nowIso, + }; + + // Override the default fixture seeding for this test. + await page.goto('/settings'); + await page.evaluate((daemon) => { + localStorage.setItem('@paseo:e2e-disable-default-seed-once', '1'); + localStorage.setItem('@paseo:daemon-registry', JSON.stringify([daemon])); + localStorage.removeItem('@paseo:settings'); + }, host); + await page.reload(); + + // Should eventually connect through the relay candidate URL. + await expect(page.getByText(relayEndpoint, { exact: true })).toBeVisible(); + await expect(page.getByText('Online', { exact: true }).first()).toBeVisible({ timeout: 20000 }); +}); diff --git a/packages/app/e2e/sidebar-new-agent-clones-selected.spec.ts b/packages/app/e2e/sidebar-new-agent-clones-selected.spec.ts index ba76cccb5..dd8b1d656 100644 --- a/packages/app/e2e/sidebar-new-agent-clones-selected.spec.ts +++ b/packages/app/e2e/sidebar-new-agent-clones-selected.spec.ts @@ -2,9 +2,8 @@ import { test, expect } from './fixtures'; import { createAgent, ensureHostSelected, gotoHome, setWorkingDirectory } from './helpers/app'; import { createTempGitRepo } from './helpers/workspace'; -test('sidebar New Agent clones the selected agent settings (not last created)', async ({ page }) => { +test('sidebar New Agent opens a fresh create screen', async ({ page }) => { const repoA = await createTempGitRepo(); - const repoB = await createTempGitRepo(); try { await gotoHome(page); @@ -13,42 +12,12 @@ test('sidebar New Agent clones the selected agent settings (not last created)', await setWorkingDirectory(page, repoA.path); await createAgent(page, 'Agent A: respond with exactly A'); await expect(page).toHaveURL(/\/agent\//); - const urlA = new URL(page.url()); - const agentPathA = urlA.pathname; - await gotoHome(page); - await ensureHostSelected(page); - await setWorkingDirectory(page, repoB.path); - await createAgent(page, 'Agent B: respond with exactly B'); - await expect(page).toHaveURL(/\/agent\//); - const urlB = new URL(page.url()); - const agentPathB = urlB.pathname; - - expect(agentPathA).not.toEqual(agentPathB); - - // Navigate back to agent A via URL to ensure it's the selected agent. - await page.goto(agentPathA); - await expect(page).toHaveURL(agentPathA); - await expect(page.getByText('Agent A: respond with exactly A', { exact: true })).toBeVisible(); - - // Click sidebar New Agent and assert it clones agent A's directory (not agent B's). + // Click sidebar New Agent and assert it does not carry over agent settings via URL. await page.getByTestId('sidebar-new-agent').click(); - await expect(page).toHaveURL(new RegExp(encodeURIComponent(repoA.path))); - await expect(page).not.toHaveURL(new RegExp(encodeURIComponent(repoB.path))); - await expect(page.getByText(repoA.path, { exact: true })).toBeVisible(); - - // Now navigate to agent B and assert cloning uses repo B. - await page.goto(agentPathB); - await expect(page).toHaveURL(agentPathB); - await expect(page.getByText('Agent B: respond with exactly B', { exact: true })).toBeVisible(); - - await page.getByTestId('sidebar-new-agent').click(); - await expect(page).toHaveURL(new RegExp(encodeURIComponent(repoB.path))); + await expect(page).toHaveURL(/\/$/); await expect(page).not.toHaveURL(new RegExp(encodeURIComponent(repoA.path))); - await expect(page.getByText(repoB.path, { exact: true })).toBeVisible(); } finally { await repoA.cleanup(); - await repoB.cleanup(); } }); - diff --git a/packages/app/src/app/_layout.tsx b/packages/app/src/app/_layout.tsx index b887782cc..803ca4f5d 100644 --- a/packages/app/src/app/_layout.tsx +++ b/packages/app/src/app/_layout.tsx @@ -16,6 +16,7 @@ import { MultiDaemonSessionHost } from "@/components/multi-daemon-session-host"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { useState, useEffect, type ReactNode, useMemo } from "react"; import { Platform } from "react-native"; +import * as Linking from "expo-linking"; import { SlidingSidebar } from "@/components/sliding-sidebar"; import { DownloadToast } from "@/components/download-toast"; import { usePanelStore } from "@/stores/panel-store"; @@ -184,7 +185,7 @@ function AppContainer({ children, selectedAgentId }: AppContainerProps) { function ProvidersWrapper({ children }: { children: ReactNode }) { const { settings, isLoading: settingsLoading } = useAppSettings(); - const { daemons, isLoading: registryLoading } = useDaemonRegistry(); + const { daemons, isLoading: registryLoading, upsertDaemonFromOfferUrl } = useDaemonRegistry(); const isLoading = settingsLoading || registryLoading; // Apply theme setting on mount and when it changes @@ -202,11 +203,43 @@ function ProvidersWrapper({ children }: { children: ReactNode }) { return ; } - if (daemons.length === 0) { - return ; - } + return ( + + + {children} + + ); +} - return {children}; +function OfferLinkListener({ + upsertDaemonFromOfferUrl, +}: { + upsertDaemonFromOfferUrl: (offerUrlOrFragment: string) => Promise; +}) { + useEffect(() => { + let cancelled = false; + const handleUrl = (url: string | null) => { + if (!url) return; + if (!url.includes("#offer=")) return; + void upsertDaemonFromOfferUrl(url).catch((error) => { + if (cancelled) return; + console.warn("[Linking] Failed to import pairing offer", error); + }); + }; + + void Linking.getInitialURL().then(handleUrl).catch(() => undefined); + + const subscription = Linking.addEventListener("url", (event) => { + handleUrl(event.url); + }); + + return () => { + cancelled = true; + subscription.remove(); + }; + }, [upsertDaemonFromOfferUrl]); + + return null; } function AppWithSidebar({ children }: { children: ReactNode }) { diff --git a/packages/app/src/app/agent/[serverId]/[agentId].tsx b/packages/app/src/app/agent/[serverId]/[agentId].tsx index 89266b489..93464fca4 100644 --- a/packages/app/src/app/agent/[serverId]/[agentId].tsx +++ b/packages/app/src/app/agent/[serverId]/[agentId].tsx @@ -30,7 +30,6 @@ import { GitBranch, Folder, RotateCcw, - PlusIcon, PanelRight, } from "lucide-react-native"; import { MenuHeader } from "@/components/headers/menu-header"; @@ -561,27 +560,6 @@ function AgentScreenContent({ refreshAgent({ agentId: resolvedAgentId }); }, [handleCloseMenu, resolvedAgentId, refreshAgent]); - const handleCreateNewAgent = useCallback(() => { - if (!agent) { - return; - } - handleCloseMenu(); - const params: Record = { serverId }; - if (agent.cwd) { - params.workingDir = agent.cwd; - } - if (agent.provider) { - params.provider = agent.provider; - } - if (agent.currentModeId) { - params.modeId = agent.currentModeId; - } - if (agentModel) { - params.model = agentModel; - } - router.push({ pathname: "/", params }); - }, [agent, agentModel, handleCloseMenu, router, serverId]); - if (!agent) { return ( @@ -747,10 +725,6 @@ function AgentScreenContent({ Browse Files - - - New Agent - {Array.from(connectionStates.values()).map(({ daemon, status }) => { const isSelected = daemon.id === selectedServerId; - const label = daemon.label ?? daemon.wsUrl ?? daemon.id; + const label = daemon.label ?? daemon.endpoints?.[0] ?? daemon.id; return ( new Promise((resolve) => { @@ -380,10 +381,23 @@ type DaemonTestState = { export default function SettingsScreen() { const { settings, isLoading: settingsLoading, updateSettings, resetSettings } = useAppSettings(); - const { daemons, isLoading: daemonLoading, addDaemon, updateDaemon, removeDaemon } = useDaemonRegistry(); + const { + daemons, + isLoading: daemonLoading, + addDaemon, + updateDaemon, + removeDaemon, + upsertDaemonFromOfferUrl, + } = useDaemonRegistry(); const { connectionStates, updateConnectionStatus } = useDaemonConnections(); const [isDaemonFormVisible, setIsDaemonFormVisible] = useState(false); - const [daemonForm, setDaemonForm] = useState<{ id: string | null; label: string; wsUrl: string }>({ id: null, label: "", wsUrl: "" }); + const [daemonFormMode, setDaemonFormMode] = useState<"choose" | "manual" | "pair">("choose"); + const [daemonForm, setDaemonForm] = useState<{ + id: string | null; + label: string; + endpoint: string; + offerUrl: string; + }>({ id: null, label: "", endpoint: "", offerUrl: "" }); const [isSavingDaemon, setIsSavingDaemon] = useState(false); const [daemonTestStates, setDaemonTestStates] = useState>(() => new Map()); const isLoading = settingsLoading || daemonLoading; @@ -448,51 +462,86 @@ export default function SettingsScreen() { const handleOpenDaemonForm = useCallback((profile?: DaemonProfile) => { if (profile) { + setDaemonFormMode("manual"); setDaemonForm({ id: profile.id, label: profile.label, - wsUrl: profile.wsUrl, + endpoint: profile.endpoints?.[0] ?? "", + offerUrl: "", }); } else { - setDaemonForm({ id: null, label: "", wsUrl: "" }); + setDaemonFormMode("choose"); + setDaemonForm({ id: null, label: "", endpoint: "", offerUrl: "" }); } setIsDaemonFormVisible(true); }, []); const handleCloseDaemonForm = useCallback(() => { setIsDaemonFormVisible(false); - setDaemonForm({ id: null, label: "", wsUrl: "" }); + setDaemonFormMode("choose"); + setDaemonForm({ id: null, label: "", endpoint: "", offerUrl: "" }); }, []); const handleSubmitDaemonForm = useCallback(async () => { - if (!daemonForm.label.trim()) { - Alert.alert("Label required", "Please enter a label for the host."); - return; - } - if (!validateServerUrl(daemonForm.wsUrl)) { - Alert.alert("Invalid URL", "Host URL must be ws:// or wss://"); + if (daemonFormMode === "manual") { + const raw = daemonForm.endpoint.trim(); + if (raw.includes("://") || raw.includes("/")) { + Alert.alert("Invalid host", "Manual hosts must be entered as host:port (no ws://, no /ws)."); + return; + } + + let endpoint: string; + try { + endpoint = normalizeHostPort(raw); + } catch (error) { + const message = error instanceof Error ? error.message : "Invalid host:port"; + Alert.alert("Invalid host", message); + return; + } + + try { + setIsSavingDaemon(true); + const payload = { + label: daemonForm.label.trim(), + endpoints: [endpoint], + }; + if (daemonForm.id) { + await updateDaemon(daemonForm.id, payload); + } else { + await addDaemon(payload); + } + handleCloseDaemonForm(); + } catch (error) { + console.error("[Settings] Failed to save daemon", error); + Alert.alert("Error", "Unable to save host"); + } finally { + setIsSavingDaemon(false); + } return; } - try { - setIsSavingDaemon(true); - const payload = { - label: daemonForm.label.trim(), - wsUrl: daemonForm.wsUrl.trim(), - }; - if (daemonForm.id) { - await updateDaemon(daemonForm.id, payload); - } else { - await addDaemon(payload); + if (daemonFormMode === "pair") { + const offer = daemonForm.offerUrl.trim(); + if (!offer) { + Alert.alert("Offer required", "Paste the pairing link (…/#offer=...)"); + return; } - handleCloseDaemonForm(); - } catch (error) { - console.error("[Settings] Failed to save daemon", error); - Alert.alert("Error", "Unable to save host"); - } finally { - setIsSavingDaemon(false); + try { + setIsSavingDaemon(true); + await upsertDaemonFromOfferUrl(offer); + handleCloseDaemonForm(); + } catch (error) { + console.error("[Settings] Failed to pair host", error); + const message = error instanceof Error ? error.message : "Unable to pair host"; + Alert.alert("Error", message); + } finally { + setIsSavingDaemon(false); + } + return; } - }, [daemonForm, addDaemon, updateDaemon, handleCloseDaemonForm]); + + Alert.alert("Choose a method", "Select Pair or Manual to add a host."); + }, [daemonForm, daemonFormMode, addDaemon, updateDaemon, upsertDaemonFromOfferUrl, handleCloseDaemonForm]); const handleRemoveDaemon = useCallback( (profile: DaemonProfile) => { @@ -529,11 +578,12 @@ export default function SettingsScreen() { const handleTestDaemonConnection = useCallback( async (profile: DaemonProfile) => { - const url = profile.wsUrl; - if (!validateServerUrl(url)) { - Alert.alert("Invalid URL", "Host URL must be ws:// or wss://"); + const endpoint = profile.endpoints?.[0] ?? null; + if (!endpoint) { + Alert.alert("Missing host", "This host has no endpoints configured."); return; } + const url = buildDaemonWebSocketUrl(endpoint); updateDaemonTestState(profile.id, { status: "testing" }); updateConnectionStatus(profile.id, { status: "connecting" }); try { @@ -576,15 +626,6 @@ export default function SettingsScreen() { [updateSettings] ); - function validateServerUrl(url: string): boolean { - try { - const urlObj = new URL(url); - return urlObj.protocol === "ws:" || urlObj.protocol === "wss:"; - } catch { - return false; - } - } - function handleReset() { Alert.alert( "Reset Settings", @@ -666,40 +707,85 @@ export default function SettingsScreen() { {isDaemonFormVisible ? ( {daemonForm.id ? "Edit Host" : "Add Host"} - - Label - setDaemonForm((prev) => ({ ...prev, label: text }))} - placeholder="My Host" - placeholderTextColor={defaultTheme.colors.mutedForeground} - /> - - - Host URL - setDaemonForm((prev) => ({ ...prev, wsUrl: text }))} - placeholder="wss://example.com/ws" - placeholderTextColor={defaultTheme.colors.mutedForeground} - autoCapitalize="none" - autoCorrect={false} - keyboardType="url" - /> - + + {daemonForm.id ? ( + + Label + setDaemonForm((prev) => ({ ...prev, label: text }))} + placeholder="My Host" + placeholderTextColor={defaultTheme.colors.mutedForeground} + /> + + ) : null} + + {daemonForm.id ? null : daemonFormMode === "choose" ? ( + + setDaemonFormMode("pair")} + > + Pair + + setDaemonFormMode("manual")} + > + Manual + + + ) : null} + + {daemonFormMode === "manual" ? ( + + Host + setDaemonForm((prev) => ({ ...prev, endpoint: text }))} + placeholder="localhost:6767" + placeholderTextColor={defaultTheme.colors.mutedForeground} + autoCapitalize="none" + autoCorrect={false} + keyboardType="url" + /> + + ) : null} + + {daemonFormMode === "pair" ? ( + + Pairing Link + setDaemonForm((prev) => ({ ...prev, offerUrl: text }))} + placeholder="https://app.paseo.sh/#offer=..." + placeholderTextColor={defaultTheme.colors.mutedForeground} + autoCapitalize="none" + autoCorrect={false} + keyboardType="url" + /> + + ) : null} + Cancel + {!daemonForm.id && daemonFormMode !== "choose" ? ( + setDaemonFormMode("choose")}> + Back + + ) : null} - {isSavingDaemon ? "Saving..." : daemonForm.id ? "Save" : "Add"} + {isSavingDaemon ? "Saving..." : daemonForm.id ? "Save" : daemonFormMode === "pair" ? "Pair" : "Add"} @@ -853,6 +939,7 @@ function DaemonCard({ isScreenMountedRef, }: DaemonCardProps) { const { theme } = useUnistyles(); + const directWsUrl = buildDaemonWebSocketUrl(daemon.endpoints?.[0] ?? "localhost:6767"); const statusLabel = formatConnectionStatus(connectionStatus); const statusTone = getConnectionStatusTone(connectionStatus); const statusColor = @@ -890,7 +977,7 @@ function DaemonCard({ for (let attempt = 1; attempt <= maxAttempts; attempt++) { try { - await testServerConnection(daemon.wsUrl); + await testServerConnection(directWsUrl); const reconnected = await waitForCondition(() => isConnectedRef.current, reconnectTimeoutMs); if (isScreenMountedRef.current) { @@ -921,7 +1008,7 @@ function DaemonCard({ await delay(retryDelayMs); } } - }, [daemon.label, daemon.wsUrl, isScreenMountedRef, testServerConnection, waitForCondition]); + }, [daemon.label, directWsUrl, isScreenMountedRef, testServerConnection, waitForCondition]); const beginServerRestart = useCallback(() => { if (!restartServerFn) { @@ -1017,7 +1104,7 @@ function DaemonCard({ {badgeText} - {daemon.wsUrl} + {daemon.endpoints?.[0] ?? ""} {connectionError ? {connectionError} : null} {testState && testState.status !== "idle" ? ( diff --git a/packages/app/src/components/multi-daemon-session-host.tsx b/packages/app/src/components/multi-daemon-session-host.tsx index 7f07a5f33..ad98f4629 100644 --- a/packages/app/src/components/multi-daemon-session-host.tsx +++ b/packages/app/src/components/multi-daemon-session-host.tsx @@ -1,5 +1,126 @@ import { SessionProvider } from "@/contexts/session-context"; -import { useDaemonRegistry } from "@/contexts/daemon-registry-context"; +import { + useDaemonRegistry, + type HostProfile, +} from "@/contexts/daemon-registry-context"; +import { useDaemonConnections } from "@/contexts/daemon-connections-context"; +import { buildDaemonWebSocketUrl, buildRelayWebSocketUrl } from "@/utils/daemon-endpoints"; +import { useEffect, useMemo, useRef, useState } from "react"; + +function buildCandidateUrls(daemon: HostProfile): string[] { + const endpoints = daemon.endpoints ?? []; + const sessionId = daemon.relay?.sessionId ?? null; + const lastKnownGood = + typeof daemon.metadata?.lastKnownGoodEndpoint === "string" + ? (daemon.metadata.lastKnownGoodEndpoint as string) + : null; + + const out: string[] = []; + const push = (url: string) => { + if (!out.includes(url)) out.push(url); + }; + + if (lastKnownGood) { + push(buildDaemonWebSocketUrl(lastKnownGood)); + } + + for (const endpoint of endpoints) { + push(buildDaemonWebSocketUrl(endpoint)); + } + + if (sessionId) { + if (lastKnownGood) { + push(buildRelayWebSocketUrl({ endpoint: lastKnownGood, sessionId })); + } + for (const endpoint of endpoints) { + push(buildRelayWebSocketUrl({ endpoint, sessionId })); + } + } + + return out; +} + +function extractEndpointFromUrl(url: string): string | null { + try { + const parsed = new URL(url); + const host = parsed.hostname; + const port = parsed.port ? Number(parsed.port) : parsed.protocol === "wss:" ? 443 : 80; + const isIpv6 = host.includes(":") && !host.startsWith("[") && !host.endsWith("]"); + return isIpv6 ? `[${host}]:${port}` : `${host}:${port}`; + } catch { + return null; + } +} + +function ManagedDaemonSession({ daemon }: { daemon: HostProfile }) { + const { connectionStates } = useDaemonConnections(); + const { updateDaemon } = useDaemonRegistry(); + + const candidates = useMemo(() => buildCandidateUrls(daemon), [daemon]); + const [activeIndex, setActiveIndex] = useState(0); + const activeUrl = candidates[activeIndex] ?? candidates[0] ?? buildDaemonWebSocketUrl("localhost:6767"); + + const lastAttemptedUrlRef = useRef(null); + const pendingMetadataWriteRef = useRef(false); + + useEffect(() => { + // If the active URL fell out of the candidate set (e.g. endpoints updated), snap back. + const idx = candidates.indexOf(activeUrl); + if (idx === -1) { + setActiveIndex(0); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [candidates.join("|")]); + + const connection = connectionStates.get(daemon.id); + const status = connection?.status ?? "idle"; + const lastError = connection?.lastError ?? null; + + useEffect(() => { + if (!connection) return; + + if (status === "online") { + if (pendingMetadataWriteRef.current) return; + const endpoint = extractEndpointFromUrl(activeUrl); + if (!endpoint) return; + if (daemon.metadata?.lastKnownGoodEndpoint === endpoint) return; + + pendingMetadataWriteRef.current = true; + void updateDaemon(daemon.id, { + metadata: { ...(daemon.metadata ?? {}), lastKnownGoodEndpoint: endpoint }, + }).finally(() => { + pendingMetadataWriteRef.current = false; + }); + return; + } + + if ((status === "error" || (status === "offline" && lastError)) && candidates.length > 1) { + if (lastAttemptedUrlRef.current === activeUrl) { + return; + } + lastAttemptedUrlRef.current = activeUrl; + if (activeIndex < candidates.length - 1) { + setActiveIndex((idx) => Math.min(idx + 1, candidates.length - 1)); + } + } + }, [ + activeIndex, + activeUrl, + candidates.length, + daemon.id, + daemon.metadata, + lastError, + status, + updateDaemon, + connection, + ]); + + return ( + + {null} + + ); +} export function MultiDaemonSessionHost() { const { daemons } = useDaemonRegistry(); @@ -10,9 +131,7 @@ export function MultiDaemonSessionHost() { return ( <> {daemons.map((daemon) => ( - - {null} - + ))} ); diff --git a/packages/app/src/components/sliding-sidebar.tsx b/packages/app/src/components/sliding-sidebar.tsx index 0c6cfb25e..4cd07f95a 100644 --- a/packages/app/src/components/sliding-sidebar.tsx +++ b/packages/app/src/components/sliding-sidebar.tsx @@ -12,7 +12,6 @@ import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyl import { Plus, Settings } from "lucide-react-native"; import { router } from "expo-router"; import { usePanelStore } from "@/stores/panel-store"; -import { useSessionStore } from "@/stores/session-store"; import { AgentList } from "./agent-list"; import { useAggregatedAgents } from "@/hooks/use-aggregated-agents"; import { useSidebarAnimation } from "@/contexts/sidebar-animation-context"; @@ -25,30 +24,6 @@ interface SlidingSidebarProps { selectedAgentId?: string; } -function buildCreateAgentRouteFromSelectedAgentKey(selectedAgentKey?: string) { - if (!selectedAgentKey) { - return { pathname: "/" as const, params: {} as Record }; - } - - const [serverId, agentId] = selectedAgentKey.split(":", 2); - if (!serverId || !agentId) { - return { pathname: "/" as const, params: {} as Record }; - } - - const state = useSessionStore.getState(); - const agent = state.sessions[serverId]?.agents.get(agentId); - if (!agent) { - return { pathname: "/" as const, params: {} as Record }; - } - - const params: Record = { serverId: agent.serverId }; - if (agent.provider) params.provider = agent.provider; - if (agent.currentModeId) params.modeId = agent.currentModeId; - if (agent.model) params.model = agent.model; - if (agent.cwd) params.workingDir = agent.cwd; - return { pathname: "/" as const, params }; -} - export function SlidingSidebar({ selectedAgentId }: SlidingSidebarProps) { const { theme } = useUnistyles(); const insets = useSafeAreaInsets(); @@ -106,20 +81,20 @@ export function SlidingSidebar({ selectedAgentId }: SlidingSidebarProps) { closeToAgent(); }, [closeToAgent]); - const handleCreateAgent = useCallback(() => { - router.push(buildCreateAgentRouteFromSelectedAgentKey(selectedAgentId)); - }, [selectedAgentId]); + const handleCreateAgentClean = useCallback(() => { + router.push("/"); + }, []); // Mobile: close sidebar and navigate - const handleCreateAgentMobile = useCallback(() => { + const handleCreateAgentCleanMobile = useCallback(() => { closeToAgent(); - handleCreateAgent(); - }, [closeToAgent, handleCreateAgent]); + handleCreateAgentClean(); + }, [closeToAgent, handleCreateAgentClean]); // Desktop: just navigate, don't close - const handleCreateAgentDesktop = useCallback(() => { - handleCreateAgent(); - }, [handleCreateAgent]); + const handleCreateAgentCleanDesktop = useCallback(() => { + handleCreateAgentClean(); + }, [handleCreateAgentClean]); // Mobile: close sidebar and navigate const handleSettingsMobile = useCallback(() => { @@ -240,7 +215,7 @@ export function SlidingSidebar({ selectedAgentId }: SlidingSidebarProps) { hovered && styles.newAgentButtonHovered, ]} testID="sidebar-new-agent" - onPress={handleCreateAgentMobile} + onPress={handleCreateAgentCleanMobile} > New Agent @@ -292,7 +267,7 @@ export function SlidingSidebar({ selectedAgentId }: SlidingSidebarProps) { hovered && styles.newAgentButtonHovered, ]} testID="sidebar-new-agent" - onPress={handleCreateAgentDesktop} + onPress={handleCreateAgentCleanDesktop} > New Agent diff --git a/packages/app/src/contexts/daemon-registry-context.tsx b/packages/app/src/contexts/daemon-registry-context.tsx index e3724dd0e..3892213a8 100644 --- a/packages/app/src/contexts/daemon-registry-context.tsx +++ b/packages/app/src/contexts/daemon-registry-context.tsx @@ -2,40 +2,67 @@ import { createContext, useCallback, useContext } from "react"; import type { ReactNode } from "react"; import AsyncStorage from "@react-native-async-storage/async-storage"; import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { z } from "zod"; +import { + buildDaemonWebSocketUrl, + decodeOfferFragmentPayload, + deriveLabelFromEndpoint, + extractHostPortFromWebSocketUrl, + normalizeHostPort, +} from "@/utils/daemon-endpoints"; const REGISTRY_STORAGE_KEY = "@paseo:daemon-registry"; const LEGACY_SETTINGS_KEY = "@paseo:settings"; -const FALLBACK_DAEMON_URL = "ws://localhost:6767/ws"; -const DEFAULT_DAEMONS: Array<{ label: string; wsUrl: string; restUrl?: string | null }> = [ - { label: "localhost", wsUrl: "ws://localhost:6767/ws" }, +const FALLBACK_DAEMON_ENDPOINT = "localhost:6767"; +const DEFAULT_HOSTS: Array<{ label: string; endpoint: string }> = [ + { label: "localhost", endpoint: "localhost:6767" }, ]; const DAEMON_REGISTRY_QUERY_KEY = ["daemon-registry"]; -export type DaemonProfile = { +export type HostRelayConfig = { + endpoint: string; + sessionId: string; +}; + +export type HostProfile = { id: string; label: string; - wsUrl: string; - restUrl?: string | null; + endpoints: string[]; + daemonPublicKeyB64?: string; + relay?: HostRelayConfig | null; createdAt: string; updatedAt: string; metadata?: Record | null; }; -type CreateDaemonInput = { +// Backward compatibility with older imports. +export type DaemonProfile = HostProfile; + +type CreateHostInput = { label: string; - wsUrl: string; - restUrl?: string | null; + endpoints: string[]; }; -type UpdateDaemonInput = Partial>; +type UpdateHostInput = Partial>; + +const ConnectionOfferV1Schema = z.object({ + v: z.literal(1), + sessionId: z.string().min(1), + endpoints: z.array(z.string().min(1)).min(1), + daemonPublicKeyB64: z.string().min(1), +}); + +export type ConnectionOfferV1 = z.infer; interface DaemonRegistryContextValue { - daemons: DaemonProfile[]; + daemons: HostProfile[]; isLoading: boolean; error: unknown | null; - addDaemon: (input: CreateDaemonInput) => Promise; - updateDaemon: (id: string, updates: UpdateDaemonInput) => Promise; + addDaemon: (input: CreateHostInput) => Promise; + updateDaemon: (id: string, updates: UpdateHostInput) => Promise; removeDaemon: (id: string) => Promise; + upsertDaemonFromOffer: (offer: ConnectionOfferV1) => Promise; + upsertDaemonFromOfferUrl: (offerUrlOrFragment: string) => Promise; } const DaemonRegistryContext = createContext(null); @@ -58,25 +85,26 @@ export function DaemonRegistryProvider({ children }: { children: ReactNode }) { }); const persist = useCallback( - async (profiles: DaemonProfile[]) => { - queryClient.setQueryData(DAEMON_REGISTRY_QUERY_KEY, profiles); + async (profiles: HostProfile[]) => { + queryClient.setQueryData(DAEMON_REGISTRY_QUERY_KEY, profiles); await AsyncStorage.setItem(REGISTRY_STORAGE_KEY, JSON.stringify(profiles)); }, [queryClient] ); const readDaemons = useCallback(() => { - return queryClient.getQueryData(DAEMON_REGISTRY_QUERY_KEY) ?? daemons; + return queryClient.getQueryData(DAEMON_REGISTRY_QUERY_KEY) ?? daemons; }, [queryClient, daemons]); - const addDaemon = useCallback(async (input: CreateDaemonInput) => { + const addDaemon = useCallback(async (input: CreateHostInput) => { const existing = readDaemons(); const timestamp = new Date().toISOString(); - const profile: DaemonProfile = { + const profile: HostProfile = { id: generateDaemonId(), - label: input.label.trim() || deriveLabelFromUrl(input.wsUrl), - wsUrl: input.wsUrl, - restUrl: input.restUrl ?? null, + label: input.label.trim() || deriveLabelFromEndpoint(input.endpoints[0] ?? ""), + endpoints: input.endpoints.map((endpoint) => normalizeHostPort(endpoint)), + daemonPublicKeyB64: undefined, + relay: null, createdAt: timestamp, updatedAt: timestamp, metadata: null, @@ -87,7 +115,7 @@ export function DaemonRegistryProvider({ children }: { children: ReactNode }) { return profile; }, [persist, readDaemons]); - const updateDaemon = useCallback(async (id: string, updates: UpdateDaemonInput) => { + const updateDaemon = useCallback(async (id: string, updates: UpdateHostInput) => { const next = readDaemons().map((daemon) => daemon.id === id ? { @@ -102,10 +130,67 @@ export function DaemonRegistryProvider({ children }: { children: ReactNode }) { const removeDaemon = useCallback(async (id: string) => { const remaining = readDaemons().filter((daemon) => daemon.id !== id); - const next = remaining.length > 0 ? remaining : [createProfile("Local Host", FALLBACK_DAEMON_URL)]; - await persist(next); + await persist(remaining); }, [persist, readDaemons]); + const upsertDaemonFromOffer = useCallback( + async (offer: ConnectionOfferV1) => { + const existing = readDaemons(); + const now = new Date().toISOString(); + const normalizedEndpoints = offer.endpoints.map((endpoint) => normalizeHostPort(endpoint)); + const relayEndpoint = normalizedEndpoints[normalizedEndpoints.length - 1]; + + const matchIndex = existing.findIndex((daemon) => daemon.daemonPublicKeyB64 === offer.daemonPublicKeyB64); + if (matchIndex !== -1) { + const updated: HostProfile = { + ...existing[matchIndex], + daemonPublicKeyB64: offer.daemonPublicKeyB64, + endpoints: normalizedEndpoints, + relay: { endpoint: relayEndpoint, sessionId: offer.sessionId }, + updatedAt: now, + }; + const next = [...existing]; + next[matchIndex] = updated; + await persist(next); + return updated; + } + + const profile: HostProfile = { + id: generateDaemonId(), + label: deriveLabelFromEndpoint(normalizedEndpoints[0] ?? "Unnamed Host"), + endpoints: normalizedEndpoints, + daemonPublicKeyB64: offer.daemonPublicKeyB64, + relay: { endpoint: relayEndpoint, sessionId: offer.sessionId }, + createdAt: now, + updatedAt: now, + metadata: null, + }; + + const next = [...existing, profile]; + await persist(next); + return profile; + }, + [persist, readDaemons] + ); + + const upsertDaemonFromOfferUrl = useCallback( + async (offerUrlOrFragment: string) => { + const marker = "#offer="; + const idx = offerUrlOrFragment.indexOf(marker); + if (idx === -1) { + throw new Error("Missing #offer= fragment"); + } + const encoded = offerUrlOrFragment.slice(idx + marker.length).trim(); + if (!encoded) { + throw new Error("Offer payload is empty"); + } + const payload = decodeOfferFragmentPayload(encoded); + const offer = ConnectionOfferV1Schema.parse(payload); + return upsertDaemonFromOffer(offer); + }, + [upsertDaemonFromOffer] + ); + const value: DaemonRegistryContextValue = { daemons, isLoading: isPending, @@ -113,6 +198,8 @@ export function DaemonRegistryProvider({ children }: { children: ReactNode }) { addDaemon, updateDaemon, removeDaemon, + upsertDaemonFromOffer, + upsertDaemonFromOfferUrl, }; return ( @@ -127,22 +214,14 @@ function generateDaemonId(): string { return `daemon_${Date.now().toString(36)}_${random}`; } -function deriveLabelFromUrl(url: string): string { - try { - const parsed = new URL(url); - return parsed.hostname || "Unnamed Host"; - } catch { - return "Unnamed Host"; - } -} - -function createProfile(label: string, wsUrl: string): DaemonProfile { +function createProfile(label: string, endpoint: string): HostProfile { const timestamp = new Date().toISOString(); return { id: generateDaemonId(), label, - wsUrl, - restUrl: null, + endpoints: [normalizeHostPort(endpoint)], + daemonPublicKeyB64: undefined, + relay: null, createdAt: timestamp, updatedAt: timestamp, metadata: null, @@ -151,14 +230,14 @@ function createProfile(label: string, wsUrl: string): DaemonProfile { type EnvDaemonConfig = { label?: string; - wsUrl: string; - restUrl?: string | null; + endpoint?: string; + wsUrl?: string; }; -function parseEnvDaemonDefaults(): DaemonProfile[] { +function parseEnvDaemonDefaults(): HostProfile[] { const envDaemons = (() => { // Primary: allow a JSON array string like - // EXPO_PUBLIC_DAEMONS='[{"label":"Host","wsUrl":"ws://10.0.0.1:6767/ws"}]' + // EXPO_PUBLIC_DAEMONS='[{"label":"Host","endpoint":"10.0.0.1:6767"}]' const jsonList = process.env.EXPO_PUBLIC_DAEMONS; if (jsonList) { try { @@ -178,7 +257,6 @@ function parseEnvDaemonDefaults(): DaemonProfile[] { { label: process.env.EXPO_PUBLIC_DAEMON_LABEL, wsUrl: singleWsUrl, - restUrl: process.env.EXPO_PUBLIC_DAEMON_REST_URL, }, ]; } @@ -188,23 +266,120 @@ function parseEnvDaemonDefaults(): DaemonProfile[] { const timestamp = new Date().toISOString(); return envDaemons - .filter((entry) => typeof entry?.wsUrl === "string" && entry.wsUrl.trim().length > 0) - .map((entry) => ({ - id: generateDaemonId(), - label: entry.label?.trim() || deriveLabelFromUrl(entry.wsUrl), - wsUrl: entry.wsUrl.trim(), - restUrl: entry.restUrl ?? null, - createdAt: timestamp, - updatedAt: timestamp, - metadata: null, - })); + .map((entry): HostProfile | null => { + const endpoint = (() => { + if (typeof entry.endpoint === "string" && entry.endpoint.trim().length > 0) { + return entry.endpoint.trim(); + } + if (typeof entry.wsUrl === "string" && entry.wsUrl.trim().length > 0) { + try { + return extractHostPortFromWebSocketUrl(entry.wsUrl.trim()); + } catch { + return null; + } + } + return null; + })(); + + if (!endpoint) return null; + + return { + id: generateDaemonId(), + label: entry.label?.trim() || deriveLabelFromEndpoint(endpoint), + endpoints: [normalizeHostPort(endpoint)], + createdAt: timestamp, + updatedAt: timestamp, + metadata: null, + }; + }) + .filter((entry): entry is HostProfile => entry !== null); } -async function loadDaemonRegistryFromStorage(): Promise { +type LegacyDaemonProfile = { + id: string; + label: string; + wsUrl: string; + restUrl?: string | null; + createdAt: string; + updatedAt: string; + metadata?: Record | null; +}; + +function isLegacyDaemonProfile(value: unknown): value is LegacyDaemonProfile { + if (!value || typeof value !== "object") return false; + const obj = value as Record; + return typeof obj.id === "string" && typeof obj.wsUrl === "string" && typeof obj.label === "string"; +} + +function isHostProfile(value: unknown): value is HostProfile { + if (!value || typeof value !== "object") return false; + const obj = value as Record; + return typeof obj.id === "string" && typeof obj.label === "string" && Array.isArray(obj.endpoints); +} + +function migrateLegacyToHostProfile(legacy: LegacyDaemonProfile): HostProfile { + const endpoint = extractHostPortFromWebSocketUrl(legacy.wsUrl); + return { + id: legacy.id, + label: legacy.label, + endpoints: [normalizeHostPort(endpoint)], + daemonPublicKeyB64: undefined, + relay: null, + createdAt: legacy.createdAt, + updatedAt: legacy.updatedAt, + metadata: legacy.metadata ?? null, + }; +} + +async function loadDaemonRegistryFromStorage(): Promise { try { const stored = await AsyncStorage.getItem(REGISTRY_STORAGE_KEY); if (stored) { - return JSON.parse(stored) as DaemonProfile[]; + const parsed = JSON.parse(stored) as unknown; + if (Array.isArray(parsed)) { + if (parsed.length === 0) { + return []; + } + const hasLegacy = parsed.some((entry) => isLegacyDaemonProfile(entry)); + const hasNew = parsed.some((entry) => isHostProfile(entry)); + + if (hasNew && !hasLegacy) { + return parsed as HostProfile[]; + } + + const migrated = parsed + .map((entry) => { + if (isHostProfile(entry)) { + const endpoints = entry.endpoints + .map((endpoint) => { + try { + return normalizeHostPort(String(endpoint)); + } catch { + return null; + } + }) + .filter((endpoint): endpoint is string => endpoint !== null); + if (endpoints.length === 0) { + return null; + } + return { ...entry, endpoints } as HostProfile; + } + if (isLegacyDaemonProfile(entry)) { + try { + return migrateLegacyToHostProfile(entry); + } catch { + return null; + } + } + return null; + }) + .filter((entry): entry is HostProfile => entry !== null); + + if (migrated.length > 0) { + await AsyncStorage.setItem(REGISTRY_STORAGE_KEY, JSON.stringify(migrated)); + return migrated; + } + } } const legacy = await AsyncStorage.getItem(LEGACY_SETTINGS_KEY); @@ -212,7 +387,8 @@ async function loadDaemonRegistryFromStorage(): Promise { const legacyParsed = JSON.parse(legacy) as Record; const legacyUrl = typeof legacyParsed.serverUrl === "string" ? legacyParsed.serverUrl : null; if (legacyUrl) { - const migrated = [createProfile("Primary Host", legacyUrl)]; + const endpoint = extractHostPortFromWebSocketUrl(legacyUrl); + const migrated = [createProfile("Primary Host", endpoint)]; await AsyncStorage.setItem(REGISTRY_STORAGE_KEY, JSON.stringify(migrated)); return migrated; } @@ -224,9 +400,9 @@ async function loadDaemonRegistryFromStorage(): Promise { return envDefaults; } - const fallback = DEFAULT_DAEMONS.length > 0 - ? DEFAULT_DAEMONS.map((entry) => createProfile(entry.label, entry.wsUrl)) - : [createProfile("Local Host", FALLBACK_DAEMON_URL)]; + const fallback = DEFAULT_HOSTS.length > 0 + ? DEFAULT_HOSTS.map((entry) => createProfile(entry.label, entry.endpoint)) + : [createProfile("Local Host", FALLBACK_DAEMON_ENDPOINT)]; await AsyncStorage.setItem(REGISTRY_STORAGE_KEY, JSON.stringify(fallback)); return fallback; } catch (error) { @@ -234,3 +410,8 @@ async function loadDaemonRegistryFromStorage(): Promise { throw error; } } + +export function buildDirectDaemonWsUrl(profile: HostProfile): string { + const endpoint = profile.endpoints[0] ?? FALLBACK_DAEMON_ENDPOINT; + return buildDaemonWebSocketUrl(endpoint); +} diff --git a/packages/app/src/hooks/use-agent-form-state.ts b/packages/app/src/hooks/use-agent-form-state.ts index 4029ff377..6edc9b662 100644 --- a/packages/app/src/hooks/use-agent-form-state.ts +++ b/packages/app/src/hooks/use-agent-form-state.ts @@ -289,8 +289,8 @@ export function useAgentFormState( return; } - // Wait for preferences to load before first resolution - if (isPreferencesLoading && !hasResolvedRef.current) { + // Wait for preferences to load before first resolution, unless explicit URL overrides exist. + if (isPreferencesLoading && !hasResolvedRef.current && !combinedInitialValues) { return; } @@ -325,6 +325,25 @@ export function useAgentFormState( formState, ]); + // Persist inferred serverId so reloads keep the selection (e.g. URL serverId or first-time load). + useEffect(() => { + if (!isVisible || !isCreateFlow) return; + if (isPreferencesLoading) return; + if (userModified.serverId) return; + const serverId = formState.serverId; + if (!serverId) return; + if (preferences?.serverId === serverId) return; + void updatePreferences({ serverId }); + }, [ + isVisible, + isCreateFlow, + isPreferencesLoading, + userModified.serverId, + formState.serverId, + preferences?.serverId, + updatePreferences, + ]); + // Provider model request timers const providerModelRequestTimersRef = useRef< Map> diff --git a/packages/app/src/hooks/use-daemon-client.ts b/packages/app/src/hooks/use-daemon-client.ts index 19c5610c5..9bd6e78eb 100644 --- a/packages/app/src/hooks/use-daemon-client.ts +++ b/packages/app/src/hooks/use-daemon-client.ts @@ -1,6 +1,7 @@ import { useEffect, useMemo } from "react"; import { AppState } from "react-native"; import { DaemonClientV2 } from "@server/client/daemon-client-v2"; +import { createTauriWebSocketTransportFactory } from "@/utils/tauri-daemon-transport"; function runDaemonRequest(label: string, promise: Promise): void { void promise.catch((error) => { @@ -10,11 +11,16 @@ function runDaemonRequest(label: string, promise: Promise): void { export function useDaemonClient(url: string): DaemonClientV2 { const client = useMemo( - () => - new DaemonClientV2({ + () => { + const tauriTransportFactory = createTauriWebSocketTransportFactory(); + return new DaemonClientV2({ url, suppressSendErrors: true, - }), + ...(tauriTransportFactory + ? { transportFactory: tauriTransportFactory } + : {}), + }); + }, [url] ); diff --git a/packages/app/src/stores/download-store.ts b/packages/app/src/stores/download-store.ts index c125465e6..c13f8d463 100644 --- a/packages/app/src/stores/download-store.ts +++ b/packages/app/src/stores/download-store.ts @@ -4,6 +4,7 @@ import { File as FSFile, Paths } from "expo-file-system"; import * as LegacyFileSystem from "expo-file-system/legacy"; import * as Sharing from "expo-sharing"; import type { DaemonProfile } from "@/contexts/daemon-registry-context"; +import { buildDaemonWebSocketUrl } from "@/utils/daemon-endpoints"; interface DownloadProgress { percent: number; @@ -246,14 +247,14 @@ type DownloadTarget = { }; function resolveDaemonDownloadTarget(daemon?: DaemonProfile): DownloadTarget { - const rawUrl = daemon?.restUrl ?? daemon?.wsUrl; - if (!rawUrl) { + const endpoint = daemon?.endpoints?.[0] ?? null; + if (!endpoint) { return { baseUrl: null, authHeader: null, authCredentials: null }; } let parsed: URL; try { - parsed = new URL(rawUrl); + parsed = new URL(buildDaemonWebSocketUrl(endpoint)); } catch { return { baseUrl: null, authHeader: null, authCredentials: null }; } diff --git a/packages/app/src/utils/daemon-endpoints.ts b/packages/app/src/utils/daemon-endpoints.ts new file mode 100644 index 000000000..34f134b40 --- /dev/null +++ b/packages/app/src/utils/daemon-endpoints.ts @@ -0,0 +1,114 @@ +import { Buffer } from "buffer"; + +export type HostPortParts = { + host: string; + port: number; + isIpv6: boolean; +}; + +function decodeBase64UrlToUtf8(input: string): string { + const base64 = input.replace(/-/g, "+").replace(/_/g, "/"); + const padded = base64.padEnd(base64.length + ((4 - (base64.length % 4)) % 4), "="); + return Buffer.from(padded, "base64").toString("utf8"); +} + +export function decodeOfferFragmentPayload(encoded: string): unknown { + const json = decodeBase64UrlToUtf8(encoded); + return JSON.parse(json) as unknown; +} + +export function parseHostPort(input: string): HostPortParts { + const trimmed = input.trim(); + if (!trimmed) { + throw new Error("Host is required"); + } + + // IPv6: [::1]:6767 + if (trimmed.startsWith("[")) { + const match = trimmed.match(/^\[([^\]]+)\]:(\d{1,5})$/); + if (!match) { + throw new Error("Invalid host:port (expected [::1]:6767)"); + } + const host = match[1].trim(); + const port = Number(match[2]); + if (!host) throw new Error("Host is required"); + if (!Number.isInteger(port) || port < 1 || port > 65535) { + throw new Error("Port must be between 1 and 65535"); + } + return { host, port, isIpv6: true }; + } + + const match = trimmed.match(/^(.+):(\d{1,5})$/); + if (!match) { + throw new Error("Invalid host:port (expected localhost:6767)"); + } + const host = match[1].trim(); + const port = Number(match[2]); + if (!host) throw new Error("Host is required"); + if (!Number.isInteger(port) || port < 1 || port > 65535) { + throw new Error("Port must be between 1 and 65535"); + } + return { host, port, isIpv6: false }; +} + +export function normalizeHostPort(input: string): string { + const { host, port, isIpv6 } = parseHostPort(input); + if (isIpv6) { + return `[${host}]:${port}`; + } + return `${host}:${port}`; +} + +export function extractHostPortFromWebSocketUrl(wsUrl: string): string { + const parsed = new URL(wsUrl); + if (parsed.protocol !== "ws:" && parsed.protocol !== "wss:") { + throw new Error("Invalid WebSocket URL protocol"); + } + if (parsed.pathname.replace(/\/+$/, "") !== "/ws") { + throw new Error("Invalid WebSocket URL (expected /ws path)"); + } + + const host = parsed.hostname; + const port = parsed.port ? Number(parsed.port) : parsed.protocol === "wss:" ? 443 : 80; + if (!host) { + throw new Error("Invalid WebSocket URL (missing hostname)"); + } + if (!Number.isInteger(port) || port < 1 || port > 65535) { + throw new Error("Invalid WebSocket URL (invalid port)"); + } + + const isIpv6 = host.includes(":") && !host.startsWith("[") && !host.endsWith("]"); + const hostPort = isIpv6 ? `[${host}]:${port}` : `${host}:${port}`; + return hostPort; +} + +function shouldUseSecureWebSocket(port: number): boolean { + return port === 443; +} + +export function buildDaemonWebSocketUrl(endpoint: string): string { + const { host, port, isIpv6 } = parseHostPort(endpoint); + const protocol = shouldUseSecureWebSocket(port) ? "wss" : "ws"; + const hostPart = isIpv6 ? `[${host}]` : host; + return `${protocol}://${hostPart}:${port}/ws`; +} + +export function buildRelayWebSocketUrl(params: { endpoint: string; sessionId: string }): string { + const { host, port, isIpv6 } = parseHostPort(params.endpoint); + const protocol = shouldUseSecureWebSocket(port) ? "wss" : "ws"; + const hostPart = isIpv6 ? `[${host}]` : host; + const url = new URL(`${protocol}://${hostPart}:${port}/ws`); + url.searchParams.set("session", params.sessionId); + url.searchParams.set("role", "client"); + return url.toString(); +} + +export function deriveLabelFromEndpoint(endpoint: string): string { + try { + const { host } = parseHostPort(endpoint); + return host || "Unnamed Host"; + } catch { + return "Unnamed Host"; + } +} + diff --git a/packages/app/src/utils/tauri-daemon-transport.ts b/packages/app/src/utils/tauri-daemon-transport.ts new file mode 100644 index 000000000..615baca28 --- /dev/null +++ b/packages/app/src/utils/tauri-daemon-transport.ts @@ -0,0 +1,232 @@ +import type { DaemonTransport, DaemonTransportFactory } from "@server/client/daemon-client-v2"; + +type TauriWebSocketMessage = + | { type: "Text"; data: string } + | { type: "Binary"; data: number[] } + | { type: "Ping"; data: number[] } + | { type: "Pong"; data: number[] } + | { type: "Close"; data: { code: number; reason: string } | null }; + +type TauriWebSocketConnection = { + addListener(cb: (msg: TauriWebSocketMessage) => void): () => void; + send(message: string | number[] | TauriWebSocketMessage): Promise; + disconnect(): Promise; +}; + +type TauriWebSocketModule = { + connect(url: string, config?: unknown): Promise; +}; + +function isTauriEnvironment(): boolean { + return typeof window !== "undefined" && (window as any).__TAURI__ !== undefined; +} + +function getTauriWebSocketModule(): TauriWebSocketModule | null { + if (!isTauriEnvironment()) { + return null; + } + const ws = (window as any).__TAURI__?.websocket; + if (ws && typeof ws.connect === "function") { + return ws as TauriWebSocketModule; + } + return null; +} + +export function createTauriWebSocketTransportFactory(): DaemonTransportFactory | null { + if (!isTauriEnvironment()) { + return null; + } + + return ({ url }) => { + let ws: TauriWebSocketConnection | null = null; + let wsListenerCleanup: (() => void) | null = null; + let disposed = false; + let opened = false; + let closeEmitted = false; + + const openHandlers = new Set<() => void>(); + const closeHandlers = new Set<(event?: unknown) => void>(); + const errorHandlers = new Set<(event?: unknown) => void>(); + const messageHandlers = new Set<(data: unknown) => void>(); + + const pendingSends: string[] = []; + let closeRequested: { code?: number; reason?: string } | null = null; + + const emitOpen = () => { + if (disposed || opened) return; + opened = true; + for (const handler of openHandlers) { + try { + handler(); + } catch { + // no-op + } + } + }; + + const emitClose = (event?: unknown) => { + if (disposed || closeEmitted) return; + closeEmitted = true; + for (const handler of closeHandlers) { + try { + handler(event); + } catch { + // no-op + } + } + }; + + const emitError = (event?: unknown) => { + if (disposed) return; + for (const handler of errorHandlers) { + try { + handler(event); + } catch { + // no-op + } + } + }; + + const emitMessage = (data: unknown) => { + if (disposed) return; + for (const handler of messageHandlers) { + try { + handler(data); + } catch { + // no-op + } + } + }; + + const connect = async () => { + try { + let module: TauriWebSocketModule | null = getTauriWebSocketModule(); + if (!module) { + const start = Date.now(); + while (!module && Date.now() - start < 2000) { + await new Promise((r) => setTimeout(r, 50)); + module = getTauriWebSocketModule(); + } + } + if (!module) { + throw new Error( + "Tauri WebSocket plugin is not available (expected window.__TAURI__.websocket). Did you enable tauri-plugin-websocket and websocket:default capability?" + ); + } + + const socket = await module.connect(url); + if (disposed) { + try { + await socket.disconnect(); + } catch { + // no-op + } + return; + } + + ws = socket; + + wsListenerCleanup = ws.addListener((msg) => { + if (msg.type === "Text") { + emitMessage({ data: msg.data }); + return; + } + if (msg.type === "Close") { + emitClose(msg.data ?? undefined); + return; + } + }); + + emitOpen(); + + while (pendingSends.length > 0) { + const next = pendingSends.shift(); + if (!next) break; + try { + await ws.send(next); + } catch (error) { + emitError(error); + } + } + + if (closeRequested) { + try { + await ws.disconnect(); + } catch (error) { + emitError(error); + } finally { + emitClose(closeRequested); + } + } + } catch (error) { + emitError(error); + } + }; + + void connect(); + + const transport: DaemonTransport = { + send: (data) => { + if (disposed) return; + if (!ws) { + pendingSends.push(data); + return; + } + void ws.send(data).catch((error) => emitError(error)); + }, + close: (code?: number, reason?: string) => { + if (disposed) return; + closeRequested = { code, reason }; + if (!ws) return; + void ws + .disconnect() + .catch((error) => emitError(error)) + .finally(() => emitClose(closeRequested)); + }, + onMessage: (handler) => { + messageHandlers.add(handler); + return () => messageHandlers.delete(handler); + }, + onOpen: (handler) => { + openHandlers.add(handler); + if (opened) { + try { + handler(); + } catch { + // no-op + } + } + return () => openHandlers.delete(handler); + }, + onClose: (handler) => { + closeHandlers.add(handler); + if (closeEmitted) { + try { + handler(closeRequested ?? undefined); + } catch { + // no-op + } + } + return () => closeHandlers.delete(handler); + }, + onError: (handler) => { + errorHandlers.add(handler); + return () => errorHandlers.delete(handler); + }, + }; + + return { + ...transport, + close: (code?: number, reason?: string) => { + transport.close(code, reason); + disposed = true; + try { + wsListenerCleanup?.(); + } catch { + // no-op + } + wsListenerCleanup = null; + }, + }; + }; +} diff --git a/packages/app/test-results/.last-run.json b/packages/app/test-results/.last-run.json deleted file mode 100644 index cbcc1fbac..000000000 --- a/packages/app/test-results/.last-run.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "status": "passed", - "failedTests": [] -} \ No newline at end of file diff --git a/packages/desktop/src-tauri/Cargo.lock b/packages/desktop/src-tauri/Cargo.lock index 9a24f402e..161e328aa 100644 --- a/packages/desktop/src-tauri/Cargo.lock +++ b/packages/desktop/src-tauri/Cargo.lock @@ -587,6 +587,12 @@ dependencies = [ "syn 2.0.114", ] +[[package]] +name = "data-encoding" +version = "2.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7a1e2f27636f116493b8b860f5546edb47c8d8f8ea73e1d2a20be88e28d1fea" + [[package]] name = "deranged" version = "0.5.5" @@ -2223,6 +2229,7 @@ dependencies = [ "tauri", "tauri-build", "tauri-plugin-log", + "tauri-plugin-websocket", ] [[package]] @@ -2582,6 +2589,16 @@ dependencies = [ "rand_core 0.6.4", ] +[[package]] +name = "rand" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" +dependencies = [ + "rand_chacha 0.9.0", + "rand_core 0.9.5", +] + [[package]] name = "rand_chacha" version = "0.2.2" @@ -2602,6 +2619,16 @@ dependencies = [ "rand_core 0.6.4", ] +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.5", +] + [[package]] name = "rand_core" version = "0.5.1" @@ -2620,6 +2647,15 @@ dependencies = [ "getrandom 0.2.17", ] +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + [[package]] name = "rand_hc" version = "0.2.0" @@ -2757,6 +2793,20 @@ dependencies = [ "web-sys", ] +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + [[package]] name = "rkyv" version = "0.7.46" @@ -2811,6 +2861,40 @@ dependencies = [ "semver", ] +[[package]] +name = "rustls" +version = "0.23.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c665f33d38cea657d9614f766881e4d510e0eda4239891eea56b4cadcf01801b" +dependencies = [ + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pki-types" +version = "1.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be040f8b0a225e40375822a563fa9524378b9d63112f53e19ffff34df5d33fdd" +dependencies = [ + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7df23109aa6c1567d1c575b9952556388da57401e4ace1d15f79eedad0d8f53" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + [[package]] name = "rustversion" version = "1.0.22" @@ -3093,6 +3177,17 @@ dependencies = [ "stable_deref_trait", ] +[[package]] +name = "sha1" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + [[package]] name = "sha2" version = "0.10.9" @@ -3241,6 +3336,12 @@ version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + [[package]] name = "swift-rs" version = "1.0.7" @@ -3523,6 +3624,26 @@ dependencies = [ "time", ] +[[package]] +name = "tauri-plugin-websocket" +version = "2.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe037be7e1c30be639fe12dc3077e8ec4709e6f30ca4a6016b7edc76232ae9ee" +dependencies = [ + "futures-util", + "http", + "log", + "rand 0.9.2", + "rustls", + "serde", + "serde_json", + "tauri", + "tauri-plugin", + "thiserror 2.0.18", + "tokio", + "tokio-tungstenite", +] + [[package]] name = "tauri-runtime" version = "2.9.2" @@ -3747,6 +3868,32 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls", + "tokio", +] + +[[package]] +name = "tokio-tungstenite" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d25a406cddcc431a75d3d9afc6a7c0f7428d4891dd973e4d54c56b46127bf857" +dependencies = [ + "futures-util", + "log", + "rustls", + "rustls-pki-types", + "tokio", + "tokio-rustls", + "tungstenite", + "webpki-roots 0.26.11", +] + [[package]] name = "tokio-util" version = "0.7.18" @@ -3948,6 +4095,25 @@ version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" +[[package]] +name = "tungstenite" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8628dcc84e5a09eb3d8423d6cb682965dea9133204e8fb3efee74c2a0c259442" +dependencies = [ + "bytes", + "data-encoding", + "http", + "httparse", + "log", + "rand 0.9.2", + "rustls", + "rustls-pki-types", + "sha1", + "thiserror 2.0.18", + "utf-8", +] + [[package]] name = "typeid" version = "1.0.3" @@ -4013,6 +4179,12 @@ version = "1.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + [[package]] name = "url" version = "2.5.8" @@ -4272,6 +4444,24 @@ dependencies = [ "system-deps", ] +[[package]] +name = "webpki-roots" +version = "0.26.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9" +dependencies = [ + "webpki-roots 1.0.5", +] + +[[package]] +name = "webpki-roots" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12bed680863276c63889429bfd6cab3b99943659923822de1c8a39c49e4d722c" +dependencies = [ + "rustls-pki-types", +] + [[package]] name = "webview2-com" version = "0.38.2" @@ -4502,6 +4692,15 @@ dependencies = [ "windows-targets 0.42.2", ] +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + [[package]] name = "windows-sys" version = "0.59.0" @@ -4912,6 +5111,12 @@ dependencies = [ "synstructure", ] +[[package]] +name = "zeroize" +version = "1.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" + [[package]] name = "zerotrie" version = "0.2.3" diff --git a/packages/desktop/src-tauri/Cargo.toml b/packages/desktop/src-tauri/Cargo.toml index ad189adee..e131648b8 100644 --- a/packages/desktop/src-tauri/Cargo.toml +++ b/packages/desktop/src-tauri/Cargo.toml @@ -27,3 +27,4 @@ serde = { version = "1.0", features = ["derive"] } log = "0.4" tauri = { version = "2.9.5", features = [] } tauri-plugin-log = "2" +tauri-plugin-websocket = "2" diff --git a/packages/desktop/src-tauri/capabilities/default.json b/packages/desktop/src-tauri/capabilities/default.json index 2dee8d1b9..7f56aa957 100644 --- a/packages/desktop/src-tauri/capabilities/default.json +++ b/packages/desktop/src-tauri/capabilities/default.json @@ -8,6 +8,7 @@ "permissions": [ "core:default", "core:window:allow-start-dragging", - "core:window:allow-toggle-maximize" + "core:window:allow-toggle-maximize", + "websocket:default" ] } diff --git a/packages/desktop/src-tauri/src/lib.rs b/packages/desktop/src-tauri/src/lib.rs index 9990c6c61..9107d5aee 100644 --- a/packages/desktop/src-tauri/src/lib.rs +++ b/packages/desktop/src-tauri/src/lib.rs @@ -19,6 +19,7 @@ fn set_zoom_factor(webview: &WebviewWindow, factor: f64) { #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { tauri::Builder::default() + .plugin(tauri_plugin_websocket::init()) .setup(|app| { if cfg!(debug_assertions) { app.handle().plugin(