Add managed desktop daemon runtime support

This commit is contained in:
Mohamed Boudra
2026-03-08 12:59:16 +07:00
parent faa5aaab6f
commit 2d5d0dcacd
35 changed files with 5377 additions and 421 deletions

View File

@@ -3,12 +3,12 @@ name: Desktop Release
on: on:
push: push:
tags: tags:
- 'v*' - "v*"
- 'desktop-v*' - "desktop-v*"
workflow_dispatch: workflow_dispatch:
inputs: inputs:
tag: tag:
description: 'Existing tag to build (e.g. v0.1.0)' description: "Existing tag to build (e.g. v0.1.0)"
required: true required: true
type: string type: string
@@ -16,14 +16,15 @@ concurrency:
group: desktop-release-${{ github.ref }} group: desktop-release-${{ github.ref }}
cancel-in-progress: false cancel-in-progress: false
env:
RELEASE_TAG: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.tag || github.ref_name }}
jobs: jobs:
publish-tauri: publish-macos:
permissions: permissions:
contents: write contents: write
packages: read packages: read
runs-on: macos-latest runs-on: macos-latest
env:
RELEASE_TAG: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.tag || github.ref_name }}
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
@@ -34,10 +35,10 @@ jobs:
- name: Setup Node - name: Setup Node
uses: actions/setup-node@v4 uses: actions/setup-node@v4
with: with:
node-version: '22' node-version: "22"
cache: 'npm' cache: "npm"
registry-url: 'https://npm.pkg.github.com' registry-url: "https://npm.pkg.github.com"
scope: '@boudra' scope: "@boudra"
- name: Install Rust stable - name: Install Rust stable
uses: dtolnay/rust-toolchain@stable uses: dtolnay/rust-toolchain@stable
@@ -91,7 +92,7 @@ jobs:
fs.writeFileSync(cargoTomlPath, `${nextLines.join('\n')}\n`); fs.writeFileSync(cargoTomlPath, `${nextLines.join('\n')}\n`);
NODE NODE
- name: Build and publish Tauri release - name: Build and publish macOS Tauri release
uses: tauri-apps/tauri-action@v0 uses: tauri-apps/tauri-action@v0
env: env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
@@ -111,3 +112,178 @@ jobs:
releaseDraft: false releaseDraft: false
prerelease: false prerelease: false
args: --target universal-apple-darwin args: --target universal-apple-darwin
publish-linux:
permissions:
contents: write
packages: read
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
ref: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.tag || github.ref }}
- name: Install Linux packaging dependencies
run: |
sudo apt-get update
sudo apt-get install -y libwebkit2gtk-4.1-dev libgtk-3-dev libappindicator3-dev librsvg2-dev patchelf
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: "22"
cache: "npm"
registry-url: "https://npm.pkg.github.com"
scope: "@boudra"
- name: Install Rust stable
uses: dtolnay/rust-toolchain@stable
- name: Install JS dependencies
run: npm ci
env:
NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Build web app for Tauri
run: npm run build:web --workspace=@getpaseo/app
- name: Set desktop version from tag
shell: bash
run: |
node <<'NODE'
const fs = require('node:fs');
const path = require('node:path');
const rawTag = process.env.RELEASE_TAG;
if (!rawTag) throw new Error('RELEASE_TAG env var is missing');
const version = rawTag.replace(/^desktop-/, '').replace(/^v/, '');
console.log(`Using desktop version ${version} from tag ${rawTag}`);
const tauriConfPath = path.join('packages', 'desktop', 'src-tauri', 'tauri.conf.json');
const tauriConfText = fs.readFileSync(tauriConfPath, 'utf8');
const tauriRe = /("version"\s*:\s*")([^"]+)(")/;
if (!tauriRe.test(tauriConfText)) {
throw new Error(`Failed to find version field in ${tauriConfPath}`);
}
fs.writeFileSync(tauriConfPath, tauriConfText.replace(tauriRe, `$1${version}$3`));
const cargoTomlPath = path.join('packages', 'desktop', 'src-tauri', 'Cargo.toml');
const cargoLines = fs.readFileSync(cargoTomlPath, 'utf8').split(/\r?\n/);
let inPackage = false;
let updated = false;
const nextLines = cargoLines.map((line) => {
if (/^\[package\]\s*$/.test(line)) inPackage = true;
else if (inPackage && /^\[/.test(line)) inPackage = false;
if (inPackage && /^version\s*=\s*".*"\s*$/.test(line)) {
updated = true;
return `version = "${version}"`;
}
return line;
});
if (!updated) throw new Error(`Failed to update Cargo package version in ${cargoTomlPath}`);
fs.writeFileSync(cargoTomlPath, `${nextLines.join('\n')}\n`);
NODE
- name: Build and publish Linux Tauri release
uses: tauri-apps/tauri-action@v0
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
with:
projectPath: packages/desktop
tagName: ${{ env.RELEASE_TAG }}
releaseName: Paseo ${{ env.RELEASE_TAG }}
releaseBody: See the assets to download and install this version.
releaseDraft: false
prerelease: false
args: --bundles appimage
publish-windows:
permissions:
contents: write
packages: read
runs-on: windows-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
ref: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.tag || github.ref }}
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: "22"
cache: "npm"
registry-url: "https://npm.pkg.github.com"
scope: "@boudra"
- name: Install Rust stable
uses: dtolnay/rust-toolchain@stable
- name: Install JS dependencies
run: npm ci
env:
NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Build web app for Tauri
run: npm run build:web --workspace=@getpaseo/app
- name: Set desktop version from tag
shell: bash
run: |
node <<'NODE'
const fs = require('node:fs');
const path = require('node:path');
const rawTag = process.env.RELEASE_TAG;
if (!rawTag) throw new Error('RELEASE_TAG env var is missing');
const version = rawTag.replace(/^desktop-/, '').replace(/^v/, '');
console.log(`Using desktop version ${version} from tag ${rawTag}`);
const tauriConfPath = path.join('packages', 'desktop', 'src-tauri', 'tauri.conf.json');
const tauriConfText = fs.readFileSync(tauriConfPath, 'utf8');
const tauriRe = /("version"\s*:\s*")([^"]+)(")/;
if (!tauriRe.test(tauriConfText)) {
throw new Error(`Failed to find version field in ${tauriConfPath}`);
}
fs.writeFileSync(tauriConfPath, tauriConfText.replace(tauriRe, `$1${version}$3`));
const cargoTomlPath = path.join('packages', 'desktop', 'src-tauri', 'Cargo.toml');
const cargoLines = fs.readFileSync(cargoTomlPath, 'utf8').split(/\r?\n/);
let inPackage = false;
let updated = false;
const nextLines = cargoLines.map((line) => {
if (/^\[package\]\s*$/.test(line)) inPackage = true;
else if (inPackage && /^\[/.test(line)) inPackage = false;
if (inPackage && /^version\s*=\s*".*"\s*$/.test(line)) {
updated = true;
return `version = "${version}"`;
}
return line;
});
if (!updated) throw new Error(`Failed to update Cargo package version in ${cargoTomlPath}`);
fs.writeFileSync(cargoTomlPath, `${nextLines.join('\n')}\n`);
NODE
- name: Build and publish Windows Tauri release
uses: tauri-apps/tauri-action@v0
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
with:
projectPath: packages/desktop
tagName: ${{ env.RELEASE_TAG }}
releaseName: Paseo ${{ env.RELEASE_TAG }}
releaseBody: See the assets to download and install this version.
releaseDraft: false
prerelease: false
args: --bundles nsis,msi

2
.gitignore vendored
View File

@@ -76,3 +76,5 @@ packages/server/src/server/fixtures/dictation/dictation-debug-largest.wav
packages/server/src/server/fixtures/dictation/dictation-debug-largest.transcript.txt packages/server/src/server/fixtures/dictation/dictation-debug-largest.transcript.txt
/artifacts /artifacts
packages/desktop/.cache/
packages/desktop/src-tauri/resources/managed-runtime/

View File

@@ -179,7 +179,11 @@ export function AddHostModal({ visible, onClose, onCancel, onSaved, targetServer
setIsSaving(true); setIsSaving(true);
setErrorMessage(""); setErrorMessage("");
const { serverId, hostname } = await probeConnection({ id: "probe", type: "direct", endpoint }); const { serverId, hostname } = await probeConnection({
id: "probe",
type: "directTcp",
endpoint,
});
if (targetServerId && serverId !== targetServerId) { if (targetServerId && serverId !== targetServerId) {
const message = `That endpoint belongs to ${serverId}, not ${targetServerId}.`; const message = `That endpoint belongs to ${serverId}, not ${targetServerId}.`;
setErrorMessage(message); setErrorMessage(message);

View File

@@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest'
import { import {
hostHasDirectEndpoint, hostHasDirectEndpoint,
registryHasDirectEndpoint, registryHasDirectEndpoint,
reconcileDesktopStartupRegistry,
type HostProfile, type HostProfile,
} from './daemon-registry-context' } from './daemon-registry-context'
@@ -10,6 +11,12 @@ function makeHost(input: Partial<HostProfile> & Pick<HostProfile, 'serverId'>):
return { return {
serverId: input.serverId, serverId: input.serverId,
label: input.label ?? input.serverId, label: input.label ?? input.serverId,
lifecycle: input.lifecycle ?? {
managed: false,
managedRuntimeId: null,
managedRuntimeVersion: null,
associatedServerId: null,
},
connections: input.connections ?? [], connections: input.connections ?? [],
preferredConnectionId: input.preferredConnectionId ?? null, preferredConnectionId: input.preferredConnectionId ?? null,
createdAt: input.createdAt ?? now, createdAt: input.createdAt ?? now,
@@ -21,7 +28,7 @@ describe('hostHasDirectEndpoint', () => {
it('returns true when host has matching direct endpoint', () => { it('returns true when host has matching direct endpoint', () => {
const host = makeHost({ const host = makeHost({
serverId: 'srv_local', serverId: 'srv_local',
connections: [{ id: 'direct:localhost:6767', type: 'direct', endpoint: 'localhost:6767' }], connections: [{ id: 'direct:localhost:6767', type: 'directTcp', endpoint: 'localhost:6767' }],
preferredConnectionId: 'direct:localhost:6767', preferredConnectionId: 'direct:localhost:6767',
}) })
@@ -51,12 +58,12 @@ describe('registryHasDirectEndpoint', () => {
const hosts: HostProfile[] = [ const hosts: HostProfile[] = [
makeHost({ makeHost({
serverId: 'srv_one', serverId: 'srv_one',
connections: [{ id: 'direct:127.0.0.1:7777', type: 'direct', endpoint: '127.0.0.1:7777' }], connections: [{ id: 'direct:127.0.0.1:7777', type: 'directTcp', endpoint: '127.0.0.1:7777' }],
preferredConnectionId: 'direct:127.0.0.1:7777', preferredConnectionId: 'direct:127.0.0.1:7777',
}), }),
makeHost({ makeHost({
serverId: 'srv_two', serverId: 'srv_two',
connections: [{ id: 'direct:localhost:6767', type: 'direct', endpoint: 'localhost:6767' }], connections: [{ id: 'direct:localhost:6767', type: 'directTcp', endpoint: 'localhost:6767' }],
preferredConnectionId: 'direct:localhost:6767', preferredConnectionId: 'direct:localhost:6767',
}), }),
] ]
@@ -68,7 +75,7 @@ describe('registryHasDirectEndpoint', () => {
const hosts: HostProfile[] = [ const hosts: HostProfile[] = [
makeHost({ makeHost({
serverId: 'srv_one', serverId: 'srv_one',
connections: [{ id: 'direct:127.0.0.1:7777', type: 'direct', endpoint: '127.0.0.1:7777' }], connections: [{ id: 'direct:127.0.0.1:7777', type: 'directTcp', endpoint: '127.0.0.1:7777' }],
preferredConnectionId: 'direct:127.0.0.1:7777', preferredConnectionId: 'direct:127.0.0.1:7777',
}), }),
] ]
@@ -76,3 +83,160 @@ describe('registryHasDirectEndpoint', () => {
expect(registryHasDirectEndpoint(hosts, 'localhost:6767')).toBe(false) expect(registryHasDirectEndpoint(hosts, 'localhost:6767')).toBe(false)
}) })
}) })
describe('reconcileDesktopStartupRegistry', () => {
it('seeds managed and localhost connections as normal host entries', () => {
const now = '2026-03-08T00:00:00.000Z'
const result = reconcileDesktopStartupRegistry({
existing: [],
managed: {
serverId: 'srv_managed',
hostname: 'managed-host',
runtimeId: 'runtime_1',
runtimeVersion: '1.2.3',
transportType: 'socket',
transportPath: '/Users/test/.paseo-test/paseo.sock',
associatedServerId: 'srv_managed',
},
localhost: {
serverId: 'srv_localhost',
hostname: 'local-dev',
endpoint: 'localhost:6767',
},
now,
})
expect(result).toEqual([
makeHost({
serverId: 'srv_managed',
label: 'managed-host',
lifecycle: {
managed: true,
managedRuntimeId: 'runtime_1',
managedRuntimeVersion: '1.2.3',
associatedServerId: 'srv_managed',
},
connections: [
{
id: 'socket:/Users/test/.paseo-test/paseo.sock',
type: 'directSocket',
path: '/Users/test/.paseo-test/paseo.sock',
},
],
preferredConnectionId: 'socket:/Users/test/.paseo-test/paseo.sock',
createdAt: now,
updatedAt: now,
}),
makeHost({
serverId: 'srv_localhost',
label: 'local-dev',
connections: [
{
id: 'direct:localhost:6767',
type: 'directTcp',
endpoint: 'localhost:6767',
},
],
preferredConnectionId: 'direct:localhost:6767',
createdAt: now,
updatedAt: now,
}),
])
})
it('keeps managed and localhost connections together when they resolve to the same server', () => {
const now = '2026-03-08T00:00:00.000Z'
const result = reconcileDesktopStartupRegistry({
existing: [],
managed: {
serverId: 'srv_shared',
hostname: 'devbox',
runtimeId: 'runtime_1',
runtimeVersion: '1.2.3',
transportType: 'socket',
transportPath: '/Users/test/.paseo-test/paseo.sock',
associatedServerId: 'srv_shared',
},
localhost: {
serverId: 'srv_shared',
hostname: 'devbox',
endpoint: 'localhost:6767',
},
now,
})
expect(result).toEqual([
makeHost({
serverId: 'srv_shared',
label: 'devbox',
lifecycle: {
managed: true,
managedRuntimeId: 'runtime_1',
managedRuntimeVersion: '1.2.3',
associatedServerId: 'srv_shared',
},
connections: [
{
id: 'socket:/Users/test/.paseo-test/paseo.sock',
type: 'directSocket',
path: '/Users/test/.paseo-test/paseo.sock',
},
{
id: 'direct:localhost:6767',
type: 'directTcp',
endpoint: 'localhost:6767',
},
],
preferredConnectionId: 'socket:/Users/test/.paseo-test/paseo.sock',
createdAt: now,
updatedAt: now,
}),
])
})
it('is idempotent for repeated desktop startup reconciliation', () => {
const now = '2026-03-08T00:00:00.000Z'
const first = reconcileDesktopStartupRegistry({
existing: [],
managed: {
serverId: 'srv_shared',
hostname: 'devbox',
runtimeId: 'runtime_1',
runtimeVersion: '1.2.3',
transportType: 'socket',
transportPath: '/Users/test/.paseo-test/paseo.sock',
associatedServerId: 'srv_shared',
},
localhost: {
serverId: 'srv_shared',
hostname: 'devbox',
endpoint: 'localhost:6767',
},
now,
})
const second = reconcileDesktopStartupRegistry({
existing: first,
managed: {
serverId: 'srv_shared',
hostname: 'devbox',
runtimeId: 'runtime_1',
runtimeVersion: '1.2.3',
transportType: 'socket',
transportPath: '/Users/test/.paseo-test/paseo.sock',
associatedServerId: 'srv_shared',
},
localhost: {
serverId: 'srv_shared',
hostname: 'devbox',
endpoint: 'localhost:6767',
},
now: '2026-03-09T00:00:00.000Z',
})
expect(second).toEqual(first)
})
})

View File

@@ -5,32 +5,64 @@ import { useQuery, useQueryClient } from '@tanstack/react-query'
import { decodeOfferFragmentPayload, normalizeHostPort } from '@/utils/daemon-endpoints' import { decodeOfferFragmentPayload, normalizeHostPort } from '@/utils/daemon-endpoints'
import { probeConnection } from '@/utils/test-daemon-connection' import { probeConnection } from '@/utils/test-daemon-connection'
import { ConnectionOfferSchema, type ConnectionOffer } from '@server/shared/connection-offer' import { ConnectionOfferSchema, type ConnectionOffer } from '@server/shared/connection-offer'
import {
type ManagedDaemonStatus,
shouldUseManagedDesktopDaemon,
startManagedDaemon,
} from '@/desktop/managed-runtime/managed-runtime'
const REGISTRY_STORAGE_KEY = '@paseo:daemon-registry' const REGISTRY_STORAGE_KEY = '@paseo:daemon-registry'
const DAEMON_REGISTRY_QUERY_KEY = ['daemon-registry'] const DAEMON_REGISTRY_QUERY_KEY = ['daemon-registry']
const DEFAULT_LOCALHOST_ENDPOINT = 'localhost:6767' const DEFAULT_LOCALHOST_ENDPOINT = 'localhost:6767'
const DEFAULT_LOCALHOST_BOOTSTRAP_KEY = '@paseo:default-localhost-bootstrap-v1' const DEFAULT_LOCALHOST_BOOTSTRAP_KEY = '@paseo:default-localhost-bootstrap-v1'
const DEFAULT_LOCALHOST_BOOTSTRAP_TIMEOUT_MS = 2500 const DEFAULT_LOCALHOST_BOOTSTRAP_TIMEOUT_MS = 2500
const DEFAULT_LOCAL_TRANSPORT_BOOTSTRAP_TIMEOUT_MS = 6000
const DEFAULT_LOCAL_TRANSPORT_BOOTSTRAP_RETRY_MS = 2000
const DEFAULT_LOCAL_TRANSPORT_BOOTSTRAP_DEADLINE_MS = 120000
const E2E_STORAGE_KEY = '@paseo:e2e' const E2E_STORAGE_KEY = '@paseo:e2e'
export type DirectHostConnection = { export type DirectTcpHostConnection = {
id: string id: string
type: 'direct' type: 'directTcp'
endpoint: string // host:port endpoint: string
}
export type DirectSocketHostConnection = {
id: string
type: 'directSocket'
path: string
}
export type DirectPipeHostConnection = {
id: string
type: 'directPipe'
path: string
} }
export type RelayHostConnection = { export type RelayHostConnection = {
id: string id: string
type: 'relay' type: 'relay'
relayEndpoint: string // host:port relayEndpoint: string
daemonPublicKeyB64: string daemonPublicKeyB64: string
} }
export type HostConnection = DirectHostConnection | RelayHostConnection export type HostConnection =
| DirectTcpHostConnection
| DirectSocketHostConnection
| DirectPipeHostConnection
| RelayHostConnection
export type HostLifecycle = {
managed: boolean
managedRuntimeId: string | null
managedRuntimeVersion: string | null
associatedServerId: string | null
}
export type HostProfile = { export type HostProfile = {
serverId: string serverId: string
label: string label: string
lifecycle: HostLifecycle
connections: HostConnection[] connections: HostConnection[]
preferredConnectionId: string | null preferredConnectionId: string | null
createdAt: string createdAt: string
@@ -39,6 +71,29 @@ export type HostProfile = {
export type UpdateHostInput = Partial<Omit<HostProfile, 'serverId' | 'createdAt'>> export type UpdateHostInput = Partial<Omit<HostProfile, 'serverId' | 'createdAt'>>
export type ManagedHostReconciliationInput = {
serverId: string
hostname?: string | null
runtimeId: string
runtimeVersion: string
transportType: string
transportPath: string
associatedServerId?: string | null
}
export type LocalhostHostReconciliationInput = {
serverId: string
hostname: string | null
endpoint: string
}
export type DesktopStartupReconciliationInput = {
existing: HostProfile[]
managed: ManagedHostReconciliationInput | null
localhost: LocalhostHostReconciliationInput | null
now?: string
}
interface DaemonRegistryContextValue { interface DaemonRegistryContextValue {
daemons: HostProfile[] daemons: HostProfile[]
isLoading: boolean isLoading: boolean
@@ -63,6 +118,20 @@ interface DaemonRegistryContextValue {
const DaemonRegistryContext = createContext<DaemonRegistryContextValue | null>(null) const DaemonRegistryContext = createContext<DaemonRegistryContextValue | null>(null)
function defaultLifecycle(): HostLifecycle {
return {
managed: false,
managedRuntimeId: null,
managedRuntimeVersion: null,
associatedServerId: null,
}
}
function normalizeHostLabel(value: string | null | undefined, serverId: string): string {
const trimmed = value?.trim() ?? ''
return trimmed.length > 0 ? trimmed : serverId
}
function normalizeEndpointOrNull(endpoint: string): string | null { function normalizeEndpointOrNull(endpoint: string): string | null {
try { try {
return normalizeHostPort(endpoint) return normalizeHostPort(endpoint)
@@ -71,8 +140,389 @@ function normalizeEndpointOrNull(endpoint: string): string | null {
} }
} }
function isDefaultLocalhostConnection(connection: HostConnection): boolean { function sleep(ms: number): Promise<void> {
return connection.type === 'direct' && connection.endpoint === DEFAULT_LOCALHOST_ENDPOINT return new Promise((resolve) => {
setTimeout(resolve, ms)
})
}
function normalizeManagedTransportConnection(input: {
transportType: string
transportPath: string
}): HostConnection | null {
const transportPath = input.transportPath.trim()
if (!transportPath) {
return null
}
if (input.transportType === 'tcp') {
try {
const endpoint = normalizeHostPort(transportPath)
return {
id: `direct:${endpoint}`,
type: 'directTcp',
endpoint,
}
} catch {
return null
}
}
if (input.transportType === 'pipe') {
return {
id: `pipe:${transportPath}`,
type: 'directPipe',
path: transportPath,
}
}
if (input.transportType === 'socket') {
return {
id: `socket:${transportPath}`,
type: 'directSocket',
path: transportPath,
}
}
return null
}
function normalizeStoredConnection(connection: unknown): HostConnection | null {
if (!connection || typeof connection !== 'object') {
return null
}
const record = connection as Record<string, unknown>
const type = typeof record.type === 'string' ? record.type : null
if (type === 'directTcp') {
try {
const endpoint = normalizeHostPort(String(record.endpoint ?? ''))
return { id: `direct:${endpoint}`, type: 'directTcp', endpoint }
} catch {
return null
}
}
if (type === 'directSocket') {
const path = String(record.path ?? '').trim()
return path ? { id: `socket:${path}`, type: 'directSocket', path } : null
}
if (type === 'directPipe') {
const path = String(record.path ?? '').trim()
return path ? { id: `pipe:${path}`, type: 'directPipe', path } : null
}
if (type === 'relay') {
try {
const relayEndpoint = normalizeHostPort(String(record.relayEndpoint ?? ''))
const daemonPublicKeyB64 = String(record.daemonPublicKeyB64 ?? '').trim()
if (!daemonPublicKeyB64) return null
return {
id: `relay:${relayEndpoint}`,
type: 'relay',
relayEndpoint,
daemonPublicKeyB64,
}
} catch {
return null
}
}
return null
}
function normalizeStoredLifecycle(lifecycle: unknown): HostLifecycle {
const record =
lifecycle && typeof lifecycle === 'object' ? (lifecycle as Record<string, unknown>) : null
return {
managed: record?.managed === true,
managedRuntimeId:
typeof record?.managedRuntimeId === 'string' ? record.managedRuntimeId : null,
managedRuntimeVersion:
typeof record?.managedRuntimeVersion === 'string' ? record.managedRuntimeVersion : null,
associatedServerId:
typeof record?.associatedServerId === 'string' ? record.associatedServerId : null,
}
}
function normalizeStoredHostProfile(entry: unknown): HostProfile | null {
if (!entry || typeof entry !== 'object') {
return null
}
const record = entry as Record<string, unknown>
const serverId = typeof record.serverId === 'string' ? record.serverId.trim() : ''
if (!serverId) {
return null
}
const rawConnections = Array.isArray(record.connections) ? record.connections : []
const connections = rawConnections
.map((connection) => normalizeStoredConnection(connection))
.filter((connection): connection is HostConnection => connection !== null)
if (connections.length === 0) {
return null
}
const lifecycle = normalizeStoredLifecycle(record.lifecycle)
const now = new Date().toISOString()
const label = normalizeHostLabel(
typeof record.label === 'string' ? record.label : null,
serverId
)
const preferredConnectionId =
typeof record.preferredConnectionId === 'string' &&
connections.some((connection) => connection.id === record.preferredConnectionId)
? record.preferredConnectionId
: connections[0]?.id ?? null
return {
serverId,
label,
lifecycle,
connections,
preferredConnectionId,
createdAt: typeof record.createdAt === 'string' ? record.createdAt : now,
updatedAt: typeof record.updatedAt === 'string' ? record.updatedAt : now,
}
}
function hostConnectionEquals(left: HostConnection, right: HostConnection): boolean {
if (left.type !== right.type || left.id !== right.id) {
return false
}
if (left.type === 'directTcp' && right.type === 'directTcp') {
return left.endpoint === right.endpoint
}
if (left.type === 'directSocket' && right.type === 'directSocket') {
return left.path === right.path
}
if (left.type === 'directPipe' && right.type === 'directPipe') {
return left.path === right.path
}
if (left.type === 'relay' && right.type === 'relay') {
return (
left.relayEndpoint === right.relayEndpoint &&
left.daemonPublicKeyB64 === right.daemonPublicKeyB64
)
}
return false
}
function hostLifecycleEquals(left: HostLifecycle, right: HostLifecycle): boolean {
return (
left.managed === right.managed &&
left.managedRuntimeId === right.managedRuntimeId &&
left.managedRuntimeVersion === right.managedRuntimeVersion &&
left.associatedServerId === right.associatedServerId
)
}
function upsertHostConnectionInProfiles(input: {
profiles: HostProfile[]
serverId: string
label?: string
lifecycle?: Partial<HostLifecycle>
connection: HostConnection
now?: string
}): HostProfile[] {
const serverId = input.serverId.trim()
if (!serverId) {
throw new Error('serverId is required')
}
const now = input.now ?? new Date().toISOString()
const labelTrimmed = input.label?.trim() ?? ''
const derivedLabel = labelTrimmed || serverId
const existing = input.profiles
const idx = existing.findIndex((daemon) => daemon.serverId === serverId)
if (idx === -1) {
const profile: HostProfile = {
serverId,
label: derivedLabel,
lifecycle: {
...defaultLifecycle(),
...(input.lifecycle ?? {}),
},
connections: [input.connection],
preferredConnectionId: input.connection.id,
createdAt: now,
updatedAt: now,
}
return [...existing, profile]
}
const prev = existing[idx]!
const connectionIdx = prev.connections.findIndex((connection) => connection.id === input.connection.id)
const hadConnection = connectionIdx !== -1
const connectionChanged =
connectionIdx === -1
? true
: !hostConnectionEquals(prev.connections[connectionIdx]!, input.connection)
const nextConnections =
connectionIdx === -1
? [...prev.connections, input.connection]
: connectionChanged
? prev.connections.map((connection, index) =>
index === connectionIdx ? input.connection : connection
)
: prev.connections
const nextLifecycle = {
...prev.lifecycle,
...(input.lifecycle ?? {}),
}
const nextLabel = labelTrimmed ? labelTrimmed : prev.label
const nextPreferredConnectionId = prev.preferredConnectionId ?? input.connection.id
const changed =
nextLabel !== prev.label ||
nextPreferredConnectionId !== prev.preferredConnectionId ||
!hostLifecycleEquals(prev.lifecycle, nextLifecycle) ||
!hadConnection ||
connectionChanged
if (!changed) {
return existing
}
const nextProfile: HostProfile = {
...prev,
label: nextLabel,
lifecycle: nextLifecycle,
connections: nextConnections,
preferredConnectionId: nextPreferredConnectionId,
updatedAt: now,
}
const next = [...existing]
next[idx] = nextProfile
return next
}
function reconcileManagedHostInProfiles(input: {
profiles: HostProfile[]
managed: ManagedHostReconciliationInput
now?: string
}): HostProfile[] {
const connection = normalizeManagedTransportConnection(input.managed)
if (!connection) {
throw new Error(`Unsupported managed daemon transport: ${input.managed.transportType}`)
}
const nextBase = input.profiles.filter((daemon) => {
return !daemon.lifecycle.managed || daemon.serverId === input.managed.serverId
})
const profiles = nextBase.length === input.profiles.length ? input.profiles : nextBase
return upsertHostConnectionInProfiles({
profiles,
serverId: input.managed.serverId,
label: input.managed.hostname ?? undefined,
lifecycle: {
managed: true,
managedRuntimeId: input.managed.runtimeId,
managedRuntimeVersion: input.managed.runtimeVersion,
associatedServerId:
input.managed.associatedServerId?.trim() || input.managed.serverId,
},
connection,
now: input.now,
})
}
export function reconcileDesktopStartupRegistry(
input: DesktopStartupReconciliationInput
): HostProfile[] {
let next = input.existing
if (input.managed) {
next = reconcileManagedHostInProfiles({
profiles: next,
managed: input.managed,
now: input.now,
})
}
if (input.localhost) {
next = upsertHostConnectionInProfiles({
profiles: next,
serverId: input.localhost.serverId,
label: input.localhost.hostname ?? undefined,
connection: {
id: `direct:${input.localhost.endpoint}`,
type: 'directTcp',
endpoint: input.localhost.endpoint,
},
now: input.now,
})
}
return next
}
async function probeManagedStartupTarget(input: {
managedDaemon: ManagedDaemonStatus
cancelled?: () => boolean
}): Promise<ManagedHostReconciliationInput | null> {
const connection = normalizeManagedTransportConnection({
transportType: input.managedDaemon.transportType,
transportPath: input.managedDaemon.transportPath,
})
if (!connection) {
return null
}
let serverId = input.managedDaemon.serverId
let hostname = input.managedDaemon.hostname
if (!serverId) {
const probed = await probeConnection(connection, {
timeoutMs: DEFAULT_LOCAL_TRANSPORT_BOOTSTRAP_TIMEOUT_MS,
})
if (input.cancelled?.()) {
throw new Error('Managed daemon bootstrap cancelled')
}
serverId = probed.serverId
hostname = hostname ?? probed.hostname
}
return {
serverId,
hostname,
runtimeId: input.managedDaemon.runtimeId,
runtimeVersion: input.managedDaemon.runtimeVersion,
transportType: input.managedDaemon.transportType,
transportPath: input.managedDaemon.transportPath,
associatedServerId: input.managedDaemon.serverId,
}
}
async function probeManagedConnectionUntilReady(
input: {
managedDaemon: ManagedDaemonStatus
cancelled?: () => boolean
}
): Promise<ManagedHostReconciliationInput | null> {
const startedAt = Date.now()
let lastError: unknown = null
while (Date.now() - startedAt < DEFAULT_LOCAL_TRANSPORT_BOOTSTRAP_DEADLINE_MS) {
if (input.cancelled?.()) {
throw new Error('Managed daemon bootstrap cancelled')
}
try {
return await probeManagedStartupTarget(input)
} catch (error) {
lastError = error
if (input.cancelled?.()) {
throw error
}
await sleep(DEFAULT_LOCAL_TRANSPORT_BOOTSTRAP_RETRY_MS)
}
}
throw lastError ?? new Error('Managed daemon bootstrap timed out')
} }
export function hostHasDirectEndpoint(host: HostProfile, endpoint: string): boolean { export function hostHasDirectEndpoint(host: HostProfile, endpoint: string): boolean {
@@ -81,7 +531,7 @@ export function hostHasDirectEndpoint(host: HostProfile, endpoint: string): bool
return false return false
} }
return host.connections.some( return host.connections.some(
(connection) => connection.type === 'direct' && connection.endpoint === normalized (connection) => connection.type === 'directTcp' && connection.endpoint === normalized
) )
} }
@@ -99,6 +549,7 @@ export function useDaemonRegistry(): DaemonRegistryContextValue {
export function DaemonRegistryProvider({ children }: { children: ReactNode }) { export function DaemonRegistryProvider({ children }: { children: ReactNode }) {
const queryClient = useQueryClient() const queryClient = useQueryClient()
const desktopStartupReconciledRef = useRef(false)
const localhostBootstrapAttemptedRef = useRef(false) const localhostBootstrapAttemptedRef = useRef(false)
const { const {
data: daemons = [], data: daemons = [],
@@ -123,10 +574,6 @@ export function DaemonRegistryProvider({ children }: { children: ReactNode }) {
return queryClient.getQueryData<HostProfile[]>(DAEMON_REGISTRY_QUERY_KEY) ?? daemons return queryClient.getQueryData<HostProfile[]>(DAEMON_REGISTRY_QUERY_KEY) ?? daemons
}, [queryClient, daemons]) }, [queryClient, daemons])
const markDefaultLocalhostBootstrapHandled = useCallback(async () => {
await AsyncStorage.setItem(DEFAULT_LOCALHOST_BOOTSTRAP_KEY, '1')
}, [])
const updateHost = useCallback( const updateHost = useCallback(
async (serverId: string, updates: UpdateHostInput) => { async (serverId: string, updates: UpdateHostInput) => {
const next = readDaemons().map((daemon) => const next = readDaemons().map((daemon) =>
@@ -146,23 +593,15 @@ export function DaemonRegistryProvider({ children }: { children: ReactNode }) {
const removeHost = useCallback( const removeHost = useCallback(
async (serverId: string) => { async (serverId: string) => {
const existing = readDaemons() const existing = readDaemons()
const removedHost = existing.find((daemon) => daemon.serverId === serverId) ?? null
const remaining = existing.filter((daemon) => daemon.serverId !== serverId) const remaining = existing.filter((daemon) => daemon.serverId !== serverId)
await persist(remaining) await persist(remaining)
if (removedHost && hostHasDirectEndpoint(removedHost, DEFAULT_LOCALHOST_ENDPOINT)) {
await markDefaultLocalhostBootstrapHandled()
}
}, },
[markDefaultLocalhostBootstrapHandled, persist, readDaemons] [persist, readDaemons]
) )
const removeConnection = useCallback( const removeConnection = useCallback(
async (serverId: string, connectionId: string) => { async (serverId: string, connectionId: string) => {
const existing = readDaemons() const existing = readDaemons()
const removedConnection =
existing
.find((daemon) => daemon.serverId === serverId)
?.connections.find((connection) => connection.id === connectionId) ?? null
const now = new Date().toISOString() const now = new Date().toISOString()
const next = existing const next = existing
.map((daemon) => { .map((daemon) => {
@@ -184,64 +623,28 @@ export function DaemonRegistryProvider({ children }: { children: ReactNode }) {
}) })
.filter((entry): entry is HostProfile => entry !== null) .filter((entry): entry is HostProfile => entry !== null)
await persist(next) await persist(next)
if (removedConnection && isDefaultLocalhostConnection(removedConnection)) {
await markDefaultLocalhostBootstrapHandled()
}
}, },
[markDefaultLocalhostBootstrapHandled, persist, readDaemons] [persist, readDaemons]
) )
const upsertHostConnection = useCallback( const upsertHostConnection = useCallback(
async ( async (input: {
input: { serverId: string
serverId: string label?: string
label?: string lifecycle?: Partial<HostLifecycle>
} & ({ connection: DirectHostConnection } | { connection: RelayHostConnection }) connection: HostConnection
) => { }) => {
const existing = readDaemons()
const now = new Date().toISOString() const now = new Date().toISOString()
const serverId = input.serverId.trim() const next = upsertHostConnectionInProfiles({
if (!serverId) { profiles: readDaemons(),
throw new Error('serverId is required') serverId: input.serverId,
} label: input.label,
lifecycle: input.lifecycle,
const labelTrimmed = input.label?.trim() ?? '' connection: input.connection,
const derivedLabel = labelTrimmed || serverId now,
})
const idx = existing.findIndex((d) => d.serverId === serverId)
if (idx === -1) {
const profile: HostProfile = {
serverId,
label: derivedLabel,
connections: [input.connection],
preferredConnectionId: input.connection.id,
createdAt: now,
updatedAt: now,
}
const next = [...existing, profile]
await persist(next)
return profile
}
const prev = existing[idx]!
const connectionIdx = prev.connections.findIndex((c) => c.id === input.connection.id)
const nextConnections =
connectionIdx === -1
? [...prev.connections, input.connection]
: prev.connections.map((c, i) => (i === connectionIdx ? input.connection : c))
const nextProfile: HostProfile = {
...prev,
label: labelTrimmed ? labelTrimmed : prev.label,
connections: nextConnections,
preferredConnectionId: prev.preferredConnectionId ?? input.connection.id,
updatedAt: now,
}
const next = [...existing]
next[idx] = nextProfile
await persist(next) await persist(next)
return nextProfile return next.find((daemon) => daemon.serverId === input.serverId) as HostProfile
}, },
[persist, readDaemons] [persist, readDaemons]
) )
@@ -249,15 +652,14 @@ export function DaemonRegistryProvider({ children }: { children: ReactNode }) {
const upsertDirectConnection = useCallback( const upsertDirectConnection = useCallback(
async (input: { serverId: string; endpoint: string; label?: string }) => { async (input: { serverId: string; endpoint: string; label?: string }) => {
const endpoint = normalizeHostPort(input.endpoint) const endpoint = normalizeHostPort(input.endpoint)
const connection: DirectHostConnection = {
id: `direct:${endpoint}`,
type: 'direct',
endpoint,
}
return upsertHostConnection({ return upsertHostConnection({
serverId: input.serverId, serverId: input.serverId,
label: input.label, label: input.label,
connection, connection: {
id: `direct:${endpoint}`,
type: 'directTcp',
endpoint,
},
}) })
}, },
[upsertHostConnection] [upsertHostConnection]
@@ -265,24 +667,114 @@ export function DaemonRegistryProvider({ children }: { children: ReactNode }) {
useEffect(() => { useEffect(() => {
if (isPending) return if (isPending) return
if (!shouldUseManagedDesktopDaemon()) return
if (desktopStartupReconciledRef.current) return
desktopStartupReconciledRef.current = true
let cancelled = false
const reconcileDesktopStartup = async () => {
try {
const isE2E = await AsyncStorage.getItem(E2E_STORAGE_KEY)
if (cancelled || isE2E) {
return
}
let managed: ManagedHostReconciliationInput | null = null
try {
const managedDaemon = await startManagedDaemon()
managed = await probeManagedConnectionUntilReady({
managedDaemon,
cancelled: () => cancelled,
})
} catch (managedBootstrapError) {
if (!cancelled) {
console.warn(
'[DaemonRegistry] Failed to reconcile managed daemon transport',
managedBootstrapError
)
}
}
let localhost: LocalhostHostReconciliationInput | null = null
try {
const { serverId, hostname } = await probeConnection(
{
id: `bootstrap:${DEFAULT_LOCALHOST_ENDPOINT}`,
type: 'directTcp',
endpoint: DEFAULT_LOCALHOST_ENDPOINT,
},
{ timeoutMs: DEFAULT_LOCALHOST_BOOTSTRAP_TIMEOUT_MS }
)
if (!cancelled) {
localhost = {
serverId,
hostname,
endpoint: DEFAULT_LOCALHOST_ENDPOINT,
}
}
} catch {
// Best-effort reconciliation only; keep startup resilient if localhost isn't reachable.
}
if (cancelled) {
return
}
const existing = readDaemons()
const next = reconcileDesktopStartupRegistry({
existing,
managed,
localhost,
})
if (next !== existing) {
await persist(next)
}
} catch (reconciliationError) {
if (cancelled) return
console.warn(
'[DaemonRegistry] Failed to reconcile desktop startup host connections',
reconciliationError
)
}
}
void reconcileDesktopStartup()
return () => {
cancelled = true
}
}, [
isPending,
persist,
readDaemons,
])
useEffect(() => {
if (isPending) return
if (shouldUseManagedDesktopDaemon()) return
if (localhostBootstrapAttemptedRef.current) return if (localhostBootstrapAttemptedRef.current) return
localhostBootstrapAttemptedRef.current = true localhostBootstrapAttemptedRef.current = true
let cancelled = false let cancelled = false
const bootstrapDefaultLocalhost = async () => { const bootstrapLocalhost = async () => {
try { try {
const [isE2E, alreadyHandled] = await Promise.all([ const isE2E = await AsyncStorage.getItem(E2E_STORAGE_KEY)
AsyncStorage.getItem(E2E_STORAGE_KEY), if (cancelled || isE2E) {
AsyncStorage.getItem(DEFAULT_LOCALHOST_BOOTSTRAP_KEY), return
]) }
if (cancelled || isE2E || alreadyHandled) {
const alreadyHandled = await AsyncStorage.getItem(DEFAULT_LOCALHOST_BOOTSTRAP_KEY)
if (cancelled || alreadyHandled) {
return return
} }
const existing = readDaemons() const existing = readDaemons()
if (registryHasDirectEndpoint(existing, DEFAULT_LOCALHOST_ENDPOINT)) { if (registryHasDirectEndpoint(existing, DEFAULT_LOCALHOST_ENDPOINT)) {
await markDefaultLocalhostBootstrapHandled() await AsyncStorage.setItem(DEFAULT_LOCALHOST_BOOTSTRAP_KEY, '1')
return return
} }
@@ -290,7 +782,7 @@ export function DaemonRegistryProvider({ children }: { children: ReactNode }) {
const { serverId, hostname } = await probeConnection( const { serverId, hostname } = await probeConnection(
{ {
id: `bootstrap:${DEFAULT_LOCALHOST_ENDPOINT}`, id: `bootstrap:${DEFAULT_LOCALHOST_ENDPOINT}`,
type: 'direct', type: 'directTcp',
endpoint: DEFAULT_LOCALHOST_ENDPOINT, endpoint: DEFAULT_LOCALHOST_ENDPOINT,
}, },
{ timeoutMs: DEFAULT_LOCALHOST_BOOTSTRAP_TIMEOUT_MS } { timeoutMs: DEFAULT_LOCALHOST_BOOTSTRAP_TIMEOUT_MS }
@@ -302,25 +794,26 @@ export function DaemonRegistryProvider({ children }: { children: ReactNode }) {
endpoint: DEFAULT_LOCALHOST_ENDPOINT, endpoint: DEFAULT_LOCALHOST_ENDPOINT,
label: hostname ?? undefined, label: hostname ?? undefined,
}) })
await markDefaultLocalhostBootstrapHandled() await AsyncStorage.setItem(DEFAULT_LOCALHOST_BOOTSTRAP_KEY, '1')
} catch { } catch {
// Best-effort bootstrap only; keep startup resilient if localhost isn't reachable. // Best-effort bootstrap only; keep startup resilient if localhost isn't reachable.
} }
} catch (bootstrapError) { } catch (bootstrapError) {
if (cancelled) return if (cancelled) return
console.warn( console.warn('[DaemonRegistry] Failed to bootstrap host connections', bootstrapError)
'[DaemonRegistry] Failed to bootstrap default localhost connection',
bootstrapError
)
} }
} }
void bootstrapDefaultLocalhost() void bootstrapLocalhost()
return () => { return () => {
cancelled = true cancelled = true
} }
}, [isPending, markDefaultLocalhostBootstrapHandled, readDaemons, upsertDirectConnection]) }, [
isPending,
readDaemons,
upsertDirectConnection,
])
const upsertRelayConnection = useCallback( const upsertRelayConnection = useCallback(
async (input: { async (input: {
@@ -334,16 +827,15 @@ export function DaemonRegistryProvider({ children }: { children: ReactNode }) {
if (!daemonPublicKeyB64) { if (!daemonPublicKeyB64) {
throw new Error('daemonPublicKeyB64 is required') throw new Error('daemonPublicKeyB64 is required')
} }
const connection: RelayHostConnection = {
id: `relay:${relayEndpoint}`,
type: 'relay',
relayEndpoint,
daemonPublicKeyB64,
}
return upsertHostConnection({ return upsertHostConnection({
serverId: input.serverId, serverId: input.serverId,
label: input.label, label: input.label,
connection, connection: {
id: `relay:${relayEndpoint}`,
type: 'relay',
relayEndpoint,
daemonPublicKeyB64,
},
}) })
}, },
[upsertHostConnection] [upsertHostConnection]
@@ -394,108 +886,21 @@ export function DaemonRegistryProvider({ children }: { children: ReactNode }) {
return <DaemonRegistryContext.Provider value={value}>{children}</DaemonRegistryContext.Provider> return <DaemonRegistryContext.Provider value={value}>{children}</DaemonRegistryContext.Provider>
} }
type LegacyHostProfileV1 = {
id: string
label: string
endpoints?: unknown
daemonPublicKeyB64?: unknown
relay?: unknown
createdAt: string
updatedAt: string
}
function isHostProfileV2(value: unknown): value is HostProfile {
if (!value || typeof value !== 'object') return false
const obj = value as Record<string, unknown>
return (
typeof obj.serverId === 'string' &&
typeof obj.label === 'string' &&
Array.isArray(obj.connections) &&
typeof obj.createdAt === 'string' &&
typeof obj.updatedAt === 'string'
)
}
async function loadDaemonRegistryFromStorage(): Promise<HostProfile[]> { async function loadDaemonRegistryFromStorage(): Promise<HostProfile[]> {
try { try {
const stored = await AsyncStorage.getItem(REGISTRY_STORAGE_KEY) const stored = await AsyncStorage.getItem(REGISTRY_STORAGE_KEY)
if (stored) { if (!stored) {
const parsed = JSON.parse(stored) as unknown return []
if (Array.isArray(parsed)) {
const v2 = parsed.filter((entry) => isHostProfileV2(entry)) as HostProfile[]
if (v2.length === parsed.length) {
return v2
}
// Hard migration from the previous in-repo schema (v1 HostProfile with `id/endpoints/relay`).
const migrated: HostProfile[] = parsed
.map((entry): HostProfile | null => {
if (!entry || typeof entry !== 'object') return null
const obj = entry as LegacyHostProfileV1
if (typeof obj.id !== 'string' || typeof obj.label !== 'string') return null
// Only keep stable daemon ids; discard transient entries to avoid confusing host selection.
if (!obj.id.startsWith('srv_')) return null
const now = new Date().toISOString()
const createdAt = typeof obj.createdAt === 'string' ? obj.createdAt : now
const updatedAt = typeof obj.updatedAt === 'string' ? obj.updatedAt : now
const connections: HostConnection[] = []
if (Array.isArray(obj.endpoints)) {
for (const endpointRaw of obj.endpoints) {
try {
const endpoint = normalizeHostPort(String(endpointRaw))
connections.push({ id: `direct:${endpoint}`, type: 'direct', endpoint })
} catch {
// ignore invalid endpoint
}
}
}
const relayEndpointRaw =
obj.relay && typeof (obj.relay as any)?.endpoint === 'string'
? String((obj.relay as any).endpoint)
: null
const daemonPublicKeyB64 =
typeof obj.daemonPublicKeyB64 === 'string' ? obj.daemonPublicKeyB64.trim() : ''
if (relayEndpointRaw && daemonPublicKeyB64) {
try {
const relayEndpoint = normalizeHostPort(relayEndpointRaw)
connections.push({
id: `relay:${relayEndpoint}`,
type: 'relay',
relayEndpoint,
daemonPublicKeyB64,
})
} catch {
// ignore invalid relay endpoint
}
}
if (connections.length === 0) return null
const preferredConnectionId: string | null = connections[0]?.id ?? null
return {
serverId: obj.id,
label: obj.label,
connections,
preferredConnectionId,
createdAt,
updatedAt,
}
})
.filter((entry): entry is HostProfile => entry !== null)
await AsyncStorage.setItem(REGISTRY_STORAGE_KEY, JSON.stringify(migrated))
return migrated
}
} }
return [] const parsed = JSON.parse(stored) as unknown
if (!Array.isArray(parsed)) {
return []
}
return parsed
.map((entry) => normalizeStoredHostProfile(entry))
.filter((entry): entry is HostProfile => entry !== null)
} catch (error) { } catch (error) {
console.error('[DaemonRegistry] Failed to load daemon registry', error) console.error('[DaemonRegistry] Failed to load daemon registry', error)
throw error throw error

View File

@@ -1,64 +1,104 @@
import { useCallback, useState } from "react"; import { useCallback, useEffect, useState } from "react";
import { Alert, Text, View } from "react-native"; import { ActivityIndicator, Alert, Image, Text, View } from "react-native";
import * as Clipboard from "expo-clipboard"; import * as Clipboard from "expo-clipboard";
import * as QRCode from "qrcode";
import { useFocusEffect } from "@react-navigation/native"; import { useFocusEffect } from "@react-navigation/native";
import { StyleSheet } from "react-native-unistyles"; import { StyleSheet } from "react-native-unistyles";
import { AdaptiveModalSheet, AdaptiveTextInput } from "@/components/adaptive-modal-sheet";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { confirmDialog } from "@/utils/confirm-dialog"; import { confirmDialog } from "@/utils/confirm-dialog";
import { import {
buildDaemonUpdateDiagnostics,
formatVersionWithPrefix, formatVersionWithPrefix,
getLocalDaemonVersion,
isVersionMismatch, isVersionMismatch,
runLocalDaemonUpdate,
shouldShowDesktopUpdateSection,
} from "@/desktop/updates/desktop-updates"; } from "@/desktop/updates/desktop-updates";
import {
getManagedDaemonLogs,
getManagedDaemonPairing,
getManagedDaemonStatus,
installManagedCliShim,
restartManagedDaemon,
shouldUseManagedDesktopDaemon,
uninstallManagedCliShim,
updateManagedDaemonTcpSettings,
type ManagedDaemonLogs,
type ManagedPairingOffer,
type ManagedDaemonStatus,
} from "@/desktop/managed-runtime/managed-runtime";
export interface LocalDaemonSectionProps { export interface LocalDaemonSectionProps {
appVersion: string | null; appVersion: string | null;
} }
export function LocalDaemonSection({ appVersion }: LocalDaemonSectionProps) { export function LocalDaemonSection({ appVersion }: LocalDaemonSectionProps) {
const showSection = shouldShowDesktopUpdateSection(); const showSection = shouldUseManagedDesktopDaemon();
const [localDaemonVersion, setLocalDaemonVersion] = useState<string | null>(null); const [managedStatus, setManagedStatus] = useState<ManagedDaemonStatus | null>(null);
const [localDaemonVersionError, setLocalDaemonVersionError] = useState<string | null>(null); const [statusError, setStatusError] = useState<string | null>(null);
const [isUpdatingLocalDaemon, setIsUpdatingLocalDaemon] = useState(false); const [isRestartingDaemon, setIsRestartingDaemon] = useState(false);
const [localDaemonUpdateMessage, setLocalDaemonUpdateMessage] = useState<string | null>(null); const [isInstallingCli, setIsInstallingCli] = useState(false);
const [localDaemonUpdateDiagnostics, setLocalDaemonUpdateDiagnostics] = useState<string | null>( const [isSavingTcpSettings, setIsSavingTcpSettings] = useState(false);
null const [statusMessage, setStatusMessage] = useState<string | null>(null);
); const [managedLogs, setManagedLogs] = useState<ManagedDaemonLogs | null>(null);
const [isTcpModalOpen, setIsTcpModalOpen] = useState(false);
const [isLogsModalOpen, setIsLogsModalOpen] = useState(false);
const [isPairingModalOpen, setIsPairingModalOpen] = useState(false);
const [isLoadingPairing, setIsLoadingPairing] = useState(false);
const [pairingOffer, setPairingOffer] = useState<ManagedPairingOffer | null>(null);
const [pairingStatusMessage, setPairingStatusMessage] = useState<string | null>(null);
const [tcpHostInput, setTcpHostInput] = useState(DEFAULT_TCP_HOST);
const [tcpPortInput, setTcpPortInput] = useState(String(DEFAULT_TCP_PORT));
const loadManagedStatus = useCallback(() => {
if (!showSection) {
return Promise.resolve();
}
return Promise.all([getManagedDaemonStatus(), getManagedDaemonLogs()])
.then(([status, logs]) => {
setManagedStatus(status);
setManagedLogs(logs);
setStatusError(null);
const [tcpHost, tcpPort] = splitTcpListen(status.tcpListen);
setTcpHostInput(tcpHost);
setTcpPortInput(tcpPort);
})
.catch((error) => {
const message = error instanceof Error ? error.message : String(error);
setStatusError(message);
});
}, [showSection]);
useFocusEffect( useFocusEffect(
useCallback(() => { useCallback(() => {
if (!showSection) { if (!showSection) {
return undefined; return undefined;
} }
void loadManagedStatus();
void getLocalDaemonVersion().then((result) => {
setLocalDaemonVersion(result.version);
setLocalDaemonVersionError(result.error);
});
return undefined; return undefined;
}, [showSection]) }, [loadManagedStatus, showSection])
); );
const localDaemonVersionText = formatVersionWithPrefix(localDaemonVersion); const localDaemonVersionText = formatVersionWithPrefix(managedStatus?.runtimeVersion ?? null);
const daemonVersionMismatch = isVersionMismatch(appVersion, localDaemonVersion); const daemonVersionMismatch = isVersionMismatch(appVersion, managedStatus?.runtimeVersion ?? null);
const daemonVersionHint = localDaemonVersionError ?? "Daemon installed on this computer."; const daemonVersionHint =
statusError ??
(managedStatus?.daemonRunning
? managedStatus.transportType === "tcp"
? `Managed daemon running on explicit TCP ${managedStatus.transportPath}.`
: `Managed daemon running on private ${managedStatus.transportType}.`
: "Managed daemon is currently stopped.");
const handleUpdateLocalDaemon = useCallback(() => { const handleUpdateLocalDaemon = useCallback(() => {
if (!showSection) { if (!showSection) {
return; return;
} }
if (isUpdatingLocalDaemon) { if (isRestartingDaemon) {
return; return;
} }
void confirmDialog({ void confirmDialog({
title: "Update local daemon", title: "Restart managed daemon",
message: message:
"This updates the Paseo daemon on this computer. A restart is required afterwards.", "This restarts the desktop-managed daemon using its private managed home and socket.",
confirmLabel: "Update daemon", confirmLabel: "Restart daemon",
cancelLabel: "Cancel", cancelLabel: "Cancel",
}) })
.then((confirmed) => { .then((confirmed) => {
@@ -66,71 +106,201 @@ export function LocalDaemonSection({ appVersion }: LocalDaemonSectionProps) {
return; return;
} }
setIsUpdatingLocalDaemon(true); setIsRestartingDaemon(true);
setLocalDaemonUpdateMessage(null); setStatusMessage(null);
setLocalDaemonUpdateDiagnostics(null);
void runLocalDaemonUpdate() void restartManagedDaemon()
.then((result) => { .then((status) => {
const diagnostics = buildDaemonUpdateDiagnostics(result); setManagedStatus(status);
if (result.exitCode !== 0) { setStatusMessage("Managed daemon restarted.");
setLocalDaemonUpdateMessage( return loadManagedStatus();
`Local daemon update failed (exit code ${result.exitCode}). Copy diagnostics below to troubleshoot.`
);
setLocalDaemonUpdateDiagnostics(diagnostics);
return;
}
setLocalDaemonUpdateMessage(
"Local daemon update finished. Restart is required: run `paseo daemon restart` on this computer."
);
if (result.stdout.trim().length > 0 || result.stderr.trim().length > 0) {
setLocalDaemonUpdateDiagnostics(diagnostics);
}
void getLocalDaemonVersion().then((versionResult) => {
setLocalDaemonVersion(versionResult.version);
setLocalDaemonVersionError(versionResult.error);
});
}) })
.catch((error) => { .catch((error) => {
console.error("[Settings] Failed to update local daemon", error); console.error("[Settings] Failed to restart managed daemon", error);
const message = error instanceof Error ? error.message : String(error); const message = error instanceof Error ? error.message : String(error);
setLocalDaemonUpdateMessage( setStatusMessage(`Managed daemon restart failed: ${message}`);
"Local daemon update failed before completion. Copy diagnostics below to troubleshoot."
);
setLocalDaemonUpdateDiagnostics(
buildDaemonUpdateDiagnostics({
exitCode: -1,
stdout: "",
stderr: message,
})
);
}) })
.finally(() => { .finally(() => {
setIsUpdatingLocalDaemon(false); setIsRestartingDaemon(false);
}); });
}) })
.catch((error) => { .catch((error) => {
console.error("[Settings] Failed to open daemon update confirmation", error); console.error("[Settings] Failed to open managed daemon restart confirmation", error);
Alert.alert("Error", "Unable to open the daemon update confirmation dialog."); Alert.alert("Error", "Unable to open the managed daemon restart confirmation dialog.");
}); });
}, [isUpdatingLocalDaemon, showSection]); }, [isRestartingDaemon, loadManagedStatus, showSection]);
const handleCopyDaemonDiagnostics = useCallback(() => { const handleToggleCliShim = useCallback(() => {
if (!localDaemonUpdateDiagnostics) { if (!showSection || isInstallingCli) {
return;
}
setIsInstallingCli(true);
setStatusMessage(null);
const action = managedStatus?.cliShimPath ? uninstallManagedCliShim : installManagedCliShim;
void action()
.then((result) => {
setStatusMessage(result.message);
return loadManagedStatus();
})
.catch((error) => {
const message = error instanceof Error ? error.message : String(error);
setStatusMessage(`CLI shim action failed: ${message}`);
})
.finally(() => {
setIsInstallingCli(false);
});
}, [isInstallingCli, loadManagedStatus, managedStatus?.cliShimPath, showSection]);
const handleCopyLogPath = useCallback(() => {
const logPath = managedLogs?.logPath ?? managedStatus?.logPath;
if (!logPath) {
return; return;
} }
void Clipboard.setStringAsync(localDaemonUpdateDiagnostics) void Clipboard.setStringAsync(logPath)
.then(() => { .then(() => {
Alert.alert("Copied", "Daemon update diagnostics copied."); Alert.alert("Copied", "Managed daemon log path copied.");
}) })
.catch((error) => { .catch((error) => {
console.error("[Settings] Failed to copy daemon update diagnostics", error); console.error("[Settings] Failed to copy managed daemon log path", error);
Alert.alert("Error", "Unable to copy diagnostics."); Alert.alert("Error", "Unable to copy log path.");
}); });
}, [localDaemonUpdateDiagnostics]); }, [managedLogs?.logPath, managedStatus?.logPath]);
const handleOpenLogs = useCallback(() => {
if (!managedLogs) {
return;
}
setIsLogsModalOpen(true);
}, [managedLogs]);
const handleOpenPairingModal = useCallback(() => {
if (isLoadingPairing) {
return;
}
setIsPairingModalOpen(true);
setIsLoadingPairing(true);
setPairingStatusMessage(null);
void getManagedDaemonPairing()
.then((pairing) => {
setPairingOffer(pairing);
if (!pairing.relayEnabled || !pairing.url) {
setPairingStatusMessage("Relay pairing is disabled for this managed daemon.");
}
})
.catch((error) => {
const message = error instanceof Error ? error.message : String(error);
setPairingOffer(null);
setPairingStatusMessage(`Unable to load pairing offer: ${message}`);
})
.finally(() => {
setIsLoadingPairing(false);
});
}, [isLoadingPairing]);
const handleCopyPairingLink = useCallback(() => {
if (!pairingOffer?.url) {
return;
}
void Clipboard.setStringAsync(pairingOffer.url)
.then(() => {
Alert.alert("Copied", "Pairing link copied.");
})
.catch((error) => {
console.error("[Settings] Failed to copy pairing link", error);
Alert.alert("Error", "Unable to copy pairing link.");
});
}, [pairingOffer?.url]);
const handleOpenTcpModal = useCallback(() => {
if (!managedStatus) {
return;
}
const [tcpHost, tcpPort] = splitTcpListen(managedStatus.tcpListen);
setTcpHostInput(tcpHost);
setTcpPortInput(tcpPort);
setIsTcpModalOpen(true);
}, [managedStatus]);
const handleDisableTcp = useCallback(() => {
if (isSavingTcpSettings) {
return;
}
setIsSavingTcpSettings(true);
setStatusMessage(null);
void updateManagedDaemonTcpSettings({
enabled: false,
host: tcpHostInput.trim() || DEFAULT_TCP_HOST,
port: parseTcpPort(tcpPortInput) ?? DEFAULT_TCP_PORT,
})
.then((status) => {
setManagedStatus(status);
setStatusMessage("Managed TCP exposure disabled.");
return loadManagedStatus();
})
.catch((error) => {
const message = error instanceof Error ? error.message : String(error);
setStatusMessage(`Managed TCP update failed: ${message}`);
})
.finally(() => {
setIsSavingTcpSettings(false);
});
}, [isSavingTcpSettings, loadManagedStatus, tcpHostInput, tcpPortInput]);
const handleSaveTcp = useCallback(() => {
if (isSavingTcpSettings) {
return;
}
const host = tcpHostInput.trim();
const port = parseTcpPort(tcpPortInput);
if (!host) {
Alert.alert("Host required", "Enter a TCP bind host.");
return;
}
if (port == null || port <= 0) {
Alert.alert("Port required", "Enter a valid TCP port.");
return;
}
if (port === 6767) {
Alert.alert("Port unavailable", "Managed TCP mode must not claim 127.0.0.1:6767.");
return;
}
void confirmDialog({
title: "Enable managed TCP",
message:
"This exposes the managed daemon on a TCP listener. Relay remains available, but network exposure is no longer private to the desktop app.",
confirmLabel: "Enable TCP",
cancelLabel: "Cancel",
})
.then((confirmed) => {
if (!confirmed) {
return;
}
setIsSavingTcpSettings(true);
setStatusMessage(null);
void updateManagedDaemonTcpSettings({ enabled: true, host, port })
.then((status) => {
setManagedStatus(status);
setIsTcpModalOpen(false);
setStatusMessage(`Managed TCP exposure enabled on ${host}:${port}.`);
return loadManagedStatus();
})
.catch((error) => {
const message = error instanceof Error ? error.message : String(error);
setStatusMessage(`Managed TCP update failed: ${message}`);
})
.finally(() => {
setIsSavingTcpSettings(false);
});
})
.catch((error) => {
console.error("[Settings] Failed to open managed TCP confirmation", error);
Alert.alert("Error", "Unable to open the managed TCP confirmation dialog.");
});
}, [isSavingTcpSettings, loadManagedStatus, tcpHostInput, tcpPortInput]);
if (!showSection) { if (!showSection) {
return null; return null;
@@ -138,7 +308,7 @@ export function LocalDaemonSection({ appVersion }: LocalDaemonSectionProps) {
return ( return (
<View style={styles.section}> <View style={styles.section}>
<Text style={styles.sectionTitle}>Local daemon</Text> <Text style={styles.sectionTitle}>Managed daemon</Text>
<View style={styles.card}> <View style={styles.card}>
<View style={styles.row}> <View style={styles.row}>
<View style={styles.rowContent}> <View style={styles.rowContent}>
@@ -149,47 +319,309 @@ export function LocalDaemonSection({ appVersion }: LocalDaemonSectionProps) {
</View> </View>
<View style={[styles.row, styles.rowBorder]}> <View style={[styles.row, styles.rowBorder]}>
<View style={styles.rowContent}> <View style={styles.rowContent}>
<Text style={styles.rowTitle}>Update daemon</Text> <Text style={styles.rowTitle}>Restart daemon</Text>
<Text style={styles.hintText}> <Text style={styles.hintText}>
Updates the daemon on this computer only. Requires a restart. Restarts the desktop-managed daemon without touching `~/.paseo` or `127.0.0.1:6767`.
</Text> </Text>
{localDaemonUpdateMessage ? ( {statusMessage ? (
<Text style={styles.statusText}>{localDaemonUpdateMessage}</Text> <Text style={styles.statusText}>{statusMessage}</Text>
) : null} ) : null}
</View> </View>
<Button <Button
variant="secondary" variant="secondary"
size="sm" size="sm"
onPress={handleUpdateLocalDaemon} onPress={handleUpdateLocalDaemon}
disabled={isUpdatingLocalDaemon} disabled={isRestartingDaemon}
> >
{isUpdatingLocalDaemon ? "Updating..." : "Update daemon"} {isRestartingDaemon ? "Restarting..." : "Restart daemon"}
</Button> </Button>
</View> </View>
<View style={[styles.row, styles.rowBorder]}>
<View style={styles.rowContent}>
<Text style={styles.rowTitle}>CLI shim</Text>
<Text style={styles.hintText}>
Installs `paseo` into your user path and points it at the managed daemon by default.
</Text>
</View>
<Button
variant="secondary"
size="sm"
onPress={handleToggleCliShim}
disabled={isInstallingCli}
>
{isInstallingCli
? "Working..."
: managedStatus?.cliShimPath
? "Uninstall CLI"
: "Install CLI"}
</Button>
</View>
<View style={[styles.row, styles.rowBorder]}>
<View style={styles.rowContent}>
<Text style={styles.rowTitle}>Log file</Text>
<Text style={styles.hintText}>
{managedLogs?.logPath ??
managedStatus?.logPath ??
"Managed daemon log path unavailable."}
</Text>
</View>
<View style={styles.actionGroup}>
{(managedLogs?.logPath ?? managedStatus?.logPath) ? (
<Button variant="secondary" size="sm" onPress={handleCopyLogPath}>
Copy path
</Button>
) : null}
<Button
variant="secondary"
size="sm"
onPress={handleOpenLogs}
disabled={!managedLogs}
>
Open logs
</Button>
</View>
</View>
<View style={[styles.row, styles.rowBorder]}>
<View style={styles.rowContent}>
<Text style={styles.rowTitle}>Pair device</Text>
<Text style={styles.hintText}>
Show the managed daemon QR code and a copyable pairing link for the mobile app.
</Text>
</View>
<Button variant="secondary" size="sm" onPress={handleOpenPairingModal}>
Pair device
</Button>
</View>
<View style={[styles.row, styles.rowBorder]}>
<View style={styles.rowContent}>
<Text style={styles.rowTitle}>Advanced TCP</Text>
<Text style={styles.hintText}>
Default off. Use only if you explicitly want a network listener instead of the
private managed transport.
</Text>
<Text style={styles.statusText}>
{managedStatus?.tcpEnabled
? `Enabled on ${managedStatus.tcpListen ?? managedStatus.transportPath}`
: "Disabled"}
</Text>
</View>
<View style={styles.actionGroup}>
{managedStatus?.tcpEnabled ? (
<Button
variant="secondary"
size="sm"
onPress={handleDisableTcp}
disabled={isSavingTcpSettings}
>
{isSavingTcpSettings ? "Working..." : "Disable TCP"}
</Button>
) : null}
<Button
variant="secondary"
size="sm"
onPress={handleOpenTcpModal}
disabled={isSavingTcpSettings}
>
{managedStatus?.tcpEnabled ? "Edit TCP" : "Enable TCP"}
</Button>
</View>
</View>
</View> </View>
{daemonVersionMismatch ? ( {daemonVersionMismatch ? (
<View style={styles.warningCard}> <View style={styles.warningCard}>
<Text style={styles.warningText}> <Text style={styles.warningText}>
Desktop app and local daemon versions differ. Keep both on the same version to avoid Desktop app and managed daemon versions differ. Keep both on the same version to avoid
stability issues or breaking changes. stability issues or breaking changes.
</Text> </Text>
</View> </View>
) : null} ) : null}
{localDaemonUpdateDiagnostics ? ( <AdaptiveModalSheet
<View style={styles.diagnosticsCard}> visible={isTcpModalOpen}
<View style={styles.diagnosticsHeader}> onClose={() => setIsTcpModalOpen(false)}
<Text style={styles.diagnosticsTitle}>Daemon update diagnostics</Text> title="Managed TCP settings"
<Button variant="secondary" size="sm" onPress={handleCopyDaemonDiagnostics}> >
Copy output <View style={styles.modalBody}>
<Text style={styles.hintText}>
TCP is advanced opt-in only. It must stay off unless you explicitly need direct network
access, and it must never use port 6767.
</Text>
<AdaptiveTextInput
value={tcpHostInput}
onChangeText={setTcpHostInput}
autoCapitalize="none"
autoCorrect={false}
placeholder="127.0.0.1"
style={styles.input}
/>
<AdaptiveTextInput
value={tcpPortInput}
onChangeText={setTcpPortInput}
autoCapitalize="none"
autoCorrect={false}
keyboardType="number-pad"
placeholder={String(DEFAULT_TCP_PORT)}
style={styles.input}
/>
<View style={styles.modalActions}>
<Button variant="secondary" size="sm" onPress={() => setIsTcpModalOpen(false)}>
Cancel
</Button>
<Button size="sm" onPress={handleSaveTcp} disabled={isSavingTcpSettings}>
{isSavingTcpSettings ? "Saving..." : "Save"}
</Button> </Button>
</View> </View>
<Text style={styles.diagnosticsText} selectable> </View>
{localDaemonUpdateDiagnostics} </AdaptiveModalSheet>
<AdaptiveModalSheet
visible={isPairingModalOpen}
onClose={() => setIsPairingModalOpen(false)}
title="Pair device"
testID="managed-daemon-pairing-dialog"
>
<PairingOfferDialogContent
isLoading={isLoadingPairing}
pairingOffer={pairingOffer}
statusMessage={pairingStatusMessage}
onCopyLink={handleCopyPairingLink}
/>
</AdaptiveModalSheet>
<AdaptiveModalSheet
visible={isLogsModalOpen}
onClose={() => setIsLogsModalOpen(false)}
title="Managed daemon logs"
testID="managed-daemon-logs-dialog"
snapPoints={["70%", "92%"]}
>
<View style={styles.modalBody}>
<Text style={styles.hintText}>
{managedLogs?.logPath ??
managedStatus?.logPath ??
"Managed daemon log path unavailable."}
</Text>
<Text style={styles.logOutput} selectable>
{managedLogs?.contents.length ? managedLogs.contents : "(log file is empty)"}
</Text> </Text>
</View> </View>
) : null} </AdaptiveModalSheet>
</View>
);
}
const DEFAULT_TCP_HOST = "127.0.0.1";
const DEFAULT_TCP_PORT = 7771;
function splitTcpListen(value: string | null): [string, string] {
if (!value) {
return [DEFAULT_TCP_HOST, String(DEFAULT_TCP_PORT)];
}
const [host, port] = value.split(":");
return [host || DEFAULT_TCP_HOST, port || String(DEFAULT_TCP_PORT)];
}
function parseTcpPort(value: string): number | null {
const parsed = Number.parseInt(value.trim(), 10);
return Number.isFinite(parsed) ? parsed : null;
}
function PairingOfferDialogContent(input: {
isLoading: boolean;
pairingOffer: ManagedPairingOffer | null;
statusMessage: string | null;
onCopyLink: () => void;
}) {
const { isLoading, pairingOffer, statusMessage, onCopyLink } = input;
const [qrDataUrl, setQrDataUrl] = useState<string | null>(null);
const [qrError, setQrError] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
if (!pairingOffer?.url) {
setQrDataUrl(null);
setQrError(null);
return () => {
cancelled = true;
};
}
setQrError(null);
setQrDataUrl(null);
void QRCode.toDataURL(pairingOffer.url, {
errorCorrectionLevel: "M",
margin: 1,
width: 320,
})
.then((dataUrl) => {
if (cancelled) {
return;
}
setQrDataUrl(dataUrl);
})
.catch((error) => {
if (cancelled) {
return;
}
setQrError(error instanceof Error ? error.message : String(error));
});
return () => {
cancelled = true;
};
}, [pairingOffer?.url]);
if (isLoading) {
return (
<View style={styles.pairingState}>
<ActivityIndicator size="small" />
<Text style={styles.hintText}>Loading pairing offer</Text>
</View>
);
}
if (statusMessage) {
return (
<View style={styles.modalBody}>
<Text style={styles.hintText}>{statusMessage}</Text>
</View>
);
}
if (!pairingOffer?.url) {
return (
<View style={styles.modalBody}>
<Text style={styles.hintText}>Pairing offer unavailable.</Text>
</View>
);
}
return (
<View style={styles.modalBody}>
<Text style={styles.hintText}>
Scan this QR code in Paseo, or copy the pairing link below.
</Text>
<View style={styles.qrCard}>
{qrDataUrl ? (
<Image source={{ uri: qrDataUrl }} style={styles.qrImage} />
) : qrError ? (
<Text style={styles.hintText}>QR unavailable: {qrError}</Text>
) : (
<ActivityIndicator size="small" />
)}
</View>
<Text style={styles.linkLabel}>Pairing link</Text>
<Text style={styles.linkText} selectable>
{pairingOffer.url}
</Text>
<View style={styles.modalActions}>
<Button variant="secondary" size="sm" onPress={onCopyLink}>
Copy link
</Button>
</View>
</View> </View>
); );
} }
@@ -227,6 +659,10 @@ const styles = StyleSheet.create((theme) => ({
flex: 1, flex: 1,
marginRight: theme.spacing[3], marginRight: theme.spacing[3],
}, },
actionGroup: {
flexDirection: "row",
gap: theme.spacing[2],
},
rowTitle: { rowTitle: {
color: theme.colors.foreground, color: theme.colors.foreground,
fontSize: theme.fontSize.base, fontSize: theme.fontSize.base,
@@ -259,28 +695,59 @@ const styles = StyleSheet.create((theme) => ({
color: theme.colors.palette.amber[500], color: theme.colors.palette.amber[500],
fontSize: theme.fontSize.xs, fontSize: theme.fontSize.xs,
}, },
diagnosticsCard: { modalBody: {
marginTop: theme.spacing[3], gap: theme.spacing[3],
paddingBottom: theme.spacing[2],
},
pairingState: {
alignItems: "center",
justifyContent: "center",
gap: theme.spacing[3],
paddingVertical: theme.spacing[6],
},
qrCard: {
alignItems: "center",
justifyContent: "center",
alignSelf: "center",
minHeight: 220,
minWidth: 220,
padding: theme.spacing[4],
borderRadius: theme.borderRadius.lg, borderRadius: theme.borderRadius.lg,
borderWidth: 1, borderWidth: 1,
borderColor: theme.colors.border, borderColor: theme.colors.border,
backgroundColor: theme.colors.surface1, backgroundColor: theme.colors.surface0,
padding: theme.spacing[3],
gap: theme.spacing[2],
}, },
diagnosticsHeader: { qrImage: {
flexDirection: "row", width: 220,
alignItems: "center", height: 220,
justifyContent: "space-between",
gap: theme.spacing[2],
}, },
diagnosticsTitle: { linkLabel: {
color: theme.colors.foreground, color: theme.colors.foreground,
fontSize: theme.fontSize.sm, fontSize: theme.fontSize.sm,
}, },
diagnosticsText: { linkText: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.xs,
lineHeight: 18,
},
logOutput: {
color: theme.colors.foregroundMuted, color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.xs, fontSize: theme.fontSize.xs,
fontFamily: "ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace", fontFamily: "ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace",
lineHeight: 18,
},
input: {
borderWidth: 1,
borderColor: theme.colors.border,
borderRadius: theme.borderRadius.md,
color: theme.colors.foreground,
backgroundColor: theme.colors.surface1,
paddingHorizontal: theme.spacing[3],
paddingVertical: theme.spacing[3],
},
modalActions: {
flexDirection: "row",
justifyContent: "flex-end",
gap: theme.spacing[2],
}, },
})); }));

View File

@@ -0,0 +1,262 @@
import { invokeDesktopCommand } from "@/desktop/tauri/invoke-desktop-command";
import { getTauri, isTauriEnvironment } from "@/utils/tauri";
export type ManagedRuntimeStatus = {
runtimeId: string;
runtimeVersion: string;
bundledRuntimeRoot: string;
installedRuntimeRoot: string;
installed: boolean;
managedHome: string;
transportType: string;
transportPath: string;
diagnosticsRoot: string;
stateFilePath: string;
};
export type ManagedDaemonStatus = {
runtimeId: string;
runtimeVersion: string;
runtimeRoot: string;
managedHome: string;
transportType: string;
transportPath: string;
daemonPid: number | null;
daemonRunning: boolean;
daemonStatus: string;
logPath: string;
serverId: string | null;
hostname: string | null;
relayEnabled: boolean;
tcpEnabled: boolean;
tcpListen: string | null;
cliShimPath: string | null;
};
export type ManagedDaemonLogs = {
logPath: string;
contents: string;
};
export type ManagedPairingOffer = {
relayEnabled: boolean;
url: string | null;
qr: string | null;
};
export type CliShimResult = {
installed: boolean;
path: string | null;
message: string;
};
export type ManagedTcpSettings = {
enabled: boolean;
host: string;
port: number;
};
export type LocalTransportTarget = {
transportType: "socket" | "pipe";
transportPath: string;
};
type LocalTransportEventPayload = {
sessionId: string;
kind: "open" | "message" | "close" | "error";
text?: string | null;
binaryBase64?: string | null;
code?: number | null;
reason?: string | null;
error?: string | null;
};
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null;
}
function toStringOrNull(value: unknown): string | null {
return typeof value === "string" && value.trim().length > 0 ? value : null;
}
function toNumberOrNull(value: unknown): number | null {
return typeof value === "number" && Number.isFinite(value) ? value : null;
}
function parseManagedRuntimeStatus(raw: unknown): ManagedRuntimeStatus {
if (!isRecord(raw)) {
throw new Error("Unexpected managed runtime status response.");
}
return {
runtimeId: toStringOrNull(raw.runtimeId) ?? "",
runtimeVersion: toStringOrNull(raw.runtimeVersion) ?? "",
bundledRuntimeRoot: toStringOrNull(raw.bundledRuntimeRoot) ?? "",
installedRuntimeRoot: toStringOrNull(raw.installedRuntimeRoot) ?? "",
installed: raw.installed === true,
managedHome: toStringOrNull(raw.managedHome) ?? "",
transportType: toStringOrNull(raw.transportType) ?? "socket",
transportPath: toStringOrNull(raw.transportPath) ?? "",
diagnosticsRoot: toStringOrNull(raw.diagnosticsRoot) ?? "",
stateFilePath: toStringOrNull(raw.stateFilePath) ?? "",
};
}
function parseManagedDaemonStatus(raw: unknown): ManagedDaemonStatus {
if (!isRecord(raw)) {
throw new Error("Unexpected managed daemon status response.");
}
return {
runtimeId: toStringOrNull(raw.runtimeId) ?? "",
runtimeVersion: toStringOrNull(raw.runtimeVersion) ?? "",
runtimeRoot: toStringOrNull(raw.runtimeRoot) ?? "",
managedHome: toStringOrNull(raw.managedHome) ?? "",
transportType: toStringOrNull(raw.transportType) ?? "socket",
transportPath: toStringOrNull(raw.transportPath) ?? "",
daemonPid: toNumberOrNull(raw.daemonPid),
daemonRunning: raw.daemonRunning === true,
daemonStatus: toStringOrNull(raw.daemonStatus) ?? "unknown",
logPath: toStringOrNull(raw.logPath) ?? "",
serverId: toStringOrNull(raw.serverId),
hostname: toStringOrNull(raw.hostname),
relayEnabled: raw.relayEnabled === true,
tcpEnabled: raw.tcpEnabled === true,
tcpListen: toStringOrNull(raw.tcpListen),
cliShimPath: toStringOrNull(raw.cliShimPath),
};
}
function parseManagedDaemonLogs(raw: unknown): ManagedDaemonLogs {
if (!isRecord(raw)) {
throw new Error("Unexpected managed daemon logs response.");
}
return {
logPath: toStringOrNull(raw.logPath) ?? "",
contents: typeof raw.contents === "string" ? raw.contents : "",
};
}
function parseManagedPairingOffer(raw: unknown): ManagedPairingOffer {
if (!isRecord(raw)) {
throw new Error("Unexpected managed daemon pairing response.");
}
return {
relayEnabled: raw.relayEnabled === true,
url: toStringOrNull(raw.url),
qr: toStringOrNull(raw.qr),
};
}
function parseCliShimResult(raw: unknown): CliShimResult {
if (!isRecord(raw)) {
throw new Error("Unexpected CLI shim response.");
}
return {
installed: raw.installed === true,
path: toStringOrNull(raw.path),
message: toStringOrNull(raw.message) ?? "",
};
}
export function shouldUseManagedDesktopDaemon(): boolean {
return isTauriEnvironment() && getTauri() !== null;
}
export async function ensureManagedRuntime(): Promise<ManagedRuntimeStatus> {
return parseManagedRuntimeStatus(await invokeDesktopCommand("ensure_managed_runtime"));
}
export async function getManagedRuntimeStatus(): Promise<ManagedRuntimeStatus> {
return parseManagedRuntimeStatus(await invokeDesktopCommand("managed_runtime_status"));
}
export async function getManagedDaemonStatus(): Promise<ManagedDaemonStatus> {
return parseManagedDaemonStatus(await invokeDesktopCommand("managed_daemon_status"));
}
export async function startManagedDaemon(): Promise<ManagedDaemonStatus> {
return parseManagedDaemonStatus(await invokeDesktopCommand("start_managed_daemon"));
}
export async function stopManagedDaemon(): Promise<ManagedDaemonStatus> {
return parseManagedDaemonStatus(await invokeDesktopCommand("stop_managed_daemon"));
}
export async function restartManagedDaemon(): Promise<ManagedDaemonStatus> {
return parseManagedDaemonStatus(await invokeDesktopCommand("restart_managed_daemon"));
}
export async function getManagedDaemonLogs(): Promise<ManagedDaemonLogs> {
return parseManagedDaemonLogs(await invokeDesktopCommand("managed_daemon_logs"));
}
export async function getManagedDaemonPairing(): Promise<ManagedPairingOffer> {
return parseManagedPairingOffer(await invokeDesktopCommand("managed_daemon_pairing"));
}
export async function installManagedCliShim(): Promise<CliShimResult> {
return parseCliShimResult(await invokeDesktopCommand("install_cli_shim"));
}
export async function uninstallManagedCliShim(): Promise<CliShimResult> {
return parseCliShimResult(await invokeDesktopCommand("uninstall_cli_shim"));
}
export async function updateManagedDaemonTcpSettings(
settings: ManagedTcpSettings
): Promise<ManagedDaemonStatus> {
return parseManagedDaemonStatus(
await invokeDesktopCommand("update_managed_daemon_tcp_settings", { settings })
);
}
export type LocalTransportEventUnlisten = () => void;
export type LocalTransportEventHandler = (payload: LocalTransportEventPayload) => void;
export async function listenToLocalTransportEvents(
handler: LocalTransportEventHandler
): Promise<LocalTransportEventUnlisten> {
const listen = getTauri()?.event?.listen;
if (typeof listen !== "function") {
throw new Error("Tauri event API is unavailable.");
}
const unlisten = await listen("local-daemon-transport-event", (event: unknown) => {
const payload = isRecord(event) && isRecord(event.payload) ? event.payload : null;
if (!payload) {
return;
}
handler({
sessionId: toStringOrNull(payload.sessionId) ?? "",
kind: (toStringOrNull(payload.kind) ?? "error") as LocalTransportEventPayload["kind"],
text: toStringOrNull(payload.text),
binaryBase64: toStringOrNull(payload.binaryBase64),
code: toNumberOrNull(payload.code),
reason: toStringOrNull(payload.reason),
error: toStringOrNull(payload.error),
});
});
return typeof unlisten === "function" ? unlisten : () => {};
}
export async function openLocalTransportSession(target: LocalTransportTarget): Promise<string> {
const raw = await invokeDesktopCommand<unknown>("open_local_daemon_transport", target);
if (typeof raw !== "string" || raw.trim().length === 0) {
throw new Error("Unexpected local transport session response.");
}
return raw;
}
export async function sendLocalTransportMessage(input: {
sessionId: string;
text?: string;
binaryBase64?: string;
}): Promise<void> {
await invokeDesktopCommand("send_local_daemon_transport_message", {
sessionId: input.sessionId,
...(input.text ? { text: input.text } : {}),
...(input.binaryBase64 ? { binaryBase64: input.binaryBase64 } : {}),
});
}
export async function closeLocalTransportSession(sessionId: string): Promise<void> {
await invokeDesktopCommand("close_local_daemon_transport", { sessionId });
}

View File

@@ -163,7 +163,7 @@ function makeFetchAgentsEntry(input: {
function makeHost(input?: Partial<HostProfile>): HostProfile { function makeHost(input?: Partial<HostProfile>): HostProfile {
const direct: HostConnection = { const direct: HostConnection = {
id: "direct:lan:6767", id: "direct:lan:6767",
type: "direct", type: "directTcp",
endpoint: "lan:6767", endpoint: "lan:6767",
}; };
const relay: HostConnection = { const relay: HostConnection = {
@@ -176,6 +176,12 @@ function makeHost(input?: Partial<HostProfile>): HostProfile {
return { return {
serverId: input?.serverId ?? "srv_test", serverId: input?.serverId ?? "srv_test",
label: input?.label ?? "test host", label: input?.label ?? "test host",
lifecycle: input?.lifecycle ?? {
managed: false,
managedRuntimeId: null,
managedRuntimeVersion: null,
associatedServerId: null,
},
connections: input?.connections ?? [direct, relay], connections: input?.connections ?? [direct, relay],
preferredConnectionId: input?.preferredConnectionId ?? direct.id, preferredConnectionId: input?.preferredConnectionId ?? direct.id,
createdAt: input?.createdAt ?? new Date(0).toISOString(), createdAt: input?.createdAt ?? new Date(0).toISOString(),
@@ -227,7 +233,7 @@ describe("HostRuntimeController", () => {
connections: [ connections: [
{ {
id: "direct:lan:6767", id: "direct:lan:6767",
type: "direct", type: "directTcp",
endpoint: "lan:6767", endpoint: "lan:6767",
}, },
], ],
@@ -262,7 +268,7 @@ describe("HostRuntimeController", () => {
connections: [ connections: [
{ {
id: "direct:lan:6767", id: "direct:lan:6767",
type: "direct", type: "directTcp",
endpoint: "lan:6767", endpoint: "lan:6767",
}, },
], ],
@@ -438,7 +444,7 @@ describe("HostRuntimeController", () => {
connections: [ connections: [
{ {
id: "direct:lan:6767", id: "direct:lan:6767",
type: "direct", type: "directTcp",
endpoint: "lan:6767", endpoint: "lan:6767",
}, },
], ],
@@ -577,7 +583,7 @@ describe("HostRuntimeController", () => {
connections: [ connections: [
{ {
id: "direct:lan:6767", id: "direct:lan:6767",
type: "direct", type: "directTcp",
endpoint: "lan:6767", endpoint: "lan:6767",
}, },
{ {
@@ -667,7 +673,7 @@ describe("HostRuntimeController", () => {
connections: [ connections: [
{ {
id: "direct:lan:6767", id: "direct:lan:6767",
type: "direct", type: "directTcp",
endpoint: "lan:6767", endpoint: "lan:6767",
}, },
], ],
@@ -723,7 +729,7 @@ describe("HostRuntimeController", () => {
connections: [ connections: [
{ {
id: "direct:lan:6767", id: "direct:lan:6767",
type: "direct", type: "directTcp",
endpoint: "lan:6767", endpoint: "lan:6767",
}, },
], ],
@@ -789,7 +795,7 @@ describe("HostRuntimeStore", () => {
connections: [ connections: [
{ {
id: "direct:lan:6767", id: "direct:lan:6767",
type: "direct", type: "directTcp",
endpoint: "lan:6767", endpoint: "lan:6767",
}, },
], ],
@@ -837,7 +843,7 @@ describe("HostRuntimeStore", () => {
connections: [ connections: [
{ {
id: "direct:lan:6767", id: "direct:lan:6767",
type: "direct", type: "directTcp",
endpoint: "lan:6767", endpoint: "lan:6767",
}, },
], ],
@@ -885,7 +891,7 @@ describe("HostRuntimeStore", () => {
connections: [ connections: [
{ {
id: "direct:lan:6767", id: "direct:lan:6767",
type: "direct", type: "directTcp",
endpoint: "lan:6767", endpoint: "lan:6767",
}, },
], ],
@@ -1045,7 +1051,7 @@ describe("HostRuntimeStore", () => {
connections: [ connections: [
{ {
id: "direct:lan:6767", id: "direct:lan:6767",
type: "direct", type: "directTcp",
endpoint: "lan:6767", endpoint: "lan:6767",
}, },
], ],

View File

@@ -17,6 +17,10 @@ import {
type ConnectionCandidate, type ConnectionCandidate,
type ConnectionProbeState, type ConnectionProbeState,
} from "@/utils/connection-selection"; } from "@/utils/connection-selection";
import {
buildLocalDaemonTransportUrl,
createTauriLocalDaemonTransportFactory,
} from "@/utils/managed-tauri-daemon-transport";
import { createTauriWebSocketTransportFactory } from "@/utils/tauri-daemon-transport"; import { createTauriWebSocketTransportFactory } from "@/utils/tauri-daemon-transport";
import { applyFetchedAgentDirectory } from "@/utils/agent-directory-sync"; import { applyFetchedAgentDirectory } from "@/utils/agent-directory-sync";
import { useSessionStore, type Agent } from "@/stores/session-store"; import { useSessionStore, type Agent } from "@/stores/session-store";
@@ -33,7 +37,9 @@ export type HostRuntimeConnectionStatus =
| "error"; | "error";
export type ActiveConnection = export type ActiveConnection =
| { type: "direct"; endpoint: string; display: string } | { type: "directTcp"; endpoint: string; display: string }
| { type: "directSocket"; endpoint: string; display: "socket" }
| { type: "directPipe"; endpoint: string; display: "pipe" }
| { type: "relay"; endpoint: string; display: "relay" }; | { type: "relay"; endpoint: string; display: "relay" };
export type HostRuntimeAgentDirectoryStatus = export type HostRuntimeAgentDirectoryStatus =
@@ -179,9 +185,23 @@ function readFetchAgentsNextCursor(
} }
function toActiveConnection(connection: HostConnection): ActiveConnection { function toActiveConnection(connection: HostConnection): ActiveConnection {
if (connection.type === "direct") { if (connection.type === "directSocket") {
return { return {
type: "direct", type: "directSocket",
endpoint: connection.path,
display: "socket",
};
}
if (connection.type === "directPipe") {
return {
type: "directPipe",
endpoint: connection.path,
display: "pipe",
};
}
if (connection.type === "directTcp") {
return {
type: "directTcp",
endpoint: connection.endpoint, endpoint: connection.endpoint,
display: connection.endpoint, display: connection.endpoint,
}; };
@@ -421,11 +441,14 @@ function createDefaultDeps(): HostRuntimeControllerDeps {
serverId: host.serverId, serverId: host.serverId,
connectionType: connection.type, connectionType: connection.type,
endpoint: endpoint:
connection.type === "direct" connection.type === "directTcp"
? connection.endpoint ? connection.endpoint
: connection.type === "directSocket" || connection.type === "directPipe"
? connection.path
: connection.relayEndpoint, : connection.relayEndpoint,
}); });
const tauriTransportFactory = createTauriWebSocketTransportFactory(); const tauriTransportFactory = createTauriWebSocketTransportFactory();
const localTransportFactory = createTauriLocalDaemonTransportFactory();
const base = { const base = {
suppressSendErrors: true, suppressSendErrors: true,
clientId, clientId,
@@ -433,18 +456,31 @@ function createDefaultDeps(): HostRuntimeControllerDeps {
runtimeGeneration, runtimeGeneration,
onDiagnosticsEvent: (event: DaemonClientDiagnosticsEvent) => onDiagnosticsEvent: (event: DaemonClientDiagnosticsEvent) =>
recordDaemonClientDiagnostics(host.serverId, event), recordDaemonClientDiagnostics(host.serverId, event),
...(tauriTransportFactory
? { transportFactory: tauriTransportFactory }
: {}),
}; };
if (connection.type === "direct") { if (connection.type === "directSocket" || connection.type === "directPipe") {
return new DaemonClient({ return new DaemonClient({
...base, ...base,
...(localTransportFactory ? { transportFactory: localTransportFactory } : {}),
url: buildLocalDaemonTransportUrl({
transportType: connection.type === "directSocket" ? "socket" : "pipe",
transportPath: connection.path,
}),
});
}
if (connection.type === "directTcp") {
return new DaemonClient({
...base,
...(tauriTransportFactory
? { transportFactory: tauriTransportFactory }
: {}),
url: buildDaemonWebSocketUrl(connection.endpoint), url: buildDaemonWebSocketUrl(connection.endpoint),
}); });
} }
return new DaemonClient({ return new DaemonClient({
...base, ...base,
...(tauriTransportFactory
? { transportFactory: tauriTransportFactory }
: {}),
url: buildRelayWebSocketUrl({ url: buildRelayWebSocketUrl({
endpoint: connection.relayEndpoint, endpoint: connection.relayEndpoint,
serverId: host.serverId, serverId: host.serverId,

View File

@@ -24,7 +24,7 @@ function shouldSampleFastTransportEvent(): boolean {
export function recordHostRuntimeCreateClient(params: { export function recordHostRuntimeCreateClient(params: {
serverId: string; serverId: string;
connectionType: "direct" | "relay"; connectionType: "directTcp" | "directSocket" | "directPipe" | "relay";
endpoint: string; endpoint: string;
}): void { }): void {
recordPerfDiagnosticMark( recordPerfDiagnosticMark(

View File

@@ -51,6 +51,42 @@ const delay = (ms: number) =>
}, ms); }, ms);
}); });
function formatHostConnectionLabel(connection: HostConnection): string {
if (connection.type === "relay") {
return `Relay (${connection.relayEndpoint})`;
}
if (connection.type === "directSocket") {
return `Socket (${connection.path})`;
}
if (connection.type === "directPipe") {
return `Pipe (${connection.path})`;
}
return `TCP (${connection.endpoint})`;
}
function formatActiveConnectionBadge(input: {
activeConnection: { type: HostConnection["type"]; display: string } | null;
theme: ReturnType<typeof useUnistyles>["theme"];
}) {
const { activeConnection, theme } = input;
if (!activeConnection) {
return null;
}
if (activeConnection.type === "relay") {
return { icon: <Globe size={theme.iconSize.xs} color={theme.colors.foregroundMuted} />, text: "Relay" };
}
if (activeConnection.type === "directSocket") {
return { icon: <Monitor size={theme.iconSize.xs} color={theme.colors.foregroundMuted} />, text: "Socket" };
}
if (activeConnection.type === "directPipe") {
return { icon: <Monitor size={theme.iconSize.xs} color={theme.colors.foregroundMuted} />, text: "Pipe" };
}
return {
icon: <Monitor size={theme.iconSize.xs} color={theme.colors.foregroundMuted} />,
text: activeConnection.display,
};
}
const styles = StyleSheet.create((theme) => ({ const styles = StyleSheet.create((theme) => ({
loadingContainer: { loadingContainer: {
flex: 1, flex: 1,
@@ -1045,14 +1081,10 @@ function HostDetailModal({
? "rgba(248, 113, 113, 0.1)" ? "rgba(248, 113, 113, 0.1)"
: "rgba(161, 161, 170, 0.1)"; : "rgba(161, 161, 170, 0.1)";
const connectionBadge = (() => { const connectionBadge = (() => {
if (!activeConnection) return null; return formatActiveConnectionBadge({
if (activeConnection.type === "relay") { activeConnection,
return { icon: <Globe size={theme.iconSize.xs} color={theme.colors.foregroundMuted} />, text: "Relay" }; theme,
} });
return {
icon: <Monitor size={theme.iconSize.xs} color={theme.colors.foregroundMuted} />,
text: activeConnection.display,
};
})(); })();
const versionBadgeText = formatDaemonVersionBadge(daemonVersion); const versionBadgeText = formatDaemonVersionBadge(daemonVersion);
const connectionError = typeof lastError === "string" && lastError.trim().length > 0 ? lastError.trim() : null; const connectionError = typeof lastError === "string" && lastError.trim().length > 0 ? lastError.trim() : null;
@@ -1138,9 +1170,7 @@ function HostDetailModal({
latencyError={probe?.status === "unavailable"} latencyError={probe?.status === "unavailable"}
onRemove={() => { onRemove={() => {
const title = const title =
conn.type === "relay" formatHostConnectionLabel(conn);
? `Relay (${conn.relayEndpoint})`
: `Direct (${conn.endpoint})`;
setPendingRemoveConnection({ serverId: host.serverId, connectionId: conn.id, title }); setPendingRemoveConnection({ serverId: host.serverId, connectionId: conn.id, title });
}} }}
/> />
@@ -1276,9 +1306,7 @@ function ConnectionRow({
}) { }) {
const { theme } = useUnistyles(); const { theme } = useUnistyles();
const title = const title =
connection.type === "relay" formatHostConnectionLabel(connection);
? `Relay (${connection.relayEndpoint})`
: `Direct (${connection.endpoint})`;
const latencyText = (() => { const latencyText = (() => {
if (latencyLoading) return "..."; if (latencyLoading) return "...";
@@ -1363,14 +1391,10 @@ function DaemonCard({
? "rgba(248, 113, 113, 0.1)" ? "rgba(248, 113, 113, 0.1)"
: "rgba(161, 161, 170, 0.1)"; : "rgba(161, 161, 170, 0.1)";
const connectionBadge = (() => { const connectionBadge = (() => {
if (!activeConnection) return null; return formatActiveConnectionBadge({
if (activeConnection.type === "relay") { activeConnection,
return { icon: <Globe size={theme.iconSize.xs} color={theme.colors.foregroundMuted} />, text: "Relay" }; theme,
} });
return {
icon: <Monitor size={theme.iconSize.xs} color={theme.colors.foregroundMuted} />,
text: activeConnection.display,
};
})(); })();
const versionBadgeText = formatDaemonVersionBadge(daemonVersion); const versionBadgeText = formatDaemonVersionBadge(daemonVersion);

View File

@@ -246,7 +246,7 @@ type DownloadTarget = {
function resolveDaemonDownloadTarget(daemon?: HostProfile): DownloadTarget { function resolveDaemonDownloadTarget(daemon?: HostProfile): DownloadTarget {
const endpoint = const endpoint =
daemon?.connections.find((conn) => conn.type === "direct")?.endpoint ?? null; daemon?.connections.find((conn) => conn.type === "directTcp")?.endpoint ?? null;
if (!endpoint) { if (!endpoint) {
return { baseUrl: null, authHeader: null, authCredentials: null }; return { baseUrl: null, authHeader: null, authCredentials: null };
} }

View File

@@ -7,7 +7,7 @@ import {
} from "./connection-selection"; } from "./connection-selection";
function makeDirect(id: string, endpoint: string): HostConnection { function makeDirect(id: string, endpoint: string): HostConnection {
return { id, type: "direct", endpoint }; return { id, type: "directTcp", endpoint };
} }
function makeRelay( function makeRelay(

View File

@@ -0,0 +1,127 @@
import { describe, expect, it, vi, beforeEach } from "vitest";
const managedRuntimeMock = vi.hoisted(() => {
let eventHandler: ((payload: {
sessionId: string;
kind: "open" | "message" | "close" | "error";
text?: string | null;
binaryBase64?: string | null;
code?: number | null;
reason?: string | null;
error?: string | null;
}) => void) | null = null;
const openLocalTransportSession = vi.fn<(...args: unknown[]) => Promise<string>>();
const listenToLocalTransportEvents = vi.fn(async (handler: typeof eventHandler extends ((...args: infer A) => any) | null ? (...args: A) => void : never) => {
eventHandler = handler;
return () => {
eventHandler = null;
};
});
const sendLocalTransportMessage = vi.fn(async () => undefined);
const closeLocalTransportSession = vi.fn(async () => undefined);
return {
openLocalTransportSession,
listenToLocalTransportEvents,
sendLocalTransportMessage,
closeLocalTransportSession,
emitEvent(payload: {
sessionId: string;
kind: "open" | "message" | "close" | "error";
text?: string | null;
binaryBase64?: string | null;
code?: number | null;
reason?: string | null;
error?: string | null;
}) {
eventHandler?.(payload);
},
};
});
vi.mock("@/desktop/managed-runtime/managed-runtime", () => ({
openLocalTransportSession: managedRuntimeMock.openLocalTransportSession,
listenToLocalTransportEvents: managedRuntimeMock.listenToLocalTransportEvents,
sendLocalTransportMessage: managedRuntimeMock.sendLocalTransportMessage,
closeLocalTransportSession: managedRuntimeMock.closeLocalTransportSession,
}));
describe("managed-tauri-daemon-transport", () => {
beforeEach(() => {
managedRuntimeMock.openLocalTransportSession.mockReset();
managedRuntimeMock.listenToLocalTransportEvents.mockClear();
managedRuntimeMock.sendLocalTransportMessage.mockClear();
managedRuntimeMock.closeLocalTransportSession.mockClear();
});
it("emits open after the session resolves even if the rust open event raced earlier", async () => {
let resolveSession!: (sessionId: string) => void;
managedRuntimeMock.openLocalTransportSession.mockImplementation(
() =>
new Promise<string>((resolve) => {
resolveSession = resolve;
})
);
const mod = await import("./managed-tauri-daemon-transport");
const transportFactory = mod.createTauriLocalDaemonTransportFactory();
expect(transportFactory).not.toBeNull();
const transport = transportFactory!({
url: "paseo+local://socket?path=%2Ftmp%2Fpaseo.sock",
});
const onOpen = vi.fn();
transport.onOpen(onOpen);
managedRuntimeMock.emitEvent({
sessionId: "local-session-1",
kind: "open",
});
expect(onOpen).not.toHaveBeenCalled();
resolveSession("local-session-1");
await Promise.resolve();
expect(onOpen).toHaveBeenCalledTimes(1);
});
it("cleans up late async setup after the transport is closed", async () => {
let resolveSession!: (sessionId: string) => void;
let resolveListen!: (cleanup: () => void) => void;
const cleanup = vi.fn();
managedRuntimeMock.openLocalTransportSession.mockImplementation(
() =>
new Promise<string>((resolve) => {
resolveSession = resolve;
})
);
managedRuntimeMock.listenToLocalTransportEvents.mockImplementation(
() =>
new Promise<() => void>((resolve) => {
resolveListen = resolve;
})
);
const mod = await import("./managed-tauri-daemon-transport");
const transportFactory = mod.createTauriLocalDaemonTransportFactory();
expect(transportFactory).not.toBeNull();
const transport = transportFactory!({
url: "paseo+local://socket?path=%2Ftmp%2Fpaseo.sock",
});
transport.close();
resolveSession("local-session-2");
resolveListen(cleanup);
await Promise.resolve();
await Promise.resolve();
expect(managedRuntimeMock.closeLocalTransportSession).toHaveBeenCalledWith("local-session-2");
expect(cleanup).toHaveBeenCalledTimes(1);
});
});

View File

@@ -0,0 +1,186 @@
import type { DaemonTransport, DaemonTransportFactory } from "@server/client/daemon-client";
import {
closeLocalTransportSession,
listenToLocalTransportEvents,
openLocalTransportSession,
sendLocalTransportMessage,
type LocalTransportTarget,
} from "@/desktop/managed-runtime/managed-runtime";
const LOCAL_TRANSPORT_SCHEME = "paseo+local:";
function encodeBinaryToBase64(data: Uint8Array | ArrayBuffer): string {
const bytes = data instanceof ArrayBuffer ? new Uint8Array(data) : data;
let binary = "";
for (let index = 0; index < bytes.length; index += 1) {
binary += String.fromCharCode(bytes[index]!);
}
return globalThis.btoa(binary);
}
function decodeBase64ToBytes(base64: string): Uint8Array {
const binary = globalThis.atob(base64);
const bytes = new Uint8Array(binary.length);
for (let index = 0; index < binary.length; index += 1) {
bytes[index] = binary.charCodeAt(index);
}
return bytes;
}
export function buildLocalDaemonTransportUrl(target: LocalTransportTarget): string {
const url = new URL(`${LOCAL_TRANSPORT_SCHEME}//${target.transportType}`);
url.searchParams.set("path", target.transportPath);
return url.toString();
}
function parseLocalDaemonTransportUrl(url: string): LocalTransportTarget {
const parsed = new URL(url);
if (parsed.protocol !== LOCAL_TRANSPORT_SCHEME) {
throw new Error(`Unsupported local transport URL: ${url}`);
}
const transportType = parsed.hostname;
const transportPath = parsed.searchParams.get("path")?.trim() ?? "";
if ((transportType !== "socket" && transportType !== "pipe") || !transportPath) {
throw new Error(`Invalid local transport target: ${url}`);
}
return {
transportType,
transportPath,
};
}
export function createTauriLocalDaemonTransportFactory(): DaemonTransportFactory | null {
return ({ url }) => {
const target = parseLocalDaemonTransportUrl(url);
let sessionId: string | null = null;
let unlisten: (() => void) | null = null;
let disposed = false;
let didEmitOpen = 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 emitOpen = () => {
if (didEmitOpen || disposed) {
return;
}
didEmitOpen = true;
for (const handler of openHandlers) {
handler();
}
};
const emitClose = (event?: unknown) => {
for (const handler of closeHandlers) {
handler(event);
}
};
const emitError = (event?: unknown) => {
for (const handler of errorHandlers) {
handler(event);
}
};
const emitMessage = (data: unknown) => {
for (const handler of messageHandlers) {
handler(data);
}
};
void listenToLocalTransportEvents((payload) => {
if (disposed || !sessionId || payload.sessionId !== sessionId) {
return;
}
if (payload.kind === "open") {
emitOpen();
return;
}
if (payload.kind === "message") {
if (payload.text) {
emitMessage({ data: payload.text });
return;
}
if (payload.binaryBase64) {
emitMessage({ data: decodeBase64ToBytes(payload.binaryBase64) });
}
return;
}
if (payload.kind === "close") {
emitClose(payload);
return;
}
emitError(payload.error ?? "Local daemon transport error");
})
.then((cleanup) => {
if (disposed) {
cleanup();
return;
}
unlisten = cleanup;
})
.catch((error) => {
emitError(error);
});
void openLocalTransportSession(target)
.then((id) => {
if (disposed) {
void closeLocalTransportSession(id).catch((error) => emitError(error));
return;
}
sessionId = id;
emitOpen();
})
.catch((error) => {
emitError(error);
});
const transport: DaemonTransport = {
send: (data) => {
if (!sessionId) {
return;
}
if (typeof data === "string") {
void sendLocalTransportMessage({ sessionId, text: data }).catch((error) =>
emitError(error)
);
return;
}
const binaryBase64 = encodeBinaryToBase64(
data instanceof ArrayBuffer ? data : new Uint8Array(data)
);
void sendLocalTransportMessage({ sessionId, binaryBase64 }).catch((error) =>
emitError(error)
);
},
close: () => {
disposed = true;
const currentSessionId = sessionId;
sessionId = null;
if (currentSessionId) {
void closeLocalTransportSession(currentSessionId).catch((error) => emitError(error));
}
unlisten?.();
unlisten = null;
},
onMessage: (handler) => {
messageHandlers.add(handler);
return () => messageHandlers.delete(handler);
},
onOpen: (handler) => {
openHandlers.add(handler);
return () => openHandlers.delete(handler);
},
onClose: (handler) => {
closeHandlers.add(handler);
return () => closeHandlers.delete(handler);
},
onError: (handler) => {
errorHandlers.add(handler);
return () => errorHandlers.delete(handler);
},
};
return transport;
};
}

View File

@@ -71,12 +71,12 @@ describe("test-daemon-connection probe client identity", () => {
await mod.measureConnectionLatency({ await mod.measureConnectionLatency({
id: "direct:lan:6767", id: "direct:lan:6767",
type: "direct", type: "directTcp",
endpoint: "lan:6767", endpoint: "lan:6767",
}); });
await mod.measureConnectionLatency({ await mod.measureConnectionLatency({
id: "direct:lan:6767", id: "direct:lan:6767",
type: "direct", type: "directTcp",
endpoint: "lan:6767", endpoint: "lan:6767",
}); });
@@ -85,4 +85,18 @@ describe("test-daemon-connection probe client identity", () => {
expect(second?.clientId).toBe("cid_shared_probe_test"); expect(second?.clientId).toBe("cid_shared_probe_test");
expect(clientIdMock.getOrCreateClientId).toHaveBeenCalledTimes(2); expect(clientIdMock.getOrCreateClientId).toHaveBeenCalledTimes(2);
}); });
it("encodes the local socket target into the probe client config", async () => {
const mod = await import("./test-daemon-connection");
await mod.measureConnectionLatency({
id: "socket:/tmp/paseo.sock",
type: "directSocket",
path: "/tmp/paseo.sock",
});
expect(daemonClientMock.createdConfigs[0]?.url).toBe(
"paseo+local://socket?path=%2Ftmp%2Fpaseo.sock"
);
});
}); });

View File

@@ -3,6 +3,10 @@ import type { DaemonClientConfig } from "@server/client/daemon-client";
import type { HostConnection } from "@/contexts/daemon-registry-context"; import type { HostConnection } from "@/contexts/daemon-registry-context";
import { getOrCreateClientId } from "./client-id"; import { getOrCreateClientId } from "./client-id";
import { buildDaemonWebSocketUrl, buildRelayWebSocketUrl } from "./daemon-endpoints"; import { buildDaemonWebSocketUrl, buildRelayWebSocketUrl } from "./daemon-endpoints";
import {
buildLocalDaemonTransportUrl,
createTauriLocalDaemonTransportFactory,
} from "./managed-tauri-daemon-transport";
import { createTauriWebSocketTransportFactory } from "./tauri-daemon-transport"; import { createTauriWebSocketTransportFactory } from "./tauri-daemon-transport";
function normalizeNonEmptyString(value: unknown): string | null { function normalizeNonEmptyString(value: unknown): string | null {
@@ -47,14 +51,31 @@ async function buildClientConfig(
): Promise<DaemonClientConfig> { ): Promise<DaemonClientConfig> {
const clientId = await getOrCreateClientId(); const clientId = await getOrCreateClientId();
const tauriTransportFactory = createTauriWebSocketTransportFactory(); const tauriTransportFactory = createTauriWebSocketTransportFactory();
const localTransportFactory = createTauriLocalDaemonTransportFactory();
const base = { const base = {
clientId, clientId,
clientType: "mobile" as const, clientType: "mobile" as const,
suppressSendErrors: true, suppressSendErrors: true,
...(tauriTransportFactory ? { transportFactory: tauriTransportFactory } : {}), ...(connection.type === "directSocket" || connection.type === "directPipe"
? localTransportFactory
? { transportFactory: localTransportFactory }
: {}
: tauriTransportFactory
? { transportFactory: tauriTransportFactory }
: {}),
}; };
if (connection.type === "direct") { if (connection.type === "directSocket" || connection.type === "directPipe") {
return {
...base,
url: buildLocalDaemonTransportUrl({
transportType: connection.type === "directSocket" ? "socket" : "pipe",
transportPath: connection.path,
}),
};
}
if (connection.type === "directTcp") {
return { return {
...base, ...base,
url: buildDaemonWebSocketUrl(connection.endpoint), url: buildDaemonWebSocketUrl(connection.endpoint),

View File

@@ -289,7 +289,12 @@ export function resolveTcpHostFromListen(listen: string): string | null {
return null return null
} }
if (normalized.startsWith('/') || normalized.startsWith('unix://')) { if (
normalized.startsWith('/') ||
normalized.startsWith('unix://') ||
normalized.startsWith('pipe://') ||
normalized.startsWith('\\\\.\\pipe\\')
) {
return null return null
} }

View File

@@ -4,14 +4,16 @@ import { generateLocalPairingOffer, loadConfig, resolvePaseoHome } from '@getpas
interface PairOptions { interface PairOptions {
home?: string home?: string
json?: boolean
} }
export function pairCommand(): Command { export function pairCommand(): Command {
return new Command('pair') return new Command('pair')
.description('Print the daemon pairing QR code and link') .description('Print the daemon pairing QR code and link')
.option('--json', 'Output in JSON format')
.option('--home <path>', 'Paseo home directory (default: ~/.paseo)') .option('--home <path>', 'Paseo home directory (default: ~/.paseo)')
.action(async (options: PairOptions) => { .action(async (_options: PairOptions, command: Command) => {
await runPairCommand(options) await runPairCommand(command.optsWithGlobals() as PairOptions)
}) })
} }
@@ -37,6 +39,21 @@ export async function runPairCommand(options: PairOptions): Promise<void> {
process.exit(1) process.exit(1)
} }
if (options.json) {
process.stdout.write(
`${JSON.stringify(
{
relayEnabled: pairing.relayEnabled,
url: pairing.url,
qr: pairing.qr,
},
null,
2
)}\n`
)
return
}
const qrBlock = pairing.qr ? `${pairing.qr}\n` : '' const qrBlock = pairing.qr ? `${pairing.qr}\n` : ''
process.stdout.write(`\nScan to pair:\n${qrBlock}${pairing.url}\n`) process.stdout.write(`\nScan to pair:\n${qrBlock}${pairing.url}\n`)
} }

View File

@@ -10,6 +10,17 @@ export interface ConnectOptions {
const DEFAULT_HOST = 'localhost:6767' const DEFAULT_HOST = 'localhost:6767'
const DEFAULT_TIMEOUT = 5000 const DEFAULT_TIMEOUT = 5000
type DaemonTarget =
| {
type: 'tcp'
url: string
}
| {
type: 'ipc'
url: string
socketPath: string
}
/** /**
* Get the daemon host from environment or options * Get the daemon host from environment or options
*/ */
@@ -17,12 +28,49 @@ export function getDaemonHost(options?: ConnectOptions): string {
return options?.host ?? process.env.PASEO_HOST ?? DEFAULT_HOST return options?.host ?? process.env.PASEO_HOST ?? DEFAULT_HOST
} }
export function resolveDaemonTarget(host: string): DaemonTarget {
const trimmed = host.trim()
if (
trimmed.startsWith('unix://') ||
trimmed.startsWith('pipe://') ||
trimmed.startsWith('\\\\.\\pipe\\')
) {
const socketPath = trimmed.startsWith('unix://')
? trimmed.slice('unix://'.length).trim()
: trimmed.startsWith('pipe://')
? trimmed.slice('pipe://'.length).trim()
: trimmed
if (!socketPath) {
throw new Error('Invalid IPC daemon target: missing socket path')
}
const isUnixSocket = trimmed.startsWith('unix://')
return {
type: 'ipc',
url: isUnixSocket
? `ws+unix://${socketPath}:/ws`
: 'ws://localhost/ws',
socketPath,
}
}
return {
type: 'tcp',
url: `ws://${trimmed}/ws`,
}
}
/** /**
* Create a WebSocket factory that works in Node.js * Create a WebSocket factory that works in Node.js
*/ */
function createNodeWebSocketFactory() { function createNodeWebSocketFactory() {
return (url: string, options?: { headers?: Record<string, string> }) => { return (
return new WebSocket(url, { headers: options?.headers }) as unknown as { url: string,
options?: { headers?: Record<string, string>; socketPath?: string }
) => {
return new WebSocket(url, {
headers: options?.headers,
...(options?.socketPath ? { socketPath: options.socketPath } : {}),
}) as unknown as {
readyState: number readyState: number
send: (data: string | Uint8Array | ArrayBuffer) => void send: (data: string | Uint8Array | ArrayBuffer) => void
close: (code?: number, reason?: string) => void close: (code?: number, reason?: string) => void
@@ -41,14 +89,19 @@ export async function connectToDaemon(options?: ConnectOptions): Promise<DaemonC
const host = getDaemonHost(options) const host = getDaemonHost(options)
const timeout = options?.timeout ?? DEFAULT_TIMEOUT const timeout = options?.timeout ?? DEFAULT_TIMEOUT
const clientId = await getOrCreateCliClientId() const clientId = await getOrCreateCliClientId()
const url = `ws://${host}/ws` const target = resolveDaemonTarget(host)
const nodeWebSocketFactory = createNodeWebSocketFactory()
const client = new DaemonClient( const client = new DaemonClient(
{ {
url, url: target.url,
clientId, clientId,
clientType: 'cli', clientType: 'cli',
webSocketFactory: createNodeWebSocketFactory(), webSocketFactory: (url: string, config?: { headers?: Record<string, string> }) =>
nodeWebSocketFactory(url, {
headers: config?.headers,
...(target.type === 'ipc' ? { socketPath: target.socketPath } : {}),
}),
reconnect: { enabled: false }, reconnect: { enabled: false },
} as unknown as ConstructorParameters<typeof DaemonClient>[0] } as unknown as ConstructorParameters<typeof DaemonClient>[0]
) )

View File

@@ -65,9 +65,22 @@ try {
console.log('✓ daemon status reports stopped when not running\n') console.log('✓ daemon status reports stopped when not running\n')
} }
// Test 4: daemon status --json outputs valid JSON // Test 4: daemon pair --json outputs valid JSON
{ {
console.log('Test 4: daemon status --json outputs JSON') console.log('Test 4: daemon pair --json outputs JSON')
const result =
await $`PASEO_HOME=${paseoHome} npx paseo daemon pair --json`.nothrow()
assert.strictEqual(result.exitCode, 0, 'daemon pair --json should succeed')
const pairing = JSON.parse(result.stdout)
assert.strictEqual(pairing.relayEnabled, true, 'pairing should report relay enabled')
assert.match(pairing.url, /#offer=/, 'pairing URL should include offer fragment')
assert.strictEqual(typeof pairing.qr, 'string', 'pairing should include QR content')
console.log('✓ daemon pair --json outputs valid JSON\n')
}
// Test 5: daemon status --json outputs valid JSON
{
console.log('Test 5: daemon status --json outputs JSON')
const result = const result =
await $`PASEO_HOME=${paseoHome} npx paseo daemon status --json`.nothrow() await $`PASEO_HOME=${paseoHome} npx paseo daemon status --json`.nothrow()
assert.strictEqual(result.exitCode, 0, '--json status should succeed') assert.strictEqual(result.exitCode, 0, '--json status should succeed')
@@ -77,9 +90,9 @@ try {
console.log('✓ daemon status --json outputs valid JSON\n') console.log('✓ daemon status --json outputs valid JSON\n')
} }
// Test 5: daemon stop handles daemon not running gracefully // Test 6: daemon stop handles daemon not running gracefully
{ {
console.log('Test 5: daemon stop handles daemon not running') console.log('Test 6: daemon stop handles daemon not running')
const result = const result =
await $`PASEO_HOME=${paseoHome} npx paseo daemon stop`.nothrow() await $`PASEO_HOME=${paseoHome} npx paseo daemon stop`.nothrow()
// Stop should succeed even if daemon is not running (idempotent). // Stop should succeed even if daemon is not running (idempotent).
@@ -92,9 +105,9 @@ try {
console.log('✓ daemon stop succeeds gracefully when daemon not running\n') console.log('✓ daemon stop succeeds gracefully when daemon not running\n')
} }
// Test 6: daemon restart starts daemon and can be stopped // Test 7: daemon restart starts daemon and can be stopped
{ {
console.log('Test 6: daemon restart starts daemon and can be stopped') console.log('Test 7: daemon restart starts daemon and can be stopped')
const result = const result =
await $`PASEO_HOME=${paseoHome} npx paseo daemon restart --port ${String(port)}`.nothrow() await $`PASEO_HOME=${paseoHome} npx paseo daemon restart --port ${String(port)}`.nothrow()
assert.strictEqual(result.exitCode, 0, 'restart should succeed even when previously stopped') assert.strictEqual(result.exitCode, 0, 'restart should succeed even when previously stopped')

View File

@@ -29,6 +29,8 @@ console.log('=== Local Daemon Utility Helpers ===\n')
console.log('Test 3: rejects unix socket listen values') console.log('Test 3: rejects unix socket listen values')
assert.strictEqual(resolveTcpHostFromListen('/tmp/paseo.sock'), null) assert.strictEqual(resolveTcpHostFromListen('/tmp/paseo.sock'), null)
assert.strictEqual(resolveTcpHostFromListen('unix:///tmp/paseo.sock'), null) assert.strictEqual(resolveTcpHostFromListen('unix:///tmp/paseo.sock'), null)
assert.strictEqual(resolveTcpHostFromListen('pipe://\\\\.\\pipe\\paseo-managed-test'), null)
assert.strictEqual(resolveTcpHostFromListen('\\\\.\\pipe\\paseo-managed-test'), null)
console.log('✓ rejects unix socket listen values\n') console.log('✓ rejects unix socket listen values\n')
} }

View File

@@ -0,0 +1,30 @@
#!/usr/bin/env npx tsx
import assert from 'node:assert'
import { resolveDaemonTarget } from '../src/utils/client.js'
console.log('=== CLI IPC Target Helpers ===\n')
{
console.log('Test 1: unix hosts resolve to ws+unix URLs')
const target = resolveDaemonTarget('unix:///tmp/paseo.sock')
assert.deepStrictEqual(target, {
type: 'ipc',
url: 'ws+unix:///tmp/paseo.sock:/ws',
socketPath: '/tmp/paseo.sock',
})
console.log('✓ unix hosts resolve to ws+unix URLs\n')
}
{
console.log('Test 2: pipe hosts preserve the Node socketPath transport form')
const target = resolveDaemonTarget('pipe://\\\\.\\pipe\\paseo-managed-test')
assert.deepStrictEqual(target, {
type: 'ipc',
url: 'ws://localhost/ws',
socketPath: '\\\\.\\pipe\\paseo-managed-test',
})
console.log('✓ pipe hosts preserve Node socketPath transport form\n')
}
console.log('=== All CLI IPC target tests passed ===')

View File

@@ -4,8 +4,11 @@
"private": true, "private": true,
"description": "Paseo desktop app (Tauri wrapper)", "description": "Paseo desktop app (Tauri wrapper)",
"scripts": { "scripts": {
"dev": "tauri dev", "build:managed-runtime": "node ./scripts/build-managed-runtime.mjs",
"build": "tauri build", "prepare:managed-runtime": "npm --prefix ../.. run build:daemon && npm run build:managed-runtime",
"dev": "npm run prepare:managed-runtime && tauri dev",
"build": "npm --prefix ../.. run build:web --workspace=@getpaseo/app && npm run prepare:managed-runtime && tauri build",
"smoke:managed-daemon": "node ./scripts/managed-daemon-smoke.mjs",
"tauri": "tauri" "tauri": "tauri"
}, },
"devDependencies": { "devDependencies": {

View File

@@ -0,0 +1,383 @@
import { createHash } from "node:crypto";
import { spawnSync } from "node:child_process";
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import process from "node:process";
import { fileURLToPath } from "node:url";
const repoRoot = path.resolve(fileURLToPath(new URL("../../..", import.meta.url)));
const desktopRoot = path.join(repoRoot, "packages", "desktop");
const resourcesRoot = path.join(desktopRoot, "src-tauri", "resources", "managed-runtime");
const cacheRoot = path.join(desktopRoot, ".cache", "managed-runtime");
const packageVersion = JSON.parse(
await fs.readFile(path.join(desktopRoot, "package.json"), "utf8")
).version;
const workspaces = [
{ name: "@getpaseo/relay", root: path.join(repoRoot, "packages", "relay") },
{ name: "@getpaseo/server", root: path.join(repoRoot, "packages", "server") },
{ name: "@getpaseo/cli", root: path.join(repoRoot, "packages", "cli") },
];
async function rmSafe(target) {
await fs.rm(target, { recursive: true, force: true });
}
async function ensureDir(target) {
await fs.mkdir(target, { recursive: true });
}
async function copyDir(source, target) {
await ensureDir(path.dirname(target));
await fs.cp(source, target, { recursive: true, dereference: true, force: true });
}
async function copyFile(source, target, mode) {
await ensureDir(path.dirname(target));
await fs.copyFile(source, target);
if (mode != null) {
await fs.chmod(target, mode);
}
}
async function pathExists(target) {
try {
await fs.access(target);
return true;
} catch {
return false;
}
}
async function readToolVersions() {
const raw = await fs.readFile(path.join(repoRoot, ".tool-versions"), "utf8");
const entries = new Map();
for (const line of raw.split(/\r?\n/)) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith("#")) {
continue;
}
const [tool, ...rest] = trimmed.split(/\s+/);
if (!tool || rest.length === 0) {
continue;
}
entries.set(tool, rest.join(" "));
}
return entries;
}
function resolveNodeVersion(toolVersions) {
const raw = toolVersions.get("nodejs");
if (!raw) {
throw new Error("Missing nodejs entry in .tool-versions.");
}
const version = raw.trim().replace(/^v/, "");
if (!/^\d+\.\d+\.\d+$/.test(version)) {
throw new Error(`Unsupported nodejs version in .tool-versions: ${raw}`);
}
return version;
}
function resolveNodeArtifact(version) {
const platformMap = {
darwin: "darwin",
linux: "linux",
win32: "win",
};
const archMap = {
arm64: "arm64",
x64: "x64",
};
const platform = platformMap[process.platform];
const arch = archMap[process.arch];
if (!platform || !arch) {
throw new Error(`Managed runtime is not implemented for ${process.platform}-${process.arch}.`);
}
const baseName = `node-v${version}-${platform}-${arch}`;
const extension = process.platform === "win32" ? "zip" : process.platform === "linux" ? "tar.xz" : "tar.gz";
return {
version,
baseName,
archiveName: `${baseName}.${extension}`,
extension,
downloadUrl: `https://nodejs.org/dist/v${version}/${baseName}.${extension}`,
checksumsUrl: `https://nodejs.org/dist/v${version}/SHASUMS256.txt`,
};
}
async function downloadFile(url, target) {
await ensureDir(path.dirname(target));
runCommand("curl", [
"--fail",
"--location",
"--retry",
"3",
"--silent",
"--show-error",
"--output",
target,
url,
]);
}
async function sha256File(target) {
const hash = createHash("sha256");
hash.update(await fs.readFile(target));
return hash.digest("hex");
}
async function computeRuntimeContentHash(input) {
const hash = createHash("sha256");
hash.update(`packageVersion:${packageVersion}\n`);
hash.update(`nodeVersion:${input.nodeVersion}\n`);
hash.update(`platform:${process.platform}\n`);
hash.update(`arch:${process.arch}\n`);
for (const tarball of input.tarballs) {
hash.update(`workspace:${tarball.name}\n`);
hash.update(`filename:${path.basename(tarball.path)}\n`);
hash.update(`sha256:${await sha256File(tarball.path)}\n`);
}
return hash.digest("hex").slice(0, 12);
}
async function ensureNodeArchive(nodeArtifact) {
const versionCacheRoot = path.join(cacheRoot, `node-v${nodeArtifact.version}`);
const archivePath = path.join(versionCacheRoot, nodeArtifact.archiveName);
const checksumsPath = path.join(versionCacheRoot, "SHASUMS256.txt");
if (!(await pathExists(archivePath))) {
await downloadFile(nodeArtifact.downloadUrl, archivePath);
}
if (!(await pathExists(checksumsPath))) {
await downloadFile(nodeArtifact.checksumsUrl, checksumsPath);
}
const checksums = await fs.readFile(checksumsPath, "utf8");
const expectedLine = checksums
.split(/\r?\n/)
.map((line) => line.trim())
.find((line) => line.endsWith(` ${nodeArtifact.archiveName}`));
if (!expectedLine) {
throw new Error(`Missing checksum for ${nodeArtifact.archiveName} in SHASUMS256.txt`);
}
const expectedSha = expectedLine.split(/\s+/)[0];
const actualSha = await sha256File(archivePath);
if (actualSha !== expectedSha) {
throw new Error(
`Checksum mismatch for ${nodeArtifact.archiveName}: expected ${expectedSha}, got ${actualSha}`
);
}
return archivePath;
}
function runCommand(command, args, options = {}) {
const result = spawnSync(command, args, {
stdio: "pipe",
encoding: "utf8",
...options,
});
if (result.status !== 0) {
throw new Error(
[
`Command failed: ${command} ${args.join(" ")}`,
result.stdout?.trim(),
result.stderr?.trim(),
]
.filter(Boolean)
.join("\n")
);
}
return result;
}
async function extractNodeDistribution(archivePath, nodeArtifact) {
const extractionRoot = await fs.mkdtemp(path.join(os.tmpdir(), "paseo-managed-runtime-node-"));
if (nodeArtifact.extension === "zip") {
runCommand("powershell", [
"-NoProfile",
"-Command",
`Expand-Archive -LiteralPath '${archivePath.replace(/'/g, "''")}' -DestinationPath '${extractionRoot.replace(/'/g, "''")}' -Force`,
]);
} else {
const tarFlag = nodeArtifact.extension === "tar.xz" ? "-xJf" : "-xzf";
runCommand("tar", [tarFlag, archivePath, "-C", extractionRoot]);
}
return {
extractionRoot,
extractedRoot: path.join(extractionRoot, nodeArtifact.baseName),
};
}
async function ensureWorkspaceBuilds() {
const requiredPaths = [
path.join(repoRoot, "packages", "relay", "dist"),
path.join(repoRoot, "packages", "server", "dist"),
path.join(repoRoot, "packages", "cli", "dist"),
];
for (const requiredPath of requiredPaths) {
if (!(await pathExists(requiredPath))) {
throw new Error(
`Managed runtime build is missing required path: ${requiredPath}. Run the daemon build first.`
);
}
}
}
async function packWorkspace(packageRoot, tarballRoot) {
await ensureDir(tarballRoot);
const result = runCommand("npm", ["pack", "--json", "--pack-destination", tarballRoot], {
cwd: packageRoot,
});
const [{ filename }] = JSON.parse(result.stdout.trim());
if (!filename) {
throw new Error(`npm pack did not produce a filename for ${packageRoot}`);
}
return path.join(tarballRoot, filename);
}
async function installPackedWorkspaces(runtimeRoot, bundledNodeRoot, tarballs) {
const nodeExecutable = process.platform === "win32"
? path.join(bundledNodeRoot, "node.exe")
: path.join(bundledNodeRoot, "bin", "node");
const npmCli = process.platform === "win32"
? path.join(bundledNodeRoot, "node_modules", "npm", "bin", "npm-cli.js")
: path.join(bundledNodeRoot, "lib", "node_modules", "npm", "bin", "npm-cli.js");
const install = spawnSync(
nodeExecutable,
[
npmCli,
"install",
"--omit=dev",
"--no-package-lock",
"--no-save",
...tarballs,
],
{
cwd: runtimeRoot,
stdio: "inherit",
env: {
...process.env,
npm_config_audit: "false",
npm_config_fund: "false",
},
}
);
if (install.status !== 0) {
throw new Error(
`Managed runtime dependency install failed with exit code ${install.status ?? 1}.`
);
}
}
async function writeRuntimePackageJson(runtimeRoot) {
await fs.writeFile(
path.join(runtimeRoot, "package.json"),
JSON.stringify(
{
name: "paseo-managed-runtime",
private: true,
version: packageVersion,
},
null,
2
) + "\n",
"utf8"
);
}
async function main() {
await ensureWorkspaceBuilds();
const toolVersions = await readToolVersions();
const nodeVersion = resolveNodeVersion(toolVersions);
const nodeArtifact = resolveNodeArtifact(nodeVersion);
await rmSafe(resourcesRoot);
await ensureDir(resourcesRoot);
const archivePath = await ensureNodeArchive(nodeArtifact);
const { extractionRoot, extractedRoot } = await extractNodeDistribution(archivePath, nodeArtifact);
const tarballRoot = await fs.mkdtemp(path.join(os.tmpdir(), "paseo-managed-runtime-pack-"));
try {
const tarballs = [];
for (const workspace of workspaces) {
tarballs.push({
name: workspace.name,
path: await packWorkspace(workspace.root, tarballRoot),
});
}
const runtimeContentHash = await computeRuntimeContentHash({
nodeVersion,
tarballs,
});
const runtimeId =
`${packageVersion}-node-${nodeVersion}-${process.platform}-${process.arch}-${runtimeContentHash}`;
const runtimeRoot = path.join(resourcesRoot, runtimeId);
await ensureDir(runtimeRoot);
await copyDir(extractedRoot, path.join(runtimeRoot, "node"));
await writeRuntimePackageJson(runtimeRoot);
await installPackedWorkspaces(
runtimeRoot,
path.join(runtimeRoot, "node"),
tarballs.map((entry) => entry.path)
);
const nodeRelativePath = process.platform === "win32"
? path.join("node", "node.exe")
: path.join("node", "bin", "node");
const manifest = {
runtimeId,
runtimeVersion: packageVersion,
platform: process.platform,
arch: process.arch,
createdAt: new Date().toISOString(),
nodeRelativePath,
cliEntrypointRelativePath: path.join(
"node_modules",
"@getpaseo",
"cli",
"dist",
"index.js"
),
cliShimRelativePath: path.join("node_modules", "@getpaseo", "cli", "bin", "paseo"),
serverRunnerRelativePath: path.join(
"node_modules",
"@getpaseo",
"server",
"dist",
"scripts",
"daemon-runner.js"
),
};
await fs.writeFile(
path.join(runtimeRoot, "runtime-manifest.json"),
JSON.stringify(manifest, null, 2) + "\n",
"utf8"
);
await fs.writeFile(
path.join(resourcesRoot, "current-runtime.json"),
JSON.stringify(
{
runtimeId,
runtimeVersion: packageVersion,
relativeRoot: runtimeId,
},
null,
2
) + "\n",
"utf8"
);
if (process.platform !== "win32") {
await fs.chmod(path.join(runtimeRoot, nodeRelativePath), 0o755);
}
} finally {
await rmSafe(extractionRoot);
await rmSafe(tarballRoot);
}
}
await main();

View File

@@ -0,0 +1,746 @@
import assert from "node:assert/strict";
import { Buffer } from "node:buffer";
import { execFile, spawn } from "node:child_process";
import fs from "node:fs/promises";
import net from "node:net";
import os from "node:os";
import path from "node:path";
import { promisify } from "node:util";
import WebSocket from "ws";
import { createClientChannel } from "@getpaseo/relay/e2ee";
const execFileAsync = promisify(execFile);
const repoRoot = path.resolve(new URL("../../..", import.meta.url).pathname);
const desktopRoot = path.join(repoRoot, "packages", "desktop");
const relayRoot = path.join(repoRoot, "packages", "relay");
const desktopPackageJson = JSON.parse(
await fs.readFile(path.join(desktopRoot, "package.json"), "utf8")
);
const currentRuntimeVersion = desktopPackageJson.version;
const currentRuntimePointer = JSON.parse(
await fs.readFile(
path.join(desktopRoot, "src-tauri", "resources", "managed-runtime", "current-runtime.json"),
"utf8"
)
);
const currentRuntimeId = currentRuntimePointer.runtimeId;
function buildRuntimeId(version) {
return currentRuntimeId.replace(currentRuntimeVersion, version);
}
function resolvePackagedBinary() {
if (process.platform === "darwin") {
return path.join(
desktopRoot,
"src-tauri",
"target",
"release",
"bundle",
"macos",
"Paseo.app",
"Contents",
"MacOS",
"Paseo"
);
}
if (process.platform === "linux") {
return path.join(
desktopRoot,
"src-tauri",
"target",
"release",
"bundle",
"appimage",
`Paseo_${desktopPackageJson.version}_amd64.AppImage`
);
}
throw new Error(`Managed desktop smoke is not implemented for ${process.platform} yet.`);
}
async function pathExists(target) {
try {
await fs.access(target);
return true;
} catch {
return false;
}
}
async function snapshotTree(root) {
if (!(await pathExists(root))) {
return [];
}
const entries = [];
async function walk(current) {
const stat = await fs.stat(current);
const relative = path.relative(root, current) || ".";
entries.push({
relative,
kind: stat.isDirectory() ? "dir" : "file",
size: stat.size,
mtimeMs: stat.mtimeMs,
});
if (!stat.isDirectory()) {
return;
}
const children = await fs.readdir(current);
children.sort();
for (const child of children) {
await walk(path.join(current, child));
}
}
await walk(root);
entries.sort((left, right) => left.relative.localeCompare(right.relative));
return entries;
}
async function sleep(ms) {
await new Promise((resolve) => setTimeout(resolve, ms));
}
function escapeForRegExp(value) {
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
function derivePreviousVersion(version) {
const match = version.match(/^(\d+)\.(\d+)\.(\d+)$/);
if (!match) {
return `${version}-previous`;
}
const [, major, minor, patch] = match;
const numericPatch = Number.parseInt(patch, 10);
if (numericPatch <= 0) {
return `${major}.${minor}.0-previous`;
}
return `${major}.${minor}.${numericPatch - 1}`;
}
function buildSeededTransportPath(testRoot) {
if (process.platform === "win32") {
return String.raw`\\.\pipe\paseo-managed-seeded-upgrade`;
}
return path.join(testRoot, "managed-home", "run", "paseo.sock");
}
async function seedPreviousManagedRuntime(testRoot) {
const previousRuntimeVersion = derivePreviousVersion(currentRuntimeVersion);
const previousRuntimeId = buildRuntimeId(previousRuntimeVersion);
const previousRuntimeRoot = path.join(testRoot, "runtime", previousRuntimeId);
const stateFilePath = path.join(testRoot, "managed-state.json");
const managedHome = path.join(testRoot, "managed-home");
const transportType = process.platform === "win32" ? "pipe" : "socket";
const transportPath = buildSeededTransportPath(testRoot);
await fs.mkdir(previousRuntimeRoot, { recursive: true });
await fs.writeFile(
path.join(previousRuntimeRoot, "runtime-manifest.json"),
JSON.stringify(
{
runtimeId: previousRuntimeId,
runtimeVersion: previousRuntimeVersion,
platform: process.platform,
arch: process.arch,
createdAt: "2000-01-01T00:00:00.000Z",
nodeRelativePath: process.platform === "win32" ? path.join("node", "node.exe") : path.join("node", "node"),
cliEntrypointRelativePath: path.join("node_modules", "@getpaseo", "cli", "dist", "index.js"),
cliShimRelativePath: path.join("node_modules", "@getpaseo", "cli", "bin", "paseo"),
serverRunnerRelativePath: path.join(
"node_modules",
"@getpaseo",
"server",
"dist",
"scripts",
"daemon-runner.js"
),
},
null,
2
) + "\n",
"utf8"
);
await fs.writeFile(path.join(previousRuntimeRoot, "seed-marker.txt"), "version-n\n", "utf8");
await fs.writeFile(
stateFilePath,
JSON.stringify(
{
runtimeId: previousRuntimeId,
runtimeRoot: previousRuntimeRoot,
managedHome,
transportType,
transportPath,
tcpEnabled: false,
tcpListen: null,
cliShimPath: null,
},
null,
2
) + "\n",
"utf8"
);
return {
previousRuntimeId,
previousRuntimeVersion,
previousRuntimeRoot,
stateFilePath,
};
}
async function runBinary(binaryPath, args, env) {
const { stdout, stderr } = await execFileAsync(binaryPath, args, {
env,
cwd: repoRoot,
maxBuffer: 10 * 1024 * 1024,
});
const trimmed = stdout.trim();
return {
stdout,
stderr,
json: trimmed ? JSON.parse(trimmed) : null,
};
}
async function runWorkspaceCli(args, env) {
const { stdout, stderr } = await execFileAsync(
process.execPath,
[path.join(repoRoot, "packages", "cli", "dist", "index.js"), ...args],
{
cwd: repoRoot,
env: {
...process.env,
...env,
},
maxBuffer: 10 * 1024 * 1024,
},
);
let json = null;
if (stdout.trim()) {
try {
json = JSON.parse(stdout.trim());
} catch {
json = null;
}
}
return { stdout, stderr, json };
}
async function readDaemonStatus(home, env) {
const result = await runWorkspaceCli(["daemon", "status", "--home", home, "--json"], env);
return result.json ?? {};
}
async function pidListeningOnPort(port) {
try {
const { stdout } = await execFileAsync("lsof", ["-ti", `tcp:${port}`], {
maxBuffer: 1024 * 1024,
});
const pid = Number.parseInt(stdout.trim().split("\n")[0] ?? "", 10);
return Number.isFinite(pid) ? pid : null;
} catch {
return null;
}
}
async function getAvailablePort() {
return await new Promise((resolve, reject) => {
const server = net.createServer();
server.unref();
server.on("error", reject);
server.listen(0, "127.0.0.1", () => {
const address = server.address();
if (!address || typeof address === "string") {
server.close(() => reject(new Error("Failed to allocate an ephemeral test port.")));
return;
}
const { port } = address;
server.close((error) => {
if (error) {
reject(error);
return;
}
resolve(port);
});
});
});
}
function buildRelayWebSocketUrl({ endpoint, serverId, role }) {
const url = new URL(`ws://${endpoint}/ws`);
url.searchParams.set("serverId", serverId);
url.searchParams.set("role", role);
url.searchParams.set("v", "2");
return url.toString();
}
function parseOfferUrlFromCommandOutput(stdout) {
const match = stdout.match(/https?:\/\/\S+#offer=\S+/);
if (!match) {
throw new Error(`Failed to find pairing URL in output: ${stdout}`);
}
return match[0];
}
function decodeOfferFromFragmentUrl(url) {
const marker = "#offer=";
const index = url.indexOf(marker);
if (index === -1) {
throw new Error(`Pairing URL is missing ${marker}: ${url}`);
}
const encoded = url.slice(index + marker.length);
const raw = Buffer.from(encoded, "base64url").toString("utf8");
const offer = JSON.parse(raw);
if (offer?.v !== 2 || typeof offer?.serverId !== "string" || typeof offer?.daemonPublicKeyB64 !== "string") {
throw new Error(`Unexpected relay offer payload: ${raw}`);
}
return offer;
}
async function waitForRelayWebSocketReady(endpoint, timeoutMs) {
await waitFor(async () => {
const probeServerId = `probe-${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`;
const url = buildRelayWebSocketUrl({
endpoint,
serverId: probeServerId,
role: "server",
});
const opened = await new Promise((resolve) => {
const ws = new WebSocket(url);
const timer = setTimeout(() => {
ws.terminate();
resolve(false);
}, 5_000);
ws.once("open", () => {
clearTimeout(timer);
ws.close(1000, "probe");
resolve(true);
});
ws.once("error", () => {
clearTimeout(timer);
resolve(false);
});
});
assert.equal(opened, true);
}, timeoutMs, "relay websocket endpoint to accept connections");
}
async function connectViaRelay(endpoint, offer) {
const stableClientId = `cid-managed-smoke-${Date.now().toString(36)}`;
const ws = new WebSocket(
buildRelayWebSocketUrl({
endpoint,
serverId: offer.serverId,
role: "client",
})
);
return await new Promise((resolve, reject) => {
const timeout = setTimeout(() => {
ws.close();
reject(new Error("Timed out waiting for managed relay pong."));
}, 20_000);
const transport = {
send: (data) => ws.send(data),
close: (code, reason) => ws.close(code, reason),
onmessage: null,
onclose: null,
onerror: null,
};
ws.on("message", (data) => {
transport.onmessage?.(typeof data === "string" ? data : data.toString());
});
ws.on("close", (code, reason) => {
transport.onclose?.(code, reason.toString());
});
ws.on("error", (error) => {
transport.onerror?.(error);
});
ws.on("open", async () => {
try {
let pingSent = false;
let channelRef = null;
const channel = await createClientChannel(transport, offer.daemonPublicKeyB64, {
onmessage: (data) => {
try {
const payload = typeof data === "string" ? JSON.parse(data) : data;
if (payload?.type === "welcome") {
if (!pingSent && channelRef) {
pingSent = true;
void channelRef.send(JSON.stringify({ type: "ping" }));
}
return;
}
if (payload?.type === "pong") {
clearTimeout(timeout);
resolve(payload);
ws.close();
}
} catch (error) {
clearTimeout(timeout);
reject(error);
}
},
onerror: (error) => {
clearTimeout(timeout);
reject(error);
},
});
channelRef = channel;
await channel.send(
JSON.stringify({
type: "hello",
clientId: stableClientId,
clientType: "cli",
protocolVersion: 1,
})
);
} catch (error) {
clearTimeout(timeout);
reject(error);
}
});
});
}
function assertNoForbiddenPathsOrPorts(value, forbidden) {
const text =
typeof value === "string" ? value : JSON.stringify(value, null, 2);
for (const candidate of forbidden) {
assert.ok(
!text.includes(candidate),
`Unexpected forbidden managed smoke reference: ${candidate}`
);
}
}
async function ensurePackagedArtifact(binaryPath) {
try {
await execFileAsync("npm", ["run", "build"], {
cwd: desktopRoot,
env: process.env,
maxBuffer: 20 * 1024 * 1024,
});
} catch (error) {
const combined = `${error.stdout ?? ""}\n${error.stderr ?? ""}`;
const signingBlocked =
combined.includes("TAURI_SIGNING_PRIVATE_KEY") &&
(await pathExists(binaryPath));
if (!signingBlocked) {
throw error;
}
console.warn(
"[managed-smoke] continuing after Tauri updater signing failure because the packaged app artifact was already produced"
);
}
}
async function waitFor(assertion, timeoutMs, label) {
const startedAt = Date.now();
while (Date.now() - startedAt < timeoutMs) {
try {
return await assertion();
} catch {
await sleep(250);
}
}
throw new Error(`Timed out waiting for ${label}`);
}
function logStep(label) {
console.log(`\n[managed-smoke] ${label}`);
}
const packagedBinary = resolvePackagedBinary();
await ensurePackagedArtifact(packagedBinary);
if (!(await pathExists(packagedBinary))) {
throw new Error(`Packaged desktop artifact not found: ${packagedBinary}`);
}
const tmpRoot = await fs.mkdtemp(path.join(os.tmpdir(), "paseo-desktop-smoke-"));
const testRoot = path.join(tmpRoot, "managed-test-root");
const fakeHome = path.join(tmpRoot, "fake-home");
const fakePaseoHome = path.join(fakeHome, ".paseo");
const cliScratchHome = path.join(tmpRoot, "cli-scratch-home");
const externalHome = path.join(tmpRoot, "external-daemon-home");
const externalPort = await getAvailablePort();
const externalEndpoint = `127.0.0.1:${externalPort}`;
const relayPort = await getAvailablePort();
const relayEndpoint = `127.0.0.1:${relayPort}`;
await fs.mkdir(testRoot, { recursive: true });
await fs.mkdir(fakePaseoHome, { recursive: true });
await fs.mkdir(cliScratchHome, { recursive: true });
await fs.mkdir(externalHome, { recursive: true });
await fs.writeFile(path.join(fakePaseoHome, "sentinel.txt"), "do not touch\n", "utf8");
const fakePaseoSnapshotBefore = await snapshotTree(fakePaseoHome);
const seededUpgradeState = await seedPreviousManagedRuntime(testRoot);
const managedEnv = {
...process.env,
HOME: fakeHome,
PASEO_HOME: cliScratchHome,
PASEO_DESKTOP_TEST_ROOT: testRoot,
PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD: "0",
PASEO_DICTATION_ENABLED: "0",
PASEO_VOICE_MODE_ENABLED: "0",
PASEO_RELAY_ENABLED: "true",
PASEO_RELAY_ENDPOINT: relayEndpoint,
PASEO_RELAY_PUBLIC_ENDPOINT: relayEndpoint,
PASEO_APP_BASE_URL: "https://app.paseo.test",
PASEO_PRIMARY_LAN_IP: "192.168.1.12",
CI: "true",
};
let externalPid = null;
let startedTemporaryExternalDaemon = false;
let relayProcess = null;
const forbiddenManagedReferences = ["127.0.0.1:6767", fakePaseoHome];
try {
logStep(`Starting isolated local relay on ${relayEndpoint}`);
relayProcess = spawn(process.platform === "win32" ? "npx.cmd" : "npx", [
"wrangler",
"dev",
"--local",
"--ip",
"127.0.0.1",
"--port",
String(relayPort),
"--live-reload=false",
"--show-interactive-dev-session=false",
], {
cwd: relayRoot,
env: process.env,
stdio: ["ignore", "pipe", "pipe"],
});
relayProcess.stdout?.on("data", (chunk) => {
process.stdout.write(`[managed-smoke relay] ${chunk}`);
});
relayProcess.stderr?.on("data", (chunk) => {
process.stderr.write(`[managed-smoke relay] ${chunk}`);
});
await waitFor(async () => {
await execFileAsync(process.execPath, ["-e", `require("node:net").connect(${relayPort}, "127.0.0.1").on("connect", function () { this.end(); process.exit(0); }).on("error", () => process.exit(1));`]);
}, 30_000, "relay HTTP endpoint to start");
await waitForRelayWebSocketReady(relayEndpoint, 60_000);
logStep(`Starting isolated external daemon on ${externalEndpoint}`);
startedTemporaryExternalDaemon = true;
await runWorkspaceCli(
["start", "--home", externalHome, "--listen", externalEndpoint, "--no-relay"],
managedEnv
);
const externalStatus = await waitFor(
async () => {
const status = await readDaemonStatus(externalHome, managedEnv);
assert.equal(status.status, "running");
assert.ok(typeof status.pid === "number" && status.pid > 0, "expected external daemon pid");
await runWorkspaceCli(["ls", "--host", externalEndpoint, "--json"], managedEnv);
return status;
},
15_000,
"external daemon to start"
);
externalPid = externalStatus.pid;
assert.ok(externalPid, "external daemon pid should be present");
logStep("Bootstrapping managed runtime from packaged desktop binary");
const runtimeStatus = await runBinary(packagedBinary, ["--managed-headless", "runtime-status"], managedEnv);
assert.equal(await pathExists(seededUpgradeState.previousRuntimeRoot), true);
assert.equal(runtimeStatus.json.runtimeId, currentRuntimeId);
assert.equal(runtimeStatus.json.runtimeVersion, currentRuntimeVersion);
assert.match(runtimeStatus.json.managedHome, new RegExp(`^${escapeForRegExp(testRoot)}`));
assert.equal(
runtimeStatus.json.installedRuntimeRoot,
path.join(testRoot, "runtime", currentRuntimeId),
"managed runtime should install under the current bundled runtime id"
);
assert.notEqual(
runtimeStatus.json.installedRuntimeRoot,
seededUpgradeState.previousRuntimeRoot,
"packaged app should adopt the bundled runtime instead of the seeded previous runtime"
);
assert.ok(runtimeStatus.json.transportType === "socket" || runtimeStatus.json.transportType === "pipe");
assert.notEqual(runtimeStatus.json.transportPath, "127.0.0.1:6767");
assertNoForbiddenPathsOrPorts(runtimeStatus.json, forbiddenManagedReferences);
const stateAfterRuntimeInstall = JSON.parse(
await fs.readFile(seededUpgradeState.stateFilePath, "utf8")
);
assert.equal(stateAfterRuntimeInstall.runtimeId, currentRuntimeId);
assert.equal(stateAfterRuntimeInstall.runtimeRoot, runtimeStatus.json.installedRuntimeRoot);
assert.equal(await pathExists(seededUpgradeState.previousRuntimeRoot), true);
const managedStart = await runBinary(packagedBinary, ["--managed-headless", "bootstrap"], managedEnv);
assert.equal(managedStart.json.daemonRunning, true);
assert.ok(managedStart.json.daemonPid, "managed daemon pid should exist");
assert.ok(
managedStart.json.transportType === "socket" || managedStart.json.transportType === "pipe",
"managed daemon should default to private IPC transport"
);
assert.notEqual(managedStart.json.transportPath, "127.0.0.1:6767");
assertNoForbiddenPathsOrPorts(managedStart.json, forbiddenManagedReferences);
const managedPid = managedStart.json.daemonPid;
const stateFile = path.join(testRoot, "managed-state.json");
assert.equal(await pathExists(stateFile), true, "managed state file should be written");
logStep("Verifying the managed daemon stays alive after the packaged command exits");
await sleep(1_500);
const persistedManagedStatus = await runBinary(
packagedBinary,
["--managed-headless", "daemon-status"],
managedEnv
);
assert.equal(persistedManagedStatus.json.daemonPid, managedPid);
assert.equal(persistedManagedStatus.json.daemonRunning, true);
assert.equal(persistedManagedStatus.json.relayEnabled, true);
assertNoForbiddenPathsOrPorts(persistedManagedStatus.json, forbiddenManagedReferences);
logStep("Installing CLI shim and verifying it targets the managed daemon");
const cliInstall = await runBinary(
packagedBinary,
["--managed-headless", "install-cli-shim"],
managedEnv
);
const cliShimPath = cliInstall.json.path;
assert.ok(cliShimPath, "CLI shim path should be returned");
assert.equal(await pathExists(cliShimPath), true, "CLI shim should exist");
const cliVersion = await execFileAsync(cliShimPath, ["--version"], {
env: managedEnv,
cwd: repoRoot,
maxBuffer: 1024 * 1024,
});
assert.match(cliVersion.stdout.trim(), /^0\./);
const shimStatus = await execFileAsync(cliShimPath, ["daemon", "status", "--json"], {
env: managedEnv,
cwd: repoRoot,
maxBuffer: 1024 * 1024,
});
const shimDaemonStatus = JSON.parse(shimStatus.stdout.trim());
assert.equal(shimDaemonStatus.pid, managedPid);
assertNoForbiddenPathsOrPorts(shimDaemonStatus, forbiddenManagedReferences);
logStep("Verifying relay connectivity still works after the desktop command has exited");
const relayPairing = await execFileAsync(
cliShimPath,
["daemon", "pair", "--home", managedStart.json.managedHome],
{
env: managedEnv,
cwd: repoRoot,
maxBuffer: 4 * 1024 * 1024,
}
);
const relayOfferUrl = parseOfferUrlFromCommandOutput(relayPairing.stdout);
const relayOffer = decodeOfferFromFragmentUrl(relayOfferUrl);
assert.equal(relayOffer.relay?.endpoint, relayEndpoint);
const relayPong = await connectViaRelay(relayEndpoint, relayOffer);
assert.deepEqual(relayPong, { type: "pong" });
logStep("Reopening packaged desktop command path without spawning duplicate daemons");
const managedRestartless = await runBinary(
packagedBinary,
["--managed-headless", "bootstrap"],
managedEnv
);
assert.equal(managedRestartless.json.daemonPid, managedPid);
logStep("Verifying managed and external daemons coexist");
const externalStatusAfter = await readDaemonStatus(externalHome, managedEnv);
const externalPidAfter = externalStatusAfter.pid ?? null;
assert.equal(externalStatusAfter.status, "running");
assert.equal(externalPidAfter, externalPid);
await runWorkspaceCli(["ls", "--host", externalEndpoint, "--json"], managedEnv);
const managedStatus = await runBinary(
packagedBinary,
["--managed-headless", "daemon-status"],
managedEnv
);
assert.ok(managedStatus.json.serverId, "managed daemon should expose a server id");
assertNoForbiddenPathsOrPorts(managedStatus.json, forbiddenManagedReferences);
logStep("Enabling managed TCP exposure on an explicit non-default port");
const tcpEnabled = await runBinary(
packagedBinary,
[
"--managed-headless",
"update-tcp",
"--enabled",
"true",
"--host",
"127.0.0.1",
"--port",
"7771",
],
managedEnv
);
assert.equal(tcpEnabled.json.tcpEnabled, true);
assert.equal(tcpEnabled.json.transportType, "tcp");
assert.equal(tcpEnabled.json.transportPath, "127.0.0.1:7771");
assert.notEqual(tcpEnabled.json.transportPath, "127.0.0.1:6767");
assertNoForbiddenPathsOrPorts(tcpEnabled.json, [fakePaseoHome]);
logStep("Disabling managed TCP exposure and returning to private transport");
const tcpDisabled = await runBinary(
packagedBinary,
[
"--managed-headless",
"update-tcp",
"--enabled",
"false",
"--host",
"127.0.0.1",
"--port",
"7771",
],
managedEnv
);
assert.equal(tcpDisabled.json.tcpEnabled, false);
assert.notEqual(tcpDisabled.json.transportType, "tcp");
assertNoForbiddenPathsOrPorts(tcpDisabled.json, forbiddenManagedReferences);
logStep("Capturing diagnostics and verifying the fake ~/.paseo stayed untouched");
const fakePaseoSnapshotAfter = await snapshotTree(fakePaseoHome);
assert.deepEqual(fakePaseoSnapshotAfter, fakePaseoSnapshotBefore);
await fs.writeFile(
path.join(testRoot, "managed-daemon-smoke-diagnostics.json"),
JSON.stringify(
{
runtimeStatus: runtimeStatus.json,
managedStart: managedStart.json,
seededUpgradeState,
stateAfterRuntimeInstall,
persistedManagedStatus: persistedManagedStatus.json,
managedRestartless: managedRestartless.json,
cliInstall: cliInstall.json,
shimDaemonStatus,
relayEndpoint,
relayOfferUrl,
relayPong,
externalEndpoint,
externalPid,
externalPidAfter,
managedStatus: managedStatus.json,
tcpEnabled: tcpEnabled.json,
tcpDisabled: tcpDisabled.json,
},
null,
2
) + "\n",
"utf8"
);
console.log(`\n[managed-smoke] PASS (${testRoot})`);
} finally {
try {
await runBinary(packagedBinary, ["--managed-headless", "stop-daemon"], managedEnv);
} catch {}
try {
await runBinary(packagedBinary, ["--managed-headless", "uninstall-cli-shim"], managedEnv);
} catch {}
try {
if (startedTemporaryExternalDaemon) {
await runWorkspaceCli(["daemon", "stop", "--home", externalHome, "--json"]);
}
} catch {}
try {
relayProcess?.kill("SIGTERM");
} catch {}
}

View File

@@ -2632,6 +2632,9 @@ name = "paseo"
version = "0.1.18" version = "0.1.18"
dependencies = [ dependencies = [
"base64 0.22.1", "base64 0.22.1",
"dirs",
"futures-util",
"http",
"log", "log",
"serde", "serde",
"serde_json", "serde_json",
@@ -2643,6 +2646,8 @@ dependencies = [
"tauri-plugin-opener", "tauri-plugin-opener",
"tauri-plugin-updater", "tauri-plugin-updater",
"tauri-plugin-websocket", "tauri-plugin-websocket",
"tokio",
"tokio-tungstenite 0.24.0",
] ]
[[package]] [[package]]
@@ -4342,7 +4347,7 @@ dependencies = [
"tauri-plugin", "tauri-plugin",
"thiserror 2.0.18", "thiserror 2.0.18",
"tokio", "tokio",
"tokio-tungstenite", "tokio-tungstenite 0.28.0",
] ]
[[package]] [[package]]
@@ -4604,6 +4609,18 @@ dependencies = [
"tokio", "tokio",
] ]
[[package]]
name = "tokio-tungstenite"
version = "0.24.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "edc5f74e248dc973e0dbb7b74c7e0d6fcc301c694ff50049504004ef4d0cdcd9"
dependencies = [
"futures-util",
"log",
"tokio",
"tungstenite 0.24.0",
]
[[package]] [[package]]
name = "tokio-tungstenite" name = "tokio-tungstenite"
version = "0.28.0" version = "0.28.0"
@@ -4616,7 +4633,7 @@ dependencies = [
"rustls-pki-types", "rustls-pki-types",
"tokio", "tokio",
"tokio-rustls", "tokio-rustls",
"tungstenite", "tungstenite 0.28.0",
"webpki-roots 0.26.11", "webpki-roots 0.26.11",
] ]
@@ -4833,6 +4850,24 @@ version = "0.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
[[package]]
name = "tungstenite"
version = "0.24.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "18e5b8366ee7a95b16d32197d0b2604b43a0be89dc5fac9f8e96ccafbaedda8a"
dependencies = [
"byteorder",
"bytes",
"data-encoding",
"http",
"httparse",
"log",
"rand 0.8.5",
"sha1",
"thiserror 1.0.69",
"utf-8",
]
[[package]] [[package]]
name = "tungstenite" name = "tungstenite"
version = "0.28.0" version = "0.28.0"

View File

@@ -23,6 +23,9 @@ tauri-build = { version = "2.5.3", features = [] }
[dependencies] [dependencies]
base64 = "0.22" base64 = "0.22"
dirs = "6"
futures-util = "0.3"
http = "1"
serde_json = "1.0" serde_json = "1.0"
serde = { version = "1.0", features = ["derive"] } serde = { version = "1.0", features = ["derive"] }
log = "0.4" log = "0.4"
@@ -33,7 +36,8 @@ tauri-plugin-notification = "2"
tauri-plugin-opener = "2" tauri-plugin-opener = "2"
tauri-plugin-updater = "2" tauri-plugin-updater = "2"
tauri-plugin-websocket = "2" tauri-plugin-websocket = "2"
tokio = { version = "1", features = ["net", "sync"] }
tokio-tungstenite = "0.24"

View File

@@ -0,0 +1 @@

View File

@@ -1,5 +1,6 @@
use base64::{engine::general_purpose::STANDARD as BASE64_STANDARD, Engine as _}; use base64::{engine::general_purpose::STANDARD as BASE64_STANDARD, Engine as _};
use serde::Serialize; use serde::Serialize;
use serde_json::json;
use std::collections::HashSet; use std::collections::HashSet;
use std::fs; use std::fs;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
@@ -11,6 +12,17 @@ use tauri::menu::AboutMetadata;
use tauri::{AppHandle, Manager, WebviewWindow}; use tauri::{AppHandle, Manager, WebviewWindow};
use tauri_plugin_updater::UpdaterExt; use tauri_plugin_updater::UpdaterExt;
mod runtime_manager;
pub use runtime_manager::try_run_cli_shim_from_args;
use runtime_manager::{
close_local_daemon_transport, ensure_managed_runtime, install_cli_shim,
managed_daemon_logs, managed_daemon_pairing, managed_daemon_status, managed_runtime_status,
open_local_daemon_transport, restart_managed_daemon,
send_local_daemon_transport_message, start_managed_daemon, stop_managed_daemon,
uninstall_cli_shim, update_managed_daemon_tcp_settings, ManagedTcpSettings,
LocalTransportState,
};
// Store zoom as u64 bits (f64 * 100 as integer for atomic ops) // Store zoom as u64 bits (f64 * 100 as integer for atomic ops)
static ZOOM_LEVEL: AtomicU64 = AtomicU64::new(100); static ZOOM_LEVEL: AtomicU64 = AtomicU64::new(100);
@@ -64,6 +76,138 @@ struct AttachmentFileResult {
byte_size: u64, byte_size: u64,
} }
#[derive(Debug, Clone)]
enum ManagedHeadlessCommand {
RuntimeStatus,
Bootstrap,
DaemonStatus,
StopDaemon,
InstallCliShim,
UninstallCliShim,
UpdateTcp(ManagedTcpSettings),
}
fn parse_managed_headless_command() -> Result<Option<ManagedHeadlessCommand>, String> {
let args = std::env::args().collect::<Vec<_>>();
let Some(flag_index) = args.iter().position(|value| value == "--managed-headless") else {
return Ok(None);
};
let command = args
.get(flag_index + 1)
.ok_or_else(|| "Missing command after --managed-headless".to_string())?;
let tail = args.iter().skip(flag_index + 2).cloned().collect::<Vec<_>>();
match command.as_str() {
"runtime-status" => Ok(Some(ManagedHeadlessCommand::RuntimeStatus)),
"bootstrap" => Ok(Some(ManagedHeadlessCommand::Bootstrap)),
"daemon-status" => Ok(Some(ManagedHeadlessCommand::DaemonStatus)),
"stop-daemon" => Ok(Some(ManagedHeadlessCommand::StopDaemon)),
"install-cli-shim" => Ok(Some(ManagedHeadlessCommand::InstallCliShim)),
"uninstall-cli-shim" => Ok(Some(ManagedHeadlessCommand::UninstallCliShim)),
"update-tcp" => {
let mut enabled = false;
let mut host = "127.0.0.1".to_string();
let mut port = 7771_u16;
let mut index = 0_usize;
while index < tail.len() {
match tail[index].as_str() {
"--enabled" => {
enabled = tail
.get(index + 1)
.ok_or_else(|| "Missing value after --enabled".to_string())?
.parse::<bool>()
.map_err(|error| format!("Invalid --enabled value: {error}"))?;
index += 2;
}
"--host" => {
host = tail
.get(index + 1)
.ok_or_else(|| "Missing value after --host".to_string())?
.to_string();
index += 2;
}
"--port" => {
port = tail
.get(index + 1)
.ok_or_else(|| "Missing value after --port".to_string())?
.parse::<u16>()
.map_err(|error| format!("Invalid --port value: {error}"))?;
index += 2;
}
other => {
return Err(format!("Unknown --managed-headless update-tcp option: {other}"));
}
}
}
Ok(Some(ManagedHeadlessCommand::UpdateTcp(ManagedTcpSettings {
enabled,
host,
port,
})))
}
other => Err(format!("Unknown --managed-headless command: {other}")),
}
}
fn maybe_run_managed_headless_command(app: &AppHandle) -> Result<bool, String> {
let Some(command) = parse_managed_headless_command()? else {
return Ok(false);
};
if let Some(window) = app.get_webview_window("main") {
let _ = window.hide();
}
let app_handle = app.clone();
tauri::async_runtime::spawn(async move {
let result = async {
let payload = match command {
ManagedHeadlessCommand::RuntimeStatus => {
json!(managed_runtime_status(app_handle.clone()).await?)
}
ManagedHeadlessCommand::Bootstrap => {
ensure_managed_runtime(app_handle.clone()).await?;
json!(start_managed_daemon(app_handle.clone()).await?)
}
ManagedHeadlessCommand::DaemonStatus => {
json!(managed_daemon_status(app_handle.clone()).await?)
}
ManagedHeadlessCommand::StopDaemon => {
json!(stop_managed_daemon(app_handle.clone()).await?)
}
ManagedHeadlessCommand::InstallCliShim => {
json!(install_cli_shim(app_handle.clone()).await?)
}
ManagedHeadlessCommand::UninstallCliShim => {
json!(uninstall_cli_shim(app_handle.clone()).await?)
}
ManagedHeadlessCommand::UpdateTcp(settings) => {
json!(update_managed_daemon_tcp_settings(app_handle.clone(), settings).await?)
}
};
Ok::<serde_json::Value, String>(payload)
}
.await;
match result {
Ok(payload) => {
println!(
"{}",
serde_json::to_string_pretty(&payload)
.unwrap_or_else(|error| format!(r#"{{"error":"{error}"}}"#))
);
app_handle.exit(0);
}
Err(error) => {
eprintln!(
"{}",
serde_json::to_string_pretty(&json!({ "error": error }))
.unwrap_or_else(|_| r#"{"error":"managed headless command failed"}"#.to_string())
);
app_handle.exit(1);
}
}
});
Ok(true)
}
fn resolve_login_shell() -> String { fn resolve_login_shell() -> String {
std::env::var("SHELL") std::env::var("SHELL")
.ok() .ok()
@@ -440,12 +584,27 @@ async fn garbage_collect_attachment_files(
#[cfg_attr(mobile, tauri::mobile_entry_point)] #[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() { pub fn run() {
tauri::Builder::default() tauri::Builder::default()
.manage(LocalTransportState::default())
.plugin(tauri_plugin_dialog::init()) .plugin(tauri_plugin_dialog::init())
.plugin(tauri_plugin_notification::init()) .plugin(tauri_plugin_notification::init())
.plugin(tauri_plugin_opener::init()) .plugin(tauri_plugin_opener::init())
.plugin(tauri_plugin_updater::Builder::new().build()) .plugin(tauri_plugin_updater::Builder::new().build())
.plugin(tauri_plugin_websocket::init()) .plugin(tauri_plugin_websocket::init())
.invoke_handler(tauri::generate_handler![ .invoke_handler(tauri::generate_handler![
managed_runtime_status,
ensure_managed_runtime,
managed_daemon_status,
start_managed_daemon,
stop_managed_daemon,
restart_managed_daemon,
managed_daemon_logs,
managed_daemon_pairing,
install_cli_shim,
uninstall_cli_shim,
update_managed_daemon_tcp_settings,
open_local_daemon_transport,
send_local_daemon_transport_message,
close_local_daemon_transport,
get_local_daemon_version, get_local_daemon_version,
run_local_daemon_update, run_local_daemon_update,
check_app_update, check_app_update,
@@ -457,6 +616,10 @@ pub fn run() {
garbage_collect_attachment_files garbage_collect_attachment_files
]) ])
.setup(|app| { .setup(|app| {
if maybe_run_managed_headless_command(app.handle())? {
return Ok(());
}
if cfg!(debug_assertions) { if cfg!(debug_assertions) {
app.handle().plugin( app.handle().plugin(
tauri_plugin_log::Builder::default() tauri_plugin_log::Builder::default()

View File

@@ -8,5 +8,9 @@
tauri::embed_plist::embed_info_plist!("../Info.plist"); tauri::embed_plist::embed_info_plist!("../Info.plist");
fn main() { fn main() {
if let Err(error) = paseo_lib::try_run_cli_shim_from_args() {
eprintln!("{error}");
std::process::exit(1);
}
paseo_lib::run(); paseo_lib::run();
} }

File diff suppressed because it is too large Load Diff

View File

@@ -33,6 +33,9 @@
"bundle": { "bundle": {
"active": true, "active": true,
"createUpdaterArtifacts": true, "createUpdaterArtifacts": true,
"resources": [
"resources/**/*"
],
"targets": "all", "targets": "all",
"icon": [ "icon": [
"icons/32x32.png", "icons/32x32.png",

View File

@@ -1,10 +1,11 @@
import os from "node:os"; import os from "node:os";
import path from "node:path"; import path from "node:path";
import { mkdir, mkdtemp, rm } from "node:fs/promises"; import { mkdir, mkdtemp, rm } from "node:fs/promises";
import { Writable } from "node:stream";
import pino from "pino"; import pino from "pino";
import { describe, expect, test } from "vitest"; import { describe, expect, test } from "vitest";
import { createPaseoDaemon, type PaseoDaemonConfig } from "./bootstrap.js"; import { createPaseoDaemon, parseListenString, type PaseoDaemonConfig } from "./bootstrap.js";
import { createTestPaseoDaemon } from "./test-utils/paseo-daemon.js"; import { createTestPaseoDaemon } from "./test-utils/paseo-daemon.js";
import { createTestAgentClients } from "./test-utils/fake-agent-client.js"; import { createTestAgentClients } from "./test-utils/fake-agent-client.js";
@@ -75,4 +76,65 @@ describe("paseo daemon bootstrap", () => {
await rm(staticDir, { recursive: true, force: true }); await rm(staticDir, { recursive: true, force: true });
} }
}); });
test("parses Windows named pipes as managed IPC listen targets", () => {
expect(parseListenString(String.raw`\\.\pipe\paseo-managed-test`)).toEqual({
type: "pipe",
path: String.raw`\\.\pipe\paseo-managed-test`,
});
expect(parseListenString(`pipe://${String.raw`\\.\pipe\paseo-managed-test`}`)).toEqual({
type: "pipe",
path: String.raw`\\.\pipe\paseo-managed-test`,
});
});
test("emits a relay pairing offer for unix socket listeners", async () => {
const paseoHomeRoot = await mkdtemp(path.join(os.tmpdir(), "paseo-socket-relay-"));
const paseoHome = path.join(paseoHomeRoot, ".paseo");
const staticDir = await mkdtemp(path.join(os.tmpdir(), "paseo-static-"));
const socketPath = path.join(paseoHomeRoot, "run", "paseo.sock");
await mkdir(path.dirname(socketPath), { recursive: true });
await mkdir(paseoHome, { recursive: true });
const lines: string[] = [];
const logger = pino(
{ level: "info" },
new Writable({
write(chunk, _encoding, callback) {
lines.push(chunk.toString("utf8"));
callback();
},
})
);
const config: PaseoDaemonConfig = {
listen: socketPath,
paseoHome,
corsAllowedOrigins: [],
allowedHosts: true,
mcpEnabled: false,
staticDir,
mcpDebug: false,
agentClients: createTestAgentClients(),
agentStoragePath: path.join(paseoHome, "agents"),
relayEnabled: true,
relayEndpoint: "127.0.0.1:9",
relayPublicEndpoint: "127.0.0.1:9",
appBaseUrl: "https://app.paseo.sh",
openai: undefined,
speech: undefined,
};
const daemon = await createPaseoDaemon(config, logger);
try {
await daemon.start();
expect(lines.some((line) => line.includes('"msg":"pairing_offer"'))).toBe(true);
} finally {
await daemon.stop().catch(() => undefined);
await daemon.agentManager.flush().catch(() => undefined);
await rm(paseoHomeRoot, { recursive: true, force: true });
await rm(staticDir, { recursive: true, force: true });
}
});
}); });

View File

@@ -11,9 +11,16 @@ import type { Logger } from "pino";
type ListenTarget = type ListenTarget =
| { type: "tcp"; host: string; port: number } | { type: "tcp"; host: string; port: number }
| { type: "socket"; path: string }; | { type: "socket"; path: string }
| { type: "pipe"; path: string };
function parseListenString(listen: string): ListenTarget { export function parseListenString(listen: string): ListenTarget {
if (listen.startsWith("\\\\.\\pipe\\") || listen.startsWith("pipe://")) {
return {
type: "pipe",
path: listen.startsWith("pipe://") ? listen.slice("pipe://".length) : listen,
};
}
// Unix socket: starts with / or ~ or contains .sock // Unix socket: starts with / or ~ or contains .sock
if (listen.startsWith("/") || listen.startsWith("~") || listen.includes(".sock")) { if (listen.startsWith("/") || listen.startsWith("~") || listen.includes(".sock")) {
return { type: "socket", path: listen }; return { type: "socket", path: listen };
@@ -570,46 +577,44 @@ export async function createPaseoDaemon(
const onListening = () => { const onListening = () => {
httpServer.off("error", onError); httpServer.off("error", onError);
const logAndResolve = async () => { const logAndResolve = async () => {
const relayEnabled = config.relayEnabled ?? true;
const relayEndpoint = config.relayEndpoint ?? "relay.paseo.sh:443";
const relayPublicEndpoint = config.relayPublicEndpoint ?? relayEndpoint;
const appBaseUrl = config.appBaseUrl ?? "https://app.paseo.sh";
if (listenTarget.type === "tcp") { if (listenTarget.type === "tcp") {
logger.info( logger.info(
{ host: listenTarget.host, port: listenTarget.port }, { host: listenTarget.host, port: listenTarget.port },
`Server listening on http://${listenTarget.host}:${listenTarget.port}` `Server listening on http://${listenTarget.host}:${listenTarget.port}`
); );
const relayEnabled = config.relayEnabled ?? true;
const relayEndpoint = config.relayEndpoint ?? "relay.paseo.sh:443";
const relayPublicEndpoint = config.relayPublicEndpoint ?? relayEndpoint;
const appBaseUrl = config.appBaseUrl ?? "https://app.paseo.sh";
if (relayEnabled) {
const offer = await createConnectionOfferV2({
serverId,
daemonPublicKeyB64: daemonKeyPair.publicKeyB64,
relay: { endpoint: relayPublicEndpoint },
});
const url = encodeOfferToFragmentUrl({ offer, appBaseUrl });
logger.info({ url }, "pairing_offer");
}
if (relayEnabled) {
relayTransport?.stop().catch(() => undefined);
relayTransport = startRelayTransport({
logger,
attachSocket: (ws, metadata) => {
if (!wsServer) {
throw new Error("WebSocket server not initialized");
}
return wsServer.attachExternalSocket(ws, metadata);
},
relayEndpoint,
serverId,
daemonKeyPair: daemonKeyPair.keyPair,
});
}
} else { } else {
logger.info({ path: listenTarget.path }, `Server listening on ${listenTarget.path}`); logger.info({ path: listenTarget.path }, `Server listening on ${listenTarget.path}`);
} }
if (relayEnabled) {
const offer = await createConnectionOfferV2({
serverId,
daemonPublicKeyB64: daemonKeyPair.publicKeyB64,
relay: { endpoint: relayPublicEndpoint },
});
const url = encodeOfferToFragmentUrl({ offer, appBaseUrl });
logger.info({ url }, "pairing_offer");
relayTransport?.stop().catch(() => undefined);
relayTransport = startRelayTransport({
logger,
attachSocket: (ws, metadata) => {
if (!wsServer) {
throw new Error("WebSocket server not initialized");
}
return wsServer.attachExternalSocket(ws, metadata);
},
relayEndpoint,
serverId,
daemonKeyPair: daemonKeyPair.keyPair,
});
}
}; };
logAndResolve().then(resolve, reject); logAndResolve().then(resolve, reject);
@@ -620,8 +625,7 @@ export async function createPaseoDaemon(
if (listenTarget.type === "tcp") { if (listenTarget.type === "tcp") {
httpServer.listen(listenTarget.port, listenTarget.host); httpServer.listen(listenTarget.port, listenTarget.host);
} else { } else {
// Remove stale socket file if it exists if (listenTarget.type === "socket" && existsSync(listenTarget.path)) {
if (existsSync(listenTarget.path)) {
unlinkSync(listenTarget.path); unlinkSync(listenTarget.path);
} }
httpServer.listen(listenTarget.path); httpServer.listen(listenTarget.path);