mirror of
https://github.com/getpaseo/paseo.git
synced 2026-07-29 12:01:31 +00:00
Add managed desktop daemon runtime support
This commit is contained in:
198
.github/workflows/desktop-release.yml
vendored
198
.github/workflows/desktop-release.yml
vendored
@@ -3,12 +3,12 @@ name: Desktop Release
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'v*'
|
||||
- 'desktop-v*'
|
||||
- "v*"
|
||||
- "desktop-v*"
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
tag:
|
||||
description: 'Existing tag to build (e.g. v0.1.0)'
|
||||
description: "Existing tag to build (e.g. v0.1.0)"
|
||||
required: true
|
||||
type: string
|
||||
|
||||
@@ -16,14 +16,15 @@ concurrency:
|
||||
group: desktop-release-${{ github.ref }}
|
||||
cancel-in-progress: false
|
||||
|
||||
env:
|
||||
RELEASE_TAG: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.tag || github.ref_name }}
|
||||
|
||||
jobs:
|
||||
publish-tauri:
|
||||
publish-macos:
|
||||
permissions:
|
||||
contents: write
|
||||
packages: read
|
||||
runs-on: macos-latest
|
||||
env:
|
||||
RELEASE_TAG: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.tag || github.ref_name }}
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
@@ -34,10 +35,10 @@ jobs:
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '22'
|
||||
cache: 'npm'
|
||||
registry-url: 'https://npm.pkg.github.com'
|
||||
scope: '@boudra'
|
||||
node-version: "22"
|
||||
cache: "npm"
|
||||
registry-url: "https://npm.pkg.github.com"
|
||||
scope: "@boudra"
|
||||
|
||||
- name: Install Rust stable
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
@@ -91,7 +92,7 @@ jobs:
|
||||
fs.writeFileSync(cargoTomlPath, `${nextLines.join('\n')}\n`);
|
||||
NODE
|
||||
|
||||
- name: Build and publish Tauri release
|
||||
- name: Build and publish macOS Tauri release
|
||||
uses: tauri-apps/tauri-action@v0
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
@@ -111,3 +112,178 @@ jobs:
|
||||
releaseDraft: false
|
||||
prerelease: false
|
||||
args: --target universal-apple-darwin
|
||||
|
||||
publish-linux:
|
||||
permissions:
|
||||
contents: write
|
||||
packages: read
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
ref: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.tag || github.ref }}
|
||||
|
||||
- name: Install Linux packaging dependencies
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y libwebkit2gtk-4.1-dev libgtk-3-dev libappindicator3-dev librsvg2-dev patchelf
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "22"
|
||||
cache: "npm"
|
||||
registry-url: "https://npm.pkg.github.com"
|
||||
scope: "@boudra"
|
||||
|
||||
- name: Install Rust stable
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
|
||||
- name: Install JS dependencies
|
||||
run: npm ci
|
||||
env:
|
||||
NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Build web app for Tauri
|
||||
run: npm run build:web --workspace=@getpaseo/app
|
||||
|
||||
- name: Set desktop version from tag
|
||||
shell: bash
|
||||
run: |
|
||||
node <<'NODE'
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
const rawTag = process.env.RELEASE_TAG;
|
||||
if (!rawTag) throw new Error('RELEASE_TAG env var is missing');
|
||||
|
||||
const version = rawTag.replace(/^desktop-/, '').replace(/^v/, '');
|
||||
console.log(`Using desktop version ${version} from tag ${rawTag}`);
|
||||
|
||||
const tauriConfPath = path.join('packages', 'desktop', 'src-tauri', 'tauri.conf.json');
|
||||
const tauriConfText = fs.readFileSync(tauriConfPath, 'utf8');
|
||||
const tauriRe = /("version"\s*:\s*")([^"]+)(")/;
|
||||
if (!tauriRe.test(tauriConfText)) {
|
||||
throw new Error(`Failed to find version field in ${tauriConfPath}`);
|
||||
}
|
||||
fs.writeFileSync(tauriConfPath, tauriConfText.replace(tauriRe, `$1${version}$3`));
|
||||
|
||||
const cargoTomlPath = path.join('packages', 'desktop', 'src-tauri', 'Cargo.toml');
|
||||
const cargoLines = fs.readFileSync(cargoTomlPath, 'utf8').split(/\r?\n/);
|
||||
let inPackage = false;
|
||||
let updated = false;
|
||||
const nextLines = cargoLines.map((line) => {
|
||||
if (/^\[package\]\s*$/.test(line)) inPackage = true;
|
||||
else if (inPackage && /^\[/.test(line)) inPackage = false;
|
||||
|
||||
if (inPackage && /^version\s*=\s*".*"\s*$/.test(line)) {
|
||||
updated = true;
|
||||
return `version = "${version}"`;
|
||||
}
|
||||
return line;
|
||||
});
|
||||
if (!updated) throw new Error(`Failed to update Cargo package version in ${cargoTomlPath}`);
|
||||
fs.writeFileSync(cargoTomlPath, `${nextLines.join('\n')}\n`);
|
||||
NODE
|
||||
|
||||
- name: Build and publish Linux Tauri release
|
||||
uses: tauri-apps/tauri-action@v0
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
|
||||
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
|
||||
with:
|
||||
projectPath: packages/desktop
|
||||
tagName: ${{ env.RELEASE_TAG }}
|
||||
releaseName: Paseo ${{ env.RELEASE_TAG }}
|
||||
releaseBody: See the assets to download and install this version.
|
||||
releaseDraft: false
|
||||
prerelease: false
|
||||
args: --bundles appimage
|
||||
|
||||
publish-windows:
|
||||
permissions:
|
||||
contents: write
|
||||
packages: read
|
||||
runs-on: windows-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
ref: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.tag || github.ref }}
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "22"
|
||||
cache: "npm"
|
||||
registry-url: "https://npm.pkg.github.com"
|
||||
scope: "@boudra"
|
||||
|
||||
- name: Install Rust stable
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
|
||||
- name: Install JS dependencies
|
||||
run: npm ci
|
||||
env:
|
||||
NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Build web app for Tauri
|
||||
run: npm run build:web --workspace=@getpaseo/app
|
||||
|
||||
- name: Set desktop version from tag
|
||||
shell: bash
|
||||
run: |
|
||||
node <<'NODE'
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
const rawTag = process.env.RELEASE_TAG;
|
||||
if (!rawTag) throw new Error('RELEASE_TAG env var is missing');
|
||||
|
||||
const version = rawTag.replace(/^desktop-/, '').replace(/^v/, '');
|
||||
console.log(`Using desktop version ${version} from tag ${rawTag}`);
|
||||
|
||||
const tauriConfPath = path.join('packages', 'desktop', 'src-tauri', 'tauri.conf.json');
|
||||
const tauriConfText = fs.readFileSync(tauriConfPath, 'utf8');
|
||||
const tauriRe = /("version"\s*:\s*")([^"]+)(")/;
|
||||
if (!tauriRe.test(tauriConfText)) {
|
||||
throw new Error(`Failed to find version field in ${tauriConfPath}`);
|
||||
}
|
||||
fs.writeFileSync(tauriConfPath, tauriConfText.replace(tauriRe, `$1${version}$3`));
|
||||
|
||||
const cargoTomlPath = path.join('packages', 'desktop', 'src-tauri', 'Cargo.toml');
|
||||
const cargoLines = fs.readFileSync(cargoTomlPath, 'utf8').split(/\r?\n/);
|
||||
let inPackage = false;
|
||||
let updated = false;
|
||||
const nextLines = cargoLines.map((line) => {
|
||||
if (/^\[package\]\s*$/.test(line)) inPackage = true;
|
||||
else if (inPackage && /^\[/.test(line)) inPackage = false;
|
||||
|
||||
if (inPackage && /^version\s*=\s*".*"\s*$/.test(line)) {
|
||||
updated = true;
|
||||
return `version = "${version}"`;
|
||||
}
|
||||
return line;
|
||||
});
|
||||
if (!updated) throw new Error(`Failed to update Cargo package version in ${cargoTomlPath}`);
|
||||
fs.writeFileSync(cargoTomlPath, `${nextLines.join('\n')}\n`);
|
||||
NODE
|
||||
|
||||
- name: Build and publish Windows Tauri release
|
||||
uses: tauri-apps/tauri-action@v0
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
|
||||
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
|
||||
with:
|
||||
projectPath: packages/desktop
|
||||
tagName: ${{ env.RELEASE_TAG }}
|
||||
releaseName: Paseo ${{ env.RELEASE_TAG }}
|
||||
releaseBody: See the assets to download and install this version.
|
||||
releaseDraft: false
|
||||
prerelease: false
|
||||
args: --bundles nsis,msi
|
||||
|
||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -76,3 +76,5 @@ packages/server/src/server/fixtures/dictation/dictation-debug-largest.wav
|
||||
packages/server/src/server/fixtures/dictation/dictation-debug-largest.transcript.txt
|
||||
|
||||
/artifacts
|
||||
packages/desktop/.cache/
|
||||
packages/desktop/src-tauri/resources/managed-runtime/
|
||||
|
||||
@@ -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],
|
||||
},
|
||||
}));
|
||||
|
||||
262
packages/app/src/desktop/managed-runtime/managed-runtime.ts
Normal file
262
packages/app/src/desktop/managed-runtime/managed-runtime.ts
Normal file
@@ -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 });
|
||||
}
|
||||
@@ -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",
|
||||
},
|
||||
],
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
127
packages/app/src/utils/managed-tauri-daemon-transport.test.ts
Normal file
127
packages/app/src/utils/managed-tauri-daemon-transport.test.ts
Normal file
@@ -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);
|
||||
});
|
||||
});
|
||||
186
packages/app/src/utils/managed-tauri-daemon-transport.ts
Normal file
186
packages/app/src/utils/managed-tauri-daemon-transport.ts
Normal file
@@ -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),
|
||||
|
||||
@@ -289,7 +289,12 @@ export function resolveTcpHostFromListen(listen: string): string | null {
|
||||
return null
|
||||
}
|
||||
|
||||
if (normalized.startsWith('/') || normalized.startsWith('unix://')) {
|
||||
if (
|
||||
normalized.startsWith('/') ||
|
||||
normalized.startsWith('unix://') ||
|
||||
normalized.startsWith('pipe://') ||
|
||||
normalized.startsWith('\\\\.\\pipe\\')
|
||||
) {
|
||||
return null
|
||||
}
|
||||
|
||||
|
||||
@@ -4,14 +4,16 @@ import { generateLocalPairingOffer, loadConfig, resolvePaseoHome } from '@getpas
|
||||
|
||||
interface PairOptions {
|
||||
home?: string
|
||||
json?: boolean
|
||||
}
|
||||
|
||||
export function pairCommand(): Command {
|
||||
return new Command('pair')
|
||||
.description('Print the daemon pairing QR code and link')
|
||||
.option('--json', 'Output in JSON format')
|
||||
.option('--home <path>', 'Paseo home directory (default: ~/.paseo)')
|
||||
.action(async (options: PairOptions) => {
|
||||
await runPairCommand(options)
|
||||
.action(async (_options: PairOptions, command: Command) => {
|
||||
await runPairCommand(command.optsWithGlobals() as PairOptions)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -37,6 +39,21 @@ export async function runPairCommand(options: PairOptions): Promise<void> {
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
if (options.json) {
|
||||
process.stdout.write(
|
||||
`${JSON.stringify(
|
||||
{
|
||||
relayEnabled: pairing.relayEnabled,
|
||||
url: pairing.url,
|
||||
qr: pairing.qr,
|
||||
},
|
||||
null,
|
||||
2
|
||||
)}\n`
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
const qrBlock = pairing.qr ? `${pairing.qr}\n` : ''
|
||||
process.stdout.write(`\nScan to pair:\n${qrBlock}${pairing.url}\n`)
|
||||
}
|
||||
|
||||
@@ -10,6 +10,17 @@ export interface ConnectOptions {
|
||||
const DEFAULT_HOST = 'localhost:6767'
|
||||
const DEFAULT_TIMEOUT = 5000
|
||||
|
||||
type DaemonTarget =
|
||||
| {
|
||||
type: 'tcp'
|
||||
url: string
|
||||
}
|
||||
| {
|
||||
type: 'ipc'
|
||||
url: string
|
||||
socketPath: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the daemon host from environment or options
|
||||
*/
|
||||
@@ -17,12 +28,49 @@ export function getDaemonHost(options?: ConnectOptions): string {
|
||||
return options?.host ?? process.env.PASEO_HOST ?? DEFAULT_HOST
|
||||
}
|
||||
|
||||
export function resolveDaemonTarget(host: string): DaemonTarget {
|
||||
const trimmed = host.trim()
|
||||
if (
|
||||
trimmed.startsWith('unix://') ||
|
||||
trimmed.startsWith('pipe://') ||
|
||||
trimmed.startsWith('\\\\.\\pipe\\')
|
||||
) {
|
||||
const socketPath = trimmed.startsWith('unix://')
|
||||
? trimmed.slice('unix://'.length).trim()
|
||||
: trimmed.startsWith('pipe://')
|
||||
? trimmed.slice('pipe://'.length).trim()
|
||||
: trimmed
|
||||
if (!socketPath) {
|
||||
throw new Error('Invalid IPC daemon target: missing socket path')
|
||||
}
|
||||
const isUnixSocket = trimmed.startsWith('unix://')
|
||||
return {
|
||||
type: 'ipc',
|
||||
url: isUnixSocket
|
||||
? `ws+unix://${socketPath}:/ws`
|
||||
: 'ws://localhost/ws',
|
||||
socketPath,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
type: 'tcp',
|
||||
url: `ws://${trimmed}/ws`,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a WebSocket factory that works in Node.js
|
||||
*/
|
||||
function createNodeWebSocketFactory() {
|
||||
return (url: string, options?: { headers?: Record<string, string> }) => {
|
||||
return new WebSocket(url, { headers: options?.headers }) as unknown as {
|
||||
return (
|
||||
url: string,
|
||||
options?: { headers?: Record<string, string>; socketPath?: string }
|
||||
) => {
|
||||
return new WebSocket(url, {
|
||||
headers: options?.headers,
|
||||
...(options?.socketPath ? { socketPath: options.socketPath } : {}),
|
||||
}) as unknown as {
|
||||
readyState: number
|
||||
send: (data: string | Uint8Array | ArrayBuffer) => void
|
||||
close: (code?: number, reason?: string) => void
|
||||
@@ -41,14 +89,19 @@ export async function connectToDaemon(options?: ConnectOptions): Promise<DaemonC
|
||||
const host = getDaemonHost(options)
|
||||
const timeout = options?.timeout ?? DEFAULT_TIMEOUT
|
||||
const clientId = await getOrCreateCliClientId()
|
||||
const url = `ws://${host}/ws`
|
||||
const target = resolveDaemonTarget(host)
|
||||
const nodeWebSocketFactory = createNodeWebSocketFactory()
|
||||
|
||||
const client = new DaemonClient(
|
||||
{
|
||||
url,
|
||||
url: target.url,
|
||||
clientId,
|
||||
clientType: 'cli',
|
||||
webSocketFactory: createNodeWebSocketFactory(),
|
||||
webSocketFactory: (url: string, config?: { headers?: Record<string, string> }) =>
|
||||
nodeWebSocketFactory(url, {
|
||||
headers: config?.headers,
|
||||
...(target.type === 'ipc' ? { socketPath: target.socketPath } : {}),
|
||||
}),
|
||||
reconnect: { enabled: false },
|
||||
} as unknown as ConstructorParameters<typeof DaemonClient>[0]
|
||||
)
|
||||
|
||||
@@ -65,9 +65,22 @@ try {
|
||||
console.log('✓ daemon status reports stopped when not running\n')
|
||||
}
|
||||
|
||||
// Test 4: daemon status --json outputs valid JSON
|
||||
// Test 4: daemon pair --json outputs valid JSON
|
||||
{
|
||||
console.log('Test 4: daemon status --json outputs JSON')
|
||||
console.log('Test 4: daemon pair --json outputs JSON')
|
||||
const result =
|
||||
await $`PASEO_HOME=${paseoHome} npx paseo daemon pair --json`.nothrow()
|
||||
assert.strictEqual(result.exitCode, 0, 'daemon pair --json should succeed')
|
||||
const pairing = JSON.parse(result.stdout)
|
||||
assert.strictEqual(pairing.relayEnabled, true, 'pairing should report relay enabled')
|
||||
assert.match(pairing.url, /#offer=/, 'pairing URL should include offer fragment')
|
||||
assert.strictEqual(typeof pairing.qr, 'string', 'pairing should include QR content')
|
||||
console.log('✓ daemon pair --json outputs valid JSON\n')
|
||||
}
|
||||
|
||||
// Test 5: daemon status --json outputs valid JSON
|
||||
{
|
||||
console.log('Test 5: daemon status --json outputs JSON')
|
||||
const result =
|
||||
await $`PASEO_HOME=${paseoHome} npx paseo daemon status --json`.nothrow()
|
||||
assert.strictEqual(result.exitCode, 0, '--json status should succeed')
|
||||
@@ -77,9 +90,9 @@ try {
|
||||
console.log('✓ daemon status --json outputs valid JSON\n')
|
||||
}
|
||||
|
||||
// Test 5: daemon stop handles daemon not running gracefully
|
||||
// Test 6: daemon stop handles daemon not running gracefully
|
||||
{
|
||||
console.log('Test 5: daemon stop handles daemon not running')
|
||||
console.log('Test 6: daemon stop handles daemon not running')
|
||||
const result =
|
||||
await $`PASEO_HOME=${paseoHome} npx paseo daemon stop`.nothrow()
|
||||
// Stop should succeed even if daemon is not running (idempotent).
|
||||
@@ -92,9 +105,9 @@ try {
|
||||
console.log('✓ daemon stop succeeds gracefully when daemon not running\n')
|
||||
}
|
||||
|
||||
// Test 6: daemon restart starts daemon and can be stopped
|
||||
// Test 7: daemon restart starts daemon and can be stopped
|
||||
{
|
||||
console.log('Test 6: daemon restart starts daemon and can be stopped')
|
||||
console.log('Test 7: daemon restart starts daemon and can be stopped')
|
||||
const result =
|
||||
await $`PASEO_HOME=${paseoHome} npx paseo daemon restart --port ${String(port)}`.nothrow()
|
||||
assert.strictEqual(result.exitCode, 0, 'restart should succeed even when previously stopped')
|
||||
|
||||
@@ -29,6 +29,8 @@ console.log('=== Local Daemon Utility Helpers ===\n')
|
||||
console.log('Test 3: rejects unix socket listen values')
|
||||
assert.strictEqual(resolveTcpHostFromListen('/tmp/paseo.sock'), null)
|
||||
assert.strictEqual(resolveTcpHostFromListen('unix:///tmp/paseo.sock'), null)
|
||||
assert.strictEqual(resolveTcpHostFromListen('pipe://\\\\.\\pipe\\paseo-managed-test'), null)
|
||||
assert.strictEqual(resolveTcpHostFromListen('\\\\.\\pipe\\paseo-managed-test'), null)
|
||||
console.log('✓ rejects unix socket listen values\n')
|
||||
}
|
||||
|
||||
|
||||
30
packages/cli/tests/28-client-ipc-targets.test.ts
Normal file
30
packages/cli/tests/28-client-ipc-targets.test.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
#!/usr/bin/env npx tsx
|
||||
|
||||
import assert from 'node:assert'
|
||||
import { resolveDaemonTarget } from '../src/utils/client.js'
|
||||
|
||||
console.log('=== CLI IPC Target Helpers ===\n')
|
||||
|
||||
{
|
||||
console.log('Test 1: unix hosts resolve to ws+unix URLs')
|
||||
const target = resolveDaemonTarget('unix:///tmp/paseo.sock')
|
||||
assert.deepStrictEqual(target, {
|
||||
type: 'ipc',
|
||||
url: 'ws+unix:///tmp/paseo.sock:/ws',
|
||||
socketPath: '/tmp/paseo.sock',
|
||||
})
|
||||
console.log('✓ unix hosts resolve to ws+unix URLs\n')
|
||||
}
|
||||
|
||||
{
|
||||
console.log('Test 2: pipe hosts preserve the Node socketPath transport form')
|
||||
const target = resolveDaemonTarget('pipe://\\\\.\\pipe\\paseo-managed-test')
|
||||
assert.deepStrictEqual(target, {
|
||||
type: 'ipc',
|
||||
url: 'ws://localhost/ws',
|
||||
socketPath: '\\\\.\\pipe\\paseo-managed-test',
|
||||
})
|
||||
console.log('✓ pipe hosts preserve Node socketPath transport form\n')
|
||||
}
|
||||
|
||||
console.log('=== All CLI IPC target tests passed ===')
|
||||
@@ -4,8 +4,11 @@
|
||||
"private": true,
|
||||
"description": "Paseo desktop app (Tauri wrapper)",
|
||||
"scripts": {
|
||||
"dev": "tauri dev",
|
||||
"build": "tauri build",
|
||||
"build:managed-runtime": "node ./scripts/build-managed-runtime.mjs",
|
||||
"prepare:managed-runtime": "npm --prefix ../.. run build:daemon && npm run build:managed-runtime",
|
||||
"dev": "npm run prepare:managed-runtime && tauri dev",
|
||||
"build": "npm --prefix ../.. run build:web --workspace=@getpaseo/app && npm run prepare:managed-runtime && tauri build",
|
||||
"smoke:managed-daemon": "node ./scripts/managed-daemon-smoke.mjs",
|
||||
"tauri": "tauri"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
383
packages/desktop/scripts/build-managed-runtime.mjs
Normal file
383
packages/desktop/scripts/build-managed-runtime.mjs
Normal file
@@ -0,0 +1,383 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import process from "node:process";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const repoRoot = path.resolve(fileURLToPath(new URL("../../..", import.meta.url)));
|
||||
const desktopRoot = path.join(repoRoot, "packages", "desktop");
|
||||
const resourcesRoot = path.join(desktopRoot, "src-tauri", "resources", "managed-runtime");
|
||||
const cacheRoot = path.join(desktopRoot, ".cache", "managed-runtime");
|
||||
const packageVersion = JSON.parse(
|
||||
await fs.readFile(path.join(desktopRoot, "package.json"), "utf8")
|
||||
).version;
|
||||
|
||||
const workspaces = [
|
||||
{ name: "@getpaseo/relay", root: path.join(repoRoot, "packages", "relay") },
|
||||
{ name: "@getpaseo/server", root: path.join(repoRoot, "packages", "server") },
|
||||
{ name: "@getpaseo/cli", root: path.join(repoRoot, "packages", "cli") },
|
||||
];
|
||||
|
||||
async function rmSafe(target) {
|
||||
await fs.rm(target, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
async function ensureDir(target) {
|
||||
await fs.mkdir(target, { recursive: true });
|
||||
}
|
||||
|
||||
async function copyDir(source, target) {
|
||||
await ensureDir(path.dirname(target));
|
||||
await fs.cp(source, target, { recursive: true, dereference: true, force: true });
|
||||
}
|
||||
|
||||
async function copyFile(source, target, mode) {
|
||||
await ensureDir(path.dirname(target));
|
||||
await fs.copyFile(source, target);
|
||||
if (mode != null) {
|
||||
await fs.chmod(target, mode);
|
||||
}
|
||||
}
|
||||
|
||||
async function pathExists(target) {
|
||||
try {
|
||||
await fs.access(target);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function readToolVersions() {
|
||||
const raw = await fs.readFile(path.join(repoRoot, ".tool-versions"), "utf8");
|
||||
const entries = new Map();
|
||||
for (const line of raw.split(/\r?\n/)) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed || trimmed.startsWith("#")) {
|
||||
continue;
|
||||
}
|
||||
const [tool, ...rest] = trimmed.split(/\s+/);
|
||||
if (!tool || rest.length === 0) {
|
||||
continue;
|
||||
}
|
||||
entries.set(tool, rest.join(" "));
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
function resolveNodeVersion(toolVersions) {
|
||||
const raw = toolVersions.get("nodejs");
|
||||
if (!raw) {
|
||||
throw new Error("Missing nodejs entry in .tool-versions.");
|
||||
}
|
||||
const version = raw.trim().replace(/^v/, "");
|
||||
if (!/^\d+\.\d+\.\d+$/.test(version)) {
|
||||
throw new Error(`Unsupported nodejs version in .tool-versions: ${raw}`);
|
||||
}
|
||||
return version;
|
||||
}
|
||||
|
||||
function resolveNodeArtifact(version) {
|
||||
const platformMap = {
|
||||
darwin: "darwin",
|
||||
linux: "linux",
|
||||
win32: "win",
|
||||
};
|
||||
const archMap = {
|
||||
arm64: "arm64",
|
||||
x64: "x64",
|
||||
};
|
||||
const platform = platformMap[process.platform];
|
||||
const arch = archMap[process.arch];
|
||||
if (!platform || !arch) {
|
||||
throw new Error(`Managed runtime is not implemented for ${process.platform}-${process.arch}.`);
|
||||
}
|
||||
const baseName = `node-v${version}-${platform}-${arch}`;
|
||||
const extension = process.platform === "win32" ? "zip" : process.platform === "linux" ? "tar.xz" : "tar.gz";
|
||||
return {
|
||||
version,
|
||||
baseName,
|
||||
archiveName: `${baseName}.${extension}`,
|
||||
extension,
|
||||
downloadUrl: `https://nodejs.org/dist/v${version}/${baseName}.${extension}`,
|
||||
checksumsUrl: `https://nodejs.org/dist/v${version}/SHASUMS256.txt`,
|
||||
};
|
||||
}
|
||||
|
||||
async function downloadFile(url, target) {
|
||||
await ensureDir(path.dirname(target));
|
||||
runCommand("curl", [
|
||||
"--fail",
|
||||
"--location",
|
||||
"--retry",
|
||||
"3",
|
||||
"--silent",
|
||||
"--show-error",
|
||||
"--output",
|
||||
target,
|
||||
url,
|
||||
]);
|
||||
}
|
||||
|
||||
async function sha256File(target) {
|
||||
const hash = createHash("sha256");
|
||||
hash.update(await fs.readFile(target));
|
||||
return hash.digest("hex");
|
||||
}
|
||||
|
||||
async function computeRuntimeContentHash(input) {
|
||||
const hash = createHash("sha256");
|
||||
hash.update(`packageVersion:${packageVersion}\n`);
|
||||
hash.update(`nodeVersion:${input.nodeVersion}\n`);
|
||||
hash.update(`platform:${process.platform}\n`);
|
||||
hash.update(`arch:${process.arch}\n`);
|
||||
for (const tarball of input.tarballs) {
|
||||
hash.update(`workspace:${tarball.name}\n`);
|
||||
hash.update(`filename:${path.basename(tarball.path)}\n`);
|
||||
hash.update(`sha256:${await sha256File(tarball.path)}\n`);
|
||||
}
|
||||
return hash.digest("hex").slice(0, 12);
|
||||
}
|
||||
|
||||
async function ensureNodeArchive(nodeArtifact) {
|
||||
const versionCacheRoot = path.join(cacheRoot, `node-v${nodeArtifact.version}`);
|
||||
const archivePath = path.join(versionCacheRoot, nodeArtifact.archiveName);
|
||||
const checksumsPath = path.join(versionCacheRoot, "SHASUMS256.txt");
|
||||
if (!(await pathExists(archivePath))) {
|
||||
await downloadFile(nodeArtifact.downloadUrl, archivePath);
|
||||
}
|
||||
if (!(await pathExists(checksumsPath))) {
|
||||
await downloadFile(nodeArtifact.checksumsUrl, checksumsPath);
|
||||
}
|
||||
const checksums = await fs.readFile(checksumsPath, "utf8");
|
||||
const expectedLine = checksums
|
||||
.split(/\r?\n/)
|
||||
.map((line) => line.trim())
|
||||
.find((line) => line.endsWith(` ${nodeArtifact.archiveName}`));
|
||||
if (!expectedLine) {
|
||||
throw new Error(`Missing checksum for ${nodeArtifact.archiveName} in SHASUMS256.txt`);
|
||||
}
|
||||
const expectedSha = expectedLine.split(/\s+/)[0];
|
||||
const actualSha = await sha256File(archivePath);
|
||||
if (actualSha !== expectedSha) {
|
||||
throw new Error(
|
||||
`Checksum mismatch for ${nodeArtifact.archiveName}: expected ${expectedSha}, got ${actualSha}`
|
||||
);
|
||||
}
|
||||
return archivePath;
|
||||
}
|
||||
|
||||
function runCommand(command, args, options = {}) {
|
||||
const result = spawnSync(command, args, {
|
||||
stdio: "pipe",
|
||||
encoding: "utf8",
|
||||
...options,
|
||||
});
|
||||
if (result.status !== 0) {
|
||||
throw new Error(
|
||||
[
|
||||
`Command failed: ${command} ${args.join(" ")}`,
|
||||
result.stdout?.trim(),
|
||||
result.stderr?.trim(),
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join("\n")
|
||||
);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
async function extractNodeDistribution(archivePath, nodeArtifact) {
|
||||
const extractionRoot = await fs.mkdtemp(path.join(os.tmpdir(), "paseo-managed-runtime-node-"));
|
||||
if (nodeArtifact.extension === "zip") {
|
||||
runCommand("powershell", [
|
||||
"-NoProfile",
|
||||
"-Command",
|
||||
`Expand-Archive -LiteralPath '${archivePath.replace(/'/g, "''")}' -DestinationPath '${extractionRoot.replace(/'/g, "''")}' -Force`,
|
||||
]);
|
||||
} else {
|
||||
const tarFlag = nodeArtifact.extension === "tar.xz" ? "-xJf" : "-xzf";
|
||||
runCommand("tar", [tarFlag, archivePath, "-C", extractionRoot]);
|
||||
}
|
||||
return {
|
||||
extractionRoot,
|
||||
extractedRoot: path.join(extractionRoot, nodeArtifact.baseName),
|
||||
};
|
||||
}
|
||||
|
||||
async function ensureWorkspaceBuilds() {
|
||||
const requiredPaths = [
|
||||
path.join(repoRoot, "packages", "relay", "dist"),
|
||||
path.join(repoRoot, "packages", "server", "dist"),
|
||||
path.join(repoRoot, "packages", "cli", "dist"),
|
||||
];
|
||||
for (const requiredPath of requiredPaths) {
|
||||
if (!(await pathExists(requiredPath))) {
|
||||
throw new Error(
|
||||
`Managed runtime build is missing required path: ${requiredPath}. Run the daemon build first.`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function packWorkspace(packageRoot, tarballRoot) {
|
||||
await ensureDir(tarballRoot);
|
||||
const result = runCommand("npm", ["pack", "--json", "--pack-destination", tarballRoot], {
|
||||
cwd: packageRoot,
|
||||
});
|
||||
const [{ filename }] = JSON.parse(result.stdout.trim());
|
||||
if (!filename) {
|
||||
throw new Error(`npm pack did not produce a filename for ${packageRoot}`);
|
||||
}
|
||||
return path.join(tarballRoot, filename);
|
||||
}
|
||||
|
||||
async function installPackedWorkspaces(runtimeRoot, bundledNodeRoot, tarballs) {
|
||||
const nodeExecutable = process.platform === "win32"
|
||||
? path.join(bundledNodeRoot, "node.exe")
|
||||
: path.join(bundledNodeRoot, "bin", "node");
|
||||
const npmCli = process.platform === "win32"
|
||||
? path.join(bundledNodeRoot, "node_modules", "npm", "bin", "npm-cli.js")
|
||||
: path.join(bundledNodeRoot, "lib", "node_modules", "npm", "bin", "npm-cli.js");
|
||||
const install = spawnSync(
|
||||
nodeExecutable,
|
||||
[
|
||||
npmCli,
|
||||
"install",
|
||||
"--omit=dev",
|
||||
"--no-package-lock",
|
||||
"--no-save",
|
||||
...tarballs,
|
||||
],
|
||||
{
|
||||
cwd: runtimeRoot,
|
||||
stdio: "inherit",
|
||||
env: {
|
||||
...process.env,
|
||||
npm_config_audit: "false",
|
||||
npm_config_fund: "false",
|
||||
},
|
||||
}
|
||||
);
|
||||
if (install.status !== 0) {
|
||||
throw new Error(
|
||||
`Managed runtime dependency install failed with exit code ${install.status ?? 1}.`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function writeRuntimePackageJson(runtimeRoot) {
|
||||
await fs.writeFile(
|
||||
path.join(runtimeRoot, "package.json"),
|
||||
JSON.stringify(
|
||||
{
|
||||
name: "paseo-managed-runtime",
|
||||
private: true,
|
||||
version: packageVersion,
|
||||
},
|
||||
null,
|
||||
2
|
||||
) + "\n",
|
||||
"utf8"
|
||||
);
|
||||
}
|
||||
|
||||
async function main() {
|
||||
await ensureWorkspaceBuilds();
|
||||
|
||||
const toolVersions = await readToolVersions();
|
||||
const nodeVersion = resolveNodeVersion(toolVersions);
|
||||
const nodeArtifact = resolveNodeArtifact(nodeVersion);
|
||||
|
||||
await rmSafe(resourcesRoot);
|
||||
await ensureDir(resourcesRoot);
|
||||
|
||||
const archivePath = await ensureNodeArchive(nodeArtifact);
|
||||
const { extractionRoot, extractedRoot } = await extractNodeDistribution(archivePath, nodeArtifact);
|
||||
const tarballRoot = await fs.mkdtemp(path.join(os.tmpdir(), "paseo-managed-runtime-pack-"));
|
||||
|
||||
try {
|
||||
const tarballs = [];
|
||||
for (const workspace of workspaces) {
|
||||
tarballs.push({
|
||||
name: workspace.name,
|
||||
path: await packWorkspace(workspace.root, tarballRoot),
|
||||
});
|
||||
}
|
||||
const runtimeContentHash = await computeRuntimeContentHash({
|
||||
nodeVersion,
|
||||
tarballs,
|
||||
});
|
||||
const runtimeId =
|
||||
`${packageVersion}-node-${nodeVersion}-${process.platform}-${process.arch}-${runtimeContentHash}`;
|
||||
const runtimeRoot = path.join(resourcesRoot, runtimeId);
|
||||
|
||||
await ensureDir(runtimeRoot);
|
||||
await copyDir(extractedRoot, path.join(runtimeRoot, "node"));
|
||||
await writeRuntimePackageJson(runtimeRoot);
|
||||
await installPackedWorkspaces(
|
||||
runtimeRoot,
|
||||
path.join(runtimeRoot, "node"),
|
||||
tarballs.map((entry) => entry.path)
|
||||
);
|
||||
|
||||
const nodeRelativePath = process.platform === "win32"
|
||||
? path.join("node", "node.exe")
|
||||
: path.join("node", "bin", "node");
|
||||
const manifest = {
|
||||
runtimeId,
|
||||
runtimeVersion: packageVersion,
|
||||
platform: process.platform,
|
||||
arch: process.arch,
|
||||
createdAt: new Date().toISOString(),
|
||||
nodeRelativePath,
|
||||
cliEntrypointRelativePath: path.join(
|
||||
"node_modules",
|
||||
"@getpaseo",
|
||||
"cli",
|
||||
"dist",
|
||||
"index.js"
|
||||
),
|
||||
cliShimRelativePath: path.join("node_modules", "@getpaseo", "cli", "bin", "paseo"),
|
||||
serverRunnerRelativePath: path.join(
|
||||
"node_modules",
|
||||
"@getpaseo",
|
||||
"server",
|
||||
"dist",
|
||||
"scripts",
|
||||
"daemon-runner.js"
|
||||
),
|
||||
};
|
||||
|
||||
await fs.writeFile(
|
||||
path.join(runtimeRoot, "runtime-manifest.json"),
|
||||
JSON.stringify(manifest, null, 2) + "\n",
|
||||
"utf8"
|
||||
);
|
||||
|
||||
await fs.writeFile(
|
||||
path.join(resourcesRoot, "current-runtime.json"),
|
||||
JSON.stringify(
|
||||
{
|
||||
runtimeId,
|
||||
runtimeVersion: packageVersion,
|
||||
relativeRoot: runtimeId,
|
||||
},
|
||||
null,
|
||||
2
|
||||
) + "\n",
|
||||
"utf8"
|
||||
);
|
||||
|
||||
if (process.platform !== "win32") {
|
||||
await fs.chmod(path.join(runtimeRoot, nodeRelativePath), 0o755);
|
||||
}
|
||||
} finally {
|
||||
await rmSafe(extractionRoot);
|
||||
await rmSafe(tarballRoot);
|
||||
}
|
||||
}
|
||||
|
||||
await main();
|
||||
746
packages/desktop/scripts/managed-daemon-smoke.mjs
Normal file
746
packages/desktop/scripts/managed-daemon-smoke.mjs
Normal file
@@ -0,0 +1,746 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { Buffer } from "node:buffer";
|
||||
import { execFile, spawn } from "node:child_process";
|
||||
import fs from "node:fs/promises";
|
||||
import net from "node:net";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { promisify } from "node:util";
|
||||
import WebSocket from "ws";
|
||||
import { createClientChannel } from "@getpaseo/relay/e2ee";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
const repoRoot = path.resolve(new URL("../../..", import.meta.url).pathname);
|
||||
const desktopRoot = path.join(repoRoot, "packages", "desktop");
|
||||
const relayRoot = path.join(repoRoot, "packages", "relay");
|
||||
const desktopPackageJson = JSON.parse(
|
||||
await fs.readFile(path.join(desktopRoot, "package.json"), "utf8")
|
||||
);
|
||||
const currentRuntimeVersion = desktopPackageJson.version;
|
||||
const currentRuntimePointer = JSON.parse(
|
||||
await fs.readFile(
|
||||
path.join(desktopRoot, "src-tauri", "resources", "managed-runtime", "current-runtime.json"),
|
||||
"utf8"
|
||||
)
|
||||
);
|
||||
const currentRuntimeId = currentRuntimePointer.runtimeId;
|
||||
|
||||
function buildRuntimeId(version) {
|
||||
return currentRuntimeId.replace(currentRuntimeVersion, version);
|
||||
}
|
||||
|
||||
function resolvePackagedBinary() {
|
||||
if (process.platform === "darwin") {
|
||||
return path.join(
|
||||
desktopRoot,
|
||||
"src-tauri",
|
||||
"target",
|
||||
"release",
|
||||
"bundle",
|
||||
"macos",
|
||||
"Paseo.app",
|
||||
"Contents",
|
||||
"MacOS",
|
||||
"Paseo"
|
||||
);
|
||||
}
|
||||
if (process.platform === "linux") {
|
||||
return path.join(
|
||||
desktopRoot,
|
||||
"src-tauri",
|
||||
"target",
|
||||
"release",
|
||||
"bundle",
|
||||
"appimage",
|
||||
`Paseo_${desktopPackageJson.version}_amd64.AppImage`
|
||||
);
|
||||
}
|
||||
throw new Error(`Managed desktop smoke is not implemented for ${process.platform} yet.`);
|
||||
}
|
||||
|
||||
async function pathExists(target) {
|
||||
try {
|
||||
await fs.access(target);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function snapshotTree(root) {
|
||||
if (!(await pathExists(root))) {
|
||||
return [];
|
||||
}
|
||||
const entries = [];
|
||||
async function walk(current) {
|
||||
const stat = await fs.stat(current);
|
||||
const relative = path.relative(root, current) || ".";
|
||||
entries.push({
|
||||
relative,
|
||||
kind: stat.isDirectory() ? "dir" : "file",
|
||||
size: stat.size,
|
||||
mtimeMs: stat.mtimeMs,
|
||||
});
|
||||
if (!stat.isDirectory()) {
|
||||
return;
|
||||
}
|
||||
const children = await fs.readdir(current);
|
||||
children.sort();
|
||||
for (const child of children) {
|
||||
await walk(path.join(current, child));
|
||||
}
|
||||
}
|
||||
await walk(root);
|
||||
entries.sort((left, right) => left.relative.localeCompare(right.relative));
|
||||
return entries;
|
||||
}
|
||||
|
||||
async function sleep(ms) {
|
||||
await new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
function escapeForRegExp(value) {
|
||||
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
}
|
||||
|
||||
function derivePreviousVersion(version) {
|
||||
const match = version.match(/^(\d+)\.(\d+)\.(\d+)$/);
|
||||
if (!match) {
|
||||
return `${version}-previous`;
|
||||
}
|
||||
const [, major, minor, patch] = match;
|
||||
const numericPatch = Number.parseInt(patch, 10);
|
||||
if (numericPatch <= 0) {
|
||||
return `${major}.${minor}.0-previous`;
|
||||
}
|
||||
return `${major}.${minor}.${numericPatch - 1}`;
|
||||
}
|
||||
|
||||
function buildSeededTransportPath(testRoot) {
|
||||
if (process.platform === "win32") {
|
||||
return String.raw`\\.\pipe\paseo-managed-seeded-upgrade`;
|
||||
}
|
||||
return path.join(testRoot, "managed-home", "run", "paseo.sock");
|
||||
}
|
||||
|
||||
async function seedPreviousManagedRuntime(testRoot) {
|
||||
const previousRuntimeVersion = derivePreviousVersion(currentRuntimeVersion);
|
||||
const previousRuntimeId = buildRuntimeId(previousRuntimeVersion);
|
||||
const previousRuntimeRoot = path.join(testRoot, "runtime", previousRuntimeId);
|
||||
const stateFilePath = path.join(testRoot, "managed-state.json");
|
||||
const managedHome = path.join(testRoot, "managed-home");
|
||||
const transportType = process.platform === "win32" ? "pipe" : "socket";
|
||||
const transportPath = buildSeededTransportPath(testRoot);
|
||||
await fs.mkdir(previousRuntimeRoot, { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(previousRuntimeRoot, "runtime-manifest.json"),
|
||||
JSON.stringify(
|
||||
{
|
||||
runtimeId: previousRuntimeId,
|
||||
runtimeVersion: previousRuntimeVersion,
|
||||
platform: process.platform,
|
||||
arch: process.arch,
|
||||
createdAt: "2000-01-01T00:00:00.000Z",
|
||||
nodeRelativePath: process.platform === "win32" ? path.join("node", "node.exe") : path.join("node", "node"),
|
||||
cliEntrypointRelativePath: path.join("node_modules", "@getpaseo", "cli", "dist", "index.js"),
|
||||
cliShimRelativePath: path.join("node_modules", "@getpaseo", "cli", "bin", "paseo"),
|
||||
serverRunnerRelativePath: path.join(
|
||||
"node_modules",
|
||||
"@getpaseo",
|
||||
"server",
|
||||
"dist",
|
||||
"scripts",
|
||||
"daemon-runner.js"
|
||||
),
|
||||
},
|
||||
null,
|
||||
2
|
||||
) + "\n",
|
||||
"utf8"
|
||||
);
|
||||
await fs.writeFile(path.join(previousRuntimeRoot, "seed-marker.txt"), "version-n\n", "utf8");
|
||||
await fs.writeFile(
|
||||
stateFilePath,
|
||||
JSON.stringify(
|
||||
{
|
||||
runtimeId: previousRuntimeId,
|
||||
runtimeRoot: previousRuntimeRoot,
|
||||
managedHome,
|
||||
transportType,
|
||||
transportPath,
|
||||
tcpEnabled: false,
|
||||
tcpListen: null,
|
||||
cliShimPath: null,
|
||||
},
|
||||
null,
|
||||
2
|
||||
) + "\n",
|
||||
"utf8"
|
||||
);
|
||||
return {
|
||||
previousRuntimeId,
|
||||
previousRuntimeVersion,
|
||||
previousRuntimeRoot,
|
||||
stateFilePath,
|
||||
};
|
||||
}
|
||||
|
||||
async function runBinary(binaryPath, args, env) {
|
||||
const { stdout, stderr } = await execFileAsync(binaryPath, args, {
|
||||
env,
|
||||
cwd: repoRoot,
|
||||
maxBuffer: 10 * 1024 * 1024,
|
||||
});
|
||||
const trimmed = stdout.trim();
|
||||
return {
|
||||
stdout,
|
||||
stderr,
|
||||
json: trimmed ? JSON.parse(trimmed) : null,
|
||||
};
|
||||
}
|
||||
|
||||
async function runWorkspaceCli(args, env) {
|
||||
const { stdout, stderr } = await execFileAsync(
|
||||
process.execPath,
|
||||
[path.join(repoRoot, "packages", "cli", "dist", "index.js"), ...args],
|
||||
{
|
||||
cwd: repoRoot,
|
||||
env: {
|
||||
...process.env,
|
||||
...env,
|
||||
},
|
||||
maxBuffer: 10 * 1024 * 1024,
|
||||
},
|
||||
);
|
||||
let json = null;
|
||||
if (stdout.trim()) {
|
||||
try {
|
||||
json = JSON.parse(stdout.trim());
|
||||
} catch {
|
||||
json = null;
|
||||
}
|
||||
}
|
||||
return { stdout, stderr, json };
|
||||
}
|
||||
|
||||
async function readDaemonStatus(home, env) {
|
||||
const result = await runWorkspaceCli(["daemon", "status", "--home", home, "--json"], env);
|
||||
return result.json ?? {};
|
||||
}
|
||||
|
||||
async function pidListeningOnPort(port) {
|
||||
try {
|
||||
const { stdout } = await execFileAsync("lsof", ["-ti", `tcp:${port}`], {
|
||||
maxBuffer: 1024 * 1024,
|
||||
});
|
||||
const pid = Number.parseInt(stdout.trim().split("\n")[0] ?? "", 10);
|
||||
return Number.isFinite(pid) ? pid : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function getAvailablePort() {
|
||||
return await new Promise((resolve, reject) => {
|
||||
const server = net.createServer();
|
||||
server.unref();
|
||||
server.on("error", reject);
|
||||
server.listen(0, "127.0.0.1", () => {
|
||||
const address = server.address();
|
||||
if (!address || typeof address === "string") {
|
||||
server.close(() => reject(new Error("Failed to allocate an ephemeral test port.")));
|
||||
return;
|
||||
}
|
||||
const { port } = address;
|
||||
server.close((error) => {
|
||||
if (error) {
|
||||
reject(error);
|
||||
return;
|
||||
}
|
||||
resolve(port);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function buildRelayWebSocketUrl({ endpoint, serverId, role }) {
|
||||
const url = new URL(`ws://${endpoint}/ws`);
|
||||
url.searchParams.set("serverId", serverId);
|
||||
url.searchParams.set("role", role);
|
||||
url.searchParams.set("v", "2");
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
function parseOfferUrlFromCommandOutput(stdout) {
|
||||
const match = stdout.match(/https?:\/\/\S+#offer=\S+/);
|
||||
if (!match) {
|
||||
throw new Error(`Failed to find pairing URL in output: ${stdout}`);
|
||||
}
|
||||
return match[0];
|
||||
}
|
||||
|
||||
function decodeOfferFromFragmentUrl(url) {
|
||||
const marker = "#offer=";
|
||||
const index = url.indexOf(marker);
|
||||
if (index === -1) {
|
||||
throw new Error(`Pairing URL is missing ${marker}: ${url}`);
|
||||
}
|
||||
const encoded = url.slice(index + marker.length);
|
||||
const raw = Buffer.from(encoded, "base64url").toString("utf8");
|
||||
const offer = JSON.parse(raw);
|
||||
if (offer?.v !== 2 || typeof offer?.serverId !== "string" || typeof offer?.daemonPublicKeyB64 !== "string") {
|
||||
throw new Error(`Unexpected relay offer payload: ${raw}`);
|
||||
}
|
||||
return offer;
|
||||
}
|
||||
|
||||
async function waitForRelayWebSocketReady(endpoint, timeoutMs) {
|
||||
await waitFor(async () => {
|
||||
const probeServerId = `probe-${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`;
|
||||
const url = buildRelayWebSocketUrl({
|
||||
endpoint,
|
||||
serverId: probeServerId,
|
||||
role: "server",
|
||||
});
|
||||
const opened = await new Promise((resolve) => {
|
||||
const ws = new WebSocket(url);
|
||||
const timer = setTimeout(() => {
|
||||
ws.terminate();
|
||||
resolve(false);
|
||||
}, 5_000);
|
||||
ws.once("open", () => {
|
||||
clearTimeout(timer);
|
||||
ws.close(1000, "probe");
|
||||
resolve(true);
|
||||
});
|
||||
ws.once("error", () => {
|
||||
clearTimeout(timer);
|
||||
resolve(false);
|
||||
});
|
||||
});
|
||||
assert.equal(opened, true);
|
||||
}, timeoutMs, "relay websocket endpoint to accept connections");
|
||||
}
|
||||
|
||||
async function connectViaRelay(endpoint, offer) {
|
||||
const stableClientId = `cid-managed-smoke-${Date.now().toString(36)}`;
|
||||
const ws = new WebSocket(
|
||||
buildRelayWebSocketUrl({
|
||||
endpoint,
|
||||
serverId: offer.serverId,
|
||||
role: "client",
|
||||
})
|
||||
);
|
||||
return await new Promise((resolve, reject) => {
|
||||
const timeout = setTimeout(() => {
|
||||
ws.close();
|
||||
reject(new Error("Timed out waiting for managed relay pong."));
|
||||
}, 20_000);
|
||||
|
||||
const transport = {
|
||||
send: (data) => ws.send(data),
|
||||
close: (code, reason) => ws.close(code, reason),
|
||||
onmessage: null,
|
||||
onclose: null,
|
||||
onerror: null,
|
||||
};
|
||||
|
||||
ws.on("message", (data) => {
|
||||
transport.onmessage?.(typeof data === "string" ? data : data.toString());
|
||||
});
|
||||
ws.on("close", (code, reason) => {
|
||||
transport.onclose?.(code, reason.toString());
|
||||
});
|
||||
ws.on("error", (error) => {
|
||||
transport.onerror?.(error);
|
||||
});
|
||||
|
||||
ws.on("open", async () => {
|
||||
try {
|
||||
let pingSent = false;
|
||||
let channelRef = null;
|
||||
const channel = await createClientChannel(transport, offer.daemonPublicKeyB64, {
|
||||
onmessage: (data) => {
|
||||
try {
|
||||
const payload = typeof data === "string" ? JSON.parse(data) : data;
|
||||
if (payload?.type === "welcome") {
|
||||
if (!pingSent && channelRef) {
|
||||
pingSent = true;
|
||||
void channelRef.send(JSON.stringify({ type: "ping" }));
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (payload?.type === "pong") {
|
||||
clearTimeout(timeout);
|
||||
resolve(payload);
|
||||
ws.close();
|
||||
}
|
||||
} catch (error) {
|
||||
clearTimeout(timeout);
|
||||
reject(error);
|
||||
}
|
||||
},
|
||||
onerror: (error) => {
|
||||
clearTimeout(timeout);
|
||||
reject(error);
|
||||
},
|
||||
});
|
||||
channelRef = channel;
|
||||
await channel.send(
|
||||
JSON.stringify({
|
||||
type: "hello",
|
||||
clientId: stableClientId,
|
||||
clientType: "cli",
|
||||
protocolVersion: 1,
|
||||
})
|
||||
);
|
||||
} catch (error) {
|
||||
clearTimeout(timeout);
|
||||
reject(error);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function assertNoForbiddenPathsOrPorts(value, forbidden) {
|
||||
const text =
|
||||
typeof value === "string" ? value : JSON.stringify(value, null, 2);
|
||||
for (const candidate of forbidden) {
|
||||
assert.ok(
|
||||
!text.includes(candidate),
|
||||
`Unexpected forbidden managed smoke reference: ${candidate}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function ensurePackagedArtifact(binaryPath) {
|
||||
try {
|
||||
await execFileAsync("npm", ["run", "build"], {
|
||||
cwd: desktopRoot,
|
||||
env: process.env,
|
||||
maxBuffer: 20 * 1024 * 1024,
|
||||
});
|
||||
} catch (error) {
|
||||
const combined = `${error.stdout ?? ""}\n${error.stderr ?? ""}`;
|
||||
const signingBlocked =
|
||||
combined.includes("TAURI_SIGNING_PRIVATE_KEY") &&
|
||||
(await pathExists(binaryPath));
|
||||
if (!signingBlocked) {
|
||||
throw error;
|
||||
}
|
||||
console.warn(
|
||||
"[managed-smoke] continuing after Tauri updater signing failure because the packaged app artifact was already produced"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function waitFor(assertion, timeoutMs, label) {
|
||||
const startedAt = Date.now();
|
||||
while (Date.now() - startedAt < timeoutMs) {
|
||||
try {
|
||||
return await assertion();
|
||||
} catch {
|
||||
await sleep(250);
|
||||
}
|
||||
}
|
||||
throw new Error(`Timed out waiting for ${label}`);
|
||||
}
|
||||
|
||||
function logStep(label) {
|
||||
console.log(`\n[managed-smoke] ${label}`);
|
||||
}
|
||||
|
||||
const packagedBinary = resolvePackagedBinary();
|
||||
await ensurePackagedArtifact(packagedBinary);
|
||||
if (!(await pathExists(packagedBinary))) {
|
||||
throw new Error(`Packaged desktop artifact not found: ${packagedBinary}`);
|
||||
}
|
||||
|
||||
const tmpRoot = await fs.mkdtemp(path.join(os.tmpdir(), "paseo-desktop-smoke-"));
|
||||
const testRoot = path.join(tmpRoot, "managed-test-root");
|
||||
const fakeHome = path.join(tmpRoot, "fake-home");
|
||||
const fakePaseoHome = path.join(fakeHome, ".paseo");
|
||||
const cliScratchHome = path.join(tmpRoot, "cli-scratch-home");
|
||||
const externalHome = path.join(tmpRoot, "external-daemon-home");
|
||||
const externalPort = await getAvailablePort();
|
||||
const externalEndpoint = `127.0.0.1:${externalPort}`;
|
||||
const relayPort = await getAvailablePort();
|
||||
const relayEndpoint = `127.0.0.1:${relayPort}`;
|
||||
await fs.mkdir(testRoot, { recursive: true });
|
||||
await fs.mkdir(fakePaseoHome, { recursive: true });
|
||||
await fs.mkdir(cliScratchHome, { recursive: true });
|
||||
await fs.mkdir(externalHome, { recursive: true });
|
||||
await fs.writeFile(path.join(fakePaseoHome, "sentinel.txt"), "do not touch\n", "utf8");
|
||||
|
||||
const fakePaseoSnapshotBefore = await snapshotTree(fakePaseoHome);
|
||||
const seededUpgradeState = await seedPreviousManagedRuntime(testRoot);
|
||||
const managedEnv = {
|
||||
...process.env,
|
||||
HOME: fakeHome,
|
||||
PASEO_HOME: cliScratchHome,
|
||||
PASEO_DESKTOP_TEST_ROOT: testRoot,
|
||||
PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD: "0",
|
||||
PASEO_DICTATION_ENABLED: "0",
|
||||
PASEO_VOICE_MODE_ENABLED: "0",
|
||||
PASEO_RELAY_ENABLED: "true",
|
||||
PASEO_RELAY_ENDPOINT: relayEndpoint,
|
||||
PASEO_RELAY_PUBLIC_ENDPOINT: relayEndpoint,
|
||||
PASEO_APP_BASE_URL: "https://app.paseo.test",
|
||||
PASEO_PRIMARY_LAN_IP: "192.168.1.12",
|
||||
CI: "true",
|
||||
};
|
||||
let externalPid = null;
|
||||
let startedTemporaryExternalDaemon = false;
|
||||
let relayProcess = null;
|
||||
const forbiddenManagedReferences = ["127.0.0.1:6767", fakePaseoHome];
|
||||
|
||||
try {
|
||||
logStep(`Starting isolated local relay on ${relayEndpoint}`);
|
||||
relayProcess = spawn(process.platform === "win32" ? "npx.cmd" : "npx", [
|
||||
"wrangler",
|
||||
"dev",
|
||||
"--local",
|
||||
"--ip",
|
||||
"127.0.0.1",
|
||||
"--port",
|
||||
String(relayPort),
|
||||
"--live-reload=false",
|
||||
"--show-interactive-dev-session=false",
|
||||
], {
|
||||
cwd: relayRoot,
|
||||
env: process.env,
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
relayProcess.stdout?.on("data", (chunk) => {
|
||||
process.stdout.write(`[managed-smoke relay] ${chunk}`);
|
||||
});
|
||||
relayProcess.stderr?.on("data", (chunk) => {
|
||||
process.stderr.write(`[managed-smoke relay] ${chunk}`);
|
||||
});
|
||||
await waitFor(async () => {
|
||||
await execFileAsync(process.execPath, ["-e", `require("node:net").connect(${relayPort}, "127.0.0.1").on("connect", function () { this.end(); process.exit(0); }).on("error", () => process.exit(1));`]);
|
||||
}, 30_000, "relay HTTP endpoint to start");
|
||||
await waitForRelayWebSocketReady(relayEndpoint, 60_000);
|
||||
|
||||
logStep(`Starting isolated external daemon on ${externalEndpoint}`);
|
||||
startedTemporaryExternalDaemon = true;
|
||||
await runWorkspaceCli(
|
||||
["start", "--home", externalHome, "--listen", externalEndpoint, "--no-relay"],
|
||||
managedEnv
|
||||
);
|
||||
const externalStatus = await waitFor(
|
||||
async () => {
|
||||
const status = await readDaemonStatus(externalHome, managedEnv);
|
||||
assert.equal(status.status, "running");
|
||||
assert.ok(typeof status.pid === "number" && status.pid > 0, "expected external daemon pid");
|
||||
await runWorkspaceCli(["ls", "--host", externalEndpoint, "--json"], managedEnv);
|
||||
return status;
|
||||
},
|
||||
15_000,
|
||||
"external daemon to start"
|
||||
);
|
||||
externalPid = externalStatus.pid;
|
||||
assert.ok(externalPid, "external daemon pid should be present");
|
||||
|
||||
logStep("Bootstrapping managed runtime from packaged desktop binary");
|
||||
const runtimeStatus = await runBinary(packagedBinary, ["--managed-headless", "runtime-status"], managedEnv);
|
||||
assert.equal(await pathExists(seededUpgradeState.previousRuntimeRoot), true);
|
||||
assert.equal(runtimeStatus.json.runtimeId, currentRuntimeId);
|
||||
assert.equal(runtimeStatus.json.runtimeVersion, currentRuntimeVersion);
|
||||
assert.match(runtimeStatus.json.managedHome, new RegExp(`^${escapeForRegExp(testRoot)}`));
|
||||
assert.equal(
|
||||
runtimeStatus.json.installedRuntimeRoot,
|
||||
path.join(testRoot, "runtime", currentRuntimeId),
|
||||
"managed runtime should install under the current bundled runtime id"
|
||||
);
|
||||
assert.notEqual(
|
||||
runtimeStatus.json.installedRuntimeRoot,
|
||||
seededUpgradeState.previousRuntimeRoot,
|
||||
"packaged app should adopt the bundled runtime instead of the seeded previous runtime"
|
||||
);
|
||||
assert.ok(runtimeStatus.json.transportType === "socket" || runtimeStatus.json.transportType === "pipe");
|
||||
assert.notEqual(runtimeStatus.json.transportPath, "127.0.0.1:6767");
|
||||
assertNoForbiddenPathsOrPorts(runtimeStatus.json, forbiddenManagedReferences);
|
||||
const stateAfterRuntimeInstall = JSON.parse(
|
||||
await fs.readFile(seededUpgradeState.stateFilePath, "utf8")
|
||||
);
|
||||
assert.equal(stateAfterRuntimeInstall.runtimeId, currentRuntimeId);
|
||||
assert.equal(stateAfterRuntimeInstall.runtimeRoot, runtimeStatus.json.installedRuntimeRoot);
|
||||
assert.equal(await pathExists(seededUpgradeState.previousRuntimeRoot), true);
|
||||
|
||||
const managedStart = await runBinary(packagedBinary, ["--managed-headless", "bootstrap"], managedEnv);
|
||||
assert.equal(managedStart.json.daemonRunning, true);
|
||||
assert.ok(managedStart.json.daemonPid, "managed daemon pid should exist");
|
||||
assert.ok(
|
||||
managedStart.json.transportType === "socket" || managedStart.json.transportType === "pipe",
|
||||
"managed daemon should default to private IPC transport"
|
||||
);
|
||||
assert.notEqual(managedStart.json.transportPath, "127.0.0.1:6767");
|
||||
assertNoForbiddenPathsOrPorts(managedStart.json, forbiddenManagedReferences);
|
||||
|
||||
const managedPid = managedStart.json.daemonPid;
|
||||
const stateFile = path.join(testRoot, "managed-state.json");
|
||||
assert.equal(await pathExists(stateFile), true, "managed state file should be written");
|
||||
|
||||
logStep("Verifying the managed daemon stays alive after the packaged command exits");
|
||||
await sleep(1_500);
|
||||
const persistedManagedStatus = await runBinary(
|
||||
packagedBinary,
|
||||
["--managed-headless", "daemon-status"],
|
||||
managedEnv
|
||||
);
|
||||
assert.equal(persistedManagedStatus.json.daemonPid, managedPid);
|
||||
assert.equal(persistedManagedStatus.json.daemonRunning, true);
|
||||
assert.equal(persistedManagedStatus.json.relayEnabled, true);
|
||||
assertNoForbiddenPathsOrPorts(persistedManagedStatus.json, forbiddenManagedReferences);
|
||||
|
||||
logStep("Installing CLI shim and verifying it targets the managed daemon");
|
||||
const cliInstall = await runBinary(
|
||||
packagedBinary,
|
||||
["--managed-headless", "install-cli-shim"],
|
||||
managedEnv
|
||||
);
|
||||
const cliShimPath = cliInstall.json.path;
|
||||
assert.ok(cliShimPath, "CLI shim path should be returned");
|
||||
assert.equal(await pathExists(cliShimPath), true, "CLI shim should exist");
|
||||
const cliVersion = await execFileAsync(cliShimPath, ["--version"], {
|
||||
env: managedEnv,
|
||||
cwd: repoRoot,
|
||||
maxBuffer: 1024 * 1024,
|
||||
});
|
||||
assert.match(cliVersion.stdout.trim(), /^0\./);
|
||||
const shimStatus = await execFileAsync(cliShimPath, ["daemon", "status", "--json"], {
|
||||
env: managedEnv,
|
||||
cwd: repoRoot,
|
||||
maxBuffer: 1024 * 1024,
|
||||
});
|
||||
const shimDaemonStatus = JSON.parse(shimStatus.stdout.trim());
|
||||
assert.equal(shimDaemonStatus.pid, managedPid);
|
||||
assertNoForbiddenPathsOrPorts(shimDaemonStatus, forbiddenManagedReferences);
|
||||
|
||||
logStep("Verifying relay connectivity still works after the desktop command has exited");
|
||||
const relayPairing = await execFileAsync(
|
||||
cliShimPath,
|
||||
["daemon", "pair", "--home", managedStart.json.managedHome],
|
||||
{
|
||||
env: managedEnv,
|
||||
cwd: repoRoot,
|
||||
maxBuffer: 4 * 1024 * 1024,
|
||||
}
|
||||
);
|
||||
const relayOfferUrl = parseOfferUrlFromCommandOutput(relayPairing.stdout);
|
||||
const relayOffer = decodeOfferFromFragmentUrl(relayOfferUrl);
|
||||
assert.equal(relayOffer.relay?.endpoint, relayEndpoint);
|
||||
const relayPong = await connectViaRelay(relayEndpoint, relayOffer);
|
||||
assert.deepEqual(relayPong, { type: "pong" });
|
||||
|
||||
logStep("Reopening packaged desktop command path without spawning duplicate daemons");
|
||||
const managedRestartless = await runBinary(
|
||||
packagedBinary,
|
||||
["--managed-headless", "bootstrap"],
|
||||
managedEnv
|
||||
);
|
||||
assert.equal(managedRestartless.json.daemonPid, managedPid);
|
||||
|
||||
logStep("Verifying managed and external daemons coexist");
|
||||
const externalStatusAfter = await readDaemonStatus(externalHome, managedEnv);
|
||||
const externalPidAfter = externalStatusAfter.pid ?? null;
|
||||
assert.equal(externalStatusAfter.status, "running");
|
||||
assert.equal(externalPidAfter, externalPid);
|
||||
await runWorkspaceCli(["ls", "--host", externalEndpoint, "--json"], managedEnv);
|
||||
const managedStatus = await runBinary(
|
||||
packagedBinary,
|
||||
["--managed-headless", "daemon-status"],
|
||||
managedEnv
|
||||
);
|
||||
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);
|
||||
await fs.writeFile(
|
||||
path.join(testRoot, "managed-daemon-smoke-diagnostics.json"),
|
||||
JSON.stringify(
|
||||
{
|
||||
runtimeStatus: runtimeStatus.json,
|
||||
managedStart: managedStart.json,
|
||||
seededUpgradeState,
|
||||
stateAfterRuntimeInstall,
|
||||
persistedManagedStatus: persistedManagedStatus.json,
|
||||
managedRestartless: managedRestartless.json,
|
||||
cliInstall: cliInstall.json,
|
||||
shimDaemonStatus,
|
||||
relayEndpoint,
|
||||
relayOfferUrl,
|
||||
relayPong,
|
||||
externalEndpoint,
|
||||
externalPid,
|
||||
externalPidAfter,
|
||||
managedStatus: managedStatus.json,
|
||||
tcpEnabled: tcpEnabled.json,
|
||||
tcpDisabled: tcpDisabled.json,
|
||||
},
|
||||
null,
|
||||
2
|
||||
) + "\n",
|
||||
"utf8"
|
||||
);
|
||||
|
||||
console.log(`\n[managed-smoke] PASS (${testRoot})`);
|
||||
} finally {
|
||||
try {
|
||||
await runBinary(packagedBinary, ["--managed-headless", "stop-daemon"], managedEnv);
|
||||
} catch {}
|
||||
try {
|
||||
await runBinary(packagedBinary, ["--managed-headless", "uninstall-cli-shim"], managedEnv);
|
||||
} catch {}
|
||||
try {
|
||||
if (startedTemporaryExternalDaemon) {
|
||||
await runWorkspaceCli(["daemon", "stop", "--home", externalHome, "--json"]);
|
||||
}
|
||||
} catch {}
|
||||
try {
|
||||
relayProcess?.kill("SIGTERM");
|
||||
} catch {}
|
||||
}
|
||||
39
packages/desktop/src-tauri/Cargo.lock
generated
39
packages/desktop/src-tauri/Cargo.lock
generated
@@ -2632,6 +2632,9 @@ name = "paseo"
|
||||
version = "0.1.18"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"dirs",
|
||||
"futures-util",
|
||||
"http",
|
||||
"log",
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -2643,6 +2646,8 @@ dependencies = [
|
||||
"tauri-plugin-opener",
|
||||
"tauri-plugin-updater",
|
||||
"tauri-plugin-websocket",
|
||||
"tokio",
|
||||
"tokio-tungstenite 0.24.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4342,7 +4347,7 @@ dependencies = [
|
||||
"tauri-plugin",
|
||||
"thiserror 2.0.18",
|
||||
"tokio",
|
||||
"tokio-tungstenite",
|
||||
"tokio-tungstenite 0.28.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4604,6 +4609,18 @@ dependencies = [
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tokio-tungstenite"
|
||||
version = "0.24.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "edc5f74e248dc973e0dbb7b74c7e0d6fcc301c694ff50049504004ef4d0cdcd9"
|
||||
dependencies = [
|
||||
"futures-util",
|
||||
"log",
|
||||
"tokio",
|
||||
"tungstenite 0.24.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tokio-tungstenite"
|
||||
version = "0.28.0"
|
||||
@@ -4616,7 +4633,7 @@ dependencies = [
|
||||
"rustls-pki-types",
|
||||
"tokio",
|
||||
"tokio-rustls",
|
||||
"tungstenite",
|
||||
"tungstenite 0.28.0",
|
||||
"webpki-roots 0.26.11",
|
||||
]
|
||||
|
||||
@@ -4833,6 +4850,24 @@ version = "0.2.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
|
||||
|
||||
[[package]]
|
||||
name = "tungstenite"
|
||||
version = "0.24.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "18e5b8366ee7a95b16d32197d0b2604b43a0be89dc5fac9f8e96ccafbaedda8a"
|
||||
dependencies = [
|
||||
"byteorder",
|
||||
"bytes",
|
||||
"data-encoding",
|
||||
"http",
|
||||
"httparse",
|
||||
"log",
|
||||
"rand 0.8.5",
|
||||
"sha1",
|
||||
"thiserror 1.0.69",
|
||||
"utf-8",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tungstenite"
|
||||
version = "0.28.0"
|
||||
|
||||
@@ -23,6 +23,9 @@ tauri-build = { version = "2.5.3", features = [] }
|
||||
|
||||
[dependencies]
|
||||
base64 = "0.22"
|
||||
dirs = "6"
|
||||
futures-util = "0.3"
|
||||
http = "1"
|
||||
serde_json = "1.0"
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
log = "0.4"
|
||||
@@ -33,7 +36,8 @@ tauri-plugin-notification = "2"
|
||||
tauri-plugin-opener = "2"
|
||||
tauri-plugin-updater = "2"
|
||||
tauri-plugin-websocket = "2"
|
||||
|
||||
tokio = { version = "1", features = ["net", "sync"] }
|
||||
tokio-tungstenite = "0.24"
|
||||
|
||||
|
||||
|
||||
|
||||
1
packages/desktop/src-tauri/resources/.gitkeep
Normal file
1
packages/desktop/src-tauri/resources/.gitkeep
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
use base64::{engine::general_purpose::STANDARD as BASE64_STANDARD, Engine as _};
|
||||
use serde::Serialize;
|
||||
use serde_json::json;
|
||||
use std::collections::HashSet;
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
@@ -11,6 +12,17 @@ use tauri::menu::AboutMetadata;
|
||||
use tauri::{AppHandle, Manager, WebviewWindow};
|
||||
use tauri_plugin_updater::UpdaterExt;
|
||||
|
||||
mod runtime_manager;
|
||||
pub use runtime_manager::try_run_cli_shim_from_args;
|
||||
use runtime_manager::{
|
||||
close_local_daemon_transport, ensure_managed_runtime, 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, ManagedTcpSettings,
|
||||
LocalTransportState,
|
||||
};
|
||||
|
||||
// Store zoom as u64 bits (f64 * 100 as integer for atomic ops)
|
||||
static ZOOM_LEVEL: AtomicU64 = AtomicU64::new(100);
|
||||
|
||||
@@ -64,6 +76,138 @@ struct AttachmentFileResult {
|
||||
byte_size: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
enum ManagedHeadlessCommand {
|
||||
RuntimeStatus,
|
||||
Bootstrap,
|
||||
DaemonStatus,
|
||||
StopDaemon,
|
||||
InstallCliShim,
|
||||
UninstallCliShim,
|
||||
UpdateTcp(ManagedTcpSettings),
|
||||
}
|
||||
|
||||
fn parse_managed_headless_command() -> Result<Option<ManagedHeadlessCommand>, String> {
|
||||
let args = std::env::args().collect::<Vec<_>>();
|
||||
let Some(flag_index) = args.iter().position(|value| value == "--managed-headless") else {
|
||||
return Ok(None);
|
||||
};
|
||||
let command = args
|
||||
.get(flag_index + 1)
|
||||
.ok_or_else(|| "Missing command after --managed-headless".to_string())?;
|
||||
let tail = args.iter().skip(flag_index + 2).cloned().collect::<Vec<_>>();
|
||||
match command.as_str() {
|
||||
"runtime-status" => Ok(Some(ManagedHeadlessCommand::RuntimeStatus)),
|
||||
"bootstrap" => Ok(Some(ManagedHeadlessCommand::Bootstrap)),
|
||||
"daemon-status" => Ok(Some(ManagedHeadlessCommand::DaemonStatus)),
|
||||
"stop-daemon" => Ok(Some(ManagedHeadlessCommand::StopDaemon)),
|
||||
"install-cli-shim" => Ok(Some(ManagedHeadlessCommand::InstallCliShim)),
|
||||
"uninstall-cli-shim" => Ok(Some(ManagedHeadlessCommand::UninstallCliShim)),
|
||||
"update-tcp" => {
|
||||
let mut enabled = false;
|
||||
let mut host = "127.0.0.1".to_string();
|
||||
let mut port = 7771_u16;
|
||||
let mut index = 0_usize;
|
||||
while index < tail.len() {
|
||||
match tail[index].as_str() {
|
||||
"--enabled" => {
|
||||
enabled = tail
|
||||
.get(index + 1)
|
||||
.ok_or_else(|| "Missing value after --enabled".to_string())?
|
||||
.parse::<bool>()
|
||||
.map_err(|error| format!("Invalid --enabled value: {error}"))?;
|
||||
index += 2;
|
||||
}
|
||||
"--host" => {
|
||||
host = tail
|
||||
.get(index + 1)
|
||||
.ok_or_else(|| "Missing value after --host".to_string())?
|
||||
.to_string();
|
||||
index += 2;
|
||||
}
|
||||
"--port" => {
|
||||
port = tail
|
||||
.get(index + 1)
|
||||
.ok_or_else(|| "Missing value after --port".to_string())?
|
||||
.parse::<u16>()
|
||||
.map_err(|error| format!("Invalid --port value: {error}"))?;
|
||||
index += 2;
|
||||
}
|
||||
other => {
|
||||
return Err(format!("Unknown --managed-headless update-tcp option: {other}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(Some(ManagedHeadlessCommand::UpdateTcp(ManagedTcpSettings {
|
||||
enabled,
|
||||
host,
|
||||
port,
|
||||
})))
|
||||
}
|
||||
other => Err(format!("Unknown --managed-headless command: {other}")),
|
||||
}
|
||||
}
|
||||
|
||||
fn maybe_run_managed_headless_command(app: &AppHandle) -> Result<bool, String> {
|
||||
let Some(command) = parse_managed_headless_command()? else {
|
||||
return Ok(false);
|
||||
};
|
||||
if let Some(window) = app.get_webview_window("main") {
|
||||
let _ = window.hide();
|
||||
}
|
||||
let app_handle = app.clone();
|
||||
tauri::async_runtime::spawn(async move {
|
||||
let result = async {
|
||||
let payload = match command {
|
||||
ManagedHeadlessCommand::RuntimeStatus => {
|
||||
json!(managed_runtime_status(app_handle.clone()).await?)
|
||||
}
|
||||
ManagedHeadlessCommand::Bootstrap => {
|
||||
ensure_managed_runtime(app_handle.clone()).await?;
|
||||
json!(start_managed_daemon(app_handle.clone()).await?)
|
||||
}
|
||||
ManagedHeadlessCommand::DaemonStatus => {
|
||||
json!(managed_daemon_status(app_handle.clone()).await?)
|
||||
}
|
||||
ManagedHeadlessCommand::StopDaemon => {
|
||||
json!(stop_managed_daemon(app_handle.clone()).await?)
|
||||
}
|
||||
ManagedHeadlessCommand::InstallCliShim => {
|
||||
json!(install_cli_shim(app_handle.clone()).await?)
|
||||
}
|
||||
ManagedHeadlessCommand::UninstallCliShim => {
|
||||
json!(uninstall_cli_shim(app_handle.clone()).await?)
|
||||
}
|
||||
ManagedHeadlessCommand::UpdateTcp(settings) => {
|
||||
json!(update_managed_daemon_tcp_settings(app_handle.clone(), settings).await?)
|
||||
}
|
||||
};
|
||||
Ok::<serde_json::Value, String>(payload)
|
||||
}
|
||||
.await;
|
||||
|
||||
match result {
|
||||
Ok(payload) => {
|
||||
println!(
|
||||
"{}",
|
||||
serde_json::to_string_pretty(&payload)
|
||||
.unwrap_or_else(|error| format!(r#"{{"error":"{error}"}}"#))
|
||||
);
|
||||
app_handle.exit(0);
|
||||
}
|
||||
Err(error) => {
|
||||
eprintln!(
|
||||
"{}",
|
||||
serde_json::to_string_pretty(&json!({ "error": error }))
|
||||
.unwrap_or_else(|_| r#"{"error":"managed headless command failed"}"#.to_string())
|
||||
);
|
||||
app_handle.exit(1);
|
||||
}
|
||||
}
|
||||
});
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
fn resolve_login_shell() -> String {
|
||||
std::env::var("SHELL")
|
||||
.ok()
|
||||
@@ -440,12 +584,27 @@ async fn garbage_collect_attachment_files(
|
||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||
pub fn run() {
|
||||
tauri::Builder::default()
|
||||
.manage(LocalTransportState::default())
|
||||
.plugin(tauri_plugin_dialog::init())
|
||||
.plugin(tauri_plugin_notification::init())
|
||||
.plugin(tauri_plugin_opener::init())
|
||||
.plugin(tauri_plugin_updater::Builder::new().build())
|
||||
.plugin(tauri_plugin_websocket::init())
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
managed_runtime_status,
|
||||
ensure_managed_runtime,
|
||||
managed_daemon_status,
|
||||
start_managed_daemon,
|
||||
stop_managed_daemon,
|
||||
restart_managed_daemon,
|
||||
managed_daemon_logs,
|
||||
managed_daemon_pairing,
|
||||
install_cli_shim,
|
||||
uninstall_cli_shim,
|
||||
update_managed_daemon_tcp_settings,
|
||||
open_local_daemon_transport,
|
||||
send_local_daemon_transport_message,
|
||||
close_local_daemon_transport,
|
||||
get_local_daemon_version,
|
||||
run_local_daemon_update,
|
||||
check_app_update,
|
||||
@@ -457,6 +616,10 @@ pub fn run() {
|
||||
garbage_collect_attachment_files
|
||||
])
|
||||
.setup(|app| {
|
||||
if maybe_run_managed_headless_command(app.handle())? {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
if cfg!(debug_assertions) {
|
||||
app.handle().plugin(
|
||||
tauri_plugin_log::Builder::default()
|
||||
|
||||
@@ -8,5 +8,9 @@
|
||||
tauri::embed_plist::embed_info_plist!("../Info.plist");
|
||||
|
||||
fn main() {
|
||||
if let Err(error) = paseo_lib::try_run_cli_shim_from_args() {
|
||||
eprintln!("{error}");
|
||||
std::process::exit(1);
|
||||
}
|
||||
paseo_lib::run();
|
||||
}
|
||||
|
||||
1534
packages/desktop/src-tauri/src/runtime_manager.rs
Normal file
1534
packages/desktop/src-tauri/src/runtime_manager.rs
Normal file
File diff suppressed because it is too large
Load Diff
@@ -33,6 +33,9 @@
|
||||
"bundle": {
|
||||
"active": true,
|
||||
"createUpdaterArtifacts": true,
|
||||
"resources": [
|
||||
"resources/**/*"
|
||||
],
|
||||
"targets": "all",
|
||||
"icon": [
|
||||
"icons/32x32.png",
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { mkdir, mkdtemp, rm } from "node:fs/promises";
|
||||
import { Writable } from "node:stream";
|
||||
import pino from "pino";
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import { createPaseoDaemon, type PaseoDaemonConfig } from "./bootstrap.js";
|
||||
import { createPaseoDaemon, parseListenString, type PaseoDaemonConfig } from "./bootstrap.js";
|
||||
import { createTestPaseoDaemon } from "./test-utils/paseo-daemon.js";
|
||||
import { createTestAgentClients } from "./test-utils/fake-agent-client.js";
|
||||
|
||||
@@ -75,4 +76,65 @@ describe("paseo daemon bootstrap", () => {
|
||||
await rm(staticDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("parses Windows named pipes as managed IPC listen targets", () => {
|
||||
expect(parseListenString(String.raw`\\.\pipe\paseo-managed-test`)).toEqual({
|
||||
type: "pipe",
|
||||
path: String.raw`\\.\pipe\paseo-managed-test`,
|
||||
});
|
||||
expect(parseListenString(`pipe://${String.raw`\\.\pipe\paseo-managed-test`}`)).toEqual({
|
||||
type: "pipe",
|
||||
path: String.raw`\\.\pipe\paseo-managed-test`,
|
||||
});
|
||||
});
|
||||
|
||||
test("emits a relay pairing offer for unix socket listeners", async () => {
|
||||
const paseoHomeRoot = await mkdtemp(path.join(os.tmpdir(), "paseo-socket-relay-"));
|
||||
const paseoHome = path.join(paseoHomeRoot, ".paseo");
|
||||
const staticDir = await mkdtemp(path.join(os.tmpdir(), "paseo-static-"));
|
||||
const socketPath = path.join(paseoHomeRoot, "run", "paseo.sock");
|
||||
await mkdir(path.dirname(socketPath), { recursive: true });
|
||||
await mkdir(paseoHome, { recursive: true });
|
||||
|
||||
const lines: string[] = [];
|
||||
const logger = pino(
|
||||
{ level: "info" },
|
||||
new Writable({
|
||||
write(chunk, _encoding, callback) {
|
||||
lines.push(chunk.toString("utf8"));
|
||||
callback();
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
const config: PaseoDaemonConfig = {
|
||||
listen: socketPath,
|
||||
paseoHome,
|
||||
corsAllowedOrigins: [],
|
||||
allowedHosts: true,
|
||||
mcpEnabled: false,
|
||||
staticDir,
|
||||
mcpDebug: false,
|
||||
agentClients: createTestAgentClients(),
|
||||
agentStoragePath: path.join(paseoHome, "agents"),
|
||||
relayEnabled: true,
|
||||
relayEndpoint: "127.0.0.1:9",
|
||||
relayPublicEndpoint: "127.0.0.1:9",
|
||||
appBaseUrl: "https://app.paseo.sh",
|
||||
openai: undefined,
|
||||
speech: undefined,
|
||||
};
|
||||
|
||||
const daemon = await createPaseoDaemon(config, logger);
|
||||
|
||||
try {
|
||||
await daemon.start();
|
||||
expect(lines.some((line) => line.includes('"msg":"pairing_offer"'))).toBe(true);
|
||||
} finally {
|
||||
await daemon.stop().catch(() => undefined);
|
||||
await daemon.agentManager.flush().catch(() => undefined);
|
||||
await rm(paseoHomeRoot, { recursive: true, force: true });
|
||||
await rm(staticDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -11,9 +11,16 @@ import type { Logger } from "pino";
|
||||
|
||||
type ListenTarget =
|
||||
| { type: "tcp"; host: string; port: number }
|
||||
| { type: "socket"; path: string };
|
||||
| { type: "socket"; path: string }
|
||||
| { type: "pipe"; path: string };
|
||||
|
||||
function parseListenString(listen: string): ListenTarget {
|
||||
export function parseListenString(listen: string): ListenTarget {
|
||||
if (listen.startsWith("\\\\.\\pipe\\") || listen.startsWith("pipe://")) {
|
||||
return {
|
||||
type: "pipe",
|
||||
path: listen.startsWith("pipe://") ? listen.slice("pipe://".length) : listen,
|
||||
};
|
||||
}
|
||||
// Unix socket: starts with / or ~ or contains .sock
|
||||
if (listen.startsWith("/") || listen.startsWith("~") || listen.includes(".sock")) {
|
||||
return { type: "socket", path: listen };
|
||||
@@ -570,46 +577,44 @@ export async function createPaseoDaemon(
|
||||
const onListening = () => {
|
||||
httpServer.off("error", onError);
|
||||
const logAndResolve = async () => {
|
||||
const relayEnabled = config.relayEnabled ?? true;
|
||||
const relayEndpoint = config.relayEndpoint ?? "relay.paseo.sh:443";
|
||||
const relayPublicEndpoint = config.relayPublicEndpoint ?? relayEndpoint;
|
||||
const appBaseUrl = config.appBaseUrl ?? "https://app.paseo.sh";
|
||||
|
||||
if (listenTarget.type === "tcp") {
|
||||
logger.info(
|
||||
{ host: listenTarget.host, port: listenTarget.port },
|
||||
`Server listening on http://${listenTarget.host}:${listenTarget.port}`
|
||||
);
|
||||
|
||||
const relayEnabled = config.relayEnabled ?? true;
|
||||
const relayEndpoint = config.relayEndpoint ?? "relay.paseo.sh:443";
|
||||
const relayPublicEndpoint = config.relayPublicEndpoint ?? relayEndpoint;
|
||||
const appBaseUrl = config.appBaseUrl ?? "https://app.paseo.sh";
|
||||
|
||||
if (relayEnabled) {
|
||||
const offer = await createConnectionOfferV2({
|
||||
serverId,
|
||||
daemonPublicKeyB64: daemonKeyPair.publicKeyB64,
|
||||
relay: { endpoint: relayPublicEndpoint },
|
||||
});
|
||||
|
||||
const url = encodeOfferToFragmentUrl({ offer, appBaseUrl });
|
||||
logger.info({ url }, "pairing_offer");
|
||||
}
|
||||
|
||||
if (relayEnabled) {
|
||||
relayTransport?.stop().catch(() => undefined);
|
||||
relayTransport = startRelayTransport({
|
||||
logger,
|
||||
attachSocket: (ws, metadata) => {
|
||||
if (!wsServer) {
|
||||
throw new Error("WebSocket server not initialized");
|
||||
}
|
||||
return wsServer.attachExternalSocket(ws, metadata);
|
||||
},
|
||||
relayEndpoint,
|
||||
serverId,
|
||||
daemonKeyPair: daemonKeyPair.keyPair,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
logger.info({ path: listenTarget.path }, `Server listening on ${listenTarget.path}`);
|
||||
}
|
||||
|
||||
if (relayEnabled) {
|
||||
const offer = await createConnectionOfferV2({
|
||||
serverId,
|
||||
daemonPublicKeyB64: daemonKeyPair.publicKeyB64,
|
||||
relay: { endpoint: relayPublicEndpoint },
|
||||
});
|
||||
|
||||
const url = encodeOfferToFragmentUrl({ offer, appBaseUrl });
|
||||
logger.info({ url }, "pairing_offer");
|
||||
|
||||
relayTransport?.stop().catch(() => undefined);
|
||||
relayTransport = startRelayTransport({
|
||||
logger,
|
||||
attachSocket: (ws, metadata) => {
|
||||
if (!wsServer) {
|
||||
throw new Error("WebSocket server not initialized");
|
||||
}
|
||||
return wsServer.attachExternalSocket(ws, metadata);
|
||||
},
|
||||
relayEndpoint,
|
||||
serverId,
|
||||
daemonKeyPair: daemonKeyPair.keyPair,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
logAndResolve().then(resolve, reject);
|
||||
@@ -620,8 +625,7 @@ export async function createPaseoDaemon(
|
||||
if (listenTarget.type === "tcp") {
|
||||
httpServer.listen(listenTarget.port, listenTarget.host);
|
||||
} else {
|
||||
// Remove stale socket file if it exists
|
||||
if (existsSync(listenTarget.path)) {
|
||||
if (listenTarget.type === "socket" && existsSync(listenTarget.path)) {
|
||||
unlinkSync(listenTarget.path);
|
||||
}
|
||||
httpServer.listen(listenTarget.path);
|
||||
|
||||
Reference in New Issue
Block a user