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:
|
on:
|
||||||
push:
|
push:
|
||||||
tags:
|
tags:
|
||||||
- 'v*'
|
- "v*"
|
||||||
- 'desktop-v*'
|
- "desktop-v*"
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
inputs:
|
inputs:
|
||||||
tag:
|
tag:
|
||||||
description: 'Existing tag to build (e.g. v0.1.0)'
|
description: "Existing tag to build (e.g. v0.1.0)"
|
||||||
required: true
|
required: true
|
||||||
type: string
|
type: string
|
||||||
|
|
||||||
@@ -16,14 +16,15 @@ concurrency:
|
|||||||
group: desktop-release-${{ github.ref }}
|
group: desktop-release-${{ github.ref }}
|
||||||
cancel-in-progress: false
|
cancel-in-progress: false
|
||||||
|
|
||||||
|
env:
|
||||||
|
RELEASE_TAG: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.tag || github.ref_name }}
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
publish-tauri:
|
publish-macos:
|
||||||
permissions:
|
permissions:
|
||||||
contents: write
|
contents: write
|
||||||
packages: read
|
packages: read
|
||||||
runs-on: macos-latest
|
runs-on: macos-latest
|
||||||
env:
|
|
||||||
RELEASE_TAG: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.tag || github.ref_name }}
|
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
@@ -34,10 +35,10 @@ jobs:
|
|||||||
- name: Setup Node
|
- name: Setup Node
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: '22'
|
node-version: "22"
|
||||||
cache: 'npm'
|
cache: "npm"
|
||||||
registry-url: 'https://npm.pkg.github.com'
|
registry-url: "https://npm.pkg.github.com"
|
||||||
scope: '@boudra'
|
scope: "@boudra"
|
||||||
|
|
||||||
- name: Install Rust stable
|
- name: Install Rust stable
|
||||||
uses: dtolnay/rust-toolchain@stable
|
uses: dtolnay/rust-toolchain@stable
|
||||||
@@ -91,7 +92,7 @@ jobs:
|
|||||||
fs.writeFileSync(cargoTomlPath, `${nextLines.join('\n')}\n`);
|
fs.writeFileSync(cargoTomlPath, `${nextLines.join('\n')}\n`);
|
||||||
NODE
|
NODE
|
||||||
|
|
||||||
- name: Build and publish Tauri release
|
- name: Build and publish macOS Tauri release
|
||||||
uses: tauri-apps/tauri-action@v0
|
uses: tauri-apps/tauri-action@v0
|
||||||
env:
|
env:
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
@@ -111,3 +112,178 @@ jobs:
|
|||||||
releaseDraft: false
|
releaseDraft: false
|
||||||
prerelease: false
|
prerelease: false
|
||||||
args: --target universal-apple-darwin
|
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
|
packages/server/src/server/fixtures/dictation/dictation-debug-largest.transcript.txt
|
||||||
|
|
||||||
/artifacts
|
/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);
|
setIsSaving(true);
|
||||||
setErrorMessage("");
|
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) {
|
if (targetServerId && serverId !== targetServerId) {
|
||||||
const message = `That endpoint belongs to ${serverId}, not ${targetServerId}.`;
|
const message = `That endpoint belongs to ${serverId}, not ${targetServerId}.`;
|
||||||
setErrorMessage(message);
|
setErrorMessage(message);
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest'
|
|||||||
import {
|
import {
|
||||||
hostHasDirectEndpoint,
|
hostHasDirectEndpoint,
|
||||||
registryHasDirectEndpoint,
|
registryHasDirectEndpoint,
|
||||||
|
reconcileDesktopStartupRegistry,
|
||||||
type HostProfile,
|
type HostProfile,
|
||||||
} from './daemon-registry-context'
|
} from './daemon-registry-context'
|
||||||
|
|
||||||
@@ -10,6 +11,12 @@ function makeHost(input: Partial<HostProfile> & Pick<HostProfile, 'serverId'>):
|
|||||||
return {
|
return {
|
||||||
serverId: input.serverId,
|
serverId: input.serverId,
|
||||||
label: input.label ?? input.serverId,
|
label: input.label ?? input.serverId,
|
||||||
|
lifecycle: input.lifecycle ?? {
|
||||||
|
managed: false,
|
||||||
|
managedRuntimeId: null,
|
||||||
|
managedRuntimeVersion: null,
|
||||||
|
associatedServerId: null,
|
||||||
|
},
|
||||||
connections: input.connections ?? [],
|
connections: input.connections ?? [],
|
||||||
preferredConnectionId: input.preferredConnectionId ?? null,
|
preferredConnectionId: input.preferredConnectionId ?? null,
|
||||||
createdAt: input.createdAt ?? now,
|
createdAt: input.createdAt ?? now,
|
||||||
@@ -21,7 +28,7 @@ describe('hostHasDirectEndpoint', () => {
|
|||||||
it('returns true when host has matching direct endpoint', () => {
|
it('returns true when host has matching direct endpoint', () => {
|
||||||
const host = makeHost({
|
const host = makeHost({
|
||||||
serverId: 'srv_local',
|
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',
|
preferredConnectionId: 'direct:localhost:6767',
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -51,12 +58,12 @@ describe('registryHasDirectEndpoint', () => {
|
|||||||
const hosts: HostProfile[] = [
|
const hosts: HostProfile[] = [
|
||||||
makeHost({
|
makeHost({
|
||||||
serverId: 'srv_one',
|
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',
|
preferredConnectionId: 'direct:127.0.0.1:7777',
|
||||||
}),
|
}),
|
||||||
makeHost({
|
makeHost({
|
||||||
serverId: 'srv_two',
|
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',
|
preferredConnectionId: 'direct:localhost:6767',
|
||||||
}),
|
}),
|
||||||
]
|
]
|
||||||
@@ -68,7 +75,7 @@ describe('registryHasDirectEndpoint', () => {
|
|||||||
const hosts: HostProfile[] = [
|
const hosts: HostProfile[] = [
|
||||||
makeHost({
|
makeHost({
|
||||||
serverId: 'srv_one',
|
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',
|
preferredConnectionId: 'direct:127.0.0.1:7777',
|
||||||
}),
|
}),
|
||||||
]
|
]
|
||||||
@@ -76,3 +83,160 @@ describe('registryHasDirectEndpoint', () => {
|
|||||||
expect(registryHasDirectEndpoint(hosts, 'localhost:6767')).toBe(false)
|
expect(registryHasDirectEndpoint(hosts, 'localhost:6767')).toBe(false)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('reconcileDesktopStartupRegistry', () => {
|
||||||
|
it('seeds managed and localhost connections as normal host entries', () => {
|
||||||
|
const now = '2026-03-08T00:00:00.000Z'
|
||||||
|
|
||||||
|
const result = reconcileDesktopStartupRegistry({
|
||||||
|
existing: [],
|
||||||
|
managed: {
|
||||||
|
serverId: 'srv_managed',
|
||||||
|
hostname: 'managed-host',
|
||||||
|
runtimeId: 'runtime_1',
|
||||||
|
runtimeVersion: '1.2.3',
|
||||||
|
transportType: 'socket',
|
||||||
|
transportPath: '/Users/test/.paseo-test/paseo.sock',
|
||||||
|
associatedServerId: 'srv_managed',
|
||||||
|
},
|
||||||
|
localhost: {
|
||||||
|
serverId: 'srv_localhost',
|
||||||
|
hostname: 'local-dev',
|
||||||
|
endpoint: 'localhost:6767',
|
||||||
|
},
|
||||||
|
now,
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(result).toEqual([
|
||||||
|
makeHost({
|
||||||
|
serverId: 'srv_managed',
|
||||||
|
label: 'managed-host',
|
||||||
|
lifecycle: {
|
||||||
|
managed: true,
|
||||||
|
managedRuntimeId: 'runtime_1',
|
||||||
|
managedRuntimeVersion: '1.2.3',
|
||||||
|
associatedServerId: 'srv_managed',
|
||||||
|
},
|
||||||
|
connections: [
|
||||||
|
{
|
||||||
|
id: 'socket:/Users/test/.paseo-test/paseo.sock',
|
||||||
|
type: 'directSocket',
|
||||||
|
path: '/Users/test/.paseo-test/paseo.sock',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
preferredConnectionId: 'socket:/Users/test/.paseo-test/paseo.sock',
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
}),
|
||||||
|
makeHost({
|
||||||
|
serverId: 'srv_localhost',
|
||||||
|
label: 'local-dev',
|
||||||
|
connections: [
|
||||||
|
{
|
||||||
|
id: 'direct:localhost:6767',
|
||||||
|
type: 'directTcp',
|
||||||
|
endpoint: 'localhost:6767',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
preferredConnectionId: 'direct:localhost:6767',
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
}),
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps managed and localhost connections together when they resolve to the same server', () => {
|
||||||
|
const now = '2026-03-08T00:00:00.000Z'
|
||||||
|
|
||||||
|
const result = reconcileDesktopStartupRegistry({
|
||||||
|
existing: [],
|
||||||
|
managed: {
|
||||||
|
serverId: 'srv_shared',
|
||||||
|
hostname: 'devbox',
|
||||||
|
runtimeId: 'runtime_1',
|
||||||
|
runtimeVersion: '1.2.3',
|
||||||
|
transportType: 'socket',
|
||||||
|
transportPath: '/Users/test/.paseo-test/paseo.sock',
|
||||||
|
associatedServerId: 'srv_shared',
|
||||||
|
},
|
||||||
|
localhost: {
|
||||||
|
serverId: 'srv_shared',
|
||||||
|
hostname: 'devbox',
|
||||||
|
endpoint: 'localhost:6767',
|
||||||
|
},
|
||||||
|
now,
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(result).toEqual([
|
||||||
|
makeHost({
|
||||||
|
serverId: 'srv_shared',
|
||||||
|
label: 'devbox',
|
||||||
|
lifecycle: {
|
||||||
|
managed: true,
|
||||||
|
managedRuntimeId: 'runtime_1',
|
||||||
|
managedRuntimeVersion: '1.2.3',
|
||||||
|
associatedServerId: 'srv_shared',
|
||||||
|
},
|
||||||
|
connections: [
|
||||||
|
{
|
||||||
|
id: 'socket:/Users/test/.paseo-test/paseo.sock',
|
||||||
|
type: 'directSocket',
|
||||||
|
path: '/Users/test/.paseo-test/paseo.sock',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'direct:localhost:6767',
|
||||||
|
type: 'directTcp',
|
||||||
|
endpoint: 'localhost:6767',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
preferredConnectionId: 'socket:/Users/test/.paseo-test/paseo.sock',
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
}),
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('is idempotent for repeated desktop startup reconciliation', () => {
|
||||||
|
const now = '2026-03-08T00:00:00.000Z'
|
||||||
|
|
||||||
|
const first = reconcileDesktopStartupRegistry({
|
||||||
|
existing: [],
|
||||||
|
managed: {
|
||||||
|
serverId: 'srv_shared',
|
||||||
|
hostname: 'devbox',
|
||||||
|
runtimeId: 'runtime_1',
|
||||||
|
runtimeVersion: '1.2.3',
|
||||||
|
transportType: 'socket',
|
||||||
|
transportPath: '/Users/test/.paseo-test/paseo.sock',
|
||||||
|
associatedServerId: 'srv_shared',
|
||||||
|
},
|
||||||
|
localhost: {
|
||||||
|
serverId: 'srv_shared',
|
||||||
|
hostname: 'devbox',
|
||||||
|
endpoint: 'localhost:6767',
|
||||||
|
},
|
||||||
|
now,
|
||||||
|
})
|
||||||
|
|
||||||
|
const second = reconcileDesktopStartupRegistry({
|
||||||
|
existing: first,
|
||||||
|
managed: {
|
||||||
|
serverId: 'srv_shared',
|
||||||
|
hostname: 'devbox',
|
||||||
|
runtimeId: 'runtime_1',
|
||||||
|
runtimeVersion: '1.2.3',
|
||||||
|
transportType: 'socket',
|
||||||
|
transportPath: '/Users/test/.paseo-test/paseo.sock',
|
||||||
|
associatedServerId: 'srv_shared',
|
||||||
|
},
|
||||||
|
localhost: {
|
||||||
|
serverId: 'srv_shared',
|
||||||
|
hostname: 'devbox',
|
||||||
|
endpoint: 'localhost:6767',
|
||||||
|
},
|
||||||
|
now: '2026-03-09T00:00:00.000Z',
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(second).toEqual(first)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
@@ -5,32 +5,64 @@ import { useQuery, useQueryClient } from '@tanstack/react-query'
|
|||||||
import { decodeOfferFragmentPayload, normalizeHostPort } from '@/utils/daemon-endpoints'
|
import { decodeOfferFragmentPayload, normalizeHostPort } from '@/utils/daemon-endpoints'
|
||||||
import { probeConnection } from '@/utils/test-daemon-connection'
|
import { probeConnection } from '@/utils/test-daemon-connection'
|
||||||
import { ConnectionOfferSchema, type ConnectionOffer } from '@server/shared/connection-offer'
|
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 REGISTRY_STORAGE_KEY = '@paseo:daemon-registry'
|
||||||
const DAEMON_REGISTRY_QUERY_KEY = ['daemon-registry']
|
const DAEMON_REGISTRY_QUERY_KEY = ['daemon-registry']
|
||||||
const DEFAULT_LOCALHOST_ENDPOINT = 'localhost:6767'
|
const DEFAULT_LOCALHOST_ENDPOINT = 'localhost:6767'
|
||||||
const DEFAULT_LOCALHOST_BOOTSTRAP_KEY = '@paseo:default-localhost-bootstrap-v1'
|
const DEFAULT_LOCALHOST_BOOTSTRAP_KEY = '@paseo:default-localhost-bootstrap-v1'
|
||||||
const DEFAULT_LOCALHOST_BOOTSTRAP_TIMEOUT_MS = 2500
|
const DEFAULT_LOCALHOST_BOOTSTRAP_TIMEOUT_MS = 2500
|
||||||
|
const DEFAULT_LOCAL_TRANSPORT_BOOTSTRAP_TIMEOUT_MS = 6000
|
||||||
|
const DEFAULT_LOCAL_TRANSPORT_BOOTSTRAP_RETRY_MS = 2000
|
||||||
|
const DEFAULT_LOCAL_TRANSPORT_BOOTSTRAP_DEADLINE_MS = 120000
|
||||||
const E2E_STORAGE_KEY = '@paseo:e2e'
|
const E2E_STORAGE_KEY = '@paseo:e2e'
|
||||||
|
|
||||||
export type DirectHostConnection = {
|
export type DirectTcpHostConnection = {
|
||||||
id: string
|
id: string
|
||||||
type: 'direct'
|
type: 'directTcp'
|
||||||
endpoint: string // host:port
|
endpoint: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type DirectSocketHostConnection = {
|
||||||
|
id: string
|
||||||
|
type: 'directSocket'
|
||||||
|
path: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type DirectPipeHostConnection = {
|
||||||
|
id: string
|
||||||
|
type: 'directPipe'
|
||||||
|
path: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export type RelayHostConnection = {
|
export type RelayHostConnection = {
|
||||||
id: string
|
id: string
|
||||||
type: 'relay'
|
type: 'relay'
|
||||||
relayEndpoint: string // host:port
|
relayEndpoint: string
|
||||||
daemonPublicKeyB64: 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 = {
|
export type HostProfile = {
|
||||||
serverId: string
|
serverId: string
|
||||||
label: string
|
label: string
|
||||||
|
lifecycle: HostLifecycle
|
||||||
connections: HostConnection[]
|
connections: HostConnection[]
|
||||||
preferredConnectionId: string | null
|
preferredConnectionId: string | null
|
||||||
createdAt: string
|
createdAt: string
|
||||||
@@ -39,6 +71,29 @@ export type HostProfile = {
|
|||||||
|
|
||||||
export type UpdateHostInput = Partial<Omit<HostProfile, 'serverId' | 'createdAt'>>
|
export type UpdateHostInput = Partial<Omit<HostProfile, 'serverId' | 'createdAt'>>
|
||||||
|
|
||||||
|
export type ManagedHostReconciliationInput = {
|
||||||
|
serverId: string
|
||||||
|
hostname?: string | null
|
||||||
|
runtimeId: string
|
||||||
|
runtimeVersion: string
|
||||||
|
transportType: string
|
||||||
|
transportPath: string
|
||||||
|
associatedServerId?: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export type LocalhostHostReconciliationInput = {
|
||||||
|
serverId: string
|
||||||
|
hostname: string | null
|
||||||
|
endpoint: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type DesktopStartupReconciliationInput = {
|
||||||
|
existing: HostProfile[]
|
||||||
|
managed: ManagedHostReconciliationInput | null
|
||||||
|
localhost: LocalhostHostReconciliationInput | null
|
||||||
|
now?: string
|
||||||
|
}
|
||||||
|
|
||||||
interface DaemonRegistryContextValue {
|
interface DaemonRegistryContextValue {
|
||||||
daemons: HostProfile[]
|
daemons: HostProfile[]
|
||||||
isLoading: boolean
|
isLoading: boolean
|
||||||
@@ -63,6 +118,20 @@ interface DaemonRegistryContextValue {
|
|||||||
|
|
||||||
const DaemonRegistryContext = createContext<DaemonRegistryContextValue | null>(null)
|
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 {
|
function normalizeEndpointOrNull(endpoint: string): string | null {
|
||||||
try {
|
try {
|
||||||
return normalizeHostPort(endpoint)
|
return normalizeHostPort(endpoint)
|
||||||
@@ -71,8 +140,389 @@ function normalizeEndpointOrNull(endpoint: string): string | null {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function isDefaultLocalhostConnection(connection: HostConnection): boolean {
|
function sleep(ms: number): Promise<void> {
|
||||||
return connection.type === 'direct' && connection.endpoint === DEFAULT_LOCALHOST_ENDPOINT
|
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 {
|
export function hostHasDirectEndpoint(host: HostProfile, endpoint: string): boolean {
|
||||||
@@ -81,7 +531,7 @@ export function hostHasDirectEndpoint(host: HostProfile, endpoint: string): bool
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
return host.connections.some(
|
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 }) {
|
export function DaemonRegistryProvider({ children }: { children: ReactNode }) {
|
||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
|
const desktopStartupReconciledRef = useRef(false)
|
||||||
const localhostBootstrapAttemptedRef = useRef(false)
|
const localhostBootstrapAttemptedRef = useRef(false)
|
||||||
const {
|
const {
|
||||||
data: daemons = [],
|
data: daemons = [],
|
||||||
@@ -123,10 +574,6 @@ export function DaemonRegistryProvider({ children }: { children: ReactNode }) {
|
|||||||
return queryClient.getQueryData<HostProfile[]>(DAEMON_REGISTRY_QUERY_KEY) ?? daemons
|
return queryClient.getQueryData<HostProfile[]>(DAEMON_REGISTRY_QUERY_KEY) ?? daemons
|
||||||
}, [queryClient, daemons])
|
}, [queryClient, daemons])
|
||||||
|
|
||||||
const markDefaultLocalhostBootstrapHandled = useCallback(async () => {
|
|
||||||
await AsyncStorage.setItem(DEFAULT_LOCALHOST_BOOTSTRAP_KEY, '1')
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const updateHost = useCallback(
|
const updateHost = useCallback(
|
||||||
async (serverId: string, updates: UpdateHostInput) => {
|
async (serverId: string, updates: UpdateHostInput) => {
|
||||||
const next = readDaemons().map((daemon) =>
|
const next = readDaemons().map((daemon) =>
|
||||||
@@ -146,23 +593,15 @@ export function DaemonRegistryProvider({ children }: { children: ReactNode }) {
|
|||||||
const removeHost = useCallback(
|
const removeHost = useCallback(
|
||||||
async (serverId: string) => {
|
async (serverId: string) => {
|
||||||
const existing = readDaemons()
|
const existing = readDaemons()
|
||||||
const removedHost = existing.find((daemon) => daemon.serverId === serverId) ?? null
|
|
||||||
const remaining = existing.filter((daemon) => daemon.serverId !== serverId)
|
const remaining = existing.filter((daemon) => daemon.serverId !== serverId)
|
||||||
await persist(remaining)
|
await persist(remaining)
|
||||||
if (removedHost && hostHasDirectEndpoint(removedHost, DEFAULT_LOCALHOST_ENDPOINT)) {
|
|
||||||
await markDefaultLocalhostBootstrapHandled()
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
[markDefaultLocalhostBootstrapHandled, persist, readDaemons]
|
[persist, readDaemons]
|
||||||
)
|
)
|
||||||
|
|
||||||
const removeConnection = useCallback(
|
const removeConnection = useCallback(
|
||||||
async (serverId: string, connectionId: string) => {
|
async (serverId: string, connectionId: string) => {
|
||||||
const existing = readDaemons()
|
const existing = readDaemons()
|
||||||
const removedConnection =
|
|
||||||
existing
|
|
||||||
.find((daemon) => daemon.serverId === serverId)
|
|
||||||
?.connections.find((connection) => connection.id === connectionId) ?? null
|
|
||||||
const now = new Date().toISOString()
|
const now = new Date().toISOString()
|
||||||
const next = existing
|
const next = existing
|
||||||
.map((daemon) => {
|
.map((daemon) => {
|
||||||
@@ -184,64 +623,28 @@ export function DaemonRegistryProvider({ children }: { children: ReactNode }) {
|
|||||||
})
|
})
|
||||||
.filter((entry): entry is HostProfile => entry !== null)
|
.filter((entry): entry is HostProfile => entry !== null)
|
||||||
await persist(next)
|
await persist(next)
|
||||||
if (removedConnection && isDefaultLocalhostConnection(removedConnection)) {
|
|
||||||
await markDefaultLocalhostBootstrapHandled()
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
[markDefaultLocalhostBootstrapHandled, persist, readDaemons]
|
[persist, readDaemons]
|
||||||
)
|
)
|
||||||
|
|
||||||
const upsertHostConnection = useCallback(
|
const upsertHostConnection = useCallback(
|
||||||
async (
|
async (input: {
|
||||||
input: {
|
serverId: string
|
||||||
serverId: string
|
label?: string
|
||||||
label?: string
|
lifecycle?: Partial<HostLifecycle>
|
||||||
} & ({ connection: DirectHostConnection } | { connection: RelayHostConnection })
|
connection: HostConnection
|
||||||
) => {
|
}) => {
|
||||||
const existing = readDaemons()
|
|
||||||
const now = new Date().toISOString()
|
const now = new Date().toISOString()
|
||||||
const serverId = input.serverId.trim()
|
const next = upsertHostConnectionInProfiles({
|
||||||
if (!serverId) {
|
profiles: readDaemons(),
|
||||||
throw new Error('serverId is required')
|
serverId: input.serverId,
|
||||||
}
|
label: input.label,
|
||||||
|
lifecycle: input.lifecycle,
|
||||||
const labelTrimmed = input.label?.trim() ?? ''
|
connection: input.connection,
|
||||||
const derivedLabel = labelTrimmed || serverId
|
now,
|
||||||
|
})
|
||||||
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
|
|
||||||
await persist(next)
|
await persist(next)
|
||||||
return nextProfile
|
return next.find((daemon) => daemon.serverId === input.serverId) as HostProfile
|
||||||
},
|
},
|
||||||
[persist, readDaemons]
|
[persist, readDaemons]
|
||||||
)
|
)
|
||||||
@@ -249,15 +652,14 @@ export function DaemonRegistryProvider({ children }: { children: ReactNode }) {
|
|||||||
const upsertDirectConnection = useCallback(
|
const upsertDirectConnection = useCallback(
|
||||||
async (input: { serverId: string; endpoint: string; label?: string }) => {
|
async (input: { serverId: string; endpoint: string; label?: string }) => {
|
||||||
const endpoint = normalizeHostPort(input.endpoint)
|
const endpoint = normalizeHostPort(input.endpoint)
|
||||||
const connection: DirectHostConnection = {
|
|
||||||
id: `direct:${endpoint}`,
|
|
||||||
type: 'direct',
|
|
||||||
endpoint,
|
|
||||||
}
|
|
||||||
return upsertHostConnection({
|
return upsertHostConnection({
|
||||||
serverId: input.serverId,
|
serverId: input.serverId,
|
||||||
label: input.label,
|
label: input.label,
|
||||||
connection,
|
connection: {
|
||||||
|
id: `direct:${endpoint}`,
|
||||||
|
type: 'directTcp',
|
||||||
|
endpoint,
|
||||||
|
},
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
[upsertHostConnection]
|
[upsertHostConnection]
|
||||||
@@ -265,24 +667,114 @@ export function DaemonRegistryProvider({ children }: { children: ReactNode }) {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isPending) return
|
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
|
if (localhostBootstrapAttemptedRef.current) return
|
||||||
localhostBootstrapAttemptedRef.current = true
|
localhostBootstrapAttemptedRef.current = true
|
||||||
|
|
||||||
let cancelled = false
|
let cancelled = false
|
||||||
|
|
||||||
const bootstrapDefaultLocalhost = async () => {
|
const bootstrapLocalhost = async () => {
|
||||||
try {
|
try {
|
||||||
const [isE2E, alreadyHandled] = await Promise.all([
|
const isE2E = await AsyncStorage.getItem(E2E_STORAGE_KEY)
|
||||||
AsyncStorage.getItem(E2E_STORAGE_KEY),
|
if (cancelled || isE2E) {
|
||||||
AsyncStorage.getItem(DEFAULT_LOCALHOST_BOOTSTRAP_KEY),
|
return
|
||||||
])
|
}
|
||||||
if (cancelled || isE2E || alreadyHandled) {
|
|
||||||
|
const alreadyHandled = await AsyncStorage.getItem(DEFAULT_LOCALHOST_BOOTSTRAP_KEY)
|
||||||
|
if (cancelled || alreadyHandled) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const existing = readDaemons()
|
const existing = readDaemons()
|
||||||
if (registryHasDirectEndpoint(existing, DEFAULT_LOCALHOST_ENDPOINT)) {
|
if (registryHasDirectEndpoint(existing, DEFAULT_LOCALHOST_ENDPOINT)) {
|
||||||
await markDefaultLocalhostBootstrapHandled()
|
await AsyncStorage.setItem(DEFAULT_LOCALHOST_BOOTSTRAP_KEY, '1')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -290,7 +782,7 @@ export function DaemonRegistryProvider({ children }: { children: ReactNode }) {
|
|||||||
const { serverId, hostname } = await probeConnection(
|
const { serverId, hostname } = await probeConnection(
|
||||||
{
|
{
|
||||||
id: `bootstrap:${DEFAULT_LOCALHOST_ENDPOINT}`,
|
id: `bootstrap:${DEFAULT_LOCALHOST_ENDPOINT}`,
|
||||||
type: 'direct',
|
type: 'directTcp',
|
||||||
endpoint: DEFAULT_LOCALHOST_ENDPOINT,
|
endpoint: DEFAULT_LOCALHOST_ENDPOINT,
|
||||||
},
|
},
|
||||||
{ timeoutMs: DEFAULT_LOCALHOST_BOOTSTRAP_TIMEOUT_MS }
|
{ timeoutMs: DEFAULT_LOCALHOST_BOOTSTRAP_TIMEOUT_MS }
|
||||||
@@ -302,25 +794,26 @@ export function DaemonRegistryProvider({ children }: { children: ReactNode }) {
|
|||||||
endpoint: DEFAULT_LOCALHOST_ENDPOINT,
|
endpoint: DEFAULT_LOCALHOST_ENDPOINT,
|
||||||
label: hostname ?? undefined,
|
label: hostname ?? undefined,
|
||||||
})
|
})
|
||||||
await markDefaultLocalhostBootstrapHandled()
|
await AsyncStorage.setItem(DEFAULT_LOCALHOST_BOOTSTRAP_KEY, '1')
|
||||||
} catch {
|
} catch {
|
||||||
// Best-effort bootstrap only; keep startup resilient if localhost isn't reachable.
|
// Best-effort bootstrap only; keep startup resilient if localhost isn't reachable.
|
||||||
}
|
}
|
||||||
} catch (bootstrapError) {
|
} catch (bootstrapError) {
|
||||||
if (cancelled) return
|
if (cancelled) return
|
||||||
console.warn(
|
console.warn('[DaemonRegistry] Failed to bootstrap host connections', bootstrapError)
|
||||||
'[DaemonRegistry] Failed to bootstrap default localhost connection',
|
|
||||||
bootstrapError
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void bootstrapDefaultLocalhost()
|
void bootstrapLocalhost()
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true
|
cancelled = true
|
||||||
}
|
}
|
||||||
}, [isPending, markDefaultLocalhostBootstrapHandled, readDaemons, upsertDirectConnection])
|
}, [
|
||||||
|
isPending,
|
||||||
|
readDaemons,
|
||||||
|
upsertDirectConnection,
|
||||||
|
])
|
||||||
|
|
||||||
const upsertRelayConnection = useCallback(
|
const upsertRelayConnection = useCallback(
|
||||||
async (input: {
|
async (input: {
|
||||||
@@ -334,16 +827,15 @@ export function DaemonRegistryProvider({ children }: { children: ReactNode }) {
|
|||||||
if (!daemonPublicKeyB64) {
|
if (!daemonPublicKeyB64) {
|
||||||
throw new Error('daemonPublicKeyB64 is required')
|
throw new Error('daemonPublicKeyB64 is required')
|
||||||
}
|
}
|
||||||
const connection: RelayHostConnection = {
|
|
||||||
id: `relay:${relayEndpoint}`,
|
|
||||||
type: 'relay',
|
|
||||||
relayEndpoint,
|
|
||||||
daemonPublicKeyB64,
|
|
||||||
}
|
|
||||||
return upsertHostConnection({
|
return upsertHostConnection({
|
||||||
serverId: input.serverId,
|
serverId: input.serverId,
|
||||||
label: input.label,
|
label: input.label,
|
||||||
connection,
|
connection: {
|
||||||
|
id: `relay:${relayEndpoint}`,
|
||||||
|
type: 'relay',
|
||||||
|
relayEndpoint,
|
||||||
|
daemonPublicKeyB64,
|
||||||
|
},
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
[upsertHostConnection]
|
[upsertHostConnection]
|
||||||
@@ -394,108 +886,21 @@ export function DaemonRegistryProvider({ children }: { children: ReactNode }) {
|
|||||||
return <DaemonRegistryContext.Provider value={value}>{children}</DaemonRegistryContext.Provider>
|
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[]> {
|
async function loadDaemonRegistryFromStorage(): Promise<HostProfile[]> {
|
||||||
try {
|
try {
|
||||||
const stored = await AsyncStorage.getItem(REGISTRY_STORAGE_KEY)
|
const stored = await AsyncStorage.getItem(REGISTRY_STORAGE_KEY)
|
||||||
if (stored) {
|
if (!stored) {
|
||||||
const parsed = JSON.parse(stored) as unknown
|
return []
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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) {
|
} catch (error) {
|
||||||
console.error('[DaemonRegistry] Failed to load daemon registry', error)
|
console.error('[DaemonRegistry] Failed to load daemon registry', error)
|
||||||
throw error
|
throw error
|
||||||
|
|||||||
@@ -1,64 +1,104 @@
|
|||||||
import { useCallback, useState } from "react";
|
import { useCallback, useEffect, useState } from "react";
|
||||||
import { Alert, Text, View } from "react-native";
|
import { ActivityIndicator, Alert, Image, Text, View } from "react-native";
|
||||||
import * as Clipboard from "expo-clipboard";
|
import * as Clipboard from "expo-clipboard";
|
||||||
|
import * as QRCode from "qrcode";
|
||||||
import { useFocusEffect } from "@react-navigation/native";
|
import { useFocusEffect } from "@react-navigation/native";
|
||||||
import { StyleSheet } from "react-native-unistyles";
|
import { StyleSheet } from "react-native-unistyles";
|
||||||
|
import { AdaptiveModalSheet, AdaptiveTextInput } from "@/components/adaptive-modal-sheet";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { confirmDialog } from "@/utils/confirm-dialog";
|
import { confirmDialog } from "@/utils/confirm-dialog";
|
||||||
import {
|
import {
|
||||||
buildDaemonUpdateDiagnostics,
|
|
||||||
formatVersionWithPrefix,
|
formatVersionWithPrefix,
|
||||||
getLocalDaemonVersion,
|
|
||||||
isVersionMismatch,
|
isVersionMismatch,
|
||||||
runLocalDaemonUpdate,
|
|
||||||
shouldShowDesktopUpdateSection,
|
|
||||||
} from "@/desktop/updates/desktop-updates";
|
} 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 {
|
export interface LocalDaemonSectionProps {
|
||||||
appVersion: string | null;
|
appVersion: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function LocalDaemonSection({ appVersion }: LocalDaemonSectionProps) {
|
export function LocalDaemonSection({ appVersion }: LocalDaemonSectionProps) {
|
||||||
const showSection = shouldShowDesktopUpdateSection();
|
const showSection = shouldUseManagedDesktopDaemon();
|
||||||
const [localDaemonVersion, setLocalDaemonVersion] = useState<string | null>(null);
|
const [managedStatus, setManagedStatus] = useState<ManagedDaemonStatus | null>(null);
|
||||||
const [localDaemonVersionError, setLocalDaemonVersionError] = useState<string | null>(null);
|
const [statusError, setStatusError] = useState<string | null>(null);
|
||||||
const [isUpdatingLocalDaemon, setIsUpdatingLocalDaemon] = useState(false);
|
const [isRestartingDaemon, setIsRestartingDaemon] = useState(false);
|
||||||
const [localDaemonUpdateMessage, setLocalDaemonUpdateMessage] = useState<string | null>(null);
|
const [isInstallingCli, setIsInstallingCli] = useState(false);
|
||||||
const [localDaemonUpdateDiagnostics, setLocalDaemonUpdateDiagnostics] = useState<string | null>(
|
const [isSavingTcpSettings, setIsSavingTcpSettings] = useState(false);
|
||||||
null
|
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(
|
useFocusEffect(
|
||||||
useCallback(() => {
|
useCallback(() => {
|
||||||
if (!showSection) {
|
if (!showSection) {
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
void loadManagedStatus();
|
||||||
void getLocalDaemonVersion().then((result) => {
|
|
||||||
setLocalDaemonVersion(result.version);
|
|
||||||
setLocalDaemonVersionError(result.error);
|
|
||||||
});
|
|
||||||
return undefined;
|
return undefined;
|
||||||
}, [showSection])
|
}, [loadManagedStatus, showSection])
|
||||||
);
|
);
|
||||||
|
|
||||||
const localDaemonVersionText = formatVersionWithPrefix(localDaemonVersion);
|
const localDaemonVersionText = formatVersionWithPrefix(managedStatus?.runtimeVersion ?? null);
|
||||||
const daemonVersionMismatch = isVersionMismatch(appVersion, localDaemonVersion);
|
const daemonVersionMismatch = isVersionMismatch(appVersion, managedStatus?.runtimeVersion ?? null);
|
||||||
const daemonVersionHint = localDaemonVersionError ?? "Daemon installed on this computer.";
|
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(() => {
|
const handleUpdateLocalDaemon = useCallback(() => {
|
||||||
if (!showSection) {
|
if (!showSection) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (isUpdatingLocalDaemon) {
|
if (isRestartingDaemon) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
void confirmDialog({
|
void confirmDialog({
|
||||||
title: "Update local daemon",
|
title: "Restart managed daemon",
|
||||||
message:
|
message:
|
||||||
"This updates the Paseo daemon on this computer. A restart is required afterwards.",
|
"This restarts the desktop-managed daemon using its private managed home and socket.",
|
||||||
confirmLabel: "Update daemon",
|
confirmLabel: "Restart daemon",
|
||||||
cancelLabel: "Cancel",
|
cancelLabel: "Cancel",
|
||||||
})
|
})
|
||||||
.then((confirmed) => {
|
.then((confirmed) => {
|
||||||
@@ -66,71 +106,201 @@ export function LocalDaemonSection({ appVersion }: LocalDaemonSectionProps) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setIsUpdatingLocalDaemon(true);
|
setIsRestartingDaemon(true);
|
||||||
setLocalDaemonUpdateMessage(null);
|
setStatusMessage(null);
|
||||||
setLocalDaemonUpdateDiagnostics(null);
|
|
||||||
|
|
||||||
void runLocalDaemonUpdate()
|
void restartManagedDaemon()
|
||||||
.then((result) => {
|
.then((status) => {
|
||||||
const diagnostics = buildDaemonUpdateDiagnostics(result);
|
setManagedStatus(status);
|
||||||
if (result.exitCode !== 0) {
|
setStatusMessage("Managed daemon restarted.");
|
||||||
setLocalDaemonUpdateMessage(
|
return loadManagedStatus();
|
||||||
`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);
|
|
||||||
});
|
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.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);
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
setLocalDaemonUpdateMessage(
|
setStatusMessage(`Managed daemon restart failed: ${message}`);
|
||||||
"Local daemon update failed before completion. Copy diagnostics below to troubleshoot."
|
|
||||||
);
|
|
||||||
setLocalDaemonUpdateDiagnostics(
|
|
||||||
buildDaemonUpdateDiagnostics({
|
|
||||||
exitCode: -1,
|
|
||||||
stdout: "",
|
|
||||||
stderr: message,
|
|
||||||
})
|
|
||||||
);
|
|
||||||
})
|
})
|
||||||
.finally(() => {
|
.finally(() => {
|
||||||
setIsUpdatingLocalDaemon(false);
|
setIsRestartingDaemon(false);
|
||||||
});
|
});
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
console.error("[Settings] Failed to open daemon update confirmation", error);
|
console.error("[Settings] Failed to open managed daemon restart confirmation", error);
|
||||||
Alert.alert("Error", "Unable to open the daemon update confirmation dialog.");
|
Alert.alert("Error", "Unable to open the managed daemon restart confirmation dialog.");
|
||||||
});
|
});
|
||||||
}, [isUpdatingLocalDaemon, showSection]);
|
}, [isRestartingDaemon, loadManagedStatus, showSection]);
|
||||||
|
|
||||||
const handleCopyDaemonDiagnostics = useCallback(() => {
|
const handleToggleCliShim = useCallback(() => {
|
||||||
if (!localDaemonUpdateDiagnostics) {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
void Clipboard.setStringAsync(localDaemonUpdateDiagnostics)
|
void Clipboard.setStringAsync(logPath)
|
||||||
.then(() => {
|
.then(() => {
|
||||||
Alert.alert("Copied", "Daemon update diagnostics copied.");
|
Alert.alert("Copied", "Managed daemon log path copied.");
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
console.error("[Settings] Failed to copy daemon update diagnostics", error);
|
console.error("[Settings] Failed to copy managed daemon log path", error);
|
||||||
Alert.alert("Error", "Unable to copy diagnostics.");
|
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) {
|
if (!showSection) {
|
||||||
return null;
|
return null;
|
||||||
@@ -138,7 +308,7 @@ export function LocalDaemonSection({ appVersion }: LocalDaemonSectionProps) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={styles.section}>
|
<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.card}>
|
||||||
<View style={styles.row}>
|
<View style={styles.row}>
|
||||||
<View style={styles.rowContent}>
|
<View style={styles.rowContent}>
|
||||||
@@ -149,47 +319,309 @@ export function LocalDaemonSection({ appVersion }: LocalDaemonSectionProps) {
|
|||||||
</View>
|
</View>
|
||||||
<View style={[styles.row, styles.rowBorder]}>
|
<View style={[styles.row, styles.rowBorder]}>
|
||||||
<View style={styles.rowContent}>
|
<View style={styles.rowContent}>
|
||||||
<Text style={styles.rowTitle}>Update daemon</Text>
|
<Text style={styles.rowTitle}>Restart daemon</Text>
|
||||||
<Text style={styles.hintText}>
|
<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>
|
</Text>
|
||||||
{localDaemonUpdateMessage ? (
|
{statusMessage ? (
|
||||||
<Text style={styles.statusText}>{localDaemonUpdateMessage}</Text>
|
<Text style={styles.statusText}>{statusMessage}</Text>
|
||||||
) : null}
|
) : null}
|
||||||
</View>
|
</View>
|
||||||
<Button
|
<Button
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
size="sm"
|
size="sm"
|
||||||
onPress={handleUpdateLocalDaemon}
|
onPress={handleUpdateLocalDaemon}
|
||||||
disabled={isUpdatingLocalDaemon}
|
disabled={isRestartingDaemon}
|
||||||
>
|
>
|
||||||
{isUpdatingLocalDaemon ? "Updating..." : "Update daemon"}
|
{isRestartingDaemon ? "Restarting..." : "Restart daemon"}
|
||||||
</Button>
|
</Button>
|
||||||
</View>
|
</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>
|
</View>
|
||||||
|
|
||||||
{daemonVersionMismatch ? (
|
{daemonVersionMismatch ? (
|
||||||
<View style={styles.warningCard}>
|
<View style={styles.warningCard}>
|
||||||
<Text style={styles.warningText}>
|
<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.
|
stability issues or breaking changes.
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{localDaemonUpdateDiagnostics ? (
|
<AdaptiveModalSheet
|
||||||
<View style={styles.diagnosticsCard}>
|
visible={isTcpModalOpen}
|
||||||
<View style={styles.diagnosticsHeader}>
|
onClose={() => setIsTcpModalOpen(false)}
|
||||||
<Text style={styles.diagnosticsTitle}>Daemon update diagnostics</Text>
|
title="Managed TCP settings"
|
||||||
<Button variant="secondary" size="sm" onPress={handleCopyDaemonDiagnostics}>
|
>
|
||||||
Copy output
|
<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>
|
</Button>
|
||||||
</View>
|
</View>
|
||||||
<Text style={styles.diagnosticsText} selectable>
|
</View>
|
||||||
{localDaemonUpdateDiagnostics}
|
</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>
|
</Text>
|
||||||
</View>
|
</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>
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -227,6 +659,10 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
flex: 1,
|
flex: 1,
|
||||||
marginRight: theme.spacing[3],
|
marginRight: theme.spacing[3],
|
||||||
},
|
},
|
||||||
|
actionGroup: {
|
||||||
|
flexDirection: "row",
|
||||||
|
gap: theme.spacing[2],
|
||||||
|
},
|
||||||
rowTitle: {
|
rowTitle: {
|
||||||
color: theme.colors.foreground,
|
color: theme.colors.foreground,
|
||||||
fontSize: theme.fontSize.base,
|
fontSize: theme.fontSize.base,
|
||||||
@@ -259,28 +695,59 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
color: theme.colors.palette.amber[500],
|
color: theme.colors.palette.amber[500],
|
||||||
fontSize: theme.fontSize.xs,
|
fontSize: theme.fontSize.xs,
|
||||||
},
|
},
|
||||||
diagnosticsCard: {
|
modalBody: {
|
||||||
marginTop: theme.spacing[3],
|
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,
|
borderRadius: theme.borderRadius.lg,
|
||||||
borderWidth: 1,
|
borderWidth: 1,
|
||||||
borderColor: theme.colors.border,
|
borderColor: theme.colors.border,
|
||||||
backgroundColor: theme.colors.surface1,
|
backgroundColor: theme.colors.surface0,
|
||||||
padding: theme.spacing[3],
|
|
||||||
gap: theme.spacing[2],
|
|
||||||
},
|
},
|
||||||
diagnosticsHeader: {
|
qrImage: {
|
||||||
flexDirection: "row",
|
width: 220,
|
||||||
alignItems: "center",
|
height: 220,
|
||||||
justifyContent: "space-between",
|
|
||||||
gap: theme.spacing[2],
|
|
||||||
},
|
},
|
||||||
diagnosticsTitle: {
|
linkLabel: {
|
||||||
color: theme.colors.foreground,
|
color: theme.colors.foreground,
|
||||||
fontSize: theme.fontSize.sm,
|
fontSize: theme.fontSize.sm,
|
||||||
},
|
},
|
||||||
diagnosticsText: {
|
linkText: {
|
||||||
|
color: theme.colors.foregroundMuted,
|
||||||
|
fontSize: theme.fontSize.xs,
|
||||||
|
lineHeight: 18,
|
||||||
|
},
|
||||||
|
logOutput: {
|
||||||
color: theme.colors.foregroundMuted,
|
color: theme.colors.foregroundMuted,
|
||||||
fontSize: theme.fontSize.xs,
|
fontSize: theme.fontSize.xs,
|
||||||
fontFamily: "ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace",
|
fontFamily: "ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace",
|
||||||
|
lineHeight: 18,
|
||||||
|
},
|
||||||
|
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 {
|
function makeHost(input?: Partial<HostProfile>): HostProfile {
|
||||||
const direct: HostConnection = {
|
const direct: HostConnection = {
|
||||||
id: "direct:lan:6767",
|
id: "direct:lan:6767",
|
||||||
type: "direct",
|
type: "directTcp",
|
||||||
endpoint: "lan:6767",
|
endpoint: "lan:6767",
|
||||||
};
|
};
|
||||||
const relay: HostConnection = {
|
const relay: HostConnection = {
|
||||||
@@ -176,6 +176,12 @@ function makeHost(input?: Partial<HostProfile>): HostProfile {
|
|||||||
return {
|
return {
|
||||||
serverId: input?.serverId ?? "srv_test",
|
serverId: input?.serverId ?? "srv_test",
|
||||||
label: input?.label ?? "test host",
|
label: input?.label ?? "test host",
|
||||||
|
lifecycle: input?.lifecycle ?? {
|
||||||
|
managed: false,
|
||||||
|
managedRuntimeId: null,
|
||||||
|
managedRuntimeVersion: null,
|
||||||
|
associatedServerId: null,
|
||||||
|
},
|
||||||
connections: input?.connections ?? [direct, relay],
|
connections: input?.connections ?? [direct, relay],
|
||||||
preferredConnectionId: input?.preferredConnectionId ?? direct.id,
|
preferredConnectionId: input?.preferredConnectionId ?? direct.id,
|
||||||
createdAt: input?.createdAt ?? new Date(0).toISOString(),
|
createdAt: input?.createdAt ?? new Date(0).toISOString(),
|
||||||
@@ -227,7 +233,7 @@ describe("HostRuntimeController", () => {
|
|||||||
connections: [
|
connections: [
|
||||||
{
|
{
|
||||||
id: "direct:lan:6767",
|
id: "direct:lan:6767",
|
||||||
type: "direct",
|
type: "directTcp",
|
||||||
endpoint: "lan:6767",
|
endpoint: "lan:6767",
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -262,7 +268,7 @@ describe("HostRuntimeController", () => {
|
|||||||
connections: [
|
connections: [
|
||||||
{
|
{
|
||||||
id: "direct:lan:6767",
|
id: "direct:lan:6767",
|
||||||
type: "direct",
|
type: "directTcp",
|
||||||
endpoint: "lan:6767",
|
endpoint: "lan:6767",
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -438,7 +444,7 @@ describe("HostRuntimeController", () => {
|
|||||||
connections: [
|
connections: [
|
||||||
{
|
{
|
||||||
id: "direct:lan:6767",
|
id: "direct:lan:6767",
|
||||||
type: "direct",
|
type: "directTcp",
|
||||||
endpoint: "lan:6767",
|
endpoint: "lan:6767",
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -577,7 +583,7 @@ describe("HostRuntimeController", () => {
|
|||||||
connections: [
|
connections: [
|
||||||
{
|
{
|
||||||
id: "direct:lan:6767",
|
id: "direct:lan:6767",
|
||||||
type: "direct",
|
type: "directTcp",
|
||||||
endpoint: "lan:6767",
|
endpoint: "lan:6767",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -667,7 +673,7 @@ describe("HostRuntimeController", () => {
|
|||||||
connections: [
|
connections: [
|
||||||
{
|
{
|
||||||
id: "direct:lan:6767",
|
id: "direct:lan:6767",
|
||||||
type: "direct",
|
type: "directTcp",
|
||||||
endpoint: "lan:6767",
|
endpoint: "lan:6767",
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -723,7 +729,7 @@ describe("HostRuntimeController", () => {
|
|||||||
connections: [
|
connections: [
|
||||||
{
|
{
|
||||||
id: "direct:lan:6767",
|
id: "direct:lan:6767",
|
||||||
type: "direct",
|
type: "directTcp",
|
||||||
endpoint: "lan:6767",
|
endpoint: "lan:6767",
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -789,7 +795,7 @@ describe("HostRuntimeStore", () => {
|
|||||||
connections: [
|
connections: [
|
||||||
{
|
{
|
||||||
id: "direct:lan:6767",
|
id: "direct:lan:6767",
|
||||||
type: "direct",
|
type: "directTcp",
|
||||||
endpoint: "lan:6767",
|
endpoint: "lan:6767",
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -837,7 +843,7 @@ describe("HostRuntimeStore", () => {
|
|||||||
connections: [
|
connections: [
|
||||||
{
|
{
|
||||||
id: "direct:lan:6767",
|
id: "direct:lan:6767",
|
||||||
type: "direct",
|
type: "directTcp",
|
||||||
endpoint: "lan:6767",
|
endpoint: "lan:6767",
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -885,7 +891,7 @@ describe("HostRuntimeStore", () => {
|
|||||||
connections: [
|
connections: [
|
||||||
{
|
{
|
||||||
id: "direct:lan:6767",
|
id: "direct:lan:6767",
|
||||||
type: "direct",
|
type: "directTcp",
|
||||||
endpoint: "lan:6767",
|
endpoint: "lan:6767",
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -1045,7 +1051,7 @@ describe("HostRuntimeStore", () => {
|
|||||||
connections: [
|
connections: [
|
||||||
{
|
{
|
||||||
id: "direct:lan:6767",
|
id: "direct:lan:6767",
|
||||||
type: "direct",
|
type: "directTcp",
|
||||||
endpoint: "lan:6767",
|
endpoint: "lan:6767",
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -17,6 +17,10 @@ import {
|
|||||||
type ConnectionCandidate,
|
type ConnectionCandidate,
|
||||||
type ConnectionProbeState,
|
type ConnectionProbeState,
|
||||||
} from "@/utils/connection-selection";
|
} from "@/utils/connection-selection";
|
||||||
|
import {
|
||||||
|
buildLocalDaemonTransportUrl,
|
||||||
|
createTauriLocalDaemonTransportFactory,
|
||||||
|
} from "@/utils/managed-tauri-daemon-transport";
|
||||||
import { createTauriWebSocketTransportFactory } from "@/utils/tauri-daemon-transport";
|
import { createTauriWebSocketTransportFactory } from "@/utils/tauri-daemon-transport";
|
||||||
import { applyFetchedAgentDirectory } from "@/utils/agent-directory-sync";
|
import { applyFetchedAgentDirectory } from "@/utils/agent-directory-sync";
|
||||||
import { useSessionStore, type Agent } from "@/stores/session-store";
|
import { useSessionStore, type Agent } from "@/stores/session-store";
|
||||||
@@ -33,7 +37,9 @@ export type HostRuntimeConnectionStatus =
|
|||||||
| "error";
|
| "error";
|
||||||
|
|
||||||
export type ActiveConnection =
|
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" };
|
| { type: "relay"; endpoint: string; display: "relay" };
|
||||||
|
|
||||||
export type HostRuntimeAgentDirectoryStatus =
|
export type HostRuntimeAgentDirectoryStatus =
|
||||||
@@ -179,9 +185,23 @@ function readFetchAgentsNextCursor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
function toActiveConnection(connection: HostConnection): ActiveConnection {
|
function toActiveConnection(connection: HostConnection): ActiveConnection {
|
||||||
if (connection.type === "direct") {
|
if (connection.type === "directSocket") {
|
||||||
return {
|
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,
|
endpoint: connection.endpoint,
|
||||||
display: connection.endpoint,
|
display: connection.endpoint,
|
||||||
};
|
};
|
||||||
@@ -421,11 +441,14 @@ function createDefaultDeps(): HostRuntimeControllerDeps {
|
|||||||
serverId: host.serverId,
|
serverId: host.serverId,
|
||||||
connectionType: connection.type,
|
connectionType: connection.type,
|
||||||
endpoint:
|
endpoint:
|
||||||
connection.type === "direct"
|
connection.type === "directTcp"
|
||||||
? connection.endpoint
|
? connection.endpoint
|
||||||
|
: connection.type === "directSocket" || connection.type === "directPipe"
|
||||||
|
? connection.path
|
||||||
: connection.relayEndpoint,
|
: connection.relayEndpoint,
|
||||||
});
|
});
|
||||||
const tauriTransportFactory = createTauriWebSocketTransportFactory();
|
const tauriTransportFactory = createTauriWebSocketTransportFactory();
|
||||||
|
const localTransportFactory = createTauriLocalDaemonTransportFactory();
|
||||||
const base = {
|
const base = {
|
||||||
suppressSendErrors: true,
|
suppressSendErrors: true,
|
||||||
clientId,
|
clientId,
|
||||||
@@ -433,18 +456,31 @@ function createDefaultDeps(): HostRuntimeControllerDeps {
|
|||||||
runtimeGeneration,
|
runtimeGeneration,
|
||||||
onDiagnosticsEvent: (event: DaemonClientDiagnosticsEvent) =>
|
onDiagnosticsEvent: (event: DaemonClientDiagnosticsEvent) =>
|
||||||
recordDaemonClientDiagnostics(host.serverId, event),
|
recordDaemonClientDiagnostics(host.serverId, event),
|
||||||
...(tauriTransportFactory
|
|
||||||
? { transportFactory: tauriTransportFactory }
|
|
||||||
: {}),
|
|
||||||
};
|
};
|
||||||
if (connection.type === "direct") {
|
if (connection.type === "directSocket" || connection.type === "directPipe") {
|
||||||
return new DaemonClient({
|
return new DaemonClient({
|
||||||
...base,
|
...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),
|
url: buildDaemonWebSocketUrl(connection.endpoint),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
return new DaemonClient({
|
return new DaemonClient({
|
||||||
...base,
|
...base,
|
||||||
|
...(tauriTransportFactory
|
||||||
|
? { transportFactory: tauriTransportFactory }
|
||||||
|
: {}),
|
||||||
url: buildRelayWebSocketUrl({
|
url: buildRelayWebSocketUrl({
|
||||||
endpoint: connection.relayEndpoint,
|
endpoint: connection.relayEndpoint,
|
||||||
serverId: host.serverId,
|
serverId: host.serverId,
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ function shouldSampleFastTransportEvent(): boolean {
|
|||||||
|
|
||||||
export function recordHostRuntimeCreateClient(params: {
|
export function recordHostRuntimeCreateClient(params: {
|
||||||
serverId: string;
|
serverId: string;
|
||||||
connectionType: "direct" | "relay";
|
connectionType: "directTcp" | "directSocket" | "directPipe" | "relay";
|
||||||
endpoint: string;
|
endpoint: string;
|
||||||
}): void {
|
}): void {
|
||||||
recordPerfDiagnosticMark(
|
recordPerfDiagnosticMark(
|
||||||
|
|||||||
@@ -51,6 +51,42 @@ const delay = (ms: number) =>
|
|||||||
}, ms);
|
}, 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) => ({
|
const styles = StyleSheet.create((theme) => ({
|
||||||
loadingContainer: {
|
loadingContainer: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
@@ -1045,14 +1081,10 @@ function HostDetailModal({
|
|||||||
? "rgba(248, 113, 113, 0.1)"
|
? "rgba(248, 113, 113, 0.1)"
|
||||||
: "rgba(161, 161, 170, 0.1)";
|
: "rgba(161, 161, 170, 0.1)";
|
||||||
const connectionBadge = (() => {
|
const connectionBadge = (() => {
|
||||||
if (!activeConnection) return null;
|
return formatActiveConnectionBadge({
|
||||||
if (activeConnection.type === "relay") {
|
activeConnection,
|
||||||
return { icon: <Globe size={theme.iconSize.xs} color={theme.colors.foregroundMuted} />, text: "Relay" };
|
theme,
|
||||||
}
|
});
|
||||||
return {
|
|
||||||
icon: <Monitor size={theme.iconSize.xs} color={theme.colors.foregroundMuted} />,
|
|
||||||
text: activeConnection.display,
|
|
||||||
};
|
|
||||||
})();
|
})();
|
||||||
const versionBadgeText = formatDaemonVersionBadge(daemonVersion);
|
const versionBadgeText = formatDaemonVersionBadge(daemonVersion);
|
||||||
const connectionError = typeof lastError === "string" && lastError.trim().length > 0 ? lastError.trim() : null;
|
const connectionError = typeof lastError === "string" && lastError.trim().length > 0 ? lastError.trim() : null;
|
||||||
@@ -1138,9 +1170,7 @@ function HostDetailModal({
|
|||||||
latencyError={probe?.status === "unavailable"}
|
latencyError={probe?.status === "unavailable"}
|
||||||
onRemove={() => {
|
onRemove={() => {
|
||||||
const title =
|
const title =
|
||||||
conn.type === "relay"
|
formatHostConnectionLabel(conn);
|
||||||
? `Relay (${conn.relayEndpoint})`
|
|
||||||
: `Direct (${conn.endpoint})`;
|
|
||||||
setPendingRemoveConnection({ serverId: host.serverId, connectionId: conn.id, title });
|
setPendingRemoveConnection({ serverId: host.serverId, connectionId: conn.id, title });
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
@@ -1276,9 +1306,7 @@ function ConnectionRow({
|
|||||||
}) {
|
}) {
|
||||||
const { theme } = useUnistyles();
|
const { theme } = useUnistyles();
|
||||||
const title =
|
const title =
|
||||||
connection.type === "relay"
|
formatHostConnectionLabel(connection);
|
||||||
? `Relay (${connection.relayEndpoint})`
|
|
||||||
: `Direct (${connection.endpoint})`;
|
|
||||||
|
|
||||||
const latencyText = (() => {
|
const latencyText = (() => {
|
||||||
if (latencyLoading) return "...";
|
if (latencyLoading) return "...";
|
||||||
@@ -1363,14 +1391,10 @@ function DaemonCard({
|
|||||||
? "rgba(248, 113, 113, 0.1)"
|
? "rgba(248, 113, 113, 0.1)"
|
||||||
: "rgba(161, 161, 170, 0.1)";
|
: "rgba(161, 161, 170, 0.1)";
|
||||||
const connectionBadge = (() => {
|
const connectionBadge = (() => {
|
||||||
if (!activeConnection) return null;
|
return formatActiveConnectionBadge({
|
||||||
if (activeConnection.type === "relay") {
|
activeConnection,
|
||||||
return { icon: <Globe size={theme.iconSize.xs} color={theme.colors.foregroundMuted} />, text: "Relay" };
|
theme,
|
||||||
}
|
});
|
||||||
return {
|
|
||||||
icon: <Monitor size={theme.iconSize.xs} color={theme.colors.foregroundMuted} />,
|
|
||||||
text: activeConnection.display,
|
|
||||||
};
|
|
||||||
})();
|
})();
|
||||||
const versionBadgeText = formatDaemonVersionBadge(daemonVersion);
|
const versionBadgeText = formatDaemonVersionBadge(daemonVersion);
|
||||||
|
|
||||||
|
|||||||
@@ -246,7 +246,7 @@ type DownloadTarget = {
|
|||||||
|
|
||||||
function resolveDaemonDownloadTarget(daemon?: HostProfile): DownloadTarget {
|
function resolveDaemonDownloadTarget(daemon?: HostProfile): DownloadTarget {
|
||||||
const endpoint =
|
const endpoint =
|
||||||
daemon?.connections.find((conn) => conn.type === "direct")?.endpoint ?? null;
|
daemon?.connections.find((conn) => conn.type === "directTcp")?.endpoint ?? null;
|
||||||
if (!endpoint) {
|
if (!endpoint) {
|
||||||
return { baseUrl: null, authHeader: null, authCredentials: null };
|
return { baseUrl: null, authHeader: null, authCredentials: null };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import {
|
|||||||
} from "./connection-selection";
|
} from "./connection-selection";
|
||||||
|
|
||||||
function makeDirect(id: string, endpoint: string): HostConnection {
|
function makeDirect(id: string, endpoint: string): HostConnection {
|
||||||
return { id, type: "direct", endpoint };
|
return { id, type: "directTcp", endpoint };
|
||||||
}
|
}
|
||||||
|
|
||||||
function makeRelay(
|
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({
|
await mod.measureConnectionLatency({
|
||||||
id: "direct:lan:6767",
|
id: "direct:lan:6767",
|
||||||
type: "direct",
|
type: "directTcp",
|
||||||
endpoint: "lan:6767",
|
endpoint: "lan:6767",
|
||||||
});
|
});
|
||||||
await mod.measureConnectionLatency({
|
await mod.measureConnectionLatency({
|
||||||
id: "direct:lan:6767",
|
id: "direct:lan:6767",
|
||||||
type: "direct",
|
type: "directTcp",
|
||||||
endpoint: "lan:6767",
|
endpoint: "lan:6767",
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -85,4 +85,18 @@ describe("test-daemon-connection probe client identity", () => {
|
|||||||
expect(second?.clientId).toBe("cid_shared_probe_test");
|
expect(second?.clientId).toBe("cid_shared_probe_test");
|
||||||
expect(clientIdMock.getOrCreateClientId).toHaveBeenCalledTimes(2);
|
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 type { HostConnection } from "@/contexts/daemon-registry-context";
|
||||||
import { getOrCreateClientId } from "./client-id";
|
import { getOrCreateClientId } from "./client-id";
|
||||||
import { buildDaemonWebSocketUrl, buildRelayWebSocketUrl } from "./daemon-endpoints";
|
import { buildDaemonWebSocketUrl, buildRelayWebSocketUrl } from "./daemon-endpoints";
|
||||||
|
import {
|
||||||
|
buildLocalDaemonTransportUrl,
|
||||||
|
createTauriLocalDaemonTransportFactory,
|
||||||
|
} from "./managed-tauri-daemon-transport";
|
||||||
import { createTauriWebSocketTransportFactory } from "./tauri-daemon-transport";
|
import { createTauriWebSocketTransportFactory } from "./tauri-daemon-transport";
|
||||||
|
|
||||||
function normalizeNonEmptyString(value: unknown): string | null {
|
function normalizeNonEmptyString(value: unknown): string | null {
|
||||||
@@ -47,14 +51,31 @@ async function buildClientConfig(
|
|||||||
): Promise<DaemonClientConfig> {
|
): Promise<DaemonClientConfig> {
|
||||||
const clientId = await getOrCreateClientId();
|
const clientId = await getOrCreateClientId();
|
||||||
const tauriTransportFactory = createTauriWebSocketTransportFactory();
|
const tauriTransportFactory = createTauriWebSocketTransportFactory();
|
||||||
|
const localTransportFactory = createTauriLocalDaemonTransportFactory();
|
||||||
const base = {
|
const base = {
|
||||||
clientId,
|
clientId,
|
||||||
clientType: "mobile" as const,
|
clientType: "mobile" as const,
|
||||||
suppressSendErrors: true,
|
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 {
|
return {
|
||||||
...base,
|
...base,
|
||||||
url: buildDaemonWebSocketUrl(connection.endpoint),
|
url: buildDaemonWebSocketUrl(connection.endpoint),
|
||||||
|
|||||||
@@ -289,7 +289,12 @@ export function resolveTcpHostFromListen(listen: string): string | null {
|
|||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
if (normalized.startsWith('/') || normalized.startsWith('unix://')) {
|
if (
|
||||||
|
normalized.startsWith('/') ||
|
||||||
|
normalized.startsWith('unix://') ||
|
||||||
|
normalized.startsWith('pipe://') ||
|
||||||
|
normalized.startsWith('\\\\.\\pipe\\')
|
||||||
|
) {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,14 +4,16 @@ import { generateLocalPairingOffer, loadConfig, resolvePaseoHome } from '@getpas
|
|||||||
|
|
||||||
interface PairOptions {
|
interface PairOptions {
|
||||||
home?: string
|
home?: string
|
||||||
|
json?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export function pairCommand(): Command {
|
export function pairCommand(): Command {
|
||||||
return new Command('pair')
|
return new Command('pair')
|
||||||
.description('Print the daemon pairing QR code and link')
|
.description('Print the daemon pairing QR code and link')
|
||||||
|
.option('--json', 'Output in JSON format')
|
||||||
.option('--home <path>', 'Paseo home directory (default: ~/.paseo)')
|
.option('--home <path>', 'Paseo home directory (default: ~/.paseo)')
|
||||||
.action(async (options: PairOptions) => {
|
.action(async (_options: PairOptions, command: Command) => {
|
||||||
await runPairCommand(options)
|
await runPairCommand(command.optsWithGlobals() as PairOptions)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -37,6 +39,21 @@ export async function runPairCommand(options: PairOptions): Promise<void> {
|
|||||||
process.exit(1)
|
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` : ''
|
const qrBlock = pairing.qr ? `${pairing.qr}\n` : ''
|
||||||
process.stdout.write(`\nScan to pair:\n${qrBlock}${pairing.url}\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_HOST = 'localhost:6767'
|
||||||
const DEFAULT_TIMEOUT = 5000
|
const DEFAULT_TIMEOUT = 5000
|
||||||
|
|
||||||
|
type DaemonTarget =
|
||||||
|
| {
|
||||||
|
type: 'tcp'
|
||||||
|
url: string
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: 'ipc'
|
||||||
|
url: string
|
||||||
|
socketPath: string
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the daemon host from environment or options
|
* 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
|
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
|
* Create a WebSocket factory that works in Node.js
|
||||||
*/
|
*/
|
||||||
function createNodeWebSocketFactory() {
|
function createNodeWebSocketFactory() {
|
||||||
return (url: string, options?: { headers?: Record<string, string> }) => {
|
return (
|
||||||
return new WebSocket(url, { headers: options?.headers }) as unknown as {
|
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
|
readyState: number
|
||||||
send: (data: string | Uint8Array | ArrayBuffer) => void
|
send: (data: string | Uint8Array | ArrayBuffer) => void
|
||||||
close: (code?: number, reason?: string) => void
|
close: (code?: number, reason?: string) => void
|
||||||
@@ -41,14 +89,19 @@ export async function connectToDaemon(options?: ConnectOptions): Promise<DaemonC
|
|||||||
const host = getDaemonHost(options)
|
const host = getDaemonHost(options)
|
||||||
const timeout = options?.timeout ?? DEFAULT_TIMEOUT
|
const timeout = options?.timeout ?? DEFAULT_TIMEOUT
|
||||||
const clientId = await getOrCreateCliClientId()
|
const clientId = await getOrCreateCliClientId()
|
||||||
const url = `ws://${host}/ws`
|
const target = resolveDaemonTarget(host)
|
||||||
|
const nodeWebSocketFactory = createNodeWebSocketFactory()
|
||||||
|
|
||||||
const client = new DaemonClient(
|
const client = new DaemonClient(
|
||||||
{
|
{
|
||||||
url,
|
url: target.url,
|
||||||
clientId,
|
clientId,
|
||||||
clientType: 'cli',
|
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 },
|
reconnect: { enabled: false },
|
||||||
} as unknown as ConstructorParameters<typeof DaemonClient>[0]
|
} as unknown as ConstructorParameters<typeof DaemonClient>[0]
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -65,9 +65,22 @@ try {
|
|||||||
console.log('✓ daemon status reports stopped when not running\n')
|
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 =
|
const result =
|
||||||
await $`PASEO_HOME=${paseoHome} npx paseo daemon status --json`.nothrow()
|
await $`PASEO_HOME=${paseoHome} npx paseo daemon status --json`.nothrow()
|
||||||
assert.strictEqual(result.exitCode, 0, '--json status should succeed')
|
assert.strictEqual(result.exitCode, 0, '--json status should succeed')
|
||||||
@@ -77,9 +90,9 @@ try {
|
|||||||
console.log('✓ daemon status --json outputs valid JSON\n')
|
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 =
|
const result =
|
||||||
await $`PASEO_HOME=${paseoHome} npx paseo daemon stop`.nothrow()
|
await $`PASEO_HOME=${paseoHome} npx paseo daemon stop`.nothrow()
|
||||||
// Stop should succeed even if daemon is not running (idempotent).
|
// 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')
|
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 =
|
const result =
|
||||||
await $`PASEO_HOME=${paseoHome} npx paseo daemon restart --port ${String(port)}`.nothrow()
|
await $`PASEO_HOME=${paseoHome} npx paseo daemon restart --port ${String(port)}`.nothrow()
|
||||||
assert.strictEqual(result.exitCode, 0, 'restart should succeed even when previously stopped')
|
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')
|
console.log('Test 3: rejects unix socket listen values')
|
||||||
assert.strictEqual(resolveTcpHostFromListen('/tmp/paseo.sock'), null)
|
assert.strictEqual(resolveTcpHostFromListen('/tmp/paseo.sock'), null)
|
||||||
assert.strictEqual(resolveTcpHostFromListen('unix:///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')
|
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,
|
"private": true,
|
||||||
"description": "Paseo desktop app (Tauri wrapper)",
|
"description": "Paseo desktop app (Tauri wrapper)",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "tauri dev",
|
"build:managed-runtime": "node ./scripts/build-managed-runtime.mjs",
|
||||||
"build": "tauri build",
|
"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"
|
"tauri": "tauri"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"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"
|
version = "0.1.18"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"base64 0.22.1",
|
"base64 0.22.1",
|
||||||
|
"dirs",
|
||||||
|
"futures-util",
|
||||||
|
"http",
|
||||||
"log",
|
"log",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
@@ -2643,6 +2646,8 @@ dependencies = [
|
|||||||
"tauri-plugin-opener",
|
"tauri-plugin-opener",
|
||||||
"tauri-plugin-updater",
|
"tauri-plugin-updater",
|
||||||
"tauri-plugin-websocket",
|
"tauri-plugin-websocket",
|
||||||
|
"tokio",
|
||||||
|
"tokio-tungstenite 0.24.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -4342,7 +4347,7 @@ dependencies = [
|
|||||||
"tauri-plugin",
|
"tauri-plugin",
|
||||||
"thiserror 2.0.18",
|
"thiserror 2.0.18",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tokio-tungstenite",
|
"tokio-tungstenite 0.28.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -4604,6 +4609,18 @@ dependencies = [
|
|||||||
"tokio",
|
"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]]
|
[[package]]
|
||||||
name = "tokio-tungstenite"
|
name = "tokio-tungstenite"
|
||||||
version = "0.28.0"
|
version = "0.28.0"
|
||||||
@@ -4616,7 +4633,7 @@ dependencies = [
|
|||||||
"rustls-pki-types",
|
"rustls-pki-types",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tokio-rustls",
|
"tokio-rustls",
|
||||||
"tungstenite",
|
"tungstenite 0.28.0",
|
||||||
"webpki-roots 0.26.11",
|
"webpki-roots 0.26.11",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -4833,6 +4850,24 @@ version = "0.2.5"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
|
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]]
|
[[package]]
|
||||||
name = "tungstenite"
|
name = "tungstenite"
|
||||||
version = "0.28.0"
|
version = "0.28.0"
|
||||||
|
|||||||
@@ -23,6 +23,9 @@ tauri-build = { version = "2.5.3", features = [] }
|
|||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
base64 = "0.22"
|
base64 = "0.22"
|
||||||
|
dirs = "6"
|
||||||
|
futures-util = "0.3"
|
||||||
|
http = "1"
|
||||||
serde_json = "1.0"
|
serde_json = "1.0"
|
||||||
serde = { version = "1.0", features = ["derive"] }
|
serde = { version = "1.0", features = ["derive"] }
|
||||||
log = "0.4"
|
log = "0.4"
|
||||||
@@ -33,7 +36,8 @@ tauri-plugin-notification = "2"
|
|||||||
tauri-plugin-opener = "2"
|
tauri-plugin-opener = "2"
|
||||||
tauri-plugin-updater = "2"
|
tauri-plugin-updater = "2"
|
||||||
tauri-plugin-websocket = "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 base64::{engine::general_purpose::STANDARD as BASE64_STANDARD, Engine as _};
|
||||||
use serde::Serialize;
|
use serde::Serialize;
|
||||||
|
use serde_json::json;
|
||||||
use std::collections::HashSet;
|
use std::collections::HashSet;
|
||||||
use std::fs;
|
use std::fs;
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
@@ -11,6 +12,17 @@ use tauri::menu::AboutMetadata;
|
|||||||
use tauri::{AppHandle, Manager, WebviewWindow};
|
use tauri::{AppHandle, Manager, WebviewWindow};
|
||||||
use tauri_plugin_updater::UpdaterExt;
|
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)
|
// Store zoom as u64 bits (f64 * 100 as integer for atomic ops)
|
||||||
static ZOOM_LEVEL: AtomicU64 = AtomicU64::new(100);
|
static ZOOM_LEVEL: AtomicU64 = AtomicU64::new(100);
|
||||||
|
|
||||||
@@ -64,6 +76,138 @@ struct AttachmentFileResult {
|
|||||||
byte_size: u64,
|
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 {
|
fn resolve_login_shell() -> String {
|
||||||
std::env::var("SHELL")
|
std::env::var("SHELL")
|
||||||
.ok()
|
.ok()
|
||||||
@@ -440,12 +584,27 @@ async fn garbage_collect_attachment_files(
|
|||||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||||
pub fn run() {
|
pub fn run() {
|
||||||
tauri::Builder::default()
|
tauri::Builder::default()
|
||||||
|
.manage(LocalTransportState::default())
|
||||||
.plugin(tauri_plugin_dialog::init())
|
.plugin(tauri_plugin_dialog::init())
|
||||||
.plugin(tauri_plugin_notification::init())
|
.plugin(tauri_plugin_notification::init())
|
||||||
.plugin(tauri_plugin_opener::init())
|
.plugin(tauri_plugin_opener::init())
|
||||||
.plugin(tauri_plugin_updater::Builder::new().build())
|
.plugin(tauri_plugin_updater::Builder::new().build())
|
||||||
.plugin(tauri_plugin_websocket::init())
|
.plugin(tauri_plugin_websocket::init())
|
||||||
.invoke_handler(tauri::generate_handler![
|
.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,
|
get_local_daemon_version,
|
||||||
run_local_daemon_update,
|
run_local_daemon_update,
|
||||||
check_app_update,
|
check_app_update,
|
||||||
@@ -457,6 +616,10 @@ pub fn run() {
|
|||||||
garbage_collect_attachment_files
|
garbage_collect_attachment_files
|
||||||
])
|
])
|
||||||
.setup(|app| {
|
.setup(|app| {
|
||||||
|
if maybe_run_managed_headless_command(app.handle())? {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
if cfg!(debug_assertions) {
|
if cfg!(debug_assertions) {
|
||||||
app.handle().plugin(
|
app.handle().plugin(
|
||||||
tauri_plugin_log::Builder::default()
|
tauri_plugin_log::Builder::default()
|
||||||
|
|||||||
@@ -8,5 +8,9 @@
|
|||||||
tauri::embed_plist::embed_info_plist!("../Info.plist");
|
tauri::embed_plist::embed_info_plist!("../Info.plist");
|
||||||
|
|
||||||
fn main() {
|
fn main() {
|
||||||
|
if let Err(error) = paseo_lib::try_run_cli_shim_from_args() {
|
||||||
|
eprintln!("{error}");
|
||||||
|
std::process::exit(1);
|
||||||
|
}
|
||||||
paseo_lib::run();
|
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": {
|
"bundle": {
|
||||||
"active": true,
|
"active": true,
|
||||||
"createUpdaterArtifacts": true,
|
"createUpdaterArtifacts": true,
|
||||||
|
"resources": [
|
||||||
|
"resources/**/*"
|
||||||
|
],
|
||||||
"targets": "all",
|
"targets": "all",
|
||||||
"icon": [
|
"icon": [
|
||||||
"icons/32x32.png",
|
"icons/32x32.png",
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
import os from "node:os";
|
import os from "node:os";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { mkdir, mkdtemp, rm } from "node:fs/promises";
|
import { mkdir, mkdtemp, rm } from "node:fs/promises";
|
||||||
|
import { Writable } from "node:stream";
|
||||||
import pino from "pino";
|
import pino from "pino";
|
||||||
import { describe, expect, test } from "vitest";
|
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 { createTestPaseoDaemon } from "./test-utils/paseo-daemon.js";
|
||||||
import { createTestAgentClients } from "./test-utils/fake-agent-client.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 });
|
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 ListenTarget =
|
||||||
| { type: "tcp"; host: string; port: number }
|
| { 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
|
// Unix socket: starts with / or ~ or contains .sock
|
||||||
if (listen.startsWith("/") || listen.startsWith("~") || listen.includes(".sock")) {
|
if (listen.startsWith("/") || listen.startsWith("~") || listen.includes(".sock")) {
|
||||||
return { type: "socket", path: listen };
|
return { type: "socket", path: listen };
|
||||||
@@ -570,46 +577,44 @@ export async function createPaseoDaemon(
|
|||||||
const onListening = () => {
|
const onListening = () => {
|
||||||
httpServer.off("error", onError);
|
httpServer.off("error", onError);
|
||||||
const logAndResolve = async () => {
|
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") {
|
if (listenTarget.type === "tcp") {
|
||||||
logger.info(
|
logger.info(
|
||||||
{ host: listenTarget.host, port: listenTarget.port },
|
{ host: listenTarget.host, port: listenTarget.port },
|
||||||
`Server listening on http://${listenTarget.host}:${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 {
|
} else {
|
||||||
logger.info({ path: listenTarget.path }, `Server listening on ${listenTarget.path}`);
|
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);
|
logAndResolve().then(resolve, reject);
|
||||||
@@ -620,8 +625,7 @@ export async function createPaseoDaemon(
|
|||||||
if (listenTarget.type === "tcp") {
|
if (listenTarget.type === "tcp") {
|
||||||
httpServer.listen(listenTarget.port, listenTarget.host);
|
httpServer.listen(listenTarget.port, listenTarget.host);
|
||||||
} else {
|
} else {
|
||||||
// Remove stale socket file if it exists
|
if (listenTarget.type === "socket" && existsSync(listenTarget.path)) {
|
||||||
if (existsSync(listenTarget.path)) {
|
|
||||||
unlinkSync(listenTarget.path);
|
unlinkSync(listenTarget.path);
|
||||||
}
|
}
|
||||||
httpServer.listen(listenTarget.path);
|
httpServer.listen(listenTarget.path);
|
||||||
|
|||||||
Reference in New Issue
Block a user