mirror of
https://github.com/getpaseo/paseo.git
synced 2026-07-29 12:01:31 +00:00
feat: migrate from wsUrl to endpoints + add relay transport and pairing
This commit is contained in:
4
.gitignore
vendored
4
.gitignore
vendored
@@ -37,6 +37,10 @@ tsconfig.tsbuildinfo
|
||||
# Testing
|
||||
coverage/
|
||||
|
||||
# Playwright
|
||||
test-results/
|
||||
**/test-results/
|
||||
|
||||
# Vercel
|
||||
.vercel/
|
||||
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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\//);
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
|
||||
|
||||
47
packages/app/e2e/manual-host-port.spec.ts
Normal file
47
packages/app/e2e/manual-host-port.spec.ts
Normal 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 }
|
||||
);
|
||||
});
|
||||
47
packages/app/e2e/migration-wsurl-to-hostport.spec.ts
Normal file
47
packages/app/e2e/migration-wsurl-to-hostport.spec.ts
Normal 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 }
|
||||
);
|
||||
});
|
||||
69
packages/app/e2e/pairing-offer-parsing.spec.ts
Normal file
69
packages/app/e2e/pairing-offer-parsing.spec.ts
Normal 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 }
|
||||
);
|
||||
});
|
||||
35
packages/app/e2e/relay-fallback-connect.spec.ts
Normal file
35
packages/app/e2e/relay-fallback-connect.spec.ts
Normal 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 });
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -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 }) {
|
||||
|
||||
@@ -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={[
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 }]}>
|
||||
|
||||
@@ -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} />
|
||||
))}
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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>>
|
||||
|
||||
@@ -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]
|
||||
);
|
||||
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
114
packages/app/src/utils/daemon-endpoints.ts
Normal file
114
packages/app/src/utils/daemon-endpoints.ts
Normal 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";
|
||||
}
|
||||
}
|
||||
|
||||
232
packages/app/src/utils/tauri-daemon-transport.ts
Normal file
232
packages/app/src/utils/tauri-daemon-transport.ts
Normal 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;
|
||||
},
|
||||
};
|
||||
};
|
||||
}
|
||||
@@ -1,4 +0,0 @@
|
||||
{
|
||||
"status": "passed",
|
||||
"failedTests": []
|
||||
}
|
||||
205
packages/desktop/src-tauri/Cargo.lock
generated
205
packages/desktop/src-tauri/Cargo.lock
generated
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
"permissions": [
|
||||
"core:default",
|
||||
"core:window:allow-start-dragging",
|
||||
"core:window:allow-toggle-maximize"
|
||||
"core:window:allow-toggle-maximize",
|
||||
"websocket:default"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user