Merge remote-tracking branch 'origin/improve-startup'

This commit is contained in:
Mohamed Boudra
2026-03-11 20:10:11 +07:00
16 changed files with 1010 additions and 1785 deletions
+8
View File
@@ -1,10 +1,12 @@
const { getDefaultConfig } = require("expo/metro-config"); const { getDefaultConfig } = require("expo/metro-config");
const exclusionList = require("metro-config/src/defaults/exclusionList");
const { resolve } = require("metro-resolver"); const { resolve } = require("metro-resolver");
const fs = require("fs"); const fs = require("fs");
const path = require("path"); const path = require("path");
const projectRoot = __dirname; const projectRoot = __dirname;
const appNodeModulesRoot = path.resolve(projectRoot, "node_modules"); const appNodeModulesRoot = path.resolve(projectRoot, "node_modules");
const appSrcRoot = path.resolve(projectRoot, "src");
const serverSrcRoot = path.resolve(projectRoot, "../server/src"); const serverSrcRoot = path.resolve(projectRoot, "../server/src");
const relaySrcRoot = path.resolve(projectRoot, "../relay/src"); const relaySrcRoot = path.resolve(projectRoot, "../relay/src");
const customWebPlatform = (process.env.PASEO_WEB_PLATFORM ?? "") const customWebPlatform = (process.env.PASEO_WEB_PLATFORM ?? "")
@@ -14,6 +16,9 @@ const customWebPlatform = (process.env.PASEO_WEB_PLATFORM ?? "")
const config = getDefaultConfig(projectRoot); const config = getDefaultConfig(projectRoot);
const defaultResolveRequest = config.resolver.resolveRequest ?? resolve; const defaultResolveRequest = config.resolver.resolveRequest ?? resolve;
const escapedAppSrcRoot = appSrcRoot
.replace(/[|\\{}()[\]^$+*?.]/g, "\\$&")
.replace(/\//g, "[\\\\/]");
config.resolver.extraNodeModules = { config.resolver.extraNodeModules = {
...(config.resolver.extraNodeModules ?? {}), ...(config.resolver.extraNodeModules ?? {}),
@@ -22,6 +27,9 @@ config.resolver.extraNodeModules = {
"react/jsx-runtime": path.join(appNodeModulesRoot, "react/jsx-runtime"), "react/jsx-runtime": path.join(appNodeModulesRoot, "react/jsx-runtime"),
"react/jsx-dev-runtime": path.join(appNodeModulesRoot, "react/jsx-dev-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) { function isLocalModuleImport(moduleName) {
return ( return (
+41
View File
@@ -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
}
+109 -54
View File
@@ -1,65 +1,120 @@
import { useEffect, useMemo } from "react"; import { useEffect, useSyncExternalStore, useState } from 'react'
import { useLocalSearchParams, usePathname, useRouter } from "expo-router"; import { usePathname, useRouter } from 'expo-router'
import { useDaemonRegistry } from "@/contexts/daemon-registry-context"; import { useDaemonRegistry } from '@/contexts/daemon-registry-context'
import { useFormPreferences } from "@/hooks/use-form-preferences"; import { shouldUseManagedDesktopDaemon } from '@/desktop/managed-runtime/managed-runtime'
import { buildHostRootRoute } from "@/utils/host-routes"; import { buildHostRootRoute } from '@/utils/host-routes'
import { StartupSplashScreen } from "@/screens/startup-splash-screen"; import { StartupSplashScreen } from '@/screens/startup-splash-screen'
import { WelcomeScreen } from "@/components/welcome-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() { export default function Index() {
const router = useRouter(); const router = useRouter()
const pathname = usePathname(); const pathname = usePathname()
const params = useLocalSearchParams<{ serverId?: string }>(); const { daemons, isLoading: registryLoading } = useDaemonRegistry()
const { daemons, isLoading: registryLoading, isReconciling } = useDaemonRegistry(); const [hasTimedOut, setHasTimedOut] = useState(false)
const { preferences, isLoading: preferencesLoading } = useFormPreferences(); const isDesktopStartupRace = shouldUseManagedDesktopDaemon()
const requestedServerId = useMemo(() => { const onlineServerId = useAnyHostOnline(daemons.map((daemon) => daemon.serverId))
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]);
useEffect(() => { useEffect(() => {
if (registryLoading || preferencesLoading) { const timer = setTimeout(() => {
return; 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) { useEffect(() => {
return <StartupSplashScreen />; 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 <StartupSplashScreen />
} }
if (!targetServerId) { if (!onlineServerId) {
if (isReconciling) { return <WelcomeScreen />
return <StartupSplashScreen />;
}
return <WelcomeScreen />;
} }
return null; return null
} }
+5
View File
@@ -0,0 +1,5 @@
import { WelcomeScreen } from '@/components/welcome-screen'
export default function WelcomeRoute() {
return <WelcomeScreen />
}
@@ -1,9 +1,8 @@
import { describe, expect, it } from 'vitest' import { describe, expect, it } from 'vitest'
import { import {
connectionFromListen,
hostHasDirectEndpoint, hostHasDirectEndpoint,
registryHasDirectEndpoint, registryHasDirectEndpoint,
reconcileDesktopStartupRegistry,
resolveManagedDesktopStartupStatus,
type HostProfile, type HostProfile,
} from './daemon-registry-context' } from './daemon-registry-context'
@@ -12,12 +11,7 @@ function makeHost(input: Partial<HostProfile> & Pick<HostProfile, 'serverId'>):
return { return {
serverId: input.serverId, serverId: input.serverId,
label: input.label ?? input.serverId, label: input.label ?? input.serverId,
lifecycle: input.lifecycle ?? { lifecycle: input.lifecycle ?? {},
managed: false,
managedRuntimeId: null,
managedRuntimeVersion: null,
associatedServerId: null,
},
connections: input.connections ?? [], connections: input.connections ?? [],
preferredConnectionId: input.preferredConnectionId ?? null, preferredConnectionId: input.preferredConnectionId ?? null,
createdAt: input.createdAt ?? now, createdAt: input.createdAt ?? now,
@@ -25,6 +19,47 @@ function makeHost(input: Partial<HostProfile> & Pick<HostProfile, 'serverId'>):
} }
} }
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', () => { describe('hostHasDirectEndpoint', () => {
it('returns true when host has matching direct endpoint', () => { it('returns true when host has matching direct endpoint', () => {
const host = makeHost({ const host = makeHost({
@@ -84,240 +119,3 @@ describe('registryHasDirectEndpoint', () => {
expect(registryHasDirectEndpoint(hosts, 'localhost:6767')).toBe(false) expect(registryHasDirectEndpoint(hosts, 'localhost:6767')).toBe(false)
}) })
}) })
describe('reconcileDesktopStartupRegistry', () => {
it('seeds managed and localhost connections as normal host entries', () => {
const now = '2026-03-08T00:00:00.000Z'
const result = reconcileDesktopStartupRegistry({
existing: [],
managed: {
serverId: 'srv_managed',
hostname: 'managed-host',
runtimeId: 'runtime_1',
runtimeVersion: '1.2.3',
transportType: 'socket',
transportPath: '/Users/test/.paseo-test/paseo.sock',
associatedServerId: 'srv_managed',
},
localhost: {
serverId: 'srv_localhost',
hostname: 'local-dev',
endpoint: 'localhost:6767',
},
now,
})
expect(result).toEqual([
makeHost({
serverId: 'srv_managed',
label: 'managed-host',
lifecycle: {
managed: true,
managedRuntimeId: 'runtime_1',
managedRuntimeVersion: '1.2.3',
associatedServerId: 'srv_managed',
},
connections: [
{
id: 'socket:/Users/test/.paseo-test/paseo.sock',
type: 'directSocket',
path: '/Users/test/.paseo-test/paseo.sock',
},
],
preferredConnectionId: 'socket:/Users/test/.paseo-test/paseo.sock',
createdAt: now,
updatedAt: now,
}),
makeHost({
serverId: 'srv_localhost',
label: 'local-dev',
connections: [
{
id: 'direct:localhost:6767',
type: 'directTcp',
endpoint: 'localhost:6767',
},
],
preferredConnectionId: 'direct:localhost:6767',
createdAt: now,
updatedAt: now,
}),
])
})
it('keeps managed and localhost connections together when they resolve to the same server', () => {
const now = '2026-03-08T00:00:00.000Z'
const result = reconcileDesktopStartupRegistry({
existing: [],
managed: {
serverId: 'srv_shared',
hostname: 'devbox',
runtimeId: 'runtime_1',
runtimeVersion: '1.2.3',
transportType: 'socket',
transportPath: '/Users/test/.paseo-test/paseo.sock',
associatedServerId: 'srv_shared',
},
localhost: {
serverId: 'srv_shared',
hostname: 'devbox',
endpoint: 'localhost:6767',
},
now,
})
expect(result).toEqual([
makeHost({
serverId: 'srv_shared',
label: 'devbox',
lifecycle: {
managed: true,
managedRuntimeId: 'runtime_1',
managedRuntimeVersion: '1.2.3',
associatedServerId: 'srv_shared',
},
connections: [
{
id: 'socket:/Users/test/.paseo-test/paseo.sock',
type: 'directSocket',
path: '/Users/test/.paseo-test/paseo.sock',
},
{
id: 'direct:localhost:6767',
type: 'directTcp',
endpoint: 'localhost:6767',
},
],
preferredConnectionId: 'socket:/Users/test/.paseo-test/paseo.sock',
createdAt: now,
updatedAt: now,
}),
])
})
it('is idempotent for repeated desktop startup reconciliation', () => {
const now = '2026-03-08T00:00:00.000Z'
const first = reconcileDesktopStartupRegistry({
existing: [],
managed: {
serverId: 'srv_shared',
hostname: 'devbox',
runtimeId: 'runtime_1',
runtimeVersion: '1.2.3',
transportType: 'socket',
transportPath: '/Users/test/.paseo-test/paseo.sock',
associatedServerId: 'srv_shared',
},
localhost: {
serverId: 'srv_shared',
hostname: 'devbox',
endpoint: 'localhost:6767',
},
now,
})
const second = reconcileDesktopStartupRegistry({
existing: first,
managed: {
serverId: 'srv_shared',
hostname: 'devbox',
runtimeId: 'runtime_1',
runtimeVersion: '1.2.3',
transportType: 'socket',
transportPath: '/Users/test/.paseo-test/paseo.sock',
associatedServerId: 'srv_shared',
},
localhost: {
serverId: 'srv_shared',
hostname: 'devbox',
endpoint: 'localhost:6767',
},
now: '2026-03-09T00:00:00.000Z',
})
expect(second).toEqual(first)
})
})
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)
})
})
@@ -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 type { ReactNode } from 'react'
import AsyncStorage from '@react-native-async-storage/async-storage' import AsyncStorage from '@react-native-async-storage/async-storage'
import { useQuery, useQueryClient } from '@tanstack/react-query' import { useQuery, useQueryClient } from '@tanstack/react-query'
import { decodeOfferFragmentPayload, normalizeHostPort } from '@/utils/daemon-endpoints' import { decodeOfferFragmentPayload, normalizeHostPort } from '@/utils/daemon-endpoints'
import { probeConnection } from '@/utils/test-daemon-connection' import { probeConnection } from '@/utils/test-daemon-connection'
import { useAppSettings, type AppSettings } from '@/hooks/use-settings'
import { ConnectionOfferSchema, type ConnectionOffer } from '@server/shared/connection-offer' import { ConnectionOfferSchema, type ConnectionOffer } from '@server/shared/connection-offer'
import { import {
getManagedDaemonStatus,
type ManagedDaemonStatus,
shouldUseManagedDesktopDaemon, shouldUseManagedDesktopDaemon,
startManagedDaemon, startManagedDaemon,
} from '@/desktop/managed-runtime/managed-runtime' } 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_ENDPOINT = 'localhost:6767'
const DEFAULT_LOCALHOST_BOOTSTRAP_KEY = '@paseo:default-localhost-bootstrap-v1' const DEFAULT_LOCALHOST_BOOTSTRAP_KEY = '@paseo:default-localhost-bootstrap-v1'
const DEFAULT_LOCALHOST_BOOTSTRAP_TIMEOUT_MS = 2500 const DEFAULT_LOCALHOST_BOOTSTRAP_TIMEOUT_MS = 2500
const DEFAULT_LOCAL_TRANSPORT_BOOTSTRAP_TIMEOUT_MS = 6000
const DEFAULT_LOCAL_TRANSPORT_BOOTSTRAP_RETRY_MS = 2000
const DEFAULT_LOCAL_TRANSPORT_BOOTSTRAP_DEADLINE_MS = 120000
const E2E_STORAGE_KEY = '@paseo:e2e' const E2E_STORAGE_KEY = '@paseo:e2e'
export type DirectTcpHostConnection = { export type DirectTcpHostConnection = {
@@ -54,12 +48,7 @@ export type HostConnection =
| DirectPipeHostConnection | DirectPipeHostConnection
| RelayHostConnection | RelayHostConnection
export type HostLifecycle = { export type HostLifecycle = Record<string, never>
managed: boolean
managedRuntimeId: string | null
managedRuntimeVersion: string | null
associatedServerId: string | null
}
export type HostProfile = { export type HostProfile = {
serverId: string serverId: string
@@ -73,33 +62,9 @@ export type HostProfile = {
export type UpdateHostInput = Partial<Omit<HostProfile, 'serverId' | 'createdAt'>> export type UpdateHostInput = Partial<Omit<HostProfile, 'serverId' | 'createdAt'>>
export type ManagedHostReconciliationInput = {
serverId: string
hostname?: string | null
runtimeId: string
runtimeVersion: string
transportType: string
transportPath: string
associatedServerId?: string | null
}
export type LocalhostHostReconciliationInput = {
serverId: string
hostname: string | null
endpoint: string
}
export type DesktopStartupReconciliationInput = {
existing: HostProfile[]
managed: ManagedHostReconciliationInput | null
localhost: LocalhostHostReconciliationInput | null
now?: string
}
interface DaemonRegistryContextValue { interface DaemonRegistryContextValue {
daemons: HostProfile[] daemons: HostProfile[]
isLoading: boolean isLoading: boolean
isReconciling: boolean
error: unknown | null error: unknown | null
upsertDirectConnection: (input: { upsertDirectConnection: (input: {
serverId: string serverId: string
@@ -122,12 +87,7 @@ interface DaemonRegistryContextValue {
const DaemonRegistryContext = createContext<DaemonRegistryContextValue | null>(null) const DaemonRegistryContext = createContext<DaemonRegistryContextValue | null>(null)
function defaultLifecycle(): HostLifecycle { function defaultLifecycle(): HostLifecycle {
return { return {}
managed: false,
managedRuntimeId: null,
managedRuntimeVersion: null,
associatedServerId: null,
}
} }
function normalizeHostLabel(value: string | null | undefined, serverId: string): string { function normalizeHostLabel(value: string | null | undefined, serverId: string): string {
@@ -143,51 +103,48 @@ function normalizeEndpointOrNull(endpoint: string): string | null {
} }
} }
function sleep(ms: number): Promise<void> { export function connectionFromListen(listen: string): HostConnection | null {
return new Promise((resolve) => { const normalizedListen = listen.trim()
setTimeout(resolve, ms) if (!normalizedListen) {
})
}
function normalizeManagedTransportConnection(input: {
transportType: string
transportPath: string
}): HostConnection | null {
const transportPath = input.transportPath.trim()
if (!transportPath) {
return null return null
} }
if (input.transportType === 'tcp') { if (normalizedListen.startsWith('pipe://')) {
try { const path = normalizedListen.slice('pipe://'.length).trim()
const endpoint = normalizeHostPort(transportPath) return path ? { id: `pipe:${path}`, type: 'directPipe', path } : null
return {
id: `direct:${endpoint}`,
type: 'directTcp',
endpoint,
}
} catch {
return 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 { return {
id: `pipe:${transportPath}`, id: `pipe:${normalizedListen}`,
type: 'directPipe', type: 'directPipe',
path: transportPath, path: normalizedListen,
} }
} }
if (input.transportType === 'socket') { if (normalizedListen.startsWith('/')) {
return { return {
id: `socket:${transportPath}`, id: `socket:${normalizedListen}`,
type: 'directSocket', 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 { function normalizeStoredConnection(connection: unknown): HostConnection | null {
@@ -231,19 +188,8 @@ function normalizeStoredConnection(connection: unknown): HostConnection | null {
return null return null
} }
function normalizeStoredLifecycle(lifecycle: unknown): HostLifecycle { function normalizeStoredLifecycle(_lifecycle: unknown): HostLifecycle {
const record = return defaultLifecycle()
lifecycle && typeof lifecycle === 'object' ? (lifecycle as Record<string, unknown>) : null
return {
managed: record?.managed === true,
managedRuntimeId:
typeof record?.managedRuntimeId === 'string' ? record.managedRuntimeId : null,
managedRuntimeVersion:
typeof record?.managedRuntimeVersion === 'string' ? record.managedRuntimeVersion : null,
associatedServerId:
typeof record?.associatedServerId === 'string' ? record.associatedServerId : null,
}
} }
function normalizeStoredHostProfile(entry: unknown): HostProfile | null { function normalizeStoredHostProfile(entry: unknown): HostProfile | null {
@@ -312,19 +258,13 @@ function hostConnectionEquals(left: HostConnection, right: HostConnection): bool
} }
function hostLifecycleEquals(left: HostLifecycle, right: HostLifecycle): boolean { function hostLifecycleEquals(left: HostLifecycle, right: HostLifecycle): boolean {
return ( return JSON.stringify(left) === JSON.stringify(right)
left.managed === right.managed &&
left.managedRuntimeId === right.managedRuntimeId &&
left.managedRuntimeVersion === right.managedRuntimeVersion &&
left.associatedServerId === right.associatedServerId
)
} }
function upsertHostConnectionInProfiles(input: { function upsertHostConnectionInProfiles(input: {
profiles: HostProfile[] profiles: HostProfile[]
serverId: string serverId: string
label?: string label?: string
lifecycle?: Partial<HostLifecycle>
connection: HostConnection connection: HostConnection
now?: string now?: string
}): HostProfile[] { }): HostProfile[] {
@@ -343,10 +283,7 @@ function upsertHostConnectionInProfiles(input: {
const profile: HostProfile = { const profile: HostProfile = {
serverId, serverId,
label: derivedLabel, label: derivedLabel,
lifecycle: { lifecycle: defaultLifecycle(),
...defaultLifecycle(),
...(input.lifecycle ?? {}),
},
connections: [input.connection], connections: [input.connection],
preferredConnectionId: input.connection.id, preferredConnectionId: input.connection.id,
createdAt: now, createdAt: now,
@@ -371,10 +308,7 @@ function upsertHostConnectionInProfiles(input: {
) )
: prev.connections : prev.connections
const nextLifecycle = { const nextLifecycle = prev.lifecycle
...prev.lifecycle,
...(input.lifecycle ?? {}),
}
const nextLabel = labelTrimmed ? labelTrimmed : prev.label const nextLabel = labelTrimmed ? labelTrimmed : prev.label
const nextPreferredConnectionId = prev.preferredConnectionId ?? input.connection.id const nextPreferredConnectionId = prev.preferredConnectionId ?? input.connection.id
const changed = const changed =
@@ -402,144 +336,6 @@ function upsertHostConnectionInProfiles(input: {
return next 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<AppSettings, 'manageBuiltInDaemon'>
startManagedDaemonFn?: () => Promise<ManagedDaemonStatus>
getManagedDaemonStatusFn?: () => Promise<ManagedDaemonStatus>
}): Promise<ManagedDaemonStatus> {
if (input.settings.manageBuiltInDaemon) {
return await (input.startManagedDaemonFn ?? startManagedDaemon)()
}
return await (input.getManagedDaemonStatusFn ?? getManagedDaemonStatus)()
}
async function probeManagedStartupTarget(input: {
managedDaemon: ManagedDaemonStatus
cancelled?: () => boolean
}): Promise<ManagedHostReconciliationInput | null> {
const connection = normalizeManagedTransportConnection({
transportType: input.managedDaemon.transportType,
transportPath: input.managedDaemon.transportPath,
})
if (!connection) {
return null
}
let serverId = input.managedDaemon.serverId
let hostname = input.managedDaemon.hostname
if (!serverId) {
const probed = await probeConnection(connection, {
timeoutMs: DEFAULT_LOCAL_TRANSPORT_BOOTSTRAP_TIMEOUT_MS,
})
if (input.cancelled?.()) {
throw new Error('Managed daemon bootstrap cancelled')
}
serverId = probed.serverId
hostname = hostname ?? probed.hostname
}
return {
serverId,
hostname,
runtimeId: input.managedDaemon.runtimeId,
runtimeVersion: input.managedDaemon.runtimeVersion,
transportType: input.managedDaemon.transportType,
transportPath: input.managedDaemon.transportPath,
associatedServerId: input.managedDaemon.serverId,
}
}
async function probeManagedConnectionUntilReady(
input: {
managedDaemon: ManagedDaemonStatus
cancelled?: () => boolean
}
): Promise<ManagedHostReconciliationInput | null> {
const startedAt = Date.now()
let lastError: unknown = null
while (Date.now() - startedAt < DEFAULT_LOCAL_TRANSPORT_BOOTSTRAP_DEADLINE_MS) {
if (input.cancelled?.()) {
throw new Error('Managed daemon bootstrap cancelled')
}
try {
return await probeManagedStartupTarget(input)
} catch (error) {
lastError = error
if (input.cancelled?.()) {
throw error
}
await sleep(DEFAULT_LOCAL_TRANSPORT_BOOTSTRAP_RETRY_MS)
}
}
throw lastError ?? new Error('Managed daemon bootstrap timed out')
}
export function hostHasDirectEndpoint(host: HostProfile, endpoint: string): boolean { export function hostHasDirectEndpoint(host: HostProfile, endpoint: string): boolean {
const normalized = normalizeEndpointOrNull(endpoint) const normalized = normalizeEndpointOrNull(endpoint)
if (!normalized) { if (!normalized) {
@@ -564,10 +360,8 @@ export function useDaemonRegistry(): DaemonRegistryContextValue {
export function DaemonRegistryProvider({ children }: { children: ReactNode }) { export function DaemonRegistryProvider({ children }: { children: ReactNode }) {
const queryClient = useQueryClient() const queryClient = useQueryClient()
const desktopStartupReconciledRef = useRef(false) const desktopStartupAttemptedRef = useRef(false)
const localhostBootstrapAttemptedRef = useRef(false) const localhostBootstrapAttemptedRef = useRef(false)
const [isReconciling, setIsReconciling] = useState(true)
const { settings, isLoading: settingsLoading } = useAppSettings()
const { const {
data: daemons = [], data: daemons = [],
isPending, isPending,
@@ -648,7 +442,6 @@ export function DaemonRegistryProvider({ children }: { children: ReactNode }) {
async (input: { async (input: {
serverId: string serverId: string
label?: string label?: string
lifecycle?: Partial<HostLifecycle>
connection: HostConnection connection: HostConnection
}) => { }) => {
const now = new Date().toISOString() const now = new Date().toISOString()
@@ -656,7 +449,6 @@ export function DaemonRegistryProvider({ children }: { children: ReactNode }) {
profiles: readDaemons(), profiles: readDaemons(),
serverId: input.serverId, serverId: input.serverId,
label: input.label, label: input.label,
lifecycle: input.lifecycle,
connection: input.connection, connection: input.connection,
now, now,
}) })
@@ -684,101 +476,81 @@ export function DaemonRegistryProvider({ children }: { children: ReactNode }) {
useEffect(() => { useEffect(() => {
if (isPending) return if (isPending) return
if (settingsLoading) return
if (!shouldUseManagedDesktopDaemon()) return if (!shouldUseManagedDesktopDaemon()) return
if (desktopStartupReconciledRef.current) return if (desktopStartupAttemptedRef.current) return
desktopStartupReconciledRef.current = true desktopStartupAttemptedRef.current = true
let cancelled = false let cancelled = false
const reconcileDesktopStartup = async () => { const bootstrapDesktopStartup = async () => {
try { try {
const isE2E = await AsyncStorage.getItem(E2E_STORAGE_KEY) const isE2E = await AsyncStorage.getItem(E2E_STORAGE_KEY)
if (cancelled || isE2E) { if (cancelled || isE2E) {
return return
} }
let managed: ManagedHostReconciliationInput | null = null await Promise.allSettled([
try { (async () => {
const managedDaemon = await resolveManagedDesktopStartupStatus({ const daemon = await startManagedDaemon()
settings, if (cancelled || !daemon.serverId) {
}) return
if (managedDaemon.daemonRunning) {
managed = await probeManagedConnectionUntilReady({
managedDaemon,
cancelled: () => cancelled,
})
}
} catch (managedBootstrapError) {
if (!cancelled) {
console.warn(
'[DaemonRegistry] Failed to reconcile managed daemon transport',
managedBootstrapError
)
}
}
let localhost: LocalhostHostReconciliationInput | null = null
try {
const { serverId, hostname } = await probeConnection(
{
id: `bootstrap:${DEFAULT_LOCALHOST_ENDPOINT}`,
type: 'directTcp',
endpoint: DEFAULT_LOCALHOST_ENDPOINT,
},
{ timeoutMs: DEFAULT_LOCALHOST_BOOTSTRAP_TIMEOUT_MS }
)
if (!cancelled) {
localhost = {
serverId,
hostname,
endpoint: DEFAULT_LOCALHOST_ENDPOINT,
} }
}
} catch {
// Best-effort reconciliation only; keep startup resilient if localhost isn't reachable.
}
if (cancelled) { const connection = connectionFromListen(daemon.listen)
return if (!connection) {
} return
}
const existing = readDaemons() await upsertHostConnection({
const next = reconcileDesktopStartupRegistry({ serverId: daemon.serverId,
existing, label: daemon.hostname ?? undefined,
managed, connection,
localhost, })
}) })().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 upsertDirectConnection({
await persist(next) serverId,
} endpoint: DEFAULT_LOCALHOST_ENDPOINT,
} catch (reconciliationError) { label: hostname ?? undefined,
})
})().catch(() => {
// Best-effort startup race only; ignore if localhost isn't reachable.
}),
])
} catch (bootstrapError) {
if (cancelled) return if (cancelled) return
console.warn( console.warn(
'[DaemonRegistry] Failed to reconcile desktop startup host connections', '[DaemonRegistry] Failed to bootstrap desktop startup host connections',
reconciliationError bootstrapError
) )
} }
} }
void reconcileDesktopStartup().finally(() => { void bootstrapDesktopStartup()
if (!cancelled) {
setIsReconciling(false)
}
})
return () => { return () => {
cancelled = true cancelled = true
} }
}, [ }, [isPending, upsertDirectConnection, upsertHostConnection])
isPending,
persist,
readDaemons,
settings.manageBuiltInDaemon,
settingsLoading,
])
useEffect(() => { useEffect(() => {
if (isPending) return if (isPending) return
@@ -832,20 +604,12 @@ export function DaemonRegistryProvider({ children }: { children: ReactNode }) {
} }
} }
void bootstrapLocalhost().finally(() => { void bootstrapLocalhost()
if (!cancelled) {
setIsReconciling(false)
}
})
return () => { return () => {
cancelled = true cancelled = true
} }
}, [ }, [isPending, readDaemons, upsertDirectConnection])
isPending,
readDaemons,
upsertDirectConnection,
])
const upsertRelayConnection = useCallback( const upsertRelayConnection = useCallback(
async (input: { async (input: {
@@ -905,7 +669,6 @@ export function DaemonRegistryProvider({ children }: { children: ReactNode }) {
const value: DaemonRegistryContextValue = { const value: DaemonRegistryContextValue = {
daemons, daemons,
isLoading: isPending, isLoading: isPending,
isReconciling,
error: error ?? null, error: error ?? null,
upsertDirectConnection, upsertDirectConnection,
upsertRelayConnection, upsertRelayConnection,
@@ -1,21 +1,28 @@
import { useCallback, useEffect, useState } from "react"; import { useCallback, useEffect, useState } from 'react'
import { ActivityIndicator, Alert, Image, Text, View } from "react-native"; import { ActivityIndicator, Alert, Image, Text, View } from 'react-native'
import * as Clipboard from "expo-clipboard"; import * as Clipboard from 'expo-clipboard'
import * as QRCode from "qrcode"; import * as QRCode from 'qrcode'
import { useFocusEffect } from "@react-navigation/native"; import { useFocusEffect } from '@react-navigation/native'
import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { StyleSheet, useUnistyles } from 'react-native-unistyles'
import { settingsStyles } from "@/styles/settings"; 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 { import {
formatVersionWithPrefix, ArrowUpRight,
isVersionMismatch, Play,
} from "@/desktop/updates/desktop-updates"; 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 { import {
getCliShimStatus,
getManagedDaemonLogs, getManagedDaemonLogs,
getManagedDaemonPairing, getManagedDaemonPairing,
getManagedDaemonStatus, getManagedDaemonStatus,
@@ -29,307 +36,304 @@ import {
type ManagedPairingOffer, type ManagedPairingOffer,
type ManagedDaemonStatus, type ManagedDaemonStatus,
type CliManualInstructions, type CliManualInstructions,
} from "@/desktop/managed-runtime/managed-runtime"; type CliShimStatus,
} from '@/desktop/managed-runtime/managed-runtime'
export interface LocalDaemonSectionProps { export interface LocalDaemonSectionProps {
appVersion: string | null; appVersion: string | null
} }
export function LocalDaemonSection({ appVersion }: LocalDaemonSectionProps) { export function LocalDaemonSection({ appVersion }: LocalDaemonSectionProps) {
const { theme } = useUnistyles(); const { theme } = useUnistyles()
const showSection = shouldUseManagedDesktopDaemon(); const showSection = shouldUseManagedDesktopDaemon()
const { settings, updateSettings } = useAppSettings(); const { settings, updateSettings } = useAppSettings()
const [managedStatus, setManagedStatus] = useState<ManagedDaemonStatus | null>(null); const [managedStatus, setManagedStatus] = useState<ManagedDaemonStatus | null>(null)
const [statusError, setStatusError] = useState<string | null>(null); const [statusError, setStatusError] = useState<string | null>(null)
const [isRestartingDaemon, setIsRestartingDaemon] = useState(false); const [isRestartingDaemon, setIsRestartingDaemon] = useState(false)
const [isUpdatingDaemonManagement, setIsUpdatingDaemonManagement] = useState(false); const [isUpdatingDaemonManagement, setIsUpdatingDaemonManagement] = useState(false)
const [isInstallingCli, setIsInstallingCli] = useState(false); const [isInstallingCli, setIsInstallingCli] = useState(false)
const [statusMessage, setStatusMessage] = useState<string | null>(null); const [statusMessage, setStatusMessage] = useState<string | null>(null)
const [cliStatusMessage, setCliStatusMessage] = useState<string | null>(null); const [cliStatusMessage, setCliStatusMessage] = useState<string | null>(null)
const [managedLogs, setManagedLogs] = useState<ManagedDaemonLogs | null>(null); const [managedLogs, setManagedLogs] = useState<ManagedDaemonLogs | null>(null)
const [isLogsModalOpen, setIsLogsModalOpen] = useState(false); const [cliShimStatus, setCliShimStatus] = useState<CliShimStatus | null>(null)
const [isPairingModalOpen, setIsPairingModalOpen] = useState(false); const [isLogsModalOpen, setIsLogsModalOpen] = useState(false)
const [isCliInstallModalOpen, setIsCliInstallModalOpen] = useState(false); const [isPairingModalOpen, setIsPairingModalOpen] = useState(false)
const [isLoadingPairing, setIsLoadingPairing] = useState(false); const [isCliInstallModalOpen, setIsCliInstallModalOpen] = useState(false)
const [pairingOffer, setPairingOffer] = useState<ManagedPairingOffer | null>(null); const [isLoadingPairing, setIsLoadingPairing] = useState(false)
const [cliInstallInstructions, setCliInstallInstructions] = useState<CliManualInstructions | null>( const [pairingOffer, setPairingOffer] = useState<ManagedPairingOffer | null>(null)
null const [cliInstallInstructions, setCliInstallInstructions] =
); useState<CliManualInstructions | null>(null)
const [pairingStatusMessage, setPairingStatusMessage] = useState<string | null>(null); const [pairingStatusMessage, setPairingStatusMessage] = useState<string | null>(null)
const loadManagedStatus = useCallback(() => { const loadManagedStatus = useCallback(() => {
if (!showSection) { if (!showSection) {
return Promise.resolve(); return Promise.resolve()
} }
return Promise.all([getManagedDaemonStatus(), getManagedDaemonLogs()]) return Promise.all([getManagedDaemonStatus(), getManagedDaemonLogs(), getCliShimStatus()])
.then(([status, logs]) => { .then(([status, logs, shimStatus]) => {
setManagedStatus(status); setManagedStatus(status)
setManagedLogs(logs); setManagedLogs(logs)
setStatusError(null); setCliShimStatus(shimStatus)
setStatusError(null)
}) })
.catch((error) => { .catch((error) => {
const message = error instanceof Error ? error.message : String(error); const message = error instanceof Error ? error.message : String(error)
setStatusError(message); setStatusError(message)
}); })
}, [showSection]); }, [showSection])
useFocusEffect( useFocusEffect(
useCallback(() => { useCallback(() => {
if (!showSection) { if (!showSection) {
return undefined; return undefined
} }
void loadManagedStatus(); void loadManagedStatus()
return undefined; return undefined
}, [loadManagedStatus, showSection]) }, [loadManagedStatus, showSection])
); )
const localDaemonVersionText = formatVersionWithPrefix(managedStatus?.runtimeVersion ?? null); const localDaemonVersionText = formatVersionWithPrefix(managedStatus?.runtimeVersion ?? null)
const daemonVersionMismatch = isVersionMismatch(appVersion, managedStatus?.runtimeVersion ?? null); const daemonVersionMismatch = isVersionMismatch(appVersion, managedStatus?.runtimeVersion ?? null)
const daemonStatusStateText = const daemonStatusStateText =
statusError ?? statusError ?? (managedStatus?.status === 'running' ? managedStatus.status : 'not running')
(managedStatus?.daemonRunning const daemonStatusDetailText = `PID ${managedStatus?.pid ? managedStatus.pid : '—'}`
? managedStatus?.daemonStatus ?? "running" const isDaemonManagementPaused = !settings.manageBuiltInDaemon
: "not running"); const daemonActionLabel = managedStatus?.status === 'running' ? 'Restart daemon' : 'Start daemon'
const daemonStatusDetailText = `PID ${managedStatus?.daemonPid ? managedStatus.daemonPid : "—"}`; const daemonActionMessage =
const isDaemonManagementPaused = !settings.manageBuiltInDaemon; managedStatus?.status === 'running'
const daemonActionLabel = managedStatus?.daemonRunning ? "Restart daemon" : "Start daemon"; ? 'Restarts the built-in daemon.'
const daemonActionMessage = managedStatus?.daemonRunning : 'Starts the built-in daemon.'
? "Restarts the built-in daemon."
: isDaemonManagementPaused
? "Starts the built-in daemon manually. Paseo will not auto-start it while paused."
: "Starts the built-in daemon.";
const handleUpdateLocalDaemon = useCallback(() => { const handleUpdateLocalDaemon = useCallback(() => {
if (!showSection) { if (!showSection) {
return; return
} }
if (isRestartingDaemon) { if (isRestartingDaemon) {
return; return
} }
void confirmDialog({ void confirmDialog({
title: daemonActionLabel, title: daemonActionLabel,
message: managedStatus?.daemonRunning message:
? "This will restart the built-in daemon. The app will reconnect automatically." managedStatus?.status === 'running'
: "This will start the built-in daemon.", ? 'This will restart the built-in daemon. The app will reconnect automatically.'
: 'This will start the built-in daemon.',
confirmLabel: daemonActionLabel, confirmLabel: daemonActionLabel,
cancelLabel: "Cancel", cancelLabel: 'Cancel',
}) })
.then((confirmed) => { .then((confirmed) => {
if (!confirmed) { if (!confirmed) {
return; return
} }
setIsRestartingDaemon(true); setIsRestartingDaemon(true)
setStatusMessage(null); setStatusMessage(null)
const action = managedStatus?.daemonRunning ? restartManagedDaemon : startManagedDaemon; const action =
managedStatus?.status === 'running' ? restartManagedDaemon : startManagedDaemon
void action() void action()
.then((status) => { .then((status) => {
setManagedStatus(status); setManagedStatus(status)
setStatusMessage( setStatusMessage(
managedStatus?.daemonRunning ? "Daemon restarted." : "Daemon started." managedStatus?.status === 'running' ? 'Daemon restarted.' : 'Daemon started.'
); )
return loadManagedStatus(); return loadManagedStatus()
}) })
.catch((error) => { .catch((error) => {
console.error("[Settings] Failed to change managed daemon state", error); console.error('[Settings] Failed to change managed daemon state', error)
const message = error instanceof Error ? error.message : String(error); const message = error instanceof Error ? error.message : String(error)
setStatusMessage(`${daemonActionLabel} failed: ${message}`); setStatusMessage(`${daemonActionLabel} failed: ${message}`)
}) })
.finally(() => { .finally(() => {
setIsRestartingDaemon(false); setIsRestartingDaemon(false)
}); })
}) })
.catch((error) => { .catch((error) => {
console.error("[Settings] Failed to open managed daemon action confirmation", error); console.error('[Settings] Failed to open managed daemon action confirmation', error)
Alert.alert("Error", "Unable to open the daemon confirmation dialog."); Alert.alert('Error', 'Unable to open the daemon confirmation dialog.')
}); })
}, [daemonActionLabel, isRestartingDaemon, loadManagedStatus, managedStatus?.daemonRunning, showSection]); }, [daemonActionLabel, isRestartingDaemon, loadManagedStatus, managedStatus?.status, showSection])
const handleToggleDaemonManagement = useCallback(() => { const handleToggleDaemonManagement = useCallback(() => {
if (isUpdatingDaemonManagement) { if (isUpdatingDaemonManagement) {
return; return
} }
if (!settings.manageBuiltInDaemon) { if (!settings.manageBuiltInDaemon) {
setIsUpdatingDaemonManagement(true); setIsUpdatingDaemonManagement(true)
setStatusMessage(null); setStatusMessage(null)
void updateSettings({ manageBuiltInDaemon: true }) void updateSettings({ manageBuiltInDaemon: true })
.then(() => { .then(() => {
setStatusMessage("Paseo will resume managing the built-in daemon on startup."); setStatusMessage('Built-in daemon management resumed.')
}) })
.catch((error) => { .catch((error) => {
console.error("[Settings] Failed to update built-in daemon management", error); console.error('[Settings] Failed to update built-in daemon management', error)
Alert.alert("Error", "Unable to update built-in daemon management."); Alert.alert('Error', 'Unable to update built-in daemon management.')
}) })
.finally(() => { .finally(() => {
setIsUpdatingDaemonManagement(false); setIsUpdatingDaemonManagement(false)
}); })
return; return
} }
void confirmDialog({ void confirmDialog({
title: "Pause built-in daemon", title: 'Pause built-in daemon',
message: 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.", '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", confirmLabel: 'Pause and stop',
cancelLabel: "Cancel", cancelLabel: 'Cancel',
destructive: true, destructive: true,
}) })
.then((confirmed) => { .then((confirmed) => {
if (!confirmed) { if (!confirmed) {
return; return
} }
setIsUpdatingDaemonManagement(true); setIsUpdatingDaemonManagement(true)
setStatusMessage(null); setStatusMessage(null)
const stopPromise = managedStatus?.daemonRunning const stopPromise =
? stopManagedDaemon() managedStatus?.status === 'running'
: Promise.resolve(managedStatus ?? null); ? stopManagedDaemon()
: Promise.resolve(managedStatus ?? null)
void stopPromise void stopPromise
.then(() => updateSettings({ manageBuiltInDaemon: false })) .then(() => updateSettings({ manageBuiltInDaemon: false }))
.then(() => loadManagedStatus()) .then(() => loadManagedStatus())
.then(() => { .then(() => {
setStatusMessage( setStatusMessage('Built-in daemon paused and stopped.')
"Paseo paused the built-in daemon and will no longer auto-start it on launch."
);
}) })
.catch((error) => { .catch((error) => {
console.error("[Settings] Failed to pause built-in daemon management", error); console.error('[Settings] Failed to pause built-in daemon management', error)
Alert.alert("Error", "Unable to pause built-in daemon management."); Alert.alert('Error', 'Unable to pause built-in daemon management.')
}) })
.finally(() => { .finally(() => {
setIsUpdatingDaemonManagement(false); setIsUpdatingDaemonManagement(false)
}); })
}) })
.catch((error) => { .catch((error) => {
console.error("[Settings] Failed to open built-in daemon pause confirmation", error); console.error('[Settings] Failed to open built-in daemon pause confirmation', error)
Alert.alert("Error", "Unable to open the daemon confirmation dialog."); Alert.alert('Error', 'Unable to open the daemon confirmation dialog.')
}); })
}, [ }, [
isUpdatingDaemonManagement, isUpdatingDaemonManagement,
loadManagedStatus, loadManagedStatus,
managedStatus, managedStatus,
settings.manageBuiltInDaemon, settings.manageBuiltInDaemon,
updateSettings, updateSettings,
]); ])
const handleToggleCliShim = useCallback(() => { const handleToggleCliShim = useCallback(() => {
if (!showSection || isInstallingCli) { if (!showSection || isInstallingCli) {
return; return
} }
setIsInstallingCli(true); setIsInstallingCli(true)
const isInstalling = !managedStatus?.cliShimPath; const isInstalling = !cliShimStatus?.path
setCliStatusMessage( setCliStatusMessage(
isInstalling isInstalling ? 'A permissions popup may appear while Paseo installs the CLI globally.' : null
? "A permissions popup may appear while Paseo installs the CLI globally." )
: null const action = cliShimStatus?.path ? uninstallManagedCliShim : installManagedCliShim
);
const action = managedStatus?.cliShimPath ? uninstallManagedCliShim : installManagedCliShim;
void action() void action()
.then((result) => { .then((result) => {
setCliStatusMessage(result.message); setCliStatusMessage(result.message)
if (result.manualInstructions) { if (result.manualInstructions) {
setCliInstallInstructions(result.manualInstructions); setCliInstallInstructions(result.manualInstructions)
setIsCliInstallModalOpen(true); setIsCliInstallModalOpen(true)
} else { } else {
setCliInstallInstructions(null); setCliInstallInstructions(null)
setIsCliInstallModalOpen(false); setIsCliInstallModalOpen(false)
} }
return loadManagedStatus(); return loadManagedStatus()
}) })
.catch((error) => { .catch((error) => {
const message = error instanceof Error ? error.message : String(error); const message = error instanceof Error ? error.message : String(error)
setCliStatusMessage(`CLI install failed: ${message}`); setCliStatusMessage(`CLI install failed: ${message}`)
}) })
.finally(() => { .finally(() => {
setIsInstallingCli(false); setIsInstallingCli(false)
}); })
}, [isInstallingCli, loadManagedStatus, managedStatus?.cliShimPath, showSection]); }, [cliShimStatus?.path, isInstallingCli, loadManagedStatus, showSection])
const handleCopyCliInstallCommands = useCallback(() => { const handleCopyCliInstallCommands = useCallback(() => {
if (!cliInstallInstructions?.commands) { if (!cliInstallInstructions?.commands) {
return; return
} }
void Clipboard.setStringAsync(cliInstallInstructions.commands) void Clipboard.setStringAsync(cliInstallInstructions.commands)
.then(() => { .then(() => {
Alert.alert("Copied", "CLI install commands copied."); Alert.alert('Copied', 'CLI install commands copied.')
}) })
.catch((error) => { .catch((error) => {
console.error("[Settings] Failed to copy CLI install commands", error); console.error('[Settings] Failed to copy CLI install commands', error)
Alert.alert("Error", "Unable to copy CLI install commands."); Alert.alert('Error', 'Unable to copy CLI install commands.')
}); })
}, [cliInstallInstructions?.commands]); }, [cliInstallInstructions?.commands])
const handleCopyLogPath = useCallback(() => { const handleCopyLogPath = useCallback(() => {
const logPath = managedLogs?.logPath ?? managedStatus?.logPath; const logPath = managedLogs?.logPath
if (!logPath) { if (!logPath) {
return; return
} }
void Clipboard.setStringAsync(logPath) void Clipboard.setStringAsync(logPath)
.then(() => { .then(() => {
Alert.alert("Copied", "Log path copied."); Alert.alert('Copied', 'Log path copied.')
}) })
.catch((error) => { .catch((error) => {
console.error("[Settings] Failed to copy log path", error); console.error('[Settings] Failed to copy log path', error)
Alert.alert("Error", "Unable to copy log path."); Alert.alert('Error', 'Unable to copy log path.')
}); })
}, [managedLogs?.logPath, managedStatus?.logPath]); }, [managedLogs?.logPath])
const handleOpenLogs = useCallback(() => { const handleOpenLogs = useCallback(() => {
if (!managedLogs) { if (!managedLogs) {
return; return
} }
setIsLogsModalOpen(true); setIsLogsModalOpen(true)
}, [managedLogs]); }, [managedLogs])
const handleOpenPairingModal = useCallback(() => { const handleOpenPairingModal = useCallback(() => {
if (isLoadingPairing) { if (isLoadingPairing) {
return; return
} }
setIsPairingModalOpen(true); setIsPairingModalOpen(true)
setIsLoadingPairing(true); setIsLoadingPairing(true)
setPairingStatusMessage(null); setPairingStatusMessage(null)
void getManagedDaemonPairing() void getManagedDaemonPairing()
.then((pairing) => { .then((pairing) => {
setPairingOffer(pairing); setPairingOffer(pairing)
if (!pairing.relayEnabled || !pairing.url) { if (!pairing.relayEnabled || !pairing.url) {
setPairingStatusMessage("Relay pairing is not available."); setPairingStatusMessage('Relay pairing is not available.')
} }
}) })
.catch((error) => { .catch((error) => {
const message = error instanceof Error ? error.message : String(error); const message = error instanceof Error ? error.message : String(error)
setPairingOffer(null); setPairingOffer(null)
setPairingStatusMessage(`Unable to load pairing offer: ${message}`); setPairingStatusMessage(`Unable to load pairing offer: ${message}`)
}) })
.finally(() => { .finally(() => {
setIsLoadingPairing(false); setIsLoadingPairing(false)
}); })
}, [isLoadingPairing]); }, [isLoadingPairing])
const handleCopyPairingLink = useCallback(() => { const handleCopyPairingLink = useCallback(() => {
if (!pairingOffer?.url) { if (!pairingOffer?.url) {
return; return
} }
void Clipboard.setStringAsync(pairingOffer.url) void Clipboard.setStringAsync(pairingOffer.url)
.then(() => { .then(() => {
Alert.alert("Copied", "Pairing link copied."); Alert.alert('Copied', 'Pairing link copied.')
}) })
.catch((error) => { .catch((error) => {
console.error("[Settings] Failed to copy pairing link", error); console.error('[Settings] Failed to copy pairing link', error)
Alert.alert("Error", "Unable to copy pairing link."); Alert.alert('Error', 'Unable to copy pairing link.')
}); })
}, [pairingOffer?.url]); }, [pairingOffer?.url])
if (!showSection) { if (!showSection) {
return null; return null
} }
return ( return (
@@ -364,35 +368,37 @@ export function LocalDaemonSection({ appVersion }: LocalDaemonSectionProps) {
<Text style={styles.rowTitle}>Daemon management</Text> <Text style={styles.rowTitle}>Daemon management</Text>
<Text style={styles.hintText}> <Text style={styles.hintText}>
{isDaemonManagementPaused {isDaemonManagementPaused
? "Paused. Paseo will not auto-start the built-in daemon on app launch." ? 'Paused. The built-in daemon stays stopped until you start it again.'
: "Enabled. Paseo will start the built-in daemon automatically when needed."} : 'Enabled. Paseo can manage the built-in daemon from the desktop app.'}
</Text> </Text>
</View> </View>
<Button <Button
variant="outline" variant="outline"
size="sm" size="sm"
leftIcon={isDaemonManagementPaused leftIcon={
? <Play size={theme.iconSize.sm} color={theme.colors.foreground} /> isDaemonManagementPaused ? (
: <Pause size={theme.iconSize.sm} color={theme.colors.foreground} />} <Play size={theme.iconSize.sm} color={theme.colors.foreground} />
) : (
<Pause size={theme.iconSize.sm} color={theme.colors.foreground} />
)
}
onPress={handleToggleDaemonManagement} onPress={handleToggleDaemonManagement}
disabled={isUpdatingDaemonManagement} disabled={isUpdatingDaemonManagement}
> >
{isUpdatingDaemonManagement {isUpdatingDaemonManagement
? isDaemonManagementPaused ? isDaemonManagementPaused
? "Resuming..." ? 'Resuming...'
: "Pausing..." : 'Pausing...'
: isDaemonManagementPaused : isDaemonManagementPaused
? "Resume" ? 'Resume'
: "Pause"} : 'Pause'}
</Button> </Button>
</View> </View>
<View style={[styles.row, styles.rowBorder]}> <View style={[styles.row, styles.rowBorder]}>
<View style={styles.rowContent}> <View style={styles.rowContent}>
<Text style={styles.rowTitle}>{daemonActionLabel}</Text> <Text style={styles.rowTitle}>{daemonActionLabel}</Text>
<Text style={styles.hintText}>{daemonActionMessage}</Text> <Text style={styles.hintText}>{daemonActionMessage}</Text>
{statusMessage ? ( {statusMessage ? <Text style={styles.statusText}>{statusMessage}</Text> : null}
<Text style={styles.statusText}>{statusMessage}</Text>
) : null}
</View> </View>
<Button <Button
variant="outline" variant="outline"
@@ -402,18 +408,16 @@ export function LocalDaemonSection({ appVersion }: LocalDaemonSectionProps) {
disabled={isRestartingDaemon} disabled={isRestartingDaemon}
> >
{isRestartingDaemon {isRestartingDaemon
? managedStatus?.daemonRunning ? managedStatus?.status === 'running'
? "Restarting..." ? 'Restarting...'
: "Starting..." : 'Starting...'
: daemonActionLabel} : daemonActionLabel}
</Button> </Button>
</View> </View>
<View style={[styles.row, styles.rowBorder]}> <View style={[styles.row, styles.rowBorder]}>
<View style={styles.rowContent}> <View style={styles.rowContent}>
<Text style={styles.rowTitle}>Command line (CLI)</Text> <Text style={styles.rowTitle}>Command line (CLI)</Text>
<Text style={styles.hintText}> <Text style={styles.hintText}>Adds the `paseo` command to your terminal.</Text>
Adds the `paseo` command to your terminal.
</Text>
{cliStatusMessage ? <Text style={styles.statusText}>{cliStatusMessage}</Text> : null} {cliStatusMessage ? <Text style={styles.statusText}>{cliStatusMessage}</Text> : null}
</View> </View>
<Button <Button
@@ -423,25 +427,22 @@ export function LocalDaemonSection({ appVersion }: LocalDaemonSectionProps) {
onPress={handleToggleCliShim} onPress={handleToggleCliShim}
disabled={isInstallingCli} disabled={isInstallingCli}
> >
{isInstallingCli {isInstallingCli ? 'Working...' : cliShimStatus?.path ? 'Uninstall CLI' : 'Install CLI'}
? "Working..."
: managedStatus?.cliShimPath
? "Uninstall CLI"
: "Install CLI"}
</Button> </Button>
</View> </View>
<View style={[styles.row, styles.rowBorder]}> <View style={[styles.row, styles.rowBorder]}>
<View style={styles.rowContent}> <View style={styles.rowContent}>
<Text style={styles.rowTitle}>Log file</Text> <Text style={styles.rowTitle}>Log file</Text>
<Text style={styles.hintText}> <Text style={styles.hintText}>{managedLogs?.logPath ?? 'Log path unavailable.'}</Text>
{managedLogs?.logPath ??
managedStatus?.logPath ??
"Log path unavailable."}
</Text>
</View> </View>
<View style={styles.actionGroup}> <View style={styles.actionGroup}>
{(managedLogs?.logPath ?? managedStatus?.logPath) ? ( {managedLogs?.logPath ? (
<Button variant="outline" size="sm" leftIcon={<Copy size={theme.iconSize.sm} color={theme.colors.foreground} />} onPress={handleCopyLogPath}> <Button
variant="outline"
size="sm"
leftIcon={<Copy size={theme.iconSize.sm} color={theme.colors.foreground} />}
onPress={handleCopyLogPath}
>
Copy path Copy path
</Button> </Button>
) : null} ) : null}
@@ -459,11 +460,14 @@ export function LocalDaemonSection({ appVersion }: LocalDaemonSectionProps) {
<View style={[styles.row, styles.rowBorder]}> <View style={[styles.row, styles.rowBorder]}>
<View style={styles.rowContent}> <View style={styles.rowContent}>
<Text style={styles.rowTitle}>Pair device</Text> <Text style={styles.rowTitle}>Pair device</Text>
<Text style={styles.hintText}> <Text style={styles.hintText}>Connect your phone to this computer.</Text>
Connect your phone to this computer.
</Text>
</View> </View>
<Button variant="outline" size="sm" leftIcon={<Smartphone size={theme.iconSize.sm} color={theme.colors.foreground} />} onPress={handleOpenPairingModal}> <Button
variant="outline"
size="sm"
leftIcon={<Smartphone size={theme.iconSize.sm} color={theme.colors.foreground} />}
onPress={handleOpenPairingModal}
>
Pair device Pair device
</Button> </Button>
</View> </View>
@@ -493,7 +497,7 @@ export function LocalDaemonSection({ appVersion }: LocalDaemonSectionProps) {
<Text style={styles.hintText}>{cliInstallInstructions.detail}</Text> <Text style={styles.hintText}>{cliInstallInstructions.detail}</Text>
) : null} ) : null}
<Text style={styles.codeBlock} selectable> <Text style={styles.codeBlock} selectable>
{cliInstallInstructions?.commands ?? ""} {cliInstallInstructions?.commands ?? ''}
</Text> </Text>
<View style={styles.modalActions}> <View style={styles.modalActions}>
<Button variant="outline" size="sm" onPress={() => setIsCliInstallModalOpen(false)}> <Button variant="outline" size="sm" onPress={() => setIsCliInstallModalOpen(false)}>
@@ -525,71 +529,67 @@ export function LocalDaemonSection({ appVersion }: LocalDaemonSectionProps) {
onClose={() => setIsLogsModalOpen(false)} onClose={() => setIsLogsModalOpen(false)}
title="Daemon logs" title="Daemon logs"
testID="managed-daemon-logs-dialog" testID="managed-daemon-logs-dialog"
snapPoints={["70%", "92%"]} snapPoints={['70%', '92%']}
> >
<View style={styles.modalBody}> <View style={styles.modalBody}>
<Text style={styles.hintText}> <Text style={styles.hintText}>{managedLogs?.logPath ?? 'Log path unavailable.'}</Text>
{managedLogs?.logPath ??
managedStatus?.logPath ??
"Log path unavailable."}
</Text>
<Text style={styles.logOutput} selectable> <Text style={styles.logOutput} selectable>
{managedLogs?.contents.length ? managedLogs.contents : "(log file is empty)"} {managedLogs?.contents.length ? managedLogs.contents : '(log file is empty)'}
</Text> </Text>
</View> </View>
</AdaptiveModalSheet> </AdaptiveModalSheet>
</View> </View>
); )
} }
const ADVANCED_DAEMON_SETTINGS_URL = "https://paseo.sh/docs/configuration"; const ADVANCED_DAEMON_SETTINGS_URL = 'https://paseo.sh/docs/configuration'
function PairingOfferDialogContent(input: { function PairingOfferDialogContent(input: {
isLoading: boolean; isLoading: boolean
pairingOffer: ManagedPairingOffer | null; pairingOffer: ManagedPairingOffer | null
statusMessage: string | null; statusMessage: string | null
onCopyLink: () => void; onCopyLink: () => void
}) { }) {
const { isLoading, pairingOffer, statusMessage, onCopyLink } = input; const { isLoading, pairingOffer, statusMessage, onCopyLink } = input
const [qrDataUrl, setQrDataUrl] = useState<string | null>(null); const [qrDataUrl, setQrDataUrl] = useState<string | null>(null)
const [qrError, setQrError] = useState<string | null>(null); const [qrError, setQrError] = useState<string | null>(null)
useEffect(() => { useEffect(() => {
let cancelled = false; let cancelled = false
if (!pairingOffer?.url) { if (!pairingOffer?.url) {
setQrDataUrl(null); setQrDataUrl(null)
setQrError(null); setQrError(null)
return () => { return () => {
cancelled = true; cancelled = true
}; }
} }
setQrError(null); setQrError(null)
setQrDataUrl(null); setQrDataUrl(null)
void QRCode.toDataURL(pairingOffer.url, { void QRCode.toDataURL(pairingOffer.url, {
errorCorrectionLevel: "M", errorCorrectionLevel: 'M',
margin: 1, margin: 1,
width: 320, width: 320,
}) })
.then((dataUrl) => { .then((dataUrl) => {
if (cancelled) { if (cancelled) {
return; return
} }
setQrDataUrl(dataUrl); setQrDataUrl(dataUrl)
}) })
.catch((error) => { .catch((error) => {
if (cancelled) { if (cancelled) {
return; return
} }
setQrError(error instanceof Error ? error.message : String(error)); setQrError(error instanceof Error ? error.message : String(error))
}); })
return () => { return () => {
cancelled = true; cancelled = true
}; }
}, [pairingOffer?.url]); }, [pairingOffer?.url])
if (isLoading) { if (isLoading) {
return ( return (
@@ -597,7 +597,7 @@ function PairingOfferDialogContent(input: {
<ActivityIndicator size="small" /> <ActivityIndicator size="small" />
<Text style={styles.hintText}>Loading pairing offer…</Text> <Text style={styles.hintText}>Loading pairing offer…</Text>
</View> </View>
); )
} }
if (statusMessage) { if (statusMessage) {
@@ -605,7 +605,7 @@ function PairingOfferDialogContent(input: {
<View style={styles.modalBody}> <View style={styles.modalBody}>
<Text style={styles.hintText}>{statusMessage}</Text> <Text style={styles.hintText}>{statusMessage}</Text>
</View> </View>
); )
} }
if (!pairingOffer?.url) { if (!pairingOffer?.url) {
@@ -613,7 +613,7 @@ function PairingOfferDialogContent(input: {
<View style={styles.modalBody}> <View style={styles.modalBody}>
<Text style={styles.hintText}>Pairing offer unavailable.</Text> <Text style={styles.hintText}>Pairing offer unavailable.</Text>
</View> </View>
); )
} }
return ( return (
@@ -640,20 +640,20 @@ function PairingOfferDialogContent(input: {
</Button> </Button>
</View> </View>
</View> </View>
); )
} }
const styles = StyleSheet.create((theme) => ({ const styles = StyleSheet.create((theme) => ({
sectionHeader: { sectionHeader: {
alignItems: "center", alignItems: 'center',
flexDirection: "row", flexDirection: 'row',
justifyContent: "space-between", justifyContent: 'space-between',
marginBottom: theme.spacing[3], marginBottom: theme.spacing[3],
marginLeft: theme.spacing[1], marginLeft: theme.spacing[1],
}, },
sectionLink: { sectionLink: {
alignItems: "center", alignItems: 'center',
flexDirection: "row", flexDirection: 'row',
gap: theme.spacing[1], gap: theme.spacing[1],
}, },
sectionLinkText: { sectionLinkText: {
@@ -661,9 +661,9 @@ const styles = StyleSheet.create((theme) => ({
fontSize: theme.fontSize.xs, fontSize: theme.fontSize.xs,
}, },
row: { row: {
flexDirection: "row", flexDirection: 'row',
alignItems: "center", alignItems: 'center',
justifyContent: "space-between", justifyContent: 'space-between',
paddingVertical: theme.spacing[4], paddingVertical: theme.spacing[4],
paddingHorizontal: theme.spacing[4], paddingHorizontal: theme.spacing[4],
}, },
@@ -676,13 +676,13 @@ const styles = StyleSheet.create((theme) => ({
marginRight: theme.spacing[3], marginRight: theme.spacing[3],
}, },
actionGroup: { actionGroup: {
flexDirection: "row", flexDirection: 'row',
gap: theme.spacing[2], gap: theme.spacing[2],
flexWrap: "wrap", flexWrap: 'wrap',
justifyContent: "flex-end", justifyContent: 'flex-end',
}, },
statusValueGroup: { statusValueGroup: {
alignItems: "flex-end", alignItems: 'flex-end',
gap: 2, gap: 2,
}, },
rowTitle: { rowTitle: {
@@ -713,7 +713,7 @@ const styles = StyleSheet.create((theme) => ({
borderRadius: theme.borderRadius.lg, borderRadius: theme.borderRadius.lg,
borderWidth: 1, borderWidth: 1,
borderColor: theme.colors.palette.amber[500], borderColor: theme.colors.palette.amber[500],
backgroundColor: "rgba(245, 158, 11, 0.12)", backgroundColor: 'rgba(245, 158, 11, 0.12)',
paddingVertical: theme.spacing[2], paddingVertical: theme.spacing[2],
paddingHorizontal: theme.spacing[3], paddingHorizontal: theme.spacing[3],
}, },
@@ -726,15 +726,15 @@ const styles = StyleSheet.create((theme) => ({
paddingBottom: theme.spacing[2], paddingBottom: theme.spacing[2],
}, },
pairingState: { pairingState: {
alignItems: "center", alignItems: 'center',
justifyContent: "center", justifyContent: 'center',
gap: theme.spacing[3], gap: theme.spacing[3],
paddingVertical: theme.spacing[6], paddingVertical: theme.spacing[6],
}, },
qrCard: { qrCard: {
alignItems: "center", alignItems: 'center',
justifyContent: "center", justifyContent: 'center',
alignSelf: "center", alignSelf: 'center',
minHeight: 220, minHeight: 220,
minWidth: 220, minWidth: 220,
padding: theme.spacing[4], padding: theme.spacing[4],
@@ -759,13 +759,13 @@ const styles = StyleSheet.create((theme) => ({
logOutput: { logOutput: {
color: theme.colors.foregroundMuted, color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.xs, fontSize: theme.fontSize.xs,
fontFamily: "ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace", fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace',
lineHeight: 18, lineHeight: 18,
}, },
codeBlock: { codeBlock: {
color: theme.colors.foregroundMuted, color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.xs, fontSize: theme.fontSize.xs,
fontFamily: "ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace", fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace',
lineHeight: 18, lineHeight: 18,
borderWidth: 1, borderWidth: 1,
borderColor: theme.colors.border, borderColor: theme.colors.border,
@@ -774,8 +774,8 @@ const styles = StyleSheet.create((theme) => ({
padding: theme.spacing[3], padding: theme.spacing[3],
}, },
modalActions: { modalActions: {
flexDirection: "row", flexDirection: 'row',
justifyContent: "flex-end", justifyContent: 'flex-end',
gap: theme.spacing[2], gap: theme.spacing[2],
}, },
})); }))
@@ -1,282 +1,273 @@
import { invokeDesktopCommand } from "@/desktop/tauri/invoke-desktop-command"; import { invokeDesktopCommand } from '@/desktop/tauri/invoke-desktop-command'
import { getTauri, isTauriEnvironment } from "@/utils/tauri"; import { getTauri, isTauriEnvironment } from '@/utils/tauri'
export type ManagedRuntimeStatus = { export type ManagedRuntimeStatus = {
runtimeId: string; runtimeId: string
runtimeVersion: string; runtimeVersion: string
runtimeRoot: string; runtimeRoot: string
managedHome: string; }
transportType: string;
transportPath: string;
diagnosticsRoot: string;
stateFilePath: string;
};
export type ManagedDaemonStatus = { export type ManagedDaemonStatus = {
runtimeId: string; runtimeId: string
runtimeVersion: string; runtimeVersion: string
runtimeRoot: string; serverId: string
managedHome: string; status: string
transportType: string; listen: string
transportPath: string; hostname: string | null
daemonPid: number | null; pid: number | null
daemonRunning: boolean; home: string
daemonStatus: string; }
logPath: string;
serverId: string | null;
hostname: string | null;
relayEnabled: boolean;
tcpEnabled: boolean;
tcpListen: string | null;
cliShimPath: string | null;
};
export type ManagedDaemonLogs = { export type ManagedDaemonLogs = {
logPath: string; logPath: string
contents: string; contents: string
}; }
export type CliShimStatus = {
path: string | null
}
export type ManagedPairingOffer = { export type ManagedPairingOffer = {
relayEnabled: boolean; relayEnabled: boolean
url: string | null; url: string | null
qr: string | null; qr: string | null
}; }
export type CliShimResult = { export type CliShimResult = {
status: status:
| "installed" | 'installed'
| "removed" | 'removed'
| "elevationDenied" | 'elevationDenied'
| "automaticInstallUnavailable" | 'automaticInstallUnavailable'
| "manualInstallRequired"; | 'manualInstallRequired'
installed: boolean; installed: boolean
path: string | null; path: string | null
message: string; message: string
manualInstructions: CliManualInstructions | null; manualInstructions: CliManualInstructions | null
}; }
export type CliManualInstructions = { export type CliManualInstructions = {
title: string; title: string
detail: string; detail: string
commands: string; commands: string
}; }
export type ManagedTcpSettings = { export type ManagedTcpSettings = {
enabled: boolean; enabled: boolean
host: string; host: string
port: number; port: number
}; }
export type LocalTransportTarget = { export type LocalTransportTarget = {
transportType: "socket" | "pipe"; transportType: 'socket' | 'pipe'
transportPath: string; transportPath: string
}; }
type LocalTransportEventPayload = { type LocalTransportEventPayload = {
sessionId: string; sessionId: string
kind: "open" | "message" | "close" | "error"; kind: 'open' | 'message' | 'close' | 'error'
text?: string | null; text?: string | null
binaryBase64?: string | null; binaryBase64?: string | null
code?: number | null; code?: number | null
reason?: string | null; reason?: string | null
error?: string | null; error?: string | null
}; }
function isRecord(value: unknown): value is Record<string, unknown> { function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null; return typeof value === 'object' && value !== null
} }
function toStringOrNull(value: unknown): string | 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 { 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 { function parseManagedRuntimeStatus(raw: unknown): ManagedRuntimeStatus {
if (!isRecord(raw)) { if (!isRecord(raw)) {
throw new Error("Unexpected managed runtime status response."); throw new Error('Unexpected managed runtime status response.')
} }
return { return {
runtimeId: toStringOrNull(raw.runtimeId) ?? "", runtimeId: toStringOrNull(raw.runtimeId) ?? '',
runtimeVersion: toStringOrNull(raw.runtimeVersion) ?? "", runtimeVersion: toStringOrNull(raw.runtimeVersion) ?? '',
runtimeRoot: toStringOrNull(raw.runtimeRoot) ?? "", runtimeRoot: toStringOrNull(raw.runtimeRoot) ?? '',
managedHome: toStringOrNull(raw.managedHome) ?? "", }
transportType: toStringOrNull(raw.transportType) ?? "socket",
transportPath: toStringOrNull(raw.transportPath) ?? "",
diagnosticsRoot: toStringOrNull(raw.diagnosticsRoot) ?? "",
stateFilePath: toStringOrNull(raw.stateFilePath) ?? "",
};
} }
function parseManagedDaemonStatus(raw: unknown): ManagedDaemonStatus { function parseManagedDaemonStatus(raw: unknown): ManagedDaemonStatus {
if (!isRecord(raw)) { if (!isRecord(raw)) {
throw new Error("Unexpected managed daemon status response."); throw new Error('Unexpected managed daemon status response.')
} }
return { return {
runtimeId: toStringOrNull(raw.runtimeId) ?? "", runtimeId: toStringOrNull(raw.runtimeId) ?? '',
runtimeVersion: toStringOrNull(raw.runtimeVersion) ?? "", runtimeVersion: toStringOrNull(raw.runtimeVersion) ?? '',
runtimeRoot: toStringOrNull(raw.runtimeRoot) ?? "", serverId: toStringOrNull(raw.serverId) ?? '',
managedHome: toStringOrNull(raw.managedHome) ?? "", status: toStringOrNull(raw.status) ?? 'unknown',
transportType: toStringOrNull(raw.transportType) ?? "socket", listen: toStringOrNull(raw.listen) ?? '',
transportPath: toStringOrNull(raw.transportPath) ?? "",
daemonPid: toNumberOrNull(raw.daemonPid),
daemonRunning: raw.daemonRunning === true,
daemonStatus: toStringOrNull(raw.daemonStatus) ?? "unknown",
logPath: toStringOrNull(raw.logPath) ?? "",
serverId: toStringOrNull(raw.serverId),
hostname: toStringOrNull(raw.hostname), hostname: toStringOrNull(raw.hostname),
relayEnabled: raw.relayEnabled === true, pid: toNumberOrNull(raw.pid),
tcpEnabled: raw.tcpEnabled === true, home: toStringOrNull(raw.home) ?? '',
tcpListen: toStringOrNull(raw.tcpListen), }
cliShimPath: toStringOrNull(raw.cliShimPath),
};
} }
function parseManagedDaemonLogs(raw: unknown): ManagedDaemonLogs { function parseManagedDaemonLogs(raw: unknown): ManagedDaemonLogs {
if (!isRecord(raw)) { if (!isRecord(raw)) {
throw new Error("Unexpected managed daemon logs response."); throw new Error('Unexpected managed daemon logs response.')
} }
return { return {
logPath: toStringOrNull(raw.logPath) ?? "", logPath: toStringOrNull(raw.logPath) ?? '',
contents: typeof raw.contents === "string" ? raw.contents : "", 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 { function parseManagedPairingOffer(raw: unknown): ManagedPairingOffer {
if (!isRecord(raw)) { if (!isRecord(raw)) {
throw new Error("Unexpected managed daemon pairing response."); throw new Error('Unexpected managed daemon pairing response.')
} }
return { return {
relayEnabled: raw.relayEnabled === true, relayEnabled: raw.relayEnabled === true,
url: toStringOrNull(raw.url), url: toStringOrNull(raw.url),
qr: toStringOrNull(raw.qr), qr: toStringOrNull(raw.qr),
}; }
} }
function parseCliManualInstructions(raw: unknown): CliManualInstructions | null { function parseCliManualInstructions(raw: unknown): CliManualInstructions | null {
if (!isRecord(raw)) { if (!isRecord(raw)) {
return null; return null
} }
return { return {
title: toStringOrNull(raw.title) ?? "", title: toStringOrNull(raw.title) ?? '',
detail: toStringOrNull(raw.detail) ?? "", detail: toStringOrNull(raw.detail) ?? '',
commands: toStringOrNull(raw.commands) ?? "", commands: toStringOrNull(raw.commands) ?? '',
}; }
} }
export function parseCliShimResult(raw: unknown): CliShimResult { export function parseCliShimResult(raw: unknown): CliShimResult {
if (!isRecord(raw)) { if (!isRecord(raw)) {
throw new Error("Unexpected CLI shim response."); throw new Error('Unexpected CLI shim response.')
} }
return { return {
status: status:
(toStringOrNull(raw.status) as CliShimResult["status"] | null) ?? (toStringOrNull(raw.status) as CliShimResult['status'] | null) ??
(raw.installed === true ? "installed" : "removed"), (raw.installed === true ? 'installed' : 'removed'),
installed: raw.installed === true, installed: raw.installed === true,
path: toStringOrNull(raw.path), path: toStringOrNull(raw.path),
message: toStringOrNull(raw.message) ?? "", message: toStringOrNull(raw.message) ?? '',
manualInstructions: parseCliManualInstructions(raw.manualInstructions), manualInstructions: parseCliManualInstructions(raw.manualInstructions),
}; }
} }
export function shouldUseManagedDesktopDaemon(): boolean { export function shouldUseManagedDesktopDaemon(): boolean {
return isTauriEnvironment() && getTauri() !== null; return isTauriEnvironment() && getTauri() !== null
} }
export async function getManagedRuntimeStatus(): Promise<ManagedRuntimeStatus> { export async function getManagedRuntimeStatus(): Promise<ManagedRuntimeStatus> {
return parseManagedRuntimeStatus(await invokeDesktopCommand("managed_runtime_status")); return parseManagedRuntimeStatus(await invokeDesktopCommand('managed_runtime_status'))
} }
export async function getManagedDaemonStatus(): Promise<ManagedDaemonStatus> { export async function getManagedDaemonStatus(): Promise<ManagedDaemonStatus> {
return parseManagedDaemonStatus(await invokeDesktopCommand("managed_daemon_status")); return parseManagedDaemonStatus(await invokeDesktopCommand('managed_daemon_status'))
} }
export async function startManagedDaemon(): Promise<ManagedDaemonStatus> { export async function startManagedDaemon(): Promise<ManagedDaemonStatus> {
return parseManagedDaemonStatus(await invokeDesktopCommand("start_managed_daemon")); return parseManagedDaemonStatus(await invokeDesktopCommand('start_managed_daemon'))
} }
export async function stopManagedDaemon(): Promise<ManagedDaemonStatus> { export async function stopManagedDaemon(): Promise<ManagedDaemonStatus> {
return parseManagedDaemonStatus(await invokeDesktopCommand("stop_managed_daemon")); return parseManagedDaemonStatus(await invokeDesktopCommand('stop_managed_daemon'))
} }
export async function restartManagedDaemon(): Promise<ManagedDaemonStatus> { export async function restartManagedDaemon(): Promise<ManagedDaemonStatus> {
return parseManagedDaemonStatus(await invokeDesktopCommand("restart_managed_daemon")); return parseManagedDaemonStatus(await invokeDesktopCommand('restart_managed_daemon'))
} }
export async function getManagedDaemonLogs(): Promise<ManagedDaemonLogs> { export async function getManagedDaemonLogs(): Promise<ManagedDaemonLogs> {
return parseManagedDaemonLogs(await invokeDesktopCommand("managed_daemon_logs")); return parseManagedDaemonLogs(await invokeDesktopCommand('managed_daemon_logs'))
}
export async function getCliShimStatus(): Promise<CliShimStatus> {
return parseCliShimStatus(await invokeDesktopCommand('cli_shim_status'))
} }
export async function getManagedDaemonPairing(): Promise<ManagedPairingOffer> { export async function getManagedDaemonPairing(): Promise<ManagedPairingOffer> {
return parseManagedPairingOffer(await invokeDesktopCommand("managed_daemon_pairing")); return parseManagedPairingOffer(await invokeDesktopCommand('managed_daemon_pairing'))
} }
export async function installManagedCliShim(): Promise<CliShimResult> { export async function installManagedCliShim(): Promise<CliShimResult> {
return parseCliShimResult(await invokeDesktopCommand("install_cli_shim")); return parseCliShimResult(await invokeDesktopCommand('install_cli_shim'))
} }
export async function uninstallManagedCliShim(): Promise<CliShimResult> { export async function uninstallManagedCliShim(): Promise<CliShimResult> {
return parseCliShimResult(await invokeDesktopCommand("uninstall_cli_shim")); return parseCliShimResult(await invokeDesktopCommand('uninstall_cli_shim'))
} }
export async function updateManagedDaemonTcpSettings( export async function updateManagedDaemonTcpSettings(
settings: ManagedTcpSettings settings: ManagedTcpSettings
): Promise<ManagedDaemonStatus> { ): Promise<ManagedDaemonStatus> {
return parseManagedDaemonStatus( return parseManagedDaemonStatus(
await invokeDesktopCommand("update_managed_daemon_tcp_settings", { settings }) await invokeDesktopCommand('update_managed_daemon_tcp_settings', { settings })
); )
} }
export type LocalTransportEventUnlisten = () => void; export type LocalTransportEventUnlisten = () => void
export type LocalTransportEventHandler = (payload: LocalTransportEventPayload) => void; export type LocalTransportEventHandler = (payload: LocalTransportEventPayload) => void
export async function listenToLocalTransportEvents( export async function listenToLocalTransportEvents(
handler: LocalTransportEventHandler handler: LocalTransportEventHandler
): Promise<LocalTransportEventUnlisten> { ): Promise<LocalTransportEventUnlisten> {
const listen = getTauri()?.event?.listen; const listen = getTauri()?.event?.listen
if (typeof listen !== "function") { if (typeof listen !== 'function') {
throw new Error("Tauri event API is unavailable."); throw new Error('Tauri event API is unavailable.')
} }
const unlisten = await listen("local-daemon-transport-event", (event: unknown) => { const unlisten = await listen('local-daemon-transport-event', (event: unknown) => {
const payload = isRecord(event) && isRecord(event.payload) ? event.payload : null; const payload = isRecord(event) && isRecord(event.payload) ? event.payload : null
if (!payload) { if (!payload) {
return; return
} }
handler({ handler({
sessionId: toStringOrNull(payload.sessionId) ?? "", sessionId: toStringOrNull(payload.sessionId) ?? '',
kind: (toStringOrNull(payload.kind) ?? "error") as LocalTransportEventPayload["kind"], kind: (toStringOrNull(payload.kind) ?? 'error') as LocalTransportEventPayload['kind'],
text: toStringOrNull(payload.text), text: toStringOrNull(payload.text),
binaryBase64: toStringOrNull(payload.binaryBase64), binaryBase64: toStringOrNull(payload.binaryBase64),
code: toNumberOrNull(payload.code), code: toNumberOrNull(payload.code),
reason: toStringOrNull(payload.reason), reason: toStringOrNull(payload.reason),
error: toStringOrNull(payload.error), error: toStringOrNull(payload.error),
}); })
}); })
return typeof unlisten === "function" ? unlisten : () => {}; return typeof unlisten === 'function' ? unlisten : () => {}
} }
export async function openLocalTransportSession(target: LocalTransportTarget): Promise<string> { export async function openLocalTransportSession(target: LocalTransportTarget): Promise<string> {
const raw = await invokeDesktopCommand<unknown>("open_local_daemon_transport", target); const raw = await invokeDesktopCommand<unknown>('open_local_daemon_transport', target)
if (typeof raw !== "string" || raw.trim().length === 0) { if (typeof raw !== 'string' || raw.trim().length === 0) {
throw new Error("Unexpected local transport session response."); throw new Error('Unexpected local transport session response.')
} }
return raw; return raw
} }
export async function sendLocalTransportMessage(input: { export async function sendLocalTransportMessage(input: {
sessionId: string; sessionId: string
text?: string; text?: string
binaryBase64?: string; binaryBase64?: string
}): Promise<void> { }): Promise<void> {
await invokeDesktopCommand("send_local_daemon_transport_message", { await invokeDesktopCommand('send_local_daemon_transport_message', {
sessionId: input.sessionId, sessionId: input.sessionId,
...(input.text ? { text: input.text } : {}), ...(input.text ? { text: input.text } : {}),
...(input.binaryBase64 ? { binaryBase64: input.binaryBase64 } : {}), ...(input.binaryBase64 ? { binaryBase64: input.binaryBase64 } : {}),
}); })
} }
export async function closeLocalTransportSession(sessionId: string): Promise<void> { export async function closeLocalTransportSession(sessionId: string): Promise<void> {
await invokeDesktopCommand("close_local_daemon_transport", { sessionId }); await invokeDesktopCommand('close_local_daemon_transport', { sessionId })
} }
@@ -176,12 +176,7 @@ function makeHost(input?: Partial<HostProfile>): HostProfile {
return { return {
serverId: input?.serverId ?? "srv_test", serverId: input?.serverId ?? "srv_test",
label: input?.label ?? "test host", label: input?.label ?? "test host",
lifecycle: input?.lifecycle ?? { lifecycle: input?.lifecycle ?? {},
managed: false,
managedRuntimeId: null,
managedRuntimeVersion: null,
associatedServerId: null,
},
connections: input?.connections ?? [direct, relay], connections: input?.connections ?? [direct, relay],
preferredConnectionId: input?.preferredConnectionId ?? direct.id, preferredConnectionId: input?.preferredConnectionId ?? direct.id,
createdAt: input?.createdAt ?? new Date(0).toISOString(), createdAt: input?.createdAt ?? new Date(0).toISOString(),
@@ -1,6 +1,7 @@
import type { Command } from 'commander' import type { Command } from 'commander'
import { spawnSync } from 'node:child_process' import { spawnSync } from 'node:child_process'
import { createRequire } from 'node:module' import { createRequire } from 'node:module'
import { getOrCreateServerId } from '@getpaseo/server'
import { tryConnectToDaemon } from '../../utils/client.js' import { tryConnectToDaemon } from '../../utils/client.js'
import type { CommandOptions, ListResult, OutputSchema } from '../../output/index.js' import type { CommandOptions, ListResult, OutputSchema } from '../../output/index.js'
import { resolveLocalDaemonState, resolveTcpHostFromListen } from './local-daemon.js' import { resolveLocalDaemonState, resolveTcpHostFromListen } from './local-daemon.js'
@@ -12,9 +13,11 @@ import {
} from './runtime-toolchain.js' } from './runtime-toolchain.js'
interface DaemonStatus { interface DaemonStatus {
serverId: string | null
status: 'running' | 'stopped' | 'unresponsive' status: 'running' | 'stopped' | 'unresponsive'
home: string home: string
listen: string listen: string
hostname: string | null
pid: number | null pid: number | null
startedAt: string | null startedAt: string | null
owner: string | null owner: string | null
@@ -196,9 +199,11 @@ function createStatusSchema(status: DaemonStatus): OutputSchema<StatusRow> {
function toStatusRows(status: DaemonStatus): StatusRow[] { function toStatusRows(status: DaemonStatus): StatusRow[] {
const rows: StatusRow[] = [ const rows: StatusRow[] = [
{ key: 'Server ID', value: status.serverId ?? '-' },
{ key: 'Status', value: status.status }, { key: 'Status', value: status.status },
{ key: 'Home', value: status.home }, { key: 'Home', value: status.home },
{ key: 'Listen', value: status.listen }, { key: 'Listen', value: status.listen },
{ key: 'Hostname', value: status.hostname ?? '-' },
{ key: 'PID', value: status.pid === null ? '-' : String(status.pid) }, { key: 'PID', value: status.pid === null ? '-' : String(status.pid) },
{ key: 'Started', value: status.startedAt ?? '-' }, { key: 'Started', value: status.startedAt ?? '-' },
{ key: 'Owner', value: status.owner ?? '-' }, { key: 'Owner', value: status.owner ?? '-' },
@@ -323,10 +328,19 @@ export async function runStatusCommand(
updateStatus = 'unknown (npm unresolved)' 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 = { const daemonStatus: DaemonStatus = {
serverId,
status, status,
home: state.home, home: state.home,
listen: state.listen, listen: state.listen,
hostname: state.pidInfo?.hostname ?? null,
pid: state.pidInfo?.pid ?? null, pid: state.pidInfo?.pid ?? null,
startedAt: state.pidInfo?.startedAt ?? null, startedAt: state.pidInfo?.startedAt ?? null,
owner, owner,
+2
View File
@@ -85,8 +85,10 @@ try {
await $`PASEO_HOME=${paseoHome} npx paseo daemon status --json`.nothrow() await $`PASEO_HOME=${paseoHome} npx paseo daemon status --json`.nothrow()
assert.strictEqual(result.exitCode, 0, '--json status should succeed') assert.strictEqual(result.exitCode, 0, '--json status should succeed')
const status = JSON.parse(result.stdout) 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.status, 'stopped', 'json status should report stopped')
assert.strictEqual(status.home, paseoHome, 'json status should reflect the isolated home') 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') console.log('✓ daemon status --json outputs valid JSON\n')
} }
@@ -588,7 +588,6 @@ try {
const runtimeStatus = await runBinary(packagedBinary, ["--managed-headless", "runtime-status"], managedEnv); const runtimeStatus = await runBinary(packagedBinary, ["--managed-headless", "runtime-status"], managedEnv);
assert.equal(runtimeStatus.json.runtimeId, currentRuntimeId); assert.equal(runtimeStatus.json.runtimeId, currentRuntimeId);
assert.equal(runtimeStatus.json.runtimeVersion, currentRuntimeVersion); assert.equal(runtimeStatus.json.runtimeVersion, currentRuntimeVersion);
assert.match(runtimeStatus.json.managedHome, new RegExp(`^${escapeForRegExp(testRoot)}`));
assert.match( assert.match(
runtimeStatus.json.runtimeRoot, runtimeStatus.json.runtimeRoot,
new RegExp(escapeForRegExp(currentRuntimeId)), new RegExp(escapeForRegExp(currentRuntimeId)),
@@ -599,15 +598,8 @@ try {
false, false,
"runtime status should not resolve into managed app data" "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); assertNoForbiddenPathsOrPorts(runtimeStatus.json, forbiddenManagedReferences);
assert.equal(await pathExists(managedRuntimeDir), false, "managed app data should not gain a runtime tree"); 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 managedBootstrap = await runBinary(packagedBinary, ["--managed-headless", "bootstrap"], managedEnv);
const managedStart = managedBootstrap.json ?? await waitFor( const managedStart = managedBootstrap.json ?? await waitFor(
@@ -617,27 +609,23 @@ try {
["--managed-headless", "daemon-status"], ["--managed-headless", "daemon-status"],
managedEnv managedEnv
); );
assert.equal(status.json?.daemonRunning, true); assert.equal(status.json?.status, "running");
assert.ok(status.json?.daemonPid, "managed daemon pid should exist"); 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; return status.json;
}, },
10_000, 10_000,
"managed daemon bootstrap status" "managed daemon bootstrap status"
); );
assert.equal(managedStart.daemonRunning, true); assert.equal(managedStart.status, "running");
assert.ok(managedStart.daemonPid, "managed daemon pid should exist"); assert.ok(managedStart.pid, "managed daemon pid should exist");
assert.equal(managedStart.runtimeRoot, runtimeStatus.json.runtimeRoot); assert.ok(managedStart.serverId, "managed daemon should expose a server id");
assert.ok( assert.ok(managedStart.home, "managed daemon should expose its home directory");
managedStart.transportType === "socket" || managedStart.transportType === "pipe", assert.ok(managedStart.listen, "managed daemon should expose its listen target");
"managed daemon should default to private IPC transport"
);
assert.notEqual(managedStart.transportPath, "127.0.0.1:6767");
assertNoForbiddenPathsOrPorts(managedStart, forbiddenManagedReferences); assertNoForbiddenPathsOrPorts(managedStart, forbiddenManagedReferences);
assert.equal(await pathExists(managedRuntimeDir), false, "starting the daemon should not install a runtime copy"); assert.equal(await pathExists(managedRuntimeDir), false, "starting the daemon should not install a runtime copy");
const managedPid = managedStart.daemonPid; const managedPid = managedStart.pid;
const stateFile = path.join(testRoot, "managed-state.json");
assert.equal(await pathExists(stateFile), true, "managed state file should be written");
logStep("Verifying the managed daemon stays alive after the packaged command exits"); logStep("Verifying the managed daemon stays alive after the packaged command exits");
await sleep(1_500); await sleep(1_500);
@@ -646,9 +634,9 @@ try {
["--managed-headless", "daemon-status"], ["--managed-headless", "daemon-status"],
managedEnv managedEnv
); );
assert.equal(persistedManagedStatus.json.daemonPid, managedPid); assert.equal(persistedManagedStatus.json.pid, managedPid);
assert.equal(persistedManagedStatus.json.daemonRunning, true); assert.equal(persistedManagedStatus.json.status, "running");
assert.equal(persistedManagedStatus.json.relayEnabled, true); assert.equal(persistedManagedStatus.json.serverId, managedStart.serverId);
assertNoForbiddenPathsOrPorts(persistedManagedStatus.json, forbiddenManagedReferences); assertNoForbiddenPathsOrPorts(persistedManagedStatus.json, forbiddenManagedReferences);
const attemptCliShimInstall = shouldAttemptCliShimInstall(managedEnv); const attemptCliShimInstall = shouldAttemptCliShimInstall(managedEnv);
@@ -694,7 +682,7 @@ try {
}, "installed CLI shim version check") }, "installed CLI shim version check")
: await runBundledRuntimeCli( : await runBundledRuntimeCli(
runtimeStatus.json.runtimeRoot, runtimeStatus.json.runtimeRoot,
managedStart.managedHome, managedStart.home,
["--version"], ["--version"],
managedEnv managedEnv
); );
@@ -707,7 +695,7 @@ try {
}, "installed CLI shim daemon status") }, "installed CLI shim daemon status")
: await runBundledRuntimeCli( : await runBundledRuntimeCli(
runtimeStatus.json.runtimeRoot, runtimeStatus.json.runtimeRoot,
managedStart.managedHome, managedStart.home,
["daemon", "status", "--json"], ["daemon", "status", "--json"],
managedEnv managedEnv
); );
@@ -719,7 +707,7 @@ try {
const relayPairing = cliShimInstalled const relayPairing = cliShimInstalled
? await execFileWithTimeout( ? await execFileWithTimeout(
cliShimPath, cliShimPath,
["daemon", "pair", "--home", managedStart.managedHome], ["daemon", "pair"],
{ {
env: managedEnv, env: managedEnv,
cwd: repoRoot, cwd: repoRoot,
@@ -729,8 +717,8 @@ try {
) )
: await runBundledRuntimeCli( : await runBundledRuntimeCli(
runtimeStatus.json.runtimeRoot, runtimeStatus.json.runtimeRoot,
managedStart.managedHome, managedStart.home,
["daemon", "pair", "--home", managedStart.managedHome], ["daemon", "pair"],
managedEnv managedEnv
); );
const relayOfferUrl = parseOfferUrlFromCommandOutput(relayPairing.stdout); const relayOfferUrl = parseOfferUrlFromCommandOutput(relayPairing.stdout);
@@ -745,7 +733,7 @@ try {
["--managed-headless", "bootstrap"], ["--managed-headless", "bootstrap"],
managedEnv managedEnv
); );
assert.equal(managedRestartless.json.daemonPid, managedPid); assert.equal(managedRestartless.json.pid, managedPid);
logStep("Verifying managed and external daemons coexist"); logStep("Verifying managed and external daemons coexist");
const externalStatusAfter = await readDaemonStatus(externalHome, managedEnv); const externalStatusAfter = await readDaemonStatus(externalHome, managedEnv);
@@ -761,46 +749,6 @@ try {
assert.ok(managedStatus.json.serverId, "managed daemon should expose a server id"); assert.ok(managedStatus.json.serverId, "managed daemon should expose a server id");
assertNoForbiddenPathsOrPorts(managedStatus.json, forbiddenManagedReferences); 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"); logStep("Capturing diagnostics and verifying the fake ~/.paseo stayed untouched");
const fakePaseoSnapshotAfter = await snapshotTree(fakePaseoHome); const fakePaseoSnapshotAfter = await snapshotTree(fakePaseoHome);
assert.deepEqual(fakePaseoSnapshotAfter, fakePaseoSnapshotBefore); assert.deepEqual(fakePaseoSnapshotAfter, fakePaseoSnapshotBefore);
@@ -811,7 +759,6 @@ try {
runtimeStatus: runtimeStatus.json, runtimeStatus: runtimeStatus.json,
managedBootstrap: managedBootstrap.json, managedBootstrap: managedBootstrap.json,
managedStart, managedStart,
stateAfterRuntimeStatus,
persistedManagedStatus: persistedManagedStatus.json, persistedManagedStatus: persistedManagedStatus.json,
managedRestartless: managedRestartless.json, managedRestartless: managedRestartless.json,
cliInstall: cliInstall.json, cliInstall: cliInstall.json,
@@ -823,8 +770,6 @@ try {
externalPid, externalPid,
externalPidAfter, externalPidAfter,
managedStatus: managedStatus.json, managedStatus: managedStatus.json,
tcpEnabled: tcpEnabled.json,
tcpDisabled: tcpDisabled.json,
}, },
null, null,
2 2
+6 -5
View File
@@ -14,11 +14,11 @@ use tauri_plugin_updater::UpdaterExt;
mod runtime_manager; mod runtime_manager;
use runtime_manager::{ use runtime_manager::{
close_local_daemon_transport, install_cli_shim, managed_daemon_logs, managed_daemon_pairing, cli_shim_status, close_local_daemon_transport, install_cli_shim, managed_daemon_logs,
managed_daemon_status, managed_runtime_status, open_local_daemon_transport, managed_daemon_pairing, managed_daemon_status, managed_runtime_status,
restart_managed_daemon, send_local_daemon_transport_message, start_managed_daemon, open_local_daemon_transport, restart_managed_daemon, send_local_daemon_transport_message,
stop_managed_daemon, uninstall_cli_shim, update_managed_daemon_tcp_settings, start_managed_daemon, stop_managed_daemon, uninstall_cli_shim,
LocalTransportState, ManagedTcpSettings, update_managed_daemon_tcp_settings, LocalTransportState, ManagedTcpSettings,
}; };
// Store zoom as u64 bits (f64 * 100 as integer for atomic ops) // Store zoom as u64 bits (f64 * 100 as integer for atomic ops)
@@ -622,6 +622,7 @@ pub fn run() {
.invoke_handler(tauri::generate_handler![ .invoke_handler(tauri::generate_handler![
managed_runtime_status, managed_runtime_status,
managed_daemon_status, managed_daemon_status,
cli_shim_status,
start_managed_daemon, start_managed_daemon,
stop_managed_daemon, stop_managed_daemon,
restart_managed_daemon, restart_managed_daemon,
File diff suppressed because it is too large Load Diff
+1
View File
@@ -2,6 +2,7 @@
export { createPaseoDaemon, type PaseoDaemon, type PaseoDaemonConfig } from "./bootstrap.js"; export { createPaseoDaemon, type PaseoDaemon, type PaseoDaemonConfig } from "./bootstrap.js";
export { loadConfig, type CliConfigOverrides } from "./config.js"; export { loadConfig, type CliConfigOverrides } from "./config.js";
export { resolvePaseoHome } from "./paseo-home.js"; export { resolvePaseoHome } from "./paseo-home.js";
export { getOrCreateServerId } from "./server-id.js";
export { createRootLogger, type LogLevel, type LogFormat } from "./logger.js"; export { createRootLogger, type LogLevel, type LogFormat } from "./logger.js";
export { loadPersistedConfig, type PersistedConfig } from "./persisted-config.js"; export { loadPersistedConfig, type PersistedConfig } from "./persisted-config.js";
export { generateLocalPairingOffer, type LocalPairingOffer } from "./pairing-offer.js"; export { generateLocalPairingOffer, type LocalPairingOffer } from "./pairing-offer.js";
+161
View File
@@ -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