Commit current desktop and CLI diff

This commit is contained in:
Mohamed Boudra
2026-04-09 18:55:19 +07:00
parent 4068554532
commit 2a5ac7a013
7 changed files with 126 additions and 244 deletions
@@ -1,7 +1,6 @@
import { useCallback, useEffect, useState } from "react";
import { ActivityIndicator, Alert, Image, Text, View } from "react-native";
import { ActivityIndicator, Alert, Text, View } from "react-native";
import * as Clipboard from "expo-clipboard";
import * as QRCode from "qrcode";
import { useFocusEffect } from "@react-navigation/native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { settingsStyles } from "@/styles/settings";
@@ -12,7 +11,6 @@ import {
RotateCw,
Copy,
FileText,
Smartphone,
Activity,
} from "lucide-react-native";
import { AdaptiveModalSheet } from "@/components/adaptive-modal-sheet";
@@ -24,7 +22,6 @@ import { isVersionMismatch } from "@/desktop/updates/desktop-updates";
import {
getCliDaemonStatus,
getDesktopDaemonLogs,
getDesktopDaemonPairing,
getDesktopDaemonStatus,
restartDesktopDaemon,
shouldUseDesktopDaemon,
@@ -32,7 +29,6 @@ import {
stopDesktopDaemon,
type DesktopDaemonLogs,
type DesktopDaemonStatus,
type DesktopPairingOffer,
} from "@/desktop/daemon/desktop-daemon";
export interface LocalDaemonSectionProps {
@@ -52,10 +48,6 @@ export function LocalDaemonSection({ appVersion, showLifecycleControls }: LocalD
const [statusMessage, setStatusMessage] = useState<string | null>(null);
const [daemonLogs, setDaemonLogs] = useState<DesktopDaemonLogs | null>(null);
const [isLogsModalOpen, setIsLogsModalOpen] = useState(false);
const [isPairingModalOpen, setIsPairingModalOpen] = useState(false);
const [isLoadingPairing, setIsLoadingPairing] = useState(false);
const [pairingOffer, setPairingOffer] = useState<DesktopPairingOffer | null>(null);
const [pairingStatusMessage, setPairingStatusMessage] = useState<string | null>(null);
const [cliStatusOutput, setCliStatusOutput] = useState<string | null>(null);
const [isCliStatusModalOpen, setIsCliStatusModalOpen] = useState(false);
const [isLoadingCliStatus, setIsLoadingCliStatus] = useState(false);
@@ -238,46 +230,6 @@ export function LocalDaemonSection({ appVersion, showLifecycleControls }: LocalD
setIsLogsModalOpen(true);
}, [daemonLogs]);
const handleOpenPairingModal = useCallback(() => {
if (isLoadingPairing) {
return;
}
setIsPairingModalOpen(true);
setIsLoadingPairing(true);
setPairingStatusMessage(null);
void getDesktopDaemonPairing()
.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]);
const handleOpenCliStatus = useCallback(async () => {
setIsLoadingCliStatus(true);
try {
@@ -418,20 +370,6 @@ export function LocalDaemonSection({ appVersion, showLifecycleControls }: LocalD
</Button>
</View>
</View>
<View style={[settingsStyles.row, settingsStyles.rowBorder]}>
<View style={settingsStyles.rowContent}>
<Text style={settingsStyles.rowTitle}>Pair device</Text>
<Text style={settingsStyles.rowHint}>Connect your phone to this computer.</Text>
</View>
<Button
variant="outline"
size="sm"
leftIcon={<Smartphone size={theme.iconSize.sm} color={theme.colors.foreground} />}
onPress={handleOpenPairingModal}
>
Pair device
</Button>
</View>
<View style={[settingsStyles.row, settingsStyles.rowBorder]}>
<View style={settingsStyles.rowContent}>
<Text style={settingsStyles.rowTitle}>Full status</Text>
@@ -460,20 +398,6 @@ export function LocalDaemonSection({ appVersion, showLifecycleControls }: LocalD
</View>
) : null}
<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)}
@@ -516,107 +440,6 @@ export function LocalDaemonSection({ appVersion, showLifecycleControls }: LocalD
const ADVANCED_DAEMON_SETTINGS_URL = "https://paseo.sh/docs/configuration";
function PairingOfferDialogContent(input: {
isLoading: boolean;
pairingOffer: DesktopPairingOffer | 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: 480,
})
.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={settingsStyles.rowHint}>Loading pairing offer…</Text>
</View>
);
}
if (statusMessage) {
return (
<View style={styles.modalBody}>
<Text style={settingsStyles.rowHint}>{statusMessage}</Text>
</View>
);
}
if (!pairingOffer?.url) {
return (
<View style={styles.modalBody}>
<Text style={settingsStyles.rowHint}>Pairing offer unavailable.</Text>
</View>
);
}
return (
<View style={styles.modalBody}>
<Text style={settingsStyles.rowHint}>
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} resizeMode="contain" />
) : qrError ? (
<Text style={settingsStyles.rowHint}>QR unavailable: {qrError}</Text>
) : (
<ActivityIndicator size="small" />
)}
</View>
<View style={styles.linkSection}>
<Text style={styles.linkLabel}>Pairing link</Text>
<Text style={styles.linkText} selectable>
{pairingOffer.url}
</Text>
</View>
<View style={styles.modalActions}>
<Button variant="outline" size="sm" onPress={onCopyLink}>
Copy link
</Button>
</View>
</View>
);
}
const styles = StyleSheet.create((theme) => ({
actionGroup: {
flexDirection: "row",
@@ -659,40 +482,6 @@ const styles = StyleSheet.create((theme) => ({
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",
width: "100%",
aspectRatio: 1,
alignSelf: "stretch",
padding: theme.spacing[3],
borderRadius: theme.borderRadius.lg,
borderWidth: 1,
borderColor: theme.colors.border,
backgroundColor: theme.colors.surface0,
},
qrImage: {
width: "100%",
height: "100%",
},
linkSection: {
gap: theme.spacing[2],
},
linkLabel: {
color: theme.colors.foreground,
fontSize: theme.fontSize.sm,
},
linkText: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.xs,
lineHeight: 18,
},
logOutput: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.xs,
+7 -1
View File
@@ -22,6 +22,7 @@ import {
Shield,
Puzzle,
Blocks,
Smartphone,
} from "lucide-react-native";
import { useAppSettings, type AppSettings } from "@/hooks/use-settings";
import type { HostProfile, HostConnection } from "@/types/host-connection";
@@ -54,6 +55,7 @@ import { AdaptiveModalSheet, AdaptiveTextInput } from "@/components/adaptive-mod
import { DesktopPermissionsSection } from "@/desktop/components/desktop-permissions-section";
import { IntegrationsSection } from "@/desktop/components/integrations-section";
import { LocalDaemonSection } from "@/desktop/components/desktop-updates-section";
import { PairDeviceSection } from "@/desktop/components/pair-device-section";
import { isElectronRuntime } from "@/desktop/host";
import { useDesktopAppUpdater } from "@/desktop/updates/use-desktop-app-updater";
import { formatVersionWithPrefix } from "@/desktop/updates/desktop-updates";
@@ -82,7 +84,8 @@ type SettingsSectionId =
| "diagnostics"
| "about"
| "permissions"
| "daemon";
| "daemon"
| "pair-device";
interface SettingsSectionDef {
id: SettingsSectionId;
@@ -101,6 +104,7 @@ function getSettingsSections(context: { isDesktopApp: boolean }): SettingsSectio
if (context.isDesktopApp) {
sections.push(
{ id: "integrations", label: "Integrations", icon: Puzzle },
{ id: "pair-device", label: "Pair device", icon: Smartphone },
{ id: "daemon", label: "Daemon", icon: Settings },
);
}
@@ -646,6 +650,8 @@ function SettingsSectionContent({
return isDesktopApp ? <IntegrationsSection /> : null;
case "permissions":
return isDesktopApp ? <DesktopPermissionsSection /> : null;
case "pair-device":
return isDesktopApp ? <PairDeviceSection /> : null;
case "daemon":
return isDesktopApp ? (
<LocalDaemonSection appVersion={appVersion} showLifecycleControls={isLocalDaemon} />
+19 -15
View File
@@ -1,12 +1,14 @@
import type { Command } from "commander";
import { execFileSync } from "node:child_process";
import { execFile } from "node:child_process";
import { createRequire } from "node:module";
import { promisify } from "node:util";
import {
getOrCreateServerId,
findExecutable,
quoteWindowsCommand,
applyProviderEnv,
} from "@getpaseo/server";
const execFileAsync = promisify(execFile);
import { tryConnectToDaemon } from "../../utils/client.js";
import type { CommandOptions, ListResult, OutputSchema } from "../../output/index.js";
import { resolveLocalDaemonState, resolveTcpHostFromListen } from "./local-daemon.js";
@@ -171,31 +173,33 @@ const PROVIDER_BINARIES: { label: string; binary: string }[] = [
{ label: "OpenCode", binary: "opencode" },
];
function checkProviderBinary(binary: string): { path: string | null; version: string | null } {
const binaryPath = findExecutable(binary);
async function checkProviderBinary(binary: string): Promise<{ path: string | null; version: string | null }> {
const binaryPath = await findExecutable(binary);
if (!binaryPath) {
return { path: null, version: null };
}
const env = applyProviderEnv(process.env);
try {
const output = execFileSync(quoteWindowsCommand(binaryPath), ["--version"], {
const { stdout } = await execFileAsync(binaryPath, ["--version"], {
encoding: "utf8",
timeout: 5000,
stdio: ["ignore", "pipe", "pipe"],
env,
shell: process.platform === "win32",
}).trim();
return { path: binaryPath, version: output || null };
windowsHide: true,
});
return { path: binaryPath, version: stdout.trim() || null };
} catch {
return { path: binaryPath, version: null };
}
}
function checkProviderBinaries(): ProviderBinaryStatus[] {
return PROVIDER_BINARIES.map(({ label, binary }) => {
const result = checkProviderBinary(binary);
return { label, ...result };
});
async function checkProviderBinaries(): Promise<ProviderBinaryStatus[]> {
const results = await Promise.all(
PROVIDER_BINARIES.map(async ({ label, binary }) => {
const result = await checkProviderBinary(binary);
return { label, ...result };
}),
);
return results;
}
function resolveOwnerLabel(uid: number | undefined, hostname: string | undefined): string | null {
@@ -295,7 +299,7 @@ export async function runStatusCommand(
note = appendNote(note, `serverId unavailable: ${shortenMessage(normalizeError(error))}`);
}
const providers = checkProviderBinaries();
const providers = await checkProviderBinaries();
const daemonStatus: DaemonStatus = {
serverId,
@@ -0,0 +1,72 @@
#!/usr/bin/env npx tsx
import assert from "node:assert";
import { createE2ETestContext, type TestDaemonContext } from "../helpers/test-daemon.ts";
interface E2EContext extends TestDaemonContext {
paseo: (
args: string[],
opts?: { timeout?: number; cwd?: string },
) => Promise<{
exitCode: number;
stdout: string;
stderr: string;
}>;
}
let ctx: E2EContext;
async function setup(): Promise<void> {
ctx = await createE2ETestContext({ timeout: 45_000 });
}
async function cleanup(): Promise<void> {
if (ctx) {
await ctx.stop();
}
}
async function test_invalid_opencode_model_does_not_report_completed_while_still_running() {
const result = await ctx.paseo(
["run", "--provider", "opencode/adklasldkdas", "hello"],
{ timeout: 45_000 },
);
const output = `${result.stdout}\n${result.stderr}`;
const agentId = output.match(/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/i)?.[0];
if (result.exitCode !== 0) {
assert(
output.toLowerCase().includes("error") || output.toLowerCase().includes("failed"),
`expected invalid model failure output\nstdout:\n${result.stdout}\nstderr:\n${result.stderr}`,
);
return;
}
assert(agentId, `expected run output to include an agent id\nstdout:\n${result.stdout}`);
const inspect = await ctx.paseo(["inspect", agentId], { timeout: 15_000 });
assert.strictEqual(inspect.exitCode, 0, `inspect failed\nstdout:\n${inspect.stdout}\nstderr:\n${inspect.stderr}`);
const runReportedCompleted = result.stdout.includes("completed");
const inspectStillRunning = inspect.stdout.includes("Status running");
assert(
!(runReportedCompleted && inspectStillRunning),
`run reported completed while inspect still showed running\nrun stdout:\n${result.stdout}\ninspect stdout:\n${inspect.stdout}`,
);
}
async function main(): Promise<void> {
try {
await setup();
await test_invalid_opencode_model_does_not_report_completed_while_still_running();
} catch (error) {
console.error(error);
process.exitCode = 1;
} finally {
await cleanup();
}
}
main();
+7 -11
View File
@@ -1,9 +1,9 @@
import { spawn, type ChildProcess } from "node:child_process";
import { type ChildProcess } from "node:child_process";
import { readFileSync } from "node:fs";
import path from "node:path";
import { app, ipcMain } from "electron";
import log from "electron-log/main";
import { resolvePaseoHome } from "@getpaseo/server";
import { resolvePaseoHome, spawnProcess } from "@getpaseo/server";
import {
copyAttachmentFileToManagedStorage,
deleteManagedAttachmentFile,
@@ -266,15 +266,11 @@ async function startDaemon(): Promise<DesktopDaemonStatus> {
args: invocation.args,
});
const child: ChildProcess = spawn(
invocation.command,
invocation.args,
{
detached: true,
env: { ...invocation.env, PASEO_DESKTOP_MANAGED: "1" },
stdio: ["ignore", "ignore", "ignore"],
},
);
const child: ChildProcess = spawnProcess(invocation.command, invocation.args, {
detached: true,
env: { ...invocation.env, PASEO_DESKTOP_MANAGED: "1" },
stdio: ["ignore", "ignore", "ignore"],
});
logDesktopDaemonLifecycle("detached spawn returned", {
childPid: child.pid ?? null,
+6 -4
View File
@@ -1,5 +1,6 @@
import { existsSync, readFileSync } from "node:fs";
import { spawn, spawnSync } from "node:child_process";
import { spawnSync } from "node:child_process";
import { spawnProcess } from "@getpaseo/server";
import { createRequire } from "node:module";
import path from "node:path";
import { app } from "electron";
@@ -234,6 +235,7 @@ export function runCliPassthroughCommand(args: string[]): number {
const result = spawnSync(invocation.command, invocation.args, {
env: invocation.env,
stdio: "inherit",
windowsHide: true,
});
if (result.error) {
throw result.error;
@@ -252,7 +254,7 @@ function spawnAsync(
options: { env: NodeJS.ProcessEnv },
): Promise<{ stdout: string; stderr: string; exitCode: number | null }> {
return new Promise((resolve, reject) => {
const child = spawn(command, args, {
const child = spawnProcess(command, args, {
env: options.env,
stdio: ["ignore", "pipe", "pipe"],
});
@@ -260,10 +262,10 @@ function spawnAsync(
let stdout = "";
let stderr = "";
child.stdout.on("data", (data: Buffer) => {
child.stdout!.on("data", (data: Buffer) => {
stdout += data.toString();
});
child.stderr.on("data", (data: Buffer) => {
child.stderr!.on("data", (data: Buffer) => {
stderr += data.toString();
});
@@ -200,7 +200,20 @@ export async function installCli(): Promise<InstallStatus> {
if (await pathOrSymlinkExists(targetPath)) {
await fs.unlink(targetPath);
}
await fs.copyFile(installSourcePath, targetPath);
// Generate a thin .cmd trampoline that delegates to the bundled shim.
// Only the app install path is baked in — internal details (asar layout,
// entrypoint scripts) live in the bundled shim and update with the app.
const cmdContent = [
"@echo off",
`set "BUNDLED_CLI=${shimPath}"`,
`if not exist "%BUNDLED_CLI%" (`,
` echo Paseo CLI not found at %BUNDLED_CLI% — is Paseo installed? 1>&2`,
` exit /b 1`,
`)`,
`call "%BUNDLED_CLI%" %*`,
`exit /b %errorlevel%`,
].join("\r\n");
await fs.writeFile(targetPath, cmdContent, "utf-8");
} else {
if (await pathOrSymlinkExists(targetPath)) {
await fs.unlink(targetPath);