diff --git a/.github/workflows/desktop-release.yml b/.github/workflows/desktop-release.yml index fe3150ed3..43691e3d9 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,22 @@ 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: + strategy: + matrix: + include: + - runner: macos-14 + rust_target: aarch64-apple-darwin + - runner: macos-13 + rust_target: x86_64-apple-darwin permissions: contents: write packages: read - runs-on: macos-latest - env: - RELEASE_TAG: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.tag || github.ref_name }} + runs-on: ${{ matrix.runner }} steps: - uses: actions/checkout@v4 @@ -34,15 +42,15 @@ 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 with: - targets: aarch64-apple-darwin,x86_64-apple-darwin + targets: ${{ matrix.rust_target }} - name: Install JS dependencies run: npm ci @@ -91,7 +99,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 }} @@ -110,4 +118,179 @@ jobs: releaseBody: See the assets to download and install this version. releaseDraft: false prerelease: false - args: --target universal-apple-darwin + args: --target ${{ matrix.rust_target }} + + 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/RUNTIME_SIMPLIFICATION_PLAN.md b/RUNTIME_SIMPLIFICATION_PLAN.md new file mode 100644 index 000000000..2d0e591c6 --- /dev/null +++ b/RUNTIME_SIMPLIFICATION_PLAN.md @@ -0,0 +1,81 @@ +# Runtime Simplification Plan + +## Goal + +Remove the managed runtime install/copy layer. The desktop app must run the bundled runtime in place from the application resources/install directory on macOS, Windows, and Linux. + +This is a simplification task. If a change preserves the old install-manager shape under a new name, it fails the goal. + +## Hard Requirements + +1. The bundled runtime is read-only. + The desktop app must not copy the bundled runtime to app data, temp, cache, or any other writable directory. + +2. The app must execute Node/CLI/server entrypoints directly from the bundled runtime root. + On macOS this means inside `Paseo.app/Contents/Resources/...`. + On Windows and Linux this means inside the installed app resources directory. + +3. There is exactly one runtime per installed app. + Remove versioned installed runtime directories like `runtime/` from the runtime execution path. + +4. The CLI shim may point into the installed app bundle/directory. + Do not keep an extra installed runtime tree just to preserve a stable shim target across updates. + +5. All mutable state remains outside the bundled runtime. + Logs, sockets/pipes, PID files, daemon state, `PASEO_HOME`, and any other writable files must continue to live in managed home / app data locations. + +6. Runtime discovery must stay cross-platform. + The implementation must resolve the bundled runtime/resources path on macOS, Windows, and Linux using the app install/resources directory, not hardcoded `.app` assumptions. + +7. Remove dead machinery, do not leave adapters behind. + If install/copy/versioned-runtime code becomes unused, delete it instead of keeping fallback paths "just in case". + +## Non-Goals + +1. Do not change how the runtime is built into the desktop app bundle in this task. + This task is about runtime execution and path management, not bundling format. + +2. Do not reintroduce a second runtime location for migration compatibility. + It is acceptable if older installed clients do not migrate cleanly. + +3. Do not add feature flags, env-guarded fallback paths, or compatibility shims unless absolutely required by a real platform constraint proven in code. + +## Concrete Implementation Direction + +1. Treat `bundled_runtime_root(app)` plus `current-runtime.json` as the runtime source of truth. + +2. Replace `paths.runtime_root` usage for runtime execution with the bundled runtime root selected by `current-runtime.json`. + +3. Delete `install_runtime_if_needed(...)` and related copy/install staging logic if nothing else still needs it. + +4. Rework CLI shim generation so the inner launcher points at the bundled runtime's Node + CLI entrypoint directly, while still keeping mutable state (`PASEO_HOME`) outside the runtime. + +5. Simplify `ManagedPaths` and related structs if `runtime_root` and `stable_runtime_root` no longer need separate installed-runtime semantics. + +6. Keep diagnostics/status reporting accurate. + If the app reports bundled vs installed runtime roots today, update that output to reflect the new single-runtime model. + +## Acceptance Criteria + +1. No code path copies the bundled runtime tree into app data before launching the daemon or CLI. + +2. No code path depends on a versioned installed runtime directory for execution. + +3. The CLI shim and daemon launch path both resolve to bundled runtime executables/resources. + +4. Typecheck passes. + +5. Relevant desktop/runtime tests pass, updated to reflect the new direct-from-bundle model. + +6. The resulting implementation is materially simpler: + fewer runtime path concepts, fewer staging/install branches, fewer indirections. + +## Review Bar + +The implementation should be rejected if: + +- it still copies the runtime anywhere before execution +- it keeps runtime version directories in the execution path +- it preserves the stable installed runtime launcher concept without a hard platform reason +- it adds migration complexity for old installs +- it introduces new fallback branches instead of removing obsolete ones diff --git a/packages/app/src/components/add-host-method-modal.tsx b/packages/app/src/components/add-host-method-modal.tsx index 834aa27f0..1d9984d70 100644 --- a/packages/app/src/components/add-host-method-modal.tsx +++ b/packages/app/src/components/add-host-method-modal.tsx @@ -65,7 +65,7 @@ export function AddHostMethodModal({ Direct connection - Local network or Tailscale (unencrypted). + Local network or VPN. @@ -74,7 +74,7 @@ export function AddHostMethodModal({ Scan QR code - Relay pairing (E2EE). + Encrypted relay connection. ) : null} @@ -83,7 +83,7 @@ export function AddHostMethodModal({ Paste pairing link - Relay pairing (E2EE). + Encrypted relay connection. diff --git a/packages/app/src/components/add-host-modal.tsx b/packages/app/src/components/add-host-modal.tsx index fbb879554..7fd530015 100644 --- a/packages/app/src/components/add-host-modal.tsx +++ b/packages/app/src/components/add-host-modal.tsx @@ -103,13 +103,13 @@ function buildConnectionFailureCopy(endpoint: string, error: unknown): { title: rawLower.includes("connection refused") || rawLower.includes("err_connection_refused") ) { - detail = "Connection was refused. Is the daemon running on that host and port?"; + detail = "Connection refused. Is the server running at this address?"; } else if (rawLower.includes("enotfound") || rawLower.includes("not found")) { detail = "Host not found. Check the hostname and try again."; } else if (rawLower.includes("ehostunreach") || rawLower.includes("host is unreachable")) { detail = "Host is unreachable. Check your network and firewall."; } else if (rawLower.includes("certificate") || rawLower.includes("tls") || rawLower.includes("ssl")) { - detail = "TLS/certificate error. This app expects a daemon reachable over the local network or via relay."; + detail = "TLS error. Direct connections use an unencrypted local connection. Use relay for remote access."; } else if (raw) { detail = "Unable to connect. Check the host/port and that the daemon is reachable."; } else { @@ -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); @@ -217,7 +221,7 @@ export function AddHostModal({ visible, onClose, onCancel, onSaved, targetServer return ( - Connect to a daemon by entering host:port. + Enter the address of a Paseo server. Host @@ -225,7 +229,7 @@ export function AddHostModal({ visible, onClose, onCancel, onSaved, targetServer ref={hostInputRef} value={endpointRaw} onChangeText={setEndpointRaw} - placeholder="host:6767" + placeholder="hostname:port" placeholderTextColor={theme.colors.foregroundMuted} style={styles.input} autoCapitalize="none" diff --git a/packages/app/src/components/pair-link-modal.tsx b/packages/app/src/components/pair-link-modal.tsx index a0b1c22c8..c49c35dea 100644 --- a/packages/app/src/components/pair-link-modal.tsx +++ b/packages/app/src/components/pair-link-modal.tsx @@ -149,7 +149,7 @@ export function PairLinkModal({ visible, onClose, onCancel, onSaved, targetServe return ( - Paste the daemon’s pairing link. + Paste the pairing link from your server. Pairing link diff --git a/packages/app/src/contexts/daemon-registry-context.test.ts b/packages/app/src/contexts/daemon-registry-context.test.ts index f5b3c3b17..13e2ca891 100644 --- a/packages/app/src/contexts/daemon-registry-context.test.ts +++ b/packages/app/src/contexts/daemon-registry-context.test.ts @@ -2,6 +2,8 @@ import { describe, expect, it } from 'vitest' import { hostHasDirectEndpoint, registryHasDirectEndpoint, + reconcileDesktopStartupRegistry, + resolveManagedDesktopStartupStatus, type HostProfile, } from './daemon-registry-context' @@ -10,6 +12,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 +29,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 +59,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 +76,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 +84,240 @@ 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) + }) +}) + +describe('resolveManagedDesktopStartupStatus', () => { + it('starts the managed daemon when management is enabled', async () => { + const managedStatus = { + runtimeId: 'runtime_1', + runtimeVersion: '1.2.3', + runtimeRoot: '/runtime', + managedHome: '/home', + transportType: 'socket', + transportPath: '/tmp/paseo.sock', + daemonPid: 123, + daemonRunning: true, + daemonStatus: 'running', + logPath: '/tmp/daemon.log', + serverId: 'srv_managed', + hostname: 'managed-host', + relayEnabled: true, + tcpEnabled: false, + tcpListen: null, + cliShimPath: null, + } + let startCalls = 0 + let statusCalls = 0 + + const result = await resolveManagedDesktopStartupStatus({ + settings: { manageBuiltInDaemon: true }, + startManagedDaemonFn: async () => { + startCalls += 1 + return managedStatus + }, + getManagedDaemonStatusFn: async () => { + statusCalls += 1 + return managedStatus + }, + }) + + expect(result).toEqual(managedStatus) + expect(startCalls).toBe(1) + expect(statusCalls).toBe(0) + }) + + it('only reads managed daemon status when management is paused', async () => { + const managedStatus = { + runtimeId: 'runtime_1', + runtimeVersion: '1.2.3', + runtimeRoot: '/runtime', + managedHome: '/home', + transportType: 'socket', + transportPath: '/tmp/paseo.sock', + daemonPid: null, + daemonRunning: false, + daemonStatus: 'stopped', + logPath: '/tmp/daemon.log', + serverId: null, + hostname: null, + relayEnabled: true, + tcpEnabled: false, + tcpListen: null, + cliShimPath: null, + } + let startCalls = 0 + let statusCalls = 0 + + const result = await resolveManagedDesktopStartupStatus({ + settings: { manageBuiltInDaemon: false }, + startManagedDaemonFn: async () => { + startCalls += 1 + return managedStatus + }, + getManagedDaemonStatusFn: async () => { + statusCalls += 1 + return managedStatus + }, + }) + + expect(result).toEqual(managedStatus) + expect(startCalls).toBe(0) + expect(statusCalls).toBe(1) + }) +}) diff --git a/packages/app/src/contexts/daemon-registry-context.tsx b/packages/app/src/contexts/daemon-registry-context.tsx index 24f17a188..9649b89da 100644 --- a/packages/app/src/contexts/daemon-registry-context.tsx +++ b/packages/app/src/contexts/daemon-registry-context.tsx @@ -4,33 +4,67 @@ import AsyncStorage from '@react-native-async-storage/async-storage' import { useQuery, useQueryClient } from '@tanstack/react-query' import { decodeOfferFragmentPayload, normalizeHostPort } from '@/utils/daemon-endpoints' import { probeConnection } from '@/utils/test-daemon-connection' +import { useAppSettings, type AppSettings } from '@/hooks/use-settings' import { ConnectionOfferSchema, type ConnectionOffer } from '@server/shared/connection-offer' +import { + getManagedDaemonStatus, + 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 +73,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 +120,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 +142,401 @@ 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 +} + +export async function resolveManagedDesktopStartupStatus(input: { + settings: Pick + startManagedDaemonFn?: () => Promise + getManagedDaemonStatusFn?: () => Promise +}): Promise { + if (input.settings.manageBuiltInDaemon) { + return await (input.startManagedDaemonFn ?? startManagedDaemon)() + } + + return await (input.getManagedDaemonStatusFn ?? getManagedDaemonStatus)() +} + +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 +545,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,7 +563,9 @@ export function useDaemonRegistry(): DaemonRegistryContextValue { export function DaemonRegistryProvider({ children }: { children: ReactNode }) { const queryClient = useQueryClient() + const desktopStartupReconciledRef = useRef(false) const localhostBootstrapAttemptedRef = useRef(false) + const { settings, isLoading: settingsLoading } = useAppSettings() const { data: daemons = [], isPending, @@ -123,10 +589,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 +608,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 +638,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 +667,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 +682,121 @@ export function DaemonRegistryProvider({ children }: { children: ReactNode }) { useEffect(() => { if (isPending) return + if (settingsLoading) 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 resolveManagedDesktopStartupStatus({ + settings, + }) + if (managedDaemon.daemonRunning) { + 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, + settings.manageBuiltInDaemon, + settingsLoading, + ]) + + 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 +804,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 +816,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 +849,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 +908,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..6515dc193 100644 --- a/packages/app/src/desktop/components/desktop-updates-section.tsx +++ b/packages/app/src/desktop/components/desktop-updates-section.tsx @@ -1,64 +1,116 @@ -import { useCallback, useState } from "react"; -import { Alert, Text, View } from "react-native"; +import { useCallback, useEffect, useState } from "react"; +import { ActivityIndicator, Alert, Image, Pressable, 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 { ArrowUpRight } from "lucide-react-native"; +import { AdaptiveModalSheet } from "@/components/adaptive-modal-sheet"; import { Button } from "@/components/ui/button"; +import { useAppSettings } from "@/hooks/use-settings"; import { confirmDialog } from "@/utils/confirm-dialog"; +import { openExternalUrl } from "@/utils/open-external-url"; import { - buildDaemonUpdateDiagnostics, formatVersionWithPrefix, - getLocalDaemonVersion, isVersionMismatch, - runLocalDaemonUpdate, - shouldShowDesktopUpdateSection, } from "@/desktop/updates/desktop-updates"; +import { + getManagedDaemonLogs, + getManagedDaemonPairing, + getManagedDaemonStatus, + installManagedCliShim, + restartManagedDaemon, + shouldUseManagedDesktopDaemon, + startManagedDaemon, + stopManagedDaemon, + uninstallManagedCliShim, + type ManagedDaemonLogs, + type ManagedPairingOffer, + type ManagedDaemonStatus, + type CliManualInstructions, +} 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( + const showSection = shouldUseManagedDesktopDaemon(); + const { settings, updateSettings } = useAppSettings(); + const [managedStatus, setManagedStatus] = useState(null); + const [statusError, setStatusError] = useState(null); + const [isRestartingDaemon, setIsRestartingDaemon] = useState(false); + const [isUpdatingDaemonManagement, setIsUpdatingDaemonManagement] = useState(false); + const [isInstallingCli, setIsInstallingCli] = useState(false); + const [statusMessage, setStatusMessage] = useState(null); + const [cliStatusMessage, setCliStatusMessage] = useState(null); + const [managedLogs, setManagedLogs] = useState(null); + const [isLogsModalOpen, setIsLogsModalOpen] = useState(false); + const [isPairingModalOpen, setIsPairingModalOpen] = useState(false); + const [isCliInstallModalOpen, setIsCliInstallModalOpen] = useState(false); + const [isLoadingPairing, setIsLoadingPairing] = useState(false); + const [pairingOffer, setPairingOffer] = useState(null); + const [cliInstallInstructions, setCliInstallInstructions] = useState( null ); + const [pairingStatusMessage, setPairingStatusMessage] = useState(null); + + const loadManagedStatus = useCallback(() => { + if (!showSection) { + return Promise.resolve(); + } + return Promise.all([getManagedDaemonStatus(), getManagedDaemonLogs()]) + .then(([status, logs]) => { + setManagedStatus(status); + setManagedLogs(logs); + setStatusError(null); + }) + .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 daemonStatusStateText = + statusError ?? + (managedStatus?.daemonRunning + ? managedStatus?.daemonStatus ?? "running" + : "not running"); + const daemonStatusDetailText = `PID ${managedStatus?.daemonPid ? managedStatus.daemonPid : "—"}`; + const isDaemonManagementPaused = !settings.manageBuiltInDaemon; + const daemonActionLabel = managedStatus?.daemonRunning ? "Restart daemon" : "Start daemon"; + const daemonActionMessage = managedStatus?.daemonRunning + ? "Restarts the built-in daemon." + : isDaemonManagementPaused + ? "Starts the built-in daemon manually. Paseo will not auto-start it while paused." + : "Starts the built-in daemon."; const handleUpdateLocalDaemon = useCallback(() => { if (!showSection) { return; } - if (isUpdatingLocalDaemon) { + if (isRestartingDaemon) { return; } void confirmDialog({ - title: "Update local daemon", - message: - "This updates the Paseo daemon on this computer. A restart is required afterwards.", - confirmLabel: "Update daemon", + title: daemonActionLabel, + message: managedStatus?.daemonRunning + ? "This will restart the built-in daemon. The app will reconnect automatically." + : "This will start the built-in daemon.", + confirmLabel: daemonActionLabel, cancelLabel: "Cancel", }) .then((confirmed) => { @@ -66,71 +118,213 @@ 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; - } + const action = managedStatus?.daemonRunning ? restartManagedDaemon : startManagedDaemon; - setLocalDaemonUpdateMessage( - "Local daemon update finished. Restart is required: run `paseo daemon restart` on this computer." + void action() + .then((status) => { + setManagedStatus(status); + setStatusMessage( + managedStatus?.daemonRunning ? "Daemon restarted." : "Daemon started." ); - if (result.stdout.trim().length > 0 || result.stderr.trim().length > 0) { - setLocalDaemonUpdateDiagnostics(diagnostics); - } - - void getLocalDaemonVersion().then((versionResult) => { - setLocalDaemonVersion(versionResult.version); - setLocalDaemonVersionError(versionResult.error); - }); + return loadManagedStatus(); }) .catch((error) => { - console.error("[Settings] Failed to update local daemon", error); + console.error("[Settings] Failed to change managed daemon state", 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(`${daemonActionLabel} 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 action confirmation", error); + Alert.alert("Error", "Unable to open the daemon confirmation dialog."); }); - }, [isUpdatingLocalDaemon, showSection]); + }, [daemonActionLabel, isRestartingDaemon, loadManagedStatus, managedStatus?.daemonRunning, showSection]); - const handleCopyDaemonDiagnostics = useCallback(() => { - if (!localDaemonUpdateDiagnostics) { + const handleToggleDaemonManagement = useCallback(() => { + if (isUpdatingDaemonManagement) { return; } - void Clipboard.setStringAsync(localDaemonUpdateDiagnostics) - .then(() => { - Alert.alert("Copied", "Daemon update diagnostics copied."); + if (!settings.manageBuiltInDaemon) { + setIsUpdatingDaemonManagement(true); + setStatusMessage(null); + void updateSettings({ manageBuiltInDaemon: true }) + .then(() => { + setStatusMessage("Paseo will resume managing the built-in daemon on startup."); + }) + .catch((error) => { + console.error("[Settings] Failed to update built-in daemon management", error); + Alert.alert("Error", "Unable to update built-in daemon management."); + }) + .finally(() => { + setIsUpdatingDaemonManagement(false); + }); + return; + } + + void confirmDialog({ + title: "Pause built-in daemon", + message: + "This will stop the built-in daemon immediately and prevent Paseo from auto-starting it on launch. Running agents and terminals connected to the built-in daemon will be stopped.", + confirmLabel: "Pause and stop", + cancelLabel: "Cancel", + destructive: true, + }) + .then((confirmed) => { + if (!confirmed) { + return; + } + + setIsUpdatingDaemonManagement(true); + setStatusMessage(null); + + const stopPromise = managedStatus?.daemonRunning + ? stopManagedDaemon() + : Promise.resolve(managedStatus ?? null); + + void stopPromise + .then(() => updateSettings({ manageBuiltInDaemon: false })) + .then(() => loadManagedStatus()) + .then(() => { + setStatusMessage( + "Paseo paused the built-in daemon and will no longer auto-start it on launch." + ); + }) + .catch((error) => { + console.error("[Settings] Failed to pause built-in daemon management", error); + Alert.alert("Error", "Unable to pause built-in daemon management."); + }) + .finally(() => { + setIsUpdatingDaemonManagement(false); + }); }) .catch((error) => { - console.error("[Settings] Failed to copy daemon update diagnostics", error); - Alert.alert("Error", "Unable to copy diagnostics."); + console.error("[Settings] Failed to open built-in daemon pause confirmation", error); + Alert.alert("Error", "Unable to open the daemon confirmation dialog."); }); - }, [localDaemonUpdateDiagnostics]); + }, [ + isUpdatingDaemonManagement, + loadManagedStatus, + managedStatus, + settings.manageBuiltInDaemon, + updateSettings, + ]); + + const handleToggleCliShim = useCallback(() => { + if (!showSection || isInstallingCli) { + return; + } + setIsInstallingCli(true); + const isInstalling = !managedStatus?.cliShimPath; + setCliStatusMessage( + isInstalling + ? "A permissions popup may appear while Paseo installs the CLI globally." + : null + ); + const action = managedStatus?.cliShimPath ? uninstallManagedCliShim : installManagedCliShim; + void action() + .then((result) => { + setCliStatusMessage(result.message); + if (result.manualInstructions) { + setCliInstallInstructions(result.manualInstructions); + setIsCliInstallModalOpen(true); + } else { + setCliInstallInstructions(null); + setIsCliInstallModalOpen(false); + } + return loadManagedStatus(); + }) + .catch((error) => { + const message = error instanceof Error ? error.message : String(error); + setCliStatusMessage(`CLI install failed: ${message}`); + }) + .finally(() => { + setIsInstallingCli(false); + }); + }, [isInstallingCli, loadManagedStatus, managedStatus?.cliShimPath, showSection]); + + const handleCopyCliInstallCommands = useCallback(() => { + if (!cliInstallInstructions?.commands) { + return; + } + void Clipboard.setStringAsync(cliInstallInstructions.commands) + .then(() => { + Alert.alert("Copied", "CLI install commands copied."); + }) + .catch((error) => { + console.error("[Settings] Failed to copy CLI install commands", error); + Alert.alert("Error", "Unable to copy CLI install commands."); + }); + }, [cliInstallInstructions?.commands]); + + const handleCopyLogPath = useCallback(() => { + const logPath = managedLogs?.logPath ?? managedStatus?.logPath; + if (!logPath) { + return; + } + + void Clipboard.setStringAsync(logPath) + .then(() => { + Alert.alert("Copied", "Log path copied."); + }) + .catch((error) => { + console.error("[Settings] Failed to copy log path", error); + Alert.alert("Error", "Unable to copy log path."); + }); + }, [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 not available."); + } + }) + .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]); if (!showSection) { return null; @@ -138,32 +332,131 @@ export function LocalDaemonSection({ appVersion }: LocalDaemonSectionProps) { return ( - Local daemon + + Built-in daemon + void openExternalUrl(ADVANCED_DAEMON_SETTINGS_URL)} + style={styles.sectionLink} + > + Advanced settings + + + - Version - {daemonVersionHint} + Status + Only the built-in managed daemon is shown here. + + + {daemonStatusStateText} + {daemonStatusDetailText} - {localDaemonVersionText} - Update daemon + Daemon management - Updates the daemon on this computer only. Requires a restart. + {isDaemonManagementPaused + ? "Paused. Paseo will not auto-start the built-in daemon on app launch." + : "Enabled. Paseo will start the built-in daemon automatically when needed."} - {localDaemonUpdateMessage ? ( - {localDaemonUpdateMessage} + + + + + + {daemonActionLabel} + {daemonActionMessage} + {statusMessage ? ( + {statusMessage} ) : null} + + + + Command line (CLI) + + Adds the `paseo` command to your terminal. + + {cliStatusMessage ? {cliStatusMessage} : null} + + + + + + Log file + + {managedLogs?.logPath ?? + managedStatus?.logPath ?? + "Log path unavailable."} + + + + {(managedLogs?.logPath ?? managedStatus?.logPath) ? ( + + ) : null} + + + + + + Pair device + + Connect your phone to this computer. + + + @@ -171,25 +464,173 @@ export function LocalDaemonSection({ appVersion }: LocalDaemonSectionProps) { {daemonVersionMismatch ? ( - Desktop app and local daemon versions differ. Keep both on the same version to avoid - stability issues or breaking changes. + App and daemon versions don't match. Update both to the same version for the best + experience. ) : null} - {localDaemonUpdateDiagnostics ? ( - - - Daemon update diagnostics - + - - {localDaemonUpdateDiagnostics} + + + + setIsPairingModalOpen(false)} + title="Pair device" + testID="managed-daemon-pairing-dialog" + > + + + + setIsLogsModalOpen(false)} + title="Daemon logs" + testID="managed-daemon-logs-dialog" + snapPoints={["70%", "92%"]} + > + + + {managedLogs?.logPath ?? + managedStatus?.logPath ?? + "Log path unavailable."} + + + {managedLogs?.contents.length ? managedLogs.contents : "(log file is empty)"} - ) : null} + + + ); +} + +const ADVANCED_DAEMON_SETTINGS_URL = "https://paseo.sh/docs/configuration"; + +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} + + + + ); } @@ -198,12 +639,26 @@ const styles = StyleSheet.create((theme) => ({ section: { marginBottom: theme.spacing[6], }, + sectionHeader: { + alignItems: "center", + flexDirection: "row", + justifyContent: "space-between", + marginBottom: theme.spacing[3], + marginLeft: theme.spacing[1], + }, sectionTitle: { color: theme.colors.foregroundMuted, fontSize: theme.fontSize.xs, fontWeight: theme.fontWeight.normal, - marginBottom: theme.spacing[3], - marginLeft: theme.spacing[1], + }, + sectionLink: { + alignItems: "center", + flexDirection: "row", + gap: theme.spacing[1], + }, + sectionLinkText: { + color: theme.colors.foregroundMuted, + fontSize: theme.fontSize.xs, }, card: { backgroundColor: theme.colors.surface2, @@ -227,6 +682,22 @@ const styles = StyleSheet.create((theme) => ({ flex: 1, marginRight: theme.spacing[3], }, + actionGroup: { + flexDirection: "row", + gap: theme.spacing[2], + flexWrap: "wrap", + justifyContent: "flex-end", + }, + statusValueGroup: { + alignItems: "flex-end", + gap: 2, + }, + primaryActionButton: { + minWidth: 124, + }, + secondaryActionButton: { + minWidth: 112, + }, rowTitle: { color: theme.colors.foreground, fontSize: theme.fontSize.base, @@ -236,6 +707,10 @@ const styles = StyleSheet.create((theme) => ({ fontSize: theme.fontSize.sm, fontWeight: theme.fontWeight.normal, }, + valueSubtext: { + color: theme.colors.foregroundMuted, + fontSize: theme.fontSize.xs, + }, hintText: { color: theme.colors.foregroundMuted, fontSize: theme.fontSize.xs, @@ -259,28 +734,61 @@ 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, + }, + codeBlock: { + color: theme.colors.foregroundMuted, + fontSize: theme.fontSize.xs, + fontFamily: "ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace", + lineHeight: 18, + borderWidth: 1, + borderColor: theme.colors.border, + borderRadius: theme.borderRadius.md, + backgroundColor: theme.colors.surface0, + padding: theme.spacing[3], + }, + modalActions: { + flexDirection: "row", + justifyContent: "flex-end", + gap: theme.spacing[2], }, })); diff --git a/packages/app/src/desktop/managed-runtime/managed-runtime.test.ts b/packages/app/src/desktop/managed-runtime/managed-runtime.test.ts new file mode 100644 index 000000000..e505e87df --- /dev/null +++ b/packages/app/src/desktop/managed-runtime/managed-runtime.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it } from "vitest"; +import { parseCliShimResult } from "./managed-runtime"; + +describe("parseCliShimResult", () => { + it("parses manual install payloads from the desktop backend", () => { + expect( + parseCliShimResult({ + status: "manualInstallRequired", + installed: false, + path: "/usr/local/bin/paseo", + message: "Install it manually.", + manualInstructions: { + title: "Install from Terminal", + detail: "Run these commands.", + commands: "sudo tee /usr/local/bin/paseo", + }, + }) + ).toEqual({ + status: "manualInstallRequired", + installed: false, + path: "/usr/local/bin/paseo", + message: "Install it manually.", + manualInstructions: { + title: "Install from Terminal", + detail: "Run these commands.", + commands: "sudo tee /usr/local/bin/paseo", + }, + }); + }); + + it("falls back to installed or removed when older payloads omit status", () => { + expect( + parseCliShimResult({ + installed: true, + path: "/usr/local/bin/paseo", + message: "Installed.", + }) + ).toEqual({ + status: "installed", + installed: true, + path: "/usr/local/bin/paseo", + message: "Installed.", + manualInstructions: null, + }); + }); +}); 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..ef402d2c0 --- /dev/null +++ b/packages/app/src/desktop/managed-runtime/managed-runtime.ts @@ -0,0 +1,282 @@ +import { invokeDesktopCommand } from "@/desktop/tauri/invoke-desktop-command"; +import { getTauri, isTauriEnvironment } from "@/utils/tauri"; + +export type ManagedRuntimeStatus = { + runtimeId: string; + runtimeVersion: string; + runtimeRoot: string; + 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 = { + status: + | "installed" + | "removed" + | "elevationDenied" + | "automaticInstallUnavailable" + | "manualInstallRequired"; + installed: boolean; + path: string | null; + message: string; + manualInstructions: CliManualInstructions | null; +}; + +export type CliManualInstructions = { + title: string; + detail: string; + commands: 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) ?? "", + runtimeRoot: toStringOrNull(raw.runtimeRoot) ?? "", + 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 parseCliManualInstructions(raw: unknown): CliManualInstructions | null { + if (!isRecord(raw)) { + return null; + } + return { + title: toStringOrNull(raw.title) ?? "", + detail: toStringOrNull(raw.detail) ?? "", + commands: toStringOrNull(raw.commands) ?? "", + }; +} + +export function parseCliShimResult(raw: unknown): CliShimResult { + if (!isRecord(raw)) { + throw new Error("Unexpected CLI shim response."); + } + return { + status: + (toStringOrNull(raw.status) as CliShimResult["status"] | null) ?? + (raw.installed === true ? "installed" : "removed"), + installed: raw.installed === true, + path: toStringOrNull(raw.path), + message: toStringOrNull(raw.message) ?? "", + manualInstructions: parseCliManualInstructions(raw.manualInstructions), + }; +} + +export function shouldUseManagedDesktopDaemon(): boolean { + return isTauriEnvironment() && getTauri() !== null; +} + +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/hooks/use-settings.test.ts b/packages/app/src/hooks/use-settings.test.ts new file mode 100644 index 000000000..18370ae19 --- /dev/null +++ b/packages/app/src/hooks/use-settings.test.ts @@ -0,0 +1,53 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const asyncStorageMock = vi.hoisted(() => ({ + getItem: vi.fn<(_: string) => Promise>(), + setItem: vi.fn<(_: string, __: string) => Promise>(), +})); + +vi.mock("@react-native-async-storage/async-storage", () => ({ + default: asyncStorageMock, +})); + +describe("use-settings", () => { + beforeEach(() => { + vi.resetModules(); + asyncStorageMock.getItem.mockReset(); + asyncStorageMock.setItem.mockReset(); + }); + + it("defaults built-in daemon management to enabled when storage is empty", async () => { + asyncStorageMock.getItem.mockResolvedValue(null); + asyncStorageMock.setItem.mockResolvedValue(); + + const mod = await import("./use-settings"); + const result = await mod.loadSettingsFromStorage(); + + expect(result).toEqual(mod.DEFAULT_APP_SETTINGS); + expect(asyncStorageMock.setItem).toHaveBeenCalledWith( + mod.APP_SETTINGS_KEY, + JSON.stringify(mod.DEFAULT_APP_SETTINGS) + ); + }); + + it("loads persisted built-in daemon management state", async () => { + asyncStorageMock.getItem.mockImplementation(async (key: string) => { + if (key === "@paseo:app-settings") { + return JSON.stringify({ + theme: "light", + manageBuiltInDaemon: false, + }); + } + return null; + }); + + const mod = await import("./use-settings"); + const result = await mod.loadSettingsFromStorage(); + + expect(result).toEqual({ + theme: "light", + manageBuiltInDaemon: false, + }); + expect(asyncStorageMock.setItem).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/app/src/hooks/use-settings.ts b/packages/app/src/hooks/use-settings.ts index bbe28fe4d..6bb837c99 100644 --- a/packages/app/src/hooks/use-settings.ts +++ b/packages/app/src/hooks/use-settings.ts @@ -2,16 +2,18 @@ import { useCallback } from "react"; import AsyncStorage from "@react-native-async-storage/async-storage"; import { useQuery, useQueryClient } from "@tanstack/react-query"; -const APP_SETTINGS_KEY = "@paseo:app-settings"; +export const APP_SETTINGS_KEY = "@paseo:app-settings"; const LEGACY_SETTINGS_KEY = "@paseo:settings"; const APP_SETTINGS_QUERY_KEY = ["app-settings"]; export interface AppSettings { theme: "dark" | "light" | "auto"; + manageBuiltInDaemon: boolean; } -const DEFAULT_APP_SETTINGS: AppSettings = { +export const DEFAULT_APP_SETTINGS: AppSettings = { theme: "dark", + manageBuiltInDaemon: true, }; export interface UseAppSettingsReturn { @@ -66,7 +68,7 @@ export function useAppSettings(): UseAppSettingsReturn { }; } -async function loadSettingsFromStorage(): Promise { +export async function loadSettingsFromStorage(): Promise { try { const stored = await AsyncStorage.getItem(APP_SETTINGS_KEY); if (stored) { @@ -98,6 +100,9 @@ function pickAppSettingsFromLegacy(legacy: Record): 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", }, ], @@ -984,7 +990,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..162a7b02d 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 `Local (${connection.path})`; + } + if (connection.type === "directPipe") { + return `Local (${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: "Local" }; + } + if (activeConnection.type === "directPipe") { + return { icon: , text: "Local" }; + } + return { + icon: , + text: activeConnection.display, + }; +} + const styles = StyleSheet.create((theme) => ({ loadingContainer: { flex: 1, @@ -669,7 +705,7 @@ export default function SettingsScreen() { ); const restartConfirmationMessage = - "This will immediately stop the Paseo daemon process. The app will disconnect until it restarts."; + "This will restart the daemon. The app will reconnect automatically."; if (isLoading) { return ( @@ -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/cli.ts b/packages/cli/src/cli.ts index 9868f48e4..2bb8e2095 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -66,7 +66,7 @@ export function createCli(): Command { .option('--label ', 'Filter by label (can be used multiple times)', collectMultiple, []) .option('--thinking ', 'Filter by thinking option ID') .option('--json', 'Output in JSON format') - .option('--host ', 'Daemon host:port (default: localhost:6767)') + .option('--host ', 'Daemon host target (default: local socket/pipe, then localhost:6767)') .action(withOutput(runLsCommand)) program @@ -86,14 +86,14 @@ export function createCli(): Command { .option('--label ', 'Add label(s) to the agent (can be used multiple times)', collectMultiple, []) .option('--output-schema ', 'Output JSON matching the provided schema file path or inline JSON schema') .option('--json', 'Output in JSON format') - .option('--host ', 'Daemon host:port (default: localhost:6767)') + .option('--host ', 'Daemon host target (default: local socket/pipe, then localhost:6767)') .action(withOutput(runRunCommand)) program .command('attach') .description("Attach to a running agent's output stream") .argument('', 'Agent ID (or prefix)') - .option('--host ', 'Daemon host:port (default: localhost:6767)') + .option('--host ', 'Daemon host target (default: local socket/pipe, then localhost:6767)') .action(runAttachCommand) program @@ -104,7 +104,7 @@ export function createCli(): Command { .option('--tail ', 'Show last n entries') .option('--filter ', 'Filter by event type (tools, text, errors, permissions)') .option('--since