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