feat: migrate from wsUrl to endpoints + add relay transport and pairing

This commit is contained in:
Mohamed Boudra
2026-01-21 14:55:23 +07:00
parent ae9a98223f
commit ddbcc272f1
29 changed files with 1729 additions and 280 deletions

4
.gitignore vendored
View File

@@ -37,6 +37,10 @@ tsconfig.tsbuildinfo
# Testing
coverage/
# Playwright
test-results/
**/test-results/
# Vercel
.vercel/

View File

@@ -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();
});

View File

@@ -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\//);

View File

@@ -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');

View File

@@ -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<number> {
return new Promise((resolve, reject) => {
@@ -40,20 +42,59 @@ async function waitForServer(port: number, timeout = 15000): Promise<void> {
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<OfferPayload>;
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<void>((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;

View File

@@ -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<void> {
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<void> {
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 });

View File

@@ -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 });
});

View File

@@ -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 }
);
});

View File

@@ -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 }
);
});

View File

@@ -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 }
);
});

View File

@@ -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 });
});

View File

@@ -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();
}
});

View File

@@ -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 <LoadingView />;
}
if (daemons.length === 0) {
return <MissingDaemonView />;
}
return (
<RealtimeProvider>
<OfferLinkListener upsertDaemonFromOfferUrl={upsertDaemonFromOfferUrl} />
{children}
</RealtimeProvider>
);
}
return <RealtimeProvider>{children}</RealtimeProvider>;
function OfferLinkListener({
upsertDaemonFromOfferUrl,
}: {
upsertDaemonFromOfferUrl: (offerUrlOrFragment: string) => Promise<unknown>;
}) {
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 }) {

View File

@@ -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<string, string> = { 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 (
<View style={styles.container}>
@@ -747,10 +725,6 @@ function AgentScreenContent({
<Folder size={16} color={theme.colors.foreground} />
<Text style={styles.menuItemText}>Browse Files</Text>
</Pressable>
<Pressable onPress={handleCreateNewAgent} style={styles.menuItem}>
<PlusIcon size={16} color={theme.colors.foreground} />
<Text style={styles.menuItemText}>New Agent</Text>
</Pressable>
<Pressable
onPress={handleRefreshAgent}
style={[

View File

@@ -571,7 +571,7 @@ export default function HomeScreen() {
<View style={styles.dropdownSheetList}>
{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 (
<Pressable
key={daemon.id}

View File

@@ -23,6 +23,7 @@ import { theme as defaultTheme } from "@/styles/theme";
import { MenuHeader } from "@/components/headers/menu-header";
import { useSessionStore } from "@/stores/session-store";
import { DaemonClientV2 } from "@server/client/daemon-client-v2";
import { buildDaemonWebSocketUrl, normalizeHostPort } from "@/utils/daemon-endpoints";
const delay = (ms: number) =>
new Promise<void>((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<Map<string, DaemonTestState>>(() => 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 ? (
<View style={styles.formCard}>
<Text style={styles.formTitle}>{daemonForm.id ? "Edit Host" : "Add Host"}</Text>
<View style={styles.formField}>
<Text style={styles.label}>Label</Text>
<TextInput
style={styles.input}
value={daemonForm.label}
onChangeText={(text) => setDaemonForm((prev) => ({ ...prev, label: text }))}
placeholder="My Host"
placeholderTextColor={defaultTheme.colors.mutedForeground}
/>
</View>
<View style={styles.formField}>
<Text style={styles.label}>Host URL</Text>
<TextInput
style={[styles.input, styles.inputUrl]}
value={daemonForm.wsUrl}
onChangeText={(text) => setDaemonForm((prev) => ({ ...prev, wsUrl: text }))}
placeholder="wss://example.com/ws"
placeholderTextColor={defaultTheme.colors.mutedForeground}
autoCapitalize="none"
autoCorrect={false}
keyboardType="url"
/>
</View>
{daemonForm.id ? (
<View style={styles.formField}>
<Text style={styles.label}>Label</Text>
<TextInput
style={styles.input}
value={daemonForm.label}
onChangeText={(text) => setDaemonForm((prev) => ({ ...prev, label: text }))}
placeholder="My Host"
placeholderTextColor={defaultTheme.colors.mutedForeground}
/>
</View>
) : null}
{daemonForm.id ? null : daemonFormMode === "choose" ? (
<View style={styles.formActionsRow}>
<Pressable
style={[styles.formButton, styles.formButtonPrimary]}
onPress={() => setDaemonFormMode("pair")}
>
<Text style={[styles.formButtonText, styles.formButtonPrimaryText]}>Pair</Text>
</Pressable>
<Pressable
style={[styles.formButton, styles.formButtonPrimary]}
onPress={() => setDaemonFormMode("manual")}
>
<Text style={[styles.formButtonText, styles.formButtonPrimaryText]}>Manual</Text>
</Pressable>
</View>
) : null}
{daemonFormMode === "manual" ? (
<View style={styles.formField}>
<Text style={styles.label}>Host</Text>
<TextInput
style={[styles.input, styles.inputUrl]}
value={daemonForm.endpoint}
onChangeText={(text) => setDaemonForm((prev) => ({ ...prev, endpoint: text }))}
placeholder="localhost:6767"
placeholderTextColor={defaultTheme.colors.mutedForeground}
autoCapitalize="none"
autoCorrect={false}
keyboardType="url"
/>
</View>
) : null}
{daemonFormMode === "pair" ? (
<View style={styles.formField}>
<Text style={styles.label}>Pairing Link</Text>
<TextInput
style={[styles.input, styles.inputUrl]}
value={daemonForm.offerUrl}
onChangeText={(text) => setDaemonForm((prev) => ({ ...prev, offerUrl: text }))}
placeholder="https://app.paseo.sh/#offer=..."
placeholderTextColor={defaultTheme.colors.mutedForeground}
autoCapitalize="none"
autoCorrect={false}
keyboardType="url"
/>
</View>
) : null}
<View style={styles.formActionsRow}>
<Pressable style={styles.formButton} onPress={handleCloseDaemonForm}>
<Text style={styles.formButtonText}>Cancel</Text>
</Pressable>
{!daemonForm.id && daemonFormMode !== "choose" ? (
<Pressable style={styles.formButton} onPress={() => setDaemonFormMode("choose")}>
<Text style={styles.formButtonText}>Back</Text>
</Pressable>
) : null}
<Pressable
style={[styles.formButton, styles.formButtonPrimary, isSavingDaemon && styles.hostActionDisabled]}
onPress={handleSubmitDaemonForm}
disabled={isSavingDaemon}
>
<Text style={[styles.formButtonText, styles.formButtonPrimaryText]}>
{isSavingDaemon ? "Saving..." : daemonForm.id ? "Save" : "Add"}
{isSavingDaemon ? "Saving..." : daemonForm.id ? "Save" : daemonFormMode === "pair" ? "Pair" : "Add"}
</Text>
</Pressable>
</View>
@@ -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({
<Text style={[styles.statusText, { color: statusColor }]}>{badgeText}</Text>
</View>
</View>
<Text style={styles.hostUrl}>{daemon.wsUrl}</Text>
<Text style={styles.hostUrl}>{daemon.endpoints?.[0] ?? ""}</Text>
{connectionError ? <Text style={styles.hostError}>{connectionError}</Text> : null}
{testState && testState.status !== "idle" ? (
<Text style={[styles.testResultText, { color: testResultColor }]}>

View File

@@ -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<string | null>(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 (
<SessionProvider key={`${daemon.id}:${activeUrl}`} serverUrl={activeUrl} serverId={daemon.id}>
{null}
</SessionProvider>
);
}
export function MultiDaemonSessionHost() {
const { daemons } = useDaemonRegistry();
@@ -10,9 +131,7 @@ export function MultiDaemonSessionHost() {
return (
<>
{daemons.map((daemon) => (
<SessionProvider key={daemon.id} serverUrl={daemon.wsUrl} serverId={daemon.id}>
{null}
</SessionProvider>
<ManagedDaemonSession key={daemon.id} daemon={daemon} />
))}
</>
);

View File

@@ -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<string, string> };
}
const [serverId, agentId] = selectedAgentKey.split(":", 2);
if (!serverId || !agentId) {
return { pathname: "/" as const, params: {} as Record<string, string> };
}
const state = useSessionStore.getState();
const agent = state.sessions[serverId]?.agents.get(agentId);
if (!agent) {
return { pathname: "/" as const, params: {} as Record<string, string> };
}
const params: Record<string, string> = { 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}
>
<Plus size={18} color={theme.colors.foreground} />
<Text style={styles.newAgentButtonText}>New Agent</Text>
@@ -292,7 +267,7 @@ export function SlidingSidebar({ selectedAgentId }: SlidingSidebarProps) {
hovered && styles.newAgentButtonHovered,
]}
testID="sidebar-new-agent"
onPress={handleCreateAgentDesktop}
onPress={handleCreateAgentCleanDesktop}
>
<Plus size={18} color={theme.colors.foreground} />
<Text style={styles.newAgentButtonText}>New Agent</Text>

View File

@@ -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<string, unknown> | 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<Omit<DaemonProfile, "id" | "createdAt">>;
type UpdateHostInput = Partial<Omit<HostProfile, "id" | "createdAt">>;
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<typeof ConnectionOfferV1Schema>;
interface DaemonRegistryContextValue {
daemons: DaemonProfile[];
daemons: HostProfile[];
isLoading: boolean;
error: unknown | null;
addDaemon: (input: CreateDaemonInput) => Promise<DaemonProfile>;
updateDaemon: (id: string, updates: UpdateDaemonInput) => Promise<void>;
addDaemon: (input: CreateHostInput) => Promise<HostProfile>;
updateDaemon: (id: string, updates: UpdateHostInput) => Promise<void>;
removeDaemon: (id: string) => Promise<void>;
upsertDaemonFromOffer: (offer: ConnectionOfferV1) => Promise<HostProfile>;
upsertDaemonFromOfferUrl: (offerUrlOrFragment: string) => Promise<HostProfile>;
}
const DaemonRegistryContext = createContext<DaemonRegistryContextValue | null>(null);
@@ -58,25 +85,26 @@ export function DaemonRegistryProvider({ children }: { children: ReactNode }) {
});
const persist = useCallback(
async (profiles: DaemonProfile[]) => {
queryClient.setQueryData<DaemonProfile[]>(DAEMON_REGISTRY_QUERY_KEY, profiles);
async (profiles: HostProfile[]) => {
queryClient.setQueryData<HostProfile[]>(DAEMON_REGISTRY_QUERY_KEY, profiles);
await AsyncStorage.setItem(REGISTRY_STORAGE_KEY, JSON.stringify(profiles));
},
[queryClient]
);
const readDaemons = useCallback(() => {
return queryClient.getQueryData<DaemonProfile[]>(DAEMON_REGISTRY_QUERY_KEY) ?? daemons;
return queryClient.getQueryData<HostProfile[]>(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<DaemonProfile[]> {
type LegacyDaemonProfile = {
id: string;
label: string;
wsUrl: string;
restUrl?: string | null;
createdAt: string;
updatedAt: string;
metadata?: Record<string, unknown> | null;
};
function isLegacyDaemonProfile(value: unknown): value is LegacyDaemonProfile {
if (!value || typeof value !== "object") return false;
const obj = value as Record<string, unknown>;
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<string, unknown>;
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<HostProfile[]> {
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<DaemonProfile[]> {
const legacyParsed = JSON.parse(legacy) as Record<string, unknown>;
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<DaemonProfile[]> {
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<DaemonProfile[]> {
throw error;
}
}
export function buildDirectDaemonWsUrl(profile: HostProfile): string {
const endpoint = profile.endpoints[0] ?? FALLBACK_DAEMON_ENDPOINT;
return buildDaemonWebSocketUrl(endpoint);
}

View File

@@ -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<string, ReturnType<typeof setTimeout>>

View File

@@ -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<unknown>): void {
void promise.catch((error) => {
@@ -10,11 +11,16 @@ function runDaemonRequest(label: string, promise: Promise<unknown>): 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]
);

View File

@@ -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 };
}

View File

@@ -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";
}
}

View File

@@ -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<void>;
disconnect(): Promise<void>;
};
type TauriWebSocketModule = {
connect(url: string, config?: unknown): Promise<TauriWebSocketConnection>;
};
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;
},
};
};
}

View File

@@ -1,4 +0,0 @@
{
"status": "passed",
"failedTests": []
}

View File

@@ -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"

View File

@@ -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"

View File

@@ -8,6 +8,7 @@
"permissions": [
"core:default",
"core:window:allow-start-dragging",
"core:window:allow-toggle-maximize"
"core:window:allow-toggle-maximize",
"websocket:default"
]
}

View File

@@ -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(