Merge branch 'managed-daemon-bundling'
This commit is contained in:
@@ -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,22 @@ 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:
|
||||||
|
strategy:
|
||||||
|
matrix:
|
||||||
|
include:
|
||||||
|
- runner: macos-14
|
||||||
|
rust_target: aarch64-apple-darwin
|
||||||
|
- runner: macos-13
|
||||||
|
rust_target: x86_64-apple-darwin
|
||||||
permissions:
|
permissions:
|
||||||
contents: write
|
contents: write
|
||||||
packages: read
|
packages: read
|
||||||
runs-on: macos-latest
|
runs-on: ${{ matrix.runner }}
|
||||||
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,15 +42,15 @@ 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
|
||||||
with:
|
with:
|
||||||
targets: aarch64-apple-darwin,x86_64-apple-darwin
|
targets: ${{ matrix.rust_target }}
|
||||||
|
|
||||||
- name: Install JS dependencies
|
- name: Install JS dependencies
|
||||||
run: npm ci
|
run: npm ci
|
||||||
@@ -91,7 +99,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 }}
|
||||||
@@ -110,4 +118,179 @@ jobs:
|
|||||||
releaseBody: See the assets to download and install this version.
|
releaseBody: See the assets to download and install this version.
|
||||||
releaseDraft: false
|
releaseDraft: false
|
||||||
prerelease: false
|
prerelease: false
|
||||||
args: --target universal-apple-darwin
|
args: --target ${{ matrix.rust_target }}
|
||||||
|
|
||||||
|
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
|
||||||
|
|||||||
@@ -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/
|
||||||
|
|||||||
@@ -0,0 +1,81 @@
|
|||||||
|
# Runtime Simplification Plan
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Remove the managed runtime install/copy layer. The desktop app must run the bundled runtime in place from the application resources/install directory on macOS, Windows, and Linux.
|
||||||
|
|
||||||
|
This is a simplification task. If a change preserves the old install-manager shape under a new name, it fails the goal.
|
||||||
|
|
||||||
|
## Hard Requirements
|
||||||
|
|
||||||
|
1. The bundled runtime is read-only.
|
||||||
|
The desktop app must not copy the bundled runtime to app data, temp, cache, or any other writable directory.
|
||||||
|
|
||||||
|
2. The app must execute Node/CLI/server entrypoints directly from the bundled runtime root.
|
||||||
|
On macOS this means inside `Paseo.app/Contents/Resources/...`.
|
||||||
|
On Windows and Linux this means inside the installed app resources directory.
|
||||||
|
|
||||||
|
3. There is exactly one runtime per installed app.
|
||||||
|
Remove versioned installed runtime directories like `runtime/<runtime-id>` from the runtime execution path.
|
||||||
|
|
||||||
|
4. The CLI shim may point into the installed app bundle/directory.
|
||||||
|
Do not keep an extra installed runtime tree just to preserve a stable shim target across updates.
|
||||||
|
|
||||||
|
5. All mutable state remains outside the bundled runtime.
|
||||||
|
Logs, sockets/pipes, PID files, daemon state, `PASEO_HOME`, and any other writable files must continue to live in managed home / app data locations.
|
||||||
|
|
||||||
|
6. Runtime discovery must stay cross-platform.
|
||||||
|
The implementation must resolve the bundled runtime/resources path on macOS, Windows, and Linux using the app install/resources directory, not hardcoded `.app` assumptions.
|
||||||
|
|
||||||
|
7. Remove dead machinery, do not leave adapters behind.
|
||||||
|
If install/copy/versioned-runtime code becomes unused, delete it instead of keeping fallback paths "just in case".
|
||||||
|
|
||||||
|
## Non-Goals
|
||||||
|
|
||||||
|
1. Do not change how the runtime is built into the desktop app bundle in this task.
|
||||||
|
This task is about runtime execution and path management, not bundling format.
|
||||||
|
|
||||||
|
2. Do not reintroduce a second runtime location for migration compatibility.
|
||||||
|
It is acceptable if older installed clients do not migrate cleanly.
|
||||||
|
|
||||||
|
3. Do not add feature flags, env-guarded fallback paths, or compatibility shims unless absolutely required by a real platform constraint proven in code.
|
||||||
|
|
||||||
|
## Concrete Implementation Direction
|
||||||
|
|
||||||
|
1. Treat `bundled_runtime_root(app)` plus `current-runtime.json` as the runtime source of truth.
|
||||||
|
|
||||||
|
2. Replace `paths.runtime_root` usage for runtime execution with the bundled runtime root selected by `current-runtime.json`.
|
||||||
|
|
||||||
|
3. Delete `install_runtime_if_needed(...)` and related copy/install staging logic if nothing else still needs it.
|
||||||
|
|
||||||
|
4. Rework CLI shim generation so the inner launcher points at the bundled runtime's Node + CLI entrypoint directly, while still keeping mutable state (`PASEO_HOME`) outside the runtime.
|
||||||
|
|
||||||
|
5. Simplify `ManagedPaths` and related structs if `runtime_root` and `stable_runtime_root` no longer need separate installed-runtime semantics.
|
||||||
|
|
||||||
|
6. Keep diagnostics/status reporting accurate.
|
||||||
|
If the app reports bundled vs installed runtime roots today, update that output to reflect the new single-runtime model.
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
|
||||||
|
1. No code path copies the bundled runtime tree into app data before launching the daemon or CLI.
|
||||||
|
|
||||||
|
2. No code path depends on a versioned installed runtime directory for execution.
|
||||||
|
|
||||||
|
3. The CLI shim and daemon launch path both resolve to bundled runtime executables/resources.
|
||||||
|
|
||||||
|
4. Typecheck passes.
|
||||||
|
|
||||||
|
5. Relevant desktop/runtime tests pass, updated to reflect the new direct-from-bundle model.
|
||||||
|
|
||||||
|
6. The resulting implementation is materially simpler:
|
||||||
|
fewer runtime path concepts, fewer staging/install branches, fewer indirections.
|
||||||
|
|
||||||
|
## Review Bar
|
||||||
|
|
||||||
|
The implementation should be rejected if:
|
||||||
|
|
||||||
|
- it still copies the runtime anywhere before execution
|
||||||
|
- it keeps runtime version directories in the execution path
|
||||||
|
- it preserves the stable installed runtime launcher concept without a hard platform reason
|
||||||
|
- it adds migration complexity for old installs
|
||||||
|
- it introduces new fallback branches instead of removing obsolete ones
|
||||||
@@ -65,7 +65,7 @@ export function AddHostMethodModal({
|
|||||||
<Link2 size={18} color={theme.colors.foreground} />
|
<Link2 size={18} color={theme.colors.foreground} />
|
||||||
<View style={styles.optionBody}>
|
<View style={styles.optionBody}>
|
||||||
<Text style={styles.optionText}>Direct connection</Text>
|
<Text style={styles.optionText}>Direct connection</Text>
|
||||||
<Text style={styles.optionSubtext}>Local network or Tailscale (unencrypted).</Text>
|
<Text style={styles.optionSubtext}>Local network or VPN.</Text>
|
||||||
</View>
|
</View>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
|
|
||||||
@@ -74,7 +74,7 @@ export function AddHostMethodModal({
|
|||||||
<QrCode size={18} color={theme.colors.foreground} />
|
<QrCode size={18} color={theme.colors.foreground} />
|
||||||
<View style={styles.optionBody}>
|
<View style={styles.optionBody}>
|
||||||
<Text style={styles.optionText}>Scan QR code</Text>
|
<Text style={styles.optionText}>Scan QR code</Text>
|
||||||
<Text style={styles.optionSubtext}>Relay pairing (E2EE).</Text>
|
<Text style={styles.optionSubtext}>Encrypted relay connection.</Text>
|
||||||
</View>
|
</View>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
) : null}
|
) : null}
|
||||||
@@ -83,7 +83,7 @@ export function AddHostMethodModal({
|
|||||||
<ClipboardPaste size={18} color={theme.colors.foreground} />
|
<ClipboardPaste size={18} color={theme.colors.foreground} />
|
||||||
<View style={styles.optionBody}>
|
<View style={styles.optionBody}>
|
||||||
<Text style={styles.optionText}>Paste pairing link</Text>
|
<Text style={styles.optionText}>Paste pairing link</Text>
|
||||||
<Text style={styles.optionSubtext}>Relay pairing (E2EE).</Text>
|
<Text style={styles.optionSubtext}>Encrypted relay connection.</Text>
|
||||||
</View>
|
</View>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
</AdaptiveModalSheet>
|
</AdaptiveModalSheet>
|
||||||
|
|||||||
@@ -103,13 +103,13 @@ function buildConnectionFailureCopy(endpoint: string, error: unknown): { title:
|
|||||||
rawLower.includes("connection refused") ||
|
rawLower.includes("connection refused") ||
|
||||||
rawLower.includes("err_connection_refused")
|
rawLower.includes("err_connection_refused")
|
||||||
) {
|
) {
|
||||||
detail = "Connection was refused. Is the daemon running on that host and port?";
|
detail = "Connection refused. Is the server running at this address?";
|
||||||
} else if (rawLower.includes("enotfound") || rawLower.includes("not found")) {
|
} else if (rawLower.includes("enotfound") || rawLower.includes("not found")) {
|
||||||
detail = "Host not found. Check the hostname and try again.";
|
detail = "Host not found. Check the hostname and try again.";
|
||||||
} else if (rawLower.includes("ehostunreach") || rawLower.includes("host is unreachable")) {
|
} else if (rawLower.includes("ehostunreach") || rawLower.includes("host is unreachable")) {
|
||||||
detail = "Host is unreachable. Check your network and firewall.";
|
detail = "Host is unreachable. Check your network and firewall.";
|
||||||
} else if (rawLower.includes("certificate") || rawLower.includes("tls") || rawLower.includes("ssl")) {
|
} else if (rawLower.includes("certificate") || rawLower.includes("tls") || rawLower.includes("ssl")) {
|
||||||
detail = "TLS/certificate error. This app expects a daemon reachable over the local network or via relay.";
|
detail = "TLS error. Direct connections use an unencrypted local connection. Use relay for remote access.";
|
||||||
} else if (raw) {
|
} else if (raw) {
|
||||||
detail = "Unable to connect. Check the host/port and that the daemon is reachable.";
|
detail = "Unable to connect. Check the host/port and that the daemon is reachable.";
|
||||||
} else {
|
} else {
|
||||||
@@ -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);
|
||||||
@@ -217,7 +221,7 @@ export function AddHostModal({ visible, onClose, onCancel, onSaved, targetServer
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<AdaptiveModalSheet title="Direct connection" visible={visible} onClose={handleClose} testID="add-host-modal">
|
<AdaptiveModalSheet title="Direct connection" visible={visible} onClose={handleClose} testID="add-host-modal">
|
||||||
<Text style={styles.helper}>Connect to a daemon by entering host:port.</Text>
|
<Text style={styles.helper}>Enter the address of a Paseo server.</Text>
|
||||||
|
|
||||||
<View style={styles.field}>
|
<View style={styles.field}>
|
||||||
<Text style={styles.label}>Host</Text>
|
<Text style={styles.label}>Host</Text>
|
||||||
@@ -225,7 +229,7 @@ export function AddHostModal({ visible, onClose, onCancel, onSaved, targetServer
|
|||||||
ref={hostInputRef}
|
ref={hostInputRef}
|
||||||
value={endpointRaw}
|
value={endpointRaw}
|
||||||
onChangeText={setEndpointRaw}
|
onChangeText={setEndpointRaw}
|
||||||
placeholder="host:6767"
|
placeholder="hostname:port"
|
||||||
placeholderTextColor={theme.colors.foregroundMuted}
|
placeholderTextColor={theme.colors.foregroundMuted}
|
||||||
style={styles.input}
|
style={styles.input}
|
||||||
autoCapitalize="none"
|
autoCapitalize="none"
|
||||||
|
|||||||
@@ -149,7 +149,7 @@ export function PairLinkModal({ visible, onClose, onCancel, onSaved, targetServe
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<AdaptiveModalSheet title="Paste pairing link" visible={visible} onClose={handleClose} testID="pair-link-modal">
|
<AdaptiveModalSheet title="Paste pairing link" visible={visible} onClose={handleClose} testID="pair-link-modal">
|
||||||
<Text style={styles.helper}>Paste the daemon’s pairing link.</Text>
|
<Text style={styles.helper}>Paste the pairing link from your server.</Text>
|
||||||
|
|
||||||
<View style={styles.field}>
|
<View style={styles.field}>
|
||||||
<Text style={styles.label}>Pairing link</Text>
|
<Text style={styles.label}>Pairing link</Text>
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ import { describe, expect, it } from 'vitest'
|
|||||||
import {
|
import {
|
||||||
hostHasDirectEndpoint,
|
hostHasDirectEndpoint,
|
||||||
registryHasDirectEndpoint,
|
registryHasDirectEndpoint,
|
||||||
|
reconcileDesktopStartupRegistry,
|
||||||
|
resolveManagedDesktopStartupStatus,
|
||||||
type HostProfile,
|
type HostProfile,
|
||||||
} from './daemon-registry-context'
|
} from './daemon-registry-context'
|
||||||
|
|
||||||
@@ -10,6 +12,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 +29,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 +59,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 +76,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 +84,240 @@ describe('registryHasDirectEndpoint', () => {
|
|||||||
expect(registryHasDirectEndpoint(hosts, 'localhost:6767')).toBe(false)
|
expect(registryHasDirectEndpoint(hosts, 'localhost:6767')).toBe(false)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('reconcileDesktopStartupRegistry', () => {
|
||||||
|
it('seeds managed and localhost connections as normal host entries', () => {
|
||||||
|
const now = '2026-03-08T00:00:00.000Z'
|
||||||
|
|
||||||
|
const result = reconcileDesktopStartupRegistry({
|
||||||
|
existing: [],
|
||||||
|
managed: {
|
||||||
|
serverId: 'srv_managed',
|
||||||
|
hostname: 'managed-host',
|
||||||
|
runtimeId: 'runtime_1',
|
||||||
|
runtimeVersion: '1.2.3',
|
||||||
|
transportType: 'socket',
|
||||||
|
transportPath: '/Users/test/.paseo-test/paseo.sock',
|
||||||
|
associatedServerId: 'srv_managed',
|
||||||
|
},
|
||||||
|
localhost: {
|
||||||
|
serverId: 'srv_localhost',
|
||||||
|
hostname: 'local-dev',
|
||||||
|
endpoint: 'localhost:6767',
|
||||||
|
},
|
||||||
|
now,
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(result).toEqual([
|
||||||
|
makeHost({
|
||||||
|
serverId: 'srv_managed',
|
||||||
|
label: 'managed-host',
|
||||||
|
lifecycle: {
|
||||||
|
managed: true,
|
||||||
|
managedRuntimeId: 'runtime_1',
|
||||||
|
managedRuntimeVersion: '1.2.3',
|
||||||
|
associatedServerId: 'srv_managed',
|
||||||
|
},
|
||||||
|
connections: [
|
||||||
|
{
|
||||||
|
id: 'socket:/Users/test/.paseo-test/paseo.sock',
|
||||||
|
type: 'directSocket',
|
||||||
|
path: '/Users/test/.paseo-test/paseo.sock',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
preferredConnectionId: 'socket:/Users/test/.paseo-test/paseo.sock',
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
}),
|
||||||
|
makeHost({
|
||||||
|
serverId: 'srv_localhost',
|
||||||
|
label: 'local-dev',
|
||||||
|
connections: [
|
||||||
|
{
|
||||||
|
id: 'direct:localhost:6767',
|
||||||
|
type: 'directTcp',
|
||||||
|
endpoint: 'localhost:6767',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
preferredConnectionId: 'direct:localhost:6767',
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
}),
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps managed and localhost connections together when they resolve to the same server', () => {
|
||||||
|
const now = '2026-03-08T00:00:00.000Z'
|
||||||
|
|
||||||
|
const result = reconcileDesktopStartupRegistry({
|
||||||
|
existing: [],
|
||||||
|
managed: {
|
||||||
|
serverId: 'srv_shared',
|
||||||
|
hostname: 'devbox',
|
||||||
|
runtimeId: 'runtime_1',
|
||||||
|
runtimeVersion: '1.2.3',
|
||||||
|
transportType: 'socket',
|
||||||
|
transportPath: '/Users/test/.paseo-test/paseo.sock',
|
||||||
|
associatedServerId: 'srv_shared',
|
||||||
|
},
|
||||||
|
localhost: {
|
||||||
|
serverId: 'srv_shared',
|
||||||
|
hostname: 'devbox',
|
||||||
|
endpoint: 'localhost:6767',
|
||||||
|
},
|
||||||
|
now,
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(result).toEqual([
|
||||||
|
makeHost({
|
||||||
|
serverId: 'srv_shared',
|
||||||
|
label: 'devbox',
|
||||||
|
lifecycle: {
|
||||||
|
managed: true,
|
||||||
|
managedRuntimeId: 'runtime_1',
|
||||||
|
managedRuntimeVersion: '1.2.3',
|
||||||
|
associatedServerId: 'srv_shared',
|
||||||
|
},
|
||||||
|
connections: [
|
||||||
|
{
|
||||||
|
id: 'socket:/Users/test/.paseo-test/paseo.sock',
|
||||||
|
type: 'directSocket',
|
||||||
|
path: '/Users/test/.paseo-test/paseo.sock',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'direct:localhost:6767',
|
||||||
|
type: 'directTcp',
|
||||||
|
endpoint: 'localhost:6767',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
preferredConnectionId: 'socket:/Users/test/.paseo-test/paseo.sock',
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
}),
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('is idempotent for repeated desktop startup reconciliation', () => {
|
||||||
|
const now = '2026-03-08T00:00:00.000Z'
|
||||||
|
|
||||||
|
const first = reconcileDesktopStartupRegistry({
|
||||||
|
existing: [],
|
||||||
|
managed: {
|
||||||
|
serverId: 'srv_shared',
|
||||||
|
hostname: 'devbox',
|
||||||
|
runtimeId: 'runtime_1',
|
||||||
|
runtimeVersion: '1.2.3',
|
||||||
|
transportType: 'socket',
|
||||||
|
transportPath: '/Users/test/.paseo-test/paseo.sock',
|
||||||
|
associatedServerId: 'srv_shared',
|
||||||
|
},
|
||||||
|
localhost: {
|
||||||
|
serverId: 'srv_shared',
|
||||||
|
hostname: 'devbox',
|
||||||
|
endpoint: 'localhost:6767',
|
||||||
|
},
|
||||||
|
now,
|
||||||
|
})
|
||||||
|
|
||||||
|
const second = reconcileDesktopStartupRegistry({
|
||||||
|
existing: first,
|
||||||
|
managed: {
|
||||||
|
serverId: 'srv_shared',
|
||||||
|
hostname: 'devbox',
|
||||||
|
runtimeId: 'runtime_1',
|
||||||
|
runtimeVersion: '1.2.3',
|
||||||
|
transportType: 'socket',
|
||||||
|
transportPath: '/Users/test/.paseo-test/paseo.sock',
|
||||||
|
associatedServerId: 'srv_shared',
|
||||||
|
},
|
||||||
|
localhost: {
|
||||||
|
serverId: 'srv_shared',
|
||||||
|
hostname: 'devbox',
|
||||||
|
endpoint: 'localhost:6767',
|
||||||
|
},
|
||||||
|
now: '2026-03-09T00:00:00.000Z',
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(second).toEqual(first)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('resolveManagedDesktopStartupStatus', () => {
|
||||||
|
it('starts the managed daemon when management is enabled', async () => {
|
||||||
|
const managedStatus = {
|
||||||
|
runtimeId: 'runtime_1',
|
||||||
|
runtimeVersion: '1.2.3',
|
||||||
|
runtimeRoot: '/runtime',
|
||||||
|
managedHome: '/home',
|
||||||
|
transportType: 'socket',
|
||||||
|
transportPath: '/tmp/paseo.sock',
|
||||||
|
daemonPid: 123,
|
||||||
|
daemonRunning: true,
|
||||||
|
daemonStatus: 'running',
|
||||||
|
logPath: '/tmp/daemon.log',
|
||||||
|
serverId: 'srv_managed',
|
||||||
|
hostname: 'managed-host',
|
||||||
|
relayEnabled: true,
|
||||||
|
tcpEnabled: false,
|
||||||
|
tcpListen: null,
|
||||||
|
cliShimPath: null,
|
||||||
|
}
|
||||||
|
let startCalls = 0
|
||||||
|
let statusCalls = 0
|
||||||
|
|
||||||
|
const result = await resolveManagedDesktopStartupStatus({
|
||||||
|
settings: { manageBuiltInDaemon: true },
|
||||||
|
startManagedDaemonFn: async () => {
|
||||||
|
startCalls += 1
|
||||||
|
return managedStatus
|
||||||
|
},
|
||||||
|
getManagedDaemonStatusFn: async () => {
|
||||||
|
statusCalls += 1
|
||||||
|
return managedStatus
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(result).toEqual(managedStatus)
|
||||||
|
expect(startCalls).toBe(1)
|
||||||
|
expect(statusCalls).toBe(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('only reads managed daemon status when management is paused', async () => {
|
||||||
|
const managedStatus = {
|
||||||
|
runtimeId: 'runtime_1',
|
||||||
|
runtimeVersion: '1.2.3',
|
||||||
|
runtimeRoot: '/runtime',
|
||||||
|
managedHome: '/home',
|
||||||
|
transportType: 'socket',
|
||||||
|
transportPath: '/tmp/paseo.sock',
|
||||||
|
daemonPid: null,
|
||||||
|
daemonRunning: false,
|
||||||
|
daemonStatus: 'stopped',
|
||||||
|
logPath: '/tmp/daemon.log',
|
||||||
|
serverId: null,
|
||||||
|
hostname: null,
|
||||||
|
relayEnabled: true,
|
||||||
|
tcpEnabled: false,
|
||||||
|
tcpListen: null,
|
||||||
|
cliShimPath: null,
|
||||||
|
}
|
||||||
|
let startCalls = 0
|
||||||
|
let statusCalls = 0
|
||||||
|
|
||||||
|
const result = await resolveManagedDesktopStartupStatus({
|
||||||
|
settings: { manageBuiltInDaemon: false },
|
||||||
|
startManagedDaemonFn: async () => {
|
||||||
|
startCalls += 1
|
||||||
|
return managedStatus
|
||||||
|
},
|
||||||
|
getManagedDaemonStatusFn: async () => {
|
||||||
|
statusCalls += 1
|
||||||
|
return managedStatus
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(result).toEqual(managedStatus)
|
||||||
|
expect(startCalls).toBe(0)
|
||||||
|
expect(statusCalls).toBe(1)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
@@ -4,33 +4,67 @@ import AsyncStorage from '@react-native-async-storage/async-storage'
|
|||||||
import { useQuery, useQueryClient } from '@tanstack/react-query'
|
import { useQuery, useQueryClient } from '@tanstack/react-query'
|
||||||
import { decodeOfferFragmentPayload, normalizeHostPort } from '@/utils/daemon-endpoints'
|
import { decodeOfferFragmentPayload, normalizeHostPort } from '@/utils/daemon-endpoints'
|
||||||
import { probeConnection } from '@/utils/test-daemon-connection'
|
import { probeConnection } from '@/utils/test-daemon-connection'
|
||||||
|
import { useAppSettings, type AppSettings } from '@/hooks/use-settings'
|
||||||
import { ConnectionOfferSchema, type ConnectionOffer } from '@server/shared/connection-offer'
|
import { ConnectionOfferSchema, type ConnectionOffer } from '@server/shared/connection-offer'
|
||||||
|
import {
|
||||||
|
getManagedDaemonStatus,
|
||||||
|
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 +73,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 +120,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 +142,401 @@ 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
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function resolveManagedDesktopStartupStatus(input: {
|
||||||
|
settings: Pick<AppSettings, 'manageBuiltInDaemon'>
|
||||||
|
startManagedDaemonFn?: () => Promise<ManagedDaemonStatus>
|
||||||
|
getManagedDaemonStatusFn?: () => Promise<ManagedDaemonStatus>
|
||||||
|
}): Promise<ManagedDaemonStatus> {
|
||||||
|
if (input.settings.manageBuiltInDaemon) {
|
||||||
|
return await (input.startManagedDaemonFn ?? startManagedDaemon)()
|
||||||
|
}
|
||||||
|
|
||||||
|
return await (input.getManagedDaemonStatusFn ?? getManagedDaemonStatus)()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function probeManagedStartupTarget(input: {
|
||||||
|
managedDaemon: ManagedDaemonStatus
|
||||||
|
cancelled?: () => boolean
|
||||||
|
}): Promise<ManagedHostReconciliationInput | null> {
|
||||||
|
const connection = normalizeManagedTransportConnection({
|
||||||
|
transportType: input.managedDaemon.transportType,
|
||||||
|
transportPath: input.managedDaemon.transportPath,
|
||||||
|
})
|
||||||
|
if (!connection) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
let serverId = input.managedDaemon.serverId
|
||||||
|
let hostname = input.managedDaemon.hostname
|
||||||
|
|
||||||
|
if (!serverId) {
|
||||||
|
const probed = await probeConnection(connection, {
|
||||||
|
timeoutMs: DEFAULT_LOCAL_TRANSPORT_BOOTSTRAP_TIMEOUT_MS,
|
||||||
|
})
|
||||||
|
if (input.cancelled?.()) {
|
||||||
|
throw new Error('Managed daemon bootstrap cancelled')
|
||||||
|
}
|
||||||
|
serverId = probed.serverId
|
||||||
|
hostname = hostname ?? probed.hostname
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
serverId,
|
||||||
|
hostname,
|
||||||
|
runtimeId: input.managedDaemon.runtimeId,
|
||||||
|
runtimeVersion: input.managedDaemon.runtimeVersion,
|
||||||
|
transportType: input.managedDaemon.transportType,
|
||||||
|
transportPath: input.managedDaemon.transportPath,
|
||||||
|
associatedServerId: input.managedDaemon.serverId,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function probeManagedConnectionUntilReady(
|
||||||
|
input: {
|
||||||
|
managedDaemon: ManagedDaemonStatus
|
||||||
|
cancelled?: () => boolean
|
||||||
|
}
|
||||||
|
): Promise<ManagedHostReconciliationInput | null> {
|
||||||
|
const startedAt = Date.now()
|
||||||
|
let lastError: unknown = null
|
||||||
|
|
||||||
|
while (Date.now() - startedAt < DEFAULT_LOCAL_TRANSPORT_BOOTSTRAP_DEADLINE_MS) {
|
||||||
|
if (input.cancelled?.()) {
|
||||||
|
throw new Error('Managed daemon bootstrap cancelled')
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return await probeManagedStartupTarget(input)
|
||||||
|
} catch (error) {
|
||||||
|
lastError = error
|
||||||
|
if (input.cancelled?.()) {
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
await sleep(DEFAULT_LOCAL_TRANSPORT_BOOTSTRAP_RETRY_MS)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw lastError ?? new Error('Managed daemon bootstrap timed out')
|
||||||
}
|
}
|
||||||
|
|
||||||
export function hostHasDirectEndpoint(host: HostProfile, endpoint: string): boolean {
|
export function hostHasDirectEndpoint(host: HostProfile, endpoint: string): boolean {
|
||||||
@@ -81,7 +545,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,7 +563,9 @@ 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 { settings, isLoading: settingsLoading } = useAppSettings()
|
||||||
const {
|
const {
|
||||||
data: daemons = [],
|
data: daemons = [],
|
||||||
isPending,
|
isPending,
|
||||||
@@ -123,10 +589,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 +608,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 +638,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 +667,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 +682,121 @@ export function DaemonRegistryProvider({ children }: { children: ReactNode }) {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isPending) return
|
if (isPending) return
|
||||||
|
if (settingsLoading) 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 resolveManagedDesktopStartupStatus({
|
||||||
|
settings,
|
||||||
|
})
|
||||||
|
if (managedDaemon.daemonRunning) {
|
||||||
|
managed = await probeManagedConnectionUntilReady({
|
||||||
|
managedDaemon,
|
||||||
|
cancelled: () => cancelled,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
} catch (managedBootstrapError) {
|
||||||
|
if (!cancelled) {
|
||||||
|
console.warn(
|
||||||
|
'[DaemonRegistry] Failed to reconcile managed daemon transport',
|
||||||
|
managedBootstrapError
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let localhost: LocalhostHostReconciliationInput | null = null
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { serverId, hostname } = await probeConnection(
|
||||||
|
{
|
||||||
|
id: `bootstrap:${DEFAULT_LOCALHOST_ENDPOINT}`,
|
||||||
|
type: 'directTcp',
|
||||||
|
endpoint: DEFAULT_LOCALHOST_ENDPOINT,
|
||||||
|
},
|
||||||
|
{ timeoutMs: DEFAULT_LOCALHOST_BOOTSTRAP_TIMEOUT_MS }
|
||||||
|
)
|
||||||
|
if (!cancelled) {
|
||||||
|
localhost = {
|
||||||
|
serverId,
|
||||||
|
hostname,
|
||||||
|
endpoint: DEFAULT_LOCALHOST_ENDPOINT,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} 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,
|
||||||
|
settings.manageBuiltInDaemon,
|
||||||
|
settingsLoading,
|
||||||
|
])
|
||||||
|
|
||||||
|
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 +804,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 +816,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 +849,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 +908,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,116 @@
|
|||||||
import { useCallback, useState } from "react";
|
import { useCallback, useEffect, useState } from "react";
|
||||||
import { Alert, Text, View } from "react-native";
|
import { ActivityIndicator, Alert, Image, Pressable, 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 { ArrowUpRight } from "lucide-react-native";
|
||||||
|
import { AdaptiveModalSheet } from "@/components/adaptive-modal-sheet";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { useAppSettings } from "@/hooks/use-settings";
|
||||||
import { confirmDialog } from "@/utils/confirm-dialog";
|
import { confirmDialog } from "@/utils/confirm-dialog";
|
||||||
|
import { openExternalUrl } from "@/utils/open-external-url";
|
||||||
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,
|
||||||
|
startManagedDaemon,
|
||||||
|
stopManagedDaemon,
|
||||||
|
uninstallManagedCliShim,
|
||||||
|
type ManagedDaemonLogs,
|
||||||
|
type ManagedPairingOffer,
|
||||||
|
type ManagedDaemonStatus,
|
||||||
|
type CliManualInstructions,
|
||||||
|
} 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 { settings, updateSettings } = useAppSettings();
|
||||||
const [localDaemonVersionError, setLocalDaemonVersionError] = useState<string | null>(null);
|
const [managedStatus, setManagedStatus] = useState<ManagedDaemonStatus | null>(null);
|
||||||
const [isUpdatingLocalDaemon, setIsUpdatingLocalDaemon] = useState(false);
|
const [statusError, setStatusError] = useState<string | null>(null);
|
||||||
const [localDaemonUpdateMessage, setLocalDaemonUpdateMessage] = useState<string | null>(null);
|
const [isRestartingDaemon, setIsRestartingDaemon] = useState(false);
|
||||||
const [localDaemonUpdateDiagnostics, setLocalDaemonUpdateDiagnostics] = useState<string | null>(
|
const [isUpdatingDaemonManagement, setIsUpdatingDaemonManagement] = useState(false);
|
||||||
|
const [isInstallingCli, setIsInstallingCli] = useState(false);
|
||||||
|
const [statusMessage, setStatusMessage] = useState<string | null>(null);
|
||||||
|
const [cliStatusMessage, setCliStatusMessage] = useState<string | null>(null);
|
||||||
|
const [managedLogs, setManagedLogs] = useState<ManagedDaemonLogs | null>(null);
|
||||||
|
const [isLogsModalOpen, setIsLogsModalOpen] = useState(false);
|
||||||
|
const [isPairingModalOpen, setIsPairingModalOpen] = useState(false);
|
||||||
|
const [isCliInstallModalOpen, setIsCliInstallModalOpen] = useState(false);
|
||||||
|
const [isLoadingPairing, setIsLoadingPairing] = useState(false);
|
||||||
|
const [pairingOffer, setPairingOffer] = useState<ManagedPairingOffer | null>(null);
|
||||||
|
const [cliInstallInstructions, setCliInstallInstructions] = useState<CliManualInstructions | null>(
|
||||||
null
|
null
|
||||||
);
|
);
|
||||||
|
const [pairingStatusMessage, setPairingStatusMessage] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const loadManagedStatus = useCallback(() => {
|
||||||
|
if (!showSection) {
|
||||||
|
return Promise.resolve();
|
||||||
|
}
|
||||||
|
return Promise.all([getManagedDaemonStatus(), getManagedDaemonLogs()])
|
||||||
|
.then(([status, logs]) => {
|
||||||
|
setManagedStatus(status);
|
||||||
|
setManagedLogs(logs);
|
||||||
|
setStatusError(null);
|
||||||
|
})
|
||||||
|
.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 daemonStatusStateText =
|
||||||
|
statusError ??
|
||||||
|
(managedStatus?.daemonRunning
|
||||||
|
? managedStatus?.daemonStatus ?? "running"
|
||||||
|
: "not running");
|
||||||
|
const daemonStatusDetailText = `PID ${managedStatus?.daemonPid ? managedStatus.daemonPid : "—"}`;
|
||||||
|
const isDaemonManagementPaused = !settings.manageBuiltInDaemon;
|
||||||
|
const daemonActionLabel = managedStatus?.daemonRunning ? "Restart daemon" : "Start daemon";
|
||||||
|
const daemonActionMessage = managedStatus?.daemonRunning
|
||||||
|
? "Restarts the built-in daemon."
|
||||||
|
: isDaemonManagementPaused
|
||||||
|
? "Starts the built-in daemon manually. Paseo will not auto-start it while paused."
|
||||||
|
: "Starts the built-in daemon.";
|
||||||
|
|
||||||
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: daemonActionLabel,
|
||||||
message:
|
message: managedStatus?.daemonRunning
|
||||||
"This updates the Paseo daemon on this computer. A restart is required afterwards.",
|
? "This will restart the built-in daemon. The app will reconnect automatically."
|
||||||
confirmLabel: "Update daemon",
|
: "This will start the built-in daemon.",
|
||||||
|
confirmLabel: daemonActionLabel,
|
||||||
cancelLabel: "Cancel",
|
cancelLabel: "Cancel",
|
||||||
})
|
})
|
||||||
.then((confirmed) => {
|
.then((confirmed) => {
|
||||||
@@ -66,71 +118,213 @@ export function LocalDaemonSection({ appVersion }: LocalDaemonSectionProps) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setIsUpdatingLocalDaemon(true);
|
setIsRestartingDaemon(true);
|
||||||
setLocalDaemonUpdateMessage(null);
|
setStatusMessage(null);
|
||||||
setLocalDaemonUpdateDiagnostics(null);
|
|
||||||
|
|
||||||
void runLocalDaemonUpdate()
|
const action = managedStatus?.daemonRunning ? restartManagedDaemon : startManagedDaemon;
|
||||||
.then((result) => {
|
|
||||||
const diagnostics = buildDaemonUpdateDiagnostics(result);
|
|
||||||
if (result.exitCode !== 0) {
|
|
||||||
setLocalDaemonUpdateMessage(
|
|
||||||
`Local daemon update failed (exit code ${result.exitCode}). Copy diagnostics below to troubleshoot.`
|
|
||||||
);
|
|
||||||
setLocalDaemonUpdateDiagnostics(diagnostics);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setLocalDaemonUpdateMessage(
|
void action()
|
||||||
"Local daemon update finished. Restart is required: run `paseo daemon restart` on this computer."
|
.then((status) => {
|
||||||
|
setManagedStatus(status);
|
||||||
|
setStatusMessage(
|
||||||
|
managedStatus?.daemonRunning ? "Daemon restarted." : "Daemon started."
|
||||||
);
|
);
|
||||||
if (result.stdout.trim().length > 0 || result.stderr.trim().length > 0) {
|
return loadManagedStatus();
|
||||||
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 change managed daemon state", error);
|
||||||
const message = error instanceof Error ? error.message : String(error);
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
setLocalDaemonUpdateMessage(
|
setStatusMessage(`${daemonActionLabel} 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 action confirmation", error);
|
||||||
Alert.alert("Error", "Unable to open the daemon update confirmation dialog.");
|
Alert.alert("Error", "Unable to open the daemon confirmation dialog.");
|
||||||
});
|
});
|
||||||
}, [isUpdatingLocalDaemon, showSection]);
|
}, [daemonActionLabel, isRestartingDaemon, loadManagedStatus, managedStatus?.daemonRunning, showSection]);
|
||||||
|
|
||||||
const handleCopyDaemonDiagnostics = useCallback(() => {
|
const handleToggleDaemonManagement = useCallback(() => {
|
||||||
if (!localDaemonUpdateDiagnostics) {
|
if (isUpdatingDaemonManagement) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
void Clipboard.setStringAsync(localDaemonUpdateDiagnostics)
|
if (!settings.manageBuiltInDaemon) {
|
||||||
.then(() => {
|
setIsUpdatingDaemonManagement(true);
|
||||||
Alert.alert("Copied", "Daemon update diagnostics copied.");
|
setStatusMessage(null);
|
||||||
|
void updateSettings({ manageBuiltInDaemon: true })
|
||||||
|
.then(() => {
|
||||||
|
setStatusMessage("Paseo will resume managing the built-in daemon on startup.");
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
console.error("[Settings] Failed to update built-in daemon management", error);
|
||||||
|
Alert.alert("Error", "Unable to update built-in daemon management.");
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
setIsUpdatingDaemonManagement(false);
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
void confirmDialog({
|
||||||
|
title: "Pause built-in daemon",
|
||||||
|
message:
|
||||||
|
"This will stop the built-in daemon immediately and prevent Paseo from auto-starting it on launch. Running agents and terminals connected to the built-in daemon will be stopped.",
|
||||||
|
confirmLabel: "Pause and stop",
|
||||||
|
cancelLabel: "Cancel",
|
||||||
|
destructive: true,
|
||||||
|
})
|
||||||
|
.then((confirmed) => {
|
||||||
|
if (!confirmed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsUpdatingDaemonManagement(true);
|
||||||
|
setStatusMessage(null);
|
||||||
|
|
||||||
|
const stopPromise = managedStatus?.daemonRunning
|
||||||
|
? stopManagedDaemon()
|
||||||
|
: Promise.resolve(managedStatus ?? null);
|
||||||
|
|
||||||
|
void stopPromise
|
||||||
|
.then(() => updateSettings({ manageBuiltInDaemon: false }))
|
||||||
|
.then(() => loadManagedStatus())
|
||||||
|
.then(() => {
|
||||||
|
setStatusMessage(
|
||||||
|
"Paseo paused the built-in daemon and will no longer auto-start it on launch."
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
console.error("[Settings] Failed to pause built-in daemon management", error);
|
||||||
|
Alert.alert("Error", "Unable to pause built-in daemon management.");
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
setIsUpdatingDaemonManagement(false);
|
||||||
|
});
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
console.error("[Settings] Failed to copy daemon update diagnostics", error);
|
console.error("[Settings] Failed to open built-in daemon pause confirmation", error);
|
||||||
Alert.alert("Error", "Unable to copy diagnostics.");
|
Alert.alert("Error", "Unable to open the daemon confirmation dialog.");
|
||||||
});
|
});
|
||||||
}, [localDaemonUpdateDiagnostics]);
|
}, [
|
||||||
|
isUpdatingDaemonManagement,
|
||||||
|
loadManagedStatus,
|
||||||
|
managedStatus,
|
||||||
|
settings.manageBuiltInDaemon,
|
||||||
|
updateSettings,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const handleToggleCliShim = useCallback(() => {
|
||||||
|
if (!showSection || isInstallingCli) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setIsInstallingCli(true);
|
||||||
|
const isInstalling = !managedStatus?.cliShimPath;
|
||||||
|
setCliStatusMessage(
|
||||||
|
isInstalling
|
||||||
|
? "A permissions popup may appear while Paseo installs the CLI globally."
|
||||||
|
: null
|
||||||
|
);
|
||||||
|
const action = managedStatus?.cliShimPath ? uninstallManagedCliShim : installManagedCliShim;
|
||||||
|
void action()
|
||||||
|
.then((result) => {
|
||||||
|
setCliStatusMessage(result.message);
|
||||||
|
if (result.manualInstructions) {
|
||||||
|
setCliInstallInstructions(result.manualInstructions);
|
||||||
|
setIsCliInstallModalOpen(true);
|
||||||
|
} else {
|
||||||
|
setCliInstallInstructions(null);
|
||||||
|
setIsCliInstallModalOpen(false);
|
||||||
|
}
|
||||||
|
return loadManagedStatus();
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
|
setCliStatusMessage(`CLI install failed: ${message}`);
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
setIsInstallingCli(false);
|
||||||
|
});
|
||||||
|
}, [isInstallingCli, loadManagedStatus, managedStatus?.cliShimPath, showSection]);
|
||||||
|
|
||||||
|
const handleCopyCliInstallCommands = useCallback(() => {
|
||||||
|
if (!cliInstallInstructions?.commands) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
void Clipboard.setStringAsync(cliInstallInstructions.commands)
|
||||||
|
.then(() => {
|
||||||
|
Alert.alert("Copied", "CLI install commands copied.");
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
console.error("[Settings] Failed to copy CLI install commands", error);
|
||||||
|
Alert.alert("Error", "Unable to copy CLI install commands.");
|
||||||
|
});
|
||||||
|
}, [cliInstallInstructions?.commands]);
|
||||||
|
|
||||||
|
const handleCopyLogPath = useCallback(() => {
|
||||||
|
const logPath = managedLogs?.logPath ?? managedStatus?.logPath;
|
||||||
|
if (!logPath) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
void Clipboard.setStringAsync(logPath)
|
||||||
|
.then(() => {
|
||||||
|
Alert.alert("Copied", "Log path copied.");
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
console.error("[Settings] Failed to copy log path", error);
|
||||||
|
Alert.alert("Error", "Unable to copy log path.");
|
||||||
|
});
|
||||||
|
}, [managedLogs?.logPath, managedStatus?.logPath]);
|
||||||
|
|
||||||
|
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 not available.");
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.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]);
|
||||||
|
|
||||||
if (!showSection) {
|
if (!showSection) {
|
||||||
return null;
|
return null;
|
||||||
@@ -138,32 +332,131 @@ export function LocalDaemonSection({ appVersion }: LocalDaemonSectionProps) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={styles.section}>
|
<View style={styles.section}>
|
||||||
<Text style={styles.sectionTitle}>Local daemon</Text>
|
<View style={styles.sectionHeader}>
|
||||||
|
<Text style={styles.sectionTitle}>Built-in daemon</Text>
|
||||||
|
<Pressable
|
||||||
|
accessibilityRole="link"
|
||||||
|
onPress={() => void openExternalUrl(ADVANCED_DAEMON_SETTINGS_URL)}
|
||||||
|
style={styles.sectionLink}
|
||||||
|
>
|
||||||
|
<Text style={styles.sectionLinkText}>Advanced settings</Text>
|
||||||
|
<ArrowUpRight size={14} color={styles.sectionLinkText.color} />
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
<View style={styles.card}>
|
<View style={styles.card}>
|
||||||
<View style={styles.row}>
|
<View style={styles.row}>
|
||||||
<View style={styles.rowContent}>
|
<View style={styles.rowContent}>
|
||||||
<Text style={styles.rowTitle}>Version</Text>
|
<Text style={styles.rowTitle}>Status</Text>
|
||||||
<Text style={styles.hintText}>{daemonVersionHint}</Text>
|
<Text style={styles.hintText}>Only the built-in managed daemon is shown here.</Text>
|
||||||
|
</View>
|
||||||
|
<View style={styles.statusValueGroup}>
|
||||||
|
<Text style={styles.valueText}>{daemonStatusStateText}</Text>
|
||||||
|
<Text style={styles.valueSubtext}>{daemonStatusDetailText}</Text>
|
||||||
</View>
|
</View>
|
||||||
<Text style={styles.valueText}>{localDaemonVersionText}</Text>
|
|
||||||
</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}>Daemon management</Text>
|
||||||
<Text style={styles.hintText}>
|
<Text style={styles.hintText}>
|
||||||
Updates the daemon on this computer only. Requires a restart.
|
{isDaemonManagementPaused
|
||||||
|
? "Paused. Paseo will not auto-start the built-in daemon on app launch."
|
||||||
|
: "Enabled. Paseo will start the built-in daemon automatically when needed."}
|
||||||
</Text>
|
</Text>
|
||||||
{localDaemonUpdateMessage ? (
|
</View>
|
||||||
<Text style={styles.statusText}>{localDaemonUpdateMessage}</Text>
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
style={styles.primaryActionButton}
|
||||||
|
onPress={handleToggleDaemonManagement}
|
||||||
|
disabled={isUpdatingDaemonManagement}
|
||||||
|
>
|
||||||
|
{isUpdatingDaemonManagement
|
||||||
|
? isDaemonManagementPaused
|
||||||
|
? "Resuming..."
|
||||||
|
: "Pausing..."
|
||||||
|
: isDaemonManagementPaused
|
||||||
|
? "Resume"
|
||||||
|
: "Pause"}
|
||||||
|
</Button>
|
||||||
|
</View>
|
||||||
|
<View style={[styles.row, styles.rowBorder]}>
|
||||||
|
<View style={styles.rowContent}>
|
||||||
|
<Text style={styles.rowTitle}>{daemonActionLabel}</Text>
|
||||||
|
<Text style={styles.hintText}>{daemonActionMessage}</Text>
|
||||||
|
{statusMessage ? (
|
||||||
|
<Text style={styles.statusText}>{statusMessage}</Text>
|
||||||
) : null}
|
) : null}
|
||||||
</View>
|
</View>
|
||||||
<Button
|
<Button
|
||||||
variant="secondary"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
|
style={styles.primaryActionButton}
|
||||||
onPress={handleUpdateLocalDaemon}
|
onPress={handleUpdateLocalDaemon}
|
||||||
disabled={isUpdatingLocalDaemon}
|
disabled={isRestartingDaemon}
|
||||||
>
|
>
|
||||||
{isUpdatingLocalDaemon ? "Updating..." : "Update daemon"}
|
{isRestartingDaemon
|
||||||
|
? managedStatus?.daemonRunning
|
||||||
|
? "Restarting..."
|
||||||
|
: "Starting..."
|
||||||
|
: daemonActionLabel}
|
||||||
|
</Button>
|
||||||
|
</View>
|
||||||
|
<View style={[styles.row, styles.rowBorder]}>
|
||||||
|
<View style={styles.rowContent}>
|
||||||
|
<Text style={styles.rowTitle}>Command line (CLI)</Text>
|
||||||
|
<Text style={styles.hintText}>
|
||||||
|
Adds the `paseo` command to your terminal.
|
||||||
|
</Text>
|
||||||
|
{cliStatusMessage ? <Text style={styles.statusText}>{cliStatusMessage}</Text> : null}
|
||||||
|
</View>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
style={styles.secondaryActionButton}
|
||||||
|
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 ??
|
||||||
|
"Log path unavailable."}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<View style={styles.actionGroup}>
|
||||||
|
{(managedLogs?.logPath ?? managedStatus?.logPath) ? (
|
||||||
|
<Button variant="outline" size="sm" onPress={handleCopyLogPath}>
|
||||||
|
Copy path
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
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}>
|
||||||
|
Connect your phone to this computer.
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<Button variant="outline" size="sm" style={styles.secondaryActionButton} onPress={handleOpenPairingModal}>
|
||||||
|
Pair device
|
||||||
</Button>
|
</Button>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
@@ -171,25 +464,173 @@ export function LocalDaemonSection({ appVersion }: LocalDaemonSectionProps) {
|
|||||||
{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
|
App and daemon versions don't match. Update both to the same version for the best
|
||||||
stability issues or breaking changes.
|
experience.
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{localDaemonUpdateDiagnostics ? (
|
<AdaptiveModalSheet
|
||||||
<View style={styles.diagnosticsCard}>
|
visible={isCliInstallModalOpen}
|
||||||
<View style={styles.diagnosticsHeader}>
|
onClose={() => setIsCliInstallModalOpen(false)}
|
||||||
<Text style={styles.diagnosticsTitle}>Daemon update diagnostics</Text>
|
title="Install CLI manually"
|
||||||
<Button variant="secondary" size="sm" onPress={handleCopyDaemonDiagnostics}>
|
testID="managed-daemon-cli-install-dialog"
|
||||||
Copy output
|
>
|
||||||
|
<View style={styles.modalBody}>
|
||||||
|
<Text style={styles.hintText}>
|
||||||
|
A permissions popup should appear when Paseo installs the CLI globally. If it does not
|
||||||
|
complete, open a terminal and run the commands below.
|
||||||
|
</Text>
|
||||||
|
{cliInstallInstructions?.detail ? (
|
||||||
|
<Text style={styles.hintText}>{cliInstallInstructions.detail}</Text>
|
||||||
|
) : null}
|
||||||
|
<Text style={styles.codeBlock} selectable>
|
||||||
|
{cliInstallInstructions?.commands ?? ""}
|
||||||
|
</Text>
|
||||||
|
<View style={styles.modalActions}>
|
||||||
|
<Button variant="secondary" size="sm" onPress={() => setIsCliInstallModalOpen(false)}>
|
||||||
|
Close
|
||||||
|
</Button>
|
||||||
|
<Button size="sm" onPress={handleCopyCliInstallCommands}>
|
||||||
|
Copy commands
|
||||||
</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="Daemon logs"
|
||||||
|
testID="managed-daemon-logs-dialog"
|
||||||
|
snapPoints={["70%", "92%"]}
|
||||||
|
>
|
||||||
|
<View style={styles.modalBody}>
|
||||||
|
<Text style={styles.hintText}>
|
||||||
|
{managedLogs?.logPath ??
|
||||||
|
managedStatus?.logPath ??
|
||||||
|
"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 ADVANCED_DAEMON_SETTINGS_URL = "https://paseo.sh/docs/configuration";
|
||||||
|
|
||||||
|
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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -198,12 +639,26 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
section: {
|
section: {
|
||||||
marginBottom: theme.spacing[6],
|
marginBottom: theme.spacing[6],
|
||||||
},
|
},
|
||||||
|
sectionHeader: {
|
||||||
|
alignItems: "center",
|
||||||
|
flexDirection: "row",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
marginBottom: theme.spacing[3],
|
||||||
|
marginLeft: theme.spacing[1],
|
||||||
|
},
|
||||||
sectionTitle: {
|
sectionTitle: {
|
||||||
color: theme.colors.foregroundMuted,
|
color: theme.colors.foregroundMuted,
|
||||||
fontSize: theme.fontSize.xs,
|
fontSize: theme.fontSize.xs,
|
||||||
fontWeight: theme.fontWeight.normal,
|
fontWeight: theme.fontWeight.normal,
|
||||||
marginBottom: theme.spacing[3],
|
},
|
||||||
marginLeft: theme.spacing[1],
|
sectionLink: {
|
||||||
|
alignItems: "center",
|
||||||
|
flexDirection: "row",
|
||||||
|
gap: theme.spacing[1],
|
||||||
|
},
|
||||||
|
sectionLinkText: {
|
||||||
|
color: theme.colors.foregroundMuted,
|
||||||
|
fontSize: theme.fontSize.xs,
|
||||||
},
|
},
|
||||||
card: {
|
card: {
|
||||||
backgroundColor: theme.colors.surface2,
|
backgroundColor: theme.colors.surface2,
|
||||||
@@ -227,6 +682,22 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
flex: 1,
|
flex: 1,
|
||||||
marginRight: theme.spacing[3],
|
marginRight: theme.spacing[3],
|
||||||
},
|
},
|
||||||
|
actionGroup: {
|
||||||
|
flexDirection: "row",
|
||||||
|
gap: theme.spacing[2],
|
||||||
|
flexWrap: "wrap",
|
||||||
|
justifyContent: "flex-end",
|
||||||
|
},
|
||||||
|
statusValueGroup: {
|
||||||
|
alignItems: "flex-end",
|
||||||
|
gap: 2,
|
||||||
|
},
|
||||||
|
primaryActionButton: {
|
||||||
|
minWidth: 124,
|
||||||
|
},
|
||||||
|
secondaryActionButton: {
|
||||||
|
minWidth: 112,
|
||||||
|
},
|
||||||
rowTitle: {
|
rowTitle: {
|
||||||
color: theme.colors.foreground,
|
color: theme.colors.foreground,
|
||||||
fontSize: theme.fontSize.base,
|
fontSize: theme.fontSize.base,
|
||||||
@@ -236,6 +707,10 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
fontSize: theme.fontSize.sm,
|
fontSize: theme.fontSize.sm,
|
||||||
fontWeight: theme.fontWeight.normal,
|
fontWeight: theme.fontWeight.normal,
|
||||||
},
|
},
|
||||||
|
valueSubtext: {
|
||||||
|
color: theme.colors.foregroundMuted,
|
||||||
|
fontSize: theme.fontSize.xs,
|
||||||
|
},
|
||||||
hintText: {
|
hintText: {
|
||||||
color: theme.colors.foregroundMuted,
|
color: theme.colors.foregroundMuted,
|
||||||
fontSize: theme.fontSize.xs,
|
fontSize: theme.fontSize.xs,
|
||||||
@@ -259,28 +734,61 @@ 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,
|
||||||
|
},
|
||||||
|
codeBlock: {
|
||||||
|
color: theme.colors.foregroundMuted,
|
||||||
|
fontSize: theme.fontSize.xs,
|
||||||
|
fontFamily: "ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace",
|
||||||
|
lineHeight: 18,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: theme.colors.border,
|
||||||
|
borderRadius: theme.borderRadius.md,
|
||||||
|
backgroundColor: theme.colors.surface0,
|
||||||
|
padding: theme.spacing[3],
|
||||||
|
},
|
||||||
|
modalActions: {
|
||||||
|
flexDirection: "row",
|
||||||
|
justifyContent: "flex-end",
|
||||||
|
gap: theme.spacing[2],
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { parseCliShimResult } from "./managed-runtime";
|
||||||
|
|
||||||
|
describe("parseCliShimResult", () => {
|
||||||
|
it("parses manual install payloads from the desktop backend", () => {
|
||||||
|
expect(
|
||||||
|
parseCliShimResult({
|
||||||
|
status: "manualInstallRequired",
|
||||||
|
installed: false,
|
||||||
|
path: "/usr/local/bin/paseo",
|
||||||
|
message: "Install it manually.",
|
||||||
|
manualInstructions: {
|
||||||
|
title: "Install from Terminal",
|
||||||
|
detail: "Run these commands.",
|
||||||
|
commands: "sudo tee /usr/local/bin/paseo",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
).toEqual({
|
||||||
|
status: "manualInstallRequired",
|
||||||
|
installed: false,
|
||||||
|
path: "/usr/local/bin/paseo",
|
||||||
|
message: "Install it manually.",
|
||||||
|
manualInstructions: {
|
||||||
|
title: "Install from Terminal",
|
||||||
|
detail: "Run these commands.",
|
||||||
|
commands: "sudo tee /usr/local/bin/paseo",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to installed or removed when older payloads omit status", () => {
|
||||||
|
expect(
|
||||||
|
parseCliShimResult({
|
||||||
|
installed: true,
|
||||||
|
path: "/usr/local/bin/paseo",
|
||||||
|
message: "Installed.",
|
||||||
|
})
|
||||||
|
).toEqual({
|
||||||
|
status: "installed",
|
||||||
|
installed: true,
|
||||||
|
path: "/usr/local/bin/paseo",
|
||||||
|
message: "Installed.",
|
||||||
|
manualInstructions: null,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,282 @@
|
|||||||
|
import { invokeDesktopCommand } from "@/desktop/tauri/invoke-desktop-command";
|
||||||
|
import { getTauri, isTauriEnvironment } from "@/utils/tauri";
|
||||||
|
|
||||||
|
export type ManagedRuntimeStatus = {
|
||||||
|
runtimeId: string;
|
||||||
|
runtimeVersion: string;
|
||||||
|
runtimeRoot: string;
|
||||||
|
managedHome: string;
|
||||||
|
transportType: string;
|
||||||
|
transportPath: string;
|
||||||
|
diagnosticsRoot: string;
|
||||||
|
stateFilePath: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
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 = {
|
||||||
|
status:
|
||||||
|
| "installed"
|
||||||
|
| "removed"
|
||||||
|
| "elevationDenied"
|
||||||
|
| "automaticInstallUnavailable"
|
||||||
|
| "manualInstallRequired";
|
||||||
|
installed: boolean;
|
||||||
|
path: string | null;
|
||||||
|
message: string;
|
||||||
|
manualInstructions: CliManualInstructions | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CliManualInstructions = {
|
||||||
|
title: string;
|
||||||
|
detail: string;
|
||||||
|
commands: 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) ?? "",
|
||||||
|
runtimeRoot: toStringOrNull(raw.runtimeRoot) ?? "",
|
||||||
|
managedHome: toStringOrNull(raw.managedHome) ?? "",
|
||||||
|
transportType: toStringOrNull(raw.transportType) ?? "socket",
|
||||||
|
transportPath: toStringOrNull(raw.transportPath) ?? "",
|
||||||
|
diagnosticsRoot: toStringOrNull(raw.diagnosticsRoot) ?? "",
|
||||||
|
stateFilePath: toStringOrNull(raw.stateFilePath) ?? "",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseManagedDaemonStatus(raw: unknown): ManagedDaemonStatus {
|
||||||
|
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 parseCliManualInstructions(raw: unknown): CliManualInstructions | null {
|
||||||
|
if (!isRecord(raw)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
title: toStringOrNull(raw.title) ?? "",
|
||||||
|
detail: toStringOrNull(raw.detail) ?? "",
|
||||||
|
commands: toStringOrNull(raw.commands) ?? "",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseCliShimResult(raw: unknown): CliShimResult {
|
||||||
|
if (!isRecord(raw)) {
|
||||||
|
throw new Error("Unexpected CLI shim response.");
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
status:
|
||||||
|
(toStringOrNull(raw.status) as CliShimResult["status"] | null) ??
|
||||||
|
(raw.installed === true ? "installed" : "removed"),
|
||||||
|
installed: raw.installed === true,
|
||||||
|
path: toStringOrNull(raw.path),
|
||||||
|
message: toStringOrNull(raw.message) ?? "",
|
||||||
|
manualInstructions: parseCliManualInstructions(raw.manualInstructions),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function shouldUseManagedDesktopDaemon(): boolean {
|
||||||
|
return isTauriEnvironment() && getTauri() !== null;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 });
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
const asyncStorageMock = vi.hoisted(() => ({
|
||||||
|
getItem: vi.fn<(_: string) => Promise<string | null>>(),
|
||||||
|
setItem: vi.fn<(_: string, __: string) => Promise<void>>(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@react-native-async-storage/async-storage", () => ({
|
||||||
|
default: asyncStorageMock,
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe("use-settings", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.resetModules();
|
||||||
|
asyncStorageMock.getItem.mockReset();
|
||||||
|
asyncStorageMock.setItem.mockReset();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("defaults built-in daemon management to enabled when storage is empty", async () => {
|
||||||
|
asyncStorageMock.getItem.mockResolvedValue(null);
|
||||||
|
asyncStorageMock.setItem.mockResolvedValue();
|
||||||
|
|
||||||
|
const mod = await import("./use-settings");
|
||||||
|
const result = await mod.loadSettingsFromStorage();
|
||||||
|
|
||||||
|
expect(result).toEqual(mod.DEFAULT_APP_SETTINGS);
|
||||||
|
expect(asyncStorageMock.setItem).toHaveBeenCalledWith(
|
||||||
|
mod.APP_SETTINGS_KEY,
|
||||||
|
JSON.stringify(mod.DEFAULT_APP_SETTINGS)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("loads persisted built-in daemon management state", async () => {
|
||||||
|
asyncStorageMock.getItem.mockImplementation(async (key: string) => {
|
||||||
|
if (key === "@paseo:app-settings") {
|
||||||
|
return JSON.stringify({
|
||||||
|
theme: "light",
|
||||||
|
manageBuiltInDaemon: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
|
||||||
|
const mod = await import("./use-settings");
|
||||||
|
const result = await mod.loadSettingsFromStorage();
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
theme: "light",
|
||||||
|
manageBuiltInDaemon: false,
|
||||||
|
});
|
||||||
|
expect(asyncStorageMock.setItem).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -2,16 +2,18 @@ import { useCallback } from "react";
|
|||||||
import AsyncStorage from "@react-native-async-storage/async-storage";
|
import AsyncStorage from "@react-native-async-storage/async-storage";
|
||||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
|
||||||
const APP_SETTINGS_KEY = "@paseo:app-settings";
|
export const APP_SETTINGS_KEY = "@paseo:app-settings";
|
||||||
const LEGACY_SETTINGS_KEY = "@paseo:settings";
|
const LEGACY_SETTINGS_KEY = "@paseo:settings";
|
||||||
const APP_SETTINGS_QUERY_KEY = ["app-settings"];
|
const APP_SETTINGS_QUERY_KEY = ["app-settings"];
|
||||||
|
|
||||||
export interface AppSettings {
|
export interface AppSettings {
|
||||||
theme: "dark" | "light" | "auto";
|
theme: "dark" | "light" | "auto";
|
||||||
|
manageBuiltInDaemon: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const DEFAULT_APP_SETTINGS: AppSettings = {
|
export const DEFAULT_APP_SETTINGS: AppSettings = {
|
||||||
theme: "dark",
|
theme: "dark",
|
||||||
|
manageBuiltInDaemon: true,
|
||||||
};
|
};
|
||||||
|
|
||||||
export interface UseAppSettingsReturn {
|
export interface UseAppSettingsReturn {
|
||||||
@@ -66,7 +68,7 @@ export function useAppSettings(): UseAppSettingsReturn {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadSettingsFromStorage(): Promise<AppSettings> {
|
export async function loadSettingsFromStorage(): Promise<AppSettings> {
|
||||||
try {
|
try {
|
||||||
const stored = await AsyncStorage.getItem(APP_SETTINGS_KEY);
|
const stored = await AsyncStorage.getItem(APP_SETTINGS_KEY);
|
||||||
if (stored) {
|
if (stored) {
|
||||||
@@ -98,6 +100,9 @@ function pickAppSettingsFromLegacy(legacy: Record<string, unknown>): Partial<App
|
|||||||
if (legacy.theme === "dark" || legacy.theme === "light" || legacy.theme === "auto") {
|
if (legacy.theme === "dark" || legacy.theme === "light" || legacy.theme === "auto") {
|
||||||
result.theme = legacy.theme;
|
result.theme = legacy.theme;
|
||||||
}
|
}
|
||||||
|
if (typeof legacy.manageBuiltInDaemon === "boolean") {
|
||||||
|
result.manageBuiltInDaemon = legacy.manageBuiltInDaemon;
|
||||||
|
}
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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",
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -984,7 +990,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 `Local (${connection.path})`;
|
||||||
|
}
|
||||||
|
if (connection.type === "directPipe") {
|
||||||
|
return `Local (${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: "Local" };
|
||||||
|
}
|
||||||
|
if (activeConnection.type === "directPipe") {
|
||||||
|
return { icon: <Monitor size={theme.iconSize.xs} color={theme.colors.foregroundMuted} />, text: "Local" };
|
||||||
|
}
|
||||||
|
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,
|
||||||
@@ -669,7 +705,7 @@ export default function SettingsScreen() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const restartConfirmationMessage =
|
const restartConfirmationMessage =
|
||||||
"This will immediately stop the Paseo daemon process. The app will disconnect until it restarts.";
|
"This will restart the daemon. The app will reconnect automatically.";
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
@@ -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(
|
||||||
|
|||||||
@@ -0,0 +1,127 @@
|
|||||||
|
import { describe, expect, it, vi, beforeEach } from "vitest";
|
||||||
|
|
||||||
|
const managedRuntimeMock = vi.hoisted(() => {
|
||||||
|
let eventHandler: ((payload: {
|
||||||
|
sessionId: string;
|
||||||
|
kind: "open" | "message" | "close" | "error";
|
||||||
|
text?: string | null;
|
||||||
|
binaryBase64?: string | null;
|
||||||
|
code?: number | null;
|
||||||
|
reason?: string | null;
|
||||||
|
error?: string | null;
|
||||||
|
}) => void) | null = null;
|
||||||
|
|
||||||
|
const openLocalTransportSession = vi.fn<(...args: unknown[]) => Promise<string>>();
|
||||||
|
const listenToLocalTransportEvents = vi.fn(async (handler: typeof eventHandler extends ((...args: infer A) => any) | null ? (...args: A) => void : never) => {
|
||||||
|
eventHandler = handler;
|
||||||
|
return () => {
|
||||||
|
eventHandler = null;
|
||||||
|
};
|
||||||
|
});
|
||||||
|
const sendLocalTransportMessage = vi.fn(async () => undefined);
|
||||||
|
const closeLocalTransportSession = vi.fn(async () => undefined);
|
||||||
|
|
||||||
|
return {
|
||||||
|
openLocalTransportSession,
|
||||||
|
listenToLocalTransportEvents,
|
||||||
|
sendLocalTransportMessage,
|
||||||
|
closeLocalTransportSession,
|
||||||
|
emitEvent(payload: {
|
||||||
|
sessionId: string;
|
||||||
|
kind: "open" | "message" | "close" | "error";
|
||||||
|
text?: string | null;
|
||||||
|
binaryBase64?: string | null;
|
||||||
|
code?: number | null;
|
||||||
|
reason?: string | null;
|
||||||
|
error?: string | null;
|
||||||
|
}) {
|
||||||
|
eventHandler?.(payload);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mock("@/desktop/managed-runtime/managed-runtime", () => ({
|
||||||
|
openLocalTransportSession: managedRuntimeMock.openLocalTransportSession,
|
||||||
|
listenToLocalTransportEvents: managedRuntimeMock.listenToLocalTransportEvents,
|
||||||
|
sendLocalTransportMessage: managedRuntimeMock.sendLocalTransportMessage,
|
||||||
|
closeLocalTransportSession: managedRuntimeMock.closeLocalTransportSession,
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe("managed-tauri-daemon-transport", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
managedRuntimeMock.openLocalTransportSession.mockReset();
|
||||||
|
managedRuntimeMock.listenToLocalTransportEvents.mockClear();
|
||||||
|
managedRuntimeMock.sendLocalTransportMessage.mockClear();
|
||||||
|
managedRuntimeMock.closeLocalTransportSession.mockClear();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("emits open after the session resolves even if the rust open event raced earlier", async () => {
|
||||||
|
let resolveSession!: (sessionId: string) => void;
|
||||||
|
managedRuntimeMock.openLocalTransportSession.mockImplementation(
|
||||||
|
() =>
|
||||||
|
new Promise<string>((resolve) => {
|
||||||
|
resolveSession = resolve;
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
const mod = await import("./managed-tauri-daemon-transport");
|
||||||
|
const transportFactory = mod.createTauriLocalDaemonTransportFactory();
|
||||||
|
expect(transportFactory).not.toBeNull();
|
||||||
|
|
||||||
|
const transport = transportFactory!({
|
||||||
|
url: "paseo+local://socket?path=%2Ftmp%2Fpaseo.sock",
|
||||||
|
});
|
||||||
|
|
||||||
|
const onOpen = vi.fn();
|
||||||
|
transport.onOpen(onOpen);
|
||||||
|
|
||||||
|
managedRuntimeMock.emitEvent({
|
||||||
|
sessionId: "local-session-1",
|
||||||
|
kind: "open",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(onOpen).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
resolveSession("local-session-1");
|
||||||
|
await Promise.resolve();
|
||||||
|
|
||||||
|
expect(onOpen).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("cleans up late async setup after the transport is closed", async () => {
|
||||||
|
let resolveSession!: (sessionId: string) => void;
|
||||||
|
let resolveListen!: (cleanup: () => void) => void;
|
||||||
|
const cleanup = vi.fn();
|
||||||
|
|
||||||
|
managedRuntimeMock.openLocalTransportSession.mockImplementation(
|
||||||
|
() =>
|
||||||
|
new Promise<string>((resolve) => {
|
||||||
|
resolveSession = resolve;
|
||||||
|
})
|
||||||
|
);
|
||||||
|
managedRuntimeMock.listenToLocalTransportEvents.mockImplementation(
|
||||||
|
() =>
|
||||||
|
new Promise<() => void>((resolve) => {
|
||||||
|
resolveListen = resolve;
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
const mod = await import("./managed-tauri-daemon-transport");
|
||||||
|
const transportFactory = mod.createTauriLocalDaemonTransportFactory();
|
||||||
|
expect(transportFactory).not.toBeNull();
|
||||||
|
|
||||||
|
const transport = transportFactory!({
|
||||||
|
url: "paseo+local://socket?path=%2Ftmp%2Fpaseo.sock",
|
||||||
|
});
|
||||||
|
|
||||||
|
transport.close();
|
||||||
|
|
||||||
|
resolveSession("local-session-2");
|
||||||
|
resolveListen(cleanup);
|
||||||
|
await Promise.resolve();
|
||||||
|
await Promise.resolve();
|
||||||
|
|
||||||
|
expect(managedRuntimeMock.closeLocalTransportSession).toHaveBeenCalledWith("local-session-2");
|
||||||
|
expect(cleanup).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,186 @@
|
|||||||
|
import type { DaemonTransport, DaemonTransportFactory } from "@server/client/daemon-client";
|
||||||
|
import {
|
||||||
|
closeLocalTransportSession,
|
||||||
|
listenToLocalTransportEvents,
|
||||||
|
openLocalTransportSession,
|
||||||
|
sendLocalTransportMessage,
|
||||||
|
type LocalTransportTarget,
|
||||||
|
} from "@/desktop/managed-runtime/managed-runtime";
|
||||||
|
|
||||||
|
const LOCAL_TRANSPORT_SCHEME = "paseo+local:";
|
||||||
|
|
||||||
|
function encodeBinaryToBase64(data: Uint8Array | ArrayBuffer): string {
|
||||||
|
const bytes = data instanceof ArrayBuffer ? new Uint8Array(data) : data;
|
||||||
|
let binary = "";
|
||||||
|
for (let index = 0; index < bytes.length; index += 1) {
|
||||||
|
binary += String.fromCharCode(bytes[index]!);
|
||||||
|
}
|
||||||
|
return globalThis.btoa(binary);
|
||||||
|
}
|
||||||
|
|
||||||
|
function decodeBase64ToBytes(base64: string): Uint8Array {
|
||||||
|
const binary = globalThis.atob(base64);
|
||||||
|
const bytes = new Uint8Array(binary.length);
|
||||||
|
for (let index = 0; index < binary.length; index += 1) {
|
||||||
|
bytes[index] = binary.charCodeAt(index);
|
||||||
|
}
|
||||||
|
return bytes;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildLocalDaemonTransportUrl(target: LocalTransportTarget): string {
|
||||||
|
const url = new URL(`${LOCAL_TRANSPORT_SCHEME}//${target.transportType}`);
|
||||||
|
url.searchParams.set("path", target.transportPath);
|
||||||
|
return url.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseLocalDaemonTransportUrl(url: string): LocalTransportTarget {
|
||||||
|
const parsed = new URL(url);
|
||||||
|
if (parsed.protocol !== LOCAL_TRANSPORT_SCHEME) {
|
||||||
|
throw new Error(`Unsupported local transport URL: ${url}`);
|
||||||
|
}
|
||||||
|
const transportType = parsed.hostname;
|
||||||
|
const transportPath = parsed.searchParams.get("path")?.trim() ?? "";
|
||||||
|
if ((transportType !== "socket" && transportType !== "pipe") || !transportPath) {
|
||||||
|
throw new Error(`Invalid local transport target: ${url}`);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
transportType,
|
||||||
|
transportPath,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createTauriLocalDaemonTransportFactory(): DaemonTransportFactory | null {
|
||||||
|
return ({ url }) => {
|
||||||
|
const target = parseLocalDaemonTransportUrl(url);
|
||||||
|
let sessionId: string | null = null;
|
||||||
|
let unlisten: (() => void) | null = null;
|
||||||
|
let disposed = false;
|
||||||
|
let didEmitOpen = false;
|
||||||
|
|
||||||
|
const openHandlers = new Set<() => void>();
|
||||||
|
const closeHandlers = new Set<(event?: unknown) => void>();
|
||||||
|
const errorHandlers = new Set<(event?: unknown) => void>();
|
||||||
|
const messageHandlers = new Set<(data: unknown) => void>();
|
||||||
|
|
||||||
|
const emitOpen = () => {
|
||||||
|
if (didEmitOpen || disposed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
didEmitOpen = true;
|
||||||
|
for (const handler of openHandlers) {
|
||||||
|
handler();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const emitClose = (event?: unknown) => {
|
||||||
|
for (const handler of closeHandlers) {
|
||||||
|
handler(event);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const emitError = (event?: unknown) => {
|
||||||
|
for (const handler of errorHandlers) {
|
||||||
|
handler(event);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const emitMessage = (data: unknown) => {
|
||||||
|
for (const handler of messageHandlers) {
|
||||||
|
handler(data);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
void listenToLocalTransportEvents((payload) => {
|
||||||
|
if (disposed || !sessionId || payload.sessionId !== sessionId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (payload.kind === "open") {
|
||||||
|
emitOpen();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (payload.kind === "message") {
|
||||||
|
if (payload.text) {
|
||||||
|
emitMessage({ data: payload.text });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (payload.binaryBase64) {
|
||||||
|
emitMessage({ data: decodeBase64ToBytes(payload.binaryBase64) });
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (payload.kind === "close") {
|
||||||
|
emitClose(payload);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
emitError(payload.error ?? "Local daemon transport error");
|
||||||
|
})
|
||||||
|
.then((cleanup) => {
|
||||||
|
if (disposed) {
|
||||||
|
cleanup();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
unlisten = cleanup;
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
emitError(error);
|
||||||
|
});
|
||||||
|
|
||||||
|
void openLocalTransportSession(target)
|
||||||
|
.then((id) => {
|
||||||
|
if (disposed) {
|
||||||
|
void closeLocalTransportSession(id).catch((error) => emitError(error));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
sessionId = id;
|
||||||
|
emitOpen();
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
emitError(error);
|
||||||
|
});
|
||||||
|
|
||||||
|
const transport: DaemonTransport = {
|
||||||
|
send: (data) => {
|
||||||
|
if (!sessionId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (typeof data === "string") {
|
||||||
|
void sendLocalTransportMessage({ sessionId, text: data }).catch((error) =>
|
||||||
|
emitError(error)
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const binaryBase64 = encodeBinaryToBase64(
|
||||||
|
data instanceof ArrayBuffer ? data : new Uint8Array(data)
|
||||||
|
);
|
||||||
|
void sendLocalTransportMessage({ sessionId, binaryBase64 }).catch((error) =>
|
||||||
|
emitError(error)
|
||||||
|
);
|
||||||
|
},
|
||||||
|
close: () => {
|
||||||
|
disposed = true;
|
||||||
|
const currentSessionId = sessionId;
|
||||||
|
sessionId = null;
|
||||||
|
if (currentSessionId) {
|
||||||
|
void closeLocalTransportSession(currentSessionId).catch((error) => emitError(error));
|
||||||
|
}
|
||||||
|
unlisten?.();
|
||||||
|
unlisten = null;
|
||||||
|
},
|
||||||
|
onMessage: (handler) => {
|
||||||
|
messageHandlers.add(handler);
|
||||||
|
return () => messageHandlers.delete(handler);
|
||||||
|
},
|
||||||
|
onOpen: (handler) => {
|
||||||
|
openHandlers.add(handler);
|
||||||
|
return () => openHandlers.delete(handler);
|
||||||
|
},
|
||||||
|
onClose: (handler) => {
|
||||||
|
closeHandlers.add(handler);
|
||||||
|
return () => closeHandlers.delete(handler);
|
||||||
|
},
|
||||||
|
onError: (handler) => {
|
||||||
|
errorHandlers.add(handler);
|
||||||
|
return () => errorHandlers.delete(handler);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
return transport;
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -71,12 +71,12 @@ describe("test-daemon-connection probe client identity", () => {
|
|||||||
|
|
||||||
await mod.measureConnectionLatency({
|
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),
|
||||||
|
|||||||
@@ -66,7 +66,7 @@ export function createCli(): Command {
|
|||||||
.option('--label <key=value>', 'Filter by label (can be used multiple times)', collectMultiple, [])
|
.option('--label <key=value>', 'Filter by label (can be used multiple times)', collectMultiple, [])
|
||||||
.option('--thinking <id>', 'Filter by thinking option ID')
|
.option('--thinking <id>', 'Filter by thinking option ID')
|
||||||
.option('--json', 'Output in JSON format')
|
.option('--json', 'Output in JSON format')
|
||||||
.option('--host <host>', 'Daemon host:port (default: localhost:6767)')
|
.option('--host <host>', 'Daemon host target (default: local socket/pipe, then localhost:6767)')
|
||||||
.action(withOutput(runLsCommand))
|
.action(withOutput(runLsCommand))
|
||||||
|
|
||||||
program
|
program
|
||||||
@@ -86,14 +86,14 @@ export function createCli(): Command {
|
|||||||
.option('--label <key=value>', 'Add label(s) to the agent (can be used multiple times)', collectMultiple, [])
|
.option('--label <key=value>', 'Add label(s) to the agent (can be used multiple times)', collectMultiple, [])
|
||||||
.option('--output-schema <schema>', 'Output JSON matching the provided schema file path or inline JSON schema')
|
.option('--output-schema <schema>', 'Output JSON matching the provided schema file path or inline JSON schema')
|
||||||
.option('--json', 'Output in JSON format')
|
.option('--json', 'Output in JSON format')
|
||||||
.option('--host <host>', 'Daemon host:port (default: localhost:6767)')
|
.option('--host <host>', 'Daemon host target (default: local socket/pipe, then localhost:6767)')
|
||||||
.action(withOutput(runRunCommand))
|
.action(withOutput(runRunCommand))
|
||||||
|
|
||||||
program
|
program
|
||||||
.command('attach')
|
.command('attach')
|
||||||
.description("Attach to a running agent's output stream")
|
.description("Attach to a running agent's output stream")
|
||||||
.argument('<id>', 'Agent ID (or prefix)')
|
.argument('<id>', 'Agent ID (or prefix)')
|
||||||
.option('--host <host>', 'Daemon host:port (default: localhost:6767)')
|
.option('--host <host>', 'Daemon host target (default: local socket/pipe, then localhost:6767)')
|
||||||
.action(runAttachCommand)
|
.action(runAttachCommand)
|
||||||
|
|
||||||
program
|
program
|
||||||
@@ -104,7 +104,7 @@ export function createCli(): Command {
|
|||||||
.option('--tail <n>', 'Show last n entries')
|
.option('--tail <n>', 'Show last n entries')
|
||||||
.option('--filter <type>', 'Filter by event type (tools, text, errors, permissions)')
|
.option('--filter <type>', 'Filter by event type (tools, text, errors, permissions)')
|
||||||
.option('--since <time>', 'Show logs since timestamp')
|
.option('--since <time>', 'Show logs since timestamp')
|
||||||
.option('--host <host>', 'Daemon host:port (default: localhost:6767)')
|
.option('--host <host>', 'Daemon host target (default: local socket/pipe, then localhost:6767)')
|
||||||
.action(runLogsCommand)
|
.action(runLogsCommand)
|
||||||
|
|
||||||
program
|
program
|
||||||
@@ -114,7 +114,7 @@ export function createCli(): Command {
|
|||||||
.option('--all', 'Stop all agents')
|
.option('--all', 'Stop all agents')
|
||||||
.option('--cwd <path>', 'Stop all agents in directory')
|
.option('--cwd <path>', 'Stop all agents in directory')
|
||||||
.option('--json', 'Output in JSON format')
|
.option('--json', 'Output in JSON format')
|
||||||
.option('--host <host>', 'Daemon host:port (default: localhost:6767)')
|
.option('--host <host>', 'Daemon host target (default: local socket/pipe, then localhost:6767)')
|
||||||
.action(withOutput(runStopCommand))
|
.action(withOutput(runStopCommand))
|
||||||
|
|
||||||
program
|
program
|
||||||
@@ -124,7 +124,7 @@ export function createCli(): Command {
|
|||||||
.option('--all', 'Delete all agents')
|
.option('--all', 'Delete all agents')
|
||||||
.option('--cwd <path>', 'Delete all agents in directory')
|
.option('--cwd <path>', 'Delete all agents in directory')
|
||||||
.option('--json', 'Output in JSON format')
|
.option('--json', 'Output in JSON format')
|
||||||
.option('--host <host>', 'Daemon host:port (default: localhost:6767)')
|
.option('--host <host>', 'Daemon host target (default: local socket/pipe, then localhost:6767)')
|
||||||
.action(withOutput(runDeleteCommand))
|
.action(withOutput(runDeleteCommand))
|
||||||
|
|
||||||
program
|
program
|
||||||
@@ -135,7 +135,7 @@ export function createCli(): Command {
|
|||||||
.option('--no-wait', 'Return immediately without waiting for completion')
|
.option('--no-wait', 'Return immediately without waiting for completion')
|
||||||
.option('--image <path>', 'Attach image(s) to the message', collectMultiple, [])
|
.option('--image <path>', 'Attach image(s) to the message', collectMultiple, [])
|
||||||
.option('--json', 'Output in JSON format')
|
.option('--json', 'Output in JSON format')
|
||||||
.option('--host <host>', 'Daemon host:port (default: localhost:6767)')
|
.option('--host <host>', 'Daemon host target (default: local socket/pipe, then localhost:6767)')
|
||||||
.action(withOutput(runSendCommand))
|
.action(withOutput(runSendCommand))
|
||||||
|
|
||||||
program
|
program
|
||||||
@@ -143,7 +143,7 @@ export function createCli(): Command {
|
|||||||
.description('Show detailed information about an agent')
|
.description('Show detailed information about an agent')
|
||||||
.argument('<id>', 'Agent ID (or prefix)')
|
.argument('<id>', 'Agent ID (or prefix)')
|
||||||
.option('--json', 'Output in JSON format')
|
.option('--json', 'Output in JSON format')
|
||||||
.option('--host <host>', 'Daemon host:port (default: localhost:6767)')
|
.option('--host <host>', 'Daemon host target (default: local socket/pipe, then localhost:6767)')
|
||||||
.action(withOutput(runInspectCommand))
|
.action(withOutput(runInspectCommand))
|
||||||
|
|
||||||
program
|
program
|
||||||
@@ -152,7 +152,7 @@ export function createCli(): Command {
|
|||||||
.argument('<id>', 'Agent ID (or prefix)')
|
.argument('<id>', 'Agent ID (or prefix)')
|
||||||
.option('--timeout <seconds>', 'Maximum wait time (default: no limit)')
|
.option('--timeout <seconds>', 'Maximum wait time (default: no limit)')
|
||||||
.option('--json', 'Output in JSON format')
|
.option('--json', 'Output in JSON format')
|
||||||
.option('--host <host>', 'Daemon host:port (default: localhost:6767)')
|
.option('--host <host>', 'Daemon host target (default: local socket/pipe, then localhost:6767)')
|
||||||
.action(withOutput(runWaitCommand))
|
.action(withOutput(runWaitCommand))
|
||||||
|
|
||||||
// Top-level local daemon shortcuts
|
// Top-level local daemon shortcuts
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ export function createAgentCommand(): Command {
|
|||||||
.option('--label <key=value>', 'Filter by label (can be used multiple times)', collectMultiple, [])
|
.option('--label <key=value>', 'Filter by label (can be used multiple times)', collectMultiple, [])
|
||||||
.option('--thinking <id>', 'Filter by thinking option ID')
|
.option('--thinking <id>', 'Filter by thinking option ID')
|
||||||
.option('--json', 'Output in JSON format')
|
.option('--json', 'Output in JSON format')
|
||||||
.option('--host <host>', 'Daemon host:port (default: localhost:6767)')
|
.option('--host <host>', 'Daemon host target (default: local socket/pipe, then localhost:6767)')
|
||||||
.action(withOutput(runLsCommand))
|
.action(withOutput(runLsCommand))
|
||||||
|
|
||||||
agent
|
agent
|
||||||
@@ -47,14 +47,14 @@ export function createAgentCommand(): Command {
|
|||||||
.option('--label <key=value>', 'Add label(s) to the agent (can be used multiple times)', collectMultiple, [])
|
.option('--label <key=value>', 'Add label(s) to the agent (can be used multiple times)', collectMultiple, [])
|
||||||
.option('--output-schema <schema>', 'Output JSON matching the provided schema file path or inline JSON schema')
|
.option('--output-schema <schema>', 'Output JSON matching the provided schema file path or inline JSON schema')
|
||||||
.option('--json', 'Output in JSON format')
|
.option('--json', 'Output in JSON format')
|
||||||
.option('--host <host>', 'Daemon host:port (default: localhost:6767)')
|
.option('--host <host>', 'Daemon host target (default: local socket/pipe, then localhost:6767)')
|
||||||
.action(withOutput(runRunCommand))
|
.action(withOutput(runRunCommand))
|
||||||
|
|
||||||
agent
|
agent
|
||||||
.command('attach')
|
.command('attach')
|
||||||
.description("Attach to a running agent's output stream")
|
.description("Attach to a running agent's output stream")
|
||||||
.argument('<id>', 'Agent ID (or prefix)')
|
.argument('<id>', 'Agent ID (or prefix)')
|
||||||
.option('--host <host>', 'Daemon host:port (default: localhost:6767)')
|
.option('--host <host>', 'Daemon host target (default: local socket/pipe, then localhost:6767)')
|
||||||
.action(runAttachCommand)
|
.action(runAttachCommand)
|
||||||
|
|
||||||
agent
|
agent
|
||||||
@@ -64,7 +64,7 @@ export function createAgentCommand(): Command {
|
|||||||
.option('-f, --follow', 'Follow log output (streaming)')
|
.option('-f, --follow', 'Follow log output (streaming)')
|
||||||
.option('--tail <n>', 'Show last n entries')
|
.option('--tail <n>', 'Show last n entries')
|
||||||
.option('--filter <type>', 'Filter by event type (tools, text, errors, permissions)')
|
.option('--filter <type>', 'Filter by event type (tools, text, errors, permissions)')
|
||||||
.option('--host <host>', 'Daemon host:port (default: localhost:6767)')
|
.option('--host <host>', 'Daemon host target (default: local socket/pipe, then localhost:6767)')
|
||||||
.action(runLogsCommand)
|
.action(runLogsCommand)
|
||||||
|
|
||||||
agent
|
agent
|
||||||
@@ -74,7 +74,7 @@ export function createAgentCommand(): Command {
|
|||||||
.option('--all', 'Stop all agents')
|
.option('--all', 'Stop all agents')
|
||||||
.option('--cwd <path>', 'Stop all agents in directory')
|
.option('--cwd <path>', 'Stop all agents in directory')
|
||||||
.option('--json', 'Output in JSON format')
|
.option('--json', 'Output in JSON format')
|
||||||
.option('--host <host>', 'Daemon host:port (default: localhost:6767)')
|
.option('--host <host>', 'Daemon host target (default: local socket/pipe, then localhost:6767)')
|
||||||
.action(withOutput(runStopCommand))
|
.action(withOutput(runStopCommand))
|
||||||
|
|
||||||
agent
|
agent
|
||||||
@@ -84,7 +84,7 @@ export function createAgentCommand(): Command {
|
|||||||
.option('--all', 'Delete all agents')
|
.option('--all', 'Delete all agents')
|
||||||
.option('--cwd <path>', 'Delete all agents in directory')
|
.option('--cwd <path>', 'Delete all agents in directory')
|
||||||
.option('--json', 'Output in JSON format')
|
.option('--json', 'Output in JSON format')
|
||||||
.option('--host <host>', 'Daemon host:port (default: localhost:6767)')
|
.option('--host <host>', 'Daemon host target (default: local socket/pipe, then localhost:6767)')
|
||||||
.action(withOutput(runDeleteCommand))
|
.action(withOutput(runDeleteCommand))
|
||||||
|
|
||||||
agent
|
agent
|
||||||
@@ -94,7 +94,7 @@ export function createAgentCommand(): Command {
|
|||||||
.argument('<prompt>', 'The message to send')
|
.argument('<prompt>', 'The message to send')
|
||||||
.option('--no-wait', 'Return immediately without waiting for completion')
|
.option('--no-wait', 'Return immediately without waiting for completion')
|
||||||
.option('--json', 'Output in JSON format')
|
.option('--json', 'Output in JSON format')
|
||||||
.option('--host <host>', 'Daemon host:port (default: localhost:6767)')
|
.option('--host <host>', 'Daemon host target (default: local socket/pipe, then localhost:6767)')
|
||||||
.action(withOutput(runSendCommand))
|
.action(withOutput(runSendCommand))
|
||||||
|
|
||||||
agent
|
agent
|
||||||
@@ -102,7 +102,7 @@ export function createAgentCommand(): Command {
|
|||||||
.description('Show detailed information about an agent')
|
.description('Show detailed information about an agent')
|
||||||
.argument('<id>', 'Agent ID (or prefix)')
|
.argument('<id>', 'Agent ID (or prefix)')
|
||||||
.option('--json', 'Output in JSON format')
|
.option('--json', 'Output in JSON format')
|
||||||
.option('--host <host>', 'Daemon host:port (default: localhost:6767)')
|
.option('--host <host>', 'Daemon host target (default: local socket/pipe, then localhost:6767)')
|
||||||
.action(withOutput(runInspectCommand))
|
.action(withOutput(runInspectCommand))
|
||||||
|
|
||||||
agent
|
agent
|
||||||
@@ -111,7 +111,7 @@ export function createAgentCommand(): Command {
|
|||||||
.argument('<id>', 'Agent ID (or prefix)')
|
.argument('<id>', 'Agent ID (or prefix)')
|
||||||
.option('--timeout <seconds>', 'Maximum wait time (default: no limit)')
|
.option('--timeout <seconds>', 'Maximum wait time (default: no limit)')
|
||||||
.option('--json', 'Output in JSON format')
|
.option('--json', 'Output in JSON format')
|
||||||
.option('--host <host>', 'Daemon host:port (default: localhost:6767)')
|
.option('--host <host>', 'Daemon host target (default: local socket/pipe, then localhost:6767)')
|
||||||
.action(withOutput(runWaitCommand))
|
.action(withOutput(runWaitCommand))
|
||||||
|
|
||||||
// Advanced agent commands (less common operations)
|
// Advanced agent commands (less common operations)
|
||||||
@@ -122,7 +122,7 @@ export function createAgentCommand(): Command {
|
|||||||
.argument('[mode]', 'Mode to set (required unless --list)')
|
.argument('[mode]', 'Mode to set (required unless --list)')
|
||||||
.option('--list', 'List available modes for this agent')
|
.option('--list', 'List available modes for this agent')
|
||||||
.option('--json', 'Output in JSON format')
|
.option('--json', 'Output in JSON format')
|
||||||
.option('--host <host>', 'Daemon host:port (default: localhost:6767)')
|
.option('--host <host>', 'Daemon host target (default: local socket/pipe, then localhost:6767)')
|
||||||
.action(withOutput(runModeCommand))
|
.action(withOutput(runModeCommand))
|
||||||
|
|
||||||
agent
|
agent
|
||||||
@@ -131,7 +131,7 @@ export function createAgentCommand(): Command {
|
|||||||
.argument('<id>', 'Agent ID (or prefix)')
|
.argument('<id>', 'Agent ID (or prefix)')
|
||||||
.option('--force', 'Force archive running agent (interrupts active run first)')
|
.option('--force', 'Force archive running agent (interrupts active run first)')
|
||||||
.option('--json', 'Output in JSON format')
|
.option('--json', 'Output in JSON format')
|
||||||
.option('--host <host>', 'Daemon host:port (default: localhost:6767)')
|
.option('--host <host>', 'Daemon host target (default: local socket/pipe, then localhost:6767)')
|
||||||
.action(withOutput(runArchiveCommand))
|
.action(withOutput(runArchiveCommand))
|
||||||
|
|
||||||
agent
|
agent
|
||||||
@@ -146,7 +146,7 @@ export function createAgentCommand(): Command {
|
|||||||
[]
|
[]
|
||||||
)
|
)
|
||||||
.option('--json', 'Output in JSON format')
|
.option('--json', 'Output in JSON format')
|
||||||
.option('--host <host>', 'Daemon host:port (default: localhost:6767)')
|
.option('--host <host>', 'Daemon host target (default: local socket/pipe, then localhost:6767)')
|
||||||
.action(withOutput(runUpdateCommand))
|
.action(withOutput(runUpdateCommand))
|
||||||
|
|
||||||
return agent
|
return agent
|
||||||
|
|||||||
@@ -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`)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ export function createPermitCommand(): Command {
|
|||||||
.command('ls')
|
.command('ls')
|
||||||
.description('List all pending permissions')
|
.description('List all pending permissions')
|
||||||
.option('--json', 'Output in JSON format')
|
.option('--json', 'Output in JSON format')
|
||||||
.option('--host <host>', 'Daemon host:port (default: localhost:6767)')
|
.option('--host <host>', 'Daemon host target (default: local socket/pipe, then localhost:6767)')
|
||||||
.action(withOutput(runLsCommand))
|
.action(withOutput(runLsCommand))
|
||||||
|
|
||||||
permit
|
permit
|
||||||
@@ -22,7 +22,7 @@ export function createPermitCommand(): Command {
|
|||||||
.option('--all', 'Allow all pending permissions for this agent')
|
.option('--all', 'Allow all pending permissions for this agent')
|
||||||
.option('--input <json>', 'Modified input parameters (JSON)')
|
.option('--input <json>', 'Modified input parameters (JSON)')
|
||||||
.option('--json', 'Output in JSON format')
|
.option('--json', 'Output in JSON format')
|
||||||
.option('--host <host>', 'Daemon host:port (default: localhost:6767)')
|
.option('--host <host>', 'Daemon host target (default: local socket/pipe, then localhost:6767)')
|
||||||
.action(withOutput(runAllowCommand))
|
.action(withOutput(runAllowCommand))
|
||||||
|
|
||||||
permit
|
permit
|
||||||
@@ -34,7 +34,7 @@ export function createPermitCommand(): Command {
|
|||||||
.option('--message <msg>', 'Denial reason message')
|
.option('--message <msg>', 'Denial reason message')
|
||||||
.option('--interrupt', 'Stop agent after denial')
|
.option('--interrupt', 'Stop agent after denial')
|
||||||
.option('--json', 'Output in JSON format')
|
.option('--json', 'Output in JSON format')
|
||||||
.option('--host <host>', 'Daemon host:port (default: localhost:6767)')
|
.option('--host <host>', 'Daemon host target (default: local socket/pipe, then localhost:6767)')
|
||||||
.action(withOutput(runDenyCommand))
|
.action(withOutput(runDenyCommand))
|
||||||
|
|
||||||
return permit
|
return permit
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ export function createProviderCommand(): Command {
|
|||||||
.command('ls')
|
.command('ls')
|
||||||
.description('List available providers and status')
|
.description('List available providers and status')
|
||||||
.option('--json', 'Output in JSON format')
|
.option('--json', 'Output in JSON format')
|
||||||
.option('--host <host>', 'Daemon host:port (default: localhost:6767)')
|
.option('--host <host>', 'Daemon host target (default: local socket/pipe, then localhost:6767)')
|
||||||
.action(withOutput(runLsCommand))
|
.action(withOutput(runLsCommand))
|
||||||
|
|
||||||
provider
|
provider
|
||||||
@@ -19,7 +19,7 @@ export function createProviderCommand(): Command {
|
|||||||
.argument('<provider>', 'Provider name (claude, codex, opencode)')
|
.argument('<provider>', 'Provider name (claude, codex, opencode)')
|
||||||
.option('--thinking', 'Include thinking option IDs for each model')
|
.option('--thinking', 'Include thinking option IDs for each model')
|
||||||
.option('--json', 'Output in JSON format')
|
.option('--json', 'Output in JSON format')
|
||||||
.option('--host <host>', 'Daemon host:port (default: localhost:6767)')
|
.option('--host <host>', 'Daemon host target (default: local socket/pipe, then localhost:6767)')
|
||||||
.action(withOutput(runModelsCommand))
|
.action(withOutput(runModelsCommand))
|
||||||
|
|
||||||
return provider
|
return provider
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ export function createSpeechCommand(): Command {
|
|||||||
.command("models")
|
.command("models")
|
||||||
.description("List local speech model download status")
|
.description("List local speech model download status")
|
||||||
.option("--json", "Output in JSON format")
|
.option("--json", "Output in JSON format")
|
||||||
.option("--host <host>", "Daemon host:port (default: localhost:6767)")
|
.option("--host <host>", "Daemon host target (default: local socket/pipe, then localhost:6767)")
|
||||||
.action(withOutput(runSpeechModelsCommand));
|
.action(withOutput(runSpeechModelsCommand));
|
||||||
|
|
||||||
speech
|
speech
|
||||||
@@ -22,7 +22,7 @@ export function createSpeechCommand(): Command {
|
|||||||
.description("Download local speech models")
|
.description("Download local speech models")
|
||||||
.option("--model <id>", "Model ID to download (repeatable)", collectMultiple, [])
|
.option("--model <id>", "Model ID to download (repeatable)", collectMultiple, [])
|
||||||
.option("--json", "Output in JSON format")
|
.option("--json", "Output in JSON format")
|
||||||
.option("--host <host>", "Daemon host:port (default: localhost:6767)")
|
.option("--host <host>", "Daemon host target (default: local socket/pipe, then localhost:6767)")
|
||||||
.action(withOutput(runSpeechDownloadCommand));
|
.action(withOutput(runSpeechDownloadCommand));
|
||||||
|
|
||||||
return speech;
|
return speech;
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ export function createWorktreeCommand(): Command {
|
|||||||
.command('ls')
|
.command('ls')
|
||||||
.description('List Paseo-managed git worktrees')
|
.description('List Paseo-managed git worktrees')
|
||||||
.option('--json', 'Output in JSON format')
|
.option('--json', 'Output in JSON format')
|
||||||
.option('--host <host>', 'Daemon host:port (default: localhost:6767)')
|
.option('--host <host>', 'Daemon host target (default: local socket/pipe, then localhost:6767)')
|
||||||
.action(withOutput(runLsCommand))
|
.action(withOutput(runLsCommand))
|
||||||
|
|
||||||
worktree
|
worktree
|
||||||
@@ -18,7 +18,7 @@ export function createWorktreeCommand(): Command {
|
|||||||
.description('Archive a worktree (removes worktree and associated branch)')
|
.description('Archive a worktree (removes worktree and associated branch)')
|
||||||
.argument('<name>', 'Worktree name or branch name')
|
.argument('<name>', 'Worktree name or branch name')
|
||||||
.option('--json', 'Output in JSON format')
|
.option('--json', 'Output in JSON format')
|
||||||
.option('--host <host>', 'Daemon host:port (default: localhost:6767)')
|
.option('--host <host>', 'Daemon host target (default: local socket/pipe, then localhost:6767)')
|
||||||
.action(withOutput(runArchiveCommand))
|
.action(withOutput(runArchiveCommand))
|
||||||
|
|
||||||
return worktree
|
return worktree
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
import { DaemonClient } from '@getpaseo/server'
|
import { existsSync, readFileSync } from 'node:fs'
|
||||||
|
import { loadConfig, resolvePaseoHome, DaemonClient } from '@getpaseo/server'
|
||||||
|
import path from 'node:path'
|
||||||
import WebSocket from 'ws'
|
import WebSocket from 'ws'
|
||||||
import { getOrCreateCliClientId } from './client-id.js'
|
import { getOrCreateCliClientId } from './client-id.js'
|
||||||
|
|
||||||
@@ -9,20 +11,168 @@ export interface ConnectOptions {
|
|||||||
|
|
||||||
const DEFAULT_HOST = 'localhost:6767'
|
const DEFAULT_HOST = 'localhost:6767'
|
||||||
const DEFAULT_TIMEOUT = 5000
|
const DEFAULT_TIMEOUT = 5000
|
||||||
|
const PID_FILENAME = 'paseo.pid'
|
||||||
|
|
||||||
|
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
|
||||||
*/
|
*/
|
||||||
export function getDaemonHost(options?: ConnectOptions): string {
|
export function getDaemonHost(options?: ConnectOptions): string {
|
||||||
return options?.host ?? process.env.PASEO_HOST ?? DEFAULT_HOST
|
return resolveDaemonHostCandidates(options)[0] ?? DEFAULT_HOST
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeDaemonHost(raw: string): string | null {
|
||||||
|
const trimmed = raw.trim()
|
||||||
|
if (!trimmed) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
trimmed.startsWith('unix://') ||
|
||||||
|
trimmed.startsWith('pipe://') ||
|
||||||
|
trimmed.startsWith('\\\\.\\pipe\\')
|
||||||
|
) {
|
||||||
|
return trimmed.startsWith('\\\\.\\pipe\\') ? `pipe://${trimmed}` : trimmed
|
||||||
|
}
|
||||||
|
|
||||||
|
if (path.isAbsolute(trimmed)) {
|
||||||
|
return `unix://${trimmed}`
|
||||||
|
}
|
||||||
|
|
||||||
|
if (/^\d+$/.test(trimmed)) {
|
||||||
|
return `127.0.0.1:${trimmed}`
|
||||||
|
}
|
||||||
|
|
||||||
|
return trimmed.includes(':') ? trimmed : null
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveDefaultDaemonHost(env: NodeJS.ProcessEnv = process.env): string {
|
||||||
|
return resolveDefaultDaemonHosts(env)[0] ?? DEFAULT_HOST
|
||||||
|
}
|
||||||
|
|
||||||
|
function isIpcDaemonHost(host: string | null): host is string {
|
||||||
|
return host !== null && (host.startsWith('unix://') || host.startsWith('pipe://'))
|
||||||
|
}
|
||||||
|
|
||||||
|
function isTcpDaemonHost(host: string | null): host is string {
|
||||||
|
return host !== null && !isIpcDaemonHost(host)
|
||||||
|
}
|
||||||
|
|
||||||
|
function readPidSocketTarget(paseoHome: string): string | null {
|
||||||
|
const pidPath = path.join(paseoHome, PID_FILENAME)
|
||||||
|
if (!existsSync(pidPath)) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(readFileSync(pidPath, 'utf-8')) as { sockPath?: unknown }
|
||||||
|
return typeof parsed.sockPath === 'string' ? parsed.sockPath : null
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveConfiguredIpcDaemonHost(env: NodeJS.ProcessEnv, paseoHome: string): string | null {
|
||||||
|
const directEnvHost = normalizeDaemonHost(env.PASEO_LISTEN ?? '')
|
||||||
|
if (isIpcDaemonHost(directEnvHost)) {
|
||||||
|
return directEnvHost
|
||||||
|
}
|
||||||
|
|
||||||
|
const pidHost = normalizeDaemonHost(readPidSocketTarget(paseoHome) ?? '')
|
||||||
|
if (isIpcDaemonHost(pidHost)) {
|
||||||
|
return pidHost
|
||||||
|
}
|
||||||
|
|
||||||
|
const config = loadConfig(paseoHome, { env })
|
||||||
|
const configuredHost = normalizeDaemonHost(config.listen)
|
||||||
|
return isIpcDaemonHost(configuredHost) ? configuredHost : null
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveConfiguredTcpDaemonHost(env: NodeJS.ProcessEnv, paseoHome: string): string | null {
|
||||||
|
const configuredHost = normalizeDaemonHost(loadConfig(paseoHome, { env }).listen)
|
||||||
|
if (!isTcpDaemonHost(configuredHost)) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
return configuredHost === '127.0.0.1:6767' ? null : configuredHost
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveDefaultDaemonHosts(env: NodeJS.ProcessEnv = process.env): string[] {
|
||||||
|
const paseoHome = resolvePaseoHome(env)
|
||||||
|
const candidates: string[] = []
|
||||||
|
const configuredIpcHost = resolveConfiguredIpcDaemonHost(env, paseoHome)
|
||||||
|
if (configuredIpcHost) {
|
||||||
|
candidates.push(configuredIpcHost)
|
||||||
|
}
|
||||||
|
const configuredTcpHost = resolveConfiguredTcpDaemonHost(env, paseoHome)
|
||||||
|
if (configuredTcpHost) {
|
||||||
|
candidates.push(configuredTcpHost)
|
||||||
|
}
|
||||||
|
candidates.push(DEFAULT_HOST)
|
||||||
|
return Array.from(new Set(candidates))
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveDaemonHostCandidates(options?: ConnectOptions): string[] {
|
||||||
|
const explicitHost = options?.host ?? process.env.PASEO_HOST
|
||||||
|
if (explicitHost) {
|
||||||
|
return [explicitHost]
|
||||||
|
}
|
||||||
|
|
||||||
|
return resolveDefaultDaemonHosts()
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
@@ -38,45 +188,56 @@ function createNodeWebSocketFactory() {
|
|||||||
* Returns the connected client or throws if connection fails
|
* Returns the connected client or throws if connection fails
|
||||||
*/
|
*/
|
||||||
export async function connectToDaemon(options?: ConnectOptions): Promise<DaemonClient> {
|
export async function connectToDaemon(options?: ConnectOptions): Promise<DaemonClient> {
|
||||||
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 hosts = resolveDaemonHostCandidates(options)
|
||||||
|
const nodeWebSocketFactory = createNodeWebSocketFactory()
|
||||||
|
let lastError: unknown = null
|
||||||
|
|
||||||
const client = new DaemonClient(
|
for (const host of hosts) {
|
||||||
{
|
const target = resolveDaemonTarget(host)
|
||||||
url,
|
const client = new DaemonClient(
|
||||||
clientId,
|
{
|
||||||
clientType: 'cli',
|
url: target.url,
|
||||||
webSocketFactory: createNodeWebSocketFactory(),
|
clientId,
|
||||||
reconnect: { enabled: false },
|
clientType: 'cli',
|
||||||
} as unknown as ConstructorParameters<typeof DaemonClient>[0]
|
webSocketFactory: (url: string, config?: { headers?: Record<string, string> }) =>
|
||||||
)
|
nodeWebSocketFactory(url, {
|
||||||
|
headers: config?.headers,
|
||||||
|
...(target.type === 'ipc' ? { socketPath: target.socketPath } : {}),
|
||||||
|
}),
|
||||||
|
reconnect: { enabled: false },
|
||||||
|
} as unknown as ConstructorParameters<typeof DaemonClient>[0]
|
||||||
|
)
|
||||||
|
|
||||||
// Connect with timeout
|
const connectPromise = client.connect()
|
||||||
const connectPromise = client.connect()
|
let timeoutHandle: ReturnType<typeof setTimeout> | null = null
|
||||||
let timeoutHandle: ReturnType<typeof setTimeout> | null = null
|
const timeoutPromise = new Promise<never>((_, reject) => {
|
||||||
const timeoutPromise = new Promise<never>((_, reject) => {
|
timeoutHandle = setTimeout(() => {
|
||||||
timeoutHandle = setTimeout(() => {
|
reject(new Error(`Connection timeout after ${timeout}ms`))
|
||||||
reject(new Error(`Connection timeout after ${timeout}ms`))
|
}, timeout)
|
||||||
}, timeout)
|
})
|
||||||
})
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await Promise.race([connectPromise, timeoutPromise])
|
await Promise.race([connectPromise, timeoutPromise])
|
||||||
// Clear the timeout so it doesn't keep the event loop alive
|
if (timeoutHandle) {
|
||||||
if (timeoutHandle) {
|
clearTimeout(timeoutHandle)
|
||||||
clearTimeout(timeoutHandle)
|
}
|
||||||
|
return client
|
||||||
|
} catch (err) {
|
||||||
|
if (timeoutHandle) {
|
||||||
|
clearTimeout(timeoutHandle)
|
||||||
|
}
|
||||||
|
lastError = err
|
||||||
|
await client.close().catch(() => {})
|
||||||
}
|
}
|
||||||
return client
|
|
||||||
} catch (err) {
|
|
||||||
// Clear the timeout on error too
|
|
||||||
if (timeoutHandle) {
|
|
||||||
clearTimeout(timeoutHandle)
|
|
||||||
}
|
|
||||||
await client.close().catch(() => {})
|
|
||||||
throw err
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (lastError instanceof Error) {
|
||||||
|
throw lastError
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(`Unable to connect to Paseo daemon via ${hosts.join(', ')}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,111 @@
|
|||||||
|
#!/usr/bin/env npx tsx
|
||||||
|
|
||||||
|
import assert from 'node:assert'
|
||||||
|
import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs'
|
||||||
|
import os from 'node:os'
|
||||||
|
import path from 'node:path'
|
||||||
|
import {
|
||||||
|
getDaemonHost,
|
||||||
|
normalizeDaemonHost,
|
||||||
|
resolveDaemonTarget,
|
||||||
|
resolveDefaultDaemonHosts,
|
||||||
|
} 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('Test 3: local unix socket paths normalize into IPC daemon targets')
|
||||||
|
assert.strictEqual(normalizeDaemonHost('/tmp/paseo.sock'), 'unix:///tmp/paseo.sock')
|
||||||
|
console.log('✓ local unix socket paths normalize into IPC daemon targets\n')
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
console.log('Test 4: default host resolution tries local IPC first, then localhost fallback')
|
||||||
|
const paseoHome = mkdtempSync(path.join(os.tmpdir(), 'paseo-client-targets-'))
|
||||||
|
try {
|
||||||
|
mkdirSync(paseoHome, { recursive: true })
|
||||||
|
writeFileSync(
|
||||||
|
path.join(paseoHome, 'paseo.pid'),
|
||||||
|
JSON.stringify({ pid: process.pid, sockPath: '/tmp/paseo-from-pid.sock' })
|
||||||
|
)
|
||||||
|
assert.deepStrictEqual(resolveDefaultDaemonHosts({ PASEO_HOME: paseoHome }), [
|
||||||
|
'unix:///tmp/paseo-from-pid.sock',
|
||||||
|
'localhost:6767',
|
||||||
|
])
|
||||||
|
const previousHome = process.env.PASEO_HOME
|
||||||
|
const previousHost = process.env.PASEO_HOST
|
||||||
|
process.env.PASEO_HOME = paseoHome
|
||||||
|
delete process.env.PASEO_HOST
|
||||||
|
assert.strictEqual(getDaemonHost(), 'unix:///tmp/paseo-from-pid.sock')
|
||||||
|
if (previousHome === undefined) delete process.env.PASEO_HOME
|
||||||
|
else process.env.PASEO_HOME = previousHome
|
||||||
|
if (previousHost === undefined) delete process.env.PASEO_HOST
|
||||||
|
else process.env.PASEO_HOST = previousHost
|
||||||
|
} finally {
|
||||||
|
rmSync(paseoHome, { recursive: true, force: true })
|
||||||
|
}
|
||||||
|
console.log('✓ default host resolution tries local IPC first, then localhost fallback\n')
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
console.log('Test 5: configured TCP host is preserved before the localhost fallback')
|
||||||
|
const paseoHome = mkdtempSync(path.join(os.tmpdir(), 'paseo-client-targets-tcp-'))
|
||||||
|
try {
|
||||||
|
assert.deepStrictEqual(
|
||||||
|
resolveDefaultDaemonHosts({
|
||||||
|
PASEO_HOME: paseoHome,
|
||||||
|
PASEO_LISTEN: '127.0.0.1:7777',
|
||||||
|
}),
|
||||||
|
['127.0.0.1:7777', 'localhost:6767']
|
||||||
|
)
|
||||||
|
} finally {
|
||||||
|
rmSync(paseoHome, { recursive: true, force: true })
|
||||||
|
}
|
||||||
|
console.log('✓ configured TCP host is preserved before the localhost fallback\n')
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
console.log('Test 6: local IPC still takes priority over configured TCP hosts')
|
||||||
|
const paseoHome = mkdtempSync(path.join(os.tmpdir(), 'paseo-client-targets-order-'))
|
||||||
|
try {
|
||||||
|
mkdirSync(paseoHome, { recursive: true })
|
||||||
|
writeFileSync(
|
||||||
|
path.join(paseoHome, 'paseo.pid'),
|
||||||
|
JSON.stringify({ pid: process.pid, sockPath: '/tmp/paseo-priority.sock' })
|
||||||
|
)
|
||||||
|
assert.deepStrictEqual(
|
||||||
|
resolveDefaultDaemonHosts({
|
||||||
|
PASEO_HOME: paseoHome,
|
||||||
|
PASEO_LISTEN: '127.0.0.1:7777',
|
||||||
|
}),
|
||||||
|
['unix:///tmp/paseo-priority.sock', '127.0.0.1:7777', 'localhost:6767']
|
||||||
|
)
|
||||||
|
} finally {
|
||||||
|
rmSync(paseoHome, { recursive: true, force: true })
|
||||||
|
}
|
||||||
|
console.log('✓ local IPC still takes priority over configured TCP hosts\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": {
|
||||||
|
|||||||
@@ -0,0 +1,491 @@
|
|||||||
|
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") },
|
||||||
|
];
|
||||||
|
|
||||||
|
const ripgrepPlatformDirMap = {
|
||||||
|
darwin: {
|
||||||
|
arm64: "arm64-darwin",
|
||||||
|
x64: "x64-darwin",
|
||||||
|
},
|
||||||
|
linux: {
|
||||||
|
arm64: "arm64-linux",
|
||||||
|
x64: "x64-linux",
|
||||||
|
},
|
||||||
|
win32: {
|
||||||
|
arm64: "arm64-win32",
|
||||||
|
x64: "x64-win32",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
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 removeIfExists(target) {
|
||||||
|
if (await pathExists(target)) {
|
||||||
|
await rmSafe(target);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 pruneChildrenExcept(parent, keepNames) {
|
||||||
|
if (!(await pathExists(parent))) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const entries = await fs.readdir(parent);
|
||||||
|
await Promise.all(
|
||||||
|
entries
|
||||||
|
.filter((entry) => !keepNames.has(entry))
|
||||||
|
.map((entry) => rmSafe(path.join(parent, entry)))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function pruneNodeDistribution(runtimeRoot) {
|
||||||
|
const nodeRoot = path.join(runtimeRoot, "node");
|
||||||
|
await Promise.all([
|
||||||
|
removeIfExists(path.join(nodeRoot, "include")),
|
||||||
|
removeIfExists(path.join(nodeRoot, "share")),
|
||||||
|
removeIfExists(path.join(nodeRoot, "CHANGELOG.md")),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const nodeGlobalModulesRoot = path.join(nodeRoot, "lib", "node_modules");
|
||||||
|
if (await pathExists(nodeGlobalModulesRoot)) {
|
||||||
|
const keepNames = new Set(["npm"]);
|
||||||
|
await pruneChildrenExcept(nodeGlobalModulesRoot, keepNames);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function pruneOnnxRuntime(runtimeRoot) {
|
||||||
|
const onnxRoot = path.join(runtimeRoot, "node_modules", "onnxruntime-node", "bin", "napi-v6");
|
||||||
|
if (!(await pathExists(onnxRoot))) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (process.platform === "darwin") {
|
||||||
|
await removeIfExists(path.join(onnxRoot, "linux"));
|
||||||
|
await removeIfExists(path.join(onnxRoot, "win32"));
|
||||||
|
await pruneChildrenExcept(path.join(onnxRoot, "darwin"), new Set([process.arch]));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (process.platform === "linux") {
|
||||||
|
await removeIfExists(path.join(onnxRoot, "darwin"));
|
||||||
|
await removeIfExists(path.join(onnxRoot, "win32"));
|
||||||
|
await pruneChildrenExcept(path.join(onnxRoot, "linux"), new Set([process.arch]));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (process.platform === "win32") {
|
||||||
|
await removeIfExists(path.join(onnxRoot, "darwin"));
|
||||||
|
await removeIfExists(path.join(onnxRoot, "linux"));
|
||||||
|
await pruneChildrenExcept(path.join(onnxRoot, "win32"), new Set([process.arch]));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function pruneNodePty(runtimeRoot) {
|
||||||
|
const prebuildsRoot = path.join(runtimeRoot, "node_modules", "node-pty", "prebuilds");
|
||||||
|
const keepName = `${process.platform}-${process.arch}`;
|
||||||
|
await pruneChildrenExcept(prebuildsRoot, new Set([keepName]));
|
||||||
|
|
||||||
|
if (process.platform !== "win32") {
|
||||||
|
await removeIfExists(path.join(runtimeRoot, "node_modules", "node-pty", "third_party"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function pruneClaudeAgentSdk(runtimeRoot) {
|
||||||
|
const ripgrepRoot = path.join(
|
||||||
|
runtimeRoot,
|
||||||
|
"node_modules",
|
||||||
|
"@anthropic-ai",
|
||||||
|
"claude-agent-sdk",
|
||||||
|
"vendor",
|
||||||
|
"ripgrep"
|
||||||
|
);
|
||||||
|
const keepName = ripgrepPlatformDirMap[process.platform]?.[process.arch];
|
||||||
|
if (keepName) {
|
||||||
|
await pruneChildrenExcept(ripgrepRoot, new Set(["COPYING", keepName]));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function pruneManagedRuntime(runtimeRoot) {
|
||||||
|
await Promise.all([
|
||||||
|
pruneNodeDistribution(runtimeRoot),
|
||||||
|
pruneOnnxRuntime(runtimeRoot),
|
||||||
|
pruneNodePty(runtimeRoot),
|
||||||
|
pruneClaudeAgentSdk(runtimeRoot),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
);
|
||||||
|
await pruneManagedRuntime(runtimeRoot);
|
||||||
|
|
||||||
|
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();
|
||||||
@@ -0,0 +1,711 @@
|
|||||||
|
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 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, "\\$&");
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
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 runBundledRuntimeCli(runtimeRoot, managedHome, args, env) {
|
||||||
|
const manifest = JSON.parse(
|
||||||
|
await fs.readFile(path.join(runtimeRoot, "runtime-manifest.json"), "utf8")
|
||||||
|
);
|
||||||
|
const { stdout, stderr } = await execFileAsync(
|
||||||
|
path.join(runtimeRoot, manifest.nodeRelativePath),
|
||||||
|
[path.join(runtimeRoot, manifest.cliEntrypointRelativePath), ...args],
|
||||||
|
{
|
||||||
|
cwd: repoRoot,
|
||||||
|
env: {
|
||||||
|
...process.env,
|
||||||
|
...env,
|
||||||
|
PASEO_HOME: managedHome,
|
||||||
|
},
|
||||||
|
maxBuffer: 10 * 1024 * 1024,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
return { stdout, stderr };
|
||||||
|
}
|
||||||
|
|
||||||
|
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}`;
|
||||||
|
const managedRuntimeDir = path.join(testRoot, "runtime");
|
||||||
|
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 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, managedRuntimeDir];
|
||||||
|
|
||||||
|
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(runtimeStatus.json.runtimeId, currentRuntimeId);
|
||||||
|
assert.equal(runtimeStatus.json.runtimeVersion, currentRuntimeVersion);
|
||||||
|
assert.match(runtimeStatus.json.managedHome, new RegExp(`^${escapeForRegExp(testRoot)}`));
|
||||||
|
assert.match(
|
||||||
|
runtimeStatus.json.runtimeRoot,
|
||||||
|
new RegExp(escapeForRegExp(currentRuntimeId)),
|
||||||
|
"runtime status should point into the bundled runtime selected by current-runtime.json"
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
runtimeStatus.json.runtimeRoot.startsWith(testRoot),
|
||||||
|
false,
|
||||||
|
"runtime status should not resolve into managed app data"
|
||||||
|
);
|
||||||
|
assert.ok(runtimeStatus.json.transportType === "socket" || runtimeStatus.json.transportType === "pipe");
|
||||||
|
assert.notEqual(runtimeStatus.json.transportPath, "127.0.0.1:6767");
|
||||||
|
assertNoForbiddenPathsOrPorts(runtimeStatus.json, forbiddenManagedReferences);
|
||||||
|
assert.equal(await pathExists(managedRuntimeDir), false, "managed app data should not gain a runtime tree");
|
||||||
|
const stateAfterRuntimeStatus = JSON.parse(
|
||||||
|
await fs.readFile(path.join(testRoot, "managed-state.json"), "utf8")
|
||||||
|
);
|
||||||
|
assert.equal(stateAfterRuntimeStatus.runtimeId, currentRuntimeId);
|
||||||
|
assert.equal(stateAfterRuntimeStatus.runtimeRoot, runtimeStatus.json.runtimeRoot);
|
||||||
|
|
||||||
|
const 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.equal(managedStart.json.runtimeRoot, runtimeStatus.json.runtimeRoot);
|
||||||
|
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);
|
||||||
|
assert.equal(await pathExists(managedRuntimeDir), false, "starting the daemon should not install a runtime copy");
|
||||||
|
|
||||||
|
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 the bundled CLI target");
|
||||||
|
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");
|
||||||
|
const cliShimInstalled = cliInstall.json.installed === true && (await pathExists(cliShimPath));
|
||||||
|
if (!cliShimInstalled) {
|
||||||
|
assert.ok(cliInstall.json.manualInstructions, "manual CLI install instructions should be returned");
|
||||||
|
assert.match(
|
||||||
|
cliInstall.json.manualInstructions.commands,
|
||||||
|
new RegExp(escapeForRegExp(runtimeStatus.json.runtimeRoot)),
|
||||||
|
"manual CLI install instructions should point at the bundled runtime"
|
||||||
|
);
|
||||||
|
assertNoForbiddenPathsOrPorts(cliInstall.json.manualInstructions, forbiddenManagedReferences);
|
||||||
|
}
|
||||||
|
const cliVersion = cliShimInstalled
|
||||||
|
? await execFileAsync(cliShimPath, ["--version"], {
|
||||||
|
env: managedEnv,
|
||||||
|
cwd: repoRoot,
|
||||||
|
maxBuffer: 1024 * 1024,
|
||||||
|
})
|
||||||
|
: await runBundledRuntimeCli(
|
||||||
|
runtimeStatus.json.runtimeRoot,
|
||||||
|
managedStart.json.managedHome,
|
||||||
|
["--version"],
|
||||||
|
managedEnv
|
||||||
|
);
|
||||||
|
assert.match(cliVersion.stdout.trim(), /^0\./);
|
||||||
|
const shimStatus = cliShimInstalled
|
||||||
|
? await execFileAsync(cliShimPath, ["daemon", "status", "--json"], {
|
||||||
|
env: managedEnv,
|
||||||
|
cwd: repoRoot,
|
||||||
|
maxBuffer: 1024 * 1024,
|
||||||
|
})
|
||||||
|
: await runBundledRuntimeCli(
|
||||||
|
runtimeStatus.json.runtimeRoot,
|
||||||
|
managedStart.json.managedHome,
|
||||||
|
["daemon", "status", "--json"],
|
||||||
|
managedEnv
|
||||||
|
);
|
||||||
|
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 = cliShimInstalled
|
||||||
|
? await execFileAsync(
|
||||||
|
cliShimPath,
|
||||||
|
["daemon", "pair", "--home", managedStart.json.managedHome],
|
||||||
|
{
|
||||||
|
env: managedEnv,
|
||||||
|
cwd: repoRoot,
|
||||||
|
maxBuffer: 4 * 1024 * 1024,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
: await runBundledRuntimeCli(
|
||||||
|
runtimeStatus.json.runtimeRoot,
|
||||||
|
managedStart.json.managedHome,
|
||||||
|
["daemon", "pair", "--home", managedStart.json.managedHome],
|
||||||
|
managedEnv
|
||||||
|
);
|
||||||
|
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,
|
||||||
|
stateAfterRuntimeStatus,
|
||||||
|
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 {}
|
||||||
|
}
|
||||||
Generated
+37
-2
@@ -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"
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
@@ -1,16 +1,26 @@
|
|||||||
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};
|
||||||
use std::process::Command;
|
use std::process::Command;
|
||||||
use std::sync::atomic::{AtomicU64, Ordering};
|
use std::sync::atomic::{AtomicU64, Ordering};
|
||||||
use tauri::menu::{Menu, MenuItemBuilder, MenuItemKind, PredefinedMenuItem, Submenu};
|
|
||||||
#[cfg(target_os = "macos")]
|
#[cfg(target_os = "macos")]
|
||||||
use tauri::menu::AboutMetadata;
|
use tauri::menu::AboutMetadata;
|
||||||
|
use tauri::menu::{Menu, MenuItemBuilder, MenuItemKind, PredefinedMenuItem, Submenu};
|
||||||
use tauri::{AppHandle, Manager, WebviewWindow};
|
use tauri::{AppHandle, Manager, WebviewWindow};
|
||||||
use tauri_plugin_updater::UpdaterExt;
|
use tauri_plugin_updater::UpdaterExt;
|
||||||
|
|
||||||
|
mod runtime_manager;
|
||||||
|
use runtime_manager::{
|
||||||
|
close_local_daemon_transport, install_cli_shim, managed_daemon_logs, managed_daemon_pairing,
|
||||||
|
managed_daemon_status, managed_runtime_status, open_local_daemon_transport,
|
||||||
|
restart_managed_daemon, send_local_daemon_transport_message, start_managed_daemon,
|
||||||
|
stop_managed_daemon, uninstall_cli_shim, update_managed_daemon_tcp_settings,
|
||||||
|
LocalTransportState, ManagedTcpSettings,
|
||||||
|
};
|
||||||
|
|
||||||
// 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 +74,146 @@ 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 => {
|
||||||
|
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()
|
||||||
@@ -100,7 +250,10 @@ fi"#;
|
|||||||
LocalDaemonVersionResult {
|
LocalDaemonVersionResult {
|
||||||
version: None,
|
version: None,
|
||||||
error: Some(if stderr.is_empty() {
|
error: Some(if stderr.is_empty() {
|
||||||
format!("paseo --version exited with code {}", output.status.code().unwrap_or(1))
|
format!(
|
||||||
|
"paseo --version exited with code {}",
|
||||||
|
output.status.code().unwrap_or(1)
|
||||||
|
)
|
||||||
} else {
|
} else {
|
||||||
stderr
|
stderr
|
||||||
}),
|
}),
|
||||||
@@ -413,7 +566,8 @@ async fn garbage_collect_attachment_files(
|
|||||||
let entries = fs::read_dir(&attachment_dir)
|
let entries = fs::read_dir(&attachment_dir)
|
||||||
.map_err(|error| format!("Failed to scan attachment directory: {error}"))?;
|
.map_err(|error| format!("Failed to scan attachment directory: {error}"))?;
|
||||||
for entry in entries {
|
for entry in entries {
|
||||||
let entry = entry.map_err(|error| format!("Failed to read directory entry: {error}"))?;
|
let entry =
|
||||||
|
entry.map_err(|error| format!("Failed to read directory entry: {error}"))?;
|
||||||
let path = entry.path();
|
let path = entry.path();
|
||||||
if !path.is_file() {
|
if !path.is_file() {
|
||||||
continue;
|
continue;
|
||||||
@@ -440,12 +594,26 @@ 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,
|
||||||
|
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 +625,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()
|
||||||
@@ -488,7 +660,8 @@ pub fn run() {
|
|||||||
copyright: app.config().bundle.copyright.clone(),
|
copyright: app.config().bundle.copyright.clone(),
|
||||||
..Default::default()
|
..Default::default()
|
||||||
};
|
};
|
||||||
let about = PredefinedMenuItem::about(app.handle(), None, Some(about_metadata))?;
|
let about =
|
||||||
|
PredefinedMenuItem::about(app.handle(), None, Some(about_metadata))?;
|
||||||
|
|
||||||
if submenu.remove_at(0)?.is_some() {
|
if submenu.remove_at(0)?.is_some() {
|
||||||
submenu.insert(&about, 0)?;
|
submenu.insert(&about, 0)?;
|
||||||
|
|||||||
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);
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import websitePackage from '../../package.json'
|
|||||||
import '~/styles.css'
|
import '~/styles.css'
|
||||||
|
|
||||||
const desktopVersion = websitePackage.version
|
const desktopVersion = websitePackage.version
|
||||||
const macDownloadHref = `https://github.com/getpaseo/paseo/releases/download/v${desktopVersion}/Paseo_${desktopVersion}_universal.dmg`
|
const macDownloadHref = `https://github.com/getpaseo/paseo/releases/download/v${desktopVersion}/Paseo_${desktopVersion}_aarch64.dmg`
|
||||||
|
|
||||||
export const Route = createFileRoute('/')({
|
export const Route = createFileRoute('/')({
|
||||||
head: () => ({
|
head: () => ({
|
||||||
|
|||||||
Reference in New Issue
Block a user