Add managed desktop daemon runtime support

This commit is contained in:
Mohamed Boudra
2026-03-08 15:40:02 +07:00
parent faa5aaab6f
commit 2d5d0dcacd
35 changed files with 5377 additions and 421 deletions
@@ -179,7 +179,11 @@ export function AddHostModal({ visible, onClose, onCancel, onSaved, targetServer
setIsSaving(true);
setErrorMessage("");
const { serverId, hostname } = await probeConnection({ id: "probe", type: "direct", endpoint });
const { serverId, hostname } = await probeConnection({
id: "probe",
type: "directTcp",
endpoint,
});
if (targetServerId && serverId !== targetServerId) {
const message = `That endpoint belongs to ${serverId}, not ${targetServerId}.`;
setErrorMessage(message);
@@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest'
import {
hostHasDirectEndpoint,
registryHasDirectEndpoint,
reconcileDesktopStartupRegistry,
type HostProfile,
} from './daemon-registry-context'
@@ -10,6 +11,12 @@ function makeHost(input: Partial<HostProfile> & Pick<HostProfile, 'serverId'>):
return {
serverId: input.serverId,
label: input.label ?? input.serverId,
lifecycle: input.lifecycle ?? {
managed: false,
managedRuntimeId: null,
managedRuntimeVersion: null,
associatedServerId: null,
},
connections: input.connections ?? [],
preferredConnectionId: input.preferredConnectionId ?? null,
createdAt: input.createdAt ?? now,
@@ -21,7 +28,7 @@ describe('hostHasDirectEndpoint', () => {
it('returns true when host has matching direct endpoint', () => {
const host = makeHost({
serverId: 'srv_local',
connections: [{ id: 'direct:localhost:6767', type: 'direct', endpoint: 'localhost:6767' }],
connections: [{ id: 'direct:localhost:6767', type: 'directTcp', endpoint: 'localhost:6767' }],
preferredConnectionId: 'direct:localhost:6767',
})
@@ -51,12 +58,12 @@ describe('registryHasDirectEndpoint', () => {
const hosts: HostProfile[] = [
makeHost({
serverId: 'srv_one',
connections: [{ id: 'direct:127.0.0.1:7777', type: 'direct', endpoint: '127.0.0.1:7777' }],
connections: [{ id: 'direct:127.0.0.1:7777', type: 'directTcp', endpoint: '127.0.0.1:7777' }],
preferredConnectionId: 'direct:127.0.0.1:7777',
}),
makeHost({
serverId: 'srv_two',
connections: [{ id: 'direct:localhost:6767', type: 'direct', endpoint: 'localhost:6767' }],
connections: [{ id: 'direct:localhost:6767', type: 'directTcp', endpoint: 'localhost:6767' }],
preferredConnectionId: 'direct:localhost:6767',
}),
]
@@ -68,7 +75,7 @@ describe('registryHasDirectEndpoint', () => {
const hosts: HostProfile[] = [
makeHost({
serverId: 'srv_one',
connections: [{ id: 'direct:127.0.0.1:7777', type: 'direct', endpoint: '127.0.0.1:7777' }],
connections: [{ id: 'direct:127.0.0.1:7777', type: 'directTcp', endpoint: '127.0.0.1:7777' }],
preferredConnectionId: 'direct:127.0.0.1:7777',
}),
]
@@ -76,3 +83,160 @@ describe('registryHasDirectEndpoint', () => {
expect(registryHasDirectEndpoint(hosts, 'localhost:6767')).toBe(false)
})
})
describe('reconcileDesktopStartupRegistry', () => {
it('seeds managed and localhost connections as normal host entries', () => {
const now = '2026-03-08T00:00:00.000Z'
const result = reconcileDesktopStartupRegistry({
existing: [],
managed: {
serverId: 'srv_managed',
hostname: 'managed-host',
runtimeId: 'runtime_1',
runtimeVersion: '1.2.3',
transportType: 'socket',
transportPath: '/Users/test/.paseo-test/paseo.sock',
associatedServerId: 'srv_managed',
},
localhost: {
serverId: 'srv_localhost',
hostname: 'local-dev',
endpoint: 'localhost:6767',
},
now,
})
expect(result).toEqual([
makeHost({
serverId: 'srv_managed',
label: 'managed-host',
lifecycle: {
managed: true,
managedRuntimeId: 'runtime_1',
managedRuntimeVersion: '1.2.3',
associatedServerId: 'srv_managed',
},
connections: [
{
id: 'socket:/Users/test/.paseo-test/paseo.sock',
type: 'directSocket',
path: '/Users/test/.paseo-test/paseo.sock',
},
],
preferredConnectionId: 'socket:/Users/test/.paseo-test/paseo.sock',
createdAt: now,
updatedAt: now,
}),
makeHost({
serverId: 'srv_localhost',
label: 'local-dev',
connections: [
{
id: 'direct:localhost:6767',
type: 'directTcp',
endpoint: 'localhost:6767',
},
],
preferredConnectionId: 'direct:localhost:6767',
createdAt: now,
updatedAt: now,
}),
])
})
it('keeps managed and localhost connections together when they resolve to the same server', () => {
const now = '2026-03-08T00:00:00.000Z'
const result = reconcileDesktopStartupRegistry({
existing: [],
managed: {
serverId: 'srv_shared',
hostname: 'devbox',
runtimeId: 'runtime_1',
runtimeVersion: '1.2.3',
transportType: 'socket',
transportPath: '/Users/test/.paseo-test/paseo.sock',
associatedServerId: 'srv_shared',
},
localhost: {
serverId: 'srv_shared',
hostname: 'devbox',
endpoint: 'localhost:6767',
},
now,
})
expect(result).toEqual([
makeHost({
serverId: 'srv_shared',
label: 'devbox',
lifecycle: {
managed: true,
managedRuntimeId: 'runtime_1',
managedRuntimeVersion: '1.2.3',
associatedServerId: 'srv_shared',
},
connections: [
{
id: 'socket:/Users/test/.paseo-test/paseo.sock',
type: 'directSocket',
path: '/Users/test/.paseo-test/paseo.sock',
},
{
id: 'direct:localhost:6767',
type: 'directTcp',
endpoint: 'localhost:6767',
},
],
preferredConnectionId: 'socket:/Users/test/.paseo-test/paseo.sock',
createdAt: now,
updatedAt: now,
}),
])
})
it('is idempotent for repeated desktop startup reconciliation', () => {
const now = '2026-03-08T00:00:00.000Z'
const first = reconcileDesktopStartupRegistry({
existing: [],
managed: {
serverId: 'srv_shared',
hostname: 'devbox',
runtimeId: 'runtime_1',
runtimeVersion: '1.2.3',
transportType: 'socket',
transportPath: '/Users/test/.paseo-test/paseo.sock',
associatedServerId: 'srv_shared',
},
localhost: {
serverId: 'srv_shared',
hostname: 'devbox',
endpoint: 'localhost:6767',
},
now,
})
const second = reconcileDesktopStartupRegistry({
existing: first,
managed: {
serverId: 'srv_shared',
hostname: 'devbox',
runtimeId: 'runtime_1',
runtimeVersion: '1.2.3',
transportType: 'socket',
transportPath: '/Users/test/.paseo-test/paseo.sock',
associatedServerId: 'srv_shared',
},
localhost: {
serverId: 'srv_shared',
hostname: 'devbox',
endpoint: 'localhost:6767',
},
now: '2026-03-09T00:00:00.000Z',
})
expect(second).toEqual(first)
})
})
@@ -5,32 +5,64 @@ import { useQuery, useQueryClient } from '@tanstack/react-query'
import { decodeOfferFragmentPayload, normalizeHostPort } from '@/utils/daemon-endpoints'
import { probeConnection } from '@/utils/test-daemon-connection'
import { ConnectionOfferSchema, type ConnectionOffer } from '@server/shared/connection-offer'
import {
type ManagedDaemonStatus,
shouldUseManagedDesktopDaemon,
startManagedDaemon,
} from '@/desktop/managed-runtime/managed-runtime'
const REGISTRY_STORAGE_KEY = '@paseo:daemon-registry'
const DAEMON_REGISTRY_QUERY_KEY = ['daemon-registry']
const DEFAULT_LOCALHOST_ENDPOINT = 'localhost:6767'
const DEFAULT_LOCALHOST_BOOTSTRAP_KEY = '@paseo:default-localhost-bootstrap-v1'
const DEFAULT_LOCALHOST_BOOTSTRAP_TIMEOUT_MS = 2500
const DEFAULT_LOCAL_TRANSPORT_BOOTSTRAP_TIMEOUT_MS = 6000
const DEFAULT_LOCAL_TRANSPORT_BOOTSTRAP_RETRY_MS = 2000
const DEFAULT_LOCAL_TRANSPORT_BOOTSTRAP_DEADLINE_MS = 120000
const E2E_STORAGE_KEY = '@paseo:e2e'
export type DirectHostConnection = {
export type DirectTcpHostConnection = {
id: string
type: 'direct'
endpoint: string // host:port
type: 'directTcp'
endpoint: string
}
export type DirectSocketHostConnection = {
id: string
type: 'directSocket'
path: string
}
export type DirectPipeHostConnection = {
id: string
type: 'directPipe'
path: string
}
export type RelayHostConnection = {
id: string
type: 'relay'
relayEndpoint: string // host:port
relayEndpoint: string
daemonPublicKeyB64: string
}
export type HostConnection = DirectHostConnection | RelayHostConnection
export type HostConnection =
| DirectTcpHostConnection
| DirectSocketHostConnection
| DirectPipeHostConnection
| RelayHostConnection
export type HostLifecycle = {
managed: boolean
managedRuntimeId: string | null
managedRuntimeVersion: string | null
associatedServerId: string | null
}
export type HostProfile = {
serverId: string
label: string
lifecycle: HostLifecycle
connections: HostConnection[]
preferredConnectionId: string | null
createdAt: string
@@ -39,6 +71,29 @@ export type HostProfile = {
export type UpdateHostInput = Partial<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
@@ -63,6 +118,20 @@ interface DaemonRegistryContextValue {
const DaemonRegistryContext = createContext<DaemonRegistryContextValue | null>(null)
function defaultLifecycle(): HostLifecycle {
return {
managed: false,
managedRuntimeId: null,
managedRuntimeVersion: null,
associatedServerId: null,
}
}
function normalizeHostLabel(value: string | null | undefined, serverId: string): string {
const trimmed = value?.trim() ?? ''
return trimmed.length > 0 ? trimmed : serverId
}
function normalizeEndpointOrNull(endpoint: string): string | null {
try {
return normalizeHostPort(endpoint)
@@ -71,8 +140,389 @@ function normalizeEndpointOrNull(endpoint: string): string | null {
}
}
function isDefaultLocalhostConnection(connection: HostConnection): boolean {
return connection.type === 'direct' && connection.endpoint === DEFAULT_LOCALHOST_ENDPOINT
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => {
setTimeout(resolve, ms)
})
}
function normalizeManagedTransportConnection(input: {
transportType: string
transportPath: string
}): HostConnection | null {
const transportPath = input.transportPath.trim()
if (!transportPath) {
return null
}
if (input.transportType === 'tcp') {
try {
const endpoint = normalizeHostPort(transportPath)
return {
id: `direct:${endpoint}`,
type: 'directTcp',
endpoint,
}
} catch {
return null
}
}
if (input.transportType === 'pipe') {
return {
id: `pipe:${transportPath}`,
type: 'directPipe',
path: transportPath,
}
}
if (input.transportType === 'socket') {
return {
id: `socket:${transportPath}`,
type: 'directSocket',
path: transportPath,
}
}
return null
}
function normalizeStoredConnection(connection: unknown): HostConnection | null {
if (!connection || typeof connection !== 'object') {
return null
}
const record = connection as Record<string, unknown>
const type = typeof record.type === 'string' ? record.type : null
if (type === 'directTcp') {
try {
const endpoint = normalizeHostPort(String(record.endpoint ?? ''))
return { id: `direct:${endpoint}`, type: 'directTcp', endpoint }
} catch {
return null
}
}
if (type === 'directSocket') {
const path = String(record.path ?? '').trim()
return path ? { id: `socket:${path}`, type: 'directSocket', path } : null
}
if (type === 'directPipe') {
const path = String(record.path ?? '').trim()
return path ? { id: `pipe:${path}`, type: 'directPipe', path } : null
}
if (type === 'relay') {
try {
const relayEndpoint = normalizeHostPort(String(record.relayEndpoint ?? ''))
const daemonPublicKeyB64 = String(record.daemonPublicKeyB64 ?? '').trim()
if (!daemonPublicKeyB64) return null
return {
id: `relay:${relayEndpoint}`,
type: 'relay',
relayEndpoint,
daemonPublicKeyB64,
}
} catch {
return null
}
}
return null
}
function normalizeStoredLifecycle(lifecycle: unknown): HostLifecycle {
const record =
lifecycle && typeof lifecycle === 'object' ? (lifecycle as Record<string, unknown>) : null
return {
managed: record?.managed === true,
managedRuntimeId:
typeof record?.managedRuntimeId === 'string' ? record.managedRuntimeId : null,
managedRuntimeVersion:
typeof record?.managedRuntimeVersion === 'string' ? record.managedRuntimeVersion : null,
associatedServerId:
typeof record?.associatedServerId === 'string' ? record.associatedServerId : null,
}
}
function normalizeStoredHostProfile(entry: unknown): HostProfile | null {
if (!entry || typeof entry !== 'object') {
return null
}
const record = entry as Record<string, unknown>
const serverId = typeof record.serverId === 'string' ? record.serverId.trim() : ''
if (!serverId) {
return null
}
const rawConnections = Array.isArray(record.connections) ? record.connections : []
const connections = rawConnections
.map((connection) => normalizeStoredConnection(connection))
.filter((connection): connection is HostConnection => connection !== null)
if (connections.length === 0) {
return null
}
const lifecycle = normalizeStoredLifecycle(record.lifecycle)
const now = new Date().toISOString()
const label = normalizeHostLabel(
typeof record.label === 'string' ? record.label : null,
serverId
)
const preferredConnectionId =
typeof record.preferredConnectionId === 'string' &&
connections.some((connection) => connection.id === record.preferredConnectionId)
? record.preferredConnectionId
: connections[0]?.id ?? null
return {
serverId,
label,
lifecycle,
connections,
preferredConnectionId,
createdAt: typeof record.createdAt === 'string' ? record.createdAt : now,
updatedAt: typeof record.updatedAt === 'string' ? record.updatedAt : now,
}
}
function hostConnectionEquals(left: HostConnection, right: HostConnection): boolean {
if (left.type !== right.type || left.id !== right.id) {
return false
}
if (left.type === 'directTcp' && right.type === 'directTcp') {
return left.endpoint === right.endpoint
}
if (left.type === 'directSocket' && right.type === 'directSocket') {
return left.path === right.path
}
if (left.type === 'directPipe' && right.type === 'directPipe') {
return left.path === right.path
}
if (left.type === 'relay' && right.type === 'relay') {
return (
left.relayEndpoint === right.relayEndpoint &&
left.daemonPublicKeyB64 === right.daemonPublicKeyB64
)
}
return false
}
function hostLifecycleEquals(left: HostLifecycle, right: HostLifecycle): boolean {
return (
left.managed === right.managed &&
left.managedRuntimeId === right.managedRuntimeId &&
left.managedRuntimeVersion === right.managedRuntimeVersion &&
left.associatedServerId === right.associatedServerId
)
}
function upsertHostConnectionInProfiles(input: {
profiles: HostProfile[]
serverId: string
label?: string
lifecycle?: Partial<HostLifecycle>
connection: HostConnection
now?: string
}): HostProfile[] {
const serverId = input.serverId.trim()
if (!serverId) {
throw new Error('serverId is required')
}
const now = input.now ?? new Date().toISOString()
const labelTrimmed = input.label?.trim() ?? ''
const derivedLabel = labelTrimmed || serverId
const existing = input.profiles
const idx = existing.findIndex((daemon) => daemon.serverId === serverId)
if (idx === -1) {
const profile: HostProfile = {
serverId,
label: derivedLabel,
lifecycle: {
...defaultLifecycle(),
...(input.lifecycle ?? {}),
},
connections: [input.connection],
preferredConnectionId: input.connection.id,
createdAt: now,
updatedAt: now,
}
return [...existing, profile]
}
const prev = existing[idx]!
const connectionIdx = prev.connections.findIndex((connection) => connection.id === input.connection.id)
const hadConnection = connectionIdx !== -1
const connectionChanged =
connectionIdx === -1
? true
: !hostConnectionEquals(prev.connections[connectionIdx]!, input.connection)
const nextConnections =
connectionIdx === -1
? [...prev.connections, input.connection]
: connectionChanged
? prev.connections.map((connection, index) =>
index === connectionIdx ? input.connection : connection
)
: prev.connections
const nextLifecycle = {
...prev.lifecycle,
...(input.lifecycle ?? {}),
}
const nextLabel = labelTrimmed ? labelTrimmed : prev.label
const nextPreferredConnectionId = prev.preferredConnectionId ?? input.connection.id
const changed =
nextLabel !== prev.label ||
nextPreferredConnectionId !== prev.preferredConnectionId ||
!hostLifecycleEquals(prev.lifecycle, nextLifecycle) ||
!hadConnection ||
connectionChanged
if (!changed) {
return existing
}
const nextProfile: HostProfile = {
...prev,
label: nextLabel,
lifecycle: nextLifecycle,
connections: nextConnections,
preferredConnectionId: nextPreferredConnectionId,
updatedAt: now,
}
const next = [...existing]
next[idx] = nextProfile
return next
}
function reconcileManagedHostInProfiles(input: {
profiles: HostProfile[]
managed: ManagedHostReconciliationInput
now?: string
}): HostProfile[] {
const connection = normalizeManagedTransportConnection(input.managed)
if (!connection) {
throw new Error(`Unsupported managed daemon transport: ${input.managed.transportType}`)
}
const nextBase = input.profiles.filter((daemon) => {
return !daemon.lifecycle.managed || daemon.serverId === input.managed.serverId
})
const profiles = nextBase.length === input.profiles.length ? input.profiles : nextBase
return upsertHostConnectionInProfiles({
profiles,
serverId: input.managed.serverId,
label: input.managed.hostname ?? undefined,
lifecycle: {
managed: true,
managedRuntimeId: input.managed.runtimeId,
managedRuntimeVersion: input.managed.runtimeVersion,
associatedServerId:
input.managed.associatedServerId?.trim() || input.managed.serverId,
},
connection,
now: input.now,
})
}
export function reconcileDesktopStartupRegistry(
input: DesktopStartupReconciliationInput
): HostProfile[] {
let next = input.existing
if (input.managed) {
next = reconcileManagedHostInProfiles({
profiles: next,
managed: input.managed,
now: input.now,
})
}
if (input.localhost) {
next = upsertHostConnectionInProfiles({
profiles: next,
serverId: input.localhost.serverId,
label: input.localhost.hostname ?? undefined,
connection: {
id: `direct:${input.localhost.endpoint}`,
type: 'directTcp',
endpoint: input.localhost.endpoint,
},
now: input.now,
})
}
return next
}
async function probeManagedStartupTarget(input: {
managedDaemon: ManagedDaemonStatus
cancelled?: () => boolean
}): Promise<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 {
@@ -81,7 +531,7 @@ export function hostHasDirectEndpoint(host: HostProfile, endpoint: string): bool
return false
}
return host.connections.some(
(connection) => connection.type === 'direct' && connection.endpoint === normalized
(connection) => connection.type === 'directTcp' && connection.endpoint === normalized
)
}
@@ -99,6 +549,7 @@ export function useDaemonRegistry(): DaemonRegistryContextValue {
export function DaemonRegistryProvider({ children }: { children: ReactNode }) {
const queryClient = useQueryClient()
const desktopStartupReconciledRef = useRef(false)
const localhostBootstrapAttemptedRef = useRef(false)
const {
data: daemons = [],
@@ -123,10 +574,6 @@ export function DaemonRegistryProvider({ children }: { children: ReactNode }) {
return queryClient.getQueryData<HostProfile[]>(DAEMON_REGISTRY_QUERY_KEY) ?? daemons
}, [queryClient, daemons])
const markDefaultLocalhostBootstrapHandled = useCallback(async () => {
await AsyncStorage.setItem(DEFAULT_LOCALHOST_BOOTSTRAP_KEY, '1')
}, [])
const updateHost = useCallback(
async (serverId: string, updates: UpdateHostInput) => {
const next = readDaemons().map((daemon) =>
@@ -146,23 +593,15 @@ export function DaemonRegistryProvider({ children }: { children: ReactNode }) {
const removeHost = useCallback(
async (serverId: string) => {
const existing = readDaemons()
const removedHost = existing.find((daemon) => daemon.serverId === serverId) ?? null
const remaining = existing.filter((daemon) => daemon.serverId !== serverId)
await persist(remaining)
if (removedHost && hostHasDirectEndpoint(removedHost, DEFAULT_LOCALHOST_ENDPOINT)) {
await markDefaultLocalhostBootstrapHandled()
}
},
[markDefaultLocalhostBootstrapHandled, persist, readDaemons]
[persist, readDaemons]
)
const removeConnection = useCallback(
async (serverId: string, connectionId: string) => {
const existing = readDaemons()
const removedConnection =
existing
.find((daemon) => daemon.serverId === serverId)
?.connections.find((connection) => connection.id === connectionId) ?? null
const now = new Date().toISOString()
const next = existing
.map((daemon) => {
@@ -184,64 +623,28 @@ export function DaemonRegistryProvider({ children }: { children: ReactNode }) {
})
.filter((entry): entry is HostProfile => entry !== null)
await persist(next)
if (removedConnection && isDefaultLocalhostConnection(removedConnection)) {
await markDefaultLocalhostBootstrapHandled()
}
},
[markDefaultLocalhostBootstrapHandled, persist, readDaemons]
[persist, readDaemons]
)
const upsertHostConnection = useCallback(
async (
input: {
serverId: string
label?: string
} & ({ connection: DirectHostConnection } | { connection: RelayHostConnection })
) => {
const existing = readDaemons()
async (input: {
serverId: string
label?: string
lifecycle?: Partial<HostLifecycle>
connection: HostConnection
}) => {
const now = new Date().toISOString()
const serverId = input.serverId.trim()
if (!serverId) {
throw new Error('serverId is required')
}
const labelTrimmed = input.label?.trim() ?? ''
const derivedLabel = labelTrimmed || serverId
const idx = existing.findIndex((d) => d.serverId === serverId)
if (idx === -1) {
const profile: HostProfile = {
serverId,
label: derivedLabel,
connections: [input.connection],
preferredConnectionId: input.connection.id,
createdAt: now,
updatedAt: now,
}
const next = [...existing, profile]
await persist(next)
return profile
}
const prev = existing[idx]!
const connectionIdx = prev.connections.findIndex((c) => c.id === input.connection.id)
const nextConnections =
connectionIdx === -1
? [...prev.connections, input.connection]
: prev.connections.map((c, i) => (i === connectionIdx ? input.connection : c))
const nextProfile: HostProfile = {
...prev,
label: labelTrimmed ? labelTrimmed : prev.label,
connections: nextConnections,
preferredConnectionId: prev.preferredConnectionId ?? input.connection.id,
updatedAt: now,
}
const next = [...existing]
next[idx] = nextProfile
const next = upsertHostConnectionInProfiles({
profiles: readDaemons(),
serverId: input.serverId,
label: input.label,
lifecycle: input.lifecycle,
connection: input.connection,
now,
})
await persist(next)
return nextProfile
return next.find((daemon) => daemon.serverId === input.serverId) as HostProfile
},
[persist, readDaemons]
)
@@ -249,15 +652,14 @@ export function DaemonRegistryProvider({ children }: { children: ReactNode }) {
const upsertDirectConnection = useCallback(
async (input: { serverId: string; endpoint: string; label?: string }) => {
const endpoint = normalizeHostPort(input.endpoint)
const connection: DirectHostConnection = {
id: `direct:${endpoint}`,
type: 'direct',
endpoint,
}
return upsertHostConnection({
serverId: input.serverId,
label: input.label,
connection,
connection: {
id: `direct:${endpoint}`,
type: 'directTcp',
endpoint,
},
})
},
[upsertHostConnection]
@@ -265,24 +667,114 @@ export function DaemonRegistryProvider({ children }: { children: ReactNode }) {
useEffect(() => {
if (isPending) return
if (!shouldUseManagedDesktopDaemon()) return
if (desktopStartupReconciledRef.current) return
desktopStartupReconciledRef.current = true
let cancelled = false
const reconcileDesktopStartup = async () => {
try {
const isE2E = await AsyncStorage.getItem(E2E_STORAGE_KEY)
if (cancelled || isE2E) {
return
}
let managed: ManagedHostReconciliationInput | null = null
try {
const managedDaemon = await startManagedDaemon()
managed = await probeManagedConnectionUntilReady({
managedDaemon,
cancelled: () => cancelled,
})
} catch (managedBootstrapError) {
if (!cancelled) {
console.warn(
'[DaemonRegistry] Failed to reconcile managed daemon transport',
managedBootstrapError
)
}
}
let localhost: LocalhostHostReconciliationInput | null = null
try {
const { serverId, hostname } = await probeConnection(
{
id: `bootstrap:${DEFAULT_LOCALHOST_ENDPOINT}`,
type: 'directTcp',
endpoint: DEFAULT_LOCALHOST_ENDPOINT,
},
{ timeoutMs: DEFAULT_LOCALHOST_BOOTSTRAP_TIMEOUT_MS }
)
if (!cancelled) {
localhost = {
serverId,
hostname,
endpoint: DEFAULT_LOCALHOST_ENDPOINT,
}
}
} catch {
// Best-effort reconciliation only; keep startup resilient if localhost isn't reachable.
}
if (cancelled) {
return
}
const existing = readDaemons()
const next = reconcileDesktopStartupRegistry({
existing,
managed,
localhost,
})
if (next !== existing) {
await persist(next)
}
} catch (reconciliationError) {
if (cancelled) return
console.warn(
'[DaemonRegistry] Failed to reconcile desktop startup host connections',
reconciliationError
)
}
}
void reconcileDesktopStartup()
return () => {
cancelled = true
}
}, [
isPending,
persist,
readDaemons,
])
useEffect(() => {
if (isPending) return
if (shouldUseManagedDesktopDaemon()) return
if (localhostBootstrapAttemptedRef.current) return
localhostBootstrapAttemptedRef.current = true
let cancelled = false
const bootstrapDefaultLocalhost = async () => {
const bootstrapLocalhost = async () => {
try {
const [isE2E, alreadyHandled] = await Promise.all([
AsyncStorage.getItem(E2E_STORAGE_KEY),
AsyncStorage.getItem(DEFAULT_LOCALHOST_BOOTSTRAP_KEY),
])
if (cancelled || isE2E || alreadyHandled) {
const isE2E = await AsyncStorage.getItem(E2E_STORAGE_KEY)
if (cancelled || isE2E) {
return
}
const alreadyHandled = await AsyncStorage.getItem(DEFAULT_LOCALHOST_BOOTSTRAP_KEY)
if (cancelled || alreadyHandled) {
return
}
const existing = readDaemons()
if (registryHasDirectEndpoint(existing, DEFAULT_LOCALHOST_ENDPOINT)) {
await markDefaultLocalhostBootstrapHandled()
await AsyncStorage.setItem(DEFAULT_LOCALHOST_BOOTSTRAP_KEY, '1')
return
}
@@ -290,7 +782,7 @@ export function DaemonRegistryProvider({ children }: { children: ReactNode }) {
const { serverId, hostname } = await probeConnection(
{
id: `bootstrap:${DEFAULT_LOCALHOST_ENDPOINT}`,
type: 'direct',
type: 'directTcp',
endpoint: DEFAULT_LOCALHOST_ENDPOINT,
},
{ timeoutMs: DEFAULT_LOCALHOST_BOOTSTRAP_TIMEOUT_MS }
@@ -302,25 +794,26 @@ export function DaemonRegistryProvider({ children }: { children: ReactNode }) {
endpoint: DEFAULT_LOCALHOST_ENDPOINT,
label: hostname ?? undefined,
})
await markDefaultLocalhostBootstrapHandled()
await AsyncStorage.setItem(DEFAULT_LOCALHOST_BOOTSTRAP_KEY, '1')
} catch {
// Best-effort bootstrap only; keep startup resilient if localhost isn't reachable.
}
} catch (bootstrapError) {
if (cancelled) return
console.warn(
'[DaemonRegistry] Failed to bootstrap default localhost connection',
bootstrapError
)
console.warn('[DaemonRegistry] Failed to bootstrap host connections', bootstrapError)
}
}
void bootstrapDefaultLocalhost()
void bootstrapLocalhost()
return () => {
cancelled = true
}
}, [isPending, markDefaultLocalhostBootstrapHandled, readDaemons, upsertDirectConnection])
}, [
isPending,
readDaemons,
upsertDirectConnection,
])
const upsertRelayConnection = useCallback(
async (input: {
@@ -334,16 +827,15 @@ export function DaemonRegistryProvider({ children }: { children: ReactNode }) {
if (!daemonPublicKeyB64) {
throw new Error('daemonPublicKeyB64 is required')
}
const connection: RelayHostConnection = {
id: `relay:${relayEndpoint}`,
type: 'relay',
relayEndpoint,
daemonPublicKeyB64,
}
return upsertHostConnection({
serverId: input.serverId,
label: input.label,
connection,
connection: {
id: `relay:${relayEndpoint}`,
type: 'relay',
relayEndpoint,
daemonPublicKeyB64,
},
})
},
[upsertHostConnection]
@@ -394,108 +886,21 @@ export function DaemonRegistryProvider({ children }: { children: ReactNode }) {
return <DaemonRegistryContext.Provider value={value}>{children}</DaemonRegistryContext.Provider>
}
type LegacyHostProfileV1 = {
id: string
label: string
endpoints?: unknown
daemonPublicKeyB64?: unknown
relay?: unknown
createdAt: string
updatedAt: string
}
function isHostProfileV2(value: unknown): value is HostProfile {
if (!value || typeof value !== 'object') return false
const obj = value as Record<string, unknown>
return (
typeof obj.serverId === 'string' &&
typeof obj.label === 'string' &&
Array.isArray(obj.connections) &&
typeof obj.createdAt === 'string' &&
typeof obj.updatedAt === 'string'
)
}
async function loadDaemonRegistryFromStorage(): Promise<HostProfile[]> {
try {
const stored = await AsyncStorage.getItem(REGISTRY_STORAGE_KEY)
if (stored) {
const parsed = JSON.parse(stored) as unknown
if (Array.isArray(parsed)) {
const v2 = parsed.filter((entry) => isHostProfileV2(entry)) as HostProfile[]
if (v2.length === parsed.length) {
return v2
}
// Hard migration from the previous in-repo schema (v1 HostProfile with `id/endpoints/relay`).
const migrated: HostProfile[] = parsed
.map((entry): HostProfile | null => {
if (!entry || typeof entry !== 'object') return null
const obj = entry as LegacyHostProfileV1
if (typeof obj.id !== 'string' || typeof obj.label !== 'string') return null
// Only keep stable daemon ids; discard transient entries to avoid confusing host selection.
if (!obj.id.startsWith('srv_')) return null
const now = new Date().toISOString()
const createdAt = typeof obj.createdAt === 'string' ? obj.createdAt : now
const updatedAt = typeof obj.updatedAt === 'string' ? obj.updatedAt : now
const connections: HostConnection[] = []
if (Array.isArray(obj.endpoints)) {
for (const endpointRaw of obj.endpoints) {
try {
const endpoint = normalizeHostPort(String(endpointRaw))
connections.push({ id: `direct:${endpoint}`, type: 'direct', endpoint })
} catch {
// ignore invalid endpoint
}
}
}
const relayEndpointRaw =
obj.relay && typeof (obj.relay as any)?.endpoint === 'string'
? String((obj.relay as any).endpoint)
: null
const daemonPublicKeyB64 =
typeof obj.daemonPublicKeyB64 === 'string' ? obj.daemonPublicKeyB64.trim() : ''
if (relayEndpointRaw && daemonPublicKeyB64) {
try {
const relayEndpoint = normalizeHostPort(relayEndpointRaw)
connections.push({
id: `relay:${relayEndpoint}`,
type: 'relay',
relayEndpoint,
daemonPublicKeyB64,
})
} catch {
// ignore invalid relay endpoint
}
}
if (connections.length === 0) return null
const preferredConnectionId: string | null = connections[0]?.id ?? null
return {
serverId: obj.id,
label: obj.label,
connections,
preferredConnectionId,
createdAt,
updatedAt,
}
})
.filter((entry): entry is HostProfile => entry !== null)
await AsyncStorage.setItem(REGISTRY_STORAGE_KEY, JSON.stringify(migrated))
return migrated
}
if (!stored) {
return []
}
return []
const parsed = JSON.parse(stored) as unknown
if (!Array.isArray(parsed)) {
return []
}
return parsed
.map((entry) => normalizeStoredHostProfile(entry))
.filter((entry): entry is HostProfile => entry !== null)
} catch (error) {
console.error('[DaemonRegistry] Failed to load daemon registry', error)
throw error
@@ -1,64 +1,104 @@
import { useCallback, useState } from "react";
import { Alert, Text, View } from "react-native";
import { useCallback, useEffect, useState } from "react";
import { ActivityIndicator, Alert, Image, Text, View } from "react-native";
import * as Clipboard from "expo-clipboard";
import * as QRCode from "qrcode";
import { useFocusEffect } from "@react-navigation/native";
import { StyleSheet } from "react-native-unistyles";
import { AdaptiveModalSheet, AdaptiveTextInput } from "@/components/adaptive-modal-sheet";
import { Button } from "@/components/ui/button";
import { confirmDialog } from "@/utils/confirm-dialog";
import {
buildDaemonUpdateDiagnostics,
formatVersionWithPrefix,
getLocalDaemonVersion,
isVersionMismatch,
runLocalDaemonUpdate,
shouldShowDesktopUpdateSection,
} from "@/desktop/updates/desktop-updates";
import {
getManagedDaemonLogs,
getManagedDaemonPairing,
getManagedDaemonStatus,
installManagedCliShim,
restartManagedDaemon,
shouldUseManagedDesktopDaemon,
uninstallManagedCliShim,
updateManagedDaemonTcpSettings,
type ManagedDaemonLogs,
type ManagedPairingOffer,
type ManagedDaemonStatus,
} from "@/desktop/managed-runtime/managed-runtime";
export interface LocalDaemonSectionProps {
appVersion: string | null;
}
export function LocalDaemonSection({ appVersion }: LocalDaemonSectionProps) {
const showSection = shouldShowDesktopUpdateSection();
const [localDaemonVersion, setLocalDaemonVersion] = useState<string | null>(null);
const [localDaemonVersionError, setLocalDaemonVersionError] = useState<string | null>(null);
const [isUpdatingLocalDaemon, setIsUpdatingLocalDaemon] = useState(false);
const [localDaemonUpdateMessage, setLocalDaemonUpdateMessage] = useState<string | null>(null);
const [localDaemonUpdateDiagnostics, setLocalDaemonUpdateDiagnostics] = useState<string | null>(
null
);
const showSection = shouldUseManagedDesktopDaemon();
const [managedStatus, setManagedStatus] = useState<ManagedDaemonStatus | null>(null);
const [statusError, setStatusError] = useState<string | null>(null);
const [isRestartingDaemon, setIsRestartingDaemon] = useState(false);
const [isInstallingCli, setIsInstallingCli] = useState(false);
const [isSavingTcpSettings, setIsSavingTcpSettings] = useState(false);
const [statusMessage, setStatusMessage] = useState<string | null>(null);
const [managedLogs, setManagedLogs] = useState<ManagedDaemonLogs | null>(null);
const [isTcpModalOpen, setIsTcpModalOpen] = useState(false);
const [isLogsModalOpen, setIsLogsModalOpen] = useState(false);
const [isPairingModalOpen, setIsPairingModalOpen] = useState(false);
const [isLoadingPairing, setIsLoadingPairing] = useState(false);
const [pairingOffer, setPairingOffer] = useState<ManagedPairingOffer | null>(null);
const [pairingStatusMessage, setPairingStatusMessage] = useState<string | null>(null);
const [tcpHostInput, setTcpHostInput] = useState(DEFAULT_TCP_HOST);
const [tcpPortInput, setTcpPortInput] = useState(String(DEFAULT_TCP_PORT));
const loadManagedStatus = useCallback(() => {
if (!showSection) {
return Promise.resolve();
}
return Promise.all([getManagedDaemonStatus(), getManagedDaemonLogs()])
.then(([status, logs]) => {
setManagedStatus(status);
setManagedLogs(logs);
setStatusError(null);
const [tcpHost, tcpPort] = splitTcpListen(status.tcpListen);
setTcpHostInput(tcpHost);
setTcpPortInput(tcpPort);
})
.catch((error) => {
const message = error instanceof Error ? error.message : String(error);
setStatusError(message);
});
}, [showSection]);
useFocusEffect(
useCallback(() => {
if (!showSection) {
return undefined;
}
void getLocalDaemonVersion().then((result) => {
setLocalDaemonVersion(result.version);
setLocalDaemonVersionError(result.error);
});
void loadManagedStatus();
return undefined;
}, [showSection])
}, [loadManagedStatus, showSection])
);
const localDaemonVersionText = formatVersionWithPrefix(localDaemonVersion);
const daemonVersionMismatch = isVersionMismatch(appVersion, localDaemonVersion);
const daemonVersionHint = localDaemonVersionError ?? "Daemon installed on this computer.";
const localDaemonVersionText = formatVersionWithPrefix(managedStatus?.runtimeVersion ?? null);
const daemonVersionMismatch = isVersionMismatch(appVersion, managedStatus?.runtimeVersion ?? null);
const daemonVersionHint =
statusError ??
(managedStatus?.daemonRunning
? managedStatus.transportType === "tcp"
? `Managed daemon running on explicit TCP ${managedStatus.transportPath}.`
: `Managed daemon running on private ${managedStatus.transportType}.`
: "Managed daemon is currently stopped.");
const handleUpdateLocalDaemon = useCallback(() => {
if (!showSection) {
return;
}
if (isUpdatingLocalDaemon) {
if (isRestartingDaemon) {
return;
}
void confirmDialog({
title: "Update local daemon",
title: "Restart managed daemon",
message:
"This updates the Paseo daemon on this computer. A restart is required afterwards.",
confirmLabel: "Update daemon",
"This restarts the desktop-managed daemon using its private managed home and socket.",
confirmLabel: "Restart daemon",
cancelLabel: "Cancel",
})
.then((confirmed) => {
@@ -66,71 +106,201 @@ export function LocalDaemonSection({ appVersion }: LocalDaemonSectionProps) {
return;
}
setIsUpdatingLocalDaemon(true);
setLocalDaemonUpdateMessage(null);
setLocalDaemonUpdateDiagnostics(null);
setIsRestartingDaemon(true);
setStatusMessage(null);
void runLocalDaemonUpdate()
.then((result) => {
const diagnostics = buildDaemonUpdateDiagnostics(result);
if (result.exitCode !== 0) {
setLocalDaemonUpdateMessage(
`Local daemon update failed (exit code ${result.exitCode}). Copy diagnostics below to troubleshoot.`
);
setLocalDaemonUpdateDiagnostics(diagnostics);
return;
}
setLocalDaemonUpdateMessage(
"Local daemon update finished. Restart is required: run `paseo daemon restart` on this computer."
);
if (result.stdout.trim().length > 0 || result.stderr.trim().length > 0) {
setLocalDaemonUpdateDiagnostics(diagnostics);
}
void getLocalDaemonVersion().then((versionResult) => {
setLocalDaemonVersion(versionResult.version);
setLocalDaemonVersionError(versionResult.error);
});
void restartManagedDaemon()
.then((status) => {
setManagedStatus(status);
setStatusMessage("Managed daemon restarted.");
return loadManagedStatus();
})
.catch((error) => {
console.error("[Settings] Failed to update local daemon", error);
console.error("[Settings] Failed to restart managed daemon", error);
const message = error instanceof Error ? error.message : String(error);
setLocalDaemonUpdateMessage(
"Local daemon update failed before completion. Copy diagnostics below to troubleshoot."
);
setLocalDaemonUpdateDiagnostics(
buildDaemonUpdateDiagnostics({
exitCode: -1,
stdout: "",
stderr: message,
})
);
setStatusMessage(`Managed daemon restart failed: ${message}`);
})
.finally(() => {
setIsUpdatingLocalDaemon(false);
setIsRestartingDaemon(false);
});
})
.catch((error) => {
console.error("[Settings] Failed to open daemon update confirmation", error);
Alert.alert("Error", "Unable to open the daemon update confirmation dialog.");
console.error("[Settings] Failed to open managed daemon restart confirmation", error);
Alert.alert("Error", "Unable to open the managed daemon restart confirmation dialog.");
});
}, [isUpdatingLocalDaemon, showSection]);
}, [isRestartingDaemon, loadManagedStatus, showSection]);
const handleCopyDaemonDiagnostics = useCallback(() => {
if (!localDaemonUpdateDiagnostics) {
const handleToggleCliShim = useCallback(() => {
if (!showSection || isInstallingCli) {
return;
}
setIsInstallingCli(true);
setStatusMessage(null);
const action = managedStatus?.cliShimPath ? uninstallManagedCliShim : installManagedCliShim;
void action()
.then((result) => {
setStatusMessage(result.message);
return loadManagedStatus();
})
.catch((error) => {
const message = error instanceof Error ? error.message : String(error);
setStatusMessage(`CLI shim action failed: ${message}`);
})
.finally(() => {
setIsInstallingCli(false);
});
}, [isInstallingCli, loadManagedStatus, managedStatus?.cliShimPath, showSection]);
const handleCopyLogPath = useCallback(() => {
const logPath = managedLogs?.logPath ?? managedStatus?.logPath;
if (!logPath) {
return;
}
void Clipboard.setStringAsync(localDaemonUpdateDiagnostics)
void Clipboard.setStringAsync(logPath)
.then(() => {
Alert.alert("Copied", "Daemon update diagnostics copied.");
Alert.alert("Copied", "Managed daemon log path copied.");
})
.catch((error) => {
console.error("[Settings] Failed to copy daemon update diagnostics", error);
Alert.alert("Error", "Unable to copy diagnostics.");
console.error("[Settings] Failed to copy managed daemon log path", error);
Alert.alert("Error", "Unable to copy log path.");
});
}, [localDaemonUpdateDiagnostics]);
}, [managedLogs?.logPath, managedStatus?.logPath]);
const handleOpenLogs = useCallback(() => {
if (!managedLogs) {
return;
}
setIsLogsModalOpen(true);
}, [managedLogs]);
const handleOpenPairingModal = useCallback(() => {
if (isLoadingPairing) {
return;
}
setIsPairingModalOpen(true);
setIsLoadingPairing(true);
setPairingStatusMessage(null);
void getManagedDaemonPairing()
.then((pairing) => {
setPairingOffer(pairing);
if (!pairing.relayEnabled || !pairing.url) {
setPairingStatusMessage("Relay pairing is disabled for this managed daemon.");
}
})
.catch((error) => {
const message = error instanceof Error ? error.message : String(error);
setPairingOffer(null);
setPairingStatusMessage(`Unable to load pairing offer: ${message}`);
})
.finally(() => {
setIsLoadingPairing(false);
});
}, [isLoadingPairing]);
const handleCopyPairingLink = useCallback(() => {
if (!pairingOffer?.url) {
return;
}
void Clipboard.setStringAsync(pairingOffer.url)
.then(() => {
Alert.alert("Copied", "Pairing link copied.");
})
.catch((error) => {
console.error("[Settings] Failed to copy pairing link", error);
Alert.alert("Error", "Unable to copy pairing link.");
});
}, [pairingOffer?.url]);
const handleOpenTcpModal = useCallback(() => {
if (!managedStatus) {
return;
}
const [tcpHost, tcpPort] = splitTcpListen(managedStatus.tcpListen);
setTcpHostInput(tcpHost);
setTcpPortInput(tcpPort);
setIsTcpModalOpen(true);
}, [managedStatus]);
const handleDisableTcp = useCallback(() => {
if (isSavingTcpSettings) {
return;
}
setIsSavingTcpSettings(true);
setStatusMessage(null);
void updateManagedDaemonTcpSettings({
enabled: false,
host: tcpHostInput.trim() || DEFAULT_TCP_HOST,
port: parseTcpPort(tcpPortInput) ?? DEFAULT_TCP_PORT,
})
.then((status) => {
setManagedStatus(status);
setStatusMessage("Managed TCP exposure disabled.");
return loadManagedStatus();
})
.catch((error) => {
const message = error instanceof Error ? error.message : String(error);
setStatusMessage(`Managed TCP update failed: ${message}`);
})
.finally(() => {
setIsSavingTcpSettings(false);
});
}, [isSavingTcpSettings, loadManagedStatus, tcpHostInput, tcpPortInput]);
const handleSaveTcp = useCallback(() => {
if (isSavingTcpSettings) {
return;
}
const host = tcpHostInput.trim();
const port = parseTcpPort(tcpPortInput);
if (!host) {
Alert.alert("Host required", "Enter a TCP bind host.");
return;
}
if (port == null || port <= 0) {
Alert.alert("Port required", "Enter a valid TCP port.");
return;
}
if (port === 6767) {
Alert.alert("Port unavailable", "Managed TCP mode must not claim 127.0.0.1:6767.");
return;
}
void confirmDialog({
title: "Enable managed TCP",
message:
"This exposes the managed daemon on a TCP listener. Relay remains available, but network exposure is no longer private to the desktop app.",
confirmLabel: "Enable TCP",
cancelLabel: "Cancel",
})
.then((confirmed) => {
if (!confirmed) {
return;
}
setIsSavingTcpSettings(true);
setStatusMessage(null);
void updateManagedDaemonTcpSettings({ enabled: true, host, port })
.then((status) => {
setManagedStatus(status);
setIsTcpModalOpen(false);
setStatusMessage(`Managed TCP exposure enabled on ${host}:${port}.`);
return loadManagedStatus();
})
.catch((error) => {
const message = error instanceof Error ? error.message : String(error);
setStatusMessage(`Managed TCP update failed: ${message}`);
})
.finally(() => {
setIsSavingTcpSettings(false);
});
})
.catch((error) => {
console.error("[Settings] Failed to open managed TCP confirmation", error);
Alert.alert("Error", "Unable to open the managed TCP confirmation dialog.");
});
}, [isSavingTcpSettings, loadManagedStatus, tcpHostInput, tcpPortInput]);
if (!showSection) {
return null;
@@ -138,7 +308,7 @@ export function LocalDaemonSection({ appVersion }: LocalDaemonSectionProps) {
return (
<View style={styles.section}>
<Text style={styles.sectionTitle}>Local daemon</Text>
<Text style={styles.sectionTitle}>Managed daemon</Text>
<View style={styles.card}>
<View style={styles.row}>
<View style={styles.rowContent}>
@@ -149,47 +319,309 @@ export function LocalDaemonSection({ appVersion }: LocalDaemonSectionProps) {
</View>
<View style={[styles.row, styles.rowBorder]}>
<View style={styles.rowContent}>
<Text style={styles.rowTitle}>Update daemon</Text>
<Text style={styles.rowTitle}>Restart daemon</Text>
<Text style={styles.hintText}>
Updates the daemon on this computer only. Requires a restart.
Restarts the desktop-managed daemon without touching `~/.paseo` or `127.0.0.1:6767`.
</Text>
{localDaemonUpdateMessage ? (
<Text style={styles.statusText}>{localDaemonUpdateMessage}</Text>
{statusMessage ? (
<Text style={styles.statusText}>{statusMessage}</Text>
) : null}
</View>
<Button
variant="secondary"
size="sm"
onPress={handleUpdateLocalDaemon}
disabled={isUpdatingLocalDaemon}
disabled={isRestartingDaemon}
>
{isUpdatingLocalDaemon ? "Updating..." : "Update daemon"}
{isRestartingDaemon ? "Restarting..." : "Restart daemon"}
</Button>
</View>
<View style={[styles.row, styles.rowBorder]}>
<View style={styles.rowContent}>
<Text style={styles.rowTitle}>CLI shim</Text>
<Text style={styles.hintText}>
Installs `paseo` into your user path and points it at the managed daemon by default.
</Text>
</View>
<Button
variant="secondary"
size="sm"
onPress={handleToggleCliShim}
disabled={isInstallingCli}
>
{isInstallingCli
? "Working..."
: managedStatus?.cliShimPath
? "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 ??
"Managed daemon log path unavailable."}
</Text>
</View>
<View style={styles.actionGroup}>
{(managedLogs?.logPath ?? managedStatus?.logPath) ? (
<Button variant="secondary" size="sm" onPress={handleCopyLogPath}>
Copy path
</Button>
) : null}
<Button
variant="secondary"
size="sm"
onPress={handleOpenLogs}
disabled={!managedLogs}
>
Open logs
</Button>
</View>
</View>
<View style={[styles.row, styles.rowBorder]}>
<View style={styles.rowContent}>
<Text style={styles.rowTitle}>Pair device</Text>
<Text style={styles.hintText}>
Show the managed daemon QR code and a copyable pairing link for the mobile app.
</Text>
</View>
<Button variant="secondary" size="sm" onPress={handleOpenPairingModal}>
Pair device
</Button>
</View>
<View style={[styles.row, styles.rowBorder]}>
<View style={styles.rowContent}>
<Text style={styles.rowTitle}>Advanced TCP</Text>
<Text style={styles.hintText}>
Default off. Use only if you explicitly want a network listener instead of the
private managed transport.
</Text>
<Text style={styles.statusText}>
{managedStatus?.tcpEnabled
? `Enabled on ${managedStatus.tcpListen ?? managedStatus.transportPath}`
: "Disabled"}
</Text>
</View>
<View style={styles.actionGroup}>
{managedStatus?.tcpEnabled ? (
<Button
variant="secondary"
size="sm"
onPress={handleDisableTcp}
disabled={isSavingTcpSettings}
>
{isSavingTcpSettings ? "Working..." : "Disable TCP"}
</Button>
) : null}
<Button
variant="secondary"
size="sm"
onPress={handleOpenTcpModal}
disabled={isSavingTcpSettings}
>
{managedStatus?.tcpEnabled ? "Edit TCP" : "Enable TCP"}
</Button>
</View>
</View>
</View>
{daemonVersionMismatch ? (
<View style={styles.warningCard}>
<Text style={styles.warningText}>
Desktop app and local daemon versions differ. Keep both on the same version to avoid
Desktop app and managed daemon versions differ. Keep both on the same version to avoid
stability issues or breaking changes.
</Text>
</View>
) : null}
{localDaemonUpdateDiagnostics ? (
<View style={styles.diagnosticsCard}>
<View style={styles.diagnosticsHeader}>
<Text style={styles.diagnosticsTitle}>Daemon update diagnostics</Text>
<Button variant="secondary" size="sm" onPress={handleCopyDaemonDiagnostics}>
Copy output
<AdaptiveModalSheet
visible={isTcpModalOpen}
onClose={() => setIsTcpModalOpen(false)}
title="Managed TCP settings"
>
<View style={styles.modalBody}>
<Text style={styles.hintText}>
TCP is advanced opt-in only. It must stay off unless you explicitly need direct network
access, and it must never use port 6767.
</Text>
<AdaptiveTextInput
value={tcpHostInput}
onChangeText={setTcpHostInput}
autoCapitalize="none"
autoCorrect={false}
placeholder="127.0.0.1"
style={styles.input}
/>
<AdaptiveTextInput
value={tcpPortInput}
onChangeText={setTcpPortInput}
autoCapitalize="none"
autoCorrect={false}
keyboardType="number-pad"
placeholder={String(DEFAULT_TCP_PORT)}
style={styles.input}
/>
<View style={styles.modalActions}>
<Button variant="secondary" size="sm" onPress={() => setIsTcpModalOpen(false)}>
Cancel
</Button>
<Button size="sm" onPress={handleSaveTcp} disabled={isSavingTcpSettings}>
{isSavingTcpSettings ? "Saving..." : "Save"}
</Button>
</View>
<Text style={styles.diagnosticsText} selectable>
{localDaemonUpdateDiagnostics}
</View>
</AdaptiveModalSheet>
<AdaptiveModalSheet
visible={isPairingModalOpen}
onClose={() => setIsPairingModalOpen(false)}
title="Pair device"
testID="managed-daemon-pairing-dialog"
>
<PairingOfferDialogContent
isLoading={isLoadingPairing}
pairingOffer={pairingOffer}
statusMessage={pairingStatusMessage}
onCopyLink={handleCopyPairingLink}
/>
</AdaptiveModalSheet>
<AdaptiveModalSheet
visible={isLogsModalOpen}
onClose={() => setIsLogsModalOpen(false)}
title="Managed daemon logs"
testID="managed-daemon-logs-dialog"
snapPoints={["70%", "92%"]}
>
<View style={styles.modalBody}>
<Text style={styles.hintText}>
{managedLogs?.logPath ??
managedStatus?.logPath ??
"Managed daemon log path unavailable."}
</Text>
<Text style={styles.logOutput} selectable>
{managedLogs?.contents.length ? managedLogs.contents : "(log file is empty)"}
</Text>
</View>
) : null}
</AdaptiveModalSheet>
</View>
);
}
const DEFAULT_TCP_HOST = "127.0.0.1";
const DEFAULT_TCP_PORT = 7771;
function splitTcpListen(value: string | null): [string, string] {
if (!value) {
return [DEFAULT_TCP_HOST, String(DEFAULT_TCP_PORT)];
}
const [host, port] = value.split(":");
return [host || DEFAULT_TCP_HOST, port || String(DEFAULT_TCP_PORT)];
}
function parseTcpPort(value: string): number | null {
const parsed = Number.parseInt(value.trim(), 10);
return Number.isFinite(parsed) ? parsed : null;
}
function PairingOfferDialogContent(input: {
isLoading: boolean;
pairingOffer: ManagedPairingOffer | null;
statusMessage: string | null;
onCopyLink: () => void;
}) {
const { isLoading, pairingOffer, statusMessage, onCopyLink } = input;
const [qrDataUrl, setQrDataUrl] = useState<string | null>(null);
const [qrError, setQrError] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
if (!pairingOffer?.url) {
setQrDataUrl(null);
setQrError(null);
return () => {
cancelled = true;
};
}
setQrError(null);
setQrDataUrl(null);
void QRCode.toDataURL(pairingOffer.url, {
errorCorrectionLevel: "M",
margin: 1,
width: 320,
})
.then((dataUrl) => {
if (cancelled) {
return;
}
setQrDataUrl(dataUrl);
})
.catch((error) => {
if (cancelled) {
return;
}
setQrError(error instanceof Error ? error.message : String(error));
});
return () => {
cancelled = true;
};
}, [pairingOffer?.url]);
if (isLoading) {
return (
<View style={styles.pairingState}>
<ActivityIndicator size="small" />
<Text style={styles.hintText}>Loading pairing offer…</Text>
</View>
);
}
if (statusMessage) {
return (
<View style={styles.modalBody}>
<Text style={styles.hintText}>{statusMessage}</Text>
</View>
);
}
if (!pairingOffer?.url) {
return (
<View style={styles.modalBody}>
<Text style={styles.hintText}>Pairing offer unavailable.</Text>
</View>
);
}
return (
<View style={styles.modalBody}>
<Text style={styles.hintText}>
Scan this QR code in Paseo, or copy the pairing link below.
</Text>
<View style={styles.qrCard}>
{qrDataUrl ? (
<Image source={{ uri: qrDataUrl }} style={styles.qrImage} />
) : qrError ? (
<Text style={styles.hintText}>QR unavailable: {qrError}</Text>
) : (
<ActivityIndicator size="small" />
)}
</View>
<Text style={styles.linkLabel}>Pairing link</Text>
<Text style={styles.linkText} selectable>
{pairingOffer.url}
</Text>
<View style={styles.modalActions}>
<Button variant="secondary" size="sm" onPress={onCopyLink}>
Copy link
</Button>
</View>
</View>
);
}
@@ -227,6 +659,10 @@ const styles = StyleSheet.create((theme) => ({
flex: 1,
marginRight: theme.spacing[3],
},
actionGroup: {
flexDirection: "row",
gap: theme.spacing[2],
},
rowTitle: {
color: theme.colors.foreground,
fontSize: theme.fontSize.base,
@@ -259,28 +695,59 @@ const styles = StyleSheet.create((theme) => ({
color: theme.colors.palette.amber[500],
fontSize: theme.fontSize.xs,
},
diagnosticsCard: {
marginTop: theme.spacing[3],
modalBody: {
gap: theme.spacing[3],
paddingBottom: theme.spacing[2],
},
pairingState: {
alignItems: "center",
justifyContent: "center",
gap: theme.spacing[3],
paddingVertical: theme.spacing[6],
},
qrCard: {
alignItems: "center",
justifyContent: "center",
alignSelf: "center",
minHeight: 220,
minWidth: 220,
padding: theme.spacing[4],
borderRadius: theme.borderRadius.lg,
borderWidth: 1,
borderColor: theme.colors.border,
backgroundColor: theme.colors.surface1,
padding: theme.spacing[3],
gap: theme.spacing[2],
backgroundColor: theme.colors.surface0,
},
diagnosticsHeader: {
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
gap: theme.spacing[2],
qrImage: {
width: 220,
height: 220,
},
diagnosticsTitle: {
linkLabel: {
color: theme.colors.foreground,
fontSize: theme.fontSize.sm,
},
diagnosticsText: {
linkText: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.xs,
lineHeight: 18,
},
logOutput: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.xs,
fontFamily: "ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace",
lineHeight: 18,
},
input: {
borderWidth: 1,
borderColor: theme.colors.border,
borderRadius: theme.borderRadius.md,
color: theme.colors.foreground,
backgroundColor: theme.colors.surface1,
paddingHorizontal: theme.spacing[3],
paddingVertical: theme.spacing[3],
},
modalActions: {
flexDirection: "row",
justifyContent: "flex-end",
gap: theme.spacing[2],
},
}));
@@ -0,0 +1,262 @@
import { invokeDesktopCommand } from "@/desktop/tauri/invoke-desktop-command";
import { getTauri, isTauriEnvironment } from "@/utils/tauri";
export type ManagedRuntimeStatus = {
runtimeId: string;
runtimeVersion: string;
bundledRuntimeRoot: string;
installedRuntimeRoot: string;
installed: boolean;
managedHome: string;
transportType: string;
transportPath: string;
diagnosticsRoot: string;
stateFilePath: string;
};
export type ManagedDaemonStatus = {
runtimeId: string;
runtimeVersion: string;
runtimeRoot: string;
managedHome: string;
transportType: string;
transportPath: string;
daemonPid: number | null;
daemonRunning: boolean;
daemonStatus: string;
logPath: string;
serverId: string | null;
hostname: string | null;
relayEnabled: boolean;
tcpEnabled: boolean;
tcpListen: string | null;
cliShimPath: string | null;
};
export type ManagedDaemonLogs = {
logPath: string;
contents: string;
};
export type ManagedPairingOffer = {
relayEnabled: boolean;
url: string | null;
qr: string | null;
};
export type CliShimResult = {
installed: boolean;
path: string | null;
message: string;
};
export type ManagedTcpSettings = {
enabled: boolean;
host: string;
port: number;
};
export type LocalTransportTarget = {
transportType: "socket" | "pipe";
transportPath: string;
};
type LocalTransportEventPayload = {
sessionId: string;
kind: "open" | "message" | "close" | "error";
text?: string | null;
binaryBase64?: string | null;
code?: number | null;
reason?: string | null;
error?: string | null;
};
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null;
}
function toStringOrNull(value: unknown): string | null {
return typeof value === "string" && value.trim().length > 0 ? value : null;
}
function toNumberOrNull(value: unknown): number | null {
return typeof value === "number" && Number.isFinite(value) ? value : null;
}
function parseManagedRuntimeStatus(raw: unknown): ManagedRuntimeStatus {
if (!isRecord(raw)) {
throw new Error("Unexpected managed runtime status response.");
}
return {
runtimeId: toStringOrNull(raw.runtimeId) ?? "",
runtimeVersion: toStringOrNull(raw.runtimeVersion) ?? "",
bundledRuntimeRoot: toStringOrNull(raw.bundledRuntimeRoot) ?? "",
installedRuntimeRoot: toStringOrNull(raw.installedRuntimeRoot) ?? "",
installed: raw.installed === true,
managedHome: toStringOrNull(raw.managedHome) ?? "",
transportType: toStringOrNull(raw.transportType) ?? "socket",
transportPath: toStringOrNull(raw.transportPath) ?? "",
diagnosticsRoot: toStringOrNull(raw.diagnosticsRoot) ?? "",
stateFilePath: toStringOrNull(raw.stateFilePath) ?? "",
};
}
function parseManagedDaemonStatus(raw: unknown): ManagedDaemonStatus {
if (!isRecord(raw)) {
throw new Error("Unexpected managed daemon status response.");
}
return {
runtimeId: toStringOrNull(raw.runtimeId) ?? "",
runtimeVersion: toStringOrNull(raw.runtimeVersion) ?? "",
runtimeRoot: toStringOrNull(raw.runtimeRoot) ?? "",
managedHome: toStringOrNull(raw.managedHome) ?? "",
transportType: toStringOrNull(raw.transportType) ?? "socket",
transportPath: toStringOrNull(raw.transportPath) ?? "",
daemonPid: toNumberOrNull(raw.daemonPid),
daemonRunning: raw.daemonRunning === true,
daemonStatus: toStringOrNull(raw.daemonStatus) ?? "unknown",
logPath: toStringOrNull(raw.logPath) ?? "",
serverId: toStringOrNull(raw.serverId),
hostname: toStringOrNull(raw.hostname),
relayEnabled: raw.relayEnabled === true,
tcpEnabled: raw.tcpEnabled === true,
tcpListen: toStringOrNull(raw.tcpListen),
cliShimPath: toStringOrNull(raw.cliShimPath),
};
}
function parseManagedDaemonLogs(raw: unknown): ManagedDaemonLogs {
if (!isRecord(raw)) {
throw new Error("Unexpected managed daemon logs response.");
}
return {
logPath: toStringOrNull(raw.logPath) ?? "",
contents: typeof raw.contents === "string" ? raw.contents : "",
};
}
function parseManagedPairingOffer(raw: unknown): ManagedPairingOffer {
if (!isRecord(raw)) {
throw new Error("Unexpected managed daemon pairing response.");
}
return {
relayEnabled: raw.relayEnabled === true,
url: toStringOrNull(raw.url),
qr: toStringOrNull(raw.qr),
};
}
function parseCliShimResult(raw: unknown): CliShimResult {
if (!isRecord(raw)) {
throw new Error("Unexpected CLI shim response.");
}
return {
installed: raw.installed === true,
path: toStringOrNull(raw.path),
message: toStringOrNull(raw.message) ?? "",
};
}
export function shouldUseManagedDesktopDaemon(): boolean {
return isTauriEnvironment() && getTauri() !== null;
}
export async function ensureManagedRuntime(): Promise<ManagedRuntimeStatus> {
return parseManagedRuntimeStatus(await invokeDesktopCommand("ensure_managed_runtime"));
}
export async function getManagedRuntimeStatus(): Promise<ManagedRuntimeStatus> {
return parseManagedRuntimeStatus(await invokeDesktopCommand("managed_runtime_status"));
}
export async function getManagedDaemonStatus(): Promise<ManagedDaemonStatus> {
return parseManagedDaemonStatus(await invokeDesktopCommand("managed_daemon_status"));
}
export async function startManagedDaemon(): Promise<ManagedDaemonStatus> {
return parseManagedDaemonStatus(await invokeDesktopCommand("start_managed_daemon"));
}
export async function stopManagedDaemon(): Promise<ManagedDaemonStatus> {
return parseManagedDaemonStatus(await invokeDesktopCommand("stop_managed_daemon"));
}
export async function restartManagedDaemon(): Promise<ManagedDaemonStatus> {
return parseManagedDaemonStatus(await invokeDesktopCommand("restart_managed_daemon"));
}
export async function getManagedDaemonLogs(): Promise<ManagedDaemonLogs> {
return parseManagedDaemonLogs(await invokeDesktopCommand("managed_daemon_logs"));
}
export async function getManagedDaemonPairing(): Promise<ManagedPairingOffer> {
return parseManagedPairingOffer(await invokeDesktopCommand("managed_daemon_pairing"));
}
export async function installManagedCliShim(): Promise<CliShimResult> {
return parseCliShimResult(await invokeDesktopCommand("install_cli_shim"));
}
export async function uninstallManagedCliShim(): Promise<CliShimResult> {
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 })
);
}
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 unlisten = await listen("local-daemon-transport-event", (event: unknown) => {
const payload = isRecord(event) && isRecord(event.payload) ? event.payload : null;
if (!payload) {
return;
}
handler({
sessionId: toStringOrNull(payload.sessionId) ?? "",
kind: (toStringOrNull(payload.kind) ?? "error") as LocalTransportEventPayload["kind"],
text: toStringOrNull(payload.text),
binaryBase64: toStringOrNull(payload.binaryBase64),
code: toNumberOrNull(payload.code),
reason: toStringOrNull(payload.reason),
error: toStringOrNull(payload.error),
});
});
return typeof unlisten === "function" ? unlisten : () => {};
}
export async function openLocalTransportSession(target: LocalTransportTarget): Promise<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.");
}
return raw;
}
export async function sendLocalTransportMessage(input: {
sessionId: string;
text?: string;
binaryBase64?: string;
}): Promise<void> {
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 });
}
+17 -11
View File
@@ -163,7 +163,7 @@ function makeFetchAgentsEntry(input: {
function makeHost(input?: Partial<HostProfile>): HostProfile {
const direct: HostConnection = {
id: "direct:lan:6767",
type: "direct",
type: "directTcp",
endpoint: "lan:6767",
};
const relay: HostConnection = {
@@ -176,6 +176,12 @@ function makeHost(input?: Partial<HostProfile>): HostProfile {
return {
serverId: input?.serverId ?? "srv_test",
label: input?.label ?? "test host",
lifecycle: input?.lifecycle ?? {
managed: false,
managedRuntimeId: null,
managedRuntimeVersion: null,
associatedServerId: null,
},
connections: input?.connections ?? [direct, relay],
preferredConnectionId: input?.preferredConnectionId ?? direct.id,
createdAt: input?.createdAt ?? new Date(0).toISOString(),
@@ -227,7 +233,7 @@ describe("HostRuntimeController", () => {
connections: [
{
id: "direct:lan:6767",
type: "direct",
type: "directTcp",
endpoint: "lan:6767",
},
],
@@ -262,7 +268,7 @@ describe("HostRuntimeController", () => {
connections: [
{
id: "direct:lan:6767",
type: "direct",
type: "directTcp",
endpoint: "lan:6767",
},
],
@@ -438,7 +444,7 @@ describe("HostRuntimeController", () => {
connections: [
{
id: "direct:lan:6767",
type: "direct",
type: "directTcp",
endpoint: "lan:6767",
},
],
@@ -577,7 +583,7 @@ describe("HostRuntimeController", () => {
connections: [
{
id: "direct:lan:6767",
type: "direct",
type: "directTcp",
endpoint: "lan:6767",
},
{
@@ -667,7 +673,7 @@ describe("HostRuntimeController", () => {
connections: [
{
id: "direct:lan:6767",
type: "direct",
type: "directTcp",
endpoint: "lan:6767",
},
],
@@ -723,7 +729,7 @@ describe("HostRuntimeController", () => {
connections: [
{
id: "direct:lan:6767",
type: "direct",
type: "directTcp",
endpoint: "lan:6767",
},
],
@@ -789,7 +795,7 @@ describe("HostRuntimeStore", () => {
connections: [
{
id: "direct:lan:6767",
type: "direct",
type: "directTcp",
endpoint: "lan:6767",
},
],
@@ -837,7 +843,7 @@ describe("HostRuntimeStore", () => {
connections: [
{
id: "direct:lan:6767",
type: "direct",
type: "directTcp",
endpoint: "lan:6767",
},
],
@@ -885,7 +891,7 @@ describe("HostRuntimeStore", () => {
connections: [
{
id: "direct:lan:6767",
type: "direct",
type: "directTcp",
endpoint: "lan:6767",
},
],
@@ -1045,7 +1051,7 @@ describe("HostRuntimeStore", () => {
connections: [
{
id: "direct:lan:6767",
type: "direct",
type: "directTcp",
endpoint: "lan:6767",
},
],
+44 -8
View File
@@ -17,6 +17,10 @@ import {
type ConnectionCandidate,
type ConnectionProbeState,
} from "@/utils/connection-selection";
import {
buildLocalDaemonTransportUrl,
createTauriLocalDaemonTransportFactory,
} from "@/utils/managed-tauri-daemon-transport";
import { createTauriWebSocketTransportFactory } from "@/utils/tauri-daemon-transport";
import { applyFetchedAgentDirectory } from "@/utils/agent-directory-sync";
import { useSessionStore, type Agent } from "@/stores/session-store";
@@ -33,7 +37,9 @@ export type HostRuntimeConnectionStatus =
| "error";
export type ActiveConnection =
| { type: "direct"; endpoint: string; display: string }
| { type: "directTcp"; endpoint: string; display: string }
| { type: "directSocket"; endpoint: string; display: "socket" }
| { type: "directPipe"; endpoint: string; display: "pipe" }
| { type: "relay"; endpoint: string; display: "relay" };
export type HostRuntimeAgentDirectoryStatus =
@@ -179,9 +185,23 @@ function readFetchAgentsNextCursor(
}
function toActiveConnection(connection: HostConnection): ActiveConnection {
if (connection.type === "direct") {
if (connection.type === "directSocket") {
return {
type: "direct",
type: "directSocket",
endpoint: connection.path,
display: "socket",
};
}
if (connection.type === "directPipe") {
return {
type: "directPipe",
endpoint: connection.path,
display: "pipe",
};
}
if (connection.type === "directTcp") {
return {
type: "directTcp",
endpoint: connection.endpoint,
display: connection.endpoint,
};
@@ -421,11 +441,14 @@ function createDefaultDeps(): HostRuntimeControllerDeps {
serverId: host.serverId,
connectionType: connection.type,
endpoint:
connection.type === "direct"
connection.type === "directTcp"
? connection.endpoint
: connection.type === "directSocket" || connection.type === "directPipe"
? connection.path
: connection.relayEndpoint,
});
const tauriTransportFactory = createTauriWebSocketTransportFactory();
const localTransportFactory = createTauriLocalDaemonTransportFactory();
const base = {
suppressSendErrors: true,
clientId,
@@ -433,18 +456,31 @@ function createDefaultDeps(): HostRuntimeControllerDeps {
runtimeGeneration,
onDiagnosticsEvent: (event: DaemonClientDiagnosticsEvent) =>
recordDaemonClientDiagnostics(host.serverId, event),
...(tauriTransportFactory
? { transportFactory: tauriTransportFactory }
: {}),
};
if (connection.type === "direct") {
if (connection.type === "directSocket" || connection.type === "directPipe") {
return new DaemonClient({
...base,
...(localTransportFactory ? { transportFactory: localTransportFactory } : {}),
url: buildLocalDaemonTransportUrl({
transportType: connection.type === "directSocket" ? "socket" : "pipe",
transportPath: connection.path,
}),
});
}
if (connection.type === "directTcp") {
return new DaemonClient({
...base,
...(tauriTransportFactory
? { transportFactory: tauriTransportFactory }
: {}),
url: buildDaemonWebSocketUrl(connection.endpoint),
});
}
return new DaemonClient({
...base,
...(tauriTransportFactory
? { transportFactory: tauriTransportFactory }
: {}),
url: buildRelayWebSocketUrl({
endpoint: connection.relayEndpoint,
serverId: host.serverId,
@@ -24,7 +24,7 @@ function shouldSampleFastTransportEvent(): boolean {
export function recordHostRuntimeCreateClient(params: {
serverId: string;
connectionType: "direct" | "relay";
connectionType: "directTcp" | "directSocket" | "directPipe" | "relay";
endpoint: string;
}): void {
recordPerfDiagnosticMark(
+46 -22
View File
@@ -51,6 +51,42 @@ const delay = (ms: number) =>
}, ms);
});
function formatHostConnectionLabel(connection: HostConnection): string {
if (connection.type === "relay") {
return `Relay (${connection.relayEndpoint})`;
}
if (connection.type === "directSocket") {
return `Socket (${connection.path})`;
}
if (connection.type === "directPipe") {
return `Pipe (${connection.path})`;
}
return `TCP (${connection.endpoint})`;
}
function formatActiveConnectionBadge(input: {
activeConnection: { type: HostConnection["type"]; display: string } | null;
theme: ReturnType<typeof useUnistyles>["theme"];
}) {
const { activeConnection, theme } = input;
if (!activeConnection) {
return null;
}
if (activeConnection.type === "relay") {
return { icon: <Globe size={theme.iconSize.xs} color={theme.colors.foregroundMuted} />, text: "Relay" };
}
if (activeConnection.type === "directSocket") {
return { icon: <Monitor size={theme.iconSize.xs} color={theme.colors.foregroundMuted} />, text: "Socket" };
}
if (activeConnection.type === "directPipe") {
return { icon: <Monitor size={theme.iconSize.xs} color={theme.colors.foregroundMuted} />, text: "Pipe" };
}
return {
icon: <Monitor size={theme.iconSize.xs} color={theme.colors.foregroundMuted} />,
text: activeConnection.display,
};
}
const styles = StyleSheet.create((theme) => ({
loadingContainer: {
flex: 1,
@@ -1045,14 +1081,10 @@ function HostDetailModal({
? "rgba(248, 113, 113, 0.1)"
: "rgba(161, 161, 170, 0.1)";
const connectionBadge = (() => {
if (!activeConnection) return null;
if (activeConnection.type === "relay") {
return { icon: <Globe size={theme.iconSize.xs} color={theme.colors.foregroundMuted} />, text: "Relay" };
}
return {
icon: <Monitor size={theme.iconSize.xs} color={theme.colors.foregroundMuted} />,
text: activeConnection.display,
};
return formatActiveConnectionBadge({
activeConnection,
theme,
});
})();
const versionBadgeText = formatDaemonVersionBadge(daemonVersion);
const connectionError = typeof lastError === "string" && lastError.trim().length > 0 ? lastError.trim() : null;
@@ -1138,9 +1170,7 @@ function HostDetailModal({
latencyError={probe?.status === "unavailable"}
onRemove={() => {
const title =
conn.type === "relay"
? `Relay (${conn.relayEndpoint})`
: `Direct (${conn.endpoint})`;
formatHostConnectionLabel(conn);
setPendingRemoveConnection({ serverId: host.serverId, connectionId: conn.id, title });
}}
/>
@@ -1276,9 +1306,7 @@ function ConnectionRow({
}) {
const { theme } = useUnistyles();
const title =
connection.type === "relay"
? `Relay (${connection.relayEndpoint})`
: `Direct (${connection.endpoint})`;
formatHostConnectionLabel(connection);
const latencyText = (() => {
if (latencyLoading) return "...";
@@ -1363,14 +1391,10 @@ function DaemonCard({
? "rgba(248, 113, 113, 0.1)"
: "rgba(161, 161, 170, 0.1)";
const connectionBadge = (() => {
if (!activeConnection) return null;
if (activeConnection.type === "relay") {
return { icon: <Globe size={theme.iconSize.xs} color={theme.colors.foregroundMuted} />, text: "Relay" };
}
return {
icon: <Monitor size={theme.iconSize.xs} color={theme.colors.foregroundMuted} />,
text: activeConnection.display,
};
return formatActiveConnectionBadge({
activeConnection,
theme,
});
})();
const versionBadgeText = formatDaemonVersionBadge(daemonVersion);
+1 -1
View File
@@ -246,7 +246,7 @@ type DownloadTarget = {
function resolveDaemonDownloadTarget(daemon?: HostProfile): DownloadTarget {
const endpoint =
daemon?.connections.find((conn) => conn.type === "direct")?.endpoint ?? null;
daemon?.connections.find((conn) => conn.type === "directTcp")?.endpoint ?? null;
if (!endpoint) {
return { baseUrl: null, authHeader: null, authCredentials: null };
}
@@ -7,7 +7,7 @@ import {
} from "./connection-selection";
function makeDirect(id: string, endpoint: string): HostConnection {
return { id, type: "direct", endpoint };
return { id, type: "directTcp", endpoint };
}
function makeRelay(
@@ -0,0 +1,127 @@
import { describe, expect, it, vi, beforeEach } from "vitest";
const managedRuntimeMock = vi.hoisted(() => {
let eventHandler: ((payload: {
sessionId: string;
kind: "open" | "message" | "close" | "error";
text?: string | null;
binaryBase64?: string | null;
code?: number | null;
reason?: string | null;
error?: string | null;
}) => void) | null = null;
const openLocalTransportSession = vi.fn<(...args: unknown[]) => Promise<string>>();
const listenToLocalTransportEvents = vi.fn(async (handler: typeof eventHandler extends ((...args: infer A) => any) | null ? (...args: A) => void : never) => {
eventHandler = handler;
return () => {
eventHandler = null;
};
});
const sendLocalTransportMessage = vi.fn(async () => undefined);
const closeLocalTransportSession = vi.fn(async () => undefined);
return {
openLocalTransportSession,
listenToLocalTransportEvents,
sendLocalTransportMessage,
closeLocalTransportSession,
emitEvent(payload: {
sessionId: string;
kind: "open" | "message" | "close" | "error";
text?: string | null;
binaryBase64?: string | null;
code?: number | null;
reason?: string | null;
error?: string | null;
}) {
eventHandler?.(payload);
},
};
});
vi.mock("@/desktop/managed-runtime/managed-runtime", () => ({
openLocalTransportSession: managedRuntimeMock.openLocalTransportSession,
listenToLocalTransportEvents: managedRuntimeMock.listenToLocalTransportEvents,
sendLocalTransportMessage: managedRuntimeMock.sendLocalTransportMessage,
closeLocalTransportSession: managedRuntimeMock.closeLocalTransportSession,
}));
describe("managed-tauri-daemon-transport", () => {
beforeEach(() => {
managedRuntimeMock.openLocalTransportSession.mockReset();
managedRuntimeMock.listenToLocalTransportEvents.mockClear();
managedRuntimeMock.sendLocalTransportMessage.mockClear();
managedRuntimeMock.closeLocalTransportSession.mockClear();
});
it("emits open after the session resolves even if the rust open event raced earlier", async () => {
let resolveSession!: (sessionId: string) => void;
managedRuntimeMock.openLocalTransportSession.mockImplementation(
() =>
new Promise<string>((resolve) => {
resolveSession = resolve;
})
);
const mod = await import("./managed-tauri-daemon-transport");
const transportFactory = mod.createTauriLocalDaemonTransportFactory();
expect(transportFactory).not.toBeNull();
const transport = transportFactory!({
url: "paseo+local://socket?path=%2Ftmp%2Fpaseo.sock",
});
const onOpen = vi.fn();
transport.onOpen(onOpen);
managedRuntimeMock.emitEvent({
sessionId: "local-session-1",
kind: "open",
});
expect(onOpen).not.toHaveBeenCalled();
resolveSession("local-session-1");
await Promise.resolve();
expect(onOpen).toHaveBeenCalledTimes(1);
});
it("cleans up late async setup after the transport is closed", async () => {
let resolveSession!: (sessionId: string) => void;
let resolveListen!: (cleanup: () => void) => void;
const cleanup = vi.fn();
managedRuntimeMock.openLocalTransportSession.mockImplementation(
() =>
new Promise<string>((resolve) => {
resolveSession = resolve;
})
);
managedRuntimeMock.listenToLocalTransportEvents.mockImplementation(
() =>
new Promise<() => void>((resolve) => {
resolveListen = resolve;
})
);
const mod = await import("./managed-tauri-daemon-transport");
const transportFactory = mod.createTauriLocalDaemonTransportFactory();
expect(transportFactory).not.toBeNull();
const transport = transportFactory!({
url: "paseo+local://socket?path=%2Ftmp%2Fpaseo.sock",
});
transport.close();
resolveSession("local-session-2");
resolveListen(cleanup);
await Promise.resolve();
await Promise.resolve();
expect(managedRuntimeMock.closeLocalTransportSession).toHaveBeenCalledWith("local-session-2");
expect(cleanup).toHaveBeenCalledTimes(1);
});
});
@@ -0,0 +1,186 @@
import type { DaemonTransport, DaemonTransportFactory } from "@server/client/daemon-client";
import {
closeLocalTransportSession,
listenToLocalTransportEvents,
openLocalTransportSession,
sendLocalTransportMessage,
type LocalTransportTarget,
} from "@/desktop/managed-runtime/managed-runtime";
const LOCAL_TRANSPORT_SCHEME = "paseo+local:";
function encodeBinaryToBase64(data: Uint8Array | ArrayBuffer): string {
const bytes = data instanceof ArrayBuffer ? new Uint8Array(data) : data;
let binary = "";
for (let index = 0; index < bytes.length; index += 1) {
binary += String.fromCharCode(bytes[index]!);
}
return globalThis.btoa(binary);
}
function decodeBase64ToBytes(base64: string): Uint8Array {
const binary = globalThis.atob(base64);
const bytes = new Uint8Array(binary.length);
for (let index = 0; index < binary.length; index += 1) {
bytes[index] = binary.charCodeAt(index);
}
return bytes;
}
export function buildLocalDaemonTransportUrl(target: LocalTransportTarget): string {
const url = new URL(`${LOCAL_TRANSPORT_SCHEME}//${target.transportType}`);
url.searchParams.set("path", target.transportPath);
return url.toString();
}
function parseLocalDaemonTransportUrl(url: string): LocalTransportTarget {
const parsed = new URL(url);
if (parsed.protocol !== LOCAL_TRANSPORT_SCHEME) {
throw new Error(`Unsupported local transport URL: ${url}`);
}
const transportType = parsed.hostname;
const transportPath = parsed.searchParams.get("path")?.trim() ?? "";
if ((transportType !== "socket" && transportType !== "pipe") || !transportPath) {
throw new Error(`Invalid local transport target: ${url}`);
}
return {
transportType,
transportPath,
};
}
export function createTauriLocalDaemonTransportFactory(): DaemonTransportFactory | null {
return ({ url }) => {
const target = parseLocalDaemonTransportUrl(url);
let sessionId: string | null = null;
let unlisten: (() => void) | null = null;
let disposed = false;
let didEmitOpen = false;
const openHandlers = new Set<() => void>();
const closeHandlers = new Set<(event?: unknown) => void>();
const errorHandlers = new Set<(event?: unknown) => void>();
const messageHandlers = new Set<(data: unknown) => void>();
const emitOpen = () => {
if (didEmitOpen || disposed) {
return;
}
didEmitOpen = true;
for (const handler of openHandlers) {
handler();
}
};
const emitClose = (event?: unknown) => {
for (const handler of closeHandlers) {
handler(event);
}
};
const emitError = (event?: unknown) => {
for (const handler of errorHandlers) {
handler(event);
}
};
const emitMessage = (data: unknown) => {
for (const handler of messageHandlers) {
handler(data);
}
};
void listenToLocalTransportEvents((payload) => {
if (disposed || !sessionId || payload.sessionId !== sessionId) {
return;
}
if (payload.kind === "open") {
emitOpen();
return;
}
if (payload.kind === "message") {
if (payload.text) {
emitMessage({ data: payload.text });
return;
}
if (payload.binaryBase64) {
emitMessage({ data: decodeBase64ToBytes(payload.binaryBase64) });
}
return;
}
if (payload.kind === "close") {
emitClose(payload);
return;
}
emitError(payload.error ?? "Local daemon transport error");
})
.then((cleanup) => {
if (disposed) {
cleanup();
return;
}
unlisten = cleanup;
})
.catch((error) => {
emitError(error);
});
void openLocalTransportSession(target)
.then((id) => {
if (disposed) {
void closeLocalTransportSession(id).catch((error) => emitError(error));
return;
}
sessionId = id;
emitOpen();
})
.catch((error) => {
emitError(error);
});
const transport: DaemonTransport = {
send: (data) => {
if (!sessionId) {
return;
}
if (typeof data === "string") {
void sendLocalTransportMessage({ sessionId, text: data }).catch((error) =>
emitError(error)
);
return;
}
const binaryBase64 = encodeBinaryToBase64(
data instanceof ArrayBuffer ? data : new Uint8Array(data)
);
void sendLocalTransportMessage({ sessionId, binaryBase64 }).catch((error) =>
emitError(error)
);
},
close: () => {
disposed = true;
const currentSessionId = sessionId;
sessionId = null;
if (currentSessionId) {
void closeLocalTransportSession(currentSessionId).catch((error) => emitError(error));
}
unlisten?.();
unlisten = null;
},
onMessage: (handler) => {
messageHandlers.add(handler);
return () => messageHandlers.delete(handler);
},
onOpen: (handler) => {
openHandlers.add(handler);
return () => openHandlers.delete(handler);
},
onClose: (handler) => {
closeHandlers.add(handler);
return () => closeHandlers.delete(handler);
},
onError: (handler) => {
errorHandlers.add(handler);
return () => errorHandlers.delete(handler);
},
};
return transport;
};
}
@@ -71,12 +71,12 @@ describe("test-daemon-connection probe client identity", () => {
await mod.measureConnectionLatency({
id: "direct:lan:6767",
type: "direct",
type: "directTcp",
endpoint: "lan:6767",
});
await mod.measureConnectionLatency({
id: "direct:lan:6767",
type: "direct",
type: "directTcp",
endpoint: "lan:6767",
});
@@ -85,4 +85,18 @@ describe("test-daemon-connection probe client identity", () => {
expect(second?.clientId).toBe("cid_shared_probe_test");
expect(clientIdMock.getOrCreateClientId).toHaveBeenCalledTimes(2);
});
it("encodes the local socket target into the probe client config", async () => {
const mod = await import("./test-daemon-connection");
await mod.measureConnectionLatency({
id: "socket:/tmp/paseo.sock",
type: "directSocket",
path: "/tmp/paseo.sock",
});
expect(daemonClientMock.createdConfigs[0]?.url).toBe(
"paseo+local://socket?path=%2Ftmp%2Fpaseo.sock"
);
});
});
@@ -3,6 +3,10 @@ import type { DaemonClientConfig } from "@server/client/daemon-client";
import type { HostConnection } from "@/contexts/daemon-registry-context";
import { getOrCreateClientId } from "./client-id";
import { buildDaemonWebSocketUrl, buildRelayWebSocketUrl } from "./daemon-endpoints";
import {
buildLocalDaemonTransportUrl,
createTauriLocalDaemonTransportFactory,
} from "./managed-tauri-daemon-transport";
import { createTauriWebSocketTransportFactory } from "./tauri-daemon-transport";
function normalizeNonEmptyString(value: unknown): string | null {
@@ -47,14 +51,31 @@ async function buildClientConfig(
): Promise<DaemonClientConfig> {
const clientId = await getOrCreateClientId();
const tauriTransportFactory = createTauriWebSocketTransportFactory();
const localTransportFactory = createTauriLocalDaemonTransportFactory();
const base = {
clientId,
clientType: "mobile" as const,
suppressSendErrors: true,
...(tauriTransportFactory ? { transportFactory: tauriTransportFactory } : {}),
...(connection.type === "directSocket" || connection.type === "directPipe"
? localTransportFactory
? { transportFactory: localTransportFactory }
: {}
: tauriTransportFactory
? { transportFactory: tauriTransportFactory }
: {}),
};
if (connection.type === "direct") {
if (connection.type === "directSocket" || connection.type === "directPipe") {
return {
...base,
url: buildLocalDaemonTransportUrl({
transportType: connection.type === "directSocket" ? "socket" : "pipe",
transportPath: connection.path,
}),
};
}
if (connection.type === "directTcp") {
return {
...base,
url: buildDaemonWebSocketUrl(connection.endpoint),