Simplify managed runtime to execute in place from app bundle

This commit is contained in:
Mohamed Boudra
2026-03-08 20:48:13 +07:00
parent ca787271b3
commit 2721ce331a
10 changed files with 613 additions and 475 deletions
+81
View File
@@ -0,0 +1,81 @@
# Runtime Simplification Plan
## Goal
Remove the managed runtime install/copy layer. The desktop app must run the bundled runtime in place from the application resources/install directory on macOS, Windows, and Linux.
This is a simplification task. If a change preserves the old install-manager shape under a new name, it fails the goal.
## Hard Requirements
1. The bundled runtime is read-only.
The desktop app must not copy the bundled runtime to app data, temp, cache, or any other writable directory.
2. The app must execute Node/CLI/server entrypoints directly from the bundled runtime root.
On macOS this means inside `Paseo.app/Contents/Resources/...`.
On Windows and Linux this means inside the installed app resources directory.
3. There is exactly one runtime per installed app.
Remove versioned installed runtime directories like `runtime/<runtime-id>` from the runtime execution path.
4. The CLI shim may point into the installed app bundle/directory.
Do not keep an extra installed runtime tree just to preserve a stable shim target across updates.
5. All mutable state remains outside the bundled runtime.
Logs, sockets/pipes, PID files, daemon state, `PASEO_HOME`, and any other writable files must continue to live in managed home / app data locations.
6. Runtime discovery must stay cross-platform.
The implementation must resolve the bundled runtime/resources path on macOS, Windows, and Linux using the app install/resources directory, not hardcoded `.app` assumptions.
7. Remove dead machinery, do not leave adapters behind.
If install/copy/versioned-runtime code becomes unused, delete it instead of keeping fallback paths "just in case".
## Non-Goals
1. Do not change how the runtime is built into the desktop app bundle in this task.
This task is about runtime execution and path management, not bundling format.
2. Do not reintroduce a second runtime location for migration compatibility.
It is acceptable if older installed clients do not migrate cleanly.
3. Do not add feature flags, env-guarded fallback paths, or compatibility shims unless absolutely required by a real platform constraint proven in code.
## Concrete Implementation Direction
1. Treat `bundled_runtime_root(app)` plus `current-runtime.json` as the runtime source of truth.
2. Replace `paths.runtime_root` usage for runtime execution with the bundled runtime root selected by `current-runtime.json`.
3. Delete `install_runtime_if_needed(...)` and related copy/install staging logic if nothing else still needs it.
4. Rework CLI shim generation so the inner launcher points at the bundled runtime's Node + CLI entrypoint directly, while still keeping mutable state (`PASEO_HOME`) outside the runtime.
5. Simplify `ManagedPaths` and related structs if `runtime_root` and `stable_runtime_root` no longer need separate installed-runtime semantics.
6. Keep diagnostics/status reporting accurate.
If the app reports bundled vs installed runtime roots today, update that output to reflect the new single-runtime model.
## Acceptance Criteria
1. No code path copies the bundled runtime tree into app data before launching the daemon or CLI.
2. No code path depends on a versioned installed runtime directory for execution.
3. The CLI shim and daemon launch path both resolve to bundled runtime executables/resources.
4. Typecheck passes.
5. Relevant desktop/runtime tests pass, updated to reflect the new direct-from-bundle model.
6. The resulting implementation is materially simpler:
fewer runtime path concepts, fewer staging/install branches, fewer indirections.
## Review Bar
The implementation should be rejected if:
- it still copies the runtime anywhere before execution
- it keeps runtime version directories in the execution path
- it preserves the stable installed runtime launcher concept without a hard platform reason
- it adds migration complexity for old installs
- it introduces new fallback branches instead of removing obsolete ones
@@ -3,6 +3,7 @@ import {
hostHasDirectEndpoint, hostHasDirectEndpoint,
registryHasDirectEndpoint, registryHasDirectEndpoint,
reconcileDesktopStartupRegistry, reconcileDesktopStartupRegistry,
resolveManagedDesktopStartupStatus,
type HostProfile, type HostProfile,
} from './daemon-registry-context' } from './daemon-registry-context'
@@ -240,3 +241,83 @@ describe('reconcileDesktopStartupRegistry', () => {
expect(second).toEqual(first) expect(second).toEqual(first)
}) })
}) })
describe('resolveManagedDesktopStartupStatus', () => {
it('starts the managed daemon when management is enabled', async () => {
const managedStatus = {
runtimeId: 'runtime_1',
runtimeVersion: '1.2.3',
runtimeRoot: '/runtime',
managedHome: '/home',
transportType: 'socket',
transportPath: '/tmp/paseo.sock',
daemonPid: 123,
daemonRunning: true,
daemonStatus: 'running',
logPath: '/tmp/daemon.log',
serverId: 'srv_managed',
hostname: 'managed-host',
relayEnabled: true,
tcpEnabled: false,
tcpListen: null,
cliShimPath: null,
}
let startCalls = 0
let statusCalls = 0
const result = await resolveManagedDesktopStartupStatus({
settings: { manageBuiltInDaemon: true },
startManagedDaemonFn: async () => {
startCalls += 1
return managedStatus
},
getManagedDaemonStatusFn: async () => {
statusCalls += 1
return managedStatus
},
})
expect(result).toEqual(managedStatus)
expect(startCalls).toBe(1)
expect(statusCalls).toBe(0)
})
it('only reads managed daemon status when management is paused', async () => {
const managedStatus = {
runtimeId: 'runtime_1',
runtimeVersion: '1.2.3',
runtimeRoot: '/runtime',
managedHome: '/home',
transportType: 'socket',
transportPath: '/tmp/paseo.sock',
daemonPid: null,
daemonRunning: false,
daemonStatus: 'stopped',
logPath: '/tmp/daemon.log',
serverId: null,
hostname: null,
relayEnabled: true,
tcpEnabled: false,
tcpListen: null,
cliShimPath: null,
}
let startCalls = 0
let statusCalls = 0
const result = await resolveManagedDesktopStartupStatus({
settings: { manageBuiltInDaemon: false },
startManagedDaemonFn: async () => {
startCalls += 1
return managedStatus
},
getManagedDaemonStatusFn: async () => {
statusCalls += 1
return managedStatus
},
})
expect(result).toEqual(managedStatus)
expect(startCalls).toBe(0)
expect(statusCalls).toBe(1)
})
})
@@ -4,8 +4,10 @@ import AsyncStorage from '@react-native-async-storage/async-storage'
import { useQuery, useQueryClient } from '@tanstack/react-query' import { useQuery, useQueryClient } from '@tanstack/react-query'
import { decodeOfferFragmentPayload, normalizeHostPort } from '@/utils/daemon-endpoints' import { decodeOfferFragmentPayload, normalizeHostPort } from '@/utils/daemon-endpoints'
import { probeConnection } from '@/utils/test-daemon-connection' import { probeConnection } from '@/utils/test-daemon-connection'
import { useAppSettings, type AppSettings } from '@/hooks/use-settings'
import { ConnectionOfferSchema, type ConnectionOffer } from '@server/shared/connection-offer' import { ConnectionOfferSchema, type ConnectionOffer } from '@server/shared/connection-offer'
import { import {
getManagedDaemonStatus,
type ManagedDaemonStatus, type ManagedDaemonStatus,
shouldUseManagedDesktopDaemon, shouldUseManagedDesktopDaemon,
startManagedDaemon, startManagedDaemon,
@@ -460,6 +462,18 @@ export function reconcileDesktopStartupRegistry(
return next return next
} }
export async function resolveManagedDesktopStartupStatus(input: {
settings: Pick<AppSettings, 'manageBuiltInDaemon'>
startManagedDaemonFn?: () => Promise<ManagedDaemonStatus>
getManagedDaemonStatusFn?: () => Promise<ManagedDaemonStatus>
}): Promise<ManagedDaemonStatus> {
if (input.settings.manageBuiltInDaemon) {
return await (input.startManagedDaemonFn ?? startManagedDaemon)()
}
return await (input.getManagedDaemonStatusFn ?? getManagedDaemonStatus)()
}
async function probeManagedStartupTarget(input: { async function probeManagedStartupTarget(input: {
managedDaemon: ManagedDaemonStatus managedDaemon: ManagedDaemonStatus
cancelled?: () => boolean cancelled?: () => boolean
@@ -551,6 +565,7 @@ export function DaemonRegistryProvider({ children }: { children: ReactNode }) {
const queryClient = useQueryClient() const queryClient = useQueryClient()
const desktopStartupReconciledRef = useRef(false) const desktopStartupReconciledRef = useRef(false)
const localhostBootstrapAttemptedRef = useRef(false) const localhostBootstrapAttemptedRef = useRef(false)
const { settings, isLoading: settingsLoading } = useAppSettings()
const { const {
data: daemons = [], data: daemons = [],
isPending, isPending,
@@ -667,6 +682,7 @@ export function DaemonRegistryProvider({ children }: { children: ReactNode }) {
useEffect(() => { useEffect(() => {
if (isPending) return if (isPending) return
if (settingsLoading) return
if (!shouldUseManagedDesktopDaemon()) return if (!shouldUseManagedDesktopDaemon()) return
if (desktopStartupReconciledRef.current) return if (desktopStartupReconciledRef.current) return
desktopStartupReconciledRef.current = true desktopStartupReconciledRef.current = true
@@ -682,11 +698,15 @@ export function DaemonRegistryProvider({ children }: { children: ReactNode }) {
let managed: ManagedHostReconciliationInput | null = null let managed: ManagedHostReconciliationInput | null = null
try { try {
const managedDaemon = await startManagedDaemon() const managedDaemon = await resolveManagedDesktopStartupStatus({
managed = await probeManagedConnectionUntilReady({ settings,
managedDaemon,
cancelled: () => cancelled,
}) })
if (managedDaemon.daemonRunning) {
managed = await probeManagedConnectionUntilReady({
managedDaemon,
cancelled: () => cancelled,
})
}
} catch (managedBootstrapError) { } catch (managedBootstrapError) {
if (!cancelled) { if (!cancelled) {
console.warn( console.warn(
@@ -750,6 +770,8 @@ export function DaemonRegistryProvider({ children }: { children: ReactNode }) {
isPending, isPending,
persist, persist,
readDaemons, readDaemons,
settings.manageBuiltInDaemon,
settingsLoading,
]) ])
useEffect(() => { useEffect(() => {
@@ -7,6 +7,7 @@ import { StyleSheet } from "react-native-unistyles";
import { ArrowUpRight } from "lucide-react-native"; import { ArrowUpRight } from "lucide-react-native";
import { AdaptiveModalSheet } from "@/components/adaptive-modal-sheet"; import { AdaptiveModalSheet } from "@/components/adaptive-modal-sheet";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { useAppSettings } from "@/hooks/use-settings";
import { confirmDialog } from "@/utils/confirm-dialog"; import { confirmDialog } from "@/utils/confirm-dialog";
import { openExternalUrl } from "@/utils/open-external-url"; import { openExternalUrl } from "@/utils/open-external-url";
import { import {
@@ -20,6 +21,8 @@ import {
installManagedCliShim, installManagedCliShim,
restartManagedDaemon, restartManagedDaemon,
shouldUseManagedDesktopDaemon, shouldUseManagedDesktopDaemon,
startManagedDaemon,
stopManagedDaemon,
uninstallManagedCliShim, uninstallManagedCliShim,
type ManagedDaemonLogs, type ManagedDaemonLogs,
type ManagedPairingOffer, type ManagedPairingOffer,
@@ -33,9 +36,11 @@ export interface LocalDaemonSectionProps {
export function LocalDaemonSection({ appVersion }: LocalDaemonSectionProps) { export function LocalDaemonSection({ appVersion }: LocalDaemonSectionProps) {
const showSection = shouldUseManagedDesktopDaemon(); const showSection = shouldUseManagedDesktopDaemon();
const { settings, updateSettings } = useAppSettings();
const [managedStatus, setManagedStatus] = useState<ManagedDaemonStatus | null>(null); const [managedStatus, setManagedStatus] = useState<ManagedDaemonStatus | null>(null);
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 [isInstallingCli, setIsInstallingCli] = useState(false); const [isInstallingCli, setIsInstallingCli] = 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);
@@ -78,8 +83,19 @@ export function LocalDaemonSection({ appVersion }: LocalDaemonSectionProps) {
const localDaemonVersionText = formatVersionWithPrefix(managedStatus?.runtimeVersion ?? null); const localDaemonVersionText = formatVersionWithPrefix(managedStatus?.runtimeVersion ?? null);
const daemonVersionMismatch = isVersionMismatch(appVersion, managedStatus?.runtimeVersion ?? null); const daemonVersionMismatch = isVersionMismatch(appVersion, managedStatus?.runtimeVersion ?? null);
const daemonVersionHint = const daemonStatusStateText =
statusError ?? (managedStatus?.daemonRunning ? "Running." : "Not running."); statusError ??
(managedStatus?.daemonRunning
? managedStatus?.daemonStatus ?? "running"
: "not running");
const daemonStatusDetailText = `PID ${managedStatus?.daemonPid ? managedStatus.daemonPid : "—"}`;
const isDaemonManagementPaused = !settings.manageBuiltInDaemon;
const daemonActionLabel = managedStatus?.daemonRunning ? "Restart daemon" : "Start daemon";
const daemonActionMessage = managedStatus?.daemonRunning
? "Restarts the built-in daemon."
: isDaemonManagementPaused
? "Starts the built-in daemon manually. Paseo will not auto-start it while paused."
: "Starts the built-in daemon.";
const handleUpdateLocalDaemon = useCallback(() => { const handleUpdateLocalDaemon = useCallback(() => {
if (!showSection) { if (!showSection) {
@@ -90,10 +106,11 @@ export function LocalDaemonSection({ appVersion }: LocalDaemonSectionProps) {
} }
void confirmDialog({ void confirmDialog({
title: "Restart daemon", title: daemonActionLabel,
message: message: managedStatus?.daemonRunning
"This will restart the built-in daemon. The app will reconnect automatically.", ? "This will restart the built-in daemon. The app will reconnect automatically."
confirmLabel: "Restart", : "This will start the built-in daemon.",
confirmLabel: daemonActionLabel,
cancelLabel: "Cancel", cancelLabel: "Cancel",
}) })
.then((confirmed) => { .then((confirmed) => {
@@ -104,26 +121,100 @@ export function LocalDaemonSection({ appVersion }: LocalDaemonSectionProps) {
setIsRestartingDaemon(true); setIsRestartingDaemon(true);
setStatusMessage(null); setStatusMessage(null);
void restartManagedDaemon() const action = managedStatus?.daemonRunning ? restartManagedDaemon : startManagedDaemon;
void action()
.then((status) => { .then((status) => {
setManagedStatus(status); setManagedStatus(status);
setStatusMessage("Daemon restarted."); setStatusMessage(
managedStatus?.daemonRunning ? "Daemon restarted." : "Daemon started."
);
return loadManagedStatus(); return loadManagedStatus();
}) })
.catch((error) => { .catch((error) => {
console.error("[Settings] Failed to restart managed daemon", error); console.error("[Settings] Failed to change managed daemon state", error);
const message = error instanceof Error ? error.message : String(error); const message = error instanceof Error ? error.message : String(error);
setStatusMessage(`Restart failed: ${message}`); setStatusMessage(`${daemonActionLabel} failed: ${message}`);
}) })
.finally(() => { .finally(() => {
setIsRestartingDaemon(false); setIsRestartingDaemon(false);
}); });
}) })
.catch((error) => { .catch((error) => {
console.error("[Settings] Failed to open managed daemon restart confirmation", error); console.error("[Settings] Failed to open managed daemon action confirmation", error);
Alert.alert("Error", "Unable to open the restart confirmation dialog."); Alert.alert("Error", "Unable to open the daemon confirmation dialog.");
}); });
}, [isRestartingDaemon, loadManagedStatus, showSection]); }, [daemonActionLabel, isRestartingDaemon, loadManagedStatus, managedStatus?.daemonRunning, showSection]);
const handleToggleDaemonManagement = useCallback(() => {
if (isUpdatingDaemonManagement) {
return;
}
if (!settings.manageBuiltInDaemon) {
setIsUpdatingDaemonManagement(true);
setStatusMessage(null);
void updateSettings({ manageBuiltInDaemon: true })
.then(() => {
setStatusMessage("Paseo will resume managing the built-in daemon on startup.");
})
.catch((error) => {
console.error("[Settings] Failed to update built-in daemon management", error);
Alert.alert("Error", "Unable to update built-in daemon management.");
})
.finally(() => {
setIsUpdatingDaemonManagement(false);
});
return;
}
void confirmDialog({
title: "Pause built-in daemon",
message:
"This will stop the built-in daemon immediately and prevent Paseo from auto-starting it on launch. Running agents and terminals connected to the built-in daemon will be stopped.",
confirmLabel: "Pause and stop",
cancelLabel: "Cancel",
destructive: true,
})
.then((confirmed) => {
if (!confirmed) {
return;
}
setIsUpdatingDaemonManagement(true);
setStatusMessage(null);
const stopPromise = managedStatus?.daemonRunning
? stopManagedDaemon()
: Promise.resolve(managedStatus ?? null);
void stopPromise
.then(() => updateSettings({ manageBuiltInDaemon: false }))
.then(() => loadManagedStatus())
.then(() => {
setStatusMessage(
"Paseo paused the built-in daemon and will no longer auto-start it on launch."
);
})
.catch((error) => {
console.error("[Settings] Failed to pause built-in daemon management", error);
Alert.alert("Error", "Unable to pause built-in daemon management.");
})
.finally(() => {
setIsUpdatingDaemonManagement(false);
});
})
.catch((error) => {
console.error("[Settings] Failed to open built-in daemon pause confirmation", error);
Alert.alert("Error", "Unable to open the daemon confirmation dialog.");
});
}, [
isUpdatingDaemonManagement,
loadManagedStatus,
managedStatus,
settings.manageBuiltInDaemon,
updateSettings,
]);
const handleToggleCliShim = useCallback(() => { const handleToggleCliShim = useCallback(() => {
if (!showSection || isInstallingCli) { if (!showSection || isInstallingCli) {
@@ -255,26 +346,59 @@ export function LocalDaemonSection({ appVersion }: LocalDaemonSectionProps) {
<View style={styles.card}> <View style={styles.card}>
<View style={styles.row}> <View style={styles.row}>
<View style={styles.rowContent}> <View style={styles.rowContent}>
<Text style={styles.rowTitle}>Version</Text> <Text style={styles.rowTitle}>Status</Text>
<Text style={styles.hintText}>{daemonVersionHint}</Text> <Text style={styles.hintText}>Only the built-in managed daemon is shown here.</Text>
</View>
<View style={styles.statusValueGroup}>
<Text style={styles.valueText}>{daemonStatusStateText}</Text>
<Text style={styles.valueSubtext}>{daemonStatusDetailText}</Text>
</View> </View>
<Text style={styles.valueText}>{localDaemonVersionText}</Text>
</View> </View>
<View style={[styles.row, styles.rowBorder]}> <View style={[styles.row, styles.rowBorder]}>
<View style={styles.rowContent}> <View style={styles.rowContent}>
<Text style={styles.rowTitle}>Restart daemon</Text> <Text style={styles.rowTitle}>Daemon management</Text>
<Text style={styles.hintText}>Restarts the built-in daemon.</Text> <Text style={styles.hintText}>
{isDaemonManagementPaused
? "Paused. Paseo will not auto-start the built-in daemon on app launch."
: "Enabled. Paseo will start the built-in daemon automatically when needed."}
</Text>
</View>
<Button
variant="outline"
size="sm"
style={styles.primaryActionButton}
onPress={handleToggleDaemonManagement}
disabled={isUpdatingDaemonManagement}
>
{isUpdatingDaemonManagement
? isDaemonManagementPaused
? "Resuming..."
: "Pausing..."
: isDaemonManagementPaused
? "Resume"
: "Pause"}
</Button>
</View>
<View style={[styles.row, styles.rowBorder]}>
<View style={styles.rowContent}>
<Text style={styles.rowTitle}>{daemonActionLabel}</Text>
<Text style={styles.hintText}>{daemonActionMessage}</Text>
{statusMessage ? ( {statusMessage ? (
<Text style={styles.statusText}>{statusMessage}</Text> <Text style={styles.statusText}>{statusMessage}</Text>
) : null} ) : null}
</View> </View>
<Button <Button
variant="secondary" variant="outline"
size="sm" size="sm"
style={styles.primaryActionButton}
onPress={handleUpdateLocalDaemon} onPress={handleUpdateLocalDaemon}
disabled={isRestartingDaemon} disabled={isRestartingDaemon}
> >
{isRestartingDaemon ? "Restarting..." : "Restart daemon"} {isRestartingDaemon
? managedStatus?.daemonRunning
? "Restarting..."
: "Starting..."
: daemonActionLabel}
</Button> </Button>
</View> </View>
<View style={[styles.row, styles.rowBorder]}> <View style={[styles.row, styles.rowBorder]}>
@@ -286,8 +410,9 @@ export function LocalDaemonSection({ appVersion }: LocalDaemonSectionProps) {
{cliStatusMessage ? <Text style={styles.statusText}>{cliStatusMessage}</Text> : null} {cliStatusMessage ? <Text style={styles.statusText}>{cliStatusMessage}</Text> : null}
</View> </View>
<Button <Button
variant="secondary" variant="outline"
size="sm" size="sm"
style={styles.secondaryActionButton}
onPress={handleToggleCliShim} onPress={handleToggleCliShim}
disabled={isInstallingCli} disabled={isInstallingCli}
> >
@@ -309,12 +434,12 @@ export function LocalDaemonSection({ appVersion }: LocalDaemonSectionProps) {
</View> </View>
<View style={styles.actionGroup}> <View style={styles.actionGroup}>
{(managedLogs?.logPath ?? managedStatus?.logPath) ? ( {(managedLogs?.logPath ?? managedStatus?.logPath) ? (
<Button variant="secondary" size="sm" onPress={handleCopyLogPath}> <Button variant="outline" size="sm" onPress={handleCopyLogPath}>
Copy path Copy path
</Button> </Button>
) : null} ) : null}
<Button <Button
variant="secondary" variant="outline"
size="sm" size="sm"
onPress={handleOpenLogs} onPress={handleOpenLogs}
disabled={!managedLogs} disabled={!managedLogs}
@@ -330,7 +455,7 @@ export function LocalDaemonSection({ appVersion }: LocalDaemonSectionProps) {
Connect your phone to this computer. Connect your phone to this computer.
</Text> </Text>
</View> </View>
<Button variant="secondary" size="sm" onPress={handleOpenPairingModal}> <Button variant="outline" size="sm" style={styles.secondaryActionButton} onPress={handleOpenPairingModal}>
Pair device Pair device
</Button> </Button>
</View> </View>
@@ -560,6 +685,18 @@ const styles = StyleSheet.create((theme) => ({
actionGroup: { actionGroup: {
flexDirection: "row", flexDirection: "row",
gap: theme.spacing[2], gap: theme.spacing[2],
flexWrap: "wrap",
justifyContent: "flex-end",
},
statusValueGroup: {
alignItems: "flex-end",
gap: 2,
},
primaryActionButton: {
minWidth: 124,
},
secondaryActionButton: {
minWidth: 112,
}, },
rowTitle: { rowTitle: {
color: theme.colors.foreground, color: theme.colors.foreground,
@@ -570,6 +707,10 @@ const styles = StyleSheet.create((theme) => ({
fontSize: theme.fontSize.sm, fontSize: theme.fontSize.sm,
fontWeight: theme.fontWeight.normal, fontWeight: theme.fontWeight.normal,
}, },
valueSubtext: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.xs,
},
hintText: { hintText: {
color: theme.colors.foregroundMuted, color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.xs, fontSize: theme.fontSize.xs,
@@ -4,9 +4,7 @@ import { getTauri, isTauriEnvironment } from "@/utils/tauri";
export type ManagedRuntimeStatus = { export type ManagedRuntimeStatus = {
runtimeId: string; runtimeId: string;
runtimeVersion: string; runtimeVersion: string;
bundledRuntimeRoot: string; runtimeRoot: string;
installedRuntimeRoot: string;
installed: boolean;
managedHome: string; managedHome: string;
transportType: string; transportType: string;
transportPath: string; transportPath: string;
@@ -103,9 +101,7 @@ function parseManagedRuntimeStatus(raw: unknown): ManagedRuntimeStatus {
return { return {
runtimeId: toStringOrNull(raw.runtimeId) ?? "", runtimeId: toStringOrNull(raw.runtimeId) ?? "",
runtimeVersion: toStringOrNull(raw.runtimeVersion) ?? "", runtimeVersion: toStringOrNull(raw.runtimeVersion) ?? "",
bundledRuntimeRoot: toStringOrNull(raw.bundledRuntimeRoot) ?? "", runtimeRoot: toStringOrNull(raw.runtimeRoot) ?? "",
installedRuntimeRoot: toStringOrNull(raw.installedRuntimeRoot) ?? "",
installed: raw.installed === true,
managedHome: toStringOrNull(raw.managedHome) ?? "", managedHome: toStringOrNull(raw.managedHome) ?? "",
transportType: toStringOrNull(raw.transportType) ?? "socket", transportType: toStringOrNull(raw.transportType) ?? "socket",
transportPath: toStringOrNull(raw.transportPath) ?? "", transportPath: toStringOrNull(raw.transportPath) ?? "",
@@ -189,10 +185,6 @@ export function shouldUseManagedDesktopDaemon(): boolean {
return isTauriEnvironment() && getTauri() !== null; return isTauriEnvironment() && getTauri() !== null;
} }
export async function ensureManagedRuntime(): Promise<ManagedRuntimeStatus> {
return parseManagedRuntimeStatus(await invokeDesktopCommand("ensure_managed_runtime"));
}
export async function getManagedRuntimeStatus(): Promise<ManagedRuntimeStatus> { export async function getManagedRuntimeStatus(): Promise<ManagedRuntimeStatus> {
return parseManagedRuntimeStatus(await invokeDesktopCommand("managed_runtime_status")); return parseManagedRuntimeStatus(await invokeDesktopCommand("managed_runtime_status"));
} }
@@ -0,0 +1,53 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const asyncStorageMock = vi.hoisted(() => ({
getItem: vi.fn<(_: string) => Promise<string | null>>(),
setItem: vi.fn<(_: string, __: string) => Promise<void>>(),
}));
vi.mock("@react-native-async-storage/async-storage", () => ({
default: asyncStorageMock,
}));
describe("use-settings", () => {
beforeEach(() => {
vi.resetModules();
asyncStorageMock.getItem.mockReset();
asyncStorageMock.setItem.mockReset();
});
it("defaults built-in daemon management to enabled when storage is empty", async () => {
asyncStorageMock.getItem.mockResolvedValue(null);
asyncStorageMock.setItem.mockResolvedValue();
const mod = await import("./use-settings");
const result = await mod.loadSettingsFromStorage();
expect(result).toEqual(mod.DEFAULT_APP_SETTINGS);
expect(asyncStorageMock.setItem).toHaveBeenCalledWith(
mod.APP_SETTINGS_KEY,
JSON.stringify(mod.DEFAULT_APP_SETTINGS)
);
});
it("loads persisted built-in daemon management state", async () => {
asyncStorageMock.getItem.mockImplementation(async (key: string) => {
if (key === "@paseo:app-settings") {
return JSON.stringify({
theme: "light",
manageBuiltInDaemon: false,
});
}
return null;
});
const mod = await import("./use-settings");
const result = await mod.loadSettingsFromStorage();
expect(result).toEqual({
theme: "light",
manageBuiltInDaemon: false,
});
expect(asyncStorageMock.setItem).not.toHaveBeenCalled();
});
});
+8 -3
View File
@@ -2,16 +2,18 @@ import { useCallback } from "react";
import AsyncStorage from "@react-native-async-storage/async-storage"; import AsyncStorage from "@react-native-async-storage/async-storage";
import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useQuery, useQueryClient } from "@tanstack/react-query";
const APP_SETTINGS_KEY = "@paseo:app-settings"; export const APP_SETTINGS_KEY = "@paseo:app-settings";
const LEGACY_SETTINGS_KEY = "@paseo:settings"; const LEGACY_SETTINGS_KEY = "@paseo:settings";
const APP_SETTINGS_QUERY_KEY = ["app-settings"]; const APP_SETTINGS_QUERY_KEY = ["app-settings"];
export interface AppSettings { export interface AppSettings {
theme: "dark" | "light" | "auto"; theme: "dark" | "light" | "auto";
manageBuiltInDaemon: boolean;
} }
const DEFAULT_APP_SETTINGS: AppSettings = { export const DEFAULT_APP_SETTINGS: AppSettings = {
theme: "dark", theme: "dark",
manageBuiltInDaemon: true,
}; };
export interface UseAppSettingsReturn { export interface UseAppSettingsReturn {
@@ -66,7 +68,7 @@ export function useAppSettings(): UseAppSettingsReturn {
}; };
} }
async function loadSettingsFromStorage(): Promise<AppSettings> { export async function loadSettingsFromStorage(): Promise<AppSettings> {
try { try {
const stored = await AsyncStorage.getItem(APP_SETTINGS_KEY); const stored = await AsyncStorage.getItem(APP_SETTINGS_KEY);
if (stored) { if (stored) {
@@ -98,6 +100,9 @@ function pickAppSettingsFromLegacy(legacy: Record<string, unknown>): Partial<App
if (legacy.theme === "dark" || legacy.theme === "light" || legacy.theme === "auto") { if (legacy.theme === "dark" || legacy.theme === "light" || legacy.theme === "auto") {
result.theme = legacy.theme; result.theme = legacy.theme;
} }
if (typeof legacy.manageBuiltInDaemon === "boolean") {
result.manageBuiltInDaemon = legacy.manageBuiltInDaemon;
}
return result; return result;
} }
+89 -124
View File
@@ -25,10 +25,6 @@ const currentRuntimePointer = JSON.parse(
); );
const currentRuntimeId = currentRuntimePointer.runtimeId; const currentRuntimeId = currentRuntimePointer.runtimeId;
function buildRuntimeId(version) {
return currentRuntimeId.replace(currentRuntimeVersion, version);
}
function resolvePackagedBinary() { function resolvePackagedBinary() {
if (process.platform === "darwin") { if (process.platform === "darwin") {
return path.join( return path.join(
@@ -103,87 +99,6 @@ function escapeForRegExp(value) {
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
} }
function derivePreviousVersion(version) {
const match = version.match(/^(\d+)\.(\d+)\.(\d+)$/);
if (!match) {
return `${version}-previous`;
}
const [, major, minor, patch] = match;
const numericPatch = Number.parseInt(patch, 10);
if (numericPatch <= 0) {
return `${major}.${minor}.0-previous`;
}
return `${major}.${minor}.${numericPatch - 1}`;
}
function buildSeededTransportPath(testRoot) {
if (process.platform === "win32") {
return String.raw`\\.\pipe\paseo-managed-seeded-upgrade`;
}
return path.join(testRoot, "managed-home", "run", "paseo.sock");
}
async function seedPreviousManagedRuntime(testRoot) {
const previousRuntimeVersion = derivePreviousVersion(currentRuntimeVersion);
const previousRuntimeId = buildRuntimeId(previousRuntimeVersion);
const previousRuntimeRoot = path.join(testRoot, "runtime", previousRuntimeId);
const stateFilePath = path.join(testRoot, "managed-state.json");
const managedHome = path.join(testRoot, "managed-home");
const transportType = process.platform === "win32" ? "pipe" : "socket";
const transportPath = buildSeededTransportPath(testRoot);
await fs.mkdir(previousRuntimeRoot, { recursive: true });
await fs.writeFile(
path.join(previousRuntimeRoot, "runtime-manifest.json"),
JSON.stringify(
{
runtimeId: previousRuntimeId,
runtimeVersion: previousRuntimeVersion,
platform: process.platform,
arch: process.arch,
createdAt: "2000-01-01T00:00:00.000Z",
nodeRelativePath: process.platform === "win32" ? path.join("node", "node.exe") : path.join("node", "node"),
cliEntrypointRelativePath: path.join("node_modules", "@getpaseo", "cli", "dist", "index.js"),
cliShimRelativePath: path.join("node_modules", "@getpaseo", "cli", "bin", "paseo"),
serverRunnerRelativePath: path.join(
"node_modules",
"@getpaseo",
"server",
"dist",
"scripts",
"daemon-runner.js"
),
},
null,
2
) + "\n",
"utf8"
);
await fs.writeFile(path.join(previousRuntimeRoot, "seed-marker.txt"), "version-n\n", "utf8");
await fs.writeFile(
stateFilePath,
JSON.stringify(
{
runtimeId: previousRuntimeId,
runtimeRoot: previousRuntimeRoot,
managedHome,
transportType,
transportPath,
tcpEnabled: false,
tcpListen: null,
cliShimPath: null,
},
null,
2
) + "\n",
"utf8"
);
return {
previousRuntimeId,
previousRuntimeVersion,
previousRuntimeRoot,
stateFilePath,
};
}
async function runBinary(binaryPath, args, env) { async function runBinary(binaryPath, args, env) {
const { stdout, stderr } = await execFileAsync(binaryPath, args, { const { stdout, stderr } = await execFileAsync(binaryPath, args, {
@@ -223,6 +138,26 @@ async function runWorkspaceCli(args, env) {
return { stdout, stderr, json }; return { stdout, stderr, json };
} }
async function runBundledRuntimeCli(runtimeRoot, managedHome, args, env) {
const manifest = JSON.parse(
await fs.readFile(path.join(runtimeRoot, "runtime-manifest.json"), "utf8")
);
const { stdout, stderr } = await execFileAsync(
path.join(runtimeRoot, manifest.nodeRelativePath),
[path.join(runtimeRoot, manifest.cliEntrypointRelativePath), ...args],
{
cwd: repoRoot,
env: {
...process.env,
...env,
PASEO_HOME: managedHome,
},
maxBuffer: 10 * 1024 * 1024,
}
);
return { stdout, stderr };
}
async function readDaemonStatus(home, env) { 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 ?? {};
@@ -466,6 +401,7 @@ const externalPort = await getAvailablePort();
const externalEndpoint = `127.0.0.1:${externalPort}`; const externalEndpoint = `127.0.0.1:${externalPort}`;
const relayPort = await getAvailablePort(); const relayPort = await getAvailablePort();
const relayEndpoint = `127.0.0.1:${relayPort}`; const relayEndpoint = `127.0.0.1:${relayPort}`;
const managedRuntimeDir = path.join(testRoot, "runtime");
await fs.mkdir(testRoot, { recursive: true }); await fs.mkdir(testRoot, { recursive: true });
await fs.mkdir(fakePaseoHome, { recursive: true }); await fs.mkdir(fakePaseoHome, { recursive: true });
await fs.mkdir(cliScratchHome, { recursive: true }); await fs.mkdir(cliScratchHome, { recursive: true });
@@ -473,7 +409,6 @@ await fs.mkdir(externalHome, { recursive: true });
await fs.writeFile(path.join(fakePaseoHome, "sentinel.txt"), "do not touch\n", "utf8"); await fs.writeFile(path.join(fakePaseoHome, "sentinel.txt"), "do not touch\n", "utf8");
const fakePaseoSnapshotBefore = await snapshotTree(fakePaseoHome); const fakePaseoSnapshotBefore = await snapshotTree(fakePaseoHome);
const seededUpgradeState = await seedPreviousManagedRuntime(testRoot);
const managedEnv = { const managedEnv = {
...process.env, ...process.env,
HOME: fakeHome, HOME: fakeHome,
@@ -492,7 +427,7 @@ const managedEnv = {
let externalPid = null; let externalPid = null;
let startedTemporaryExternalDaemon = false; let startedTemporaryExternalDaemon = false;
let relayProcess = null; let relayProcess = null;
const forbiddenManagedReferences = ["127.0.0.1:6767", fakePaseoHome]; const forbiddenManagedReferences = ["127.0.0.1:6767", fakePaseoHome, managedRuntimeDir];
try { try {
logStep(`Starting isolated local relay on ${relayEndpoint}`); logStep(`Starting isolated local relay on ${relayEndpoint}`);
@@ -544,39 +479,40 @@ try {
logStep("Bootstrapping managed runtime from packaged desktop binary"); logStep("Bootstrapping managed runtime from packaged desktop binary");
const runtimeStatus = await runBinary(packagedBinary, ["--managed-headless", "runtime-status"], managedEnv); const runtimeStatus = await runBinary(packagedBinary, ["--managed-headless", "runtime-status"], managedEnv);
assert.equal(await pathExists(seededUpgradeState.previousRuntimeRoot), true);
assert.equal(runtimeStatus.json.runtimeId, currentRuntimeId); assert.equal(runtimeStatus.json.runtimeId, currentRuntimeId);
assert.equal(runtimeStatus.json.runtimeVersion, currentRuntimeVersion); assert.equal(runtimeStatus.json.runtimeVersion, currentRuntimeVersion);
assert.match(runtimeStatus.json.managedHome, new RegExp(`^${escapeForRegExp(testRoot)}`)); assert.match(runtimeStatus.json.managedHome, new RegExp(`^${escapeForRegExp(testRoot)}`));
assert.equal( assert.match(
runtimeStatus.json.installedRuntimeRoot, runtimeStatus.json.runtimeRoot,
path.join(testRoot, "runtime", currentRuntimeId), new RegExp(escapeForRegExp(currentRuntimeId)),
"managed runtime should install under the current bundled runtime id" "runtime status should point into the bundled runtime selected by current-runtime.json"
); );
assert.notEqual( assert.equal(
runtimeStatus.json.installedRuntimeRoot, runtimeStatus.json.runtimeRoot.startsWith(testRoot),
seededUpgradeState.previousRuntimeRoot, false,
"packaged app should adopt the bundled runtime instead of the seeded previous runtime" "runtime status should not resolve into managed app data"
); );
assert.ok(runtimeStatus.json.transportType === "socket" || runtimeStatus.json.transportType === "pipe"); assert.ok(runtimeStatus.json.transportType === "socket" || runtimeStatus.json.transportType === "pipe");
assert.notEqual(runtimeStatus.json.transportPath, "127.0.0.1:6767"); assert.notEqual(runtimeStatus.json.transportPath, "127.0.0.1:6767");
assertNoForbiddenPathsOrPorts(runtimeStatus.json, forbiddenManagedReferences); assertNoForbiddenPathsOrPorts(runtimeStatus.json, forbiddenManagedReferences);
const stateAfterRuntimeInstall = JSON.parse( assert.equal(await pathExists(managedRuntimeDir), false, "managed app data should not gain a runtime tree");
await fs.readFile(seededUpgradeState.stateFilePath, "utf8") const stateAfterRuntimeStatus = JSON.parse(
await fs.readFile(path.join(testRoot, "managed-state.json"), "utf8")
); );
assert.equal(stateAfterRuntimeInstall.runtimeId, currentRuntimeId); assert.equal(stateAfterRuntimeStatus.runtimeId, currentRuntimeId);
assert.equal(stateAfterRuntimeInstall.runtimeRoot, runtimeStatus.json.installedRuntimeRoot); assert.equal(stateAfterRuntimeStatus.runtimeRoot, runtimeStatus.json.runtimeRoot);
assert.equal(await pathExists(seededUpgradeState.previousRuntimeRoot), true);
const managedStart = await runBinary(packagedBinary, ["--managed-headless", "bootstrap"], managedEnv); const managedStart = await runBinary(packagedBinary, ["--managed-headless", "bootstrap"], managedEnv);
assert.equal(managedStart.json.daemonRunning, true); assert.equal(managedStart.json.daemonRunning, true);
assert.ok(managedStart.json.daemonPid, "managed daemon pid should exist"); assert.ok(managedStart.json.daemonPid, "managed daemon pid should exist");
assert.equal(managedStart.json.runtimeRoot, runtimeStatus.json.runtimeRoot);
assert.ok( assert.ok(
managedStart.json.transportType === "socket" || managedStart.json.transportType === "pipe", managedStart.json.transportType === "socket" || managedStart.json.transportType === "pipe",
"managed daemon should default to private IPC transport" "managed daemon should default to private IPC transport"
); );
assert.notEqual(managedStart.json.transportPath, "127.0.0.1:6767"); assert.notEqual(managedStart.json.transportPath, "127.0.0.1:6767");
assertNoForbiddenPathsOrPorts(managedStart.json, forbiddenManagedReferences); assertNoForbiddenPathsOrPorts(managedStart.json, forbiddenManagedReferences);
assert.equal(await pathExists(managedRuntimeDir), false, "starting the daemon should not install a runtime copy");
const managedPid = managedStart.json.daemonPid; const managedPid = managedStart.json.daemonPid;
const stateFile = path.join(testRoot, "managed-state.json"); const stateFile = path.join(testRoot, "managed-state.json");
@@ -594,7 +530,7 @@ try {
assert.equal(persistedManagedStatus.json.relayEnabled, true); assert.equal(persistedManagedStatus.json.relayEnabled, true);
assertNoForbiddenPathsOrPorts(persistedManagedStatus.json, forbiddenManagedReferences); assertNoForbiddenPathsOrPorts(persistedManagedStatus.json, forbiddenManagedReferences);
logStep("Installing CLI shim and verifying it targets the managed daemon"); logStep("Installing CLI shim and verifying the bundled CLI target");
const cliInstall = await runBinary( const cliInstall = await runBinary(
packagedBinary, packagedBinary,
["--managed-headless", "install-cli-shim"], ["--managed-headless", "install-cli-shim"],
@@ -602,32 +538,62 @@ try {
); );
const cliShimPath = cliInstall.json.path; const cliShimPath = cliInstall.json.path;
assert.ok(cliShimPath, "CLI shim path should be returned"); assert.ok(cliShimPath, "CLI shim path should be returned");
assert.equal(await pathExists(cliShimPath), true, "CLI shim should exist"); const cliShimInstalled = cliInstall.json.installed === true && (await pathExists(cliShimPath));
const cliVersion = await execFileAsync(cliShimPath, ["--version"], { if (!cliShimInstalled) {
env: managedEnv, assert.ok(cliInstall.json.manualInstructions, "manual CLI install instructions should be returned");
cwd: repoRoot, assert.match(
maxBuffer: 1024 * 1024, cliInstall.json.manualInstructions.commands,
}); new RegExp(escapeForRegExp(runtimeStatus.json.runtimeRoot)),
"manual CLI install instructions should point at the bundled runtime"
);
assertNoForbiddenPathsOrPorts(cliInstall.json.manualInstructions, forbiddenManagedReferences);
}
const cliVersion = cliShimInstalled
? await execFileAsync(cliShimPath, ["--version"], {
env: managedEnv,
cwd: repoRoot,
maxBuffer: 1024 * 1024,
})
: await runBundledRuntimeCli(
runtimeStatus.json.runtimeRoot,
managedStart.json.managedHome,
["--version"],
managedEnv
);
assert.match(cliVersion.stdout.trim(), /^0\./); assert.match(cliVersion.stdout.trim(), /^0\./);
const shimStatus = await execFileAsync(cliShimPath, ["daemon", "status", "--json"], { const shimStatus = cliShimInstalled
env: managedEnv, ? await execFileAsync(cliShimPath, ["daemon", "status", "--json"], {
cwd: repoRoot, env: managedEnv,
maxBuffer: 1024 * 1024, cwd: repoRoot,
}); maxBuffer: 1024 * 1024,
})
: await runBundledRuntimeCli(
runtimeStatus.json.runtimeRoot,
managedStart.json.managedHome,
["daemon", "status", "--json"],
managedEnv
);
const shimDaemonStatus = JSON.parse(shimStatus.stdout.trim()); const shimDaemonStatus = JSON.parse(shimStatus.stdout.trim());
assert.equal(shimDaemonStatus.pid, managedPid); assert.equal(shimDaemonStatus.pid, managedPid);
assertNoForbiddenPathsOrPorts(shimDaemonStatus, forbiddenManagedReferences); assertNoForbiddenPathsOrPorts(shimDaemonStatus, 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 = await execFileAsync( const relayPairing = cliShimInstalled
cliShimPath, ? await execFileAsync(
["daemon", "pair", "--home", managedStart.json.managedHome], cliShimPath,
{ ["daemon", "pair", "--home", managedStart.json.managedHome],
env: managedEnv, {
cwd: repoRoot, env: managedEnv,
maxBuffer: 4 * 1024 * 1024, cwd: repoRoot,
} maxBuffer: 4 * 1024 * 1024,
); }
)
: await runBundledRuntimeCli(
runtimeStatus.json.runtimeRoot,
managedStart.json.managedHome,
["daemon", "pair", "--home", managedStart.json.managedHome],
managedEnv
);
const relayOfferUrl = parseOfferUrlFromCommandOutput(relayPairing.stdout); const relayOfferUrl = parseOfferUrlFromCommandOutput(relayPairing.stdout);
const relayOffer = decodeOfferFromFragmentUrl(relayOfferUrl); const relayOffer = decodeOfferFromFragmentUrl(relayOfferUrl);
assert.equal(relayOffer.relay?.endpoint, relayEndpoint); assert.equal(relayOffer.relay?.endpoint, relayEndpoint);
@@ -705,8 +671,7 @@ try {
{ {
runtimeStatus: runtimeStatus.json, runtimeStatus: runtimeStatus.json,
managedStart: managedStart.json, managedStart: managedStart.json,
seededUpgradeState, stateAfterRuntimeStatus,
stateAfterRuntimeInstall,
persistedManagedStatus: persistedManagedStatus.json, persistedManagedStatus: persistedManagedStatus.json,
managedRestartless: managedRestartless.json, managedRestartless: managedRestartless.json,
cliInstall: cliInstall.json, cliInstall: cliInstall.json,
+5 -7
View File
@@ -14,11 +14,11 @@ use tauri_plugin_updater::UpdaterExt;
mod runtime_manager; mod runtime_manager;
use runtime_manager::{ use runtime_manager::{
close_local_daemon_transport, ensure_managed_runtime, install_cli_shim, managed_daemon_logs, close_local_daemon_transport, install_cli_shim, managed_daemon_logs, managed_daemon_pairing,
managed_daemon_pairing, managed_daemon_status, managed_runtime_status, managed_daemon_status, managed_runtime_status, open_local_daemon_transport,
open_local_daemon_transport, restart_managed_daemon, send_local_daemon_transport_message, restart_managed_daemon, send_local_daemon_transport_message, start_managed_daemon,
start_managed_daemon, stop_managed_daemon, uninstall_cli_shim, stop_managed_daemon, uninstall_cli_shim, update_managed_daemon_tcp_settings,
update_managed_daemon_tcp_settings, LocalTransportState, ManagedTcpSettings, LocalTransportState, ManagedTcpSettings,
}; };
// Store zoom as u64 bits (f64 * 100 as integer for atomic ops) // Store zoom as u64 bits (f64 * 100 as integer for atomic ops)
@@ -169,7 +169,6 @@ fn maybe_run_managed_headless_command(app: &AppHandle) -> Result<bool, String> {
json!(managed_runtime_status(app_handle.clone()).await?) json!(managed_runtime_status(app_handle.clone()).await?)
} }
ManagedHeadlessCommand::Bootstrap => { ManagedHeadlessCommand::Bootstrap => {
ensure_managed_runtime(app_handle.clone()).await?;
json!(start_managed_daemon(app_handle.clone()).await?) json!(start_managed_daemon(app_handle.clone()).await?)
} }
ManagedHeadlessCommand::DaemonStatus => { ManagedHeadlessCommand::DaemonStatus => {
@@ -603,7 +602,6 @@ pub fn run() {
.plugin(tauri_plugin_websocket::init()) .plugin(tauri_plugin_websocket::init())
.invoke_handler(tauri::generate_handler![ .invoke_handler(tauri::generate_handler![
managed_runtime_status, managed_runtime_status,
ensure_managed_runtime,
managed_daemon_status, managed_daemon_status,
start_managed_daemon, start_managed_daemon,
stop_managed_daemon, stop_managed_daemon,
+103 -303
View File
@@ -6,11 +6,9 @@ use std::collections::HashMap;
use std::env; use std::env;
use std::fs; use std::fs;
use std::io; use std::io;
use std::iter;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::process::{Command, Stdio}; use std::process::{Command, Stdio};
use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::OnceLock;
use std::sync::{Arc, Mutex}; use std::sync::{Arc, Mutex};
use tauri::{AppHandle, Emitter, Manager, State}; use tauri::{AppHandle, Emitter, Manager, State};
use tokio::sync::mpsc; use tokio::sync::mpsc;
@@ -33,13 +31,6 @@ const DEFAULT_MANAGED_TCP_PORT: u16 = 7771;
const CLI_SHIM_NAME: &str = "paseo"; const CLI_SHIM_NAME: &str = "paseo";
#[cfg(windows)] #[cfg(windows)]
const CLI_SHIM_WINDOWS_NAME: &str = "paseo.cmd"; const CLI_SHIM_WINDOWS_NAME: &str = "paseo.cmd";
const CLI_INNER_DIR: &str = "bin";
const CLI_INNER_UNIX_NAME: &str = "paseo-managed";
#[cfg(windows)]
const CLI_INNER_WINDOWS_NAME: &str = "paseo-managed.cmd";
static RUNTIME_INSTALL_LOCK: OnceLock<Mutex<()>> = OnceLock::new();
#[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,8 +75,6 @@ struct PidFile {
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
struct ManagedPaths { struct ManagedPaths {
runtime_root: PathBuf,
stable_runtime_root: PathBuf,
managed_home: PathBuf, managed_home: PathBuf,
transport_path: PathBuf, transport_path: PathBuf,
logs_path: PathBuf, logs_path: PathBuf,
@@ -112,9 +101,7 @@ pub struct ManagedTcpSettings {
pub struct ManagedRuntimeStatus { pub struct ManagedRuntimeStatus {
pub runtime_id: String, pub runtime_id: String,
pub runtime_version: String, pub runtime_version: String,
pub bundled_runtime_root: String, pub runtime_root: String,
pub installed_runtime_root: String,
pub installed: bool,
pub managed_home: String, pub managed_home: String,
pub transport_type: String, pub transport_type: String,
pub transport_path: String, pub transport_path: String,
@@ -353,43 +340,39 @@ mod tests {
} }
#[test] #[test]
fn outer_cli_shim_contents_only_forwards_to_inner_launcher() { fn cli_shim_contents_runs_bundled_runtime_in_place() {
let contents = outer_cli_shim_contents(Path::new( let runtime_root =
"/Applications/Paseo.app/Contents/MacOS/paseo-managed", PathBuf::from("/Applications/Paseo.app/Contents/Resources/managed-runtime/runtime-1");
)); let paths = ManagedPaths {
assert!(contents managed_home: PathBuf::from("/Users/me/.paseo"),
.contains("exec \"/Applications/Paseo.app/Contents/MacOS/paseo-managed\" \"$@\"")); transport_path: PathBuf::from("/Users/me/.paseo/paseo.sock"),
logs_path: PathBuf::from("/Users/me/.paseo/daemon.log"),
state_file_path: PathBuf::from(
"/Users/me/Library/Application Support/Paseo/managed-state.json",
),
diagnostics_root: PathBuf::from("/Users/me/Library/Application Support/Paseo"),
};
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, &paths);
assert!(contents.contains("export PASEO_HOME='/Users/me/.paseo'"));
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-cli-shim")); assert!(!contents.contains("--paseo-cli-shim"));
} }
#[test]
fn inner_cli_launcher_path_lives_inside_managed_runtime_tree() {
let runtime_root = PathBuf::from("/tmp/paseo-desktop/runtime");
let inner_launcher = managed_cli_inner_launcher_path(&runtime_root);
assert_eq!(
inner_launcher,
PathBuf::from("/tmp/paseo-desktop/runtime/bin/paseo-managed")
);
}
#[test]
fn outer_shim_target_remains_stable_across_runtime_updates() {
let runtime_root = PathBuf::from("/tmp/paseo-desktop/runtime");
let first_runtime_root = PathBuf::from("/tmp/paseo-desktop/runtime/runtime-a");
let second_runtime_root = PathBuf::from("/tmp/paseo-desktop/runtime/runtime-b");
let first_target = managed_cli_inner_launcher_path(&runtime_root);
let second_target = managed_cli_inner_launcher_path(&runtime_root);
let first_contents = outer_cli_shim_contents(&first_target);
let second_contents = outer_cli_shim_contents(&second_target);
assert_ne!(first_runtime_root, second_runtime_root);
assert_eq!(first_target, second_target);
assert_eq!(first_contents, second_contents);
assert!(!first_contents.contains(first_runtime_root.to_string_lossy().as_ref()));
assert!(!first_contents.contains(second_runtime_root.to_string_lossy().as_ref()));
}
#[cfg(unix)] #[cfg(unix)]
#[test] #[test]
#[ignore = "requires a running local daemon socket"] #[ignore = "requires a running local daemon socket"]
@@ -426,89 +409,6 @@ mod tests {
} }
} }
fn copy_dir_recursive(source: &Path, target: &Path) -> Result<(), String> {
if !source.exists() {
return Err(format!(
"Managed runtime resource is missing at {}",
source.display()
));
}
fs::create_dir_all(target)
.map_err(|error| format!("Failed to create {}: {error}", target.display()))?;
for entry in fs::read_dir(source)
.map_err(|error| format!("Failed to read {}: {error}", source.display()))?
{
let entry = entry.map_err(|error| format!("Failed to read directory entry: {error}"))?;
let source_path = entry.path();
let target_path = target.join(entry.file_name());
let file_type = entry
.file_type()
.map_err(|error| format!("Failed to inspect {}: {error}", source_path.display()))?;
if file_type.is_symlink() {
let resolved = fs::canonicalize(&source_path).map_err(|error| {
format!(
"Failed to resolve symlink {}: {error}",
source_path.display()
)
})?;
let resolved_type = fs::metadata(&resolved).map_err(|error| {
format!(
"Failed to inspect resolved symlink {}: {error}",
resolved.display()
)
})?;
if resolved_type.is_dir() {
copy_dir_recursive(&resolved, &target_path)?;
continue;
}
if let Some(parent) = target_path.parent() {
fs::create_dir_all(parent)
.map_err(|error| format!("Failed to create {}: {error}", parent.display()))?;
}
fs::copy(&resolved, &target_path).map_err(|error| {
format!(
"Failed to copy {} to {}: {error}",
resolved.display(),
target_path.display()
)
})?;
continue;
}
if file_type.is_dir() {
copy_dir_recursive(&source_path, &target_path)?;
continue;
}
if let Some(parent) = target_path.parent() {
fs::create_dir_all(parent)
.map_err(|error| format!("Failed to create {}: {error}", parent.display()))?;
}
fs::copy(&source_path, &target_path).map_err(|error| {
format!(
"Failed to copy {} to {}: {error}",
source_path.display(),
target_path.display()
)
})?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let permissions = fs::metadata(&source_path)
.map_err(|error| format!("Failed to read {}: {error}", source_path.display()))?
.permissions()
.mode();
fs::set_permissions(&target_path, fs::Permissions::from_mode(permissions)).map_err(
|error| {
format!(
"Failed to preserve mode on {}: {error}",
target_path.display()
)
},
)?;
}
}
Ok(())
}
fn read_json_file<T: for<'de> Deserialize<'de>>(path: &Path) -> Result<T, String> { fn read_json_file<T: for<'de> Deserialize<'de>>(path: &Path) -> Result<T, String> {
let raw = fs::read_to_string(path) let raw = fs::read_to_string(path)
.map_err(|error| format!("Failed to read {}: {error}", path.display()))?; .map_err(|error| format!("Failed to read {}: {error}", path.display()))?;
@@ -616,17 +516,13 @@ fn default_transport_path(managed_home: &Path, diagnostics_root: &Path) -> PathB
} }
} }
fn resolve_paths(app: &AppHandle, runtime_id: &str) -> Result<ManagedPaths, String> { fn resolve_paths(app: &AppHandle) -> Result<ManagedPaths, String> {
if let Some(test_root) = resolve_test_root() { if let Some(test_root) = resolve_test_root() {
let runtime_root = resolve_override_path("PASEO_DESKTOP_MANAGED_RUNTIME_ROOT")
.unwrap_or_else(|| test_root.join("runtime").join(runtime_id));
let managed_home = resolve_override_path("PASEO_DESKTOP_MANAGED_HOME") let managed_home = resolve_override_path("PASEO_DESKTOP_MANAGED_HOME")
.unwrap_or_else(|| test_root.join(DEFAULT_MANAGED_HOME_DIRNAME)); .unwrap_or_else(|| test_root.join(DEFAULT_MANAGED_HOME_DIRNAME));
let transport_path = resolve_override_path("PASEO_DESKTOP_MANAGED_SOCKET_PATH") let transport_path = resolve_override_path("PASEO_DESKTOP_MANAGED_SOCKET_PATH")
.unwrap_or_else(|| default_transport_path(&managed_home, &test_root)); .unwrap_or_else(|| default_transport_path(&managed_home, &test_root));
return Ok(ManagedPaths { return Ok(ManagedPaths {
runtime_root,
stable_runtime_root: test_root.join("runtime"),
managed_home: managed_home.clone(), managed_home: managed_home.clone(),
transport_path, transport_path,
logs_path: managed_home.join("daemon.log"), logs_path: managed_home.join("daemon.log"),
@@ -636,8 +532,6 @@ fn resolve_paths(app: &AppHandle, runtime_id: &str) -> Result<ManagedPaths, Stri
} }
let root = app_data_root(app)?; let root = app_data_root(app)?;
let runtime_root = resolve_override_path("PASEO_DESKTOP_MANAGED_RUNTIME_ROOT")
.unwrap_or_else(|| root.join("runtime").join(runtime_id));
let managed_home = resolve_override_path("PASEO_DESKTOP_MANAGED_HOME").unwrap_or_else(|| { let managed_home = resolve_override_path("PASEO_DESKTOP_MANAGED_HOME").unwrap_or_else(|| {
#[cfg(target_os = "macos")] #[cfg(target_os = "macos")]
{ {
@@ -651,8 +545,6 @@ fn resolve_paths(app: &AppHandle, runtime_id: &str) -> Result<ManagedPaths, Stri
let transport_path = resolve_override_path("PASEO_DESKTOP_MANAGED_SOCKET_PATH") let transport_path = resolve_override_path("PASEO_DESKTOP_MANAGED_SOCKET_PATH")
.unwrap_or_else(|| default_transport_path(&managed_home, &root)); .unwrap_or_else(|| default_transport_path(&managed_home, &root));
Ok(ManagedPaths { Ok(ManagedPaths {
runtime_root,
stable_runtime_root: root.join("runtime"),
managed_home: managed_home.clone(), managed_home: managed_home.clone(),
transport_path, transport_path,
logs_path: managed_home.join("daemon.log"), logs_path: managed_home.join("daemon.log"),
@@ -854,19 +746,6 @@ fn powershell_double_quote(value: &str) -> String {
value.replace('`', "``").replace('"', "`\"") value.replace('`', "``").replace('"', "`\"")
} }
fn managed_cli_inner_launcher_path(launcher_root: &Path) -> PathBuf {
#[cfg(windows)]
{
return launcher_root
.join(CLI_INNER_DIR)
.join(CLI_INNER_WINDOWS_NAME);
}
#[cfg(not(windows))]
{
launcher_root.join(CLI_INNER_DIR).join(CLI_INNER_UNIX_NAME)
}
}
fn outer_cli_shim_path() -> Result<PathBuf, String> { fn outer_cli_shim_path() -> Result<PathBuf, String> {
#[cfg(target_os = "macos")] #[cfg(target_os = "macos")]
{ {
@@ -889,11 +768,13 @@ fn outer_cli_shim_path() -> Result<PathBuf, String> {
} }
} }
fn inner_cli_launcher_contents(paths: &ManagedPaths, manifest: &ManagedRuntimeManifest) -> String { fn cli_shim_contents(
let node = paths.runtime_root.join(&manifest.node_relative_path); runtime_root: &Path,
let cli = paths manifest: &ManagedRuntimeManifest,
.runtime_root paths: &ManagedPaths,
.join(&manifest.cli_entrypoint_relative_path); ) -> String {
let node = runtime_root.join(&manifest.node_relative_path);
let cli = runtime_root.join(&manifest.cli_entrypoint_relative_path);
#[cfg(windows)] #[cfg(windows)]
{ {
return format!( return format!(
@@ -914,17 +795,6 @@ fn inner_cli_launcher_contents(paths: &ManagedPaths, manifest: &ManagedRuntimeMa
} }
} }
fn outer_cli_shim_contents(inner_launcher: &Path) -> String {
#[cfg(windows)]
{
return format!("@echo off\r\n\"{}\" %*\r\n", inner_launcher.display());
}
#[cfg(not(windows))]
{
format!("#!/bin/sh\nexec \"{}\" \"$@\"\n", inner_launcher.display())
}
}
fn write_cli_launcher(path: &Path, contents: &str) -> Result<(), String> { fn write_cli_launcher(path: &Path, contents: &str) -> Result<(), String> {
if let Some(parent) = path.parent() { if let Some(parent) = path.parent() {
fs::create_dir_all(parent) fs::create_dir_all(parent)
@@ -941,30 +811,18 @@ fn write_cli_launcher(path: &Path, contents: &str) -> Result<(), String> {
Ok(()) Ok(())
} }
fn ensure_inner_cli_launcher(
paths: &ManagedPaths,
manifest: &ManagedRuntimeManifest,
) -> Result<PathBuf, String> {
let inner_launcher = managed_cli_inner_launcher_path(&paths.stable_runtime_root);
write_cli_launcher(
&inner_launcher,
&inner_cli_launcher_contents(paths, manifest),
)?;
Ok(inner_launcher)
}
fn cli_manual_instructions( fn cli_manual_instructions(
runtime_root: &Path,
paths: &ManagedPaths, paths: &ManagedPaths,
manifest: &ManagedRuntimeManifest, manifest: &ManagedRuntimeManifest,
) -> Result<CliManualInstructions, String> { ) -> Result<CliManualInstructions, String> {
let inner_launcher = ensure_inner_cli_launcher(paths, manifest)?;
let outer_shim = outer_cli_shim_path()?; let outer_shim = outer_cli_shim_path()?;
let contents = cli_shim_contents(runtime_root, manifest, paths);
#[cfg(windows)] #[cfg(windows)]
{ {
let target_dir = outer_shim let target_dir = outer_shim
.parent() .parent()
.ok_or_else(|| "CLI shim target is missing a parent directory.".to_string())?; .ok_or_else(|| "CLI shim target is missing a parent directory.".to_string())?;
let contents = outer_cli_shim_contents(&inner_launcher);
return Ok(CliManualInstructions { return Ok(CliManualInstructions {
title: "Install the Paseo CLI from PowerShell".to_string(), 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(), detail: "If the automatic install does not complete, run these commands in PowerShell to write the PATH shim manually.".to_string(),
@@ -991,12 +849,9 @@ fn cli_manual_instructions(
.as_ref() .as_ref()
), ),
target_path = shell_single_quote(outer_shim.to_string_lossy().as_ref()), target_path = shell_single_quote(outer_shim.to_string_lossy().as_ref()),
lines = iter::once("#!/bin/sh".to_string()) lines = contents
.chain(iter::once(format!( .lines()
"exec \"{}\" \"$@\"", .map(|line| shell_single_quote(line))
inner_launcher.to_string_lossy()
)))
.map(|line| shell_single_quote(&line))
.collect::<Vec<_>>() .collect::<Vec<_>>()
.join(" ") .join(" ")
), ),
@@ -1004,10 +859,13 @@ fn cli_manual_instructions(
} }
} }
fn detect_installed_cli_shim_path(paths: &ManagedPaths) -> Option<String> { fn detect_installed_cli_shim_path(
runtime_root: &Path,
paths: &ManagedPaths,
manifest: &ManagedRuntimeManifest,
) -> Option<String> {
let outer_shim = outer_cli_shim_path().ok()?; let outer_shim = outer_cli_shim_path().ok()?;
let inner_launcher = managed_cli_inner_launcher_path(&paths.stable_runtime_root); let expected = cli_shim_contents(runtime_root, manifest, paths);
let expected = outer_cli_shim_contents(&inner_launcher);
let actual = fs::read_to_string(&outer_shim).ok()?; let actual = fs::read_to_string(&outer_shim).ok()?;
(actual == expected).then(|| outer_shim.to_string_lossy().into_owned()) (actual == expected).then(|| outer_shim.to_string_lossy().into_owned())
} }
@@ -1032,79 +890,22 @@ fn write_state_file(path: &Path, value: &ManagedStateFile) -> Result<(), String>
write_json_file(path, value) write_json_file(path, value)
} }
fn runtime_install_lock() -> &'static Mutex<()> { fn ensure_runtime_ready_internal(app: &AppHandle) -> Result<ManagedRuntimeStatus, String> {
RUNTIME_INSTALL_LOCK.get_or_init(|| Mutex::new(()))
}
fn install_runtime_if_needed(source_root: &Path, target_root: &Path) -> Result<(), String> {
let _guard = runtime_install_lock()
.lock()
.map_err(|_| "Managed runtime install lock was poisoned.".to_string())?;
let target_manifest = target_root.join("runtime-manifest.json");
if target_manifest.exists() {
return Ok(());
}
if target_root.exists() {
fs::remove_dir_all(target_root).map_err(|error| {
format!(
"Failed to remove incomplete runtime {}: {error}",
target_root.display()
)
})?;
}
let staging_root = target_root.with_extension(format!("installing-{}", std::process::id()));
if staging_root.exists() {
fs::remove_dir_all(&staging_root).map_err(|error| {
format!(
"Failed to remove stale runtime staging dir {}: {error}",
staging_root.display()
)
})?;
}
copy_dir_recursive(source_root, &staging_root)?;
let staging_manifest = staging_root.join("runtime-manifest.json");
if !staging_manifest.exists() {
let _ = fs::remove_dir_all(&staging_root);
return Err(format!(
"Managed runtime staging manifest is missing at {}",
staging_manifest.display()
));
}
fs::rename(&staging_root, target_root).map_err(|error| {
let _ = fs::remove_dir_all(&staging_root);
format!(
"Failed to finalize managed runtime install from {} to {}: {error}",
staging_root.display(),
target_root.display()
)
})?;
Ok(())
}
fn ensure_runtime_installed_internal(app: &AppHandle) -> Result<ManagedRuntimeStatus, String> {
let (bundled_root, pointer) = load_bundled_runtime_pointer(app)?; let (bundled_root, pointer) = load_bundled_runtime_pointer(app)?;
let source_root = bundled_root.join(&pointer.relative_root); let runtime_root = bundled_root.join(&pointer.relative_root);
let paths = resolve_paths(app, &pointer.runtime_id)?; let paths = resolve_paths(app)?;
install_runtime_if_needed(&source_root, &paths.runtime_root)?; let manifest = load_runtime_manifest(&runtime_root)?;
let manifest = load_runtime_manifest(&paths.runtime_root)?;
if let Some(parent) = paths.transport_path.parent() { if let Some(parent) = paths.transport_path.parent() {
fs::create_dir_all(parent) fs::create_dir_all(parent)
.map_err(|error| format!("Failed to create {}: {error}", parent.display()))?; .map_err(|error| format!("Failed to create {}: {error}", parent.display()))?;
} }
fs::create_dir_all(&paths.managed_home) fs::create_dir_all(&paths.managed_home)
.map_err(|error| format!("Failed to create {}: {error}", paths.managed_home.display()))?; .map_err(|error| format!("Failed to create {}: {error}", paths.managed_home.display()))?;
ensure_inner_cli_launcher(&paths, &manifest)?;
let existing_state = read_state_file(&paths.state_file_path); let existing_state = read_state_file(&paths.state_file_path);
let target = managed_transport_target(&paths, existing_state.as_ref())?; let target = managed_transport_target(&paths, existing_state.as_ref())?;
let state = ManagedStateFile { let state = ManagedStateFile {
runtime_id: manifest.runtime_id.clone(), runtime_id: manifest.runtime_id.clone(),
runtime_root: paths.runtime_root.to_string_lossy().into_owned(), runtime_root: runtime_root.to_string_lossy().into_owned(),
managed_home: paths.managed_home.to_string_lossy().into_owned(), managed_home: paths.managed_home.to_string_lossy().into_owned(),
transport_type: target.transport_type.clone(), transport_type: target.transport_type.clone(),
transport_path: target.transport_path.clone(), transport_path: target.transport_path.clone(),
@@ -1122,9 +923,7 @@ fn ensure_runtime_installed_internal(app: &AppHandle) -> Result<ManagedRuntimeSt
Ok(ManagedRuntimeStatus { Ok(ManagedRuntimeStatus {
runtime_id: manifest.runtime_id, runtime_id: manifest.runtime_id,
runtime_version: manifest.runtime_version, runtime_version: manifest.runtime_version,
bundled_runtime_root: source_root.to_string_lossy().into_owned(), runtime_root: runtime_root.to_string_lossy().into_owned(),
installed_runtime_root: paths.runtime_root.to_string_lossy().into_owned(),
installed: true,
managed_home: paths.managed_home.to_string_lossy().into_owned(), managed_home: paths.managed_home.to_string_lossy().into_owned(),
transport_type: state.transport_type, transport_type: state.transport_type,
transport_path: state.transport_path, transport_path: state.transport_path,
@@ -1161,14 +960,16 @@ fn run_cli_json_command(
} }
fn managed_daemon_status_internal(app: &AppHandle) -> Result<ManagedDaemonStatus, String> { fn managed_daemon_status_internal(app: &AppHandle) -> Result<ManagedDaemonStatus, String> {
let status = ensure_runtime_installed_internal(app)?; let status = ensure_runtime_ready_internal(app)?;
let paths = resolve_paths(app, &status.runtime_id)?; let paths = resolve_paths(app)?;
let manifest = load_runtime_manifest(&paths.runtime_root)?; let runtime_root = PathBuf::from(&status.runtime_root);
let manifest = load_runtime_manifest(&runtime_root)?;
let cli_shim_path = detect_installed_cli_shim_path(&runtime_root, &paths, &manifest);
let state = read_state_file(&paths.state_file_path); let state = read_state_file(&paths.state_file_path);
let target = managed_transport_target(&paths, state.as_ref())?; let target = managed_transport_target(&paths, state.as_ref())?;
let tcp_settings = resolve_tcp_settings_from_state(state.as_ref()); let tcp_settings = resolve_tcp_settings_from_state(state.as_ref());
let cli_status = run_cli_json_command( let cli_status = run_cli_json_command(
&paths.runtime_root, &runtime_root,
&manifest, &manifest,
&[ &[
"daemon", "daemon",
@@ -1208,7 +1009,7 @@ fn managed_daemon_status_internal(app: &AppHandle) -> Result<ManagedDaemonStatus
Ok(ManagedDaemonStatus { Ok(ManagedDaemonStatus {
runtime_id: manifest.runtime_id, runtime_id: manifest.runtime_id,
runtime_version: manifest.runtime_version, runtime_version: manifest.runtime_version,
runtime_root: paths.runtime_root.to_string_lossy().into_owned(), runtime_root: runtime_root.to_string_lossy().into_owned(),
managed_home: paths.managed_home.to_string_lossy().into_owned(), managed_home: paths.managed_home.to_string_lossy().into_owned(),
transport_type: target.transport_type, transport_type: target.transport_type,
transport_path: target.transport_path, transport_path: target.transport_path,
@@ -1223,7 +1024,7 @@ fn managed_daemon_status_internal(app: &AppHandle) -> Result<ManagedDaemonStatus
tcp_listen: tcp_settings tcp_listen: tcp_settings
.enabled .enabled
.then(|| format!("{}:{}", tcp_settings.host, tcp_settings.port)), .then(|| format!("{}:{}", tcp_settings.host, tcp_settings.port)),
cli_shim_path: detect_installed_cli_shim_path(&paths), cli_shim_path,
}) })
} }
@@ -1298,13 +1099,13 @@ fn remove_cli_shim_via_macos_prompt(shim_path: &Path) -> Result<(), String> {
} }
fn install_cli_shim_internal(app: &AppHandle) -> Result<CliShimResult, String> { fn install_cli_shim_internal(app: &AppHandle) -> Result<CliShimResult, String> {
let status = ensure_runtime_installed_internal(app)?; let status = ensure_runtime_ready_internal(app)?;
let paths = resolve_paths(app, &status.runtime_id)?; let paths = resolve_paths(app)?;
let manifest = load_runtime_manifest(&paths.runtime_root)?; let runtime_root = PathBuf::from(&status.runtime_root);
let inner_launcher = ensure_inner_cli_launcher(&paths, &manifest)?; let manifest = load_runtime_manifest(&runtime_root)?;
let shim_path = outer_cli_shim_path()?; let shim_path = outer_cli_shim_path()?;
let shim_contents = outer_cli_shim_contents(&inner_launcher); let shim_contents = cli_shim_contents(&runtime_root, &manifest, &paths);
let manual_instructions = cli_manual_instructions(&paths, &manifest)?; let manual_instructions = cli_manual_instructions(&runtime_root, &paths, &manifest)?;
#[cfg(target_os = "macos")] #[cfg(target_os = "macos")]
let install_result = install_cli_shim_via_macos_prompt(&shim_path, &shim_contents); let install_result = install_cli_shim_via_macos_prompt(&shim_path, &shim_contents);
@@ -1317,7 +1118,7 @@ fn install_cli_shim_internal(app: &AppHandle) -> Result<CliShimResult, String> {
Ok(()) => { Ok(()) => {
let mut state = read_state_file(&paths.state_file_path).unwrap_or(ManagedStateFile { let mut state = read_state_file(&paths.state_file_path).unwrap_or(ManagedStateFile {
runtime_id: status.runtime_id.clone(), runtime_id: status.runtime_id.clone(),
runtime_root: paths.runtime_root.to_string_lossy().into_owned(), runtime_root: runtime_root.to_string_lossy().into_owned(),
managed_home: paths.managed_home.to_string_lossy().into_owned(), managed_home: paths.managed_home.to_string_lossy().into_owned(),
transport_type: status.transport_type.clone(), transport_type: status.transport_type.clone(),
transport_path: status.transport_path.clone(), transport_path: status.transport_path.clone(),
@@ -1362,9 +1163,11 @@ fn install_cli_shim_internal(app: &AppHandle) -> Result<CliShimResult, String> {
} }
fn uninstall_cli_shim_internal(app: &AppHandle) -> Result<CliShimResult, String> { fn uninstall_cli_shim_internal(app: &AppHandle) -> Result<CliShimResult, String> {
let status = ensure_runtime_installed_internal(app)?; let status = ensure_runtime_ready_internal(app)?;
let paths = resolve_paths(app, &status.runtime_id)?; let paths = resolve_paths(app)?;
let shim_path = detect_installed_cli_shim_path(&paths) 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, &paths, &manifest)
.map(PathBuf::from) .map(PathBuf::from)
.or_else(|| { .or_else(|| {
read_state_file(&paths.state_file_path) read_state_file(&paths.state_file_path)
@@ -1383,7 +1186,7 @@ fn uninstall_cli_shim_internal(app: &AppHandle) -> Result<CliShimResult, String>
} }
let mut state = read_state_file(&paths.state_file_path).unwrap_or(ManagedStateFile { let mut state = read_state_file(&paths.state_file_path).unwrap_or(ManagedStateFile {
runtime_id: status.runtime_id.clone(), runtime_id: status.runtime_id.clone(),
runtime_root: paths.runtime_root.to_string_lossy().into_owned(), runtime_root: runtime_root.to_string_lossy().into_owned(),
managed_home: paths.managed_home.to_string_lossy().into_owned(), managed_home: paths.managed_home.to_string_lossy().into_owned(),
transport_type: status.transport_type.clone(), transport_type: status.transport_type.clone(),
transport_path: status.transport_path.clone(), transport_path: status.transport_path.clone(),
@@ -1404,8 +1207,8 @@ fn uninstall_cli_shim_internal(app: &AppHandle) -> Result<CliShimResult, String>
} }
fn start_managed_daemon_internal(app: &AppHandle) -> Result<ManagedDaemonStatus, String> { fn start_managed_daemon_internal(app: &AppHandle) -> Result<ManagedDaemonStatus, String> {
let status = ensure_runtime_installed_internal(app)?; let status = ensure_runtime_ready_internal(app)?;
let paths = resolve_paths(app, &status.runtime_id)?; let paths = resolve_paths(app)?;
let existing_status = managed_daemon_status_internal(app)?; let existing_status = managed_daemon_status_internal(app)?;
if existing_status.daemon_running { if existing_status.daemon_running {
return Ok(existing_status); return Ok(existing_status);
@@ -1414,11 +1217,12 @@ fn start_managed_daemon_internal(app: &AppHandle) -> Result<ManagedDaemonStatus,
fs::create_dir_all(parent) fs::create_dir_all(parent)
.map_err(|error| format!("Failed to create {}: {error}", parent.display()))?; .map_err(|error| format!("Failed to create {}: {error}", parent.display()))?;
} }
let manifest = load_runtime_manifest(&paths.runtime_root)?; let runtime_root = PathBuf::from(&status.runtime_root);
let manifest = load_runtime_manifest(&runtime_root)?;
let state = read_state_file(&paths.state_file_path); let state = read_state_file(&paths.state_file_path);
let target = managed_transport_target(&paths, state.as_ref())?; let target = managed_transport_target(&paths, state.as_ref())?;
let output = cli_command( let output = cli_command(
&paths.runtime_root, &runtime_root,
&manifest, &manifest,
&[ &[
"start", "start",
@@ -1451,11 +1255,12 @@ fn start_managed_daemon_internal(app: &AppHandle) -> Result<ManagedDaemonStatus,
} }
fn stop_managed_daemon_internal(app: &AppHandle) -> Result<ManagedDaemonStatus, String> { fn stop_managed_daemon_internal(app: &AppHandle) -> Result<ManagedDaemonStatus, String> {
let status = ensure_runtime_installed_internal(app)?; let status = ensure_runtime_ready_internal(app)?;
let paths = resolve_paths(app, &status.runtime_id)?; let paths = resolve_paths(app)?;
let manifest = load_runtime_manifest(&paths.runtime_root)?; let runtime_root = PathBuf::from(&status.runtime_root);
let manifest = load_runtime_manifest(&runtime_root)?;
let output = cli_command( let output = cli_command(
&paths.runtime_root, &runtime_root,
&manifest, &manifest,
&[ &[
"daemon", "daemon",
@@ -1481,13 +1286,14 @@ fn stop_managed_daemon_internal(app: &AppHandle) -> Result<ManagedDaemonStatus,
} }
fn restart_managed_daemon_internal(app: &AppHandle) -> Result<ManagedDaemonStatus, String> { fn restart_managed_daemon_internal(app: &AppHandle) -> Result<ManagedDaemonStatus, String> {
let status = ensure_runtime_installed_internal(app)?; let status = ensure_runtime_ready_internal(app)?;
let paths = resolve_paths(app, &status.runtime_id)?; let paths = resolve_paths(app)?;
let manifest = load_runtime_manifest(&paths.runtime_root)?; let runtime_root = PathBuf::from(&status.runtime_root);
let manifest = load_runtime_manifest(&runtime_root)?;
let state = read_state_file(&paths.state_file_path); let state = read_state_file(&paths.state_file_path);
let target = managed_transport_target(&paths, state.as_ref())?; let target = managed_transport_target(&paths, state.as_ref())?;
let output = cli_command( let output = cli_command(
&paths.runtime_root, &runtime_root,
&manifest, &manifest,
&[ &[
"daemon", "daemon",
@@ -1521,11 +1327,11 @@ fn update_managed_tcp_settings_internal(
if settings.enabled { if settings.enabled {
parse_tcp_listen(&format!("{}:{}", settings.host.trim(), settings.port))?; parse_tcp_listen(&format!("{}:{}", settings.host.trim(), settings.port))?;
} }
let status = ensure_runtime_installed_internal(app)?; let status = ensure_runtime_ready_internal(app)?;
let paths = resolve_paths(app, &status.runtime_id)?; let paths = resolve_paths(app)?;
let mut state = read_state_file(&paths.state_file_path).unwrap_or(ManagedStateFile { let mut state = read_state_file(&paths.state_file_path).unwrap_or(ManagedStateFile {
runtime_id: status.runtime_id.clone(), runtime_id: status.runtime_id.clone(),
runtime_root: paths.runtime_root.to_string_lossy().into_owned(), runtime_root: status.runtime_root.clone(),
managed_home: paths.managed_home.to_string_lossy().into_owned(), managed_home: paths.managed_home.to_string_lossy().into_owned(),
transport_type: status.transport_type.clone(), transport_type: status.transport_type.clone(),
transport_path: status.transport_path.clone(), transport_path: status.transport_path.clone(),
@@ -1548,18 +1354,11 @@ fn update_managed_tcp_settings_internal(
#[tauri::command] #[tauri::command]
pub async fn managed_runtime_status(app: AppHandle) -> Result<ManagedRuntimeStatus, String> { pub async fn managed_runtime_status(app: AppHandle) -> Result<ManagedRuntimeStatus, String> {
tauri::async_runtime::spawn_blocking(move || ensure_runtime_installed_internal(&app)) tauri::async_runtime::spawn_blocking(move || ensure_runtime_ready_internal(&app))
.await .await
.map_err(|error| format!("Managed runtime status task failed: {error}"))? .map_err(|error| format!("Managed runtime status task failed: {error}"))?
} }
#[tauri::command]
pub async fn ensure_managed_runtime(app: AppHandle) -> Result<ManagedRuntimeStatus, String> {
tauri::async_runtime::spawn_blocking(move || ensure_runtime_installed_internal(&app))
.await
.map_err(|error| format!("Managed runtime install task failed: {error}"))?
}
#[tauri::command] #[tauri::command]
pub async fn managed_daemon_status(app: AppHandle) -> Result<ManagedDaemonStatus, String> { pub async fn managed_daemon_status(app: AppHandle) -> Result<ManagedDaemonStatus, String> {
tauri::async_runtime::spawn_blocking(move || managed_daemon_status_internal(&app)) tauri::async_runtime::spawn_blocking(move || managed_daemon_status_internal(&app))
@@ -1591,8 +1390,8 @@ pub async fn restart_managed_daemon(app: AppHandle) -> Result<ManagedDaemonStatu
#[tauri::command] #[tauri::command]
pub async fn managed_daemon_logs(app: AppHandle) -> Result<ManagedDaemonLogs, String> { pub async fn managed_daemon_logs(app: AppHandle) -> Result<ManagedDaemonLogs, String> {
tauri::async_runtime::spawn_blocking(move || { tauri::async_runtime::spawn_blocking(move || {
let status = ensure_runtime_installed_internal(&app)?; ensure_runtime_ready_internal(&app)?;
let paths = resolve_paths(&app, &status.runtime_id)?; let paths = resolve_paths(&app)?;
Ok(ManagedDaemonLogs { Ok(ManagedDaemonLogs {
log_path: paths.logs_path.to_string_lossy().into_owned(), log_path: paths.logs_path.to_string_lossy().into_owned(),
contents: tail_log(&paths.logs_path, 400), contents: tail_log(&paths.logs_path, 400),
@@ -1605,11 +1404,12 @@ pub async fn managed_daemon_logs(app: AppHandle) -> Result<ManagedDaemonLogs, St
#[tauri::command] #[tauri::command]
pub async fn managed_daemon_pairing(app: AppHandle) -> Result<ManagedPairingOffer, String> { pub async fn managed_daemon_pairing(app: AppHandle) -> Result<ManagedPairingOffer, String> {
tauri::async_runtime::spawn_blocking(move || { tauri::async_runtime::spawn_blocking(move || {
let status = ensure_runtime_installed_internal(&app)?; let status = ensure_runtime_ready_internal(&app)?;
let paths = resolve_paths(&app, &status.runtime_id)?; let paths = resolve_paths(&app)?;
let manifest = load_runtime_manifest(&paths.runtime_root)?; let runtime_root = PathBuf::from(&status.runtime_root);
let manifest = load_runtime_manifest(&runtime_root)?;
let value = run_cli_json_command( let value = run_cli_json_command(
&paths.runtime_root, &runtime_root,
&manifest, &manifest,
&[ &[
"daemon", "daemon",