Update files

This commit is contained in:
Mohamed Boudra
2026-03-11 23:13:36 +07:00
parent aeefa22ddf
commit 586b48e150
11 changed files with 396 additions and 761 deletions
@@ -22,21 +22,18 @@ import { confirmDialog } from '@/utils/confirm-dialog'
import { openExternalUrl } from '@/utils/open-external-url' import { openExternalUrl } from '@/utils/open-external-url'
import { formatVersionWithPrefix, isVersionMismatch } from '@/desktop/updates/desktop-updates' import { formatVersionWithPrefix, isVersionMismatch } from '@/desktop/updates/desktop-updates'
import { import {
getCliShimStatus, getCliSymlinkInstructions,
getManagedDaemonLogs, getManagedDaemonLogs,
getManagedDaemonPairing, getManagedDaemonPairing,
getManagedDaemonStatus, getManagedDaemonStatus,
installManagedCliShim,
restartManagedDaemon, restartManagedDaemon,
shouldUseManagedDesktopDaemon, shouldUseManagedDesktopDaemon,
startManagedDaemon, startManagedDaemon,
stopManagedDaemon, stopManagedDaemon,
uninstallManagedCliShim, type CliSymlinkInstructions,
type ManagedDaemonLogs, type ManagedDaemonLogs,
type ManagedPairingOffer, type ManagedPairingOffer,
type ManagedDaemonStatus, type ManagedDaemonStatus,
type CliManualInstructions,
type CliShimStatus,
} from '@/desktop/managed-runtime/managed-runtime' } from '@/desktop/managed-runtime/managed-runtime'
export interface LocalDaemonSectionProps { export interface LocalDaemonSectionProps {
@@ -51,29 +48,27 @@ export function LocalDaemonSection({ appVersion }: LocalDaemonSectionProps) {
const [statusError, setStatusError] = useState<string | null>(null) const [statusError, setStatusError] = useState<string | null>(null)
const [isRestartingDaemon, setIsRestartingDaemon] = useState(false) const [isRestartingDaemon, setIsRestartingDaemon] = useState(false)
const [isUpdatingDaemonManagement, setIsUpdatingDaemonManagement] = useState(false) const [isUpdatingDaemonManagement, setIsUpdatingDaemonManagement] = useState(false)
const [isInstallingCli, setIsInstallingCli] = useState(false) const [isLoadingCliSymlinkInstructions, setIsLoadingCliSymlinkInstructions] = useState(false)
const [statusMessage, setStatusMessage] = useState<string | null>(null) const [statusMessage, setStatusMessage] = useState<string | null>(null)
const [cliStatusMessage, setCliStatusMessage] = useState<string | null>(null) const [cliStatusMessage, setCliStatusMessage] = useState<string | null>(null)
const [managedLogs, setManagedLogs] = useState<ManagedDaemonLogs | null>(null) const [managedLogs, setManagedLogs] = useState<ManagedDaemonLogs | null>(null)
const [cliShimStatus, setCliShimStatus] = useState<CliShimStatus | null>(null)
const [isLogsModalOpen, setIsLogsModalOpen] = useState(false) const [isLogsModalOpen, setIsLogsModalOpen] = useState(false)
const [isPairingModalOpen, setIsPairingModalOpen] = useState(false) const [isPairingModalOpen, setIsPairingModalOpen] = useState(false)
const [isCliInstallModalOpen, setIsCliInstallModalOpen] = useState(false) const [isCliSymlinkModalOpen, setIsCliSymlinkModalOpen] = useState(false)
const [isLoadingPairing, setIsLoadingPairing] = useState(false) const [isLoadingPairing, setIsLoadingPairing] = useState(false)
const [pairingOffer, setPairingOffer] = useState<ManagedPairingOffer | null>(null) const [pairingOffer, setPairingOffer] = useState<ManagedPairingOffer | null>(null)
const [cliInstallInstructions, setCliInstallInstructions] = const [cliSymlinkInstructions, setCliSymlinkInstructions] =
useState<CliManualInstructions | null>(null) useState<CliSymlinkInstructions | null>(null)
const [pairingStatusMessage, setPairingStatusMessage] = useState<string | null>(null) const [pairingStatusMessage, setPairingStatusMessage] = useState<string | null>(null)
const loadManagedStatus = useCallback(() => { const loadManagedStatus = useCallback(() => {
if (!showSection) { if (!showSection) {
return Promise.resolve() return Promise.resolve()
} }
return Promise.all([getManagedDaemonStatus(), getManagedDaemonLogs(), getCliShimStatus()]) return Promise.all([getManagedDaemonStatus(), getManagedDaemonLogs()])
.then(([status, logs, shimStatus]) => { .then(([status, logs]) => {
setManagedStatus(status) setManagedStatus(status)
setManagedLogs(logs) setManagedLogs(logs)
setCliShimStatus(shimStatus)
setStatusError(null) setStatusError(null)
}) })
.catch((error) => { .catch((error) => {
@@ -224,50 +219,39 @@ export function LocalDaemonSection({ appVersion }: LocalDaemonSectionProps) {
updateSettings, updateSettings,
]) ])
const handleToggleCliShim = useCallback(() => { const handleOpenCliSymlinkInstructions = useCallback(() => {
if (!showSection || isInstallingCli) { if (!showSection || isLoadingCliSymlinkInstructions) {
return return
} }
setIsInstallingCli(true) setIsLoadingCliSymlinkInstructions(true)
const isInstalling = !cliShimStatus?.path setCliStatusMessage(null)
setCliStatusMessage( void getCliSymlinkInstructions()
isInstalling ? 'A permissions popup may appear while Paseo installs the CLI globally.' : null .then((instructions) => {
) setCliSymlinkInstructions(instructions)
const action = cliShimStatus?.path ? uninstallManagedCliShim : installManagedCliShim setIsCliSymlinkModalOpen(true)
void action()
.then((result) => {
setCliStatusMessage(result.message)
if (result.manualInstructions) {
setCliInstallInstructions(result.manualInstructions)
setIsCliInstallModalOpen(true)
} else {
setCliInstallInstructions(null)
setIsCliInstallModalOpen(false)
}
return loadManagedStatus()
}) })
.catch((error) => { .catch((error) => {
const message = error instanceof Error ? error.message : String(error) const message = error instanceof Error ? error.message : String(error)
setCliStatusMessage(`CLI install failed: ${message}`) setCliStatusMessage(`Unable to load CLI symlink instructions: ${message}`)
}) })
.finally(() => { .finally(() => {
setIsInstallingCli(false) setIsLoadingCliSymlinkInstructions(false)
}) })
}, [cliShimStatus?.path, isInstallingCli, loadManagedStatus, showSection]) }, [isLoadingCliSymlinkInstructions, showSection])
const handleCopyCliInstallCommands = useCallback(() => { const handleCopyCliSymlinkCommands = useCallback(() => {
if (!cliInstallInstructions?.commands) { if (!cliSymlinkInstructions?.commands) {
return return
} }
void Clipboard.setStringAsync(cliInstallInstructions.commands) void Clipboard.setStringAsync(cliSymlinkInstructions.commands)
.then(() => { .then(() => {
Alert.alert('Copied', 'CLI install commands copied.') Alert.alert('Copied', 'CLI symlink commands copied.')
}) })
.catch((error) => { .catch((error) => {
console.error('[Settings] Failed to copy CLI install commands', error) console.error('[Settings] Failed to copy CLI symlink commands', error)
Alert.alert('Error', 'Unable to copy CLI install commands.') Alert.alert('Error', 'Unable to copy CLI symlink commands.')
}) })
}, [cliInstallInstructions?.commands]) }, [cliSymlinkInstructions?.commands])
const handleCopyLogPath = useCallback(() => { const handleCopyLogPath = useCallback(() => {
const logPath = managedLogs?.logPath const logPath = managedLogs?.logPath
@@ -417,17 +401,17 @@ export function LocalDaemonSection({ appVersion }: LocalDaemonSectionProps) {
<View style={[styles.row, styles.rowBorder]}> <View style={[styles.row, styles.rowBorder]}>
<View style={styles.rowContent}> <View style={styles.rowContent}>
<Text style={styles.rowTitle}>Command line (CLI)</Text> <Text style={styles.rowTitle}>Command line (CLI)</Text>
<Text style={styles.hintText}>Adds the `paseo` command to your terminal.</Text> <Text style={styles.hintText}>Shows the command to add `paseo` to your terminal.</Text>
{cliStatusMessage ? <Text style={styles.statusText}>{cliStatusMessage}</Text> : null} {cliStatusMessage ? <Text style={styles.statusText}>{cliStatusMessage}</Text> : null}
</View> </View>
<Button <Button
variant="outline" variant="outline"
size="sm" size="sm"
leftIcon={<Terminal size={theme.iconSize.sm} color={theme.colors.foreground} />} leftIcon={<Terminal size={theme.iconSize.sm} color={theme.colors.foreground} />}
onPress={handleToggleCliShim} onPress={handleOpenCliSymlinkInstructions}
disabled={isInstallingCli} disabled={isLoadingCliSymlinkInstructions}
> >
{isInstallingCli ? 'Working...' : cliShimStatus?.path ? 'Uninstall CLI' : 'Install CLI'} {isLoadingCliSymlinkInstructions ? 'Loading...' : 'Show instructions'}
</Button> </Button>
</View> </View>
<View style={[styles.row, styles.rowBorder]}> <View style={[styles.row, styles.rowBorder]}>
@@ -483,27 +467,26 @@ export function LocalDaemonSection({ appVersion }: LocalDaemonSectionProps) {
) : null} ) : null}
<AdaptiveModalSheet <AdaptiveModalSheet
visible={isCliInstallModalOpen} visible={isCliSymlinkModalOpen}
onClose={() => setIsCliInstallModalOpen(false)} onClose={() => setIsCliSymlinkModalOpen(false)}
title="Install CLI manually" title="Add paseo to your shell"
testID="managed-daemon-cli-install-dialog" testID="managed-daemon-cli-symlink-dialog"
> >
<View style={styles.modalBody}> <View style={styles.modalBody}>
<Text style={styles.hintText}> <Text style={styles.hintText}>
A permissions popup should appear when Paseo installs the CLI globally. If it does not Paseo does not add the command for you. Run the command below in your terminal.
complete, open a terminal and run the commands below.
</Text> </Text>
{cliInstallInstructions?.detail ? ( {cliSymlinkInstructions?.detail ? (
<Text style={styles.hintText}>{cliInstallInstructions.detail}</Text> <Text style={styles.hintText}>{cliSymlinkInstructions.detail}</Text>
) : null} ) : null}
<Text style={styles.codeBlock} selectable> <Text style={styles.codeBlock} selectable>
{cliInstallInstructions?.commands ?? ''} {cliSymlinkInstructions?.commands ?? ''}
</Text> </Text>
<View style={styles.modalActions}> <View style={styles.modalActions}>
<Button variant="outline" size="sm" onPress={() => setIsCliInstallModalOpen(false)}> <Button variant="outline" size="sm" onPress={() => setIsCliSymlinkModalOpen(false)}>
Close Close
</Button> </Button>
<Button size="sm" onPress={handleCopyCliInstallCommands}> <Button size="sm" onPress={handleCopyCliSymlinkCommands}>
Copy commands Copy commands
</Button> </Button>
</View> </View>
@@ -1,46 +1,24 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { parseCliShimResult } from "./managed-runtime"; import { parseCliSymlinkInstructions } from "./managed-runtime";
describe("parseCliShimResult", () => { describe("parseCliSymlinkInstructions", () => {
it("parses manual install payloads from the desktop backend", () => { it("parses CLI symlink instructions from the desktop backend", () => {
expect( expect(
parseCliShimResult({ parseCliSymlinkInstructions({
status: "manualInstallRequired", title: "Add paseo to your shell",
installed: false, detail: "Create a symlink to the Paseo desktop executable.",
path: "/usr/local/bin/paseo", commands: "sudo ln -sf /Applications/Paseo.app/Contents/MacOS/Paseo /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({ ).toEqual({
status: "manualInstallRequired", title: "Add paseo to your shell",
installed: false, detail: "Create a symlink to the Paseo desktop executable.",
path: "/usr/local/bin/paseo", commands: "sudo ln -sf /Applications/Paseo.app/Contents/MacOS/Paseo /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", () => { it("rejects non-object payloads", () => {
expect( expect(() => parseCliSymlinkInstructions(null)).toThrow(
parseCliShimResult({ "Unexpected CLI symlink instructions response."
installed: true, );
path: "/usr/local/bin/paseo",
message: "Installed.",
})
).toEqual({
status: "installed",
installed: true,
path: "/usr/local/bin/paseo",
message: "Installed.",
manualInstructions: null,
});
}); });
}); });
@@ -23,30 +23,13 @@ export type ManagedDaemonLogs = {
contents: string contents: string
} }
export type CliShimStatus = {
path: string | null
}
export type ManagedPairingOffer = { export type ManagedPairingOffer = {
relayEnabled: boolean relayEnabled: boolean
url: string | null url: string | null
qr: string | null qr: string | null
} }
export type CliShimResult = { export type CliSymlinkInstructions = {
status:
| 'installed'
| 'removed'
| 'elevationDenied'
| 'automaticInstallUnavailable'
| 'manualInstallRequired'
installed: boolean
path: string | null
message: string
manualInstructions: CliManualInstructions | null
}
export type CliManualInstructions = {
title: string title: string
detail: string detail: string
commands: string commands: string
@@ -122,15 +105,6 @@ function parseManagedDaemonLogs(raw: unknown): ManagedDaemonLogs {
} }
} }
function parseCliShimStatus(raw: unknown): CliShimStatus {
if (!isRecord(raw)) {
throw new Error('Unexpected CLI shim status response.')
}
return {
path: toStringOrNull(raw.path),
}
}
function parseManagedPairingOffer(raw: unknown): ManagedPairingOffer { function parseManagedPairingOffer(raw: unknown): ManagedPairingOffer {
if (!isRecord(raw)) { if (!isRecord(raw)) {
throw new Error('Unexpected managed daemon pairing response.') throw new Error('Unexpected managed daemon pairing response.')
@@ -142,7 +116,7 @@ function parseManagedPairingOffer(raw: unknown): ManagedPairingOffer {
} }
} }
function parseCliManualInstructions(raw: unknown): CliManualInstructions | null { function parseCliSymlinkInstructionsInternal(raw: unknown): CliSymlinkInstructions | null {
if (!isRecord(raw)) { if (!isRecord(raw)) {
return null return null
} }
@@ -153,21 +127,6 @@ function parseCliManualInstructions(raw: unknown): CliManualInstructions | null
} }
} }
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 { export function shouldUseManagedDesktopDaemon(): boolean {
return isTauriEnvironment() && getTauri() !== null return isTauriEnvironment() && getTauri() !== null
} }
@@ -196,20 +155,20 @@ export async function getManagedDaemonLogs(): Promise<ManagedDaemonLogs> {
return parseManagedDaemonLogs(await invokeDesktopCommand('managed_daemon_logs')) return parseManagedDaemonLogs(await invokeDesktopCommand('managed_daemon_logs'))
} }
export async function getCliShimStatus(): Promise<CliShimStatus> {
return parseCliShimStatus(await invokeDesktopCommand('cli_shim_status'))
}
export async function getManagedDaemonPairing(): Promise<ManagedPairingOffer> { export async function getManagedDaemonPairing(): Promise<ManagedPairingOffer> {
return parseManagedPairingOffer(await invokeDesktopCommand('managed_daemon_pairing')) return parseManagedPairingOffer(await invokeDesktopCommand('managed_daemon_pairing'))
} }
export async function installManagedCliShim(): Promise<CliShimResult> { export function parseCliSymlinkInstructions(raw: unknown): CliSymlinkInstructions {
return parseCliShimResult(await invokeDesktopCommand('install_cli_shim')) const instructions = parseCliSymlinkInstructionsInternal(raw)
if (!instructions) {
throw new Error('Unexpected CLI symlink instructions response.')
}
return instructions
} }
export async function uninstallManagedCliShim(): Promise<CliShimResult> { export async function getCliSymlinkInstructions(): Promise<CliSymlinkInstructions> {
return parseCliShimResult(await invokeDesktopCommand('uninstall_cli_shim')) return parseCliSymlinkInstructions(await invokeDesktopCommand('cli_symlink_instructions'))
} }
export async function updateManagedDaemonTcpSettings( export async function updateManagedDaemonTcpSettings(
@@ -1,5 +1,7 @@
import { Image, Text, View } from "react-native"; import { Text, View } from "react-native";
import { StyleSheet } from "react-native-unistyles"; import { StyleSheet } from "react-native-unistyles";
import { PaseoLogo } from "@/components/icons/paseo-logo";
import { useTauriDragHandlers } from "@/utils/tauri-window";
const styles = StyleSheet.create((theme) => ({ const styles = StyleSheet.create((theme) => ({
container: { container: {
@@ -8,11 +10,6 @@ const styles = StyleSheet.create((theme) => ({
alignItems: "center", alignItems: "center",
backgroundColor: theme.colors.surface0, backgroundColor: theme.colors.surface0,
}, },
logo: {
width: 96,
height: 96,
marginBottom: theme.spacing[6],
},
status: { status: {
color: theme.colors.foregroundMuted, color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.base, fontSize: theme.fontSize.base,
@@ -20,13 +17,11 @@ const styles = StyleSheet.create((theme) => ({
})); }));
export function StartupSplashScreen() { export function StartupSplashScreen() {
const dragHandlers = useTauriDragHandlers();
return ( return (
<View style={styles.container}> <View style={styles.container} {...dragHandlers}>
<Image <PaseoLogo size={96} />
source={require("../../assets/images/icon.png")}
style={styles.logo}
resizeMode="contain"
/>
<Text style={styles.status}>Starting up…</Text> <Text style={styles.status}>Starting up…</Text>
</View> </View>
); );
+139 -131
View File
@@ -228,6 +228,50 @@ async function runBundledRuntimeCli(runtimeRoot, managedHome, args, env) {
return { stdout, stderr }; return { stdout, stderr };
} }
async function resolvePackagedRuntimeStatus(binaryPath) {
const binaryDir = path.dirname(binaryPath);
const resourceDirs = [
path.join(binaryDir, "resources"),
binaryDir,
path.join(binaryDir, "..", "Resources"),
path.join(binaryDir, "..", "resources"),
];
let bundledRoot = null;
for (const resourceDir of resourceDirs) {
for (const candidate of [
path.join(resourceDir, "resources", "managed-runtime"),
path.join(resourceDir, "managed-runtime"),
]) {
if (await pathExists(candidate)) {
bundledRoot = candidate;
break;
}
}
if (bundledRoot) {
break;
}
}
if (!bundledRoot) {
throw new Error("Managed runtime resources are not bundled with this desktop build.");
}
const pointer = JSON.parse(
await fs.readFile(path.join(bundledRoot, "current-runtime.json"), "utf8")
);
const runtimeRoot = path.join(bundledRoot, pointer.relativeRoot);
const manifest = JSON.parse(
await fs.readFile(path.join(runtimeRoot, "runtime-manifest.json"), "utf8")
);
return {
runtimeId: manifest.runtimeId,
runtimeVersion: manifest.runtimeVersion,
runtimeRoot,
};
}
async function readDaemonStatus(home, env) { async function readDaemonStatus(home, env) {
const result = await runWorkspaceCli(["daemon", "status", "--home", home, "--json"], env); const result = await runWorkspaceCli(["daemon", "status", "--home", home, "--json"], env);
return result.json ?? {}; return result.json ?? {};
@@ -464,10 +508,6 @@ function logStep(label) {
console.log(`\n[managed-smoke] ${label}`); console.log(`\n[managed-smoke] ${label}`);
} }
function shouldAttemptCliShimInstall(env) {
return !(process.platform === "darwin" && env.CI === "true");
}
const packagedBinary = resolvePackagedBinary(); const packagedBinary = resolvePackagedBinary();
await ensurePackagedArtifact(packagedBinary); await ensurePackagedArtifact(packagedBinary);
if (!(await pathExists(packagedBinary))) { if (!(await pathExists(packagedBinary))) {
@@ -527,6 +567,8 @@ const managedEnv = {
let externalPid = null; let externalPid = null;
let startedTemporaryExternalDaemon = false; let startedTemporaryExternalDaemon = false;
let relayProcess = null; let relayProcess = null;
let runtimeStatus = null;
let managedStart = null;
const forbiddenManagedReferences = ["127.0.0.1:6767", fakePaseoHome, managedRuntimeDir]; const forbiddenManagedReferences = ["127.0.0.1:6767", fakePaseoHome, managedRuntimeDir];
const npmExecPath = process.env.npm_execpath; const npmExecPath = process.env.npm_execpath;
if (!npmExecPath) { if (!npmExecPath) {
@@ -584,35 +626,42 @@ try {
externalPid = externalStatus.pid; externalPid = externalStatus.pid;
assert.ok(externalPid, "external daemon pid should be present"); assert.ok(externalPid, "external daemon pid should be present");
logStep("Bootstrapping managed runtime from packaged desktop binary"); logStep("Resolving bundled runtime from packaged desktop artifact");
const runtimeStatus = await runBinary(packagedBinary, ["--managed-headless", "runtime-status"], managedEnv); runtimeStatus = await resolvePackagedRuntimeStatus(packagedBinary);
assert.equal(runtimeStatus.json.runtimeId, currentRuntimeId); assert.equal(runtimeStatus.runtimeId, currentRuntimeId);
assert.equal(runtimeStatus.json.runtimeVersion, currentRuntimeVersion); assert.equal(runtimeStatus.runtimeVersion, currentRuntimeVersion);
assert.match( assert.match(
runtimeStatus.json.runtimeRoot, runtimeStatus.runtimeRoot,
new RegExp(escapeForRegExp(currentRuntimeId)), new RegExp(escapeForRegExp(currentRuntimeId)),
"runtime status should point into the bundled runtime selected by current-runtime.json" "runtime status should point into the bundled runtime selected by current-runtime.json"
); );
assert.equal( assert.equal(
runtimeStatus.json.runtimeRoot.startsWith(testRoot), runtimeStatus.runtimeRoot.startsWith(testRoot),
false, false,
"runtime status should not resolve into managed app data" "runtime status should not resolve into managed app data"
); );
assertNoForbiddenPathsOrPorts(runtimeStatus.json, forbiddenManagedReferences); assertNoForbiddenPathsOrPorts(runtimeStatus, forbiddenManagedReferences);
assert.equal(await pathExists(managedRuntimeDir), false, "managed app data should not gain a runtime tree"); assert.equal(await pathExists(managedRuntimeDir), false, "managed app data should not gain a runtime tree");
const managedBootstrap = await runBinary(packagedBinary, ["--managed-headless", "bootstrap"], managedEnv); const managedBootstrap = await runBundledRuntimeCli(
const managedStart = managedBootstrap.json ?? await waitFor( runtimeStatus.runtimeRoot,
cliScratchHome,
["start", "--json"],
managedEnv
);
managedStart = (managedBootstrap.stdout.trim() ? JSON.parse(managedBootstrap.stdout.trim()) : null) ?? await waitFor(
async () => { async () => {
const status = await runBinary( const status = await runBundledRuntimeCli(
packagedBinary, runtimeStatus.runtimeRoot,
["--managed-headless", "daemon-status"], cliScratchHome,
["daemon", "status", "--json"],
managedEnv managedEnv
); );
assert.equal(status.json?.status, "running"); const json = JSON.parse(status.stdout.trim());
assert.ok(status.json?.pid, "managed daemon pid should exist"); assert.equal(json.status, "running");
assert.ok(status.json?.serverId, "managed daemon should expose a server id"); assert.ok(json.pid, "managed daemon pid should exist");
return status.json; assert.ok(json.serverId, "managed daemon should expose a server id");
return json;
}, },
10_000, 10_000,
"managed daemon bootstrap status" "managed daemon bootstrap status"
@@ -629,111 +678,64 @@ try {
logStep("Verifying the managed daemon stays alive after the packaged command exits"); logStep("Verifying the managed daemon stays alive after the packaged command exits");
await sleep(1_500); await sleep(1_500);
const persistedManagedStatus = await runBinary( const persistedManagedStatus = JSON.parse(
packagedBinary, (
["--managed-headless", "daemon-status"], await runBundledRuntimeCli(
managedEnv runtimeStatus.runtimeRoot,
);
assert.equal(persistedManagedStatus.json.pid, managedPid);
assert.equal(persistedManagedStatus.json.status, "running");
assert.equal(persistedManagedStatus.json.serverId, managedStart.serverId);
assertNoForbiddenPathsOrPorts(persistedManagedStatus.json, forbiddenManagedReferences);
const attemptCliShimInstall = shouldAttemptCliShimInstall(managedEnv);
const cliInstall = attemptCliShimInstall
? await (async () => {
logStep("Installing CLI shim and verifying the bundled CLI target");
return await runBinary(
packagedBinary,
["--managed-headless", "install-cli-shim"],
managedEnv
);
})()
: {
json: {
status: "skippedInCi",
installed: false,
path: null,
message: "Skipping privileged macOS CLI shim install in CI; verifying bundled CLI directly.",
},
};
const cliShimPath = cliInstall.json.path;
const cliShimInstalled =
Boolean(attemptCliShimInstall && cliShimPath) && cliInstall.json.installed === true && (await pathExists(cliShimPath));
if (attemptCliShimInstall) {
assert.ok(cliShimPath, "CLI shim path should be returned");
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);
}
} else {
logStep("Skipping privileged CLI shim install in CI and verifying the bundled CLI target directly");
}
const cliVersion = cliShimInstalled
? await execFileWithTimeout(cliShimPath, ["--version"], {
env: managedEnv,
cwd: repoRoot,
maxBuffer: 1024 * 1024,
}, "installed CLI shim version check")
: await runBundledRuntimeCli(
runtimeStatus.json.runtimeRoot,
managedStart.home,
["--version"],
managedEnv
);
assert.match(cliVersion.stdout.trim(), /^0\./);
const shimStatus = cliShimInstalled
? await execFileWithTimeout(cliShimPath, ["daemon", "status", "--json"], {
env: managedEnv,
cwd: repoRoot,
maxBuffer: 1024 * 1024,
}, "installed CLI shim daemon status")
: await runBundledRuntimeCli(
runtimeStatus.json.runtimeRoot,
managedStart.home, managedStart.home,
["daemon", "status", "--json"], ["daemon", "status", "--json"],
managedEnv managedEnv
); )
const shimDaemonStatus = JSON.parse(shimStatus.stdout.trim()); ).stdout.trim()
assert.equal(shimDaemonStatus.pid, managedPid); );
assertNoForbiddenPathsOrPorts(shimDaemonStatus, forbiddenManagedReferences); assert.equal(persistedManagedStatus.pid, managedPid);
assert.equal(persistedManagedStatus.status, "running");
assert.equal(persistedManagedStatus.serverId, managedStart.serverId);
assertNoForbiddenPathsOrPorts(persistedManagedStatus, forbiddenManagedReferences);
logStep("Verifying bundled CLI access without installing a shim");
const cliVersion = await runBundledRuntimeCli(
runtimeStatus.runtimeRoot,
managedStart.home,
["--version"],
managedEnv
);
assert.match(cliVersion.stdout.trim(), /^0\./);
const bundledCliStatus = await runBundledRuntimeCli(
runtimeStatus.runtimeRoot,
managedStart.home,
["daemon", "status", "--json"],
managedEnv
);
const bundledCliDaemonStatus = JSON.parse(bundledCliStatus.stdout.trim());
assert.equal(bundledCliDaemonStatus.pid, managedPid);
assertNoForbiddenPathsOrPorts(bundledCliDaemonStatus, forbiddenManagedReferences);
logStep("Verifying relay connectivity still works after the desktop command has exited"); logStep("Verifying relay connectivity still works after the desktop command has exited");
const relayPairing = cliShimInstalled const relayPairing = await runBundledRuntimeCli(
? await execFileWithTimeout( runtimeStatus.runtimeRoot,
cliShimPath, managedStart.home,
["daemon", "pair"], ["daemon", "pair"],
{ managedEnv
env: managedEnv, );
cwd: repoRoot,
maxBuffer: 4 * 1024 * 1024,
},
"installed CLI shim relay pairing"
)
: await runBundledRuntimeCli(
runtimeStatus.json.runtimeRoot,
managedStart.home,
["daemon", "pair"],
managedEnv
);
const relayOfferUrl = parseOfferUrlFromCommandOutput(relayPairing.stdout); const relayOfferUrl = parseOfferUrlFromCommandOutput(relayPairing.stdout);
const relayOffer = decodeOfferFromFragmentUrl(relayOfferUrl); const relayOffer = decodeOfferFromFragmentUrl(relayOfferUrl);
assert.equal(relayOffer.relay?.endpoint, relayEndpoint); assert.equal(relayOffer.relay?.endpoint, relayEndpoint);
const relayPong = await connectViaRelay(relayEndpoint, relayOffer); const relayPong = await connectViaRelay(relayEndpoint, relayOffer);
assert.deepEqual(relayPong, { type: "pong" }); assert.deepEqual(relayPong, { type: "pong" });
logStep("Reopening packaged desktop command path without spawning duplicate daemons"); logStep("Re-running managed start without spawning duplicate daemons");
const managedRestartless = await runBinary( const managedRestartless = JSON.parse(
packagedBinary, (
["--managed-headless", "bootstrap"], await runBundledRuntimeCli(
managedEnv runtimeStatus.runtimeRoot,
managedStart.home,
["start", "--json"],
managedEnv
)
).stdout.trim()
); );
assert.equal(managedRestartless.json.pid, managedPid); assert.equal(managedRestartless.pid, managedPid);
logStep("Verifying managed and external daemons coexist"); logStep("Verifying managed and external daemons coexist");
const externalStatusAfter = await readDaemonStatus(externalHome, managedEnv); const externalStatusAfter = await readDaemonStatus(externalHome, managedEnv);
@@ -741,13 +743,18 @@ try {
assert.equal(externalStatusAfter.status, "running"); assert.equal(externalStatusAfter.status, "running");
assert.equal(externalPidAfter, externalPid); assert.equal(externalPidAfter, externalPid);
await runWorkspaceCli(["ls", "--host", externalEndpoint, "--json"], managedEnv); await runWorkspaceCli(["ls", "--host", externalEndpoint, "--json"], managedEnv);
const managedStatus = await runBinary( const managedStatus = JSON.parse(
packagedBinary, (
["--managed-headless", "daemon-status"], await runBundledRuntimeCli(
managedEnv runtimeStatus.runtimeRoot,
managedStart.home,
["daemon", "status", "--json"],
managedEnv
)
).stdout.trim()
); );
assert.ok(managedStatus.json.serverId, "managed daemon should expose a server id"); assert.ok(managedStatus.serverId, "managed daemon should expose a server id");
assertNoForbiddenPathsOrPorts(managedStatus.json, forbiddenManagedReferences); assertNoForbiddenPathsOrPorts(managedStatus, forbiddenManagedReferences);
logStep("Capturing diagnostics and verifying the fake ~/.paseo stayed untouched"); logStep("Capturing diagnostics and verifying the fake ~/.paseo stayed untouched");
const fakePaseoSnapshotAfter = await snapshotTree(fakePaseoHome); const fakePaseoSnapshotAfter = await snapshotTree(fakePaseoHome);
@@ -756,20 +763,19 @@ try {
path.join(testRoot, "managed-daemon-smoke-diagnostics.json"), path.join(testRoot, "managed-daemon-smoke-diagnostics.json"),
JSON.stringify( JSON.stringify(
{ {
runtimeStatus: runtimeStatus.json, runtimeStatus,
managedBootstrap: managedBootstrap.json, managedBootstrap: managedBootstrap.stdout.trim() ? JSON.parse(managedBootstrap.stdout.trim()) : null,
managedStart, managedStart,
persistedManagedStatus: persistedManagedStatus.json, persistedManagedStatus,
managedRestartless: managedRestartless.json, managedRestartless,
cliInstall: cliInstall.json, bundledCliDaemonStatus,
shimDaemonStatus,
relayEndpoint, relayEndpoint,
relayOfferUrl, relayOfferUrl,
relayPong, relayPong,
externalEndpoint, externalEndpoint,
externalPid, externalPid,
externalPidAfter, externalPidAfter,
managedStatus: managedStatus.json, managedStatus,
}, },
null, null,
2 2
@@ -781,11 +787,13 @@ try {
} finally { } finally {
clearInterval(heartbeat); clearInterval(heartbeat);
try { try {
await runBinary(packagedBinary, ["--managed-headless", "stop-daemon"], managedEnv); if (runtimeStatus?.runtimeRoot) {
} catch {} await runBundledRuntimeCli(
try { runtimeStatus.runtimeRoot,
if (shouldAttemptCliShimInstall(managedEnv)) { managedStart?.home ?? cliScratchHome,
await runBinary(packagedBinary, ["--managed-headless", "uninstall-cli-shim"], managedEnv); ["daemon", "stop", "--json"],
managedEnv
);
} }
} catch {} } catch {}
try { try {
@@ -0,0 +1,11 @@
!macro NSIS_HOOK_POSTINSTALL
CreateDirectory "$LOCALAPPDATA\Microsoft\WinGet\Links"
FileOpen $0 "$LOCALAPPDATA\Microsoft\WinGet\Links\paseo.cmd" w
FileWrite $0 "@echo off$\r$\n"
FileWrite $0 '"$INSTDIR\Paseo.exe" %*$\r$\n'
FileClose $0
!macroend
!macro NSIS_HOOK_POSTUNINSTALL
Delete "$LOCALAPPDATA\Microsoft\WinGet\Links\paseo.cmd"
!macroend
+57 -142
View File
@@ -1,6 +1,5 @@
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};
@@ -14,11 +13,11 @@ use tauri_plugin_updater::UpdaterExt;
mod runtime_manager; mod runtime_manager;
use runtime_manager::{ use runtime_manager::{
cli_shim_status, close_local_daemon_transport, install_cli_shim, managed_daemon_logs, cli_symlink_instructions, close_local_daemon_transport, managed_daemon_logs,
managed_daemon_pairing, managed_daemon_status, managed_runtime_status, managed_daemon_pairing, managed_daemon_status, managed_runtime_status,
open_local_daemon_transport, restart_managed_daemon, send_local_daemon_transport_message, open_local_daemon_transport, restart_managed_daemon, run_managed_cli_from_current_process,
start_managed_daemon, stop_managed_daemon, uninstall_cli_shim, send_local_daemon_transport_message, start_managed_daemon, stop_managed_daemon,
update_managed_daemon_tcp_settings, LocalTransportState, ManagedTcpSettings, update_managed_daemon_tcp_settings, LocalTransportState,
}; };
// Store zoom as u64 bits (f64 * 100 as integer for atomic ops) // Store zoom as u64 bits (f64 * 100 as integer for atomic ops)
@@ -74,145 +73,32 @@ struct AttachmentFileResult {
byte_size: u64, byte_size: u64,
} }
#[derive(Debug, Clone)] fn is_ignored_gui_launch_arg(arg: &str) -> bool {
enum ManagedHeadlessCommand { arg.starts_with("-psn_")
RuntimeStatus,
Bootstrap,
DaemonStatus,
StopDaemon,
InstallCliShim,
UninstallCliShim,
UpdateTcp(ManagedTcpSettings),
} }
fn parse_managed_headless_command() -> Result<Option<ManagedHeadlessCommand>, String> { fn parse_cli_passthrough_args_from_argv(args: &[String]) -> Option<Vec<String>> {
let args = std::env::args().collect::<Vec<_>>(); let effective = args
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() .iter()
.skip(flag_index + 2) .skip(1)
.filter(|arg| !is_ignored_gui_launch_arg(arg))
.cloned() .cloned()
.collect::<Vec<_>>(); .collect::<Vec<_>>();
match command.as_str() { (!effective.is_empty()).then_some(effective)
"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> { pub fn try_run_pre_tauri_mode() -> Option<i32> {
let Some(command) = parse_managed_headless_command()? else { let args = std::env::args().collect::<Vec<_>>();
return Ok(false); let Some(cli_args) = parse_cli_passthrough_args_from_argv(&args) else {
return None;
}; };
log::info!("[headless] running managed headless command: {:?}", command);
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 { Some(
Ok(payload) => { run_managed_cli_from_current_process(cli_args).unwrap_or_else(|error| {
println!( eprintln!("{error}");
"{}", 1
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 shell_command(script: &str) -> Command { fn shell_command(script: &str) -> Command {
@@ -622,14 +508,12 @@ pub fn run() {
.invoke_handler(tauri::generate_handler![ .invoke_handler(tauri::generate_handler![
managed_runtime_status, managed_runtime_status,
managed_daemon_status, managed_daemon_status,
cli_shim_status, cli_symlink_instructions,
start_managed_daemon, start_managed_daemon,
stop_managed_daemon, stop_managed_daemon,
restart_managed_daemon, restart_managed_daemon,
managed_daemon_logs, managed_daemon_logs,
managed_daemon_pairing, managed_daemon_pairing,
install_cli_shim,
uninstall_cli_shim,
update_managed_daemon_tcp_settings, update_managed_daemon_tcp_settings,
open_local_daemon_transport, open_local_daemon_transport,
send_local_daemon_transport_message, send_local_daemon_transport_message,
@@ -650,10 +534,6 @@ pub fn run() {
app.package_info().version app.package_info().version
); );
if maybe_run_managed_headless_command(app.handle())? {
return Ok(());
}
app.handle().plugin( app.handle().plugin(
tauri_plugin_log::Builder::default() tauri_plugin_log::Builder::default()
.level(log::LevelFilter::Info) .level(log::LevelFilter::Info)
@@ -780,3 +660,38 @@ pub fn run() {
.run(tauri::generate_context!()) .run(tauri::generate_context!())
.expect("error while running tauri application"); .expect("error while running tauri application");
} }
#[cfg(test)]
mod tests {
use super::parse_cli_passthrough_args_from_argv;
#[test]
fn routes_meaningful_args_to_cli_mode() {
let args = vec![
"/Applications/Paseo.app/Contents/MacOS/Paseo".to_string(),
"--version".to_string(),
];
assert_eq!(
parse_cli_passthrough_args_from_argv(&args),
Some(vec!["--version".to_string()])
);
}
#[test]
fn ignores_plain_gui_launch() {
let args = vec!["/Applications/Paseo.app/Contents/MacOS/Paseo".to_string()];
assert_eq!(parse_cli_passthrough_args_from_argv(&args), None);
}
#[test]
fn ignores_macos_process_serial_number_argument() {
let args = vec![
"/Applications/Paseo.app/Contents/MacOS/Paseo".to_string(),
"-psn_0_12345".to_string(),
];
assert_eq!(parse_cli_passthrough_args_from_argv(&args), None);
}
}
+3
View File
@@ -8,5 +8,8 @@
tauri::embed_plist::embed_info_plist!("../Info.plist"); tauri::embed_plist::embed_info_plist!("../Info.plist");
fn main() { fn main() {
if let Some(exit_code) = paseo_lib::try_run_pre_tauri_mode() {
std::process::exit(exit_code);
}
paseo_lib::run(); paseo_lib::run();
} }
+109 -331
View File
@@ -18,9 +18,9 @@ const LOCAL_TRANSPORT_EVENT_NAME: &str = "local-daemon-transport-event";
const UNIX_CLIENT_URL: &str = "ws://localhost/ws"; const UNIX_CLIENT_URL: &str = "ws://localhost/ws";
#[cfg(windows)] #[cfg(windows)]
const PIPE_CLIENT_URL: &str = "ws://localhost/ws"; const PIPE_CLIENT_URL: &str = "ws://localhost/ws";
const CLI_SHIM_NAME: &str = "paseo"; const CLI_LINK_NAME: &str = "paseo";
#[cfg(windows)] #[cfg(windows)]
const CLI_SHIM_WINDOWS_NAME: &str = "paseo.cmd"; const CLI_LINK_WINDOWS_NAME: &str = "paseo.exe";
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct ManagedRuntimeManifest { pub struct ManagedRuntimeManifest {
@@ -84,12 +84,6 @@ pub struct ManagedDaemonStatus {
pub home: String, pub home: String,
} }
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct CliShimStatus {
pub path: Option<String>,
}
#[derive(Debug, Clone, Serialize)] #[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct ManagedDaemonLogs { pub struct ManagedDaemonLogs {
@@ -107,17 +101,7 @@ pub struct ManagedPairingOffer {
#[derive(Debug, Clone, Serialize)] #[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct CliShimResult { pub struct CliSymlinkInstructions {
pub status: String,
pub installed: bool,
pub path: Option<String>,
pub message: String,
pub manual_instructions: Option<CliManualInstructions>,
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct CliManualInstructions {
pub title: String, pub title: String,
pub detail: String, pub detail: String,
pub commands: String, pub commands: String,
@@ -272,58 +256,24 @@ mod tests {
} }
#[test] #[test]
fn cli_shim_result_serializes_manual_instructions_with_camel_case_keys() { fn cli_symlink_instructions_serialize_with_camel_case_keys() {
let value = serde_json::to_value(CliShimResult { let value = serde_json::to_value(CliSymlinkInstructions {
status: "manualInstallRequired".to_string(), title: "Add paseo to your shell".to_string(),
installed: false, detail: "Create a symlink to the Paseo desktop executable.".to_string(),
path: Some("/usr/local/bin/paseo".to_string()), commands: "sudo ...".to_string(),
message: "Manual install required.".to_string(),
manual_instructions: Some(CliManualInstructions {
title: "Install from Terminal".to_string(),
detail: "Run the commands below.".to_string(),
commands: "sudo ...".to_string(),
}),
}) })
.expect("serializes"); .expect("serializes");
assert_eq!( assert_eq!(
value.get("status").and_then(|entry| entry.as_str()), value.get("title").and_then(|entry| entry.as_str()),
Some("manualInstallRequired") Some("Add paseo to your shell")
); );
assert_eq!( assert_eq!(
value value.get("commands").and_then(|entry| entry.as_str()),
.get("manualInstructions")
.and_then(|entry| entry.get("commands"))
.and_then(|entry| entry.as_str()),
Some("sudo ...") Some("sudo ...")
); );
} }
#[test]
fn cli_shim_contents_runs_bundled_runtime_in_place() {
let runtime_root =
PathBuf::from("/Applications/Paseo.app/Contents/Resources/managed-runtime/runtime-1");
let manifest = ManagedRuntimeManifest {
runtime_id: "runtime-1".to_string(),
runtime_version: "0.1.0".to_string(),
platform: "darwin".to_string(),
arch: "arm64".to_string(),
created_at: "2025-01-01T00:00:00.000Z".to_string(),
node_relative_path: "node/node".to_string(),
cli_entrypoint_relative_path: "node_modules/@getpaseo/cli/dist/index.js".to_string(),
cli_shim_relative_path: "node_modules/@getpaseo/cli/bin/paseo".to_string(),
server_runner_relative_path:
"node_modules/@getpaseo/server/dist/scripts/daemon-runner.js".to_string(),
};
let contents = cli_shim_contents(&runtime_root, &manifest);
assert!(contents.contains("Contents/Resources/managed-runtime/runtime-1/node/node"));
assert!(contents.contains("node_modules/@getpaseo/cli/dist/index.js"));
assert!(!contents.contains("PASEO_HOME"));
assert!(!contents.contains("--paseo-cli-shim"));
}
#[cfg(unix)] #[cfg(unix)]
#[test] #[test]
#[ignore = "requires a running local daemon socket"] #[ignore = "requires a running local daemon socket"]
@@ -366,22 +316,25 @@ fn dev_resource_root() -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("resources") PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("resources")
} }
fn bundled_runtime_root_from_resource_dir(resource_dir: &Path) -> Option<PathBuf> {
for candidate in [
resource_dir.join("resources").join("managed-runtime"),
resource_dir.join("managed-runtime"),
] {
if candidate.exists() {
return Some(candidate);
}
}
None
}
fn bundled_runtime_root(app: &AppHandle) -> Result<PathBuf, String> { fn bundled_runtime_root(app: &AppHandle) -> Result<PathBuf, String> {
if let Ok(raw_resource_dir) = app.path().resource_dir() { if let Ok(raw_resource_dir) = app.path().resource_dir() {
let resource_dir = dunce::simplified(&raw_resource_dir).to_path_buf(); let resource_dir = dunce::simplified(&raw_resource_dir).to_path_buf();
// Tauri's bundle.resources preserves the source directory structure, so if let Some(candidate) = bundled_runtime_root_from_resource_dir(&resource_dir) {
// "resources/**/*" in tauri.conf.json places files at log::info!("[runtime] found bundled runtime at {}", candidate.display());
// $RESOURCE/resources/managed-runtime/. Try the nested path first (installed return Ok(candidate);
// builds), then the flat path for any future config changes.
for candidate in [
resource_dir.join("resources").join("managed-runtime"),
resource_dir.join("managed-runtime"),
] {
if candidate.exists() {
log::info!("[runtime] found bundled runtime at {}", candidate.display());
return Ok(candidate);
}
log::info!("[runtime] no bundled runtime at {}", candidate.display());
} }
} else { } else {
log::info!("[runtime] resource_dir() unavailable, checking dev path"); log::info!("[runtime] resource_dir() unavailable, checking dev path");
@@ -464,131 +417,82 @@ fn shell_single_quote(value: &str) -> String {
format!("'{}'", value.replace('\'', r"'\''")) format!("'{}'", value.replace('\'', r"'\''"))
} }
fn escape_applescript_string(value: &str) -> String {
value.replace('\\', r"\\").replace('"', "\\\"")
}
#[cfg(windows)] #[cfg(windows)]
fn powershell_double_quote(value: &str) -> String { fn powershell_double_quote(value: &str) -> String {
value.replace('`', "``").replace('"', "`\"") value.replace('`', "``").replace('"', "`\"")
} }
fn outer_cli_shim_path() -> Result<PathBuf, String> { fn outer_cli_link_path() -> Result<PathBuf, String> {
#[cfg(target_os = "macos")] #[cfg(target_os = "macos")]
{ {
return Ok(PathBuf::from("/usr/local/bin").join(CLI_SHIM_NAME)); return Ok(PathBuf::from("/usr/local/bin").join(CLI_LINK_NAME));
} }
#[cfg(all(not(target_os = "macos"), not(windows)))] #[cfg(all(not(target_os = "macos"), not(windows)))]
{ {
return Ok(PathBuf::from("/usr/local/bin").join(CLI_SHIM_NAME)); return Ok(PathBuf::from("/usr/local/bin").join(CLI_LINK_NAME));
} }
#[cfg(windows)] #[cfg(windows)]
{ {
let local_app_data = dirs::data_local_dir().ok_or_else(|| { let local_app_data = dirs::data_local_dir().ok_or_else(|| {
"Failed to resolve LocalAppData for CLI shim instructions.".to_string() "Failed to resolve LocalAppData for CLI symlink instructions.".to_string()
})?; })?;
Ok(local_app_data Ok(local_app_data
.join("Microsoft") .join("Microsoft")
.join("WinGet") .join("WinGet")
.join("Links") .join("Links")
.join(CLI_SHIM_WINDOWS_NAME)) .join(CLI_LINK_WINDOWS_NAME))
} }
} }
fn cli_shim_contents(runtime_root: &Path, manifest: &ManagedRuntimeManifest) -> String { fn desktop_cli_source_path() -> Result<PathBuf, String> {
let node = runtime_root.join(&manifest.node_relative_path); let current_exe = std::env::current_exe()
let cli = runtime_root.join(&manifest.cli_entrypoint_relative_path); .map_err(|error| format!("Failed to resolve desktop executable path: {error}"))?;
Ok(dunce::simplified(&current_exe).to_path_buf())
}
fn cli_symlink_instructions_internal() -> Result<CliSymlinkInstructions, String> {
let outer_link = outer_cli_link_path()?;
#[cfg(windows)] #[cfg(windows)]
{ {
return format!( let desktop_executable = desktop_cli_source_path()?;
"@echo off\r\n\"{}\" \"{}\" %*\r\n", let target_dir = outer_link
node.display(),
cli.display()
);
}
#[cfg(not(windows))]
{
format!(
"#!/bin/sh\nexec \"{}\" \"{}\" \"$@\"\n",
node.display(),
cli.display()
)
}
}
fn write_cli_launcher(path: &Path, contents: &str) -> Result<(), String> {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)
.map_err(|error| format!("Failed to create {}: {error}", parent.display()))?;
}
fs::write(path, contents)
.map_err(|error| format!("Failed to write {}: {error}", path.display()))?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
fs::set_permissions(path, fs::Permissions::from_mode(0o755))
.map_err(|error| format!("Failed to chmod {}: {error}", path.display()))?;
}
Ok(())
}
fn cli_manual_instructions(
runtime_root: &Path,
manifest: &ManagedRuntimeManifest,
) -> Result<CliManualInstructions, String> {
let outer_shim = outer_cli_shim_path()?;
let contents = cli_shim_contents(runtime_root, manifest);
#[cfg(windows)]
{
let target_dir = outer_shim
.parent() .parent()
.ok_or_else(|| "CLI shim target is missing a parent directory.".to_string())?; .ok_or_else(|| "CLI symlink target is missing a parent directory.".to_string())?;
return Ok(CliManualInstructions { return Ok(CliSymlinkInstructions {
title: "Install the Paseo CLI from PowerShell".to_string(), title: "Add paseo to your shell".to_string(),
detail: "If the automatic install does not complete, run these commands in PowerShell to write the PATH shim manually.".to_string(), detail: "Create a symlink to the Paseo desktop executable.".to_string(),
commands: format!( commands: format!(
"$target = \"{}\"\nNew-Item -ItemType Directory -Force -Path \"{}\" | Out-Null\n$contents = @\"\n{}\"@\nSet-Content -Path $target -Value $contents -Encoding ASCII\n", "$target = \"{}\"\nNew-Item -ItemType Directory -Force -Path \"{}\" | Out-Null\nif (Test-Path $target) {{ Remove-Item -Path $target -Force }}\nNew-Item -ItemType SymbolicLink -Path $target -Target \"{}\" | Out-Null\n",
powershell_double_quote(outer_shim.to_string_lossy().as_ref()), powershell_double_quote(outer_link.to_string_lossy().as_ref()),
powershell_double_quote(target_dir.to_string_lossy().as_ref()), powershell_double_quote(target_dir.to_string_lossy().as_ref()),
contents powershell_double_quote(desktop_executable.to_string_lossy().as_ref())
), ),
}); });
} }
#[cfg(not(windows))] #[cfg(not(windows))]
{ {
Ok(CliManualInstructions { let desktop_executable = desktop_cli_source_path()?;
title: "Install the Paseo CLI from Terminal".to_string(), Ok(CliSymlinkInstructions {
detail: "If the automatic install does not complete, run these commands in Terminal to write the global PATH shim manually.".to_string(), title: "Add paseo to your shell".to_string(),
detail: "Create a symlink to the Paseo desktop executable.".to_string(),
commands: format!( commands: format!(
"sudo mkdir -p {target_dir}\nprintf '%s\\n' {lines} | sudo tee {target_path} >/dev/null\nsudo chmod 755 {target_path}\n", "sudo mkdir -p {target_dir}\nsudo ln -sf {source_path} {target_path}\n",
target_dir = shell_single_quote( target_dir = shell_single_quote(
outer_shim outer_link
.parent() .parent()
.ok_or_else(|| "CLI shim target is missing a parent directory.".to_string())? .ok_or_else(
|| "CLI symlink target is missing a parent directory.".to_string()
)?
.to_string_lossy() .to_string_lossy()
.as_ref() .as_ref()
), ),
target_path = shell_single_quote(outer_shim.to_string_lossy().as_ref()), target_path = shell_single_quote(outer_link.to_string_lossy().as_ref()),
lines = contents source_path = shell_single_quote(desktop_executable.to_string_lossy().as_ref())
.lines()
.map(|line| shell_single_quote(line))
.collect::<Vec<_>>()
.join(" ")
), ),
}) })
} }
} }
fn detect_installed_cli_shim_path(
runtime_root: &Path,
manifest: &ManagedRuntimeManifest,
) -> Option<String> {
let outer_shim = outer_cli_shim_path().ok()?;
let expected = cli_shim_contents(runtime_root, manifest);
let actual = fs::read_to_string(&outer_shim).ok()?;
(actual == expected).then(|| outer_shim.to_string_lossy().into_owned())
}
fn ensure_runtime_ready_internal(app: &AppHandle) -> Result<ManagedRuntimeStatus, String> { fn ensure_runtime_ready_internal(app: &AppHandle) -> Result<ManagedRuntimeStatus, String> {
log::info!("[runtime] ensuring runtime is ready"); log::info!("[runtime] ensuring runtime is ready");
let (bundled_root, pointer) = load_bundled_runtime_pointer(app)?; let (bundled_root, pointer) = load_bundled_runtime_pointer(app)?;
@@ -645,6 +549,19 @@ fn run_cli_json_command(
}) })
} }
fn run_cli_passthrough_command(
runtime_root: &Path,
manifest: &ManagedRuntimeManifest,
args: &[String],
) -> Result<i32, String> {
log::info!("[cli] passthrough: {:?}", args);
let status = cli_command(runtime_root, manifest, &[])?
.args(args)
.status()
.map_err(|error| format!("Failed to run bundled CLI: {error}"))?;
Ok(status.code().unwrap_or(1))
}
fn managed_daemon_status_internal(app: &AppHandle) -> Result<ManagedDaemonStatus, String> { fn managed_daemon_status_internal(app: &AppHandle) -> Result<ManagedDaemonStatus, String> {
let status = ensure_runtime_ready_internal(app)?; let status = ensure_runtime_ready_internal(app)?;
let runtime_root = PathBuf::from(&status.runtime_root); let runtime_root = PathBuf::from(&status.runtime_root);
@@ -665,163 +582,6 @@ fn managed_daemon_status_internal(app: &AppHandle) -> Result<ManagedDaemonStatus
}) })
} }
fn cli_shim_status_internal(app: &AppHandle) -> Result<CliShimStatus, String> {
let status = ensure_runtime_ready_internal(app)?;
let runtime_root = PathBuf::from(&status.runtime_root);
let manifest = load_runtime_manifest(&runtime_root)?;
Ok(CliShimStatus {
path: detect_installed_cli_shim_path(&runtime_root, &manifest),
})
}
#[cfg(target_os = "macos")]
fn install_cli_shim_via_macos_prompt(shim_path: &Path, contents: &str) -> Result<(), String> {
// Manual smoke path: click Install CLI in the desktop app on macOS and confirm the
// /usr/local/bin/paseo prompt writes the trivial outer shim shown in cli_manual_instructions().
let temp_path = std::env::temp_dir().join(format!("paseo-cli-shim-{}", std::process::id()));
write_cli_launcher(&temp_path, contents)?;
let command = format!(
"mkdir -p {target_dir} && install -m 755 {temp_path} {target_path}",
target_dir = shell_single_quote(
shim_path
.parent()
.ok_or_else(|| "CLI shim target is missing a parent directory.".to_string())?
.to_string_lossy()
.as_ref()
),
temp_path = shell_single_quote(temp_path.to_string_lossy().as_ref()),
target_path = shell_single_quote(shim_path.to_string_lossy().as_ref())
);
let output = Command::new("osascript")
.arg("-e")
.arg(format!(
"do shell script \"{}\" with administrator privileges",
escape_applescript_string(&command)
))
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.output()
.map_err(|error| format!("Failed to request macOS CLI install privileges: {error}"))?;
let _ = fs::remove_file(&temp_path);
if output.status.success() {
return Ok(());
}
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
if stderr.contains("User canceled") || stderr.contains("(-128)") {
return Err("ELEVATION_DENIED".to_string());
}
Err(if stderr.is_empty() {
"Failed to install the global CLI shim.".to_string()
} else {
stderr
})
}
#[cfg(target_os = "macos")]
fn remove_cli_shim_via_macos_prompt(shim_path: &Path) -> Result<(), String> {
let command = format!(
"rm -f {}",
shell_single_quote(shim_path.to_string_lossy().as_ref())
);
let output = Command::new("osascript")
.arg("-e")
.arg(format!(
"do shell script \"{}\" with administrator privileges",
escape_applescript_string(&command)
))
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.output()
.map_err(|error| format!("Failed to request macOS CLI uninstall privileges: {error}"))?;
if output.status.success() {
return Ok(());
}
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
Err(if stderr.is_empty() {
"Failed to remove the global CLI shim.".to_string()
} else {
stderr
})
}
fn install_cli_shim_internal(app: &AppHandle) -> Result<CliShimResult, String> {
log::info!("[cli-shim] installing CLI shim");
let status = ensure_runtime_ready_internal(app)?;
let runtime_root = PathBuf::from(&status.runtime_root);
let manifest = load_runtime_manifest(&runtime_root)?;
let shim_path = outer_cli_shim_path()?;
let shim_contents = cli_shim_contents(&runtime_root, &manifest);
let manual_instructions = cli_manual_instructions(&runtime_root, &manifest)?;
#[cfg(target_os = "macos")]
let install_result = install_cli_shim_via_macos_prompt(&shim_path, &shim_contents);
#[cfg(all(not(target_os = "macos"), not(windows)))]
let install_result = write_cli_launcher(&shim_path, &shim_contents);
#[cfg(windows)]
let install_result: Result<(), String> = Err("AUTOMATIC_INSTALL_UNAVAILABLE".to_string());
log::info!("[cli-shim] target path: {}", shim_path.display());
match install_result {
Ok(()) => Ok(CliShimResult {
status: "installed".to_string(),
installed: true,
path: Some(shim_path.to_string_lossy().into_owned()),
message: format!("Paseo CLI installed at {}.", shim_path.display()),
manual_instructions: None,
}),
Err(error) if error == "ELEVATION_DENIED" => Ok(CliShimResult {
status: "elevationDenied".to_string(),
installed: false,
path: Some(shim_path.to_string_lossy().into_owned()),
message: "CLI install needs administrator approval. If you dismissed the prompt, install it from Terminal with the commands below.".to_string(),
manual_instructions: Some(manual_instructions),
}),
Err(error) if error == "AUTOMATIC_INSTALL_UNAVAILABLE" => Ok(CliShimResult {
status: "automaticInstallUnavailable".to_string(),
installed: false,
path: Some(shim_path.to_string_lossy().into_owned()),
message: "Automatic CLI install is not available on this platform. Finish the install manually with the commands below.".to_string(),
manual_instructions: Some(manual_instructions),
}),
Err(error) => Ok(CliShimResult {
status: "manualInstallRequired".to_string(),
installed: false,
path: Some(shim_path.to_string_lossy().into_owned()),
message: format!(
"Automatic CLI install did not complete: {error}. Finish the install manually with the commands below."
),
manual_instructions: Some(manual_instructions),
}),
}
}
fn uninstall_cli_shim_internal(app: &AppHandle) -> Result<CliShimResult, String> {
let status = ensure_runtime_ready_internal(app)?;
let runtime_root = PathBuf::from(&status.runtime_root);
let manifest = load_runtime_manifest(&runtime_root)?;
let shim_path = detect_installed_cli_shim_path(&runtime_root, &manifest)
.map(PathBuf::from)
.unwrap_or(outer_cli_shim_path()?);
if shim_path.exists() {
#[cfg(target_os = "macos")]
remove_cli_shim_via_macos_prompt(&shim_path)?;
#[cfg(all(not(target_os = "macos"), not(windows)))]
fs::remove_file(&shim_path)
.map_err(|error| format!("Failed to remove {}: {error}", shim_path.display()))?;
#[cfg(windows)]
fs::remove_file(&shim_path)
.map_err(|error| format!("Failed to remove {}: {error}", shim_path.display()))?;
}
Ok(CliShimResult {
status: "removed".to_string(),
installed: false,
path: Some(shim_path.to_string_lossy().into_owned()),
message: "Paseo CLI shim removed.".to_string(),
manual_instructions: None,
})
}
fn start_managed_daemon_internal(app: &AppHandle) -> Result<ManagedDaemonStatus, String> { fn start_managed_daemon_internal(app: &AppHandle) -> Result<ManagedDaemonStatus, String> {
log::info!("[daemon] starting managed daemon"); log::info!("[daemon] starting managed daemon");
let status = ensure_runtime_ready_internal(app)?; let status = ensure_runtime_ready_internal(app)?;
@@ -969,10 +729,13 @@ pub async fn restart_managed_daemon(app: AppHandle) -> Result<ManagedDaemonStatu
} }
#[tauri::command] #[tauri::command]
pub async fn cli_shim_status(app: AppHandle) -> Result<CliShimStatus, String> { pub async fn cli_symlink_instructions(app: AppHandle) -> Result<CliSymlinkInstructions, String> {
tauri::async_runtime::spawn_blocking(move || cli_shim_status_internal(&app)) tauri::async_runtime::spawn_blocking(move || {
.await let _ = app;
.map_err(|error| format!("CLI shim status task failed: {error}"))? cli_symlink_instructions_internal()
})
.await
.map_err(|error| format!("CLI symlink instructions task failed: {error}"))?
} }
#[tauri::command] #[tauri::command]
@@ -1009,20 +772,6 @@ pub async fn managed_daemon_pairing(app: AppHandle) -> Result<ManagedPairingOffe
.map_err(|error| format!("Managed daemon pairing task failed: {error}"))? .map_err(|error| format!("Managed daemon pairing task failed: {error}"))?
} }
#[tauri::command]
pub async fn install_cli_shim(app: AppHandle) -> Result<CliShimResult, String> {
tauri::async_runtime::spawn_blocking(move || install_cli_shim_internal(&app))
.await
.map_err(|error| format!("CLI shim install task failed: {error}"))?
}
#[tauri::command]
pub async fn uninstall_cli_shim(app: AppHandle) -> Result<CliShimResult, String> {
tauri::async_runtime::spawn_blocking(move || uninstall_cli_shim_internal(&app))
.await
.map_err(|error| format!("CLI shim uninstall task failed: {error}"))?
}
#[tauri::command] #[tauri::command]
pub async fn update_managed_daemon_tcp_settings( pub async fn update_managed_daemon_tcp_settings(
app: AppHandle, app: AppHandle,
@@ -1035,6 +784,35 @@ pub async fn update_managed_daemon_tcp_settings(
.map_err(|error| format!("Managed daemon TCP settings task failed: {error}"))? .map_err(|error| format!("Managed daemon TCP settings task failed: {error}"))?
} }
pub fn run_managed_cli_from_current_process(args: Vec<String>) -> Result<i32, String> {
let current_exe = std::env::current_exe()
.map_err(|error| format!("Failed to resolve desktop executable path: {error}"))?;
let current_exe = dunce::simplified(&current_exe).to_path_buf();
let exe_dir = current_exe
.parent()
.ok_or_else(|| "Desktop executable path is missing a parent directory.".to_string())?;
let mut resource_dirs = vec![exe_dir.join("resources"), exe_dir.to_path_buf()];
if let Some(contents_dir) = exe_dir.parent() {
resource_dirs.push(contents_dir.join("Resources"));
resource_dirs.push(contents_dir.join("resources"));
}
let bundled_root = resource_dirs
.into_iter()
.find_map(|resource_dir| bundled_runtime_root_from_resource_dir(&resource_dir))
.or_else(|| {
let dev = dev_resource_root().join("managed-runtime");
dev.exists().then_some(dev)
})
.ok_or_else(|| {
"Managed runtime resources are not bundled with this desktop build.".to_string()
})?;
let pointer =
read_json_file::<BundledRuntimePointer>(&bundled_root.join("current-runtime.json"))?;
let runtime_root = bundled_root.join(pointer.relative_root);
let manifest = load_runtime_manifest(&runtime_root)?;
run_cli_passthrough_command(&runtime_root, &manifest, &args)
}
async fn spawn_local_transport_session<S>( async fn spawn_local_transport_session<S>(
app: AppHandle, app: AppHandle,
transport_state: State<'_, LocalTransportState>, transport_state: State<'_, LocalTransportState>,
@@ -37,6 +37,11 @@
"resources/**/*" "resources/**/*"
], ],
"targets": "all", "targets": "all",
"windows": {
"nsis": {
"installerHooks": "./installer-hooks.nsh"
}
},
"icon": [ "icon": [
"icons/32x32.png", "icons/32x32.png",
"icons/128x128.png", "icons/128x128.png",
+1 -1
View File
@@ -96,7 +96,7 @@ function Home() {
transition={{ duration: 0.8, delay: 0.5, ease: 'easeOut' }} transition={{ duration: 0.8, delay: 0.5, ease: 'easeOut' }}
className="relative px-6 md:px-8 pb-8 md:pb-16" className="relative px-6 md:px-8 pb-8 md:pb-16"
> >
<div className="max-w-6xl mx-auto"> <div className="max-w-6xl lg:max-w-7xl xl:max-w-[90rem] mx-auto">
<img <img
src="/paseo-mockup.png" src="/paseo-mockup.png"
alt="Paseo app showing agent management interface" alt="Paseo app showing agent management interface"