Simplify managed runtime to execute in place from app bundle
This commit is contained in:
@@ -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,
|
||||
registryHasDirectEndpoint,
|
||||
reconcileDesktopStartupRegistry,
|
||||
resolveManagedDesktopStartupStatus,
|
||||
type HostProfile,
|
||||
} from './daemon-registry-context'
|
||||
|
||||
@@ -240,3 +241,83 @@ describe('reconcileDesktopStartupRegistry', () => {
|
||||
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 { decodeOfferFragmentPayload, normalizeHostPort } from '@/utils/daemon-endpoints'
|
||||
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 {
|
||||
getManagedDaemonStatus,
|
||||
type ManagedDaemonStatus,
|
||||
shouldUseManagedDesktopDaemon,
|
||||
startManagedDaemon,
|
||||
@@ -460,6 +462,18 @@ export function reconcileDesktopStartupRegistry(
|
||||
return next
|
||||
}
|
||||
|
||||
export async function resolveManagedDesktopStartupStatus(input: {
|
||||
settings: Pick<AppSettings, 'manageBuiltInDaemon'>
|
||||
startManagedDaemonFn?: () => Promise<ManagedDaemonStatus>
|
||||
getManagedDaemonStatusFn?: () => Promise<ManagedDaemonStatus>
|
||||
}): Promise<ManagedDaemonStatus> {
|
||||
if (input.settings.manageBuiltInDaemon) {
|
||||
return await (input.startManagedDaemonFn ?? startManagedDaemon)()
|
||||
}
|
||||
|
||||
return await (input.getManagedDaemonStatusFn ?? getManagedDaemonStatus)()
|
||||
}
|
||||
|
||||
async function probeManagedStartupTarget(input: {
|
||||
managedDaemon: ManagedDaemonStatus
|
||||
cancelled?: () => boolean
|
||||
@@ -551,6 +565,7 @@ export function DaemonRegistryProvider({ children }: { children: ReactNode }) {
|
||||
const queryClient = useQueryClient()
|
||||
const desktopStartupReconciledRef = useRef(false)
|
||||
const localhostBootstrapAttemptedRef = useRef(false)
|
||||
const { settings, isLoading: settingsLoading } = useAppSettings()
|
||||
const {
|
||||
data: daemons = [],
|
||||
isPending,
|
||||
@@ -667,6 +682,7 @@ export function DaemonRegistryProvider({ children }: { children: ReactNode }) {
|
||||
|
||||
useEffect(() => {
|
||||
if (isPending) return
|
||||
if (settingsLoading) return
|
||||
if (!shouldUseManagedDesktopDaemon()) return
|
||||
if (desktopStartupReconciledRef.current) return
|
||||
desktopStartupReconciledRef.current = true
|
||||
@@ -682,11 +698,15 @@ export function DaemonRegistryProvider({ children }: { children: ReactNode }) {
|
||||
|
||||
let managed: ManagedHostReconciliationInput | null = null
|
||||
try {
|
||||
const managedDaemon = await startManagedDaemon()
|
||||
managed = await probeManagedConnectionUntilReady({
|
||||
managedDaemon,
|
||||
cancelled: () => cancelled,
|
||||
const managedDaemon = await resolveManagedDesktopStartupStatus({
|
||||
settings,
|
||||
})
|
||||
if (managedDaemon.daemonRunning) {
|
||||
managed = await probeManagedConnectionUntilReady({
|
||||
managedDaemon,
|
||||
cancelled: () => cancelled,
|
||||
})
|
||||
}
|
||||
} catch (managedBootstrapError) {
|
||||
if (!cancelled) {
|
||||
console.warn(
|
||||
@@ -750,6 +770,8 @@ export function DaemonRegistryProvider({ children }: { children: ReactNode }) {
|
||||
isPending,
|
||||
persist,
|
||||
readDaemons,
|
||||
settings.manageBuiltInDaemon,
|
||||
settingsLoading,
|
||||
])
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@@ -7,6 +7,7 @@ import { StyleSheet } from "react-native-unistyles";
|
||||
import { ArrowUpRight } from "lucide-react-native";
|
||||
import { AdaptiveModalSheet } from "@/components/adaptive-modal-sheet";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useAppSettings } from "@/hooks/use-settings";
|
||||
import { confirmDialog } from "@/utils/confirm-dialog";
|
||||
import { openExternalUrl } from "@/utils/open-external-url";
|
||||
import {
|
||||
@@ -20,6 +21,8 @@ import {
|
||||
installManagedCliShim,
|
||||
restartManagedDaemon,
|
||||
shouldUseManagedDesktopDaemon,
|
||||
startManagedDaemon,
|
||||
stopManagedDaemon,
|
||||
uninstallManagedCliShim,
|
||||
type ManagedDaemonLogs,
|
||||
type ManagedPairingOffer,
|
||||
@@ -33,9 +36,11 @@ export interface LocalDaemonSectionProps {
|
||||
|
||||
export function LocalDaemonSection({ appVersion }: LocalDaemonSectionProps) {
|
||||
const showSection = shouldUseManagedDesktopDaemon();
|
||||
const { settings, updateSettings } = useAppSettings();
|
||||
const [managedStatus, setManagedStatus] = useState<ManagedDaemonStatus | null>(null);
|
||||
const [statusError, setStatusError] = useState<string | null>(null);
|
||||
const [isRestartingDaemon, setIsRestartingDaemon] = useState(false);
|
||||
const [isUpdatingDaemonManagement, setIsUpdatingDaemonManagement] = useState(false);
|
||||
const [isInstallingCli, setIsInstallingCli] = useState(false);
|
||||
const [statusMessage, setStatusMessage] = 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 daemonVersionMismatch = isVersionMismatch(appVersion, managedStatus?.runtimeVersion ?? null);
|
||||
const daemonVersionHint =
|
||||
statusError ?? (managedStatus?.daemonRunning ? "Running." : "Not running.");
|
||||
const daemonStatusStateText =
|
||||
statusError ??
|
||||
(managedStatus?.daemonRunning
|
||||
? managedStatus?.daemonStatus ?? "running"
|
||||
: "not running");
|
||||
const daemonStatusDetailText = `PID ${managedStatus?.daemonPid ? managedStatus.daemonPid : "—"}`;
|
||||
const isDaemonManagementPaused = !settings.manageBuiltInDaemon;
|
||||
const daemonActionLabel = managedStatus?.daemonRunning ? "Restart daemon" : "Start daemon";
|
||||
const daemonActionMessage = managedStatus?.daemonRunning
|
||||
? "Restarts the built-in daemon."
|
||||
: isDaemonManagementPaused
|
||||
? "Starts the built-in daemon manually. Paseo will not auto-start it while paused."
|
||||
: "Starts the built-in daemon.";
|
||||
|
||||
const handleUpdateLocalDaemon = useCallback(() => {
|
||||
if (!showSection) {
|
||||
@@ -90,10 +106,11 @@ export function LocalDaemonSection({ appVersion }: LocalDaemonSectionProps) {
|
||||
}
|
||||
|
||||
void confirmDialog({
|
||||
title: "Restart daemon",
|
||||
message:
|
||||
"This will restart the built-in daemon. The app will reconnect automatically.",
|
||||
confirmLabel: "Restart",
|
||||
title: daemonActionLabel,
|
||||
message: managedStatus?.daemonRunning
|
||||
? "This will restart the built-in daemon. The app will reconnect automatically."
|
||||
: "This will start the built-in daemon.",
|
||||
confirmLabel: daemonActionLabel,
|
||||
cancelLabel: "Cancel",
|
||||
})
|
||||
.then((confirmed) => {
|
||||
@@ -104,26 +121,100 @@ export function LocalDaemonSection({ appVersion }: LocalDaemonSectionProps) {
|
||||
setIsRestartingDaemon(true);
|
||||
setStatusMessage(null);
|
||||
|
||||
void restartManagedDaemon()
|
||||
const action = managedStatus?.daemonRunning ? restartManagedDaemon : startManagedDaemon;
|
||||
|
||||
void action()
|
||||
.then((status) => {
|
||||
setManagedStatus(status);
|
||||
setStatusMessage("Daemon restarted.");
|
||||
setStatusMessage(
|
||||
managedStatus?.daemonRunning ? "Daemon restarted." : "Daemon started."
|
||||
);
|
||||
return loadManagedStatus();
|
||||
})
|
||||
.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);
|
||||
setStatusMessage(`Restart failed: ${message}`);
|
||||
setStatusMessage(`${daemonActionLabel} failed: ${message}`);
|
||||
})
|
||||
.finally(() => {
|
||||
setIsRestartingDaemon(false);
|
||||
});
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("[Settings] Failed to open managed daemon restart confirmation", error);
|
||||
Alert.alert("Error", "Unable to open the restart confirmation dialog.");
|
||||
console.error("[Settings] Failed to open managed daemon action confirmation", error);
|
||||
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(() => {
|
||||
if (!showSection || isInstallingCli) {
|
||||
@@ -255,26 +346,59 @@ export function LocalDaemonSection({ appVersion }: LocalDaemonSectionProps) {
|
||||
<View style={styles.card}>
|
||||
<View style={styles.row}>
|
||||
<View style={styles.rowContent}>
|
||||
<Text style={styles.rowTitle}>Version</Text>
|
||||
<Text style={styles.hintText}>{daemonVersionHint}</Text>
|
||||
<Text style={styles.rowTitle}>Status</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>
|
||||
<Text style={styles.valueText}>{localDaemonVersionText}</Text>
|
||||
</View>
|
||||
<View style={[styles.row, styles.rowBorder]}>
|
||||
<View style={styles.rowContent}>
|
||||
<Text style={styles.rowTitle}>Restart daemon</Text>
|
||||
<Text style={styles.hintText}>Restarts the built-in daemon.</Text>
|
||||
<Text style={styles.rowTitle}>Daemon management</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 ? (
|
||||
<Text style={styles.statusText}>{statusMessage}</Text>
|
||||
) : null}
|
||||
</View>
|
||||
<Button
|
||||
variant="secondary"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
style={styles.primaryActionButton}
|
||||
onPress={handleUpdateLocalDaemon}
|
||||
disabled={isRestartingDaemon}
|
||||
>
|
||||
{isRestartingDaemon ? "Restarting..." : "Restart daemon"}
|
||||
{isRestartingDaemon
|
||||
? managedStatus?.daemonRunning
|
||||
? "Restarting..."
|
||||
: "Starting..."
|
||||
: daemonActionLabel}
|
||||
</Button>
|
||||
</View>
|
||||
<View style={[styles.row, styles.rowBorder]}>
|
||||
@@ -286,8 +410,9 @@ export function LocalDaemonSection({ appVersion }: LocalDaemonSectionProps) {
|
||||
{cliStatusMessage ? <Text style={styles.statusText}>{cliStatusMessage}</Text> : null}
|
||||
</View>
|
||||
<Button
|
||||
variant="secondary"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
style={styles.secondaryActionButton}
|
||||
onPress={handleToggleCliShim}
|
||||
disabled={isInstallingCli}
|
||||
>
|
||||
@@ -309,12 +434,12 @@ export function LocalDaemonSection({ appVersion }: LocalDaemonSectionProps) {
|
||||
</View>
|
||||
<View style={styles.actionGroup}>
|
||||
{(managedLogs?.logPath ?? managedStatus?.logPath) ? (
|
||||
<Button variant="secondary" size="sm" onPress={handleCopyLogPath}>
|
||||
<Button variant="outline" size="sm" onPress={handleCopyLogPath}>
|
||||
Copy path
|
||||
</Button>
|
||||
) : null}
|
||||
<Button
|
||||
variant="secondary"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onPress={handleOpenLogs}
|
||||
disabled={!managedLogs}
|
||||
@@ -330,7 +455,7 @@ export function LocalDaemonSection({ appVersion }: LocalDaemonSectionProps) {
|
||||
Connect your phone to this computer.
|
||||
</Text>
|
||||
</View>
|
||||
<Button variant="secondary" size="sm" onPress={handleOpenPairingModal}>
|
||||
<Button variant="outline" size="sm" style={styles.secondaryActionButton} onPress={handleOpenPairingModal}>
|
||||
Pair device
|
||||
</Button>
|
||||
</View>
|
||||
@@ -560,6 +685,18 @@ const styles = StyleSheet.create((theme) => ({
|
||||
actionGroup: {
|
||||
flexDirection: "row",
|
||||
gap: theme.spacing[2],
|
||||
flexWrap: "wrap",
|
||||
justifyContent: "flex-end",
|
||||
},
|
||||
statusValueGroup: {
|
||||
alignItems: "flex-end",
|
||||
gap: 2,
|
||||
},
|
||||
primaryActionButton: {
|
||||
minWidth: 124,
|
||||
},
|
||||
secondaryActionButton: {
|
||||
minWidth: 112,
|
||||
},
|
||||
rowTitle: {
|
||||
color: theme.colors.foreground,
|
||||
@@ -570,6 +707,10 @@ const styles = StyleSheet.create((theme) => ({
|
||||
fontSize: theme.fontSize.sm,
|
||||
fontWeight: theme.fontWeight.normal,
|
||||
},
|
||||
valueSubtext: {
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.xs,
|
||||
},
|
||||
hintText: {
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.xs,
|
||||
|
||||
@@ -4,9 +4,7 @@ import { getTauri, isTauriEnvironment } from "@/utils/tauri";
|
||||
export type ManagedRuntimeStatus = {
|
||||
runtimeId: string;
|
||||
runtimeVersion: string;
|
||||
bundledRuntimeRoot: string;
|
||||
installedRuntimeRoot: string;
|
||||
installed: boolean;
|
||||
runtimeRoot: string;
|
||||
managedHome: string;
|
||||
transportType: string;
|
||||
transportPath: string;
|
||||
@@ -103,9 +101,7 @@ function parseManagedRuntimeStatus(raw: unknown): ManagedRuntimeStatus {
|
||||
return {
|
||||
runtimeId: toStringOrNull(raw.runtimeId) ?? "",
|
||||
runtimeVersion: toStringOrNull(raw.runtimeVersion) ?? "",
|
||||
bundledRuntimeRoot: toStringOrNull(raw.bundledRuntimeRoot) ?? "",
|
||||
installedRuntimeRoot: toStringOrNull(raw.installedRuntimeRoot) ?? "",
|
||||
installed: raw.installed === true,
|
||||
runtimeRoot: toStringOrNull(raw.runtimeRoot) ?? "",
|
||||
managedHome: toStringOrNull(raw.managedHome) ?? "",
|
||||
transportType: toStringOrNull(raw.transportType) ?? "socket",
|
||||
transportPath: toStringOrNull(raw.transportPath) ?? "",
|
||||
@@ -189,10 +185,6 @@ export function shouldUseManagedDesktopDaemon(): boolean {
|
||||
return isTauriEnvironment() && getTauri() !== null;
|
||||
}
|
||||
|
||||
export async function ensureManagedRuntime(): Promise<ManagedRuntimeStatus> {
|
||||
return parseManagedRuntimeStatus(await invokeDesktopCommand("ensure_managed_runtime"));
|
||||
}
|
||||
|
||||
export async function getManagedRuntimeStatus(): Promise<ManagedRuntimeStatus> {
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -2,16 +2,18 @@ import { useCallback } from "react";
|
||||
import AsyncStorage from "@react-native-async-storage/async-storage";
|
||||
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 APP_SETTINGS_QUERY_KEY = ["app-settings"];
|
||||
|
||||
export interface AppSettings {
|
||||
theme: "dark" | "light" | "auto";
|
||||
manageBuiltInDaemon: boolean;
|
||||
}
|
||||
|
||||
const DEFAULT_APP_SETTINGS: AppSettings = {
|
||||
export const DEFAULT_APP_SETTINGS: AppSettings = {
|
||||
theme: "dark",
|
||||
manageBuiltInDaemon: true,
|
||||
};
|
||||
|
||||
export interface UseAppSettingsReturn {
|
||||
@@ -66,7 +68,7 @@ export function useAppSettings(): UseAppSettingsReturn {
|
||||
};
|
||||
}
|
||||
|
||||
async function loadSettingsFromStorage(): Promise<AppSettings> {
|
||||
export async function loadSettingsFromStorage(): Promise<AppSettings> {
|
||||
try {
|
||||
const stored = await AsyncStorage.getItem(APP_SETTINGS_KEY);
|
||||
if (stored) {
|
||||
@@ -98,6 +100,9 @@ function pickAppSettingsFromLegacy(legacy: Record<string, unknown>): Partial<App
|
||||
if (legacy.theme === "dark" || legacy.theme === "light" || legacy.theme === "auto") {
|
||||
result.theme = legacy.theme;
|
||||
}
|
||||
if (typeof legacy.manageBuiltInDaemon === "boolean") {
|
||||
result.manageBuiltInDaemon = legacy.manageBuiltInDaemon;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
|
||||
@@ -25,10 +25,6 @@ const currentRuntimePointer = JSON.parse(
|
||||
);
|
||||
const currentRuntimeId = currentRuntimePointer.runtimeId;
|
||||
|
||||
function buildRuntimeId(version) {
|
||||
return currentRuntimeId.replace(currentRuntimeVersion, version);
|
||||
}
|
||||
|
||||
function resolvePackagedBinary() {
|
||||
if (process.platform === "darwin") {
|
||||
return path.join(
|
||||
@@ -103,87 +99,6 @@ function escapeForRegExp(value) {
|
||||
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) {
|
||||
const { stdout, stderr } = await execFileAsync(binaryPath, args, {
|
||||
@@ -223,6 +138,26 @@ async function runWorkspaceCli(args, env) {
|
||||
return { stdout, stderr, json };
|
||||
}
|
||||
|
||||
async function runBundledRuntimeCli(runtimeRoot, managedHome, args, env) {
|
||||
const manifest = JSON.parse(
|
||||
await fs.readFile(path.join(runtimeRoot, "runtime-manifest.json"), "utf8")
|
||||
);
|
||||
const { stdout, stderr } = await execFileAsync(
|
||||
path.join(runtimeRoot, manifest.nodeRelativePath),
|
||||
[path.join(runtimeRoot, manifest.cliEntrypointRelativePath), ...args],
|
||||
{
|
||||
cwd: repoRoot,
|
||||
env: {
|
||||
...process.env,
|
||||
...env,
|
||||
PASEO_HOME: managedHome,
|
||||
},
|
||||
maxBuffer: 10 * 1024 * 1024,
|
||||
}
|
||||
);
|
||||
return { stdout, stderr };
|
||||
}
|
||||
|
||||
async function readDaemonStatus(home, env) {
|
||||
const result = await runWorkspaceCli(["daemon", "status", "--home", home, "--json"], env);
|
||||
return result.json ?? {};
|
||||
@@ -466,6 +401,7 @@ const externalPort = await getAvailablePort();
|
||||
const externalEndpoint = `127.0.0.1:${externalPort}`;
|
||||
const relayPort = await getAvailablePort();
|
||||
const relayEndpoint = `127.0.0.1:${relayPort}`;
|
||||
const managedRuntimeDir = path.join(testRoot, "runtime");
|
||||
await fs.mkdir(testRoot, { recursive: true });
|
||||
await fs.mkdir(fakePaseoHome, { recursive: true });
|
||||
await fs.mkdir(cliScratchHome, { recursive: true });
|
||||
@@ -473,7 +409,6 @@ await fs.mkdir(externalHome, { recursive: true });
|
||||
await fs.writeFile(path.join(fakePaseoHome, "sentinel.txt"), "do not touch\n", "utf8");
|
||||
|
||||
const fakePaseoSnapshotBefore = await snapshotTree(fakePaseoHome);
|
||||
const seededUpgradeState = await seedPreviousManagedRuntime(testRoot);
|
||||
const managedEnv = {
|
||||
...process.env,
|
||||
HOME: fakeHome,
|
||||
@@ -492,7 +427,7 @@ const managedEnv = {
|
||||
let externalPid = null;
|
||||
let startedTemporaryExternalDaemon = false;
|
||||
let relayProcess = null;
|
||||
const forbiddenManagedReferences = ["127.0.0.1:6767", fakePaseoHome];
|
||||
const forbiddenManagedReferences = ["127.0.0.1:6767", fakePaseoHome, managedRuntimeDir];
|
||||
|
||||
try {
|
||||
logStep(`Starting isolated local relay on ${relayEndpoint}`);
|
||||
@@ -544,39 +479,40 @@ try {
|
||||
|
||||
logStep("Bootstrapping managed runtime from packaged desktop binary");
|
||||
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.runtimeVersion, currentRuntimeVersion);
|
||||
assert.match(runtimeStatus.json.managedHome, new RegExp(`^${escapeForRegExp(testRoot)}`));
|
||||
assert.equal(
|
||||
runtimeStatus.json.installedRuntimeRoot,
|
||||
path.join(testRoot, "runtime", currentRuntimeId),
|
||||
"managed runtime should install under the current bundled runtime id"
|
||||
assert.match(
|
||||
runtimeStatus.json.runtimeRoot,
|
||||
new RegExp(escapeForRegExp(currentRuntimeId)),
|
||||
"runtime status should point into the bundled runtime selected by current-runtime.json"
|
||||
);
|
||||
assert.notEqual(
|
||||
runtimeStatus.json.installedRuntimeRoot,
|
||||
seededUpgradeState.previousRuntimeRoot,
|
||||
"packaged app should adopt the bundled runtime instead of the seeded previous runtime"
|
||||
assert.equal(
|
||||
runtimeStatus.json.runtimeRoot.startsWith(testRoot),
|
||||
false,
|
||||
"runtime status should not resolve into managed app data"
|
||||
);
|
||||
assert.ok(runtimeStatus.json.transportType === "socket" || runtimeStatus.json.transportType === "pipe");
|
||||
assert.notEqual(runtimeStatus.json.transportPath, "127.0.0.1:6767");
|
||||
assertNoForbiddenPathsOrPorts(runtimeStatus.json, forbiddenManagedReferences);
|
||||
const stateAfterRuntimeInstall = JSON.parse(
|
||||
await fs.readFile(seededUpgradeState.stateFilePath, "utf8")
|
||||
assert.equal(await pathExists(managedRuntimeDir), false, "managed app data should not gain a runtime tree");
|
||||
const stateAfterRuntimeStatus = JSON.parse(
|
||||
await fs.readFile(path.join(testRoot, "managed-state.json"), "utf8")
|
||||
);
|
||||
assert.equal(stateAfterRuntimeInstall.runtimeId, currentRuntimeId);
|
||||
assert.equal(stateAfterRuntimeInstall.runtimeRoot, runtimeStatus.json.installedRuntimeRoot);
|
||||
assert.equal(await pathExists(seededUpgradeState.previousRuntimeRoot), true);
|
||||
assert.equal(stateAfterRuntimeStatus.runtimeId, currentRuntimeId);
|
||||
assert.equal(stateAfterRuntimeStatus.runtimeRoot, runtimeStatus.json.runtimeRoot);
|
||||
|
||||
const managedStart = await runBinary(packagedBinary, ["--managed-headless", "bootstrap"], managedEnv);
|
||||
assert.equal(managedStart.json.daemonRunning, true);
|
||||
assert.ok(managedStart.json.daemonPid, "managed daemon pid should exist");
|
||||
assert.equal(managedStart.json.runtimeRoot, runtimeStatus.json.runtimeRoot);
|
||||
assert.ok(
|
||||
managedStart.json.transportType === "socket" || managedStart.json.transportType === "pipe",
|
||||
"managed daemon should default to private IPC transport"
|
||||
);
|
||||
assert.notEqual(managedStart.json.transportPath, "127.0.0.1:6767");
|
||||
assertNoForbiddenPathsOrPorts(managedStart.json, forbiddenManagedReferences);
|
||||
assert.equal(await pathExists(managedRuntimeDir), false, "starting the daemon should not install a runtime copy");
|
||||
|
||||
const managedPid = managedStart.json.daemonPid;
|
||||
const stateFile = path.join(testRoot, "managed-state.json");
|
||||
@@ -594,7 +530,7 @@ try {
|
||||
assert.equal(persistedManagedStatus.json.relayEnabled, true);
|
||||
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(
|
||||
packagedBinary,
|
||||
["--managed-headless", "install-cli-shim"],
|
||||
@@ -602,32 +538,62 @@ try {
|
||||
);
|
||||
const cliShimPath = cliInstall.json.path;
|
||||
assert.ok(cliShimPath, "CLI shim path should be returned");
|
||||
assert.equal(await pathExists(cliShimPath), true, "CLI shim should exist");
|
||||
const cliVersion = await execFileAsync(cliShimPath, ["--version"], {
|
||||
env: managedEnv,
|
||||
cwd: repoRoot,
|
||||
maxBuffer: 1024 * 1024,
|
||||
});
|
||||
const cliShimInstalled = cliInstall.json.installed === true && (await pathExists(cliShimPath));
|
||||
if (!cliShimInstalled) {
|
||||
assert.ok(cliInstall.json.manualInstructions, "manual CLI install instructions should be returned");
|
||||
assert.match(
|
||||
cliInstall.json.manualInstructions.commands,
|
||||
new RegExp(escapeForRegExp(runtimeStatus.json.runtimeRoot)),
|
||||
"manual CLI install instructions should point at the bundled runtime"
|
||||
);
|
||||
assertNoForbiddenPathsOrPorts(cliInstall.json.manualInstructions, forbiddenManagedReferences);
|
||||
}
|
||||
const cliVersion = cliShimInstalled
|
||||
? await execFileAsync(cliShimPath, ["--version"], {
|
||||
env: managedEnv,
|
||||
cwd: repoRoot,
|
||||
maxBuffer: 1024 * 1024,
|
||||
})
|
||||
: await runBundledRuntimeCli(
|
||||
runtimeStatus.json.runtimeRoot,
|
||||
managedStart.json.managedHome,
|
||||
["--version"],
|
||||
managedEnv
|
||||
);
|
||||
assert.match(cliVersion.stdout.trim(), /^0\./);
|
||||
const shimStatus = await execFileAsync(cliShimPath, ["daemon", "status", "--json"], {
|
||||
env: managedEnv,
|
||||
cwd: repoRoot,
|
||||
maxBuffer: 1024 * 1024,
|
||||
});
|
||||
const shimStatus = cliShimInstalled
|
||||
? await execFileAsync(cliShimPath, ["daemon", "status", "--json"], {
|
||||
env: managedEnv,
|
||||
cwd: repoRoot,
|
||||
maxBuffer: 1024 * 1024,
|
||||
})
|
||||
: await runBundledRuntimeCli(
|
||||
runtimeStatus.json.runtimeRoot,
|
||||
managedStart.json.managedHome,
|
||||
["daemon", "status", "--json"],
|
||||
managedEnv
|
||||
);
|
||||
const shimDaemonStatus = JSON.parse(shimStatus.stdout.trim());
|
||||
assert.equal(shimDaemonStatus.pid, managedPid);
|
||||
assertNoForbiddenPathsOrPorts(shimDaemonStatus, forbiddenManagedReferences);
|
||||
|
||||
logStep("Verifying relay connectivity still works after the desktop command has exited");
|
||||
const relayPairing = await execFileAsync(
|
||||
cliShimPath,
|
||||
["daemon", "pair", "--home", managedStart.json.managedHome],
|
||||
{
|
||||
env: managedEnv,
|
||||
cwd: repoRoot,
|
||||
maxBuffer: 4 * 1024 * 1024,
|
||||
}
|
||||
);
|
||||
const relayPairing = cliShimInstalled
|
||||
? await execFileAsync(
|
||||
cliShimPath,
|
||||
["daemon", "pair", "--home", managedStart.json.managedHome],
|
||||
{
|
||||
env: managedEnv,
|
||||
cwd: repoRoot,
|
||||
maxBuffer: 4 * 1024 * 1024,
|
||||
}
|
||||
)
|
||||
: await runBundledRuntimeCli(
|
||||
runtimeStatus.json.runtimeRoot,
|
||||
managedStart.json.managedHome,
|
||||
["daemon", "pair", "--home", managedStart.json.managedHome],
|
||||
managedEnv
|
||||
);
|
||||
const relayOfferUrl = parseOfferUrlFromCommandOutput(relayPairing.stdout);
|
||||
const relayOffer = decodeOfferFromFragmentUrl(relayOfferUrl);
|
||||
assert.equal(relayOffer.relay?.endpoint, relayEndpoint);
|
||||
@@ -705,8 +671,7 @@ try {
|
||||
{
|
||||
runtimeStatus: runtimeStatus.json,
|
||||
managedStart: managedStart.json,
|
||||
seededUpgradeState,
|
||||
stateAfterRuntimeInstall,
|
||||
stateAfterRuntimeStatus,
|
||||
persistedManagedStatus: persistedManagedStatus.json,
|
||||
managedRestartless: managedRestartless.json,
|
||||
cliInstall: cliInstall.json,
|
||||
|
||||
@@ -14,11 +14,11 @@ use tauri_plugin_updater::UpdaterExt;
|
||||
|
||||
mod runtime_manager;
|
||||
use runtime_manager::{
|
||||
close_local_daemon_transport, ensure_managed_runtime, install_cli_shim, managed_daemon_logs,
|
||||
managed_daemon_pairing, managed_daemon_status, managed_runtime_status,
|
||||
open_local_daemon_transport, restart_managed_daemon, send_local_daemon_transport_message,
|
||||
start_managed_daemon, stop_managed_daemon, uninstall_cli_shim,
|
||||
update_managed_daemon_tcp_settings, LocalTransportState, ManagedTcpSettings,
|
||||
close_local_daemon_transport, install_cli_shim, managed_daemon_logs, managed_daemon_pairing,
|
||||
managed_daemon_status, managed_runtime_status, open_local_daemon_transport,
|
||||
restart_managed_daemon, send_local_daemon_transport_message, start_managed_daemon,
|
||||
stop_managed_daemon, uninstall_cli_shim, update_managed_daemon_tcp_settings,
|
||||
LocalTransportState, ManagedTcpSettings,
|
||||
};
|
||||
|
||||
// Store zoom as u64 bits (f64 * 100 as integer for atomic ops)
|
||||
@@ -169,7 +169,6 @@ fn maybe_run_managed_headless_command(app: &AppHandle) -> Result<bool, String> {
|
||||
json!(managed_runtime_status(app_handle.clone()).await?)
|
||||
}
|
||||
ManagedHeadlessCommand::Bootstrap => {
|
||||
ensure_managed_runtime(app_handle.clone()).await?;
|
||||
json!(start_managed_daemon(app_handle.clone()).await?)
|
||||
}
|
||||
ManagedHeadlessCommand::DaemonStatus => {
|
||||
@@ -603,7 +602,6 @@ pub fn run() {
|
||||
.plugin(tauri_plugin_websocket::init())
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
managed_runtime_status,
|
||||
ensure_managed_runtime,
|
||||
managed_daemon_status,
|
||||
start_managed_daemon,
|
||||
stop_managed_daemon,
|
||||
|
||||
@@ -6,11 +6,9 @@ use std::collections::HashMap;
|
||||
use std::env;
|
||||
use std::fs;
|
||||
use std::io;
|
||||
use std::iter;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::{Command, Stdio};
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::sync::OnceLock;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use tauri::{AppHandle, Emitter, Manager, State};
|
||||
use tokio::sync::mpsc;
|
||||
@@ -33,13 +31,6 @@ const DEFAULT_MANAGED_TCP_PORT: u16 = 7771;
|
||||
const CLI_SHIM_NAME: &str = "paseo";
|
||||
#[cfg(windows)]
|
||||
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)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ManagedRuntimeManifest {
|
||||
@@ -84,8 +75,6 @@ struct PidFile {
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct ManagedPaths {
|
||||
runtime_root: PathBuf,
|
||||
stable_runtime_root: PathBuf,
|
||||
managed_home: PathBuf,
|
||||
transport_path: PathBuf,
|
||||
logs_path: PathBuf,
|
||||
@@ -112,9 +101,7 @@ pub struct ManagedTcpSettings {
|
||||
pub struct ManagedRuntimeStatus {
|
||||
pub runtime_id: String,
|
||||
pub runtime_version: String,
|
||||
pub bundled_runtime_root: String,
|
||||
pub installed_runtime_root: String,
|
||||
pub installed: bool,
|
||||
pub runtime_root: String,
|
||||
pub managed_home: String,
|
||||
pub transport_type: String,
|
||||
pub transport_path: String,
|
||||
@@ -353,43 +340,39 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn outer_cli_shim_contents_only_forwards_to_inner_launcher() {
|
||||
let contents = outer_cli_shim_contents(Path::new(
|
||||
"/Applications/Paseo.app/Contents/MacOS/paseo-managed",
|
||||
));
|
||||
assert!(contents
|
||||
.contains("exec \"/Applications/Paseo.app/Contents/MacOS/paseo-managed\" \"$@\""));
|
||||
fn cli_shim_contents_runs_bundled_runtime_in_place() {
|
||||
let runtime_root =
|
||||
PathBuf::from("/Applications/Paseo.app/Contents/Resources/managed-runtime/runtime-1");
|
||||
let paths = ManagedPaths {
|
||||
managed_home: PathBuf::from("/Users/me/.paseo"),
|
||||
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"));
|
||||
}
|
||||
|
||||
#[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)]
|
||||
#[test]
|
||||
#[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> {
|
||||
let raw = fs::read_to_string(path)
|
||||
.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() {
|
||||
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")
|
||||
.unwrap_or_else(|| test_root.join(DEFAULT_MANAGED_HOME_DIRNAME));
|
||||
let transport_path = resolve_override_path("PASEO_DESKTOP_MANAGED_SOCKET_PATH")
|
||||
.unwrap_or_else(|| default_transport_path(&managed_home, &test_root));
|
||||
return Ok(ManagedPaths {
|
||||
runtime_root,
|
||||
stable_runtime_root: test_root.join("runtime"),
|
||||
managed_home: managed_home.clone(),
|
||||
transport_path,
|
||||
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 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(|| {
|
||||
#[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")
|
||||
.unwrap_or_else(|| default_transport_path(&managed_home, &root));
|
||||
Ok(ManagedPaths {
|
||||
runtime_root,
|
||||
stable_runtime_root: root.join("runtime"),
|
||||
managed_home: managed_home.clone(),
|
||||
transport_path,
|
||||
logs_path: managed_home.join("daemon.log"),
|
||||
@@ -854,19 +746,6 @@ fn powershell_double_quote(value: &str) -> String {
|
||||
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> {
|
||||
#[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 {
|
||||
let node = paths.runtime_root.join(&manifest.node_relative_path);
|
||||
let cli = paths
|
||||
.runtime_root
|
||||
.join(&manifest.cli_entrypoint_relative_path);
|
||||
fn cli_shim_contents(
|
||||
runtime_root: &Path,
|
||||
manifest: &ManagedRuntimeManifest,
|
||||
paths: &ManagedPaths,
|
||||
) -> String {
|
||||
let node = runtime_root.join(&manifest.node_relative_path);
|
||||
let cli = runtime_root.join(&manifest.cli_entrypoint_relative_path);
|
||||
#[cfg(windows)]
|
||||
{
|
||||
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> {
|
||||
if let Some(parent) = path.parent() {
|
||||
fs::create_dir_all(parent)
|
||||
@@ -941,30 +811,18 @@ fn write_cli_launcher(path: &Path, contents: &str) -> Result<(), String> {
|
||||
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(
|
||||
runtime_root: &Path,
|
||||
paths: &ManagedPaths,
|
||||
manifest: &ManagedRuntimeManifest,
|
||||
) -> Result<CliManualInstructions, String> {
|
||||
let inner_launcher = ensure_inner_cli_launcher(paths, manifest)?;
|
||||
let outer_shim = outer_cli_shim_path()?;
|
||||
let contents = cli_shim_contents(runtime_root, manifest, paths);
|
||||
#[cfg(windows)]
|
||||
{
|
||||
let target_dir = outer_shim
|
||||
.parent()
|
||||
.ok_or_else(|| "CLI shim target is missing a parent directory.".to_string())?;
|
||||
let contents = outer_cli_shim_contents(&inner_launcher);
|
||||
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(),
|
||||
@@ -991,12 +849,9 @@ fn cli_manual_instructions(
|
||||
.as_ref()
|
||||
),
|
||||
target_path = shell_single_quote(outer_shim.to_string_lossy().as_ref()),
|
||||
lines = iter::once("#!/bin/sh".to_string())
|
||||
.chain(iter::once(format!(
|
||||
"exec \"{}\" \"$@\"",
|
||||
inner_launcher.to_string_lossy()
|
||||
)))
|
||||
.map(|line| shell_single_quote(&line))
|
||||
lines = contents
|
||||
.lines()
|
||||
.map(|line| shell_single_quote(line))
|
||||
.collect::<Vec<_>>()
|
||||
.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 inner_launcher = managed_cli_inner_launcher_path(&paths.stable_runtime_root);
|
||||
let expected = outer_cli_shim_contents(&inner_launcher);
|
||||
let expected = cli_shim_contents(runtime_root, manifest, paths);
|
||||
let actual = fs::read_to_string(&outer_shim).ok()?;
|
||||
(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)
|
||||
}
|
||||
|
||||
fn runtime_install_lock() -> &'static Mutex<()> {
|
||||
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> {
|
||||
fn ensure_runtime_ready_internal(app: &AppHandle) -> Result<ManagedRuntimeStatus, String> {
|
||||
let (bundled_root, pointer) = load_bundled_runtime_pointer(app)?;
|
||||
let source_root = bundled_root.join(&pointer.relative_root);
|
||||
let paths = resolve_paths(app, &pointer.runtime_id)?;
|
||||
install_runtime_if_needed(&source_root, &paths.runtime_root)?;
|
||||
let manifest = load_runtime_manifest(&paths.runtime_root)?;
|
||||
let runtime_root = bundled_root.join(&pointer.relative_root);
|
||||
let paths = resolve_paths(app)?;
|
||||
let manifest = load_runtime_manifest(&runtime_root)?;
|
||||
if let Some(parent) = paths.transport_path.parent() {
|
||||
fs::create_dir_all(parent)
|
||||
.map_err(|error| format!("Failed to create {}: {error}", parent.display()))?;
|
||||
}
|
||||
fs::create_dir_all(&paths.managed_home)
|
||||
.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 target = managed_transport_target(&paths, existing_state.as_ref())?;
|
||||
let state = ManagedStateFile {
|
||||
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(),
|
||||
transport_type: target.transport_type.clone(),
|
||||
transport_path: target.transport_path.clone(),
|
||||
@@ -1122,9 +923,7 @@ fn ensure_runtime_installed_internal(app: &AppHandle) -> Result<ManagedRuntimeSt
|
||||
Ok(ManagedRuntimeStatus {
|
||||
runtime_id: manifest.runtime_id,
|
||||
runtime_version: manifest.runtime_version,
|
||||
bundled_runtime_root: source_root.to_string_lossy().into_owned(),
|
||||
installed_runtime_root: paths.runtime_root.to_string_lossy().into_owned(),
|
||||
installed: true,
|
||||
runtime_root: runtime_root.to_string_lossy().into_owned(),
|
||||
managed_home: paths.managed_home.to_string_lossy().into_owned(),
|
||||
transport_type: state.transport_type,
|
||||
transport_path: state.transport_path,
|
||||
@@ -1161,14 +960,16 @@ fn run_cli_json_command(
|
||||
}
|
||||
|
||||
fn managed_daemon_status_internal(app: &AppHandle) -> Result<ManagedDaemonStatus, String> {
|
||||
let status = ensure_runtime_installed_internal(app)?;
|
||||
let paths = resolve_paths(app, &status.runtime_id)?;
|
||||
let manifest = load_runtime_manifest(&paths.runtime_root)?;
|
||||
let status = ensure_runtime_ready_internal(app)?;
|
||||
let paths = resolve_paths(app)?;
|
||||
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 target = managed_transport_target(&paths, state.as_ref())?;
|
||||
let tcp_settings = resolve_tcp_settings_from_state(state.as_ref());
|
||||
let cli_status = run_cli_json_command(
|
||||
&paths.runtime_root,
|
||||
&runtime_root,
|
||||
&manifest,
|
||||
&[
|
||||
"daemon",
|
||||
@@ -1208,7 +1009,7 @@ fn managed_daemon_status_internal(app: &AppHandle) -> Result<ManagedDaemonStatus
|
||||
Ok(ManagedDaemonStatus {
|
||||
runtime_id: manifest.runtime_id,
|
||||
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(),
|
||||
transport_type: target.transport_type,
|
||||
transport_path: target.transport_path,
|
||||
@@ -1223,7 +1024,7 @@ fn managed_daemon_status_internal(app: &AppHandle) -> Result<ManagedDaemonStatus
|
||||
tcp_listen: tcp_settings
|
||||
.enabled
|
||||
.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> {
|
||||
let status = ensure_runtime_installed_internal(app)?;
|
||||
let paths = resolve_paths(app, &status.runtime_id)?;
|
||||
let manifest = load_runtime_manifest(&paths.runtime_root)?;
|
||||
let inner_launcher = ensure_inner_cli_launcher(&paths, &manifest)?;
|
||||
let status = ensure_runtime_ready_internal(app)?;
|
||||
let paths = resolve_paths(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 = outer_cli_shim_contents(&inner_launcher);
|
||||
let manual_instructions = cli_manual_instructions(&paths, &manifest)?;
|
||||
let shim_contents = cli_shim_contents(&runtime_root, &manifest, &paths);
|
||||
let manual_instructions = cli_manual_instructions(&runtime_root, &paths, &manifest)?;
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
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(()) => {
|
||||
let mut state = read_state_file(&paths.state_file_path).unwrap_or(ManagedStateFile {
|
||||
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(),
|
||||
transport_type: status.transport_type.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> {
|
||||
let status = ensure_runtime_installed_internal(app)?;
|
||||
let paths = resolve_paths(app, &status.runtime_id)?;
|
||||
let shim_path = detect_installed_cli_shim_path(&paths)
|
||||
let status = ensure_runtime_ready_internal(app)?;
|
||||
let paths = resolve_paths(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, &paths, &manifest)
|
||||
.map(PathBuf::from)
|
||||
.or_else(|| {
|
||||
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 {
|
||||
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(),
|
||||
transport_type: status.transport_type.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> {
|
||||
let status = ensure_runtime_installed_internal(app)?;
|
||||
let paths = resolve_paths(app, &status.runtime_id)?;
|
||||
let status = ensure_runtime_ready_internal(app)?;
|
||||
let paths = resolve_paths(app)?;
|
||||
let existing_status = managed_daemon_status_internal(app)?;
|
||||
if existing_status.daemon_running {
|
||||
return Ok(existing_status);
|
||||
@@ -1414,11 +1217,12 @@ fn start_managed_daemon_internal(app: &AppHandle) -> Result<ManagedDaemonStatus,
|
||||
fs::create_dir_all(parent)
|
||||
.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 target = managed_transport_target(&paths, state.as_ref())?;
|
||||
let output = cli_command(
|
||||
&paths.runtime_root,
|
||||
&runtime_root,
|
||||
&manifest,
|
||||
&[
|
||||
"start",
|
||||
@@ -1451,11 +1255,12 @@ fn start_managed_daemon_internal(app: &AppHandle) -> Result<ManagedDaemonStatus,
|
||||
}
|
||||
|
||||
fn stop_managed_daemon_internal(app: &AppHandle) -> Result<ManagedDaemonStatus, String> {
|
||||
let status = ensure_runtime_installed_internal(app)?;
|
||||
let paths = resolve_paths(app, &status.runtime_id)?;
|
||||
let manifest = load_runtime_manifest(&paths.runtime_root)?;
|
||||
let status = ensure_runtime_ready_internal(app)?;
|
||||
let paths = resolve_paths(app)?;
|
||||
let runtime_root = PathBuf::from(&status.runtime_root);
|
||||
let manifest = load_runtime_manifest(&runtime_root)?;
|
||||
let output = cli_command(
|
||||
&paths.runtime_root,
|
||||
&runtime_root,
|
||||
&manifest,
|
||||
&[
|
||||
"daemon",
|
||||
@@ -1481,13 +1286,14 @@ fn stop_managed_daemon_internal(app: &AppHandle) -> Result<ManagedDaemonStatus,
|
||||
}
|
||||
|
||||
fn restart_managed_daemon_internal(app: &AppHandle) -> Result<ManagedDaemonStatus, String> {
|
||||
let status = ensure_runtime_installed_internal(app)?;
|
||||
let paths = resolve_paths(app, &status.runtime_id)?;
|
||||
let manifest = load_runtime_manifest(&paths.runtime_root)?;
|
||||
let status = ensure_runtime_ready_internal(app)?;
|
||||
let paths = resolve_paths(app)?;
|
||||
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 target = managed_transport_target(&paths, state.as_ref())?;
|
||||
let output = cli_command(
|
||||
&paths.runtime_root,
|
||||
&runtime_root,
|
||||
&manifest,
|
||||
&[
|
||||
"daemon",
|
||||
@@ -1521,11 +1327,11 @@ fn update_managed_tcp_settings_internal(
|
||||
if settings.enabled {
|
||||
parse_tcp_listen(&format!("{}:{}", settings.host.trim(), settings.port))?;
|
||||
}
|
||||
let status = ensure_runtime_installed_internal(app)?;
|
||||
let paths = resolve_paths(app, &status.runtime_id)?;
|
||||
let status = ensure_runtime_ready_internal(app)?;
|
||||
let paths = resolve_paths(app)?;
|
||||
let mut state = read_state_file(&paths.state_file_path).unwrap_or(ManagedStateFile {
|
||||
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(),
|
||||
transport_type: status.transport_type.clone(),
|
||||
transport_path: status.transport_path.clone(),
|
||||
@@ -1548,18 +1354,11 @@ fn update_managed_tcp_settings_internal(
|
||||
|
||||
#[tauri::command]
|
||||
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
|
||||
.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]
|
||||
pub async fn managed_daemon_status(app: AppHandle) -> Result<ManagedDaemonStatus, String> {
|
||||
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]
|
||||
pub async fn managed_daemon_logs(app: AppHandle) -> Result<ManagedDaemonLogs, String> {
|
||||
tauri::async_runtime::spawn_blocking(move || {
|
||||
let status = ensure_runtime_installed_internal(&app)?;
|
||||
let paths = resolve_paths(&app, &status.runtime_id)?;
|
||||
ensure_runtime_ready_internal(&app)?;
|
||||
let paths = resolve_paths(&app)?;
|
||||
Ok(ManagedDaemonLogs {
|
||||
log_path: paths.logs_path.to_string_lossy().into_owned(),
|
||||
contents: tail_log(&paths.logs_path, 400),
|
||||
@@ -1605,11 +1404,12 @@ pub async fn managed_daemon_logs(app: AppHandle) -> Result<ManagedDaemonLogs, St
|
||||
#[tauri::command]
|
||||
pub async fn managed_daemon_pairing(app: AppHandle) -> Result<ManagedPairingOffer, String> {
|
||||
tauri::async_runtime::spawn_blocking(move || {
|
||||
let status = ensure_runtime_installed_internal(&app)?;
|
||||
let paths = resolve_paths(&app, &status.runtime_id)?;
|
||||
let manifest = load_runtime_manifest(&paths.runtime_root)?;
|
||||
let status = ensure_runtime_ready_internal(&app)?;
|
||||
let paths = resolve_paths(&app)?;
|
||||
let runtime_root = PathBuf::from(&status.runtime_root);
|
||||
let manifest = load_runtime_manifest(&runtime_root)?;
|
||||
let value = run_cli_json_command(
|
||||
&paths.runtime_root,
|
||||
&runtime_root,
|
||||
&manifest,
|
||||
&[
|
||||
"daemon",
|
||||
|
||||
Reference in New Issue
Block a user