diff --git a/.github/workflows/desktop-release.yml b/.github/workflows/desktop-release.yml index fe3150ed3..efb874660 100644 --- a/.github/workflows/desktop-release.yml +++ b/.github/workflows/desktop-release.yml @@ -3,12 +3,12 @@ name: Desktop Release on: push: tags: - - 'v*' - - 'desktop-v*' + - "v*" + - "desktop-v*" workflow_dispatch: inputs: tag: - description: 'Existing tag to build (e.g. v0.1.0)' + description: "Existing tag to build (e.g. v0.1.0)" required: true type: string @@ -16,14 +16,15 @@ concurrency: group: desktop-release-${{ github.ref }} cancel-in-progress: false +env: + RELEASE_TAG: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.tag || github.ref_name }} + jobs: - publish-tauri: + publish-macos: permissions: contents: write packages: read runs-on: macos-latest - env: - RELEASE_TAG: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.tag || github.ref_name }} steps: - uses: actions/checkout@v4 @@ -34,10 +35,10 @@ jobs: - name: Setup Node uses: actions/setup-node@v4 with: - node-version: '22' - cache: 'npm' - registry-url: 'https://npm.pkg.github.com' - scope: '@boudra' + node-version: "22" + cache: "npm" + registry-url: "https://npm.pkg.github.com" + scope: "@boudra" - name: Install Rust stable uses: dtolnay/rust-toolchain@stable @@ -91,7 +92,7 @@ jobs: fs.writeFileSync(cargoTomlPath, `${nextLines.join('\n')}\n`); NODE - - name: Build and publish Tauri release + - name: Build and publish macOS Tauri release uses: tauri-apps/tauri-action@v0 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} @@ -111,3 +112,178 @@ jobs: releaseDraft: false prerelease: false 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 diff --git a/.gitignore b/.gitignore index fffee8989..ebb97acae 100644 --- a/.gitignore +++ b/.gitignore @@ -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 /artifacts +packages/desktop/.cache/ +packages/desktop/src-tauri/resources/managed-runtime/ diff --git a/packages/app/src/components/add-host-modal.tsx b/packages/app/src/components/add-host-modal.tsx index fbb879554..17aaad552 100644 --- a/packages/app/src/components/add-host-modal.tsx +++ b/packages/app/src/components/add-host-modal.tsx @@ -179,7 +179,11 @@ export function AddHostModal({ visible, onClose, onCancel, onSaved, targetServer setIsSaving(true); 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) { const message = `That endpoint belongs to ${serverId}, not ${targetServerId}.`; setErrorMessage(message); diff --git a/packages/app/src/contexts/daemon-registry-context.test.ts b/packages/app/src/contexts/daemon-registry-context.test.ts index f5b3c3b17..d44a9b6cd 100644 --- a/packages/app/src/contexts/daemon-registry-context.test.ts +++ b/packages/app/src/contexts/daemon-registry-context.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest' import { hostHasDirectEndpoint, registryHasDirectEndpoint, + reconcileDesktopStartupRegistry, type HostProfile, } from './daemon-registry-context' @@ -10,6 +11,12 @@ function makeHost(input: Partial & Pick): return { serverId: input.serverId, label: input.label ?? input.serverId, + lifecycle: input.lifecycle ?? { + managed: false, + managedRuntimeId: null, + managedRuntimeVersion: null, + associatedServerId: null, + }, connections: input.connections ?? [], preferredConnectionId: input.preferredConnectionId ?? null, createdAt: input.createdAt ?? now, @@ -21,7 +28,7 @@ describe('hostHasDirectEndpoint', () => { it('returns true when host has matching direct endpoint', () => { const host = makeHost({ 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', }) @@ -51,12 +58,12 @@ describe('registryHasDirectEndpoint', () => { const hosts: HostProfile[] = [ makeHost({ 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', }), makeHost({ 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', }), ] @@ -68,7 +75,7 @@ describe('registryHasDirectEndpoint', () => { const hosts: HostProfile[] = [ makeHost({ 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', }), ] @@ -76,3 +83,160 @@ describe('registryHasDirectEndpoint', () => { 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) + }) +}) diff --git a/packages/app/src/contexts/daemon-registry-context.tsx b/packages/app/src/contexts/daemon-registry-context.tsx index 24f17a188..b510c4c92 100644 --- a/packages/app/src/contexts/daemon-registry-context.tsx +++ b/packages/app/src/contexts/daemon-registry-context.tsx @@ -5,32 +5,64 @@ import { useQuery, useQueryClient } from '@tanstack/react-query' import { decodeOfferFragmentPayload, normalizeHostPort } from '@/utils/daemon-endpoints' import { probeConnection } from '@/utils/test-daemon-connection' 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 DAEMON_REGISTRY_QUERY_KEY = ['daemon-registry'] const DEFAULT_LOCALHOST_ENDPOINT = 'localhost:6767' const DEFAULT_LOCALHOST_BOOTSTRAP_KEY = '@paseo:default-localhost-bootstrap-v1' 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' -export type DirectHostConnection = { +export type DirectTcpHostConnection = { id: string - type: 'direct' - endpoint: string // host:port + type: 'directTcp' + endpoint: string +} + +export type DirectSocketHostConnection = { + id: string + type: 'directSocket' + path: string +} + +export type DirectPipeHostConnection = { + id: string + type: 'directPipe' + path: string } export type RelayHostConnection = { id: string type: 'relay' - relayEndpoint: string // host:port + relayEndpoint: 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 = { serverId: string label: string + lifecycle: HostLifecycle connections: HostConnection[] preferredConnectionId: string | null createdAt: string @@ -39,6 +71,29 @@ export type HostProfile = { export type UpdateHostInput = Partial> +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 { daemons: HostProfile[] isLoading: boolean @@ -63,6 +118,20 @@ interface DaemonRegistryContextValue { const DaemonRegistryContext = createContext(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 { try { return normalizeHostPort(endpoint) @@ -71,8 +140,389 @@ function normalizeEndpointOrNull(endpoint: string): string | null { } } -function isDefaultLocalhostConnection(connection: HostConnection): boolean { - return connection.type === 'direct' && connection.endpoint === DEFAULT_LOCALHOST_ENDPOINT +function sleep(ms: number): Promise { + 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 + 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) : 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 + 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 + 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 { + 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 { + 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 { @@ -81,7 +531,7 @@ export function hostHasDirectEndpoint(host: HostProfile, endpoint: string): bool return false } 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 }) { const queryClient = useQueryClient() + const desktopStartupReconciledRef = useRef(false) const localhostBootstrapAttemptedRef = useRef(false) const { data: daemons = [], @@ -123,10 +574,6 @@ export function DaemonRegistryProvider({ children }: { children: ReactNode }) { return queryClient.getQueryData(DAEMON_REGISTRY_QUERY_KEY) ?? daemons }, [queryClient, daemons]) - const markDefaultLocalhostBootstrapHandled = useCallback(async () => { - await AsyncStorage.setItem(DEFAULT_LOCALHOST_BOOTSTRAP_KEY, '1') - }, []) - const updateHost = useCallback( async (serverId: string, updates: UpdateHostInput) => { const next = readDaemons().map((daemon) => @@ -146,23 +593,15 @@ export function DaemonRegistryProvider({ children }: { children: ReactNode }) { const removeHost = useCallback( async (serverId: string) => { const existing = readDaemons() - const removedHost = existing.find((daemon) => daemon.serverId === serverId) ?? null const remaining = existing.filter((daemon) => daemon.serverId !== serverId) await persist(remaining) - if (removedHost && hostHasDirectEndpoint(removedHost, DEFAULT_LOCALHOST_ENDPOINT)) { - await markDefaultLocalhostBootstrapHandled() - } }, - [markDefaultLocalhostBootstrapHandled, persist, readDaemons] + [persist, readDaemons] ) const removeConnection = useCallback( async (serverId: string, connectionId: string) => { const existing = readDaemons() - const removedConnection = - existing - .find((daemon) => daemon.serverId === serverId) - ?.connections.find((connection) => connection.id === connectionId) ?? null const now = new Date().toISOString() const next = existing .map((daemon) => { @@ -184,64 +623,28 @@ export function DaemonRegistryProvider({ children }: { children: ReactNode }) { }) .filter((entry): entry is HostProfile => entry !== null) await persist(next) - if (removedConnection && isDefaultLocalhostConnection(removedConnection)) { - await markDefaultLocalhostBootstrapHandled() - } }, - [markDefaultLocalhostBootstrapHandled, persist, readDaemons] + [persist, readDaemons] ) const upsertHostConnection = useCallback( - async ( - input: { - serverId: string - label?: string - } & ({ connection: DirectHostConnection } | { connection: RelayHostConnection }) - ) => { - const existing = readDaemons() + async (input: { + serverId: string + label?: string + lifecycle?: Partial + connection: HostConnection + }) => { const now = new Date().toISOString() - const serverId = input.serverId.trim() - if (!serverId) { - throw new Error('serverId is required') - } - - const labelTrimmed = input.label?.trim() ?? '' - const derivedLabel = labelTrimmed || serverId - - 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 + const next = upsertHostConnectionInProfiles({ + profiles: readDaemons(), + serverId: input.serverId, + label: input.label, + lifecycle: input.lifecycle, + connection: input.connection, + now, + }) await persist(next) - return nextProfile + return next.find((daemon) => daemon.serverId === input.serverId) as HostProfile }, [persist, readDaemons] ) @@ -249,15 +652,14 @@ export function DaemonRegistryProvider({ children }: { children: ReactNode }) { const upsertDirectConnection = useCallback( async (input: { serverId: string; endpoint: string; label?: string }) => { const endpoint = normalizeHostPort(input.endpoint) - const connection: DirectHostConnection = { - id: `direct:${endpoint}`, - type: 'direct', - endpoint, - } return upsertHostConnection({ serverId: input.serverId, label: input.label, - connection, + connection: { + id: `direct:${endpoint}`, + type: 'directTcp', + endpoint, + }, }) }, [upsertHostConnection] @@ -265,24 +667,114 @@ export function DaemonRegistryProvider({ children }: { children: ReactNode }) { useEffect(() => { 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 localhostBootstrapAttemptedRef.current = true let cancelled = false - const bootstrapDefaultLocalhost = async () => { + const bootstrapLocalhost = async () => { try { - const [isE2E, alreadyHandled] = await Promise.all([ - AsyncStorage.getItem(E2E_STORAGE_KEY), - AsyncStorage.getItem(DEFAULT_LOCALHOST_BOOTSTRAP_KEY), - ]) - if (cancelled || isE2E || alreadyHandled) { + const isE2E = await AsyncStorage.getItem(E2E_STORAGE_KEY) + if (cancelled || isE2E) { + return + } + + const alreadyHandled = await AsyncStorage.getItem(DEFAULT_LOCALHOST_BOOTSTRAP_KEY) + if (cancelled || alreadyHandled) { return } const existing = readDaemons() if (registryHasDirectEndpoint(existing, DEFAULT_LOCALHOST_ENDPOINT)) { - await markDefaultLocalhostBootstrapHandled() + await AsyncStorage.setItem(DEFAULT_LOCALHOST_BOOTSTRAP_KEY, '1') return } @@ -290,7 +782,7 @@ export function DaemonRegistryProvider({ children }: { children: ReactNode }) { const { serverId, hostname } = await probeConnection( { id: `bootstrap:${DEFAULT_LOCALHOST_ENDPOINT}`, - type: 'direct', + type: 'directTcp', endpoint: DEFAULT_LOCALHOST_ENDPOINT, }, { timeoutMs: DEFAULT_LOCALHOST_BOOTSTRAP_TIMEOUT_MS } @@ -302,25 +794,26 @@ export function DaemonRegistryProvider({ children }: { children: ReactNode }) { endpoint: DEFAULT_LOCALHOST_ENDPOINT, label: hostname ?? undefined, }) - await markDefaultLocalhostBootstrapHandled() + await AsyncStorage.setItem(DEFAULT_LOCALHOST_BOOTSTRAP_KEY, '1') } catch { // Best-effort bootstrap only; keep startup resilient if localhost isn't reachable. } } catch (bootstrapError) { if (cancelled) return - console.warn( - '[DaemonRegistry] Failed to bootstrap default localhost connection', - bootstrapError - ) + console.warn('[DaemonRegistry] Failed to bootstrap host connections', bootstrapError) } } - void bootstrapDefaultLocalhost() + void bootstrapLocalhost() return () => { cancelled = true } - }, [isPending, markDefaultLocalhostBootstrapHandled, readDaemons, upsertDirectConnection]) + }, [ + isPending, + readDaemons, + upsertDirectConnection, + ]) const upsertRelayConnection = useCallback( async (input: { @@ -334,16 +827,15 @@ export function DaemonRegistryProvider({ children }: { children: ReactNode }) { if (!daemonPublicKeyB64) { throw new Error('daemonPublicKeyB64 is required') } - const connection: RelayHostConnection = { - id: `relay:${relayEndpoint}`, - type: 'relay', - relayEndpoint, - daemonPublicKeyB64, - } return upsertHostConnection({ serverId: input.serverId, label: input.label, - connection, + connection: { + id: `relay:${relayEndpoint}`, + type: 'relay', + relayEndpoint, + daemonPublicKeyB64, + }, }) }, [upsertHostConnection] @@ -394,108 +886,21 @@ export function DaemonRegistryProvider({ children }: { children: ReactNode }) { return {children} } -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 - 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 { try { const stored = await AsyncStorage.getItem(REGISTRY_STORAGE_KEY) - if (stored) { - const parsed = JSON.parse(stored) as unknown - 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 - } + if (!stored) { + return [] } - 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) { console.error('[DaemonRegistry] Failed to load daemon registry', error) throw error diff --git a/packages/app/src/desktop/components/desktop-updates-section.tsx b/packages/app/src/desktop/components/desktop-updates-section.tsx index 848366d9c..a92a4bd38 100644 --- a/packages/app/src/desktop/components/desktop-updates-section.tsx +++ b/packages/app/src/desktop/components/desktop-updates-section.tsx @@ -1,64 +1,104 @@ -import { useCallback, useState } from "react"; -import { Alert, Text, View } from "react-native"; +import { useCallback, useEffect, useState } from "react"; +import { ActivityIndicator, Alert, Image, Text, View } from "react-native"; import * as Clipboard from "expo-clipboard"; +import * as QRCode from "qrcode"; import { useFocusEffect } from "@react-navigation/native"; import { StyleSheet } from "react-native-unistyles"; +import { AdaptiveModalSheet, AdaptiveTextInput } from "@/components/adaptive-modal-sheet"; import { Button } from "@/components/ui/button"; import { confirmDialog } from "@/utils/confirm-dialog"; import { - buildDaemonUpdateDiagnostics, formatVersionWithPrefix, - getLocalDaemonVersion, isVersionMismatch, - runLocalDaemonUpdate, - shouldShowDesktopUpdateSection, } 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 { appVersion: string | null; } export function LocalDaemonSection({ appVersion }: LocalDaemonSectionProps) { - const showSection = shouldShowDesktopUpdateSection(); - const [localDaemonVersion, setLocalDaemonVersion] = useState(null); - const [localDaemonVersionError, setLocalDaemonVersionError] = useState(null); - const [isUpdatingLocalDaemon, setIsUpdatingLocalDaemon] = useState(false); - const [localDaemonUpdateMessage, setLocalDaemonUpdateMessage] = useState(null); - const [localDaemonUpdateDiagnostics, setLocalDaemonUpdateDiagnostics] = useState( - null - ); + const showSection = shouldUseManagedDesktopDaemon(); + const [managedStatus, setManagedStatus] = useState(null); + const [statusError, setStatusError] = useState(null); + const [isRestartingDaemon, setIsRestartingDaemon] = useState(false); + const [isInstallingCli, setIsInstallingCli] = useState(false); + const [isSavingTcpSettings, setIsSavingTcpSettings] = useState(false); + const [statusMessage, setStatusMessage] = useState(null); + const [managedLogs, setManagedLogs] = useState(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(null); + const [pairingStatusMessage, setPairingStatusMessage] = useState(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( useCallback(() => { if (!showSection) { return undefined; } - - void getLocalDaemonVersion().then((result) => { - setLocalDaemonVersion(result.version); - setLocalDaemonVersionError(result.error); - }); + void loadManagedStatus(); return undefined; - }, [showSection]) + }, [loadManagedStatus, showSection]) ); - const localDaemonVersionText = formatVersionWithPrefix(localDaemonVersion); - const daemonVersionMismatch = isVersionMismatch(appVersion, localDaemonVersion); - const daemonVersionHint = localDaemonVersionError ?? "Daemon installed on this computer."; + const localDaemonVersionText = formatVersionWithPrefix(managedStatus?.runtimeVersion ?? null); + const daemonVersionMismatch = isVersionMismatch(appVersion, managedStatus?.runtimeVersion ?? null); + 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(() => { if (!showSection) { return; } - if (isUpdatingLocalDaemon) { + if (isRestartingDaemon) { return; } void confirmDialog({ - title: "Update local daemon", + title: "Restart managed daemon", message: - "This updates the Paseo daemon on this computer. A restart is required afterwards.", - confirmLabel: "Update daemon", + "This restarts the desktop-managed daemon using its private managed home and socket.", + confirmLabel: "Restart daemon", cancelLabel: "Cancel", }) .then((confirmed) => { @@ -66,71 +106,201 @@ export function LocalDaemonSection({ appVersion }: LocalDaemonSectionProps) { return; } - setIsUpdatingLocalDaemon(true); - setLocalDaemonUpdateMessage(null); - setLocalDaemonUpdateDiagnostics(null); + setIsRestartingDaemon(true); + setStatusMessage(null); - void runLocalDaemonUpdate() - .then((result) => { - const diagnostics = buildDaemonUpdateDiagnostics(result); - if (result.exitCode !== 0) { - setLocalDaemonUpdateMessage( - `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); - }); + void restartManagedDaemon() + .then((status) => { + setManagedStatus(status); + setStatusMessage("Managed daemon restarted."); + return loadManagedStatus(); }) .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); - setLocalDaemonUpdateMessage( - "Local daemon update failed before completion. Copy diagnostics below to troubleshoot." - ); - setLocalDaemonUpdateDiagnostics( - buildDaemonUpdateDiagnostics({ - exitCode: -1, - stdout: "", - stderr: message, - }) - ); + setStatusMessage(`Managed daemon restart failed: ${message}`); }) .finally(() => { - setIsUpdatingLocalDaemon(false); + setIsRestartingDaemon(false); }); }) .catch((error) => { - console.error("[Settings] Failed to open daemon update confirmation", error); - Alert.alert("Error", "Unable to open the daemon update confirmation dialog."); + console.error("[Settings] Failed to open managed daemon restart confirmation", error); + Alert.alert("Error", "Unable to open the managed daemon restart confirmation dialog."); }); - }, [isUpdatingLocalDaemon, showSection]); + }, [isRestartingDaemon, loadManagedStatus, showSection]); - const handleCopyDaemonDiagnostics = useCallback(() => { - if (!localDaemonUpdateDiagnostics) { + const handleToggleCliShim = useCallback(() => { + 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; } - void Clipboard.setStringAsync(localDaemonUpdateDiagnostics) + void Clipboard.setStringAsync(logPath) .then(() => { - Alert.alert("Copied", "Daemon update diagnostics copied."); + Alert.alert("Copied", "Managed daemon log path copied."); }) .catch((error) => { - console.error("[Settings] Failed to copy daemon update diagnostics", error); - Alert.alert("Error", "Unable to copy diagnostics."); + console.error("[Settings] Failed to copy managed daemon log path", error); + 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) { return null; @@ -138,7 +308,7 @@ export function LocalDaemonSection({ appVersion }: LocalDaemonSectionProps) { return ( - Local daemon + Managed daemon @@ -149,47 +319,309 @@ export function LocalDaemonSection({ appVersion }: LocalDaemonSectionProps) { - Update daemon + Restart daemon - Updates the daemon on this computer only. Requires a restart. + Restarts the desktop-managed daemon without touching `~/.paseo` or `127.0.0.1:6767`. - {localDaemonUpdateMessage ? ( - {localDaemonUpdateMessage} + {statusMessage ? ( + {statusMessage} ) : null} + + + CLI shim + + Installs `paseo` into your user path and points it at the managed daemon by default. + + + + + + + Log file + + {managedLogs?.logPath ?? + managedStatus?.logPath ?? + "Managed daemon log path unavailable."} + + + + {(managedLogs?.logPath ?? managedStatus?.logPath) ? ( + + ) : null} + + + + + + Pair device + + Show the managed daemon QR code and a copyable pairing link for the mobile app. + + + + + + + Advanced TCP + + Default off. Use only if you explicitly want a network listener instead of the + private managed transport. + + + {managedStatus?.tcpEnabled + ? `Enabled on ${managedStatus.tcpListen ?? managedStatus.transportPath}` + : "Disabled"} + + + + {managedStatus?.tcpEnabled ? ( + + ) : null} + + + {daemonVersionMismatch ? ( - 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. ) : null} - {localDaemonUpdateDiagnostics ? ( - - - Daemon update diagnostics - + - - {localDaemonUpdateDiagnostics} + + + + setIsPairingModalOpen(false)} + title="Pair device" + testID="managed-daemon-pairing-dialog" + > + + + + setIsLogsModalOpen(false)} + title="Managed daemon logs" + testID="managed-daemon-logs-dialog" + snapPoints={["70%", "92%"]} + > + + + {managedLogs?.logPath ?? + managedStatus?.logPath ?? + "Managed daemon log path unavailable."} + + + {managedLogs?.contents.length ? managedLogs.contents : "(log file is empty)"} - ) : null} + + + ); +} + +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(null); + const [qrError, setQrError] = useState(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 ( + + + Loading pairing offer… + + ); + } + + if (statusMessage) { + return ( + + {statusMessage} + + ); + } + + if (!pairingOffer?.url) { + return ( + + Pairing offer unavailable. + + ); + } + + return ( + + + Scan this QR code in Paseo, or copy the pairing link below. + + + {qrDataUrl ? ( + + ) : qrError ? ( + QR unavailable: {qrError} + ) : ( + + )} + + Pairing link + + {pairingOffer.url} + + + + ); } @@ -227,6 +659,10 @@ const styles = StyleSheet.create((theme) => ({ flex: 1, marginRight: theme.spacing[3], }, + actionGroup: { + flexDirection: "row", + gap: theme.spacing[2], + }, rowTitle: { color: theme.colors.foreground, fontSize: theme.fontSize.base, @@ -259,28 +695,59 @@ const styles = StyleSheet.create((theme) => ({ color: theme.colors.palette.amber[500], fontSize: theme.fontSize.xs, }, - diagnosticsCard: { - marginTop: theme.spacing[3], + modalBody: { + 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, borderWidth: 1, borderColor: theme.colors.border, - backgroundColor: theme.colors.surface1, - padding: theme.spacing[3], - gap: theme.spacing[2], + backgroundColor: theme.colors.surface0, }, - diagnosticsHeader: { - flexDirection: "row", - alignItems: "center", - justifyContent: "space-between", - gap: theme.spacing[2], + qrImage: { + width: 220, + height: 220, }, - diagnosticsTitle: { + linkLabel: { color: theme.colors.foreground, fontSize: theme.fontSize.sm, }, - diagnosticsText: { + linkText: { + color: theme.colors.foregroundMuted, + fontSize: theme.fontSize.xs, + lineHeight: 18, + }, + logOutput: { color: theme.colors.foregroundMuted, fontSize: theme.fontSize.xs, 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], }, })); diff --git a/packages/app/src/desktop/managed-runtime/managed-runtime.ts b/packages/app/src/desktop/managed-runtime/managed-runtime.ts new file mode 100644 index 000000000..d0acee49c --- /dev/null +++ b/packages/app/src/desktop/managed-runtime/managed-runtime.ts @@ -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 { + 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 { + return parseManagedRuntimeStatus(await invokeDesktopCommand("ensure_managed_runtime")); +} + +export async function getManagedRuntimeStatus(): Promise { + return parseManagedRuntimeStatus(await invokeDesktopCommand("managed_runtime_status")); +} + +export async function getManagedDaemonStatus(): Promise { + return parseManagedDaemonStatus(await invokeDesktopCommand("managed_daemon_status")); +} + +export async function startManagedDaemon(): Promise { + return parseManagedDaemonStatus(await invokeDesktopCommand("start_managed_daemon")); +} + +export async function stopManagedDaemon(): Promise { + return parseManagedDaemonStatus(await invokeDesktopCommand("stop_managed_daemon")); +} + +export async function restartManagedDaemon(): Promise { + return parseManagedDaemonStatus(await invokeDesktopCommand("restart_managed_daemon")); +} + +export async function getManagedDaemonLogs(): Promise { + return parseManagedDaemonLogs(await invokeDesktopCommand("managed_daemon_logs")); +} + +export async function getManagedDaemonPairing(): Promise { + return parseManagedPairingOffer(await invokeDesktopCommand("managed_daemon_pairing")); +} + +export async function installManagedCliShim(): Promise { + return parseCliShimResult(await invokeDesktopCommand("install_cli_shim")); +} + +export async function uninstallManagedCliShim(): Promise { + return parseCliShimResult(await invokeDesktopCommand("uninstall_cli_shim")); +} + +export async function updateManagedDaemonTcpSettings( + settings: ManagedTcpSettings +): Promise { + 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 { + 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 { + const raw = await invokeDesktopCommand("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 { + 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 { + await invokeDesktopCommand("close_local_daemon_transport", { sessionId }); +} diff --git a/packages/app/src/runtime/host-runtime.test.ts b/packages/app/src/runtime/host-runtime.test.ts index c7286c8b3..0eb694f7a 100644 --- a/packages/app/src/runtime/host-runtime.test.ts +++ b/packages/app/src/runtime/host-runtime.test.ts @@ -163,7 +163,7 @@ function makeFetchAgentsEntry(input: { function makeHost(input?: Partial): HostProfile { const direct: HostConnection = { id: "direct:lan:6767", - type: "direct", + type: "directTcp", endpoint: "lan:6767", }; const relay: HostConnection = { @@ -176,6 +176,12 @@ function makeHost(input?: Partial): HostProfile { return { serverId: input?.serverId ?? "srv_test", label: input?.label ?? "test host", + lifecycle: input?.lifecycle ?? { + managed: false, + managedRuntimeId: null, + managedRuntimeVersion: null, + associatedServerId: null, + }, connections: input?.connections ?? [direct, relay], preferredConnectionId: input?.preferredConnectionId ?? direct.id, createdAt: input?.createdAt ?? new Date(0).toISOString(), @@ -227,7 +233,7 @@ describe("HostRuntimeController", () => { connections: [ { id: "direct:lan:6767", - type: "direct", + type: "directTcp", endpoint: "lan:6767", }, ], @@ -262,7 +268,7 @@ describe("HostRuntimeController", () => { connections: [ { id: "direct:lan:6767", - type: "direct", + type: "directTcp", endpoint: "lan:6767", }, ], @@ -438,7 +444,7 @@ describe("HostRuntimeController", () => { connections: [ { id: "direct:lan:6767", - type: "direct", + type: "directTcp", endpoint: "lan:6767", }, ], @@ -577,7 +583,7 @@ describe("HostRuntimeController", () => { connections: [ { id: "direct:lan:6767", - type: "direct", + type: "directTcp", endpoint: "lan:6767", }, { @@ -667,7 +673,7 @@ describe("HostRuntimeController", () => { connections: [ { id: "direct:lan:6767", - type: "direct", + type: "directTcp", endpoint: "lan:6767", }, ], @@ -723,7 +729,7 @@ describe("HostRuntimeController", () => { connections: [ { id: "direct:lan:6767", - type: "direct", + type: "directTcp", endpoint: "lan:6767", }, ], @@ -789,7 +795,7 @@ describe("HostRuntimeStore", () => { connections: [ { id: "direct:lan:6767", - type: "direct", + type: "directTcp", endpoint: "lan:6767", }, ], @@ -837,7 +843,7 @@ describe("HostRuntimeStore", () => { connections: [ { id: "direct:lan:6767", - type: "direct", + type: "directTcp", endpoint: "lan:6767", }, ], @@ -885,7 +891,7 @@ describe("HostRuntimeStore", () => { connections: [ { id: "direct:lan:6767", - type: "direct", + type: "directTcp", endpoint: "lan:6767", }, ], @@ -1045,7 +1051,7 @@ describe("HostRuntimeStore", () => { connections: [ { id: "direct:lan:6767", - type: "direct", + type: "directTcp", endpoint: "lan:6767", }, ], diff --git a/packages/app/src/runtime/host-runtime.ts b/packages/app/src/runtime/host-runtime.ts index 055b619a5..84c4ddb8d 100644 --- a/packages/app/src/runtime/host-runtime.ts +++ b/packages/app/src/runtime/host-runtime.ts @@ -17,6 +17,10 @@ import { type ConnectionCandidate, type ConnectionProbeState, } from "@/utils/connection-selection"; +import { + buildLocalDaemonTransportUrl, + createTauriLocalDaemonTransportFactory, +} from "@/utils/managed-tauri-daemon-transport"; import { createTauriWebSocketTransportFactory } from "@/utils/tauri-daemon-transport"; import { applyFetchedAgentDirectory } from "@/utils/agent-directory-sync"; import { useSessionStore, type Agent } from "@/stores/session-store"; @@ -33,7 +37,9 @@ export type HostRuntimeConnectionStatus = | "error"; 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" }; export type HostRuntimeAgentDirectoryStatus = @@ -179,9 +185,23 @@ function readFetchAgentsNextCursor( } function toActiveConnection(connection: HostConnection): ActiveConnection { - if (connection.type === "direct") { + if (connection.type === "directSocket") { 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, display: connection.endpoint, }; @@ -421,11 +441,14 @@ function createDefaultDeps(): HostRuntimeControllerDeps { serverId: host.serverId, connectionType: connection.type, endpoint: - connection.type === "direct" + connection.type === "directTcp" ? connection.endpoint + : connection.type === "directSocket" || connection.type === "directPipe" + ? connection.path : connection.relayEndpoint, }); const tauriTransportFactory = createTauriWebSocketTransportFactory(); + const localTransportFactory = createTauriLocalDaemonTransportFactory(); const base = { suppressSendErrors: true, clientId, @@ -433,18 +456,31 @@ function createDefaultDeps(): HostRuntimeControllerDeps { runtimeGeneration, onDiagnosticsEvent: (event: DaemonClientDiagnosticsEvent) => recordDaemonClientDiagnostics(host.serverId, event), - ...(tauriTransportFactory - ? { transportFactory: tauriTransportFactory } - : {}), }; - if (connection.type === "direct") { + if (connection.type === "directSocket" || connection.type === "directPipe") { return new DaemonClient({ ...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), }); } return new DaemonClient({ ...base, + ...(tauriTransportFactory + ? { transportFactory: tauriTransportFactory } + : {}), url: buildRelayWebSocketUrl({ endpoint: connection.relayEndpoint, serverId: host.serverId, diff --git a/packages/app/src/runtime/perf-diagnostics/host-runtime-diagnostics.ts b/packages/app/src/runtime/perf-diagnostics/host-runtime-diagnostics.ts index 1d445defb..634dfac02 100644 --- a/packages/app/src/runtime/perf-diagnostics/host-runtime-diagnostics.ts +++ b/packages/app/src/runtime/perf-diagnostics/host-runtime-diagnostics.ts @@ -24,7 +24,7 @@ function shouldSampleFastTransportEvent(): boolean { export function recordHostRuntimeCreateClient(params: { serverId: string; - connectionType: "direct" | "relay"; + connectionType: "directTcp" | "directSocket" | "directPipe" | "relay"; endpoint: string; }): void { recordPerfDiagnosticMark( diff --git a/packages/app/src/screens/settings-screen.tsx b/packages/app/src/screens/settings-screen.tsx index 79c2b6776..b842050d6 100644 --- a/packages/app/src/screens/settings-screen.tsx +++ b/packages/app/src/screens/settings-screen.tsx @@ -51,6 +51,42 @@ const delay = (ms: number) => }, 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["theme"]; +}) { + const { activeConnection, theme } = input; + if (!activeConnection) { + return null; + } + if (activeConnection.type === "relay") { + return { icon: , text: "Relay" }; + } + if (activeConnection.type === "directSocket") { + return { icon: , text: "Socket" }; + } + if (activeConnection.type === "directPipe") { + return { icon: , text: "Pipe" }; + } + return { + icon: , + text: activeConnection.display, + }; +} + const styles = StyleSheet.create((theme) => ({ loadingContainer: { flex: 1, @@ -1045,14 +1081,10 @@ function HostDetailModal({ ? "rgba(248, 113, 113, 0.1)" : "rgba(161, 161, 170, 0.1)"; const connectionBadge = (() => { - if (!activeConnection) return null; - if (activeConnection.type === "relay") { - return { icon: , text: "Relay" }; - } - return { - icon: , - text: activeConnection.display, - }; + return formatActiveConnectionBadge({ + activeConnection, + theme, + }); })(); const versionBadgeText = formatDaemonVersionBadge(daemonVersion); const connectionError = typeof lastError === "string" && lastError.trim().length > 0 ? lastError.trim() : null; @@ -1138,9 +1170,7 @@ function HostDetailModal({ latencyError={probe?.status === "unavailable"} onRemove={() => { const title = - conn.type === "relay" - ? `Relay (${conn.relayEndpoint})` - : `Direct (${conn.endpoint})`; + formatHostConnectionLabel(conn); setPendingRemoveConnection({ serverId: host.serverId, connectionId: conn.id, title }); }} /> @@ -1276,9 +1306,7 @@ function ConnectionRow({ }) { const { theme } = useUnistyles(); const title = - connection.type === "relay" - ? `Relay (${connection.relayEndpoint})` - : `Direct (${connection.endpoint})`; + formatHostConnectionLabel(connection); const latencyText = (() => { if (latencyLoading) return "..."; @@ -1363,14 +1391,10 @@ function DaemonCard({ ? "rgba(248, 113, 113, 0.1)" : "rgba(161, 161, 170, 0.1)"; const connectionBadge = (() => { - if (!activeConnection) return null; - if (activeConnection.type === "relay") { - return { icon: , text: "Relay" }; - } - return { - icon: , - text: activeConnection.display, - }; + return formatActiveConnectionBadge({ + activeConnection, + theme, + }); })(); const versionBadgeText = formatDaemonVersionBadge(daemonVersion); diff --git a/packages/app/src/stores/download-store.ts b/packages/app/src/stores/download-store.ts index 1c9d5d512..91d223518 100644 --- a/packages/app/src/stores/download-store.ts +++ b/packages/app/src/stores/download-store.ts @@ -246,7 +246,7 @@ type DownloadTarget = { function resolveDaemonDownloadTarget(daemon?: HostProfile): DownloadTarget { const endpoint = - daemon?.connections.find((conn) => conn.type === "direct")?.endpoint ?? null; + daemon?.connections.find((conn) => conn.type === "directTcp")?.endpoint ?? null; if (!endpoint) { return { baseUrl: null, authHeader: null, authCredentials: null }; } diff --git a/packages/app/src/utils/connection-selection.test.ts b/packages/app/src/utils/connection-selection.test.ts index 70e2af722..348a0beb3 100644 --- a/packages/app/src/utils/connection-selection.test.ts +++ b/packages/app/src/utils/connection-selection.test.ts @@ -7,7 +7,7 @@ import { } from "./connection-selection"; function makeDirect(id: string, endpoint: string): HostConnection { - return { id, type: "direct", endpoint }; + return { id, type: "directTcp", endpoint }; } function makeRelay( diff --git a/packages/app/src/utils/managed-tauri-daemon-transport.test.ts b/packages/app/src/utils/managed-tauri-daemon-transport.test.ts new file mode 100644 index 000000000..978b03295 --- /dev/null +++ b/packages/app/src/utils/managed-tauri-daemon-transport.test.ts @@ -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>(); + 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((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((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); + }); +}); diff --git a/packages/app/src/utils/managed-tauri-daemon-transport.ts b/packages/app/src/utils/managed-tauri-daemon-transport.ts new file mode 100644 index 000000000..cc663f7d6 --- /dev/null +++ b/packages/app/src/utils/managed-tauri-daemon-transport.ts @@ -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; + }; +} diff --git a/packages/app/src/utils/test-daemon-connection.test.ts b/packages/app/src/utils/test-daemon-connection.test.ts index bf5cbdad9..02500eec8 100644 --- a/packages/app/src/utils/test-daemon-connection.test.ts +++ b/packages/app/src/utils/test-daemon-connection.test.ts @@ -71,12 +71,12 @@ describe("test-daemon-connection probe client identity", () => { await mod.measureConnectionLatency({ id: "direct:lan:6767", - type: "direct", + type: "directTcp", endpoint: "lan:6767", }); await mod.measureConnectionLatency({ id: "direct:lan:6767", - type: "direct", + type: "directTcp", endpoint: "lan:6767", }); @@ -85,4 +85,18 @@ describe("test-daemon-connection probe client identity", () => { expect(second?.clientId).toBe("cid_shared_probe_test"); 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" + ); + }); }); diff --git a/packages/app/src/utils/test-daemon-connection.ts b/packages/app/src/utils/test-daemon-connection.ts index d015a310b..507edaad4 100644 --- a/packages/app/src/utils/test-daemon-connection.ts +++ b/packages/app/src/utils/test-daemon-connection.ts @@ -3,6 +3,10 @@ import type { DaemonClientConfig } from "@server/client/daemon-client"; import type { HostConnection } from "@/contexts/daemon-registry-context"; import { getOrCreateClientId } from "./client-id"; import { buildDaemonWebSocketUrl, buildRelayWebSocketUrl } from "./daemon-endpoints"; +import { + buildLocalDaemonTransportUrl, + createTauriLocalDaemonTransportFactory, +} from "./managed-tauri-daemon-transport"; import { createTauriWebSocketTransportFactory } from "./tauri-daemon-transport"; function normalizeNonEmptyString(value: unknown): string | null { @@ -47,14 +51,31 @@ async function buildClientConfig( ): Promise { const clientId = await getOrCreateClientId(); const tauriTransportFactory = createTauriWebSocketTransportFactory(); + const localTransportFactory = createTauriLocalDaemonTransportFactory(); const base = { clientId, clientType: "mobile" as const, 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 { ...base, url: buildDaemonWebSocketUrl(connection.endpoint), diff --git a/packages/cli/src/commands/daemon/local-daemon.ts b/packages/cli/src/commands/daemon/local-daemon.ts index 54d8c2f04..e9d79bf4e 100644 --- a/packages/cli/src/commands/daemon/local-daemon.ts +++ b/packages/cli/src/commands/daemon/local-daemon.ts @@ -289,7 +289,12 @@ export function resolveTcpHostFromListen(listen: string): string | null { return null } - if (normalized.startsWith('/') || normalized.startsWith('unix://')) { + if ( + normalized.startsWith('/') || + normalized.startsWith('unix://') || + normalized.startsWith('pipe://') || + normalized.startsWith('\\\\.\\pipe\\') + ) { return null } diff --git a/packages/cli/src/commands/daemon/pair.ts b/packages/cli/src/commands/daemon/pair.ts index 55e34f7e0..13e907b57 100644 --- a/packages/cli/src/commands/daemon/pair.ts +++ b/packages/cli/src/commands/daemon/pair.ts @@ -4,14 +4,16 @@ import { generateLocalPairingOffer, loadConfig, resolvePaseoHome } from '@getpas interface PairOptions { home?: string + json?: boolean } export function pairCommand(): Command { return new Command('pair') .description('Print the daemon pairing QR code and link') + .option('--json', 'Output in JSON format') .option('--home ', 'Paseo home directory (default: ~/.paseo)') - .action(async (options: PairOptions) => { - await runPairCommand(options) + .action(async (_options: PairOptions, command: Command) => { + await runPairCommand(command.optsWithGlobals() as PairOptions) }) } @@ -37,6 +39,21 @@ export async function runPairCommand(options: PairOptions): Promise { 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` : '' process.stdout.write(`\nScan to pair:\n${qrBlock}${pairing.url}\n`) } diff --git a/packages/cli/src/utils/client.ts b/packages/cli/src/utils/client.ts index ddd667df4..b57cbd1cc 100644 --- a/packages/cli/src/utils/client.ts +++ b/packages/cli/src/utils/client.ts @@ -10,6 +10,17 @@ export interface ConnectOptions { const DEFAULT_HOST = 'localhost:6767' const DEFAULT_TIMEOUT = 5000 +type DaemonTarget = + | { + type: 'tcp' + url: string + } + | { + type: 'ipc' + url: string + socketPath: string + } + /** * 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 } +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 */ function createNodeWebSocketFactory() { - return (url: string, options?: { headers?: Record }) => { - return new WebSocket(url, { headers: options?.headers }) as unknown as { + return ( + url: string, + options?: { headers?: Record; socketPath?: string } + ) => { + return new WebSocket(url, { + headers: options?.headers, + ...(options?.socketPath ? { socketPath: options.socketPath } : {}), + }) as unknown as { readyState: number send: (data: string | Uint8Array | ArrayBuffer) => void close: (code?: number, reason?: string) => void @@ -41,14 +89,19 @@ export async function connectToDaemon(options?: ConnectOptions): Promise }) => + nodeWebSocketFactory(url, { + headers: config?.headers, + ...(target.type === 'ipc' ? { socketPath: target.socketPath } : {}), + }), reconnect: { enabled: false }, } as unknown as ConstructorParameters[0] ) diff --git a/packages/cli/tests/03-daemon.test.ts b/packages/cli/tests/03-daemon.test.ts index 16a3d5623..502c7223a 100644 --- a/packages/cli/tests/03-daemon.test.ts +++ b/packages/cli/tests/03-daemon.test.ts @@ -65,9 +65,22 @@ try { 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 = await $`PASEO_HOME=${paseoHome} npx paseo daemon status --json`.nothrow() assert.strictEqual(result.exitCode, 0, '--json status should succeed') @@ -77,9 +90,9 @@ try { 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 = await $`PASEO_HOME=${paseoHome} npx paseo daemon stop`.nothrow() // 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') } - // 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 = await $`PASEO_HOME=${paseoHome} npx paseo daemon restart --port ${String(port)}`.nothrow() assert.strictEqual(result.exitCode, 0, 'restart should succeed even when previously stopped') diff --git a/packages/cli/tests/18-local-daemon-utils.test.ts b/packages/cli/tests/18-local-daemon-utils.test.ts index 43119077a..d0b786751 100644 --- a/packages/cli/tests/18-local-daemon-utils.test.ts +++ b/packages/cli/tests/18-local-daemon-utils.test.ts @@ -29,6 +29,8 @@ console.log('=== Local Daemon Utility Helpers ===\n') console.log('Test 3: rejects unix socket listen values') assert.strictEqual(resolveTcpHostFromListen('/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') } diff --git a/packages/cli/tests/28-client-ipc-targets.test.ts b/packages/cli/tests/28-client-ipc-targets.test.ts new file mode 100644 index 000000000..36913b9ee --- /dev/null +++ b/packages/cli/tests/28-client-ipc-targets.test.ts @@ -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 ===') diff --git a/packages/desktop/package.json b/packages/desktop/package.json index a1ee35f0c..6a784e81c 100644 --- a/packages/desktop/package.json +++ b/packages/desktop/package.json @@ -4,8 +4,11 @@ "private": true, "description": "Paseo desktop app (Tauri wrapper)", "scripts": { - "dev": "tauri dev", - "build": "tauri build", + "build:managed-runtime": "node ./scripts/build-managed-runtime.mjs", + "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" }, "devDependencies": { diff --git a/packages/desktop/scripts/build-managed-runtime.mjs b/packages/desktop/scripts/build-managed-runtime.mjs new file mode 100644 index 000000000..db8632933 --- /dev/null +++ b/packages/desktop/scripts/build-managed-runtime.mjs @@ -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(); diff --git a/packages/desktop/scripts/managed-daemon-smoke.mjs b/packages/desktop/scripts/managed-daemon-smoke.mjs new file mode 100644 index 000000000..caf2ba2d5 --- /dev/null +++ b/packages/desktop/scripts/managed-daemon-smoke.mjs @@ -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 {} +} diff --git a/packages/desktop/src-tauri/Cargo.lock b/packages/desktop/src-tauri/Cargo.lock index d69e5878b..2109e8515 100644 --- a/packages/desktop/src-tauri/Cargo.lock +++ b/packages/desktop/src-tauri/Cargo.lock @@ -2632,6 +2632,9 @@ name = "paseo" version = "0.1.18" dependencies = [ "base64 0.22.1", + "dirs", + "futures-util", + "http", "log", "serde", "serde_json", @@ -2643,6 +2646,8 @@ dependencies = [ "tauri-plugin-opener", "tauri-plugin-updater", "tauri-plugin-websocket", + "tokio", + "tokio-tungstenite 0.24.0", ] [[package]] @@ -4342,7 +4347,7 @@ dependencies = [ "tauri-plugin", "thiserror 2.0.18", "tokio", - "tokio-tungstenite", + "tokio-tungstenite 0.28.0", ] [[package]] @@ -4604,6 +4609,18 @@ dependencies = [ "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]] name = "tokio-tungstenite" version = "0.28.0" @@ -4616,7 +4633,7 @@ dependencies = [ "rustls-pki-types", "tokio", "tokio-rustls", - "tungstenite", + "tungstenite 0.28.0", "webpki-roots 0.26.11", ] @@ -4833,6 +4850,24 @@ version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" 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]] name = "tungstenite" version = "0.28.0" diff --git a/packages/desktop/src-tauri/Cargo.toml b/packages/desktop/src-tauri/Cargo.toml index aac163125..78ac69d6b 100644 --- a/packages/desktop/src-tauri/Cargo.toml +++ b/packages/desktop/src-tauri/Cargo.toml @@ -23,6 +23,9 @@ tauri-build = { version = "2.5.3", features = [] } [dependencies] base64 = "0.22" +dirs = "6" +futures-util = "0.3" +http = "1" serde_json = "1.0" serde = { version = "1.0", features = ["derive"] } log = "0.4" @@ -33,7 +36,8 @@ tauri-plugin-notification = "2" tauri-plugin-opener = "2" tauri-plugin-updater = "2" tauri-plugin-websocket = "2" - +tokio = { version = "1", features = ["net", "sync"] } +tokio-tungstenite = "0.24" diff --git a/packages/desktop/src-tauri/resources/.gitkeep b/packages/desktop/src-tauri/resources/.gitkeep new file mode 100644 index 000000000..8b1378917 --- /dev/null +++ b/packages/desktop/src-tauri/resources/.gitkeep @@ -0,0 +1 @@ + diff --git a/packages/desktop/src-tauri/src/lib.rs b/packages/desktop/src-tauri/src/lib.rs index 766c2480b..610ce9de1 100644 --- a/packages/desktop/src-tauri/src/lib.rs +++ b/packages/desktop/src-tauri/src/lib.rs @@ -1,5 +1,6 @@ use base64::{engine::general_purpose::STANDARD as BASE64_STANDARD, Engine as _}; use serde::Serialize; +use serde_json::json; use std::collections::HashSet; use std::fs; use std::path::{Path, PathBuf}; @@ -11,6 +12,17 @@ use tauri::menu::AboutMetadata; use tauri::{AppHandle, Manager, WebviewWindow}; 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) static ZOOM_LEVEL: AtomicU64 = AtomicU64::new(100); @@ -64,6 +76,138 @@ struct AttachmentFileResult { byte_size: u64, } +#[derive(Debug, Clone)] +enum ManagedHeadlessCommand { + RuntimeStatus, + Bootstrap, + DaemonStatus, + StopDaemon, + InstallCliShim, + UninstallCliShim, + UpdateTcp(ManagedTcpSettings), +} + +fn parse_managed_headless_command() -> Result, String> { + let args = std::env::args().collect::>(); + 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::>(); + 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::() + .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::() + .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 { + 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::(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 { std::env::var("SHELL") .ok() @@ -440,12 +584,27 @@ async fn garbage_collect_attachment_files( #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { tauri::Builder::default() + .manage(LocalTransportState::default()) .plugin(tauri_plugin_dialog::init()) .plugin(tauri_plugin_notification::init()) .plugin(tauri_plugin_opener::init()) .plugin(tauri_plugin_updater::Builder::new().build()) .plugin(tauri_plugin_websocket::init()) .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, run_local_daemon_update, check_app_update, @@ -457,6 +616,10 @@ pub fn run() { garbage_collect_attachment_files ]) .setup(|app| { + if maybe_run_managed_headless_command(app.handle())? { + return Ok(()); + } + if cfg!(debug_assertions) { app.handle().plugin( tauri_plugin_log::Builder::default() diff --git a/packages/desktop/src-tauri/src/main.rs b/packages/desktop/src-tauri/src/main.rs index 549683263..8103674a3 100644 --- a/packages/desktop/src-tauri/src/main.rs +++ b/packages/desktop/src-tauri/src/main.rs @@ -8,5 +8,9 @@ tauri::embed_plist::embed_info_plist!("../Info.plist"); fn main() { + if let Err(error) = paseo_lib::try_run_cli_shim_from_args() { + eprintln!("{error}"); + std::process::exit(1); + } paseo_lib::run(); } diff --git a/packages/desktop/src-tauri/src/runtime_manager.rs b/packages/desktop/src-tauri/src/runtime_manager.rs new file mode 100644 index 000000000..a465045ed --- /dev/null +++ b/packages/desktop/src-tauri/src/runtime_manager.rs @@ -0,0 +1,1534 @@ +use base64::{engine::general_purpose::STANDARD as BASE64_STANDARD, Engine as _}; +use futures_util::{SinkExt, StreamExt}; +use http::Request; +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; +use std::env; +use std::fs; +use std::io; +use std::path::{Path, PathBuf}; +use std::process::{Command, Stdio}; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::OnceLock; +use std::sync::{Arc, Mutex}; +use tauri::{AppHandle, Emitter, Manager, State}; +use tokio::sync::mpsc; +use tokio_tungstenite::tungstenite::client::IntoClientRequest; +use tokio_tungstenite::tungstenite::Message; + +const LOCAL_TRANSPORT_EVENT_NAME: &str = "local-daemon-transport-event"; +const MANAGED_STATE_FILE: &str = "managed-state.json"; +const DEFAULT_MANAGED_HOME_BASENAME: &str = ".paseo"; +const DEFAULT_MANAGED_HOME_DIRNAME: &str = "managed-home"; +#[cfg(not(windows))] +const SHORT_SOCKET_FILENAME: &str = "paseo.sock"; +const UNIX_CLIENT_URL: &str = "ws://localhost/ws"; +#[cfg(windows)] +const PIPE_CLIENT_URL: &str = "ws://localhost/ws"; +#[cfg(windows)] +const PIPE_PREFIX: &str = r"\\.\pipe\"; +const DEFAULT_MANAGED_TCP_HOST: &str = "127.0.0.1"; +const DEFAULT_MANAGED_TCP_PORT: u16 = 7771; + +static RUNTIME_INSTALL_LOCK: OnceLock> = OnceLock::new(); + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ManagedRuntimeManifest { + pub runtime_id: String, + pub runtime_version: String, + pub platform: String, + pub arch: String, + pub created_at: String, + pub node_relative_path: String, + pub cli_entrypoint_relative_path: String, + pub cli_shim_relative_path: String, + pub server_runner_relative_path: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +struct BundledRuntimePointer { + runtime_id: String, + runtime_version: String, + relative_root: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +struct ManagedStateFile { + runtime_id: String, + runtime_root: String, + managed_home: String, + transport_type: String, + transport_path: String, + tcp_enabled: bool, + tcp_listen: Option, + cli_shim_path: Option, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +struct PidFile { + pid: Option, + hostname: Option, +} + +#[derive(Debug, Clone)] +struct ManagedPaths { + runtime_root: PathBuf, + managed_home: PathBuf, + transport_path: PathBuf, + logs_path: PathBuf, + state_file_path: PathBuf, + diagnostics_root: PathBuf, +} + +#[derive(Debug, Clone)] +struct ManagedTransportTarget { + transport_type: String, + transport_path: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ManagedTcpSettings { + pub enabled: bool, + pub host: String, + pub port: u16, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ManagedRuntimeStatus { + pub runtime_id: String, + pub runtime_version: String, + pub bundled_runtime_root: String, + pub installed_runtime_root: String, + pub installed: bool, + pub managed_home: String, + pub transport_type: String, + pub transport_path: String, + pub diagnostics_root: String, + pub state_file_path: String, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ManagedDaemonStatus { + pub runtime_id: String, + pub runtime_version: String, + pub runtime_root: String, + pub managed_home: String, + pub transport_type: String, + pub transport_path: String, + pub daemon_pid: Option, + pub daemon_running: bool, + pub daemon_status: String, + pub log_path: String, + pub server_id: Option, + pub hostname: Option, + pub relay_enabled: bool, + pub tcp_enabled: bool, + pub tcp_listen: Option, + pub cli_shim_path: Option, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ManagedDaemonLogs { + pub log_path: String, + pub contents: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ManagedPairingOffer { + pub relay_enabled: bool, + pub url: Option, + pub qr: Option, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct CliShimResult { + pub installed: bool, + pub path: Option, + pub message: String, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +struct LocalTransportEvent { + session_id: String, + kind: String, + text: Option, + binary_base64: Option, + code: Option, + reason: Option, + error: Option, +} + +struct LocalTransportSession { + sender: mpsc::UnboundedSender, +} + +pub struct LocalTransportState { + next_session_id: AtomicU64, + sessions: Arc>>, +} + +impl Default for LocalTransportState { + fn default() -> Self { + Self { + next_session_id: AtomicU64::new(1), + sessions: Arc::new(Mutex::new(HashMap::new())), + } + } +} + +impl LocalTransportState { + fn alloc_session_id(&self) -> String { + format!("local-session-{}", self.next_session_id.fetch_add(1, Ordering::Relaxed)) + } +} + +#[cfg(unix)] +fn build_local_websocket_request(url: &str) -> Result, tokio_tungstenite::tungstenite::Error> { + url.into_client_request() +} + +#[cfg(windows)] +fn build_local_websocket_request(url: &str) -> Result, tokio_tungstenite::tungstenite::Error> { + url.into_client_request() +} + +#[cfg(all(test, unix))] +fn local_client_url() -> &'static str { + UNIX_CLIENT_URL +} + +#[cfg(all(test, windows))] +fn local_client_url() -> &'static str { + PIPE_CLIENT_URL +} + +#[cfg(unix)] +async fn connect_local_socket( + socket_path: PathBuf, +) -> Result< + tokio_tungstenite::WebSocketStream, + tokio_tungstenite::tungstenite::Error, +> { + let stream = tokio::net::UnixStream::connect(socket_path) + .await + .map_err(tokio_tungstenite::tungstenite::Error::Io)?; + let request = build_local_websocket_request(UNIX_CLIENT_URL)?; + let (ws_stream, _) = tokio_tungstenite::client_async(request, stream).await?; + Ok(ws_stream) +} + +#[cfg(windows)] +async fn connect_local_pipe( + pipe_path: String, +) -> Result< + tokio_tungstenite::WebSocketStream, + tokio_tungstenite::tungstenite::Error, +> { + let stream = tokio::net::windows::named_pipe::ClientOptions::new() + .open(&pipe_path) + .map_err(tokio_tungstenite::tungstenite::Error::Io)?; + let request = build_local_websocket_request(PIPE_CLIENT_URL)?; + let (ws_stream, _) = tokio_tungstenite::client_async(request, stream).await?; + Ok(ws_stream) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn manual_http_request_lacks_websocket_handshake_headers() { + let request = Request::builder() + .uri(local_client_url()) + .header("Host", "localhost") + .body(()) + .expect("valid manual request"); + + assert!(request.headers().get("sec-websocket-key").is_none()); + assert!(request.headers().get("sec-websocket-version").is_none()); + assert!(request.headers().get("upgrade").is_none()); + assert!(request.headers().get("connection").is_none()); + } + + #[test] + fn generated_local_websocket_request_includes_required_headers() { + let request = build_local_websocket_request(local_client_url()) + .expect("local websocket request should be generated"); + + assert_eq!(request.uri().to_string(), local_client_url()); + assert_eq!( + request.headers().get("host").and_then(|value| value.to_str().ok()), + Some("localhost") + ); + assert!(request.headers().contains_key("sec-websocket-key")); + assert_eq!( + request + .headers() + .get("sec-websocket-version") + .and_then(|value| value.to_str().ok()), + Some("13") + ); + assert_eq!( + request.headers().get("upgrade").and_then(|value| value.to_str().ok()), + Some("websocket") + ); + assert_eq!( + request + .headers() + .get("connection") + .and_then(|value| value.to_str().ok()), + Some("Upgrade") + ); + } + + #[cfg(unix)] + #[test] + #[ignore = "requires a running local daemon socket"] + fn connects_to_running_local_daemon_socket() { + let socket_path = std::env::var("PASEO_LOCAL_SOCKET_SMOKE_PATH") + .ok() + .filter(|value| !value.trim().is_empty()) + .map(PathBuf::from) + .or_else(|| { + dirs::home_dir().map(|home| home.join(DEFAULT_MANAGED_HOME_BASENAME).join(SHORT_SOCKET_FILENAME)) + }) + .expect("socket path should resolve"); + + assert!( + socket_path.exists(), + "socket path does not exist: {}", + socket_path.display() + ); + + tauri::async_runtime::block_on(async move { + let mut ws_stream = connect_local_socket(socket_path.clone()) + .await + .unwrap_or_else(|error| { + panic!( + "local socket websocket handshake failed for {}: {error}", + socket_path.display() + ) + }); + ws_stream.close(None).await.expect("close websocket stream"); + }); + } +} + +fn copy_dir_recursive(source: &Path, target: &Path) -> Result<(), String> { + if !source.exists() { + return Err(format!( + "Managed runtime resource is missing at {}", + source.display() + )); + } + fs::create_dir_all(target) + .map_err(|error| format!("Failed to create {}: {error}", target.display()))?; + for entry in fs::read_dir(source) + .map_err(|error| format!("Failed to read {}: {error}", source.display()))? + { + let entry = entry.map_err(|error| format!("Failed to read directory entry: {error}"))?; + let source_path = entry.path(); + let target_path = target.join(entry.file_name()); + let file_type = entry + .file_type() + .map_err(|error| format!("Failed to inspect {}: {error}", source_path.display()))?; + if file_type.is_symlink() { + let resolved = fs::canonicalize(&source_path).map_err(|error| { + format!("Failed to resolve symlink {}: {error}", source_path.display()) + })?; + let resolved_type = fs::metadata(&resolved).map_err(|error| { + format!("Failed to inspect resolved symlink {}: {error}", resolved.display()) + })?; + if resolved_type.is_dir() { + copy_dir_recursive(&resolved, &target_path)?; + continue; + } + if let Some(parent) = target_path.parent() { + fs::create_dir_all(parent) + .map_err(|error| format!("Failed to create {}: {error}", parent.display()))?; + } + fs::copy(&resolved, &target_path).map_err(|error| { + format!( + "Failed to copy {} to {}: {error}", + resolved.display(), + target_path.display() + ) + })?; + continue; + } + if file_type.is_dir() { + copy_dir_recursive(&source_path, &target_path)?; + continue; + } + if let Some(parent) = target_path.parent() { + fs::create_dir_all(parent) + .map_err(|error| format!("Failed to create {}: {error}", parent.display()))?; + } + fs::copy(&source_path, &target_path).map_err(|error| { + format!( + "Failed to copy {} to {}: {error}", + source_path.display(), + target_path.display() + ) + })?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let permissions = fs::metadata(&source_path) + .map_err(|error| format!("Failed to read {}: {error}", source_path.display()))? + .permissions() + .mode(); + fs::set_permissions(&target_path, fs::Permissions::from_mode(permissions)).map_err( + |error| format!("Failed to preserve mode on {}: {error}", target_path.display()), + )?; + } + } + Ok(()) +} + +fn read_json_file Deserialize<'de>>(path: &Path) -> Result { + let raw = fs::read_to_string(path) + .map_err(|error| format!("Failed to read {}: {error}", path.display()))?; + serde_json::from_str::(&raw) + .map_err(|error| format!("Failed to parse {}: {error}", path.display())) +} + +fn write_json_file(path: &Path, value: &T) -> Result<(), String> { + let raw = serde_json::to_string_pretty(value) + .map_err(|error| format!("Failed to serialize {}: {error}", path.display()))?; + if let Some(parent) = path.parent() { + fs::create_dir_all(parent) + .map_err(|error| format!("Failed to create {}: {error}", parent.display()))?; + } + fs::write(path, format!("{raw}\n")) + .map_err(|error| format!("Failed to write {}: {error}", path.display())) +} + +fn app_data_root(app: &AppHandle) -> Result { + app.path() + .app_data_dir() + .map_err(|error| format!("Failed to resolve app data directory: {error}")) +} + +fn resolve_test_root() -> Option { + std::env::var("PASEO_DESKTOP_TEST_ROOT") + .ok() + .map(PathBuf::from) +} + +fn resolve_override_path(name: &str) -> Option { + std::env::var(name).ok().map(PathBuf::from) +} + +#[cfg(windows)] +fn current_username() -> String { + std::env::var("USER") + .ok() + .or_else(|| std::env::var("USERNAME").ok()) + .filter(|value| !value.trim().is_empty()) + .unwrap_or_else(|| "user".to_string()) +} + +#[cfg(windows)] +fn hash_seed(value: &str) -> String { + let mut hasher = DefaultHasher::new(); + value.hash(&mut hasher); + format!("{:016x}", hasher.finish()) +} + +#[cfg(windows)] +fn build_windows_pipe_path(seed: &str) -> String { + let user = current_username() + .chars() + .map(|character| { + if character.is_ascii_alphanumeric() { + character.to_ascii_lowercase() + } else { + '-' + } + }) + .collect::(); + format!("{PIPE_PREFIX}paseo-managed-{user}-{}", hash_seed(seed)) +} + +fn default_transport_type() -> &'static str { + #[cfg(windows)] + { + "pipe" + } + #[cfg(not(windows))] + { + "socket" + } +} + +#[cfg(target_os = "macos")] +fn default_managed_home() -> PathBuf { + dirs::home_dir() + .unwrap_or_else(|| PathBuf::from("/tmp")) + .join(DEFAULT_MANAGED_HOME_BASENAME) +} + +#[cfg(not(target_os = "macos"))] +fn default_managed_home(root: &Path) -> PathBuf { + root.join(DEFAULT_MANAGED_HOME_DIRNAME) +} + +fn default_transport_path(managed_home: &Path, diagnostics_root: &Path) -> PathBuf { + #[cfg(windows)] + { + PathBuf::from(build_windows_pipe_path( + diagnostics_root.to_string_lossy().as_ref(), + )) + } + #[cfg(target_os = "macos")] + { + let _ = diagnostics_root; + managed_home.join(SHORT_SOCKET_FILENAME) + } + #[cfg(all(not(windows), not(target_os = "macos")))] + { + let _ = diagnostics_root; + managed_home.join(SHORT_SOCKET_FILENAME) + } +} + +fn resolve_paths(app: &AppHandle, runtime_id: &str) -> Result { + if let Some(test_root) = resolve_test_root() { + let runtime_root = resolve_override_path("PASEO_DESKTOP_MANAGED_RUNTIME_ROOT") + .unwrap_or_else(|| test_root.join("runtime").join(runtime_id)); + let managed_home = resolve_override_path("PASEO_DESKTOP_MANAGED_HOME") + .unwrap_or_else(|| test_root.join(DEFAULT_MANAGED_HOME_DIRNAME)); + let transport_path = resolve_override_path("PASEO_DESKTOP_MANAGED_SOCKET_PATH") + .unwrap_or_else(|| default_transport_path(&managed_home, &test_root)); + return Ok(ManagedPaths { + runtime_root, + managed_home: managed_home.clone(), + transport_path, + logs_path: managed_home.join("daemon.log"), + state_file_path: test_root.join(MANAGED_STATE_FILE), + diagnostics_root: test_root, + }); + } + + let root = app_data_root(app)?; + let runtime_root = resolve_override_path("PASEO_DESKTOP_MANAGED_RUNTIME_ROOT") + .unwrap_or_else(|| root.join("runtime").join(runtime_id)); + let managed_home = resolve_override_path("PASEO_DESKTOP_MANAGED_HOME") + .unwrap_or_else(|| { + #[cfg(target_os = "macos")] + { + default_managed_home() + } + #[cfg(not(target_os = "macos"))] + { + default_managed_home(&root) + } + }); + let transport_path = resolve_override_path("PASEO_DESKTOP_MANAGED_SOCKET_PATH") + .unwrap_or_else(|| default_transport_path(&managed_home, &root)); + Ok(ManagedPaths { + runtime_root, + managed_home: managed_home.clone(), + transport_path, + logs_path: managed_home.join("daemon.log"), + state_file_path: root.join(MANAGED_STATE_FILE), + diagnostics_root: root, + }) +} + +fn dev_resource_root() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("resources") +} + +fn bundled_runtime_root(app: &AppHandle) -> Result { + if let Ok(resource_dir) = app.path().resource_dir() { + let candidate = resource_dir.join("managed-runtime"); + if candidate.exists() { + return Ok(candidate); + } + } + let dev = dev_resource_root().join("managed-runtime"); + if dev.exists() { + return Ok(dev); + } + Err("Managed runtime resources are not bundled with this desktop build.".to_string()) +} + +fn load_bundled_runtime_pointer(app: &AppHandle) -> Result<(PathBuf, BundledRuntimePointer), String> { + let root = bundled_runtime_root(app)?; + let pointer_path = root.join("current-runtime.json"); + let pointer = read_json_file::(&pointer_path)?; + Ok((root, pointer)) +} + +fn load_runtime_manifest(runtime_root: &Path) -> Result { + read_json_file::(&runtime_root.join("runtime-manifest.json")) +} + +fn read_server_id(managed_home: &Path) -> Option { + let raw = fs::read_to_string(managed_home.join("server-id")).ok()?; + let trimmed = raw.trim(); + if trimmed.is_empty() { + None + } else { + Some(trimmed.to_string()) + } +} + +fn tail_log(path: &Path, max_lines: usize) -> String { + let raw = match fs::read_to_string(path) { + Ok(value) => value, + Err(_) => return String::new(), + }; + let mut lines = raw.lines().rev().take(max_lines).collect::>(); + lines.reverse(); + lines.join("\n") +} + +fn is_pid_running(pid: i32) -> bool { + #[cfg(unix)] + { + let output = Command::new("kill") + .arg("-0") + .arg(pid.to_string()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status(); + return output.map(|status| status.success()).unwrap_or(false); + } + #[cfg(not(unix))] + { + let _ = pid; + false + } +} + +fn to_stdio_message(input: Option<&str>) -> String { + input.unwrap_or_default().trim().to_string() +} + +fn parse_tcp_listen(listen: &str) -> Result<(String, u16), String> { + let trimmed = listen.trim(); + let (host, port_raw) = trimmed + .rsplit_once(':') + .ok_or_else(|| "Managed TCP listen target must be host:port.".to_string())?; + let host = host.trim(); + if host.is_empty() { + return Err("Managed TCP host cannot be empty.".to_string()); + } + let port = port_raw + .trim() + .parse::() + .map_err(|_| "Managed TCP port must be a valid integer.".to_string())?; + if port == 0 { + return Err("Managed TCP port must be greater than 0.".to_string()); + } + if port == 6767 { + return Err("Managed TCP mode cannot use port 6767.".to_string()); + } + Ok((host.to_string(), port)) +} + +fn resolve_tcp_settings_from_state(state: Option<&ManagedStateFile>) -> ManagedTcpSettings { + let listen = state + .and_then(|value| value.tcp_listen.clone()) + .unwrap_or_else(|| format!("{DEFAULT_MANAGED_TCP_HOST}:{DEFAULT_MANAGED_TCP_PORT}")); + let (host, port) = + parse_tcp_listen(&listen).unwrap_or((DEFAULT_MANAGED_TCP_HOST.to_string(), DEFAULT_MANAGED_TCP_PORT)); + ManagedTcpSettings { + enabled: state.map(|value| value.tcp_enabled).unwrap_or(false), + host, + port, + } +} + +fn managed_transport_target( + paths: &ManagedPaths, + state: Option<&ManagedStateFile>, +) -> Result { + let tcp_settings = resolve_tcp_settings_from_state(state); + if tcp_settings.enabled { + return Ok(ManagedTransportTarget { + transport_type: "tcp".to_string(), + transport_path: format!("{}:{}", tcp_settings.host, tcp_settings.port), + }); + } + Ok(ManagedTransportTarget { + transport_type: default_transport_type().to_string(), + transport_path: paths.transport_path.to_string_lossy().into_owned(), + }) +} + +fn cli_host_for_target(target: &ManagedTransportTarget) -> String { + match target.transport_type.as_str() { + "tcp" => target.transport_path.clone(), + "pipe" => format!("pipe://{}", target.transport_path), + _ => format!("unix://{}", target.transport_path), + } +} + +fn cli_env(paths: &ManagedPaths) -> Vec<(String, String)> { + let state = read_state_file(&paths.state_file_path); + let target = managed_transport_target(paths, state.as_ref()).unwrap_or(ManagedTransportTarget { + transport_type: default_transport_type().to_string(), + transport_path: paths.transport_path.to_string_lossy().into_owned(), + }); + vec![ + ( + "PASEO_HOME".to_string(), + paths.managed_home.to_string_lossy().into_owned(), + ), + ("PASEO_HOST".to_string(), cli_host_for_target(&target)), + ] +} + +fn cli_command( + runtime_root: &Path, + manifest: &ManagedRuntimeManifest, + args: &[&str], + paths: &ManagedPaths, +) -> Result { + let node = runtime_root.join(&manifest.node_relative_path); + let cli = runtime_root.join(&manifest.cli_entrypoint_relative_path); + if !node.exists() { + return Err(format!("Bundled Node runtime is missing at {}", node.display())); + } + if !cli.exists() { + return Err(format!("Bundled CLI entrypoint is missing at {}", cli.display())); + } + let mut command = Command::new(node); + command.arg(cli); + command.args(args); + for (key, value) in cli_env(paths) { + command.env(key, value); + } + Ok(command) +} + +fn read_pid_from_home(managed_home: &Path) -> Option { + read_json_file::(&managed_home.join("paseo.pid")) + .ok() + .and_then(|parsed| parsed.pid) +} + +fn read_hostname_from_home(managed_home: &Path) -> Option { + read_json_file::(&managed_home.join("paseo.pid")) + .ok() + .and_then(|parsed| parsed.hostname) +} + +fn read_state_file(path: &Path) -> Option { + read_json_file::(path).ok() +} + +fn write_state_file(path: &Path, value: &ManagedStateFile) -> Result<(), String> { + write_json_file(path, value) +} + +fn runtime_install_lock() -> &'static Mutex<()> { + RUNTIME_INSTALL_LOCK.get_or_init(|| Mutex::new(())) +} + +fn install_runtime_if_needed(source_root: &Path, target_root: &Path) -> Result<(), String> { + let _guard = runtime_install_lock() + .lock() + .map_err(|_| "Managed runtime install lock was poisoned.".to_string())?; + + let target_manifest = target_root.join("runtime-manifest.json"); + if target_manifest.exists() { + return Ok(()); + } + + if target_root.exists() { + fs::remove_dir_all(target_root) + .map_err(|error| format!("Failed to remove incomplete runtime {}: {error}", target_root.display()))?; + } + + let staging_root = target_root.with_extension(format!("installing-{}", std::process::id())); + if staging_root.exists() { + fs::remove_dir_all(&staging_root).map_err(|error| { + format!( + "Failed to remove stale runtime staging dir {}: {error}", + staging_root.display() + ) + })?; + } + + copy_dir_recursive(source_root, &staging_root)?; + let staging_manifest = staging_root.join("runtime-manifest.json"); + if !staging_manifest.exists() { + let _ = fs::remove_dir_all(&staging_root); + return Err(format!( + "Managed runtime staging manifest is missing at {}", + staging_manifest.display() + )); + } + + fs::rename(&staging_root, target_root).map_err(|error| { + let _ = fs::remove_dir_all(&staging_root); + format!( + "Failed to finalize managed runtime install from {} to {}: {error}", + staging_root.display(), + target_root.display() + ) + })?; + + Ok(()) +} + +fn ensure_runtime_installed_internal(app: &AppHandle) -> Result { + let (bundled_root, pointer) = load_bundled_runtime_pointer(app)?; + let source_root = bundled_root.join(&pointer.relative_root); + let paths = resolve_paths(app, &pointer.runtime_id)?; + install_runtime_if_needed(&source_root, &paths.runtime_root)?; + let manifest = load_runtime_manifest(&paths.runtime_root)?; + if let Some(parent) = paths.transport_path.parent() { + fs::create_dir_all(parent) + .map_err(|error| format!("Failed to create {}: {error}", parent.display()))?; + } + fs::create_dir_all(&paths.managed_home) + .map_err(|error| format!("Failed to create {}: {error}", paths.managed_home.display()))?; + let existing_state = read_state_file(&paths.state_file_path); + let target = managed_transport_target(&paths, existing_state.as_ref())?; + let state = ManagedStateFile { + runtime_id: manifest.runtime_id.clone(), + runtime_root: paths.runtime_root.to_string_lossy().into_owned(), + managed_home: paths.managed_home.to_string_lossy().into_owned(), + transport_type: target.transport_type.clone(), + transport_path: target.transport_path.clone(), + tcp_enabled: existing_state + .as_ref() + .map(|entry| entry.tcp_enabled) + .unwrap_or(false), + tcp_listen: existing_state.as_ref().and_then(|entry| entry.tcp_listen.clone()), + cli_shim_path: existing_state.and_then(|entry| entry.cli_shim_path), + }; + write_state_file(&paths.state_file_path, &state)?; + + Ok(ManagedRuntimeStatus { + runtime_id: manifest.runtime_id, + runtime_version: manifest.runtime_version, + bundled_runtime_root: source_root.to_string_lossy().into_owned(), + installed_runtime_root: paths.runtime_root.to_string_lossy().into_owned(), + installed: true, + managed_home: paths.managed_home.to_string_lossy().into_owned(), + transport_type: state.transport_type, + transport_path: state.transport_path, + diagnostics_root: paths.diagnostics_root.to_string_lossy().into_owned(), + state_file_path: paths.state_file_path.to_string_lossy().into_owned(), + }) +} + +fn run_cli_json_command( + runtime_root: &Path, + manifest: &ManagedRuntimeManifest, + args: &[&str], + paths: &ManagedPaths, +) -> Result { + let output = cli_command(runtime_root, manifest, args, paths)? + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .output() + .map_err(|error| format!("Failed to run bundled CLI: {error}"))?; + if !output.status.success() { + return Err(format!( + "Bundled CLI failed (exit {}): {}", + output.status.code().unwrap_or(-1), + String::from_utf8_lossy(&output.stderr).trim() + )); + } + let stdout = String::from_utf8_lossy(&output.stdout); + serde_json::from_str(stdout.trim()).map_err(|error| { + format!( + "Failed to parse bundled CLI JSON output: {error}; stdout={}", + stdout.trim() + ) + }) +} + +fn managed_daemon_status_internal(app: &AppHandle) -> Result { + let status = ensure_runtime_installed_internal(app)?; + let paths = resolve_paths(app, &status.runtime_id)?; + let manifest = load_runtime_manifest(&paths.runtime_root)?; + let state = read_state_file(&paths.state_file_path); + let target = managed_transport_target(&paths, state.as_ref())?; + let tcp_settings = resolve_tcp_settings_from_state(state.as_ref()); + let cli_status = run_cli_json_command( + &paths.runtime_root, + &manifest, + &["daemon", "status", "--home", &paths.managed_home.to_string_lossy(), "--json"], + &paths, + ) + .ok(); + + let daemon_status = cli_status + .as_ref() + .and_then(|value| value.get("status")) + .and_then(|value| value.as_str()) + .unwrap_or(if read_pid_from_home(&paths.managed_home).is_some() { + "running" + } else { + "stopped" + }) + .to_string(); + let daemon_pid = cli_status + .as_ref() + .and_then(|value| value.get("pid")) + .and_then(|value| { + value + .as_i64() + .or_else(|| value.as_str().and_then(|raw| raw.parse::().ok())) + }) + .or_else(|| read_pid_from_home(&paths.managed_home)); + let daemon_running = daemon_pid + .map(|pid| is_pid_running(pid as i32)) + .unwrap_or(false) + || daemon_status == "running"; + + Ok(ManagedDaemonStatus { + runtime_id: manifest.runtime_id, + runtime_version: manifest.runtime_version, + runtime_root: paths.runtime_root.to_string_lossy().into_owned(), + managed_home: paths.managed_home.to_string_lossy().into_owned(), + transport_type: target.transport_type, + transport_path: target.transport_path, + daemon_pid, + daemon_running, + daemon_status, + log_path: paths.logs_path.to_string_lossy().into_owned(), + server_id: read_server_id(&paths.managed_home), + hostname: read_hostname_from_home(&paths.managed_home), + relay_enabled: true, + tcp_enabled: tcp_settings.enabled, + tcp_listen: tcp_settings + .enabled + .then(|| format!("{}:{}", tcp_settings.host, tcp_settings.port)), + cli_shim_path: state.and_then(|entry| entry.cli_shim_path), + }) +} + +fn cli_shim_contents(app_exe: &Path, state_file: &Path) -> String { + format!( + "#!/usr/bin/env sh\nexec \"{}\" --paseo-cli-shim \"{}\" \"$@\"\n", + app_exe.display(), + state_file.display() + ) +} + +fn install_cli_shim_internal(app: &AppHandle) -> Result { + let status = ensure_runtime_installed_internal(app)?; + let paths = resolve_paths(app, &status.runtime_id)?; + let bin_dir = if cfg!(target_os = "windows") { + dirs::data_dir() + .ok_or_else(|| "Failed to resolve data directory for CLI shim.".to_string())? + .join("paseo") + .join("bin") + } else { + dirs::home_dir() + .ok_or_else(|| "Failed to resolve home directory for CLI shim.".to_string())? + .join(".local") + .join("bin") + }; + fs::create_dir_all(&bin_dir) + .map_err(|error| format!("Failed to create {}: {error}", bin_dir.display()))?; + let shim_path = if cfg!(target_os = "windows") { + bin_dir.join("paseo.cmd") + } else { + bin_dir.join("paseo") + }; + let app_exe = std::env::current_exe() + .map_err(|error| format!("Failed to resolve desktop executable: {error}"))?; + if cfg!(target_os = "windows") { + fs::write( + &shim_path, + format!( + "@echo off\r\n\"{}\" --paseo-cli-shim \"{}\" %*\r\n", + app_exe.display(), + paths.state_file_path.display() + ), + ) + .map_err(|error| format!("Failed to write {}: {error}", shim_path.display()))?; + } else { + fs::write(&shim_path, cli_shim_contents(&app_exe, &paths.state_file_path)) + .map_err(|error| format!("Failed to write {}: {error}", shim_path.display()))?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + fs::set_permissions(&shim_path, fs::Permissions::from_mode(0o755)).map_err( + |error| format!("Failed to chmod {}: {error}", shim_path.display()), + )?; + } + } + + let mut state = read_state_file(&paths.state_file_path).unwrap_or(ManagedStateFile { + runtime_id: status.runtime_id.clone(), + runtime_root: paths.runtime_root.to_string_lossy().into_owned(), + managed_home: paths.managed_home.to_string_lossy().into_owned(), + transport_type: status.transport_type.clone(), + transport_path: status.transport_path.clone(), + tcp_enabled: false, + tcp_listen: None, + cli_shim_path: None, + }); + state.cli_shim_path = Some(shim_path.to_string_lossy().into_owned()); + write_state_file(&paths.state_file_path, &state)?; + + Ok(CliShimResult { + installed: true, + path: Some(shim_path.to_string_lossy().into_owned()), + message: "CLI shim installed for the current user.".to_string(), + }) +} + +fn uninstall_cli_shim_internal(app: &AppHandle) -> Result { + let status = ensure_runtime_installed_internal(app)?; + let paths = resolve_paths(app, &status.runtime_id)?; + let shim_path = read_state_file(&paths.state_file_path).and_then(|entry| entry.cli_shim_path); + if let Some(shim_path) = shim_path.clone() { + let shim_path_buf = PathBuf::from(&shim_path); + if shim_path_buf.exists() { + fs::remove_file(&shim_path_buf) + .map_err(|error| format!("Failed to remove {}: {error}", shim_path_buf.display()))?; + } + } + let mut state = read_state_file(&paths.state_file_path).unwrap_or(ManagedStateFile { + runtime_id: status.runtime_id.clone(), + runtime_root: paths.runtime_root.to_string_lossy().into_owned(), + managed_home: paths.managed_home.to_string_lossy().into_owned(), + transport_type: status.transport_type.clone(), + transport_path: status.transport_path.clone(), + tcp_enabled: false, + tcp_listen: None, + cli_shim_path: None, + }); + state.cli_shim_path = None; + write_state_file(&paths.state_file_path, &state)?; + + Ok(CliShimResult { + installed: false, + path: None, + message: "CLI shim removed.".to_string(), + }) +} + +pub fn try_run_cli_shim_from_args() -> Result { + let args = env::args().collect::>(); + let Some(flag_index) = args.iter().position(|value| value == "--paseo-cli-shim") else { + return Ok(false); + }; + let state_file = args + .get(flag_index + 1) + .ok_or_else(|| "Missing state file path after --paseo-cli-shim".to_string())?; + let passthrough_args = args + .iter() + .skip(flag_index + 2) + .cloned() + .collect::>(); + let state = read_json_file::(&PathBuf::from(state_file))?; + let manifest = load_runtime_manifest(&PathBuf::from(&state.runtime_root))?; + let node = PathBuf::from(&state.runtime_root).join(&manifest.node_relative_path); + let cli = PathBuf::from(&state.runtime_root).join(&manifest.cli_entrypoint_relative_path); + let mut command = Command::new(node); + command.arg(cli); + command.args(passthrough_args); + command.env("PASEO_HOME", state.managed_home); + command.env( + "PASEO_HOST", + cli_host_for_target(&ManagedTransportTarget { + transport_type: state.transport_type, + transport_path: state.transport_path, + }), + ); + let status = command + .status() + .map_err(|error| format!("Failed to execute bundled CLI shim: {error}"))?; + std::process::exit(status.code().unwrap_or(1)); +} + +fn start_managed_daemon_internal(app: &AppHandle) -> Result { + let status = ensure_runtime_installed_internal(app)?; + let paths = resolve_paths(app, &status.runtime_id)?; + let existing_status = managed_daemon_status_internal(app)?; + if existing_status.daemon_running { + return Ok(existing_status); + } + if let Some(parent) = paths.transport_path.parent() { + fs::create_dir_all(parent) + .map_err(|error| format!("Failed to create {}: {error}", parent.display()))?; + } + let manifest = load_runtime_manifest(&paths.runtime_root)?; + let state = read_state_file(&paths.state_file_path); + let target = managed_transport_target(&paths, state.as_ref())?; + let output = cli_command( + &paths.runtime_root, + &manifest, + &[ + "start", + "--home", + &paths.managed_home.to_string_lossy(), + "--listen", + &target.transport_path, + ], + &paths, + )? + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .output() + .map_err(|error| format!("Failed to launch managed daemon: {error}"))?; + if !output.status.success() { + return Err(format!( + "Managed daemon start failed (exit {}): {}", + output.status.code().unwrap_or(-1), + to_stdio_message(Some(&String::from_utf8_lossy(&output.stderr))) + )); + } + for _ in 0..30 { + let daemon_status = managed_daemon_status_internal(app)?; + if daemon_status.daemon_running { + return Ok(daemon_status); + } + std::thread::sleep(std::time::Duration::from_millis(200)); + } + managed_daemon_status_internal(app) +} + +fn stop_managed_daemon_internal(app: &AppHandle) -> Result { + let status = ensure_runtime_installed_internal(app)?; + let paths = resolve_paths(app, &status.runtime_id)?; + let manifest = load_runtime_manifest(&paths.runtime_root)?; + let output = cli_command( + &paths.runtime_root, + &manifest, + &[ + "daemon", + "stop", + "--home", + &paths.managed_home.to_string_lossy(), + "--json", + ], + &paths, + )? + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .output() + .map_err(|error| format!("Failed to stop managed daemon: {error}"))?; + if !output.status.success() { + return Err(format!( + "Managed daemon stop failed (exit {}): {}", + output.status.code().unwrap_or(-1), + to_stdio_message(Some(&String::from_utf8_lossy(&output.stderr))) + )); + } + managed_daemon_status_internal(app) +} + +fn restart_managed_daemon_internal(app: &AppHandle) -> Result { + let status = ensure_runtime_installed_internal(app)?; + let paths = resolve_paths(app, &status.runtime_id)?; + let manifest = load_runtime_manifest(&paths.runtime_root)?; + let state = read_state_file(&paths.state_file_path); + let target = managed_transport_target(&paths, state.as_ref())?; + let output = cli_command( + &paths.runtime_root, + &manifest, + &[ + "daemon", + "restart", + "--home", + &paths.managed_home.to_string_lossy(), + "--listen", + &target.transport_path, + "--json", + ], + &paths, + )? + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .output() + .map_err(|error| format!("Failed to restart managed daemon: {error}"))?; + if !output.status.success() { + return Err(format!( + "Managed daemon restart failed (exit {}): {}", + output.status.code().unwrap_or(-1), + to_stdio_message(Some(&String::from_utf8_lossy(&output.stderr))) + )); + } + managed_daemon_status_internal(app) +} + +fn update_managed_tcp_settings_internal( + app: &AppHandle, + settings: ManagedTcpSettings, +) -> Result { + if settings.enabled { + parse_tcp_listen(&format!("{}:{}", settings.host.trim(), settings.port))?; + } + let status = ensure_runtime_installed_internal(app)?; + let paths = resolve_paths(app, &status.runtime_id)?; + let mut state = read_state_file(&paths.state_file_path).unwrap_or(ManagedStateFile { + runtime_id: status.runtime_id.clone(), + runtime_root: paths.runtime_root.to_string_lossy().into_owned(), + managed_home: paths.managed_home.to_string_lossy().into_owned(), + transport_type: status.transport_type.clone(), + transport_path: status.transport_path.clone(), + tcp_enabled: false, + tcp_listen: None, + cli_shim_path: None, + }); + state.tcp_enabled = settings.enabled; + state.tcp_listen = Some(format!("{}:{}", settings.host.trim(), settings.port)); + let target = managed_transport_target(&paths, Some(&state))?; + state.transport_type = target.transport_type; + state.transport_path = target.transport_path; + write_state_file(&paths.state_file_path, &state)?; + + if managed_daemon_status_internal(app)?.daemon_running { + return restart_managed_daemon_internal(app); + } + managed_daemon_status_internal(app) +} + +#[tauri::command] +pub async fn managed_runtime_status(app: AppHandle) -> Result { + tauri::async_runtime::spawn_blocking(move || ensure_runtime_installed_internal(&app)) + .await + .map_err(|error| format!("Managed runtime status task failed: {error}"))? +} + +#[tauri::command] +pub async fn ensure_managed_runtime(app: AppHandle) -> Result { + tauri::async_runtime::spawn_blocking(move || ensure_runtime_installed_internal(&app)) + .await + .map_err(|error| format!("Managed runtime install task failed: {error}"))? +} + +#[tauri::command] +pub async fn managed_daemon_status(app: AppHandle) -> Result { + tauri::async_runtime::spawn_blocking(move || managed_daemon_status_internal(&app)) + .await + .map_err(|error| format!("Managed daemon status task failed: {error}"))? +} + +#[tauri::command] +pub async fn start_managed_daemon(app: AppHandle) -> Result { + tauri::async_runtime::spawn_blocking(move || start_managed_daemon_internal(&app)) + .await + .map_err(|error| format!("Managed daemon start task failed: {error}"))? +} + +#[tauri::command] +pub async fn stop_managed_daemon(app: AppHandle) -> Result { + tauri::async_runtime::spawn_blocking(move || stop_managed_daemon_internal(&app)) + .await + .map_err(|error| format!("Managed daemon stop task failed: {error}"))? +} + +#[tauri::command] +pub async fn restart_managed_daemon(app: AppHandle) -> Result { + tauri::async_runtime::spawn_blocking(move || restart_managed_daemon_internal(&app)) + .await + .map_err(|error| format!("Managed daemon restart task failed: {error}"))? +} + +#[tauri::command] +pub async fn managed_daemon_logs(app: AppHandle) -> Result { + tauri::async_runtime::spawn_blocking(move || { + let status = ensure_runtime_installed_internal(&app)?; + let paths = resolve_paths(&app, &status.runtime_id)?; + Ok(ManagedDaemonLogs { + log_path: paths.logs_path.to_string_lossy().into_owned(), + contents: tail_log(&paths.logs_path, 400), + }) + }) + .await + .map_err(|error| format!("Managed daemon logs task failed: {error}"))? +} + +#[tauri::command] +pub async fn managed_daemon_pairing(app: AppHandle) -> Result { + tauri::async_runtime::spawn_blocking(move || { + let status = ensure_runtime_installed_internal(&app)?; + let paths = resolve_paths(&app, &status.runtime_id)?; + let manifest = load_runtime_manifest(&paths.runtime_root)?; + let value = run_cli_json_command( + &paths.runtime_root, + &manifest, + &["daemon", "pair", "--home", &paths.managed_home.to_string_lossy(), "--json"], + &paths, + )?; + serde_json::from_value::(value) + .map_err(|error| format!("Failed to parse managed pairing offer: {error}")) + }) + .await + .map_err(|error| format!("Managed daemon pairing task failed: {error}"))? +} + +#[tauri::command] +pub async fn install_cli_shim(app: AppHandle) -> Result { + tauri::async_runtime::spawn_blocking(move || install_cli_shim_internal(&app)) + .await + .map_err(|error| format!("CLI shim install task failed: {error}"))? +} + +#[tauri::command] +pub async fn uninstall_cli_shim(app: AppHandle) -> Result { + tauri::async_runtime::spawn_blocking(move || uninstall_cli_shim_internal(&app)) + .await + .map_err(|error| format!("CLI shim uninstall task failed: {error}"))? +} + +#[tauri::command] +pub async fn update_managed_daemon_tcp_settings( + app: AppHandle, + settings: ManagedTcpSettings, +) -> Result { + tauri::async_runtime::spawn_blocking(move || update_managed_tcp_settings_internal(&app, settings)) + .await + .map_err(|error| format!("Managed daemon TCP settings task failed: {error}"))? +} + +async fn spawn_local_transport_session( + app: AppHandle, + transport_state: State<'_, LocalTransportState>, + session_id: String, + ws_stream: tokio_tungstenite::WebSocketStream, +) -> Result +where + S: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin + Send + 'static, +{ + let (mut write, mut read) = ws_stream.split(); + let (sender, mut receiver) = mpsc::unbounded_channel::(); + transport_state + .sessions + .lock() + .map_err(|_| "Local transport session lock poisoned.".to_string())? + .insert(session_id.clone(), LocalTransportSession { sender }); + + let app_for_read = app.clone(); + let app_for_write = app.clone(); + let sessions_for_read = Arc::clone(&transport_state.sessions); + let read_session_id = session_id.clone(); + tauri::async_runtime::spawn(async move { + let _ = app_for_read.emit( + LOCAL_TRANSPORT_EVENT_NAME, + LocalTransportEvent { + session_id: read_session_id.clone(), + kind: "open".to_string(), + text: None, + binary_base64: None, + code: None, + reason: None, + error: None, + }, + ); + + while let Some(message) = read.next().await { + match message { + Ok(Message::Text(text)) => { + let _ = app_for_read.emit( + LOCAL_TRANSPORT_EVENT_NAME, + LocalTransportEvent { + session_id: read_session_id.clone(), + kind: "message".to_string(), + text: Some(text.to_string()), + binary_base64: None, + code: None, + reason: None, + error: None, + }, + ); + } + Ok(Message::Binary(bytes)) => { + let _ = app_for_read.emit( + LOCAL_TRANSPORT_EVENT_NAME, + LocalTransportEvent { + session_id: read_session_id.clone(), + kind: "message".to_string(), + text: None, + binary_base64: Some(BASE64_STANDARD.encode(bytes)), + code: None, + reason: None, + error: None, + }, + ); + } + Ok(Message::Close(frame)) => { + let _ = app_for_read.emit( + LOCAL_TRANSPORT_EVENT_NAME, + LocalTransportEvent { + session_id: read_session_id.clone(), + kind: "close".to_string(), + text: None, + binary_base64: None, + code: frame.as_ref().map(|value| value.code.into()), + reason: frame.as_ref().map(|value| value.reason.to_string()), + error: None, + }, + ); + break; + } + Ok(Message::Ping(_)) | Ok(Message::Pong(_)) => {} + Ok(Message::Frame(_)) => {} + Err(error) => { + let _ = app_for_read.emit( + LOCAL_TRANSPORT_EVENT_NAME, + LocalTransportEvent { + session_id: read_session_id.clone(), + kind: "error".to_string(), + text: None, + binary_base64: None, + code: None, + reason: None, + error: Some(error.to_string()), + }, + ); + break; + } + } + } + + if let Ok(mut sessions) = sessions_for_read.lock() { + sessions.remove(&read_session_id); + } + }); + + let sessions_for_write = Arc::clone(&transport_state.sessions); + let write_session_id = session_id.clone(); + tauri::async_runtime::spawn(async move { + while let Some(message) = receiver.recv().await { + if write.send(message).await.is_err() { + let _ = app_for_write.emit( + LOCAL_TRANSPORT_EVENT_NAME, + LocalTransportEvent { + session_id: write_session_id.clone(), + kind: "error".to_string(), + text: None, + binary_base64: None, + code: None, + reason: None, + error: Some("Local transport write failed.".to_string()), + }, + ); + break; + } + } + if let Ok(mut sessions) = sessions_for_write.lock() { + sessions.remove(&write_session_id); + } + }); + + Ok(session_id) +} + +#[tauri::command] +pub async fn open_local_daemon_transport( + app: AppHandle, + transport_state: State<'_, LocalTransportState>, + transport_type: String, + transport_path: String, +) -> Result { + let session_id = transport_state.alloc_session_id(); + let _ = app; + match transport_type.as_str() { + "pipe" => { + #[cfg(windows)] + { + let ws_stream = connect_local_pipe(transport_path) + .await + .map_err(|error| format!("Failed to connect to local daemon pipe: {error}"))?; + spawn_local_transport_session(app, transport_state, session_id, ws_stream).await + } + #[cfg(not(windows))] + { + Err(tokio_tungstenite::tungstenite::Error::Io(io::Error::new( + io::ErrorKind::Unsupported, + "Local pipe transport is only available on Windows.", + )) + .to_string()) + } + } + "socket" => { + #[cfg(unix)] + { + let ws_stream = connect_local_socket(PathBuf::from(transport_path)) + .await + .map_err(|error| format!("Failed to connect to local daemon socket: {error}"))?; + spawn_local_transport_session(app, transport_state, session_id, ws_stream).await + } + #[cfg(not(unix))] + { + Err(tokio_tungstenite::tungstenite::Error::Io(io::Error::new( + io::ErrorKind::Unsupported, + "Local socket transport is only available on Unix platforms.", + )) + .to_string()) + } + } + other => Err(format!("Unsupported local transport type: {other}")), + } +} + +#[tauri::command] +pub async fn send_local_daemon_transport_message( + transport_state: State<'_, LocalTransportState>, + session_id: String, + text: Option, + binary_base64: Option, +) -> Result<(), String> { + let sessions = transport_state + .sessions + .lock() + .map_err(|_| "Local transport session lock poisoned.".to_string())?; + let session = sessions + .get(&session_id) + .ok_or_else(|| format!("Local transport session not found: {session_id}"))?; + if let Some(text) = text { + session + .sender + .send(Message::Text(text.into())) + .map_err(|_| "Local transport session is closed.".to_string())?; + return Ok(()); + } + if let Some(binary_base64) = binary_base64 { + let bytes = BASE64_STANDARD + .decode(binary_base64.as_bytes()) + .map_err(|error| format!("Failed to decode local transport payload: {error}"))?; + session + .sender + .send(Message::Binary(bytes.into())) + .map_err(|_| "Local transport session is closed.".to_string())?; + return Ok(()); + } + Err("Local transport send requires text or binary payload.".to_string()) +} + +#[tauri::command] +pub async fn close_local_daemon_transport( + transport_state: State<'_, LocalTransportState>, + session_id: String, +) -> Result<(), String> { + let mut sessions = transport_state + .sessions + .lock() + .map_err(|_| "Local transport session lock poisoned.".to_string())?; + let session = sessions + .remove(&session_id) + .ok_or_else(|| format!("Local transport session not found: {session_id}"))?; + session + .sender + .send(Message::Close(None)) + .map_err(|_| "Local transport session is already closed.".to_string())?; + Ok(()) +} diff --git a/packages/desktop/src-tauri/tauri.conf.json b/packages/desktop/src-tauri/tauri.conf.json index 3a1120e22..8ea1efff0 100644 --- a/packages/desktop/src-tauri/tauri.conf.json +++ b/packages/desktop/src-tauri/tauri.conf.json @@ -33,6 +33,9 @@ "bundle": { "active": true, "createUpdaterArtifacts": true, + "resources": [ + "resources/**/*" + ], "targets": "all", "icon": [ "icons/32x32.png", diff --git a/packages/server/src/server/bootstrap.smoke.test.ts b/packages/server/src/server/bootstrap.smoke.test.ts index 404f01780..3baf76765 100644 --- a/packages/server/src/server/bootstrap.smoke.test.ts +++ b/packages/server/src/server/bootstrap.smoke.test.ts @@ -1,10 +1,11 @@ import os from "node:os"; import path from "node:path"; import { mkdir, mkdtemp, rm } from "node:fs/promises"; +import { Writable } from "node:stream"; import pino from "pino"; 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 { createTestAgentClients } from "./test-utils/fake-agent-client.js"; @@ -75,4 +76,65 @@ describe("paseo daemon bootstrap", () => { 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 }); + } + }); }); diff --git a/packages/server/src/server/bootstrap.ts b/packages/server/src/server/bootstrap.ts index 66cbfeffe..eddfc337e 100644 --- a/packages/server/src/server/bootstrap.ts +++ b/packages/server/src/server/bootstrap.ts @@ -11,9 +11,16 @@ import type { Logger } from "pino"; type ListenTarget = | { 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 if (listen.startsWith("/") || listen.startsWith("~") || listen.includes(".sock")) { return { type: "socket", path: listen }; @@ -570,46 +577,44 @@ export async function createPaseoDaemon( const onListening = () => { httpServer.off("error", onError); 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") { logger.info( { host: listenTarget.host, port: 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 { 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); @@ -620,8 +625,7 @@ export async function createPaseoDaemon( if (listenTarget.type === "tcp") { httpServer.listen(listenTarget.port, listenTarget.host); } else { - // Remove stale socket file if it exists - if (existsSync(listenTarget.path)) { + if (listenTarget.type === "socket" && existsSync(listenTarget.path)) { unlinkSync(listenTarget.path); } httpServer.listen(listenTarget.path);