From 586b48e150490c64997b8962fbe3ba0f0efc67d3 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Wed, 11 Mar 2026 23:13:36 +0700 Subject: [PATCH] Update files --- .../components/desktop-updates-section.tsx | 97 ++-- .../managed-runtime/managed-runtime.test.ts | 50 +- .../managed-runtime/managed-runtime.ts | 61 +-- .../app/src/screens/startup-splash-screen.tsx | 19 +- .../desktop/scripts/managed-daemon-smoke.mjs | 270 +++++------ .../desktop/src-tauri/installer-hooks.nsh | 11 + packages/desktop/src-tauri/src/lib.rs | 199 +++----- packages/desktop/src-tauri/src/main.rs | 3 + .../desktop/src-tauri/src/runtime_manager.rs | 440 +++++------------- packages/desktop/src-tauri/tauri.conf.json | 5 + packages/website/src/routes/index.tsx | 2 +- 11 files changed, 396 insertions(+), 761 deletions(-) create mode 100644 packages/desktop/src-tauri/installer-hooks.nsh diff --git a/packages/app/src/desktop/components/desktop-updates-section.tsx b/packages/app/src/desktop/components/desktop-updates-section.tsx index 5e7a85e29..898f1ef89 100644 --- a/packages/app/src/desktop/components/desktop-updates-section.tsx +++ b/packages/app/src/desktop/components/desktop-updates-section.tsx @@ -22,21 +22,18 @@ import { confirmDialog } from '@/utils/confirm-dialog' import { openExternalUrl } from '@/utils/open-external-url' import { formatVersionWithPrefix, isVersionMismatch } from '@/desktop/updates/desktop-updates' import { - getCliShimStatus, + getCliSymlinkInstructions, getManagedDaemonLogs, getManagedDaemonPairing, getManagedDaemonStatus, - installManagedCliShim, restartManagedDaemon, shouldUseManagedDesktopDaemon, startManagedDaemon, stopManagedDaemon, - uninstallManagedCliShim, + type CliSymlinkInstructions, type ManagedDaemonLogs, type ManagedPairingOffer, type ManagedDaemonStatus, - type CliManualInstructions, - type CliShimStatus, } from '@/desktop/managed-runtime/managed-runtime' export interface LocalDaemonSectionProps { @@ -51,29 +48,27 @@ export function LocalDaemonSection({ appVersion }: LocalDaemonSectionProps) { const [statusError, setStatusError] = useState(null) const [isRestartingDaemon, setIsRestartingDaemon] = useState(false) const [isUpdatingDaemonManagement, setIsUpdatingDaemonManagement] = useState(false) - const [isInstallingCli, setIsInstallingCli] = useState(false) + const [isLoadingCliSymlinkInstructions, setIsLoadingCliSymlinkInstructions] = useState(false) const [statusMessage, setStatusMessage] = useState(null) const [cliStatusMessage, setCliStatusMessage] = useState(null) const [managedLogs, setManagedLogs] = useState(null) - const [cliShimStatus, setCliShimStatus] = useState(null) const [isLogsModalOpen, setIsLogsModalOpen] = useState(false) const [isPairingModalOpen, setIsPairingModalOpen] = useState(false) - const [isCliInstallModalOpen, setIsCliInstallModalOpen] = useState(false) + const [isCliSymlinkModalOpen, setIsCliSymlinkModalOpen] = useState(false) const [isLoadingPairing, setIsLoadingPairing] = useState(false) const [pairingOffer, setPairingOffer] = useState(null) - const [cliInstallInstructions, setCliInstallInstructions] = - useState(null) + const [cliSymlinkInstructions, setCliSymlinkInstructions] = + useState(null) const [pairingStatusMessage, setPairingStatusMessage] = useState(null) const loadManagedStatus = useCallback(() => { if (!showSection) { return Promise.resolve() } - return Promise.all([getManagedDaemonStatus(), getManagedDaemonLogs(), getCliShimStatus()]) - .then(([status, logs, shimStatus]) => { + return Promise.all([getManagedDaemonStatus(), getManagedDaemonLogs()]) + .then(([status, logs]) => { setManagedStatus(status) setManagedLogs(logs) - setCliShimStatus(shimStatus) setStatusError(null) }) .catch((error) => { @@ -224,50 +219,39 @@ export function LocalDaemonSection({ appVersion }: LocalDaemonSectionProps) { updateSettings, ]) - const handleToggleCliShim = useCallback(() => { - if (!showSection || isInstallingCli) { + const handleOpenCliSymlinkInstructions = useCallback(() => { + if (!showSection || isLoadingCliSymlinkInstructions) { return } - setIsInstallingCli(true) - const isInstalling = !cliShimStatus?.path - setCliStatusMessage( - isInstalling ? 'A permissions popup may appear while Paseo installs the CLI globally.' : null - ) - const action = cliShimStatus?.path ? uninstallManagedCliShim : installManagedCliShim - void action() - .then((result) => { - setCliStatusMessage(result.message) - if (result.manualInstructions) { - setCliInstallInstructions(result.manualInstructions) - setIsCliInstallModalOpen(true) - } else { - setCliInstallInstructions(null) - setIsCliInstallModalOpen(false) - } - return loadManagedStatus() + setIsLoadingCliSymlinkInstructions(true) + setCliStatusMessage(null) + void getCliSymlinkInstructions() + .then((instructions) => { + setCliSymlinkInstructions(instructions) + setIsCliSymlinkModalOpen(true) }) .catch((error) => { const message = error instanceof Error ? error.message : String(error) - setCliStatusMessage(`CLI install failed: ${message}`) + setCliStatusMessage(`Unable to load CLI symlink instructions: ${message}`) }) .finally(() => { - setIsInstallingCli(false) + setIsLoadingCliSymlinkInstructions(false) }) - }, [cliShimStatus?.path, isInstallingCli, loadManagedStatus, showSection]) + }, [isLoadingCliSymlinkInstructions, showSection]) - const handleCopyCliInstallCommands = useCallback(() => { - if (!cliInstallInstructions?.commands) { + const handleCopyCliSymlinkCommands = useCallback(() => { + if (!cliSymlinkInstructions?.commands) { return } - void Clipboard.setStringAsync(cliInstallInstructions.commands) + void Clipboard.setStringAsync(cliSymlinkInstructions.commands) .then(() => { - Alert.alert('Copied', 'CLI install commands copied.') + Alert.alert('Copied', 'CLI symlink commands copied.') }) .catch((error) => { - console.error('[Settings] Failed to copy CLI install commands', error) - Alert.alert('Error', 'Unable to copy CLI install commands.') + console.error('[Settings] Failed to copy CLI symlink commands', error) + Alert.alert('Error', 'Unable to copy CLI symlink commands.') }) - }, [cliInstallInstructions?.commands]) + }, [cliSymlinkInstructions?.commands]) const handleCopyLogPath = useCallback(() => { const logPath = managedLogs?.logPath @@ -417,17 +401,17 @@ export function LocalDaemonSection({ appVersion }: LocalDaemonSectionProps) { Command line (CLI) - Adds the `paseo` command to your terminal. + Shows the command to add `paseo` to your terminal. {cliStatusMessage ? {cliStatusMessage} : null} @@ -483,27 +467,26 @@ export function LocalDaemonSection({ appVersion }: LocalDaemonSectionProps) { ) : null} setIsCliInstallModalOpen(false)} - title="Install CLI manually" - testID="managed-daemon-cli-install-dialog" + visible={isCliSymlinkModalOpen} + onClose={() => setIsCliSymlinkModalOpen(false)} + title="Add paseo to your shell" + testID="managed-daemon-cli-symlink-dialog" > - A permissions popup should appear when Paseo installs the CLI globally. If it does not - complete, open a terminal and run the commands below. + Paseo does not add the command for you. Run the command below in your terminal. - {cliInstallInstructions?.detail ? ( - {cliInstallInstructions.detail} + {cliSymlinkInstructions?.detail ? ( + {cliSymlinkInstructions.detail} ) : null} - {cliInstallInstructions?.commands ?? ''} + {cliSymlinkInstructions?.commands ?? ''} - - diff --git a/packages/app/src/desktop/managed-runtime/managed-runtime.test.ts b/packages/app/src/desktop/managed-runtime/managed-runtime.test.ts index e505e87df..bb64d8771 100644 --- a/packages/app/src/desktop/managed-runtime/managed-runtime.test.ts +++ b/packages/app/src/desktop/managed-runtime/managed-runtime.test.ts @@ -1,46 +1,24 @@ import { describe, expect, it } from "vitest"; -import { parseCliShimResult } from "./managed-runtime"; +import { parseCliSymlinkInstructions } from "./managed-runtime"; -describe("parseCliShimResult", () => { - it("parses manual install payloads from the desktop backend", () => { +describe("parseCliSymlinkInstructions", () => { + it("parses CLI symlink instructions 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", - }, + parseCliSymlinkInstructions({ + title: "Add paseo to your shell", + detail: "Create a symlink to the Paseo desktop executable.", + commands: "sudo ln -sf /Applications/Paseo.app/Contents/MacOS/Paseo /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", - }, + title: "Add paseo to your shell", + detail: "Create a symlink to the Paseo desktop executable.", + commands: "sudo ln -sf /Applications/Paseo.app/Contents/MacOS/Paseo /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, - }); + it("rejects non-object payloads", () => { + expect(() => parseCliSymlinkInstructions(null)).toThrow( + "Unexpected CLI symlink instructions response." + ); }); }); diff --git a/packages/app/src/desktop/managed-runtime/managed-runtime.ts b/packages/app/src/desktop/managed-runtime/managed-runtime.ts index de0aeeb22..49cb0739e 100644 --- a/packages/app/src/desktop/managed-runtime/managed-runtime.ts +++ b/packages/app/src/desktop/managed-runtime/managed-runtime.ts @@ -23,30 +23,13 @@ export type ManagedDaemonLogs = { contents: string } -export type CliShimStatus = { - path: string | null -} - 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 = { +export type CliSymlinkInstructions = { title: string detail: 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 { if (!isRecord(raw)) { 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)) { 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 { return isTauriEnvironment() && getTauri() !== null } @@ -196,20 +155,20 @@ export async function getManagedDaemonLogs(): Promise { return parseManagedDaemonLogs(await invokeDesktopCommand('managed_daemon_logs')) } -export async function getCliShimStatus(): Promise { - return parseCliShimStatus(await invokeDesktopCommand('cli_shim_status')) -} - export async function getManagedDaemonPairing(): Promise { return parseManagedPairingOffer(await invokeDesktopCommand('managed_daemon_pairing')) } -export async function installManagedCliShim(): Promise { - return parseCliShimResult(await invokeDesktopCommand('install_cli_shim')) +export function parseCliSymlinkInstructions(raw: unknown): CliSymlinkInstructions { + const instructions = parseCliSymlinkInstructionsInternal(raw) + if (!instructions) { + throw new Error('Unexpected CLI symlink instructions response.') + } + return instructions } -export async function uninstallManagedCliShim(): Promise { - return parseCliShimResult(await invokeDesktopCommand('uninstall_cli_shim')) +export async function getCliSymlinkInstructions(): Promise { + return parseCliSymlinkInstructions(await invokeDesktopCommand('cli_symlink_instructions')) } export async function updateManagedDaemonTcpSettings( diff --git a/packages/app/src/screens/startup-splash-screen.tsx b/packages/app/src/screens/startup-splash-screen.tsx index 4cc39b4a5..cdd5077e2 100644 --- a/packages/app/src/screens/startup-splash-screen.tsx +++ b/packages/app/src/screens/startup-splash-screen.tsx @@ -1,5 +1,7 @@ -import { Image, Text, View } from "react-native"; +import { Text, View } from "react-native"; import { StyleSheet } from "react-native-unistyles"; +import { PaseoLogo } from "@/components/icons/paseo-logo"; +import { useTauriDragHandlers } from "@/utils/tauri-window"; const styles = StyleSheet.create((theme) => ({ container: { @@ -8,11 +10,6 @@ const styles = StyleSheet.create((theme) => ({ alignItems: "center", backgroundColor: theme.colors.surface0, }, - logo: { - width: 96, - height: 96, - marginBottom: theme.spacing[6], - }, status: { color: theme.colors.foregroundMuted, fontSize: theme.fontSize.base, @@ -20,13 +17,11 @@ const styles = StyleSheet.create((theme) => ({ })); export function StartupSplashScreen() { + const dragHandlers = useTauriDragHandlers(); + return ( - - + + Starting up… ); diff --git a/packages/desktop/scripts/managed-daemon-smoke.mjs b/packages/desktop/scripts/managed-daemon-smoke.mjs index 917110ba9..73827c17a 100644 --- a/packages/desktop/scripts/managed-daemon-smoke.mjs +++ b/packages/desktop/scripts/managed-daemon-smoke.mjs @@ -228,6 +228,50 @@ async function runBundledRuntimeCli(runtimeRoot, managedHome, args, env) { 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) { const result = await runWorkspaceCli(["daemon", "status", "--home", home, "--json"], env); return result.json ?? {}; @@ -464,10 +508,6 @@ function logStep(label) { console.log(`\n[managed-smoke] ${label}`); } -function shouldAttemptCliShimInstall(env) { - return !(process.platform === "darwin" && env.CI === "true"); -} - const packagedBinary = resolvePackagedBinary(); await ensurePackagedArtifact(packagedBinary); if (!(await pathExists(packagedBinary))) { @@ -527,6 +567,8 @@ const managedEnv = { let externalPid = null; let startedTemporaryExternalDaemon = false; let relayProcess = null; +let runtimeStatus = null; +let managedStart = null; const forbiddenManagedReferences = ["127.0.0.1:6767", fakePaseoHome, managedRuntimeDir]; const npmExecPath = process.env.npm_execpath; if (!npmExecPath) { @@ -584,35 +626,42 @@ try { 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); + logStep("Resolving bundled runtime from packaged desktop artifact"); + runtimeStatus = await resolvePackagedRuntimeStatus(packagedBinary); + assert.equal(runtimeStatus.runtimeId, currentRuntimeId); + assert.equal(runtimeStatus.runtimeVersion, currentRuntimeVersion); assert.match( - runtimeStatus.json.runtimeRoot, + runtimeStatus.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), + runtimeStatus.runtimeRoot.startsWith(testRoot), false, "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"); - const managedBootstrap = await runBinary(packagedBinary, ["--managed-headless", "bootstrap"], managedEnv); - const managedStart = managedBootstrap.json ?? await waitFor( + const managedBootstrap = await runBundledRuntimeCli( + runtimeStatus.runtimeRoot, + cliScratchHome, + ["start", "--json"], + managedEnv + ); + managedStart = (managedBootstrap.stdout.trim() ? JSON.parse(managedBootstrap.stdout.trim()) : null) ?? await waitFor( async () => { - const status = await runBinary( - packagedBinary, - ["--managed-headless", "daemon-status"], + const status = await runBundledRuntimeCli( + runtimeStatus.runtimeRoot, + cliScratchHome, + ["daemon", "status", "--json"], managedEnv ); - assert.equal(status.json?.status, "running"); - assert.ok(status.json?.pid, "managed daemon pid should exist"); - assert.ok(status.json?.serverId, "managed daemon should expose a server id"); - return status.json; + const json = JSON.parse(status.stdout.trim()); + assert.equal(json.status, "running"); + assert.ok(json.pid, "managed daemon pid should exist"); + assert.ok(json.serverId, "managed daemon should expose a server id"); + return json; }, 10_000, "managed daemon bootstrap status" @@ -629,111 +678,64 @@ try { 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.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, + const persistedManagedStatus = JSON.parse( + ( + await runBundledRuntimeCli( + runtimeStatus.runtimeRoot, managedStart.home, ["daemon", "status", "--json"], managedEnv - ); - const shimDaemonStatus = JSON.parse(shimStatus.stdout.trim()); - assert.equal(shimDaemonStatus.pid, managedPid); - assertNoForbiddenPathsOrPorts(shimDaemonStatus, forbiddenManagedReferences); + ) + ).stdout.trim() + ); + 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"); - const relayPairing = cliShimInstalled - ? await execFileWithTimeout( - cliShimPath, - ["daemon", "pair"], - { - env: managedEnv, - cwd: repoRoot, - maxBuffer: 4 * 1024 * 1024, - }, - "installed CLI shim relay pairing" - ) - : await runBundledRuntimeCli( - runtimeStatus.json.runtimeRoot, - managedStart.home, - ["daemon", "pair"], - managedEnv - ); + const relayPairing = await runBundledRuntimeCli( + runtimeStatus.runtimeRoot, + managedStart.home, + ["daemon", "pair"], + 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 + logStep("Re-running managed start without spawning duplicate daemons"); + const managedRestartless = JSON.parse( + ( + await runBundledRuntimeCli( + 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"); const externalStatusAfter = await readDaemonStatus(externalHome, managedEnv); @@ -741,13 +743,18 @@ try { 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 + const managedStatus = JSON.parse( + ( + await runBundledRuntimeCli( + runtimeStatus.runtimeRoot, + managedStart.home, + ["daemon", "status", "--json"], + managedEnv + ) + ).stdout.trim() ); - assert.ok(managedStatus.json.serverId, "managed daemon should expose a server id"); - assertNoForbiddenPathsOrPorts(managedStatus.json, forbiddenManagedReferences); + assert.ok(managedStatus.serverId, "managed daemon should expose a server id"); + assertNoForbiddenPathsOrPorts(managedStatus, forbiddenManagedReferences); logStep("Capturing diagnostics and verifying the fake ~/.paseo stayed untouched"); const fakePaseoSnapshotAfter = await snapshotTree(fakePaseoHome); @@ -756,20 +763,19 @@ try { path.join(testRoot, "managed-daemon-smoke-diagnostics.json"), JSON.stringify( { - runtimeStatus: runtimeStatus.json, - managedBootstrap: managedBootstrap.json, + runtimeStatus, + managedBootstrap: managedBootstrap.stdout.trim() ? JSON.parse(managedBootstrap.stdout.trim()) : null, managedStart, - persistedManagedStatus: persistedManagedStatus.json, - managedRestartless: managedRestartless.json, - cliInstall: cliInstall.json, - shimDaemonStatus, + persistedManagedStatus, + managedRestartless, + bundledCliDaemonStatus, relayEndpoint, relayOfferUrl, relayPong, externalEndpoint, externalPid, externalPidAfter, - managedStatus: managedStatus.json, + managedStatus, }, null, 2 @@ -781,11 +787,13 @@ try { } finally { clearInterval(heartbeat); try { - await runBinary(packagedBinary, ["--managed-headless", "stop-daemon"], managedEnv); - } catch {} - try { - if (shouldAttemptCliShimInstall(managedEnv)) { - await runBinary(packagedBinary, ["--managed-headless", "uninstall-cli-shim"], managedEnv); + if (runtimeStatus?.runtimeRoot) { + await runBundledRuntimeCli( + runtimeStatus.runtimeRoot, + managedStart?.home ?? cliScratchHome, + ["daemon", "stop", "--json"], + managedEnv + ); } } catch {} try { diff --git a/packages/desktop/src-tauri/installer-hooks.nsh b/packages/desktop/src-tauri/installer-hooks.nsh new file mode 100644 index 000000000..105239136 --- /dev/null +++ b/packages/desktop/src-tauri/installer-hooks.nsh @@ -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 diff --git a/packages/desktop/src-tauri/src/lib.rs b/packages/desktop/src-tauri/src/lib.rs index 90279009d..3ad2a7807 100644 --- a/packages/desktop/src-tauri/src/lib.rs +++ b/packages/desktop/src-tauri/src/lib.rs @@ -1,6 +1,5 @@ use base64::{engine::general_purpose::STANDARD as BASE64_STANDARD, Engine as _}; use serde::Serialize; -use serde_json::json; use std::collections::HashSet; use std::fs; use std::path::{Path, PathBuf}; @@ -14,11 +13,11 @@ use tauri_plugin_updater::UpdaterExt; mod 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, - 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, + open_local_daemon_transport, restart_managed_daemon, run_managed_cli_from_current_process, + send_local_daemon_transport_message, start_managed_daemon, stop_managed_daemon, + update_managed_daemon_tcp_settings, LocalTransportState, }; // Store zoom as u64 bits (f64 * 100 as integer for atomic ops) @@ -74,145 +73,32 @@ struct AttachmentFileResult { byte_size: u64, } -#[derive(Debug, Clone)] -enum ManagedHeadlessCommand { - RuntimeStatus, - Bootstrap, - DaemonStatus, - StopDaemon, - InstallCliShim, - UninstallCliShim, - UpdateTcp(ManagedTcpSettings), +fn is_ignored_gui_launch_arg(arg: &str) -> bool { + arg.starts_with("-psn_") } -fn parse_managed_headless_command() -> Result, String> { - let args = std::env::args().collect::>(); - 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 +fn parse_cli_passthrough_args_from_argv(args: &[String]) -> Option> { + let effective = args .iter() - .skip(flag_index + 2) + .skip(1) + .filter(|arg| !is_ignored_gui_launch_arg(arg)) .cloned() .collect::>(); - 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::() - .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::() - .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}")), - } + (!effective.is_empty()).then_some(effective) } -fn maybe_run_managed_headless_command(app: &AppHandle) -> Result { - let Some(command) = parse_managed_headless_command()? else { - return Ok(false); +pub fn try_run_pre_tauri_mode() -> Option { + let args = std::env::args().collect::>(); + 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::(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) + Some( + run_managed_cli_from_current_process(cli_args).unwrap_or_else(|error| { + eprintln!("{error}"); + 1 + }), + ) } fn shell_command(script: &str) -> Command { @@ -622,14 +508,12 @@ pub fn run() { .invoke_handler(tauri::generate_handler![ managed_runtime_status, managed_daemon_status, - cli_shim_status, + cli_symlink_instructions, 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, @@ -650,10 +534,6 @@ pub fn run() { app.package_info().version ); - if maybe_run_managed_headless_command(app.handle())? { - return Ok(()); - } - app.handle().plugin( tauri_plugin_log::Builder::default() .level(log::LevelFilter::Info) @@ -780,3 +660,38 @@ pub fn run() { .run(tauri::generate_context!()) .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); + } +} diff --git a/packages/desktop/src-tauri/src/main.rs b/packages/desktop/src-tauri/src/main.rs index 549683263..09b423e9a 100644 --- a/packages/desktop/src-tauri/src/main.rs +++ b/packages/desktop/src-tauri/src/main.rs @@ -8,5 +8,8 @@ tauri::embed_plist::embed_info_plist!("../Info.plist"); fn main() { + if let Some(exit_code) = paseo_lib::try_run_pre_tauri_mode() { + std::process::exit(exit_code); + } paseo_lib::run(); } diff --git a/packages/desktop/src-tauri/src/runtime_manager.rs b/packages/desktop/src-tauri/src/runtime_manager.rs index 423345a6f..7bebb7cc7 100644 --- a/packages/desktop/src-tauri/src/runtime_manager.rs +++ b/packages/desktop/src-tauri/src/runtime_manager.rs @@ -18,9 +18,9 @@ const LOCAL_TRANSPORT_EVENT_NAME: &str = "local-daemon-transport-event"; const UNIX_CLIENT_URL: &str = "ws://localhost/ws"; #[cfg(windows)] const PIPE_CLIENT_URL: &str = "ws://localhost/ws"; -const CLI_SHIM_NAME: &str = "paseo"; +const CLI_LINK_NAME: &str = "paseo"; #[cfg(windows)] -const CLI_SHIM_WINDOWS_NAME: &str = "paseo.cmd"; +const CLI_LINK_WINDOWS_NAME: &str = "paseo.exe"; #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct ManagedRuntimeManifest { @@ -84,12 +84,6 @@ pub struct ManagedDaemonStatus { pub home: String, } -#[derive(Debug, Clone, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct CliShimStatus { - pub path: Option, -} - #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] pub struct ManagedDaemonLogs { @@ -107,17 +101,7 @@ pub struct ManagedPairingOffer { #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] -pub struct CliShimResult { - pub status: String, - pub installed: bool, - pub path: Option, - pub message: String, - pub manual_instructions: Option, -} - -#[derive(Debug, Clone, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct CliManualInstructions { +pub struct CliSymlinkInstructions { pub title: String, pub detail: String, pub commands: String, @@ -272,58 +256,24 @@ mod tests { } #[test] - fn cli_shim_result_serializes_manual_instructions_with_camel_case_keys() { - let value = serde_json::to_value(CliShimResult { - status: "manualInstallRequired".to_string(), - installed: false, - path: Some("/usr/local/bin/paseo".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(), - }), + fn cli_symlink_instructions_serialize_with_camel_case_keys() { + let value = serde_json::to_value(CliSymlinkInstructions { + title: "Add paseo to your shell".to_string(), + detail: "Create a symlink to the Paseo desktop executable.".to_string(), + commands: "sudo ...".to_string(), }) .expect("serializes"); assert_eq!( - value.get("status").and_then(|entry| entry.as_str()), - Some("manualInstallRequired") + value.get("title").and_then(|entry| entry.as_str()), + Some("Add paseo to your shell") ); assert_eq!( - value - .get("manualInstructions") - .and_then(|entry| entry.get("commands")) - .and_then(|entry| entry.as_str()), + value.get("commands").and_then(|entry| entry.as_str()), 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)] #[test] #[ignore = "requires a running local daemon socket"] @@ -366,22 +316,25 @@ fn dev_resource_root() -> PathBuf { PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("resources") } +fn bundled_runtime_root_from_resource_dir(resource_dir: &Path) -> Option { + 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 { if let Ok(raw_resource_dir) = app.path().resource_dir() { let resource_dir = dunce::simplified(&raw_resource_dir).to_path_buf(); - // Tauri's bundle.resources preserves the source directory structure, so - // "resources/**/*" in tauri.conf.json places files at - // $RESOURCE/resources/managed-runtime/. Try the nested path first (installed - // 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()); + if let Some(candidate) = bundled_runtime_root_from_resource_dir(&resource_dir) { + log::info!("[runtime] found bundled runtime at {}", candidate.display()); + return Ok(candidate); } } else { log::info!("[runtime] resource_dir() unavailable, checking dev path"); @@ -464,131 +417,82 @@ fn shell_single_quote(value: &str) -> String { format!("'{}'", value.replace('\'', r"'\''")) } -fn escape_applescript_string(value: &str) -> String { - value.replace('\\', r"\\").replace('"', "\\\"") -} - #[cfg(windows)] fn powershell_double_quote(value: &str) -> String { value.replace('`', "``").replace('"', "`\"") } -fn outer_cli_shim_path() -> Result { +fn outer_cli_link_path() -> Result { #[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)))] { - return Ok(PathBuf::from("/usr/local/bin").join(CLI_SHIM_NAME)); + return Ok(PathBuf::from("/usr/local/bin").join(CLI_LINK_NAME)); } #[cfg(windows)] { 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 .join("Microsoft") .join("WinGet") .join("Links") - .join(CLI_SHIM_WINDOWS_NAME)) + .join(CLI_LINK_WINDOWS_NAME)) } } -fn cli_shim_contents(runtime_root: &Path, manifest: &ManagedRuntimeManifest) -> String { - let node = runtime_root.join(&manifest.node_relative_path); - let cli = runtime_root.join(&manifest.cli_entrypoint_relative_path); +fn desktop_cli_source_path() -> Result { + let current_exe = std::env::current_exe() + .map_err(|error| format!("Failed to resolve desktop executable path: {error}"))?; + Ok(dunce::simplified(¤t_exe).to_path_buf()) +} + +fn cli_symlink_instructions_internal() -> Result { + let outer_link = outer_cli_link_path()?; #[cfg(windows)] { - return format!( - "@echo off\r\n\"{}\" \"{}\" %*\r\n", - 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 { - let outer_shim = outer_cli_shim_path()?; - let contents = cli_shim_contents(runtime_root, manifest); - #[cfg(windows)] - { - let target_dir = outer_shim + let desktop_executable = desktop_cli_source_path()?; + let target_dir = outer_link .parent() - .ok_or_else(|| "CLI shim target is missing a parent directory.".to_string())?; - return Ok(CliManualInstructions { - title: "Install the Paseo CLI from PowerShell".to_string(), - detail: "If the automatic install does not complete, run these commands in PowerShell to write the PATH shim manually.".to_string(), + .ok_or_else(|| "CLI symlink target is missing a parent directory.".to_string())?; + return Ok(CliSymlinkInstructions { + title: "Add paseo to your shell".to_string(), + detail: "Create a symlink to the Paseo desktop executable.".to_string(), commands: format!( - "$target = \"{}\"\nNew-Item -ItemType Directory -Force -Path \"{}\" | Out-Null\n$contents = @\"\n{}\"@\nSet-Content -Path $target -Value $contents -Encoding ASCII\n", - powershell_double_quote(outer_shim.to_string_lossy().as_ref()), + "$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_link.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))] { - Ok(CliManualInstructions { - title: "Install the Paseo CLI from Terminal".to_string(), - detail: "If the automatic install does not complete, run these commands in Terminal to write the global PATH shim manually.".to_string(), + let desktop_executable = desktop_cli_source_path()?; + Ok(CliSymlinkInstructions { + title: "Add paseo to your shell".to_string(), + detail: "Create a symlink to the Paseo desktop executable.".to_string(), 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( - outer_shim + outer_link .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() .as_ref() ), - target_path = shell_single_quote(outer_shim.to_string_lossy().as_ref()), - lines = contents - .lines() - .map(|line| shell_single_quote(line)) - .collect::>() - .join(" ") + target_path = shell_single_quote(outer_link.to_string_lossy().as_ref()), + source_path = shell_single_quote(desktop_executable.to_string_lossy().as_ref()) ), }) } } -fn detect_installed_cli_shim_path( - runtime_root: &Path, - manifest: &ManagedRuntimeManifest, -) -> Option { - 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 { log::info!("[runtime] ensuring runtime is ready"); 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 { + 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 { let status = ensure_runtime_ready_internal(app)?; let runtime_root = PathBuf::from(&status.runtime_root); @@ -665,163 +582,6 @@ fn managed_daemon_status_internal(app: &AppHandle) -> Result Result { - 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 { - 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 { - 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 { log::info!("[daemon] starting managed daemon"); let status = ensure_runtime_ready_internal(app)?; @@ -969,10 +729,13 @@ pub async fn restart_managed_daemon(app: AppHandle) -> Result Result { - tauri::async_runtime::spawn_blocking(move || cli_shim_status_internal(&app)) - .await - .map_err(|error| format!("CLI shim status task failed: {error}"))? +pub async fn cli_symlink_instructions(app: AppHandle) -> Result { + tauri::async_runtime::spawn_blocking(move || { + let _ = app; + cli_symlink_instructions_internal() + }) + .await + .map_err(|error| format!("CLI symlink instructions task failed: {error}"))? } #[tauri::command] @@ -1009,20 +772,6 @@ pub async fn managed_daemon_pairing(app: AppHandle) -> Result Result { - 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 { - tauri::async_runtime::spawn_blocking(move || uninstall_cli_shim_internal(&app)) - .await - .map_err(|error| format!("CLI shim uninstall task failed: {error}"))? -} - #[tauri::command] pub async fn update_managed_daemon_tcp_settings( 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}"))? } +pub fn run_managed_cli_from_current_process(args: Vec) -> Result { + let current_exe = std::env::current_exe() + .map_err(|error| format!("Failed to resolve desktop executable path: {error}"))?; + let current_exe = dunce::simplified(¤t_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::(&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( app: AppHandle, transport_state: State<'_, LocalTransportState>, diff --git a/packages/desktop/src-tauri/tauri.conf.json b/packages/desktop/src-tauri/tauri.conf.json index c9038172c..4b25b8939 100644 --- a/packages/desktop/src-tauri/tauri.conf.json +++ b/packages/desktop/src-tauri/tauri.conf.json @@ -37,6 +37,11 @@ "resources/**/*" ], "targets": "all", + "windows": { + "nsis": { + "installerHooks": "./installer-hooks.nsh" + } + }, "icon": [ "icons/32x32.png", "icons/128x128.png", diff --git a/packages/website/src/routes/index.tsx b/packages/website/src/routes/index.tsx index 5d97191e7..4a93c6479 100644 --- a/packages/website/src/routes/index.tsx +++ b/packages/website/src/routes/index.tsx @@ -96,7 +96,7 @@ function Home() { transition={{ duration: 0.8, delay: 0.5, ease: 'easeOut' }} className="relative px-6 md:px-8 pb-8 md:pb-16" > -
+