diff --git a/packages/app/metro.config.js b/packages/app/metro.config.js index a4ac59f3c..027ece81b 100644 --- a/packages/app/metro.config.js +++ b/packages/app/metro.config.js @@ -1,10 +1,12 @@ const { getDefaultConfig } = require("expo/metro-config"); +const exclusionList = require("metro-config/src/defaults/exclusionList"); const { resolve } = require("metro-resolver"); const fs = require("fs"); const path = require("path"); const projectRoot = __dirname; const appNodeModulesRoot = path.resolve(projectRoot, "node_modules"); +const appSrcRoot = path.resolve(projectRoot, "src"); const serverSrcRoot = path.resolve(projectRoot, "../server/src"); const relaySrcRoot = path.resolve(projectRoot, "../relay/src"); const customWebPlatform = (process.env.PASEO_WEB_PLATFORM ?? "") @@ -14,6 +16,9 @@ const customWebPlatform = (process.env.PASEO_WEB_PLATFORM ?? "") const config = getDefaultConfig(projectRoot); const defaultResolveRequest = config.resolver.resolveRequest ?? resolve; +const escapedAppSrcRoot = appSrcRoot + .replace(/[|\\{}()[\]^$+*?.]/g, "\\$&") + .replace(/\//g, "[\\\\/]"); config.resolver.extraNodeModules = { ...(config.resolver.extraNodeModules ?? {}), @@ -22,6 +27,9 @@ config.resolver.extraNodeModules = { "react/jsx-runtime": path.join(appNodeModulesRoot, "react/jsx-runtime"), "react/jsx-dev-runtime": path.join(appNodeModulesRoot, "react/jsx-dev-runtime"), }; +config.resolver.blockList = exclusionList([ + new RegExp(`^${escapedAppSrcRoot}[\\\\/].*\\.(test|spec)\\.(ts|tsx)$`), +]); function isLocalModuleImport(moduleName) { return ( diff --git a/packages/app/src/app/index-startup.ts b/packages/app/src/app/index-startup.ts new file mode 100644 index 000000000..337f4304c --- /dev/null +++ b/packages/app/src/app/index-startup.ts @@ -0,0 +1,41 @@ +export const WELCOME_ROUTE = '/welcome' + +export function shouldWaitOnStartupRace(input: { + registryLoading: boolean + onlineServerId: string | null + hasTimedOut: boolean + isDesktopStartupRace: boolean + daemonCount: number + pathname: string +}): boolean { + if (input.registryLoading) { + return true + } + if (input.onlineServerId) { + return false + } + if (input.pathname === WELCOME_ROUTE) { + return false + } + if (input.hasTimedOut) { + return false + } + return input.isDesktopStartupRace || input.daemonCount > 0 +} + +export function shouldRedirectToWelcome(input: { + registryLoading: boolean + onlineServerId: string | null + hasTimedOut: boolean + pathname: string + isDesktopStartupRace: boolean + daemonCount: number +}): boolean { + if (input.registryLoading || input.onlineServerId || !input.hasTimedOut) { + return false + } + if (input.pathname !== '/' && input.pathname !== '') { + return false + } + return input.isDesktopStartupRace || input.daemonCount > 0 +} diff --git a/packages/app/src/app/index.tsx b/packages/app/src/app/index.tsx index 45fe75571..3502d86bf 100644 --- a/packages/app/src/app/index.tsx +++ b/packages/app/src/app/index.tsx @@ -1,65 +1,120 @@ -import { useEffect, useMemo } from "react"; -import { useLocalSearchParams, usePathname, useRouter } from "expo-router"; -import { useDaemonRegistry } from "@/contexts/daemon-registry-context"; -import { useFormPreferences } from "@/hooks/use-form-preferences"; -import { buildHostRootRoute } from "@/utils/host-routes"; -import { StartupSplashScreen } from "@/screens/startup-splash-screen"; -import { WelcomeScreen } from "@/components/welcome-screen"; +import { useEffect, useSyncExternalStore, useState } from 'react' +import { usePathname, useRouter } from 'expo-router' +import { useDaemonRegistry } from '@/contexts/daemon-registry-context' +import { shouldUseManagedDesktopDaemon } from '@/desktop/managed-runtime/managed-runtime' +import { buildHostRootRoute } from '@/utils/host-routes' +import { StartupSplashScreen } from '@/screens/startup-splash-screen' +import { WelcomeScreen } from '@/components/welcome-screen' +import { getHostRuntimeStore, isHostRuntimeConnected } from '@/runtime/host-runtime' +import { + shouldRedirectToWelcome, + shouldWaitOnStartupRace, + WELCOME_ROUTE, +} from './index-startup' + +const STARTUP_TIMEOUT_MS = 30_000 + +function useAnyHostOnline(serverIds: string[]): string | null { + const runtime = getHostRuntimeStore() + return useSyncExternalStore( + (onStoreChange) => runtime.subscribeAll(onStoreChange), + () => { + let firstOnlineServerId: string | null = null + let firstOnlineAt: string | null = null + for (const serverId of serverIds) { + const snapshot = runtime.getSnapshot(serverId) + const lastOnlineAt = snapshot?.lastOnlineAt ?? null + if (!isHostRuntimeConnected(snapshot) || !lastOnlineAt) { + continue + } + if (!firstOnlineAt || lastOnlineAt < firstOnlineAt) { + firstOnlineAt = lastOnlineAt + firstOnlineServerId = serverId + } + } + return firstOnlineServerId + }, + () => { + let firstOnlineServerId: string | null = null + let firstOnlineAt: string | null = null + for (const serverId of serverIds) { + const snapshot = runtime.getSnapshot(serverId) + const lastOnlineAt = snapshot?.lastOnlineAt ?? null + if (!isHostRuntimeConnected(snapshot) || !lastOnlineAt) { + continue + } + if (!firstOnlineAt || lastOnlineAt < firstOnlineAt) { + firstOnlineAt = lastOnlineAt + firstOnlineServerId = serverId + } + } + return firstOnlineServerId + } + ) +} export default function Index() { - const router = useRouter(); - const pathname = usePathname(); - const params = useLocalSearchParams<{ serverId?: string }>(); - const { daemons, isLoading: registryLoading, isReconciling } = useDaemonRegistry(); - const { preferences, isLoading: preferencesLoading } = useFormPreferences(); - const requestedServerId = useMemo(() => { - return typeof params.serverId === "string" ? params.serverId.trim() : ""; - }, [params.serverId]); - - const targetServerId = useMemo(() => { - if (daemons.length === 0) { - return null; - } - if (requestedServerId) { - const requested = daemons.find( - (daemon) => daemon.serverId === requestedServerId - ); - if (requested) { - return requested.serverId; - } - } - if (preferences.serverId) { - const match = daemons.find((daemon) => daemon.serverId === preferences.serverId); - if (match) { - return match.serverId; - } - } - return daemons[0]?.serverId ?? null; - }, [daemons, preferences.serverId, requestedServerId]); + const router = useRouter() + const pathname = usePathname() + const { daemons, isLoading: registryLoading } = useDaemonRegistry() + const [hasTimedOut, setHasTimedOut] = useState(false) + const isDesktopStartupRace = shouldUseManagedDesktopDaemon() + const onlineServerId = useAnyHostOnline(daemons.map((daemon) => daemon.serverId)) useEffect(() => { - if (registryLoading || preferencesLoading) { - return; + const timer = setTimeout(() => { + setHasTimedOut(true) + }, STARTUP_TIMEOUT_MS) + return () => { + clearTimeout(timer) } - if (!targetServerId) { - return; - } - if (pathname !== "/" && pathname !== "") { - return; - } - router.replace(buildHostRootRoute(targetServerId) as any); - }, [pathname, preferencesLoading, registryLoading, router, targetServerId]); + }, []) - if (registryLoading || preferencesLoading) { - return ; + useEffect(() => { + if (registryLoading) { + return + } + if (!onlineServerId) { + return + } + if (pathname !== '/' && pathname !== '') { + return + } + router.replace(buildHostRootRoute(onlineServerId) as any) + }, [onlineServerId, pathname, registryLoading, router]) + + useEffect(() => { + if ( + !shouldRedirectToWelcome({ + registryLoading, + onlineServerId, + hasTimedOut, + pathname, + isDesktopStartupRace, + daemonCount: daemons.length, + }) + ) { + return + } + router.replace(WELCOME_ROUTE as any) + }, [daemons.length, hasTimedOut, isDesktopStartupRace, onlineServerId, pathname, registryLoading, router]) + + if ( + shouldWaitOnStartupRace({ + registryLoading, + onlineServerId, + hasTimedOut, + isDesktopStartupRace, + daemonCount: daemons.length, + pathname, + }) + ) { + return } - if (!targetServerId) { - if (isReconciling) { - return ; - } - return ; + if (!onlineServerId) { + return } - return null; + return null } diff --git a/packages/app/src/app/welcome.tsx b/packages/app/src/app/welcome.tsx new file mode 100644 index 000000000..ead2f1c1e --- /dev/null +++ b/packages/app/src/app/welcome.tsx @@ -0,0 +1,5 @@ +import { WelcomeScreen } from '@/components/welcome-screen' + +export default function WelcomeRoute() { + return +} diff --git a/packages/app/src/contexts/daemon-registry-context.test.ts b/packages/app/src/contexts/daemon-registry-context.test.ts index 13e2ca891..407361220 100644 --- a/packages/app/src/contexts/daemon-registry-context.test.ts +++ b/packages/app/src/contexts/daemon-registry-context.test.ts @@ -1,9 +1,8 @@ import { describe, expect, it } from 'vitest' import { + connectionFromListen, hostHasDirectEndpoint, registryHasDirectEndpoint, - reconcileDesktopStartupRegistry, - resolveManagedDesktopStartupStatus, type HostProfile, } from './daemon-registry-context' @@ -12,12 +11,7 @@ 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, - }, + lifecycle: input.lifecycle ?? {}, connections: input.connections ?? [], preferredConnectionId: input.preferredConnectionId ?? null, createdAt: input.createdAt ?? now, @@ -25,6 +19,47 @@ function makeHost(input: Partial & Pick): } } +describe('connectionFromListen', () => { + it('parses tcp listen targets', () => { + expect(connectionFromListen('127.0.0.1:6767')).toEqual({ + id: 'direct:127.0.0.1:6767', + type: 'directTcp', + endpoint: '127.0.0.1:6767', + }) + }) + + it('parses unix socket listen targets', () => { + expect(connectionFromListen('/tmp/paseo.sock')).toEqual({ + id: 'socket:/tmp/paseo.sock', + type: 'directSocket', + path: '/tmp/paseo.sock', + }) + expect(connectionFromListen('unix:///tmp/paseo.sock')).toEqual({ + id: 'socket:/tmp/paseo.sock', + type: 'directSocket', + path: '/tmp/paseo.sock', + }) + }) + + it('parses named pipe listen targets', () => { + expect(connectionFromListen(String.raw`\\.\pipe\paseo-test`)).toEqual({ + id: String.raw`pipe:\\.\pipe\paseo-test`, + type: 'directPipe', + path: String.raw`\\.\pipe\paseo-test`, + }) + expect(connectionFromListen('pipe://paseo-test')).toEqual({ + id: 'pipe:paseo-test', + type: 'directPipe', + path: 'paseo-test', + }) + }) + + it('returns null for unsupported listen targets', () => { + expect(connectionFromListen('')).toBeNull() + expect(connectionFromListen('not-a-listen-target')).toBeNull() + }) +}) + describe('hostHasDirectEndpoint', () => { it('returns true when host has matching direct endpoint', () => { const host = makeHost({ @@ -84,240 +119,3 @@ 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 13e7eb5b4..36d04e22a 100644 --- a/packages/app/src/contexts/daemon-registry-context.tsx +++ b/packages/app/src/contexts/daemon-registry-context.tsx @@ -1,14 +1,11 @@ -import { createContext, useCallback, useContext, useEffect, useRef, useState } from 'react' +import { createContext, useCallback, useContext, useEffect, useRef } from 'react' import type { ReactNode } from 'react' 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' @@ -18,9 +15,6 @@ 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 DirectTcpHostConnection = { @@ -54,12 +48,7 @@ export type HostConnection = | DirectPipeHostConnection | RelayHostConnection -export type HostLifecycle = { - managed: boolean - managedRuntimeId: string | null - managedRuntimeVersion: string | null - associatedServerId: string | null -} +export type HostLifecycle = Record export type HostProfile = { serverId: string @@ -73,33 +62,9 @@ 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 - isReconciling: boolean error: unknown | null upsertDirectConnection: (input: { serverId: string @@ -122,12 +87,7 @@ interface DaemonRegistryContextValue { const DaemonRegistryContext = createContext(null) function defaultLifecycle(): HostLifecycle { - return { - managed: false, - managedRuntimeId: null, - managedRuntimeVersion: null, - associatedServerId: null, - } + return {} } function normalizeHostLabel(value: string | null | undefined, serverId: string): string { @@ -143,51 +103,48 @@ function normalizeEndpointOrNull(endpoint: string): string | null { } } -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) { +export function connectionFromListen(listen: string): HostConnection | null { + const normalizedListen = listen.trim() + if (!normalizedListen) { return null } - if (input.transportType === 'tcp') { - try { - const endpoint = normalizeHostPort(transportPath) - return { - id: `direct:${endpoint}`, - type: 'directTcp', - endpoint, - } - } catch { - return null - } + if (normalizedListen.startsWith('pipe://')) { + const path = normalizedListen.slice('pipe://'.length).trim() + return path ? { id: `pipe:${path}`, type: 'directPipe', path } : null } - if (input.transportType === 'pipe') { + if (normalizedListen.startsWith('unix://')) { + const path = normalizedListen.slice('unix://'.length).trim() + return path ? { id: `socket:${path}`, type: 'directSocket', path } : null + } + + if (normalizedListen.startsWith('\\\\.\\pipe\\')) { return { - id: `pipe:${transportPath}`, + id: `pipe:${normalizedListen}`, type: 'directPipe', - path: transportPath, + path: normalizedListen, } } - if (input.transportType === 'socket') { + if (normalizedListen.startsWith('/')) { return { - id: `socket:${transportPath}`, + id: `socket:${normalizedListen}`, type: 'directSocket', - path: transportPath, + path: normalizedListen, } } - return null + try { + const endpoint = normalizeHostPort(normalizedListen) + return { + id: `direct:${endpoint}`, + type: 'directTcp', + endpoint, + } + } catch { + return null + } } function normalizeStoredConnection(connection: unknown): HostConnection | null { @@ -231,19 +188,8 @@ function normalizeStoredConnection(connection: unknown): HostConnection | 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 normalizeStoredLifecycle(_lifecycle: unknown): HostLifecycle { + return defaultLifecycle() } function normalizeStoredHostProfile(entry: unknown): HostProfile | null { @@ -312,19 +258,13 @@ function hostConnectionEquals(left: HostConnection, right: HostConnection): bool } function hostLifecycleEquals(left: HostLifecycle, right: HostLifecycle): boolean { - return ( - left.managed === right.managed && - left.managedRuntimeId === right.managedRuntimeId && - left.managedRuntimeVersion === right.managedRuntimeVersion && - left.associatedServerId === right.associatedServerId - ) + return JSON.stringify(left) === JSON.stringify(right) } function upsertHostConnectionInProfiles(input: { profiles: HostProfile[] serverId: string label?: string - lifecycle?: Partial connection: HostConnection now?: string }): HostProfile[] { @@ -343,10 +283,7 @@ function upsertHostConnectionInProfiles(input: { const profile: HostProfile = { serverId, label: derivedLabel, - lifecycle: { - ...defaultLifecycle(), - ...(input.lifecycle ?? {}), - }, + lifecycle: defaultLifecycle(), connections: [input.connection], preferredConnectionId: input.connection.id, createdAt: now, @@ -371,10 +308,7 @@ function upsertHostConnectionInProfiles(input: { ) : prev.connections - const nextLifecycle = { - ...prev.lifecycle, - ...(input.lifecycle ?? {}), - } + const nextLifecycle = prev.lifecycle const nextLabel = labelTrimmed ? labelTrimmed : prev.label const nextPreferredConnectionId = prev.preferredConnectionId ?? input.connection.id const changed = @@ -402,144 +336,6 @@ function upsertHostConnectionInProfiles(input: { 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 { const normalized = normalizeEndpointOrNull(endpoint) if (!normalized) { @@ -564,10 +360,8 @@ export function useDaemonRegistry(): DaemonRegistryContextValue { export function DaemonRegistryProvider({ children }: { children: ReactNode }) { const queryClient = useQueryClient() - const desktopStartupReconciledRef = useRef(false) + const desktopStartupAttemptedRef = useRef(false) const localhostBootstrapAttemptedRef = useRef(false) - const [isReconciling, setIsReconciling] = useState(true) - const { settings, isLoading: settingsLoading } = useAppSettings() const { data: daemons = [], isPending, @@ -648,7 +442,6 @@ export function DaemonRegistryProvider({ children }: { children: ReactNode }) { async (input: { serverId: string label?: string - lifecycle?: Partial connection: HostConnection }) => { const now = new Date().toISOString() @@ -656,7 +449,6 @@ export function DaemonRegistryProvider({ children }: { children: ReactNode }) { profiles: readDaemons(), serverId: input.serverId, label: input.label, - lifecycle: input.lifecycle, connection: input.connection, now, }) @@ -684,101 +476,81 @@ export function DaemonRegistryProvider({ children }: { children: ReactNode }) { useEffect(() => { if (isPending) return - if (settingsLoading) return if (!shouldUseManagedDesktopDaemon()) return - if (desktopStartupReconciledRef.current) return - desktopStartupReconciledRef.current = true + if (desktopStartupAttemptedRef.current) return + desktopStartupAttemptedRef.current = true let cancelled = false - const reconcileDesktopStartup = async () => { + const bootstrapDesktopStartup = 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, + await Promise.allSettled([ + (async () => { + const daemon = await startManagedDaemon() + if (cancelled || !daemon.serverId) { + return } - } - } catch { - // Best-effort reconciliation only; keep startup resilient if localhost isn't reachable. - } - if (cancelled) { - return - } + const connection = connectionFromListen(daemon.listen) + if (!connection) { + return + } - const existing = readDaemons() - const next = reconcileDesktopStartupRegistry({ - existing, - managed, - localhost, - }) + await upsertHostConnection({ + serverId: daemon.serverId, + label: daemon.hostname ?? undefined, + connection, + }) + })().catch((managedBootstrapError) => { + if (!cancelled) { + console.warn( + '[DaemonRegistry] Failed to bootstrap desktop daemon connection', + managedBootstrapError + ) + } + }), + (async () => { + const { serverId, hostname } = await probeConnection( + { + id: `bootstrap:${DEFAULT_LOCALHOST_ENDPOINT}`, + type: 'directTcp', + endpoint: DEFAULT_LOCALHOST_ENDPOINT, + }, + { timeoutMs: DEFAULT_LOCALHOST_BOOTSTRAP_TIMEOUT_MS } + ) + if (cancelled) { + return + } - if (next !== existing) { - await persist(next) - } - } catch (reconciliationError) { + await upsertDirectConnection({ + serverId, + endpoint: DEFAULT_LOCALHOST_ENDPOINT, + label: hostname ?? undefined, + }) + })().catch(() => { + // Best-effort startup race only; ignore if localhost isn't reachable. + }), + ]) + } catch (bootstrapError) { if (cancelled) return console.warn( - '[DaemonRegistry] Failed to reconcile desktop startup host connections', - reconciliationError + '[DaemonRegistry] Failed to bootstrap desktop startup host connections', + bootstrapError ) } } - void reconcileDesktopStartup().finally(() => { - if (!cancelled) { - setIsReconciling(false) - } - }) + void bootstrapDesktopStartup() return () => { cancelled = true } - }, [ - isPending, - persist, - readDaemons, - settings.manageBuiltInDaemon, - settingsLoading, - ]) + }, [isPending, upsertDirectConnection, upsertHostConnection]) useEffect(() => { if (isPending) return @@ -832,20 +604,12 @@ export function DaemonRegistryProvider({ children }: { children: ReactNode }) { } } - void bootstrapLocalhost().finally(() => { - if (!cancelled) { - setIsReconciling(false) - } - }) + void bootstrapLocalhost() return () => { cancelled = true } - }, [ - isPending, - readDaemons, - upsertDirectConnection, - ]) + }, [isPending, readDaemons, upsertDirectConnection]) const upsertRelayConnection = useCallback( async (input: { @@ -905,7 +669,6 @@ export function DaemonRegistryProvider({ children }: { children: ReactNode }) { const value: DaemonRegistryContextValue = { daemons, isLoading: isPending, - isReconciling, error: error ?? null, upsertDirectConnection, upsertRelayConnection, diff --git a/packages/app/src/desktop/components/desktop-updates-section.tsx b/packages/app/src/desktop/components/desktop-updates-section.tsx index 301d9e010..5e7a85e29 100644 --- a/packages/app/src/desktop/components/desktop-updates-section.tsx +++ b/packages/app/src/desktop/components/desktop-updates-section.tsx @@ -1,21 +1,28 @@ -import { useCallback, useEffect, useState } from "react"; -import { ActivityIndicator, Alert, Image, Text, View } from "react-native"; -import * as Clipboard from "expo-clipboard"; -import * as QRCode from "qrcode"; -import { useFocusEffect } from "@react-navigation/native"; -import { StyleSheet, useUnistyles } from "react-native-unistyles"; -import { settingsStyles } from "@/styles/settings"; -import { ArrowUpRight, Play, Pause, RotateCw, Terminal, Copy, FileText, Smartphone } 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 { useCallback, useEffect, useState } from 'react' +import { ActivityIndicator, Alert, Image, Text, View } from 'react-native' +import * as Clipboard from 'expo-clipboard' +import * as QRCode from 'qrcode' +import { useFocusEffect } from '@react-navigation/native' +import { StyleSheet, useUnistyles } from 'react-native-unistyles' +import { settingsStyles } from '@/styles/settings' import { - formatVersionWithPrefix, - isVersionMismatch, -} from "@/desktop/updates/desktop-updates"; + ArrowUpRight, + Play, + Pause, + RotateCw, + Terminal, + Copy, + FileText, + Smartphone, +} 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 { formatVersionWithPrefix, isVersionMismatch } from '@/desktop/updates/desktop-updates' import { + getCliShimStatus, getManagedDaemonLogs, getManagedDaemonPairing, getManagedDaemonStatus, @@ -29,307 +36,304 @@ import { type ManagedPairingOffer, type ManagedDaemonStatus, type CliManualInstructions, -} from "@/desktop/managed-runtime/managed-runtime"; + type CliShimStatus, +} from '@/desktop/managed-runtime/managed-runtime' export interface LocalDaemonSectionProps { - appVersion: string | null; + appVersion: string | null } export function LocalDaemonSection({ appVersion }: LocalDaemonSectionProps) { - const { theme } = useUnistyles(); - 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 { theme } = useUnistyles() + 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 [cliShimStatus, setCliShimStatus] = 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.resolve() } - return Promise.all([getManagedDaemonStatus(), getManagedDaemonLogs()]) - .then(([status, logs]) => { - setManagedStatus(status); - setManagedLogs(logs); - setStatusError(null); + return Promise.all([getManagedDaemonStatus(), getManagedDaemonLogs(), getCliShimStatus()]) + .then(([status, logs, shimStatus]) => { + setManagedStatus(status) + setManagedLogs(logs) + setCliShimStatus(shimStatus) + setStatusError(null) }) .catch((error) => { - const message = error instanceof Error ? error.message : String(error); - setStatusError(message); - }); - }, [showSection]); + const message = error instanceof Error ? error.message : String(error) + setStatusError(message) + }) + }, [showSection]) useFocusEffect( useCallback(() => { if (!showSection) { - return undefined; + return undefined } - void loadManagedStatus(); - return undefined; + void loadManagedStatus() + return undefined }, [loadManagedStatus, showSection]) - ); + ) - const localDaemonVersionText = formatVersionWithPrefix(managedStatus?.runtimeVersion ?? null); - const daemonVersionMismatch = isVersionMismatch(appVersion, managedStatus?.runtimeVersion ?? null); + 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."; + statusError ?? (managedStatus?.status === 'running' ? managedStatus.status : 'not running') + const daemonStatusDetailText = `PID ${managedStatus?.pid ? managedStatus.pid : '—'}` + const isDaemonManagementPaused = !settings.manageBuiltInDaemon + const daemonActionLabel = managedStatus?.status === 'running' ? 'Restart daemon' : 'Start daemon' + const daemonActionMessage = + managedStatus?.status === 'running' + ? 'Restarts the built-in daemon.' + : 'Starts the built-in daemon.' const handleUpdateLocalDaemon = useCallback(() => { if (!showSection) { - return; + return } if (isRestartingDaemon) { - return; + return } void confirmDialog({ title: daemonActionLabel, - message: managedStatus?.daemonRunning - ? "This will restart the built-in daemon. The app will reconnect automatically." - : "This will start the built-in daemon.", + message: + managedStatus?.status === 'running' + ? 'This will restart the built-in daemon. The app will reconnect automatically.' + : 'This will start the built-in daemon.', confirmLabel: daemonActionLabel, - cancelLabel: "Cancel", + cancelLabel: 'Cancel', }) .then((confirmed) => { if (!confirmed) { - return; + return } - setIsRestartingDaemon(true); - setStatusMessage(null); + setIsRestartingDaemon(true) + setStatusMessage(null) - const action = managedStatus?.daemonRunning ? restartManagedDaemon : startManagedDaemon; + const action = + managedStatus?.status === 'running' ? restartManagedDaemon : startManagedDaemon void action() .then((status) => { - setManagedStatus(status); + setManagedStatus(status) setStatusMessage( - managedStatus?.daemonRunning ? "Daemon restarted." : "Daemon started." - ); - return loadManagedStatus(); + managedStatus?.status === 'running' ? 'Daemon restarted.' : 'Daemon started.' + ) + return loadManagedStatus() }) .catch((error) => { - console.error("[Settings] Failed to change managed daemon state", error); - const message = error instanceof Error ? error.message : String(error); - setStatusMessage(`${daemonActionLabel} failed: ${message}`); + console.error('[Settings] Failed to change managed daemon state', error) + const message = error instanceof Error ? error.message : String(error) + setStatusMessage(`${daemonActionLabel} failed: ${message}`) }) .finally(() => { - setIsRestartingDaemon(false); - }); + setIsRestartingDaemon(false) + }) }) .catch((error) => { - console.error("[Settings] Failed to open managed daemon action confirmation", error); - Alert.alert("Error", "Unable to open the daemon confirmation dialog."); - }); - }, [daemonActionLabel, isRestartingDaemon, loadManagedStatus, managedStatus?.daemonRunning, showSection]); + console.error('[Settings] Failed to open managed daemon action confirmation', error) + Alert.alert('Error', 'Unable to open the daemon confirmation dialog.') + }) + }, [daemonActionLabel, isRestartingDaemon, loadManagedStatus, managedStatus?.status, showSection]) const handleToggleDaemonManagement = useCallback(() => { if (isUpdatingDaemonManagement) { - return; + return } if (!settings.manageBuiltInDaemon) { - setIsUpdatingDaemonManagement(true); - setStatusMessage(null); + setIsUpdatingDaemonManagement(true) + setStatusMessage(null) void updateSettings({ manageBuiltInDaemon: true }) .then(() => { - setStatusMessage("Paseo will resume managing the built-in daemon on startup."); + setStatusMessage('Built-in daemon management resumed.') }) .catch((error) => { - console.error("[Settings] Failed to update built-in daemon management", error); - Alert.alert("Error", "Unable to update built-in daemon management."); + 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; + setIsUpdatingDaemonManagement(false) + }) + return } void confirmDialog({ - title: "Pause built-in daemon", + 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", + 'This will stop the built-in daemon immediately. 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; + return } - setIsUpdatingDaemonManagement(true); - setStatusMessage(null); + setIsUpdatingDaemonManagement(true) + setStatusMessage(null) - const stopPromise = managedStatus?.daemonRunning - ? stopManagedDaemon() - : Promise.resolve(managedStatus ?? null); + const stopPromise = + managedStatus?.status === 'running' + ? 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." - ); + setStatusMessage('Built-in daemon paused and stopped.') }) .catch((error) => { - console.error("[Settings] Failed to pause built-in daemon management", error); - Alert.alert("Error", "Unable to pause built-in daemon management."); + console.error('[Settings] Failed to pause built-in daemon management', error) + Alert.alert('Error', 'Unable to pause built-in daemon management.') }) .finally(() => { - setIsUpdatingDaemonManagement(false); - }); + setIsUpdatingDaemonManagement(false) + }) }) .catch((error) => { - console.error("[Settings] Failed to open built-in daemon pause confirmation", error); - Alert.alert("Error", "Unable to open the daemon confirmation dialog."); - }); + console.error('[Settings] Failed to open built-in daemon pause confirmation', error) + Alert.alert('Error', 'Unable to open the daemon confirmation dialog.') + }) }, [ isUpdatingDaemonManagement, loadManagedStatus, managedStatus, settings.manageBuiltInDaemon, updateSettings, - ]); + ]) const handleToggleCliShim = useCallback(() => { if (!showSection || isInstallingCli) { - return; + return } - setIsInstallingCli(true); - const isInstalling = !managedStatus?.cliShimPath; + setIsInstallingCli(true) + const isInstalling = !cliShimStatus?.path setCliStatusMessage( - isInstalling - ? "A permissions popup may appear while Paseo installs the CLI globally." - : null - ); - const action = managedStatus?.cliShimPath ? uninstallManagedCliShim : installManagedCliShim; + isInstalling ? 'A permissions popup may appear while Paseo installs the CLI globally.' : null + ) + const action = cliShimStatus?.path ? uninstallManagedCliShim : installManagedCliShim void action() .then((result) => { - setCliStatusMessage(result.message); + setCliStatusMessage(result.message) if (result.manualInstructions) { - setCliInstallInstructions(result.manualInstructions); - setIsCliInstallModalOpen(true); + setCliInstallInstructions(result.manualInstructions) + setIsCliInstallModalOpen(true) } else { - setCliInstallInstructions(null); - setIsCliInstallModalOpen(false); + setCliInstallInstructions(null) + setIsCliInstallModalOpen(false) } - return loadManagedStatus(); + return loadManagedStatus() }) .catch((error) => { - const message = error instanceof Error ? error.message : String(error); - setCliStatusMessage(`CLI install failed: ${message}`); + const message = error instanceof Error ? error.message : String(error) + setCliStatusMessage(`CLI install failed: ${message}`) }) .finally(() => { - setIsInstallingCli(false); - }); - }, [isInstallingCli, loadManagedStatus, managedStatus?.cliShimPath, showSection]); + setIsInstallingCli(false) + }) + }, [cliShimStatus?.path, isInstallingCli, loadManagedStatus, showSection]) const handleCopyCliInstallCommands = useCallback(() => { if (!cliInstallInstructions?.commands) { - return; + return } void Clipboard.setStringAsync(cliInstallInstructions.commands) .then(() => { - Alert.alert("Copied", "CLI install commands copied."); + 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]); + 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; + const logPath = managedLogs?.logPath if (!logPath) { - return; + return } void Clipboard.setStringAsync(logPath) .then(() => { - Alert.alert("Copied", "Log path copied."); + 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]); + console.error('[Settings] Failed to copy log path', error) + Alert.alert('Error', 'Unable to copy log path.') + }) + }, [managedLogs?.logPath]) const handleOpenLogs = useCallback(() => { if (!managedLogs) { - return; + return } - setIsLogsModalOpen(true); - }, [managedLogs]); + setIsLogsModalOpen(true) + }, [managedLogs]) const handleOpenPairingModal = useCallback(() => { if (isLoadingPairing) { - return; + return } - setIsPairingModalOpen(true); - setIsLoadingPairing(true); - setPairingStatusMessage(null); + setIsPairingModalOpen(true) + setIsLoadingPairing(true) + setPairingStatusMessage(null) void getManagedDaemonPairing() .then((pairing) => { - setPairingOffer(pairing); + setPairingOffer(pairing) if (!pairing.relayEnabled || !pairing.url) { - setPairingStatusMessage("Relay pairing is not available."); + 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}`); + const message = error instanceof Error ? error.message : String(error) + setPairingOffer(null) + setPairingStatusMessage(`Unable to load pairing offer: ${message}`) }) .finally(() => { - setIsLoadingPairing(false); - }); - }, [isLoadingPairing]); + setIsLoadingPairing(false) + }) + }, [isLoadingPairing]) const handleCopyPairingLink = useCallback(() => { if (!pairingOffer?.url) { - return; + return } void Clipboard.setStringAsync(pairingOffer.url) .then(() => { - Alert.alert("Copied", "Pairing link copied."); + 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]); + console.error('[Settings] Failed to copy pairing link', error) + Alert.alert('Error', 'Unable to copy pairing link.') + }) + }, [pairingOffer?.url]) if (!showSection) { - return null; + return null } return ( @@ -364,35 +368,37 @@ export function LocalDaemonSection({ appVersion }: LocalDaemonSectionProps) { Daemon management {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."} + ? 'Paused. The built-in daemon stays stopped until you start it again.' + : 'Enabled. Paseo can manage the built-in daemon from the desktop app.'} {daemonActionLabel} {daemonActionMessage} - {statusMessage ? ( - {statusMessage} - ) : null} + {statusMessage ? {statusMessage} : null} Command line (CLI) - - Adds the `paseo` command to your terminal. - + Adds the `paseo` command to your terminal. {cliStatusMessage ? {cliStatusMessage} : null} Log file - - {managedLogs?.logPath ?? - managedStatus?.logPath ?? - "Log path unavailable."} - + {managedLogs?.logPath ?? 'Log path unavailable.'} - {(managedLogs?.logPath ?? managedStatus?.logPath) ? ( - ) : null} @@ -459,11 +460,14 @@ export function LocalDaemonSection({ appVersion }: LocalDaemonSectionProps) { Pair device - - Connect your phone to this computer. - + Connect your phone to this computer. - @@ -493,7 +497,7 @@ export function LocalDaemonSection({ appVersion }: LocalDaemonSectionProps) { {cliInstallInstructions.detail} ) : null} - {cliInstallInstructions?.commands ?? ""} + {cliInstallInstructions?.commands ?? ''} - ); + ) } const styles = StyleSheet.create((theme) => ({ sectionHeader: { - alignItems: "center", - flexDirection: "row", - justifyContent: "space-between", + alignItems: 'center', + flexDirection: 'row', + justifyContent: 'space-between', marginBottom: theme.spacing[3], marginLeft: theme.spacing[1], }, sectionLink: { - alignItems: "center", - flexDirection: "row", + alignItems: 'center', + flexDirection: 'row', gap: theme.spacing[1], }, sectionLinkText: { @@ -661,9 +661,9 @@ const styles = StyleSheet.create((theme) => ({ fontSize: theme.fontSize.xs, }, row: { - flexDirection: "row", - alignItems: "center", - justifyContent: "space-between", + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', paddingVertical: theme.spacing[4], paddingHorizontal: theme.spacing[4], }, @@ -676,13 +676,13 @@ const styles = StyleSheet.create((theme) => ({ marginRight: theme.spacing[3], }, actionGroup: { - flexDirection: "row", + flexDirection: 'row', gap: theme.spacing[2], - flexWrap: "wrap", - justifyContent: "flex-end", + flexWrap: 'wrap', + justifyContent: 'flex-end', }, statusValueGroup: { - alignItems: "flex-end", + alignItems: 'flex-end', gap: 2, }, rowTitle: { @@ -713,7 +713,7 @@ const styles = StyleSheet.create((theme) => ({ borderRadius: theme.borderRadius.lg, borderWidth: 1, borderColor: theme.colors.palette.amber[500], - backgroundColor: "rgba(245, 158, 11, 0.12)", + backgroundColor: 'rgba(245, 158, 11, 0.12)', paddingVertical: theme.spacing[2], paddingHorizontal: theme.spacing[3], }, @@ -726,15 +726,15 @@ const styles = StyleSheet.create((theme) => ({ paddingBottom: theme.spacing[2], }, pairingState: { - alignItems: "center", - justifyContent: "center", + alignItems: 'center', + justifyContent: 'center', gap: theme.spacing[3], paddingVertical: theme.spacing[6], }, qrCard: { - alignItems: "center", - justifyContent: "center", - alignSelf: "center", + alignItems: 'center', + justifyContent: 'center', + alignSelf: 'center', minHeight: 220, minWidth: 220, padding: theme.spacing[4], @@ -759,13 +759,13 @@ const styles = StyleSheet.create((theme) => ({ logOutput: { color: theme.colors.foregroundMuted, fontSize: theme.fontSize.xs, - fontFamily: "ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace", + 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", + fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace', lineHeight: 18, borderWidth: 1, borderColor: theme.colors.border, @@ -774,8 +774,8 @@ const styles = StyleSheet.create((theme) => ({ padding: theme.spacing[3], }, modalActions: { - flexDirection: "row", - justifyContent: "flex-end", + flexDirection: 'row', + justifyContent: 'flex-end', gap: theme.spacing[2], }, -})); +})) diff --git a/packages/app/src/desktop/managed-runtime/managed-runtime.ts b/packages/app/src/desktop/managed-runtime/managed-runtime.ts index ef402d2c0..de0aeeb22 100644 --- a/packages/app/src/desktop/managed-runtime/managed-runtime.ts +++ b/packages/app/src/desktop/managed-runtime/managed-runtime.ts @@ -1,282 +1,273 @@ -import { invokeDesktopCommand } from "@/desktop/tauri/invoke-desktop-command"; -import { getTauri, isTauriEnvironment } from "@/utils/tauri"; +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; -}; + runtimeId: string + runtimeVersion: string + runtimeRoot: 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; -}; + runtimeId: string + runtimeVersion: string + serverId: string + status: string + listen: string + hostname: string | null + pid: number | null + home: string +} export type ManagedDaemonLogs = { - logPath: string; - contents: string; -}; + logPath: string + contents: string +} + +export type CliShimStatus = { + path: string | null +} export type ManagedPairingOffer = { - relayEnabled: boolean; - url: string | null; - qr: string | null; -}; + 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; -}; + | 'installed' + | 'removed' + | 'elevationDenied' + | 'automaticInstallUnavailable' + | 'manualInstallRequired' + installed: boolean + path: string | null + message: string + manualInstructions: CliManualInstructions | null +} export type CliManualInstructions = { - title: string; - detail: string; - commands: string; -}; + title: string + detail: string + commands: string +} export type ManagedTcpSettings = { - enabled: boolean; - host: string; - port: number; -}; + enabled: boolean + host: string + port: number +} export type LocalTransportTarget = { - transportType: "socket" | "pipe"; - transportPath: string; -}; + 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; -}; + 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; + return typeof value === 'object' && value !== null } function toStringOrNull(value: unknown): string | null { - return typeof value === "string" && value.trim().length > 0 ? value : 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; + 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."); + 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) ?? "", - }; + runtimeId: toStringOrNull(raw.runtimeId) ?? '', + runtimeVersion: toStringOrNull(raw.runtimeVersion) ?? '', + runtimeRoot: toStringOrNull(raw.runtimeRoot) ?? '', + } } function parseManagedDaemonStatus(raw: unknown): ManagedDaemonStatus { if (!isRecord(raw)) { - throw new Error("Unexpected managed daemon status response."); + 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), + runtimeId: toStringOrNull(raw.runtimeId) ?? '', + runtimeVersion: toStringOrNull(raw.runtimeVersion) ?? '', + serverId: toStringOrNull(raw.serverId) ?? '', + status: toStringOrNull(raw.status) ?? 'unknown', + listen: toStringOrNull(raw.listen) ?? '', hostname: toStringOrNull(raw.hostname), - relayEnabled: raw.relayEnabled === true, - tcpEnabled: raw.tcpEnabled === true, - tcpListen: toStringOrNull(raw.tcpListen), - cliShimPath: toStringOrNull(raw.cliShimPath), - }; + pid: toNumberOrNull(raw.pid), + home: toStringOrNull(raw.home) ?? '', + } } function parseManagedDaemonLogs(raw: unknown): ManagedDaemonLogs { if (!isRecord(raw)) { - throw new Error("Unexpected managed daemon logs response."); + throw new Error('Unexpected managed daemon logs response.') } return { - logPath: toStringOrNull(raw.logPath) ?? "", - contents: typeof raw.contents === "string" ? raw.contents : "", - }; + logPath: toStringOrNull(raw.logPath) ?? '', + contents: typeof raw.contents === 'string' ? raw.contents : '', + } +} + +function parseCliShimStatus(raw: unknown): CliShimStatus { + if (!isRecord(raw)) { + throw new Error('Unexpected CLI shim status response.') + } + return { + path: toStringOrNull(raw.path), + } } function parseManagedPairingOffer(raw: unknown): ManagedPairingOffer { if (!isRecord(raw)) { - throw new Error("Unexpected managed daemon pairing response."); + 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 null } return { - title: toStringOrNull(raw.title) ?? "", - detail: toStringOrNull(raw.detail) ?? "", - commands: toStringOrNull(raw.commands) ?? "", - }; + 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."); + throw new Error('Unexpected CLI shim response.') } return { status: - (toStringOrNull(raw.status) as CliShimResult["status"] | null) ?? - (raw.installed === true ? "installed" : "removed"), + (toStringOrNull(raw.status) as CliShimResult['status'] | null) ?? + (raw.installed === true ? 'installed' : 'removed'), installed: raw.installed === true, path: toStringOrNull(raw.path), - message: toStringOrNull(raw.message) ?? "", + message: toStringOrNull(raw.message) ?? '', manualInstructions: parseCliManualInstructions(raw.manualInstructions), - }; + } } export function shouldUseManagedDesktopDaemon(): boolean { - return isTauriEnvironment() && getTauri() !== null; + return isTauriEnvironment() && getTauri() !== null } export async function getManagedRuntimeStatus(): Promise { - return parseManagedRuntimeStatus(await invokeDesktopCommand("managed_runtime_status")); + return parseManagedRuntimeStatus(await invokeDesktopCommand('managed_runtime_status')) } export async function getManagedDaemonStatus(): Promise { - return parseManagedDaemonStatus(await invokeDesktopCommand("managed_daemon_status")); + return parseManagedDaemonStatus(await invokeDesktopCommand('managed_daemon_status')) } export async function startManagedDaemon(): Promise { - return parseManagedDaemonStatus(await invokeDesktopCommand("start_managed_daemon")); + return parseManagedDaemonStatus(await invokeDesktopCommand('start_managed_daemon')) } export async function stopManagedDaemon(): Promise { - return parseManagedDaemonStatus(await invokeDesktopCommand("stop_managed_daemon")); + return parseManagedDaemonStatus(await invokeDesktopCommand('stop_managed_daemon')) } export async function restartManagedDaemon(): Promise { - return parseManagedDaemonStatus(await invokeDesktopCommand("restart_managed_daemon")); + return parseManagedDaemonStatus(await invokeDesktopCommand('restart_managed_daemon')) } export async function getManagedDaemonLogs(): Promise { - return parseManagedDaemonLogs(await invokeDesktopCommand("managed_daemon_logs")); + return parseManagedDaemonLogs(await invokeDesktopCommand('managed_daemon_logs')) +} + +export async function getCliShimStatus(): Promise { + return parseCliShimStatus(await invokeDesktopCommand('cli_shim_status')) } export async function getManagedDaemonPairing(): Promise { - return parseManagedPairingOffer(await invokeDesktopCommand("managed_daemon_pairing")); + return parseManagedPairingOffer(await invokeDesktopCommand('managed_daemon_pairing')) } export async function installManagedCliShim(): Promise { - return parseCliShimResult(await invokeDesktopCommand("install_cli_shim")); + return parseCliShimResult(await invokeDesktopCommand('install_cli_shim')) } export async function uninstallManagedCliShim(): Promise { - return parseCliShimResult(await invokeDesktopCommand("uninstall_cli_shim")); + return parseCliShimResult(await invokeDesktopCommand('uninstall_cli_shim')) } export async function updateManagedDaemonTcpSettings( settings: ManagedTcpSettings ): Promise { return parseManagedDaemonStatus( - await invokeDesktopCommand("update_managed_daemon_tcp_settings", { settings }) - ); + await invokeDesktopCommand('update_managed_daemon_tcp_settings', { settings }) + ) } -export type LocalTransportEventUnlisten = () => void; -export type LocalTransportEventHandler = (payload: LocalTransportEventPayload) => void; +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 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; + const unlisten = await listen('local-daemon-transport-event', (event: unknown) => { + const payload = isRecord(event) && isRecord(event.payload) ? event.payload : null if (!payload) { - return; + return } handler({ - sessionId: toStringOrNull(payload.sessionId) ?? "", - kind: (toStringOrNull(payload.kind) ?? "error") as LocalTransportEventPayload["kind"], + 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 : () => {}; + }) + }) + 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."); + 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; + return raw } export async function sendLocalTransportMessage(input: { - sessionId: string; - text?: string; - binaryBase64?: string; + sessionId: string + text?: string + binaryBase64?: string }): Promise { - await invokeDesktopCommand("send_local_daemon_transport_message", { + 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 }); + await invokeDesktopCommand('close_local_daemon_transport', { sessionId }) } diff --git a/packages/app/src/runtime/host-runtime.test.ts b/packages/app/src/runtime/host-runtime.test.ts index c4915d5b5..2b983f76e 100644 --- a/packages/app/src/runtime/host-runtime.test.ts +++ b/packages/app/src/runtime/host-runtime.test.ts @@ -176,12 +176,7 @@ 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, - }, + lifecycle: input?.lifecycle ?? {}, connections: input?.connections ?? [direct, relay], preferredConnectionId: input?.preferredConnectionId ?? direct.id, createdAt: input?.createdAt ?? new Date(0).toISOString(), diff --git a/packages/cli/src/commands/daemon/status.ts b/packages/cli/src/commands/daemon/status.ts index 7f051fc1e..1b27cf85f 100644 --- a/packages/cli/src/commands/daemon/status.ts +++ b/packages/cli/src/commands/daemon/status.ts @@ -1,6 +1,7 @@ import type { Command } from 'commander' import { spawnSync } from 'node:child_process' import { createRequire } from 'node:module' +import { getOrCreateServerId } from '@getpaseo/server' import { tryConnectToDaemon } from '../../utils/client.js' import type { CommandOptions, ListResult, OutputSchema } from '../../output/index.js' import { resolveLocalDaemonState, resolveTcpHostFromListen } from './local-daemon.js' @@ -12,9 +13,11 @@ import { } from './runtime-toolchain.js' interface DaemonStatus { + serverId: string | null status: 'running' | 'stopped' | 'unresponsive' home: string listen: string + hostname: string | null pid: number | null startedAt: string | null owner: string | null @@ -196,9 +199,11 @@ function createStatusSchema(status: DaemonStatus): OutputSchema { function toStatusRows(status: DaemonStatus): StatusRow[] { const rows: StatusRow[] = [ + { key: 'Server ID', value: status.serverId ?? '-' }, { key: 'Status', value: status.status }, { key: 'Home', value: status.home }, { key: 'Listen', value: status.listen }, + { key: 'Hostname', value: status.hostname ?? '-' }, { key: 'PID', value: status.pid === null ? '-' : String(status.pid) }, { key: 'Started', value: status.startedAt ?? '-' }, { key: 'Owner', value: status.owner ?? '-' }, @@ -323,10 +328,19 @@ export async function runStatusCommand( updateStatus = 'unknown (npm unresolved)' } + let serverId: string | null = null + try { + serverId = getOrCreateServerId(state.home) + } catch (error) { + note = appendNote(note, `serverId unavailable: ${shortenMessage(normalizeError(error))}`) + } + const daemonStatus: DaemonStatus = { + serverId, status, home: state.home, listen: state.listen, + hostname: state.pidInfo?.hostname ?? null, pid: state.pidInfo?.pid ?? null, startedAt: state.pidInfo?.startedAt ?? null, owner, diff --git a/packages/cli/tests/03-daemon.test.ts b/packages/cli/tests/03-daemon.test.ts index 502c7223a..1f3a5218e 100644 --- a/packages/cli/tests/03-daemon.test.ts +++ b/packages/cli/tests/03-daemon.test.ts @@ -85,8 +85,10 @@ try { await $`PASEO_HOME=${paseoHome} npx paseo daemon status --json`.nothrow() assert.strictEqual(result.exitCode, 0, '--json status should succeed') const status = JSON.parse(result.stdout) + assert.strictEqual(typeof status.serverId, 'string', 'json status should include serverId') assert.strictEqual(status.status, 'stopped', 'json status should report stopped') assert.strictEqual(status.home, paseoHome, 'json status should reflect the isolated home') + assert.strictEqual(status.hostname, null, 'json status should include hostname when unavailable') console.log('✓ daemon status --json outputs valid JSON\n') } diff --git a/packages/desktop/scripts/managed-daemon-smoke.mjs b/packages/desktop/scripts/managed-daemon-smoke.mjs index c7619bcee..917110ba9 100644 --- a/packages/desktop/scripts/managed-daemon-smoke.mjs +++ b/packages/desktop/scripts/managed-daemon-smoke.mjs @@ -588,7 +588,6 @@ try { const runtimeStatus = await runBinary(packagedBinary, ["--managed-headless", "runtime-status"], managedEnv); assert.equal(runtimeStatus.json.runtimeId, currentRuntimeId); assert.equal(runtimeStatus.json.runtimeVersion, currentRuntimeVersion); - assert.match(runtimeStatus.json.managedHome, new RegExp(`^${escapeForRegExp(testRoot)}`)); assert.match( runtimeStatus.json.runtimeRoot, new RegExp(escapeForRegExp(currentRuntimeId)), @@ -599,15 +598,8 @@ try { false, "runtime status should not resolve into managed app data" ); - assert.ok(runtimeStatus.json.transportType === "socket" || runtimeStatus.json.transportType === "pipe"); - assert.notEqual(runtimeStatus.json.transportPath, "127.0.0.1:6767"); assertNoForbiddenPathsOrPorts(runtimeStatus.json, forbiddenManagedReferences); assert.equal(await pathExists(managedRuntimeDir), false, "managed app data should not gain a runtime tree"); - const stateAfterRuntimeStatus = JSON.parse( - await fs.readFile(path.join(testRoot, "managed-state.json"), "utf8") - ); - assert.equal(stateAfterRuntimeStatus.runtimeId, currentRuntimeId); - assert.equal(stateAfterRuntimeStatus.runtimeRoot, runtimeStatus.json.runtimeRoot); const managedBootstrap = await runBinary(packagedBinary, ["--managed-headless", "bootstrap"], managedEnv); const managedStart = managedBootstrap.json ?? await waitFor( @@ -617,27 +609,23 @@ try { ["--managed-headless", "daemon-status"], managedEnv ); - assert.equal(status.json?.daemonRunning, true); - assert.ok(status.json?.daemonPid, "managed daemon pid should exist"); + assert.equal(status.json?.status, "running"); + assert.ok(status.json?.pid, "managed daemon pid should exist"); + assert.ok(status.json?.serverId, "managed daemon should expose a server id"); return status.json; }, 10_000, "managed daemon bootstrap status" ); - assert.equal(managedStart.daemonRunning, true); - assert.ok(managedStart.daemonPid, "managed daemon pid should exist"); - assert.equal(managedStart.runtimeRoot, runtimeStatus.json.runtimeRoot); - assert.ok( - managedStart.transportType === "socket" || managedStart.transportType === "pipe", - "managed daemon should default to private IPC transport" - ); - assert.notEqual(managedStart.transportPath, "127.0.0.1:6767"); + assert.equal(managedStart.status, "running"); + assert.ok(managedStart.pid, "managed daemon pid should exist"); + assert.ok(managedStart.serverId, "managed daemon should expose a server id"); + assert.ok(managedStart.home, "managed daemon should expose its home directory"); + assert.ok(managedStart.listen, "managed daemon should expose its listen target"); assertNoForbiddenPathsOrPorts(managedStart, forbiddenManagedReferences); assert.equal(await pathExists(managedRuntimeDir), false, "starting the daemon should not install a runtime copy"); - const managedPid = managedStart.daemonPid; - const stateFile = path.join(testRoot, "managed-state.json"); - assert.equal(await pathExists(stateFile), true, "managed state file should be written"); + const managedPid = managedStart.pid; logStep("Verifying the managed daemon stays alive after the packaged command exits"); await sleep(1_500); @@ -646,9 +634,9 @@ try { ["--managed-headless", "daemon-status"], managedEnv ); - assert.equal(persistedManagedStatus.json.daemonPid, managedPid); - assert.equal(persistedManagedStatus.json.daemonRunning, true); - assert.equal(persistedManagedStatus.json.relayEnabled, true); + assert.equal(persistedManagedStatus.json.pid, managedPid); + assert.equal(persistedManagedStatus.json.status, "running"); + assert.equal(persistedManagedStatus.json.serverId, managedStart.serverId); assertNoForbiddenPathsOrPorts(persistedManagedStatus.json, forbiddenManagedReferences); const attemptCliShimInstall = shouldAttemptCliShimInstall(managedEnv); @@ -694,7 +682,7 @@ try { }, "installed CLI shim version check") : await runBundledRuntimeCli( runtimeStatus.json.runtimeRoot, - managedStart.managedHome, + managedStart.home, ["--version"], managedEnv ); @@ -707,7 +695,7 @@ try { }, "installed CLI shim daemon status") : await runBundledRuntimeCli( runtimeStatus.json.runtimeRoot, - managedStart.managedHome, + managedStart.home, ["daemon", "status", "--json"], managedEnv ); @@ -719,7 +707,7 @@ try { const relayPairing = cliShimInstalled ? await execFileWithTimeout( cliShimPath, - ["daemon", "pair", "--home", managedStart.managedHome], + ["daemon", "pair"], { env: managedEnv, cwd: repoRoot, @@ -729,8 +717,8 @@ try { ) : await runBundledRuntimeCli( runtimeStatus.json.runtimeRoot, - managedStart.managedHome, - ["daemon", "pair", "--home", managedStart.managedHome], + managedStart.home, + ["daemon", "pair"], managedEnv ); const relayOfferUrl = parseOfferUrlFromCommandOutput(relayPairing.stdout); @@ -745,7 +733,7 @@ try { ["--managed-headless", "bootstrap"], managedEnv ); - assert.equal(managedRestartless.json.daemonPid, managedPid); + assert.equal(managedRestartless.json.pid, managedPid); logStep("Verifying managed and external daemons coexist"); const externalStatusAfter = await readDaemonStatus(externalHome, managedEnv); @@ -761,46 +749,6 @@ try { assert.ok(managedStatus.json.serverId, "managed daemon should expose a server id"); assertNoForbiddenPathsOrPorts(managedStatus.json, forbiddenManagedReferences); - logStep("Enabling managed TCP exposure on an explicit non-default port"); - const tcpEnabled = await runBinary( - packagedBinary, - [ - "--managed-headless", - "update-tcp", - "--enabled", - "true", - "--host", - "127.0.0.1", - "--port", - "7771", - ], - managedEnv - ); - assert.equal(tcpEnabled.json.tcpEnabled, true); - assert.equal(tcpEnabled.json.transportType, "tcp"); - assert.equal(tcpEnabled.json.transportPath, "127.0.0.1:7771"); - assert.notEqual(tcpEnabled.json.transportPath, "127.0.0.1:6767"); - assertNoForbiddenPathsOrPorts(tcpEnabled.json, [fakePaseoHome]); - - logStep("Disabling managed TCP exposure and returning to private transport"); - const tcpDisabled = await runBinary( - packagedBinary, - [ - "--managed-headless", - "update-tcp", - "--enabled", - "false", - "--host", - "127.0.0.1", - "--port", - "7771", - ], - managedEnv - ); - assert.equal(tcpDisabled.json.tcpEnabled, false); - assert.notEqual(tcpDisabled.json.transportType, "tcp"); - assertNoForbiddenPathsOrPorts(tcpDisabled.json, forbiddenManagedReferences); - logStep("Capturing diagnostics and verifying the fake ~/.paseo stayed untouched"); const fakePaseoSnapshotAfter = await snapshotTree(fakePaseoHome); assert.deepEqual(fakePaseoSnapshotAfter, fakePaseoSnapshotBefore); @@ -811,7 +759,6 @@ try { runtimeStatus: runtimeStatus.json, managedBootstrap: managedBootstrap.json, managedStart, - stateAfterRuntimeStatus, persistedManagedStatus: persistedManagedStatus.json, managedRestartless: managedRestartless.json, cliInstall: cliInstall.json, @@ -823,8 +770,6 @@ try { externalPid, externalPidAfter, managedStatus: managedStatus.json, - tcpEnabled: tcpEnabled.json, - tcpDisabled: tcpDisabled.json, }, null, 2 diff --git a/packages/desktop/src-tauri/src/lib.rs b/packages/desktop/src-tauri/src/lib.rs index 008b397f1..90279009d 100644 --- a/packages/desktop/src-tauri/src/lib.rs +++ b/packages/desktop/src-tauri/src/lib.rs @@ -14,11 +14,11 @@ use tauri_plugin_updater::UpdaterExt; mod runtime_manager; use runtime_manager::{ - close_local_daemon_transport, install_cli_shim, managed_daemon_logs, managed_daemon_pairing, - managed_daemon_status, managed_runtime_status, open_local_daemon_transport, - restart_managed_daemon, send_local_daemon_transport_message, start_managed_daemon, - stop_managed_daemon, uninstall_cli_shim, update_managed_daemon_tcp_settings, - LocalTransportState, ManagedTcpSettings, + cli_shim_status, close_local_daemon_transport, install_cli_shim, managed_daemon_logs, + managed_daemon_pairing, managed_daemon_status, managed_runtime_status, + open_local_daemon_transport, restart_managed_daemon, send_local_daemon_transport_message, + start_managed_daemon, stop_managed_daemon, uninstall_cli_shim, + update_managed_daemon_tcp_settings, LocalTransportState, ManagedTcpSettings, }; // Store zoom as u64 bits (f64 * 100 as integer for atomic ops) @@ -622,6 +622,7 @@ pub fn run() { .invoke_handler(tauri::generate_handler![ managed_runtime_status, managed_daemon_status, + cli_shim_status, start_managed_daemon, stop_managed_daemon, restart_managed_daemon, diff --git a/packages/desktop/src-tauri/src/runtime_manager.rs b/packages/desktop/src-tauri/src/runtime_manager.rs index da85c354a..423345a6f 100644 --- a/packages/desktop/src-tauri/src/runtime_manager.rs +++ b/packages/desktop/src-tauri/src/runtime_manager.rs @@ -3,9 +3,6 @@ use futures_util::{SinkExt, StreamExt}; use http::Request; use serde::{Deserialize, Serialize}; use std::collections::HashMap; -use std::env; -#[cfg(windows)] -use std::hash::{DefaultHasher, Hash, Hasher}; use std::fs; use std::io; use std::path::{Path, PathBuf}; @@ -18,18 +15,9 @@ use tokio_tungstenite::tungstenite::client::IntoClientRequest; use tokio_tungstenite::tungstenite::Message; const LOCAL_TRANSPORT_EVENT_NAME: &str = "local-daemon-transport-event"; -const MANAGED_STATE_FILE: &str = "managed-state.json"; -const DEFAULT_MANAGED_HOME_BASENAME: &str = ".paseo"; -const DEFAULT_MANAGED_HOME_DIRNAME: &str = "managed-home"; -#[cfg(not(windows))] -const SHORT_SOCKET_FILENAME: &str = "paseo.sock"; const UNIX_CLIENT_URL: &str = "ws://localhost/ws"; #[cfg(windows)] const PIPE_CLIENT_URL: &str = "ws://localhost/ws"; -#[cfg(windows)] -const PIPE_PREFIX: &str = r"\\.\pipe\"; -const DEFAULT_MANAGED_TCP_HOST: &str = "127.0.0.1"; -const DEFAULT_MANAGED_TCP_PORT: u16 = 7771; const CLI_SHIM_NAME: &str = "paseo"; #[cfg(windows)] const CLI_SHIM_WINDOWS_NAME: &str = "paseo.cmd"; @@ -55,41 +43,6 @@ struct BundledRuntimePointer { relative_root: String, } -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -struct ManagedStateFile { - runtime_id: String, - runtime_root: String, - managed_home: String, - transport_type: String, - transport_path: String, - tcp_enabled: bool, - tcp_listen: Option, - cli_shim_path: Option, -} - -#[derive(Debug, Clone, Deserialize)] -#[serde(rename_all = "camelCase")] -struct PidFile { - pid: Option, - hostname: Option, -} - -#[derive(Debug, Clone)] -struct ManagedPaths { - managed_home: PathBuf, - transport_path: PathBuf, - logs_path: PathBuf, - state_file_path: PathBuf, - diagnostics_root: PathBuf, -} - -#[derive(Debug, Clone)] -struct ManagedTransportTarget { - transport_type: String, - transport_path: String, -} - #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct ManagedTcpSettings { @@ -104,11 +57,18 @@ pub struct ManagedRuntimeStatus { pub runtime_id: String, pub runtime_version: String, pub runtime_root: String, - pub managed_home: String, - pub transport_type: String, - pub transport_path: String, - pub diagnostics_root: String, - pub state_file_path: String, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +struct CliDaemonStatus { + server_id: Option, + status: String, + listen: String, + hostname: Option, + pid: Option, + home: String, + log_path: String, } #[derive(Debug, Clone, Serialize)] @@ -116,20 +76,18 @@ pub struct ManagedRuntimeStatus { pub struct ManagedDaemonStatus { pub runtime_id: String, pub runtime_version: String, - pub runtime_root: String, - pub managed_home: String, - pub transport_type: String, - pub transport_path: String, - pub daemon_pid: Option, - pub daemon_running: bool, - pub daemon_status: String, - pub log_path: String, - pub server_id: Option, + pub server_id: String, + pub status: String, + pub listen: String, pub hostname: Option, - pub relay_enabled: bool, - pub tcp_enabled: bool, - pub tcp_listen: Option, - pub cli_shim_path: Option, + pub pid: Option, + pub home: String, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct CliShimStatus { + pub path: Option, } #[derive(Debug, Clone, Serialize)] @@ -345,15 +303,6 @@ mod tests { fn cli_shim_contents_runs_bundled_runtime_in_place() { let runtime_root = PathBuf::from("/Applications/Paseo.app/Contents/Resources/managed-runtime/runtime-1"); - let paths = ManagedPaths { - managed_home: PathBuf::from("/Users/me/.paseo"), - transport_path: PathBuf::from("/Users/me/.paseo/paseo.sock"), - logs_path: PathBuf::from("/Users/me/.paseo/daemon.log"), - state_file_path: PathBuf::from( - "/Users/me/Library/Application Support/Paseo/managed-state.json", - ), - diagnostics_root: PathBuf::from("/Users/me/Library/Application Support/Paseo"), - }; let manifest = ManagedRuntimeManifest { runtime_id: "runtime-1".to_string(), runtime_version: "0.1.0".to_string(), @@ -367,11 +316,11 @@ mod tests { "node_modules/@getpaseo/server/dist/scripts/daemon-runner.js".to_string(), }; - let contents = cli_shim_contents(&runtime_root, &manifest, &paths); + let contents = cli_shim_contents(&runtime_root, &manifest); - assert!(contents.contains("export PASEO_HOME='/Users/me/.paseo'")); assert!(contents.contains("Contents/Resources/managed-runtime/runtime-1/node/node")); assert!(contents.contains("node_modules/@getpaseo/cli/dist/index.js")); + assert!(!contents.contains("PASEO_HOME")); assert!(!contents.contains("--paseo-cli-shim")); } @@ -383,12 +332,7 @@ mod tests { .ok() .filter(|value| !value.trim().is_empty()) .map(PathBuf::from) - .or_else(|| { - dirs::home_dir().map(|home| { - home.join(DEFAULT_MANAGED_HOME_BASENAME) - .join(SHORT_SOCKET_FILENAME) - }) - }) + .or_else(|| dirs::home_dir().map(|home| home.join(".paseo").join("paseo.sock"))) .expect("socket path should resolve"); assert!( @@ -418,145 +362,6 @@ fn read_json_file Deserialize<'de>>(path: &Path) -> Result(path: &Path, value: &T) -> Result<(), String> { - let raw = serde_json::to_string_pretty(value) - .map_err(|error| format!("Failed to serialize {}: {error}", path.display()))?; - if let Some(parent) = path.parent() { - fs::create_dir_all(parent) - .map_err(|error| format!("Failed to create {}: {error}", parent.display()))?; - } - fs::write(path, format!("{raw}\n")) - .map_err(|error| format!("Failed to write {}: {error}", path.display())) -} - -fn app_data_root(app: &AppHandle) -> Result { - app.path() - .app_data_dir() - .map_err(|error| format!("Failed to resolve app data directory: {error}")) -} - -fn resolve_test_root() -> Option { - std::env::var("PASEO_DESKTOP_TEST_ROOT") - .ok() - .map(PathBuf::from) -} - -fn resolve_override_path(name: &str) -> Option { - std::env::var(name).ok().map(PathBuf::from) -} - -#[cfg(windows)] -fn current_username() -> String { - std::env::var("USER") - .ok() - .or_else(|| std::env::var("USERNAME").ok()) - .filter(|value| !value.trim().is_empty()) - .unwrap_or_else(|| "user".to_string()) -} - -#[cfg(windows)] -fn hash_seed(value: &str) -> String { - let mut hasher = DefaultHasher::new(); - value.hash(&mut hasher); - format!("{:016x}", hasher.finish()) -} - -#[cfg(windows)] -fn build_windows_pipe_path(seed: &str) -> String { - let user = current_username() - .chars() - .map(|character| { - if character.is_ascii_alphanumeric() { - character.to_ascii_lowercase() - } else { - '-' - } - }) - .collect::(); - format!("{PIPE_PREFIX}paseo-managed-{user}-{}", hash_seed(seed)) -} - -fn default_transport_type() -> &'static str { - #[cfg(windows)] - { - "pipe" - } - #[cfg(not(windows))] - { - "socket" - } -} - -#[cfg(target_os = "macos")] -fn default_managed_home() -> PathBuf { - dirs::home_dir() - .unwrap_or_else(|| PathBuf::from("/tmp")) - .join(DEFAULT_MANAGED_HOME_BASENAME) -} - -#[cfg(not(target_os = "macos"))] -fn default_managed_home() -> PathBuf { - dirs::home_dir() - .unwrap_or_else(|| PathBuf::from("/tmp")) - .join(DEFAULT_MANAGED_HOME_BASENAME) -} - -fn default_transport_path(managed_home: &Path, diagnostics_root: &Path) -> PathBuf { - #[cfg(windows)] - { - PathBuf::from(build_windows_pipe_path( - diagnostics_root.to_string_lossy().as_ref(), - )) - } - #[cfg(target_os = "macos")] - { - let _ = diagnostics_root; - managed_home.join(SHORT_SOCKET_FILENAME) - } - #[cfg(all(not(windows), not(target_os = "macos")))] - { - let _ = diagnostics_root; - managed_home.join(SHORT_SOCKET_FILENAME) - } -} - -fn resolve_paths(app: &AppHandle) -> Result { - if let Some(test_root) = resolve_test_root() { - let managed_home = resolve_override_path("PASEO_DESKTOP_MANAGED_HOME") - .unwrap_or_else(|| test_root.join(DEFAULT_MANAGED_HOME_DIRNAME)); - let transport_path = resolve_override_path("PASEO_DESKTOP_MANAGED_SOCKET_PATH") - .unwrap_or_else(|| default_transport_path(&managed_home, &test_root)); - return Ok(ManagedPaths { - managed_home: managed_home.clone(), - transport_path, - logs_path: managed_home.join("daemon.log"), - state_file_path: test_root.join(MANAGED_STATE_FILE), - diagnostics_root: test_root, - }); - } - - let root = app_data_root(app)?; - let managed_home = resolve_override_path("PASEO_DESKTOP_MANAGED_HOME").unwrap_or_else(|| { - #[cfg(target_os = "macos")] - { - default_managed_home() - } - #[cfg(not(target_os = "macos"))] - { - default_managed_home() - } - }); - let transport_path = resolve_override_path("PASEO_DESKTOP_MANAGED_SOCKET_PATH") - .unwrap_or_else(|| default_transport_path(&managed_home, &root)); - Ok(ManagedPaths { - managed_home: managed_home.clone(), - transport_path, - logs_path: managed_home.join("daemon.log"), - state_file_path: root.join(MANAGED_STATE_FILE), - diagnostics_root: root, - }) -} - fn dev_resource_root() -> PathBuf { PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("resources") } @@ -576,10 +381,7 @@ fn bundled_runtime_root(app: &AppHandle) -> Result { log::info!("[runtime] found bundled runtime at {}", candidate.display()); return Ok(candidate); } - log::info!( - "[runtime] no bundled runtime at {}", - candidate.display() - ); + log::info!("[runtime] no bundled runtime at {}", candidate.display()); } } else { log::info!("[runtime] resource_dir() unavailable, checking dev path"); @@ -606,16 +408,6 @@ fn load_runtime_manifest(runtime_root: &Path) -> Result(&runtime_root.join("runtime-manifest.json")) } -fn read_server_id(managed_home: &Path) -> Option { - let raw = fs::read_to_string(managed_home.join("server-id")).ok()?; - let trimmed = raw.trim(); - if trimmed.is_empty() { - None - } else { - Some(trimmed.to_string()) - } -} - fn tail_log(path: &Path, max_lines: usize) -> String { let raw = match fs::read_to_string(path) { Ok(value) => value, @@ -626,122 +418,14 @@ fn tail_log(path: &Path, max_lines: usize) -> String { lines.join("\n") } -fn is_pid_running(pid: i32) -> bool { - #[cfg(unix)] - { - let output = Command::new("kill") - .arg("-0") - .arg(pid.to_string()) - .stdout(Stdio::null()) - .stderr(Stdio::null()) - .status(); - return output.map(|status| status.success()).unwrap_or(false); - } - #[cfg(windows)] - { - use std::os::windows::process::CommandExt; - let output = Command::new("tasklist") - .args(["/FI", &format!("PID eq {pid}"), "/NH", "/FO", "CSV"]) - .stdout(Stdio::piped()) - .stderr(Stdio::null()) - .creation_flags(0x08000000) - .output(); - return output - .map(|out| { - let stdout = String::from_utf8_lossy(&out.stdout); - stdout.contains(&pid.to_string()) - }) - .unwrap_or(false); - } -} - fn to_stdio_message(input: Option<&str>) -> String { input.unwrap_or_default().trim().to_string() } -fn parse_tcp_listen(listen: &str) -> Result<(String, u16), String> { - let trimmed = listen.trim(); - let (host, port_raw) = trimmed - .rsplit_once(':') - .ok_or_else(|| "Managed TCP listen target must be host:port.".to_string())?; - let host = host.trim(); - if host.is_empty() { - return Err("Managed TCP host cannot be empty.".to_string()); - } - let port = port_raw - .trim() - .parse::() - .map_err(|_| "Managed TCP port must be a valid integer.".to_string())?; - if port == 0 { - return Err("Managed TCP port must be greater than 0.".to_string()); - } - if port == 6767 { - return Err("Managed TCP mode cannot use port 6767.".to_string()); - } - Ok((host.to_string(), port)) -} - -fn resolve_tcp_settings_from_state(state: Option<&ManagedStateFile>) -> ManagedTcpSettings { - let listen = state - .and_then(|value| value.tcp_listen.clone()) - .unwrap_or_else(|| format!("{DEFAULT_MANAGED_TCP_HOST}:{DEFAULT_MANAGED_TCP_PORT}")); - let (host, port) = parse_tcp_listen(&listen).unwrap_or(( - DEFAULT_MANAGED_TCP_HOST.to_string(), - DEFAULT_MANAGED_TCP_PORT, - )); - ManagedTcpSettings { - enabled: state.map(|value| value.tcp_enabled).unwrap_or(false), - host, - port, - } -} - -fn managed_transport_target( - paths: &ManagedPaths, - state: Option<&ManagedStateFile>, -) -> Result { - let tcp_settings = resolve_tcp_settings_from_state(state); - if tcp_settings.enabled { - return Ok(ManagedTransportTarget { - transport_type: "tcp".to_string(), - transport_path: format!("{}:{}", tcp_settings.host, tcp_settings.port), - }); - } - Ok(ManagedTransportTarget { - transport_type: default_transport_type().to_string(), - transport_path: paths.transport_path.to_string_lossy().into_owned(), - }) -} - -fn cli_host_for_target(target: &ManagedTransportTarget) -> String { - match target.transport_type.as_str() { - "tcp" => target.transport_path.clone(), - "pipe" => format!("pipe://{}", target.transport_path), - _ => format!("unix://{}", target.transport_path), - } -} - -fn cli_env(paths: &ManagedPaths) -> Vec<(String, String)> { - let state = read_state_file(&paths.state_file_path); - let target = - managed_transport_target(paths, state.as_ref()).unwrap_or(ManagedTransportTarget { - transport_type: default_transport_type().to_string(), - transport_path: paths.transport_path.to_string_lossy().into_owned(), - }); - vec![ - ( - "PASEO_HOME".to_string(), - paths.managed_home.to_string_lossy().into_owned(), - ), - ("PASEO_HOST".to_string(), cli_host_for_target(&target)), - ] -} - fn cli_command( runtime_root: &Path, manifest: &ManagedRuntimeManifest, args: &[&str], - paths: &ManagedPaths, ) -> Result { let node = runtime_root.join(&manifest.node_relative_path); let cli = runtime_root.join(&manifest.cli_entrypoint_relative_path); @@ -773,9 +457,6 @@ fn cli_command( use std::os::windows::process::CommandExt; command.creation_flags(0x08000000); // CREATE_NO_WINDOW } - for (key, value) in cli_env(paths) { - command.env(key, value); - } Ok(command) } @@ -814,18 +495,13 @@ fn outer_cli_shim_path() -> Result { } } -fn cli_shim_contents( - runtime_root: &Path, - manifest: &ManagedRuntimeManifest, - paths: &ManagedPaths, -) -> String { +fn cli_shim_contents(runtime_root: &Path, manifest: &ManagedRuntimeManifest) -> String { let node = runtime_root.join(&manifest.node_relative_path); let cli = runtime_root.join(&manifest.cli_entrypoint_relative_path); #[cfg(windows)] { return format!( - "@echo off\r\nset \"PASEO_HOME={}\"\r\n\"{}\" \"{}\" %*\r\n", - paths.managed_home.display(), + "@echo off\r\n\"{}\" \"{}\" %*\r\n", node.display(), cli.display() ); @@ -833,8 +509,7 @@ fn cli_shim_contents( #[cfg(not(windows))] { format!( - "#!/bin/sh\nexport PASEO_HOME={}\nexec \"{}\" \"{}\" \"$@\"\n", - shell_single_quote(paths.managed_home.to_string_lossy().as_ref()), + "#!/bin/sh\nexec \"{}\" \"{}\" \"$@\"\n", node.display(), cli.display() ) @@ -859,11 +534,10 @@ fn write_cli_launcher(path: &Path, contents: &str) -> Result<(), String> { fn cli_manual_instructions( runtime_root: &Path, - paths: &ManagedPaths, manifest: &ManagedRuntimeManifest, ) -> Result { let outer_shim = outer_cli_shim_path()?; - let contents = cli_shim_contents(runtime_root, manifest, paths); + let contents = cli_shim_contents(runtime_root, manifest); #[cfg(windows)] { let target_dir = outer_shim @@ -907,91 +581,29 @@ fn cli_manual_instructions( fn detect_installed_cli_shim_path( runtime_root: &Path, - paths: &ManagedPaths, manifest: &ManagedRuntimeManifest, ) -> Option { let outer_shim = outer_cli_shim_path().ok()?; - let expected = cli_shim_contents(runtime_root, manifest, paths); + let expected = cli_shim_contents(runtime_root, manifest); let actual = fs::read_to_string(&outer_shim).ok()?; (actual == expected).then(|| outer_shim.to_string_lossy().into_owned()) } -fn read_pid_from_home(managed_home: &Path) -> Option { - read_json_file::(&managed_home.join("paseo.pid")) - .ok() - .and_then(|parsed| parsed.pid) -} - -fn read_hostname_from_home(managed_home: &Path) -> Option { - read_json_file::(&managed_home.join("paseo.pid")) - .ok() - .and_then(|parsed| parsed.hostname) -} - -fn read_state_file(path: &Path) -> Option { - read_json_file::(path).ok() -} - -fn write_state_file(path: &Path, value: &ManagedStateFile) -> Result<(), String> { - write_json_file(path, value) -} - fn ensure_runtime_ready_internal(app: &AppHandle) -> Result { log::info!("[runtime] ensuring runtime is ready"); let (bundled_root, pointer) = load_bundled_runtime_pointer(app)?; let runtime_root = bundled_root.join(&pointer.relative_root); - let paths = resolve_paths(app)?; - log::info!( - "[runtime] runtime_root={} managed_home={} transport={}", - runtime_root.display(), - paths.managed_home.display(), - paths.transport_path.display() - ); let manifest = load_runtime_manifest(&runtime_root)?; log::info!( "[runtime] manifest: id={} version={}", manifest.runtime_id, manifest.runtime_version ); - if let Some(parent) = paths.transport_path.parent() { - fs::create_dir_all(parent) - .map_err(|error| format!("Failed to create {}: {error}", parent.display()))?; - } - fs::create_dir_all(&paths.managed_home) - .map_err(|error| format!("Failed to create {}: {error}", paths.managed_home.display()))?; - let existing_state = read_state_file(&paths.state_file_path); - let target = managed_transport_target(&paths, existing_state.as_ref())?; - log::info!( - "[runtime] transport target: type={} path={}", - target.transport_type, - target.transport_path - ); - let state = ManagedStateFile { - runtime_id: manifest.runtime_id.clone(), - runtime_root: runtime_root.to_string_lossy().into_owned(), - managed_home: paths.managed_home.to_string_lossy().into_owned(), - transport_type: target.transport_type.clone(), - transport_path: target.transport_path.clone(), - tcp_enabled: existing_state - .as_ref() - .map(|entry| entry.tcp_enabled) - .unwrap_or(false), - tcp_listen: existing_state - .as_ref() - .and_then(|entry| entry.tcp_listen.clone()), - cli_shim_path: existing_state.and_then(|entry| entry.cli_shim_path), - }; - write_state_file(&paths.state_file_path, &state)?; Ok(ManagedRuntimeStatus { runtime_id: manifest.runtime_id, runtime_version: manifest.runtime_version, runtime_root: runtime_root.to_string_lossy().into_owned(), - managed_home: paths.managed_home.to_string_lossy().into_owned(), - transport_type: state.transport_type, - transport_path: state.transport_path, - diagnostics_root: paths.diagnostics_root.to_string_lossy().into_owned(), - state_file_path: paths.state_file_path.to_string_lossy().into_owned(), }) } @@ -999,10 +611,9 @@ fn run_cli_json_command( runtime_root: &Path, manifest: &ManagedRuntimeManifest, args: &[&str], - paths: &ManagedPaths, ) -> Result { log::info!("[cli] running: {:?}", args); - let output = cli_command(runtime_root, manifest, args, paths)? + let output = cli_command(runtime_root, manifest, args)? .stdout(Stdio::piped()) .stderr(Stdio::piped()) .output() @@ -1036,70 +647,30 @@ fn run_cli_json_command( fn managed_daemon_status_internal(app: &AppHandle) -> Result { let status = ensure_runtime_ready_internal(app)?; - let paths = resolve_paths(app)?; let runtime_root = PathBuf::from(&status.runtime_root); let manifest = load_runtime_manifest(&runtime_root)?; - let cli_shim_path = detect_installed_cli_shim_path(&runtime_root, &paths, &manifest); - let state = read_state_file(&paths.state_file_path); - let target = managed_transport_target(&paths, state.as_ref())?; - let tcp_settings = resolve_tcp_settings_from_state(state.as_ref()); - let cli_status = run_cli_json_command( - &runtime_root, - &manifest, - &[ - "daemon", - "status", - "--home", - &paths.managed_home.to_string_lossy(), - "--json", - ], - &paths, - ) - .ok(); - - let daemon_status = cli_status - .as_ref() - .and_then(|value| value.get("status")) - .and_then(|value| value.as_str()) - .unwrap_or(if read_pid_from_home(&paths.managed_home).is_some() { - "running" - } else { - "stopped" - }) - .to_string(); - let daemon_pid = cli_status - .as_ref() - .and_then(|value| value.get("pid")) - .and_then(|value| { - value - .as_i64() - .or_else(|| value.as_str().and_then(|raw| raw.parse::().ok())) - }) - .or_else(|| read_pid_from_home(&paths.managed_home)); - let daemon_running = daemon_pid - .map(|pid| is_pid_running(pid as i32)) - .unwrap_or(false) - || daemon_status == "running"; + let value = run_cli_json_command(&runtime_root, &manifest, &["daemon", "status", "--json"])?; + let daemon_status: CliDaemonStatus = serde_json::from_value(value) + .map_err(|error| format!("Failed to parse managed daemon status: {error}"))?; Ok(ManagedDaemonStatus { runtime_id: manifest.runtime_id, runtime_version: manifest.runtime_version, - runtime_root: runtime_root.to_string_lossy().into_owned(), - managed_home: paths.managed_home.to_string_lossy().into_owned(), - transport_type: target.transport_type, - transport_path: target.transport_path, - daemon_pid, - daemon_running, - daemon_status, - log_path: paths.logs_path.to_string_lossy().into_owned(), - server_id: read_server_id(&paths.managed_home), - hostname: read_hostname_from_home(&paths.managed_home), - relay_enabled: true, - tcp_enabled: tcp_settings.enabled, - tcp_listen: tcp_settings - .enabled - .then(|| format!("{}:{}", tcp_settings.host, tcp_settings.port)), - cli_shim_path, + server_id: daemon_status.server_id.unwrap_or_default(), + status: daemon_status.status, + listen: daemon_status.listen, + hostname: daemon_status.hostname, + pid: daemon_status.pid, + home: daemon_status.home, + }) +} + +fn cli_shim_status_internal(app: &AppHandle) -> Result { + let status = ensure_runtime_ready_internal(app)?; + let runtime_root = PathBuf::from(&status.runtime_root); + let manifest = load_runtime_manifest(&runtime_root)?; + Ok(CliShimStatus { + path: detect_installed_cli_shim_path(&runtime_root, &manifest), }) } @@ -1176,12 +747,11 @@ fn remove_cli_shim_via_macos_prompt(shim_path: &Path) -> Result<(), String> { fn install_cli_shim_internal(app: &AppHandle) -> Result { log::info!("[cli-shim] installing CLI shim"); let status = ensure_runtime_ready_internal(app)?; - let paths = resolve_paths(app)?; let runtime_root = PathBuf::from(&status.runtime_root); let manifest = load_runtime_manifest(&runtime_root)?; let shim_path = outer_cli_shim_path()?; - let shim_contents = cli_shim_contents(&runtime_root, &manifest, &paths); - let manual_instructions = cli_manual_instructions(&runtime_root, &paths, &manifest)?; + let shim_contents = cli_shim_contents(&runtime_root, &manifest); + let manual_instructions = cli_manual_instructions(&runtime_root, &manifest)?; #[cfg(target_os = "macos")] let install_result = install_cli_shim_via_macos_prompt(&shim_path, &shim_contents); @@ -1192,27 +762,13 @@ fn install_cli_shim_internal(app: &AppHandle) -> Result { log::info!("[cli-shim] target path: {}", shim_path.display()); match install_result { - Ok(()) => { - let mut state = read_state_file(&paths.state_file_path).unwrap_or(ManagedStateFile { - runtime_id: status.runtime_id.clone(), - runtime_root: runtime_root.to_string_lossy().into_owned(), - managed_home: paths.managed_home.to_string_lossy().into_owned(), - transport_type: status.transport_type.clone(), - transport_path: status.transport_path.clone(), - tcp_enabled: false, - tcp_listen: None, - cli_shim_path: None, - }); - state.cli_shim_path = Some(shim_path.to_string_lossy().into_owned()); - write_state_file(&paths.state_file_path, &state)?; - Ok(CliShimResult { - status: "installed".to_string(), - installed: true, - path: Some(shim_path.to_string_lossy().into_owned()), - message: format!("Paseo CLI installed at {}.", shim_path.display()), - manual_instructions: None, - }) - } + Ok(()) => Ok(CliShimResult { + status: "installed".to_string(), + installed: true, + path: Some(shim_path.to_string_lossy().into_owned()), + message: format!("Paseo CLI installed at {}.", shim_path.display()), + manual_instructions: None, + }), Err(error) if error == "ELEVATION_DENIED" => Ok(CliShimResult { status: "elevationDenied".to_string(), installed: false, @@ -1241,15 +797,10 @@ fn install_cli_shim_internal(app: &AppHandle) -> Result { fn uninstall_cli_shim_internal(app: &AppHandle) -> Result { let status = ensure_runtime_ready_internal(app)?; - let paths = resolve_paths(app)?; let runtime_root = PathBuf::from(&status.runtime_root); let manifest = load_runtime_manifest(&runtime_root)?; - let shim_path = detect_installed_cli_shim_path(&runtime_root, &paths, &manifest) + let shim_path = detect_installed_cli_shim_path(&runtime_root, &manifest) .map(PathBuf::from) - .or_else(|| { - read_state_file(&paths.state_file_path) - .and_then(|entry| entry.cli_shim_path.map(PathBuf::from)) - }) .unwrap_or(outer_cli_shim_path()?); if shim_path.exists() { #[cfg(target_os = "macos")] @@ -1261,18 +812,6 @@ fn uninstall_cli_shim_internal(app: &AppHandle) -> Result fs::remove_file(&shim_path) .map_err(|error| format!("Failed to remove {}: {error}", shim_path.display()))?; } - let mut state = read_state_file(&paths.state_file_path).unwrap_or(ManagedStateFile { - runtime_id: status.runtime_id.clone(), - runtime_root: runtime_root.to_string_lossy().into_owned(), - managed_home: paths.managed_home.to_string_lossy().into_owned(), - transport_type: status.transport_type.clone(), - transport_path: status.transport_path.clone(), - tcp_enabled: false, - tcp_listen: None, - cli_shim_path: None, - }); - state.cli_shim_path = None; - write_state_file(&paths.state_file_path, &state)?; Ok(CliShimResult { status: "removed".to_string(), @@ -1286,48 +825,16 @@ fn uninstall_cli_shim_internal(app: &AppHandle) -> Result fn start_managed_daemon_internal(app: &AppHandle) -> Result { log::info!("[daemon] starting managed daemon"); let status = ensure_runtime_ready_internal(app)?; - let paths = resolve_paths(app)?; - let existing_status = managed_daemon_status_internal(app)?; - if existing_status.daemon_running { - log::info!( - "[daemon] already running (pid={:?})", - existing_status.daemon_pid - ); - return Ok(existing_status); - } - if let Some(parent) = paths.transport_path.parent() { - fs::create_dir_all(parent) - .map_err(|error| format!("Failed to create {}: {error}", parent.display()))?; - } let runtime_root = PathBuf::from(&status.runtime_root); let manifest = load_runtime_manifest(&runtime_root)?; - let state = read_state_file(&paths.state_file_path); - let target = managed_transport_target(&paths, state.as_ref())?; - log::info!( - "[daemon] spawning: home={} listen={} (type={})", - paths.managed_home.display(), - target.transport_path, - target.transport_type - ); - let output = cli_command( - &runtime_root, - &manifest, - &[ - "start", - "--home", - &paths.managed_home.to_string_lossy(), - "--listen", - &target.transport_path, - ], - &paths, - )? - .stdout(Stdio::piped()) - .stderr(Stdio::piped()) - .output() - .map_err(|error| { - log::error!("[daemon] failed to spawn: {error}"); - format!("Failed to launch managed daemon: {error}") - })?; + let output = cli_command(&runtime_root, &manifest, &["start"])? + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .output() + .map_err(|error| { + log::error!("[daemon] failed to spawn: {error}"); + format!("Failed to launch managed daemon: {error}") + })?; if !output.status.success() { let stderr = to_stdio_message(Some(&String::from_utf8_lossy(&output.stderr))); log::error!( @@ -1342,13 +849,13 @@ fn start_managed_daemon_internal(app: &AppHandle) -> Result Result Result { log::info!("[daemon] stopping managed daemon"); let status = ensure_runtime_ready_internal(app)?; - let paths = resolve_paths(app)?; let runtime_root = PathBuf::from(&status.runtime_root); let manifest = load_runtime_manifest(&runtime_root)?; - let output = cli_command( - &runtime_root, - &manifest, - &[ - "daemon", - "stop", - "--home", - &paths.managed_home.to_string_lossy(), - "--json", - ], - &paths, - )? - .stdout(Stdio::piped()) - .stderr(Stdio::piped()) - .output() - .map_err(|error| { - log::error!("[daemon] failed to spawn stop command: {error}"); - format!("Failed to stop managed daemon: {error}") - })?; + let output = cli_command(&runtime_root, &manifest, &["daemon", "stop", "--json"])? + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .output() + .map_err(|error| { + log::error!("[daemon] failed to spawn stop command: {error}"); + format!("Failed to stop managed daemon: {error}") + })?; if !output.status.success() { let stderr = to_stdio_message(Some(&String::from_utf8_lossy(&output.stderr))); log::error!( @@ -1403,37 +898,16 @@ fn stop_managed_daemon_internal(app: &AppHandle) -> Result Result { log::info!("[daemon] restarting managed daemon"); let status = ensure_runtime_ready_internal(app)?; - let paths = resolve_paths(app)?; let runtime_root = PathBuf::from(&status.runtime_root); let manifest = load_runtime_manifest(&runtime_root)?; - let state = read_state_file(&paths.state_file_path); - let target = managed_transport_target(&paths, state.as_ref())?; - log::info!( - "[daemon] restart: listen={} (type={})", - target.transport_path, - target.transport_type - ); - let output = cli_command( - &runtime_root, - &manifest, - &[ - "daemon", - "restart", - "--home", - &paths.managed_home.to_string_lossy(), - "--listen", - &target.transport_path, - "--json", - ], - &paths, - )? - .stdout(Stdio::piped()) - .stderr(Stdio::piped()) - .output() - .map_err(|error| { - log::error!("[daemon] failed to spawn restart command: {error}"); - format!("Failed to restart managed daemon: {error}") - })?; + let output = cli_command(&runtime_root, &manifest, &["daemon", "restart", "--json"])? + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .output() + .map_err(|error| { + log::error!("[daemon] failed to spawn restart command: {error}"); + format!("Failed to restart managed daemon: {error}") + })?; if !output.status.success() { let stderr = to_stdio_message(Some(&String::from_utf8_lossy(&output.stderr))); log::error!( @@ -1453,40 +927,10 @@ fn restart_managed_daemon_internal(app: &AppHandle) -> Result Result { - log::info!( - "[tcp] updating settings: enabled={} host={} port={}", - settings.enabled, - settings.host, - settings.port - ); - if settings.enabled { - parse_tcp_listen(&format!("{}:{}", settings.host.trim(), settings.port))?; - } - let status = ensure_runtime_ready_internal(app)?; - let paths = resolve_paths(app)?; - let mut state = read_state_file(&paths.state_file_path).unwrap_or(ManagedStateFile { - runtime_id: status.runtime_id.clone(), - runtime_root: status.runtime_root.clone(), - managed_home: paths.managed_home.to_string_lossy().into_owned(), - transport_type: status.transport_type.clone(), - transport_path: status.transport_path.clone(), - tcp_enabled: false, - tcp_listen: None, - cli_shim_path: None, - }); - state.tcp_enabled = settings.enabled; - state.tcp_listen = Some(format!("{}:{}", settings.host.trim(), settings.port)); - let target = managed_transport_target(&paths, Some(&state))?; - state.transport_type = target.transport_type; - state.transport_path = target.transport_path; - write_state_file(&paths.state_file_path, &state)?; - - if managed_daemon_status_internal(app)?.daemon_running { - return restart_managed_daemon_internal(app); - } - managed_daemon_status_internal(app) + let _ = app; + Err("Managed daemon TCP settings are no longer configurable from desktop.".to_string()) } #[tauri::command] @@ -1524,14 +968,27 @@ pub async fn restart_managed_daemon(app: AppHandle) -> Result Result { + tauri::async_runtime::spawn_blocking(move || cli_shim_status_internal(&app)) + .await + .map_err(|error| format!("CLI shim status task failed: {error}"))? +} + #[tauri::command] pub async fn managed_daemon_logs(app: AppHandle) -> Result { tauri::async_runtime::spawn_blocking(move || { - ensure_runtime_ready_internal(&app)?; - let paths = resolve_paths(&app)?; + let runtime_status = ensure_runtime_ready_internal(&app)?; + let runtime_root = PathBuf::from(&runtime_status.runtime_root); + let manifest = load_runtime_manifest(&runtime_root)?; + let value = + run_cli_json_command(&runtime_root, &manifest, &["daemon", "status", "--json"])?; + let daemon_status: CliDaemonStatus = serde_json::from_value(value) + .map_err(|error| format!("Failed to parse managed daemon status for logs: {error}"))?; + let log_path = PathBuf::from(&daemon_status.log_path); Ok(ManagedDaemonLogs { - log_path: paths.logs_path.to_string_lossy().into_owned(), - contents: tail_log(&paths.logs_path, 400), + log_path: log_path.to_string_lossy().into_owned(), + contents: tail_log(&log_path, 400), }) }) .await @@ -1542,21 +999,9 @@ pub async fn managed_daemon_logs(app: AppHandle) -> Result Result { tauri::async_runtime::spawn_blocking(move || { let status = ensure_runtime_ready_internal(&app)?; - let paths = resolve_paths(&app)?; let runtime_root = PathBuf::from(&status.runtime_root); let manifest = load_runtime_manifest(&runtime_root)?; - let value = run_cli_json_command( - &runtime_root, - &manifest, - &[ - "daemon", - "pair", - "--home", - &paths.managed_home.to_string_lossy(), - "--json", - ], - &paths, - )?; + let value = run_cli_json_command(&runtime_root, &manifest, &["daemon", "pair", "--json"])?; serde_json::from_value::(value) .map_err(|error| format!("Failed to parse managed pairing offer: {error}")) }) diff --git a/packages/server/src/server/exports.ts b/packages/server/src/server/exports.ts index dee16d888..df7ea3411 100644 --- a/packages/server/src/server/exports.ts +++ b/packages/server/src/server/exports.ts @@ -2,6 +2,7 @@ export { createPaseoDaemon, type PaseoDaemon, type PaseoDaemonConfig } from "./bootstrap.js"; export { loadConfig, type CliConfigOverrides } from "./config.js"; export { resolvePaseoHome } from "./paseo-home.js"; +export { getOrCreateServerId } from "./server-id.js"; export { createRootLogger, type LogLevel, type LogFormat } from "./logger.js"; export { loadPersistedConfig, type PersistedConfig } from "./persisted-config.js"; export { generateLocalPairingOffer, type LocalPairingOffer } from "./pairing-offer.js"; diff --git a/windows-startup-refactor-plan.md b/windows-startup-refactor-plan.md new file mode 100644 index 000000000..663b9b151 --- /dev/null +++ b/windows-startup-refactor-plan.md @@ -0,0 +1,161 @@ +# Desktop Startup Refactor Plan + +## Goal + +1. Tauri runs `paseo start`, then polls `paseo daemon status --json` until ready +2. `daemon status --json` returns ALL needed data: serverId, listen address, hostname, status, pid +3. App takes that data and adds it to the registry like any other host +4. App blocks on startup splash until any host runtime goes online (race) +5. 30s timeout → redirect to welcome/add-connection screen + +## Core Principles + +- The daemon is autonomous. Tauri does not pass `--home`, `--listen`, or read any files. +- `daemon status --json` is the single source of truth. All data comes from it. +- The host registry has no concept of "managed" daemons. A host is just a serverId + connections. + +--- + +## Implementation Plan + +### Phase 1: Add serverId to `daemon status --json` + +**File: `packages/cli/src/commands/daemon/status.ts`** + +`DaemonStatus` currently has: `status`, `home`, `listen`, `pid`, `startedAt`, `owner`, `logPath`, etc. + +Add: +- `serverId: string | null` +- `hostname: string | null` (already available from pidInfo, just not in the output) + +The status command already calls `resolveLocalDaemonState()` which reads the pid file (has hostname) and resolves home. From home it can call `getOrCreateServerId(home)` to get the serverId. All internal to the status command — Tauri never touches these files. + +### Phase 2: Tauri — just start + poll status + +**File: `packages/desktop/src-tauri/src/runtime_manager.rs`** + +#### 2a. `start_managed_daemon_internal` becomes: + +1. Run `paseo start` (no `--home`, no `--listen`) +2. Poll `paseo daemon status --json` in a loop until `status == "running"` and `serverId` is present +3. Return the parsed JSON to the app + +#### 2b. Remove all daemon plumbing from Rust + +Delete: +- `managed_transport_target()`, `ManagedTransportTarget` +- `default_transport_path()`, `default_transport_type()` +- `default_managed_home()` +- `resolve_paths()` / `ManagedPaths` +- `read_server_id()`, `read_hostname_from_home()`, `read_pid_from_home()` +- `cli_env()` — no `PASEO_HOME` or `PASEO_HOST` injection +- State file reading/writing for transport config +- All direct file I/O for daemon state + +Keep: +- `bundled_runtime_root()` — to find bundled Node.js + CLI +- `cli_command()` — to invoke the bundled CLI +- `ensure_runtime_ready_internal()` — to verify runtime extraction + +#### 2c. `ManagedDaemonStatus` mirrors `daemon status --json` + +- `serverId: string` +- `status: string` +- `listen: string` +- `hostname: string | null` +- `pid: number | null` +- `home: string` +- Plus: `runtimeId`, `runtimeVersion` (from bundled runtime manifest) + +App parses `listen` to determine transport type: +- `\\.\pipe\*` or `pipe://` → pipe +- `/` or `unix://` → socket +- `host:port` → tcp + +### Phase 3: App — remove "managed" concept from registry + +**File: `packages/app/src/contexts/daemon-registry-context.tsx`** + +#### 3a. Delete managed-specific code + +- `ManagedHostReconciliationInput` +- `DesktopStartupReconciliationInput` +- `reconcileManagedHostInProfiles()` +- `reconcileDesktopStartupRegistry()` +- `probeManagedConnectionUntilReady()` +- `probeManagedStartupTarget()` +- `resolveManagedDesktopStartupStatus()` +- `DEFAULT_LOCAL_TRANSPORT_BOOTSTRAP_*` constants + +#### 3b. Remove `managed` from `HostLifecycle` + +Remove `managed`, `managedRuntimeId`, `managedRuntimeVersion`, `associatedServerId`. Remove all code that checks `lifecycle.managed`. + +#### 3c. Desktop startup effect becomes: + +```typescript +const bootstrap = async () => { + const daemon = await startManagedDaemon() + if (cancelled || !daemon.serverId) return + + const connection = connectionFromListen(daemon.listen) + if (!connection) return + + await upsertHostConnection({ + serverId: daemon.serverId, + label: daemon.hostname ?? undefined, + connection, + }) +} +``` + +#### 3d. Remove `isReconciling` + +No longer needed. Registry update triggers `MultiDaemonSessionHost` → `syncHosts()` → `HostRuntimeController` connects. + +### Phase 4: Index screen — race until online + +**File: `packages/app/src/app/index.tsx`** + +- New hook: `useAnyHostOnline(serverIds)` — subscribes to `HostRuntimeStore` +- Show splash while: registry loading OR (hosts exist, none online, within 30s) +- Any host goes online → navigate to it +- 30s timeout → WelcomeScreen + +Both managed daemon and localhost:6767 race. First to connect wins. + +### Phase 5: Localhost bootstrap + +Non-desktop: keep the lightweight probe (2.5s, needs serverId from handshake). +Desktop: localhost:6767 added as a race participant alongside the daemon connection. + +### Phase 6: Metro must ignore app test files + +**Files: `packages/app/metro.config.js` and any directly related Metro resolver helpers/tests** + +The app already contains many `*.test.ts` and `*.test.tsx` files under `packages/app/src`. Those files must remain in the repo and must NOT be deleted to make Metro happy. + +Implement Metro-side protection so Expo/Metro does not try to bundle app test files from `packages/app/src` during normal app/web startup: + +- Treat `*.test.ts`, `*.test.tsx`, `*.spec.ts`, and `*.spec.tsx` under `packages/app/src` as ignored/non-bundle inputs for Metro app resolution +- If the current Metro resolver hook needs to special-case local imports or extensions, update it so test files are skipped cleanly instead of causing bundle resolution crashes +- Preserve Vitest usage for those files; the change is only about Metro bundling behavior, not removing or renaming tests +- If a test index/helper file is needed for app tests, keep it and make Metro ignore it rather than deleting it + +--- + +## What stays the same + +- `HostRuntimeController`, `HostRuntimeStore`, `MultiDaemonSessionHost` — no changes +- `DaemonClient` transports — no changes +- The daemon itself — no changes (already autonomous) + +## Acceptance Criteria + +1. `paseo daemon status --json` returns `serverId`, `listen`, `hostname`, `status`, and `pid`, and desktop startup consumes that shape as the source of truth +2. The app registry no longer contains managed-daemon lifecycle concepts; desktop startup upserts the managed daemon like any other host +3. Desktop startup blocks on the startup splash during the connection race and only leaves it when a host goes online or the 30s timeout expires +4. Desktop still races the managed daemon and `localhost:6767`; non-desktop keeps the lightweight localhost probe +5. Metro does not try to bundle `packages/app/src/**/*.test.*` or `packages/app/src/**/*.spec.*` during normal app startup +6. Existing app test files, including any `index.test.ts` helper needed by tests, remain in the repo and are not deleted as a workaround +7. `npm run typecheck` passes