chore: finalize electron desktop migration

This commit is contained in:
Mohamed Boudra
2026-03-21 01:50:00 +07:00
parent 77fb74c188
commit 111576e2ca
169 changed files with 6463 additions and 13191 deletions
@@ -1,4 +1,4 @@
import { invokeDesktopCommand } from "@/desktop/tauri/invoke-desktop-command";
import { invokeDesktopCommand } from "@/desktop/electron/invoke";
interface AttachmentFileResult {
path: string;
@@ -4,7 +4,7 @@ const { invokeDesktopCommandMock } = vi.hoisted(() => ({
invokeDesktopCommandMock: vi.fn(async () => "AAECAw=="),
}));
vi.mock("@/desktop/tauri/invoke-desktop-command", () => ({
vi.mock("@/desktop/electron/invoke", () => ({
invokeDesktopCommand: invokeDesktopCommandMock,
}));
@@ -1,6 +1,6 @@
import type { AttachmentMetadata } from "@/attachments/types";
import { fileUriToPath } from "@/attachments/utils";
import { invokeDesktopCommand } from "@/desktop/tauri/invoke-desktop-command";
import { invokeDesktopCommand } from "@/desktop/electron/invoke";
function base64ToUint8Array(base64: string): Uint8Array {
const binary = atob(base64);
@@ -20,21 +20,21 @@ 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 { formatVersionWithPrefix, isVersionMismatch } from '@/desktop/updates/desktop-updates'
import { getLocalDaemonVersion, isVersionMismatch } from '@/desktop/updates/desktop-updates'
import {
getCliSymlinkInstructions,
getManagedDaemonLogs,
getManagedDaemonPairing,
getManagedDaemonStatus,
restartManagedDaemon,
shouldUseManagedDesktopDaemon,
startManagedDaemon,
stopManagedDaemon,
getDesktopDaemonLogs,
getDesktopDaemonPairing,
getDesktopDaemonStatus,
restartDesktopDaemon,
shouldUseDesktopDaemon,
startDesktopDaemon,
stopDesktopDaemon,
type CliSymlinkInstructions,
type ManagedDaemonLogs,
type ManagedPairingOffer,
type ManagedDaemonStatus,
} from '@/desktop/managed-runtime/managed-runtime'
type DesktopDaemonLogs,
type DesktopDaemonStatus,
type DesktopPairingOffer,
} from '@/desktop/daemon/desktop-daemon'
export interface LocalDaemonSectionProps {
appVersion: string | null
@@ -46,33 +46,35 @@ export function LocalDaemonSection({
showLifecycleControls,
}: LocalDaemonSectionProps) {
const { theme } = useUnistyles()
const showSection = shouldUseManagedDesktopDaemon()
const showSection = shouldUseDesktopDaemon()
const { settings, updateSettings } = useAppSettings()
const [managedStatus, setManagedStatus] = useState<ManagedDaemonStatus | null>(null)
const [daemonStatus, setDaemonStatus] = useState<DesktopDaemonStatus | null>(null)
const [daemonVersion, setDaemonVersion] = useState<string | null>(null)
const [statusError, setStatusError] = useState<string | null>(null)
const [isRestartingDaemon, setIsRestartingDaemon] = useState(false)
const [isUpdatingDaemonManagement, setIsUpdatingDaemonManagement] = useState(false)
const [isLoadingCliSymlinkInstructions, setIsLoadingCliSymlinkInstructions] = useState(false)
const [statusMessage, setStatusMessage] = useState<string | null>(null)
const [cliStatusMessage, setCliStatusMessage] = useState<string | null>(null)
const [managedLogs, setManagedLogs] = useState<ManagedDaemonLogs | null>(null)
const [daemonLogs, setDaemonLogs] = useState<DesktopDaemonLogs | null>(null)
const [isLogsModalOpen, setIsLogsModalOpen] = useState(false)
const [isPairingModalOpen, setIsPairingModalOpen] = useState(false)
const [isCliSymlinkModalOpen, setIsCliSymlinkModalOpen] = useState(false)
const [isLoadingPairing, setIsLoadingPairing] = useState(false)
const [pairingOffer, setPairingOffer] = useState<ManagedPairingOffer | null>(null)
const [pairingOffer, setPairingOffer] = useState<DesktopPairingOffer | null>(null)
const [cliSymlinkInstructions, setCliSymlinkInstructions] =
useState<CliSymlinkInstructions | null>(null)
const [pairingStatusMessage, setPairingStatusMessage] = useState<string | null>(null)
const loadManagedStatus = useCallback(() => {
const loadDaemonData = useCallback(() => {
if (!showSection) {
return Promise.resolve()
}
return Promise.all([getManagedDaemonStatus(), getManagedDaemonLogs()])
.then(([status, logs]) => {
setManagedStatus(status)
setManagedLogs(logs)
return Promise.all([getDesktopDaemonStatus(), getDesktopDaemonLogs(), getLocalDaemonVersion()])
.then(([status, logs, version]) => {
setDaemonStatus(status)
setDaemonLogs(logs)
setDaemonVersion(version.version)
setStatusError(null)
})
.catch((error) => {
@@ -86,35 +88,31 @@ export function LocalDaemonSection({
if (!showSection) {
return undefined
}
void loadManagedStatus()
void loadDaemonData()
return undefined
}, [loadManagedStatus, showSection])
}, [loadDaemonData, showSection])
)
const localDaemonVersionText = formatVersionWithPrefix(managedStatus?.runtimeVersion ?? null)
const daemonVersionMismatch = isVersionMismatch(appVersion, managedStatus?.runtimeVersion ?? null)
const daemonVersionMismatch = isVersionMismatch(appVersion, daemonVersion)
const daemonStatusStateText =
statusError ?? (managedStatus?.status === 'running' ? managedStatus.status : 'not running')
const daemonStatusDetailText = `PID ${managedStatus?.pid ? managedStatus.pid : '—'}`
statusError ?? (daemonStatus?.status === 'running' ? daemonStatus.status : 'not running')
const daemonStatusDetailText = `PID ${daemonStatus?.pid ? daemonStatus.pid : '—'}`
const isDaemonManagementPaused = !settings.manageBuiltInDaemon
const daemonActionLabel = managedStatus?.status === 'running' ? 'Restart daemon' : 'Start daemon'
const daemonActionLabel = daemonStatus?.status === 'running' ? 'Restart daemon' : 'Start daemon'
const daemonActionMessage =
managedStatus?.status === 'running'
daemonStatus?.status === 'running'
? 'Restarts the built-in daemon.'
: 'Starts the built-in daemon.'
const handleUpdateLocalDaemon = useCallback(() => {
if (!showSection) {
return
}
if (isRestartingDaemon) {
if (!showSection || isRestartingDaemon) {
return
}
void confirmDialog({
title: daemonActionLabel,
message:
managedStatus?.status === 'running'
daemonStatus?.status === 'running'
? 'This will restart the built-in daemon. The app will reconnect automatically.'
: 'This will start the built-in daemon.',
confirmLabel: daemonActionLabel,
@@ -128,19 +126,18 @@ export function LocalDaemonSection({
setIsRestartingDaemon(true)
setStatusMessage(null)
const action =
managedStatus?.status === 'running' ? restartManagedDaemon : startManagedDaemon
const action = daemonStatus?.status === 'running' ? restartDesktopDaemon : startDesktopDaemon
void action()
.then((status) => {
setManagedStatus(status)
setDaemonStatus(status)
setStatusMessage(
managedStatus?.status === 'running' ? 'Daemon restarted.' : 'Daemon started.'
daemonStatus?.status === 'running' ? 'Daemon restarted.' : 'Daemon started.'
)
return loadManagedStatus()
return loadDaemonData()
})
.catch((error) => {
console.error('[Settings] Failed to change managed daemon state', error)
console.error('[Settings] Failed to change desktop daemon state', error)
const message = error instanceof Error ? error.message : String(error)
setStatusMessage(`${daemonActionLabel} failed: ${message}`)
})
@@ -149,10 +146,10 @@ export function LocalDaemonSection({
})
})
.catch((error) => {
console.error('[Settings] Failed to open managed daemon action confirmation', error)
console.error('[Settings] Failed to open desktop daemon action confirmation', error)
Alert.alert('Error', 'Unable to open the daemon confirmation dialog.')
})
}, [daemonActionLabel, isRestartingDaemon, loadManagedStatus, managedStatus?.status, showSection])
}, [daemonActionLabel, daemonStatus?.status, isRestartingDaemon, loadDaemonData, showSection])
const handleToggleDaemonManagement = useCallback(() => {
if (isUpdatingDaemonManagement) {
@@ -193,13 +190,13 @@ export function LocalDaemonSection({
setStatusMessage(null)
const stopPromise =
managedStatus?.status === 'running'
? stopManagedDaemon()
: Promise.resolve(managedStatus ?? null)
daemonStatus?.status === 'running'
? stopDesktopDaemon()
: Promise.resolve(daemonStatus ?? null)
void stopPromise
.then(() => updateSettings({ manageBuiltInDaemon: false }))
.then(() => loadManagedStatus())
.then(() => loadDaemonData())
.then(() => {
setStatusMessage('Built-in daemon paused and stopped.')
})
@@ -216,9 +213,9 @@ export function LocalDaemonSection({
Alert.alert('Error', 'Unable to open the daemon confirmation dialog.')
})
}, [
daemonStatus,
isUpdatingDaemonManagement,
loadManagedStatus,
managedStatus,
loadDaemonData,
settings.manageBuiltInDaemon,
updateSettings,
])
@@ -258,7 +255,7 @@ export function LocalDaemonSection({
}, [cliSymlinkInstructions?.commands])
const handleCopyLogPath = useCallback(() => {
const logPath = managedLogs?.logPath
const logPath = daemonLogs?.logPath
if (!logPath) {
return
}
@@ -271,14 +268,14 @@ export function LocalDaemonSection({
console.error('[Settings] Failed to copy log path', error)
Alert.alert('Error', 'Unable to copy log path.')
})
}, [managedLogs?.logPath])
}, [daemonLogs?.logPath])
const handleOpenLogs = useCallback(() => {
if (!managedLogs) {
if (!daemonLogs) {
return
}
setIsLogsModalOpen(true)
}, [managedLogs])
}, [daemonLogs])
const handleOpenPairingModal = useCallback(() => {
if (isLoadingPairing) {
@@ -289,7 +286,7 @@ export function LocalDaemonSection({
setIsLoadingPairing(true)
setPairingStatusMessage(null)
void getManagedDaemonPairing()
void getDesktopDaemonPairing()
.then((pairing) => {
setPairingOffer(pairing)
if (!pairing.relayEnabled || !pairing.url) {
@@ -344,7 +341,7 @@ export function LocalDaemonSection({
<View style={styles.row}>
<View style={styles.rowContent}>
<Text style={styles.rowTitle}>Status</Text>
<Text style={styles.hintText}>Only the built-in managed daemon is shown here.</Text>
<Text style={styles.hintText}>Only the built-in desktop daemon is shown here.</Text>
</View>
<View style={styles.statusValueGroup}>
<Text style={styles.valueText}>{daemonStatusStateText}</Text>
@@ -398,7 +395,7 @@ export function LocalDaemonSection({
disabled={isRestartingDaemon}
>
{isRestartingDaemon
? managedStatus?.status === 'running'
? daemonStatus?.status === 'running'
? 'Restarting...'
: 'Starting...'
: daemonActionLabel}
@@ -425,10 +422,10 @@ export function LocalDaemonSection({
<View style={[styles.row, styles.rowBorder]}>
<View style={styles.rowContent}>
<Text style={styles.rowTitle}>Log file</Text>
<Text style={styles.hintText}>{managedLogs?.logPath ?? 'Log path unavailable.'}</Text>
<Text style={styles.hintText}>{daemonLogs?.logPath ?? 'Log path unavailable.'}</Text>
</View>
<View style={styles.actionGroup}>
{managedLogs?.logPath ? (
{daemonLogs?.logPath ? (
<Button
variant="outline"
size="sm"
@@ -443,7 +440,7 @@ export function LocalDaemonSection({
size="sm"
leftIcon={<FileText size={theme.iconSize.sm} color={theme.colors.foreground} />}
onPress={handleOpenLogs}
disabled={!managedLogs}
disabled={!daemonLogs}
>
Open logs
</Button>
@@ -523,9 +520,9 @@ export function LocalDaemonSection({
snapPoints={['70%', '92%']}
>
<View style={styles.modalBody}>
<Text style={styles.hintText}>{managedLogs?.logPath ?? 'Log path unavailable.'}</Text>
<Text style={styles.hintText}>{daemonLogs?.logPath ?? 'Log path unavailable.'}</Text>
<Text style={styles.logOutput} selectable>
{managedLogs?.contents.length ? managedLogs.contents : '(log file is empty)'}
{daemonLogs?.contents.length ? daemonLogs.contents : '(log file is empty)'}
</Text>
</View>
</AdaptiveModalSheet>
@@ -537,7 +534,7 @@ const ADVANCED_DAEMON_SETTINGS_URL = 'https://paseo.sh/docs/configuration'
function PairingOfferDialogContent(input: {
isLoading: boolean
pairingOffer: ManagedPairingOffer | null
pairingOffer: DesktopPairingOffer | null
statusMessage: string | null
onCopyLink: () => void
}) {
@@ -0,0 +1,127 @@
import { describe, expect, it, vi, beforeEach } from "vitest";
const desktopDaemonMock = vi.hoisted(() => {
let eventHandler: ((payload: {
sessionId: string;
kind: "open" | "message" | "close" | "error";
text?: string | null;
binaryBase64?: string | null;
code?: number | null;
reason?: string | null;
error?: string | null;
}) => void) | null = null;
const openLocalTransportSession = vi.fn<(...args: unknown[]) => Promise<string>>();
const listenToLocalTransportEvents = vi.fn(async (handler: typeof eventHandler extends ((...args: infer A) => any) | null ? (...args: A) => void : never) => {
eventHandler = handler;
return () => {
eventHandler = null;
};
});
const sendLocalTransportMessage = vi.fn(async () => undefined);
const closeLocalTransportSession = vi.fn(async () => undefined);
return {
openLocalTransportSession,
listenToLocalTransportEvents,
sendLocalTransportMessage,
closeLocalTransportSession,
emitEvent(payload: {
sessionId: string;
kind: "open" | "message" | "close" | "error";
text?: string | null;
binaryBase64?: string | null;
code?: number | null;
reason?: string | null;
error?: string | null;
}) {
eventHandler?.(payload);
},
};
});
vi.mock("./desktop-daemon", () => ({
openLocalTransportSession: desktopDaemonMock.openLocalTransportSession,
listenToLocalTransportEvents: desktopDaemonMock.listenToLocalTransportEvents,
sendLocalTransportMessage: desktopDaemonMock.sendLocalTransportMessage,
closeLocalTransportSession: desktopDaemonMock.closeLocalTransportSession,
}));
describe("desktop-daemon-transport", () => {
beforeEach(() => {
desktopDaemonMock.openLocalTransportSession.mockReset();
desktopDaemonMock.listenToLocalTransportEvents.mockClear();
desktopDaemonMock.sendLocalTransportMessage.mockClear();
desktopDaemonMock.closeLocalTransportSession.mockClear();
});
it("emits open after the session resolves even if the rust open event raced earlier", async () => {
let resolveSession!: (sessionId: string) => void;
desktopDaemonMock.openLocalTransportSession.mockImplementation(
() =>
new Promise<string>((resolve) => {
resolveSession = resolve;
})
);
const mod = await import("./desktop-daemon-transport");
const transportFactory = mod.createDesktopLocalDaemonTransportFactory();
expect(transportFactory).not.toBeNull();
const transport = transportFactory!({
url: "paseo+local://socket?path=%2Ftmp%2Fpaseo.sock",
});
const onOpen = vi.fn();
transport.onOpen(onOpen);
desktopDaemonMock.emitEvent({
sessionId: "local-session-1",
kind: "open",
});
expect(onOpen).not.toHaveBeenCalled();
resolveSession("local-session-1");
await Promise.resolve();
expect(onOpen).toHaveBeenCalledTimes(1);
});
it("cleans up late async setup after the transport is closed", async () => {
let resolveSession!: (sessionId: string) => void;
let resolveListen!: (cleanup: () => void) => void;
const cleanup = vi.fn();
desktopDaemonMock.openLocalTransportSession.mockImplementation(
() =>
new Promise<string>((resolve) => {
resolveSession = resolve;
})
);
desktopDaemonMock.listenToLocalTransportEvents.mockImplementation(
() =>
new Promise<() => void>((resolve) => {
resolveListen = resolve;
})
);
const mod = await import("./desktop-daemon-transport");
const transportFactory = mod.createDesktopLocalDaemonTransportFactory();
expect(transportFactory).not.toBeNull();
const transport = transportFactory!({
url: "paseo+local://socket?path=%2Ftmp%2Fpaseo.sock",
});
transport.close();
resolveSession("local-session-2");
resolveListen(cleanup);
await Promise.resolve();
await Promise.resolve();
expect(desktopDaemonMock.closeLocalTransportSession).toHaveBeenCalledWith("local-session-2");
expect(cleanup).toHaveBeenCalledTimes(1);
});
});
@@ -0,0 +1,186 @@
import type { DaemonTransport, DaemonTransportFactory } from "@server/client/daemon-client";
import {
closeLocalTransportSession,
listenToLocalTransportEvents,
openLocalTransportSession,
sendLocalTransportMessage,
type LocalTransportTarget,
} from "./desktop-daemon";
const LOCAL_TRANSPORT_SCHEME = "paseo+local:";
function encodeBinaryToBase64(data: Uint8Array | ArrayBuffer): string {
const bytes = data instanceof ArrayBuffer ? new Uint8Array(data) : data;
let binary = "";
for (let index = 0; index < bytes.length; index += 1) {
binary += String.fromCharCode(bytes[index]!);
}
return globalThis.btoa(binary);
}
function decodeBase64ToBytes(base64: string): Uint8Array {
const binary = globalThis.atob(base64);
const bytes = new Uint8Array(binary.length);
for (let index = 0; index < binary.length; index += 1) {
bytes[index] = binary.charCodeAt(index);
}
return bytes;
}
export function buildLocalDaemonTransportUrl(target: LocalTransportTarget): string {
const url = new URL(`${LOCAL_TRANSPORT_SCHEME}//${target.transportType}`);
url.searchParams.set("path", target.transportPath);
return url.toString();
}
function parseLocalDaemonTransportUrl(url: string): LocalTransportTarget {
const parsed = new URL(url);
if (parsed.protocol !== LOCAL_TRANSPORT_SCHEME) {
throw new Error(`Unsupported local transport URL: ${url}`);
}
const transportType = parsed.hostname;
const transportPath = parsed.searchParams.get("path")?.trim() ?? "";
if ((transportType !== "socket" && transportType !== "pipe") || !transportPath) {
throw new Error(`Invalid local transport target: ${url}`);
}
return {
transportType,
transportPath,
};
}
export function createDesktopLocalDaemonTransportFactory(): DaemonTransportFactory | null {
return ({ url }) => {
const target = parseLocalDaemonTransportUrl(url);
let sessionId: string | null = null;
let unlisten: (() => void) | null = null;
let disposed = false;
let didEmitOpen = false;
const openHandlers = new Set<() => void>();
const closeHandlers = new Set<(event?: unknown) => void>();
const errorHandlers = new Set<(event?: unknown) => void>();
const messageHandlers = new Set<(data: unknown) => void>();
const emitOpen = () => {
if (didEmitOpen || disposed) {
return;
}
didEmitOpen = true;
for (const handler of openHandlers) {
handler();
}
};
const emitClose = (event?: unknown) => {
for (const handler of closeHandlers) {
handler(event);
}
};
const emitError = (event?: unknown) => {
for (const handler of errorHandlers) {
handler(event);
}
};
const emitMessage = (data: unknown) => {
for (const handler of messageHandlers) {
handler(data);
}
};
void listenToLocalTransportEvents((payload) => {
if (disposed || !sessionId || payload.sessionId !== sessionId) {
return;
}
if (payload.kind === "open") {
emitOpen();
return;
}
if (payload.kind === "message") {
if (payload.text) {
emitMessage({ data: payload.text });
return;
}
if (payload.binaryBase64) {
emitMessage({ data: decodeBase64ToBytes(payload.binaryBase64) });
}
return;
}
if (payload.kind === "close") {
emitClose(payload);
return;
}
emitError(payload.error ?? "Local daemon transport error");
})
.then((cleanup) => {
if (disposed) {
cleanup();
return;
}
unlisten = cleanup;
})
.catch((error) => {
emitError(error);
});
void openLocalTransportSession(target)
.then((id) => {
if (disposed) {
void closeLocalTransportSession(id).catch((error) => emitError(error));
return;
}
sessionId = id;
emitOpen();
})
.catch((error) => {
emitError(error);
});
const transport: DaemonTransport = {
send: (data) => {
if (!sessionId) {
return;
}
if (typeof data === "string") {
void sendLocalTransportMessage({ sessionId, text: data }).catch((error) =>
emitError(error)
);
return;
}
const binaryBase64 = encodeBinaryToBase64(
data instanceof ArrayBuffer ? data : new Uint8Array(data)
);
void sendLocalTransportMessage({ sessionId, binaryBase64 }).catch((error) =>
emitError(error)
);
},
close: () => {
disposed = true;
const currentSessionId = sessionId;
sessionId = null;
if (currentSessionId) {
void closeLocalTransportSession(currentSessionId).catch((error) => emitError(error));
}
unlisten?.();
unlisten = null;
},
onMessage: (handler) => {
messageHandlers.add(handler);
return () => messageHandlers.delete(handler);
},
onOpen: (handler) => {
openHandlers.add(handler);
return () => openHandlers.delete(handler);
},
onClose: (handler) => {
closeHandlers.add(handler);
return () => closeHandlers.delete(handler);
},
onError: (handler) => {
errorHandlers.add(handler);
return () => errorHandlers.delete(handler);
},
};
return transport;
};
}
@@ -1,29 +1,24 @@
import { invokeDesktopCommand } from '@/desktop/tauri/invoke-desktop-command'
import { getTauri, isTauriEnvironment } from '@/utils/tauri'
import { getDesktopHost, isDesktop } from '@/desktop/host'
import { invokeDesktopCommand } from '@/desktop/electron/invoke'
export type ManagedRuntimeStatus = {
runtimeId: string
runtimeVersion: string
runtimeRoot: string
}
export type DesktopDaemonState = 'starting' | 'running' | 'stopped' | 'errored'
export type ManagedDaemonStatus = {
runtimeId: string
runtimeVersion: string
export type DesktopDaemonStatus = {
serverId: string
status: string
status: DesktopDaemonState
listen: string
hostname: string | null
pid: number | null
home: string
error: string | null
}
export type ManagedDaemonLogs = {
export type DesktopDaemonLogs = {
logPath: string
contents: string
}
export type ManagedPairingOffer = {
export type DesktopPairingOffer = {
relayEnabled: boolean
url: string | null
qr: string | null
@@ -35,12 +30,6 @@ export type CliSymlinkInstructions = {
commands: string
}
export type ManagedTcpSettings = {
enabled: boolean
host: string
port: number
}
export type LocalTransportTarget = {
transportType: 'socket' | 'pipe'
transportPath: string
@@ -68,36 +57,42 @@ function toNumberOrNull(value: unknown): number | null {
return typeof value === 'number' && Number.isFinite(value) ? value : null
}
function parseManagedRuntimeStatus(raw: unknown): ManagedRuntimeStatus {
if (!isRecord(raw)) {
throw new Error('Unexpected managed runtime status response.')
}
return {
runtimeId: toStringOrNull(raw.runtimeId) ?? '',
runtimeVersion: toStringOrNull(raw.runtimeVersion) ?? '',
runtimeRoot: toStringOrNull(raw.runtimeRoot) ?? '',
function parseDesktopDaemonState(value: unknown): DesktopDaemonState {
const normalized = toStringOrNull(value)?.toLowerCase()
switch (normalized) {
case 'starting':
return 'starting'
case 'running':
return 'running'
case 'errored':
case 'error':
return 'errored'
case 'stopped':
case 'stopping':
case 'unknown':
default:
return 'stopped'
}
}
function parseManagedDaemonStatus(raw: unknown): ManagedDaemonStatus {
function parseDesktopDaemonStatus(raw: unknown): DesktopDaemonStatus {
if (!isRecord(raw)) {
throw new Error('Unexpected managed daemon status response.')
throw new Error('Unexpected desktop daemon status response.')
}
return {
runtimeId: toStringOrNull(raw.runtimeId) ?? '',
runtimeVersion: toStringOrNull(raw.runtimeVersion) ?? '',
serverId: toStringOrNull(raw.serverId) ?? '',
status: toStringOrNull(raw.status) ?? 'unknown',
status: parseDesktopDaemonState(raw.status),
listen: toStringOrNull(raw.listen) ?? '',
hostname: toStringOrNull(raw.hostname),
pid: toNumberOrNull(raw.pid),
home: toStringOrNull(raw.home) ?? '',
error: toStringOrNull(raw.error),
}
}
function parseManagedDaemonLogs(raw: unknown): ManagedDaemonLogs {
function parseDesktopDaemonLogs(raw: unknown): DesktopDaemonLogs {
if (!isRecord(raw)) {
throw new Error('Unexpected managed daemon logs response.')
throw new Error('Unexpected desktop daemon logs response.')
}
return {
logPath: toStringOrNull(raw.logPath) ?? '',
@@ -105,9 +100,9 @@ function parseManagedDaemonLogs(raw: unknown): ManagedDaemonLogs {
}
}
function parseManagedPairingOffer(raw: unknown): ManagedPairingOffer {
function parseDesktopPairingOffer(raw: unknown): DesktopPairingOffer {
if (!isRecord(raw)) {
throw new Error('Unexpected managed daemon pairing response.')
throw new Error('Unexpected desktop daemon pairing response.')
}
return {
relayEnabled: raw.relayEnabled === true,
@@ -127,36 +122,32 @@ function parseCliSymlinkInstructionsInternal(raw: unknown): CliSymlinkInstructio
}
}
export function shouldUseManagedDesktopDaemon(): boolean {
return isTauriEnvironment() && getTauri() !== null
export function shouldUseDesktopDaemon(): boolean {
return isDesktop()
}
export async function getManagedRuntimeStatus(): Promise<ManagedRuntimeStatus> {
return parseManagedRuntimeStatus(await invokeDesktopCommand('managed_runtime_status'))
export async function getDesktopDaemonStatus(): Promise<DesktopDaemonStatus> {
return parseDesktopDaemonStatus(await invokeDesktopCommand('desktop_daemon_status'))
}
export async function getManagedDaemonStatus(): Promise<ManagedDaemonStatus> {
return parseManagedDaemonStatus(await invokeDesktopCommand('managed_daemon_status'))
export async function startDesktopDaemon(): Promise<DesktopDaemonStatus> {
return parseDesktopDaemonStatus(await invokeDesktopCommand('start_desktop_daemon'))
}
export async function startManagedDaemon(): Promise<ManagedDaemonStatus> {
return parseManagedDaemonStatus(await invokeDesktopCommand('start_managed_daemon'))
export async function stopDesktopDaemon(): Promise<DesktopDaemonStatus> {
return parseDesktopDaemonStatus(await invokeDesktopCommand('stop_desktop_daemon'))
}
export async function stopManagedDaemon(): Promise<ManagedDaemonStatus> {
return parseManagedDaemonStatus(await invokeDesktopCommand('stop_managed_daemon'))
export async function restartDesktopDaemon(): Promise<DesktopDaemonStatus> {
return parseDesktopDaemonStatus(await invokeDesktopCommand('restart_desktop_daemon'))
}
export async function restartManagedDaemon(): Promise<ManagedDaemonStatus> {
return parseManagedDaemonStatus(await invokeDesktopCommand('restart_managed_daemon'))
export async function getDesktopDaemonLogs(): Promise<DesktopDaemonLogs> {
return parseDesktopDaemonLogs(await invokeDesktopCommand('desktop_daemon_logs'))
}
export async function getManagedDaemonLogs(): Promise<ManagedDaemonLogs> {
return parseManagedDaemonLogs(await invokeDesktopCommand('managed_daemon_logs'))
}
export async function getManagedDaemonPairing(): Promise<ManagedPairingOffer> {
return parseManagedPairingOffer(await invokeDesktopCommand('managed_daemon_pairing'))
export async function getDesktopDaemonPairing(): Promise<DesktopPairingOffer> {
return parseDesktopPairingOffer(await invokeDesktopCommand('desktop_daemon_pairing'))
}
export function parseCliSymlinkInstructions(raw: unknown): CliSymlinkInstructions {
@@ -171,27 +162,18 @@ export async function getCliSymlinkInstructions(): Promise<CliSymlinkInstruction
return parseCliSymlinkInstructions(await invokeDesktopCommand('cli_symlink_instructions'))
}
export async function updateManagedDaemonTcpSettings(
settings: ManagedTcpSettings
): Promise<ManagedDaemonStatus> {
return parseManagedDaemonStatus(
await invokeDesktopCommand('update_managed_daemon_tcp_settings', { settings })
)
}
export type LocalTransportEventUnlisten = () => void
export type LocalTransportEventHandler = (payload: LocalTransportEventPayload) => void
export async function listenToLocalTransportEvents(
handler: LocalTransportEventHandler
): Promise<LocalTransportEventUnlisten> {
const listen = getTauri()?.event?.listen
const listen = getDesktopHost()?.events?.on
if (typeof listen !== 'function') {
throw new Error('Tauri event API is unavailable.')
throw new Error('Desktop events API is unavailable.')
}
const unlisten = await listen('local-daemon-transport-event', (event: unknown) => {
const payload = isRecord(event) && isRecord(event.payload) ? event.payload : null
if (!payload) {
const unlisten = await listen('local-daemon-transport-event', (payload: unknown) => {
if (!isRecord(payload)) {
return
}
handler({
@@ -0,0 +1,27 @@
import { getDesktopHost } from "@/desktop/host";
export type DesktopEventUnlisten = () => void;
type EventEnvelope = {
payload?: unknown;
};
export async function listenToDesktopEvent<TPayload>(
event: string,
handler: (payload: TPayload) => void
): Promise<DesktopEventUnlisten> {
const listen = getDesktopHost()?.events?.on;
if (typeof listen !== "function") {
throw new Error("Desktop event API is unavailable.");
}
const unlisten = await listen(event, (rawEvent: unknown) => {
const payload =
typeof rawEvent === "object" && rawEvent !== null && "payload" in rawEvent
? (rawEvent as EventEnvelope).payload
: rawEvent;
handler(payload as TPayload);
});
return typeof unlisten === "function" ? unlisten : () => {};
}
+12
View File
@@ -0,0 +1,12 @@
import type { DesktopHostBridge } from "@/desktop/host";
export function getElectronHost(): DesktopHostBridge | null {
if (typeof window === "undefined") {
return null;
}
const host = window.paseoDesktop;
if (!host || typeof host !== "object") {
return null;
}
return host;
}
@@ -1,13 +1,12 @@
import { getTauri } from "@/utils/tauri";
import { getDesktopHost } from "@/desktop/host";
export async function invokeDesktopCommand<T>(
command: string,
args?: Record<string, unknown>
): Promise<T> {
const invoke = getTauri()?.core?.invoke;
const invoke = getDesktopHost()?.invoke;
if (typeof invoke !== "function") {
throw new Error("Tauri invoke() is unavailable in this environment.");
throw new Error("Desktop invoke() is unavailable in this environment.");
}
return (await invoke(command, args)) as T;
}
@@ -0,0 +1,37 @@
import { getDesktopHost, type DesktopWindowBridge } from "@/desktop/host";
export function getDesktopWindow(): DesktopWindowBridge | null {
const getter = getDesktopHost()?.window?.getCurrentWindow;
if (typeof getter !== "function") {
return null;
}
try {
return getter() ?? null;
} catch {
return null;
}
}
export async function startDesktopDragging(): Promise<void> {
const win = getDesktopWindow();
if (!win || typeof win.startDragging !== "function") {
return;
}
await win.startDragging();
}
export async function toggleDesktopMaximize(): Promise<void> {
const win = getDesktopWindow();
if (!win || typeof win.toggleMaximize !== "function") {
return;
}
await win.toggleMaximize();
}
export async function isDesktopFullscreen(): Promise<boolean> {
const win = getDesktopWindow();
if (!win || typeof win.isFullscreen !== "function") {
return false;
}
return await win.isFullscreen();
}
+111
View File
@@ -0,0 +1,111 @@
import { Platform } from "react-native";
import { getElectronHost } from "@/desktop/electron/host";
export type DesktopNotificationPermission = "granted" | "denied" | "default";
export interface DesktopDialogAskOptions {
title?: string;
okLabel?: string;
cancelLabel?: string;
kind?: "info" | "warning" | "error";
}
export interface DesktopDialogOpenOptions {
title?: string;
defaultPath?: string;
directory?: boolean;
multiple?: boolean;
filters?: Array<{
name: string;
extensions: string[];
}>;
}
export interface DesktopDialogBridge {
ask?: (message: string, options?: DesktopDialogAskOptions) => Promise<boolean>;
open?: (
options?: DesktopDialogOpenOptions
) => Promise<string | string[] | null>;
}
export interface DesktopNotificationBridge {
isSupported?: () => Promise<boolean>;
sendNotification?: (
payload: string | { title: string; body?: string; data?: Record<string, unknown> }
) => Promise<boolean>;
}
export interface DesktopOpenerBridge {
openUrl?: (url: string) => Promise<void>;
}
export interface DesktopWindowBridge {
label?: string;
startDragging?: () => Promise<void>;
toggleMaximize?: () => Promise<void>;
isFullscreen?: () => Promise<boolean>;
onResized?: <TEvent = unknown>(
handler: (event: TEvent) => void
) => Promise<() => void> | (() => void);
setBadgeCount?: (count?: number) => Promise<void>;
onDragDropEvent?: <TEvent = unknown>(
handler: (event: TEvent) => void
) => Promise<() => void> | (() => void);
}
export interface DesktopWindowModuleBridge {
getCurrentWindow?: () => DesktopWindowBridge;
}
export interface DesktopEventsBridge {
on?: (
event: string,
handler: (payload: unknown) => void
) => Promise<() => void> | (() => void);
}
export interface DesktopInvokeBridge {
invoke?: (command: string, args?: Record<string, unknown>) => Promise<unknown>;
}
export interface DesktopHostBridge {
platform?: string;
invoke?: DesktopInvokeBridge["invoke"];
events?: DesktopEventsBridge;
window?: DesktopWindowModuleBridge;
dialog?: DesktopDialogBridge;
notification?: DesktopNotificationBridge;
opener?: DesktopOpenerBridge;
}
declare global {
interface Window {
paseoDesktop?: DesktopHostBridge;
}
}
export function getDesktopHost(): DesktopHostBridge | null {
if (Platform.OS !== "web") {
return null;
}
return getElectronHost();
}
export function isDesktop(): boolean {
return getDesktopHost() !== null;
}
export function isDesktopMac(): boolean {
if (!isDesktop()) {
return false;
}
if (typeof navigator === "undefined") {
return false;
}
const hostPlatform = getDesktopHost()?.platform?.toLowerCase();
if (hostPlatform === "darwin" || hostPlatform === "mac" || hostPlatform === "macos") {
return true;
}
const ua = navigator.userAgent;
return ua.includes("Mac OS") || ua.includes("Macintosh");
}
@@ -1,24 +0,0 @@
import { describe, expect, it } from "vitest";
import { parseCliSymlinkInstructions } from "./managed-runtime";
describe("parseCliSymlinkInstructions", () => {
it("parses CLI symlink instructions from the desktop backend", () => {
expect(
parseCliSymlinkInstructions({
title: "Add paseo to your shell",
detail: "Create a symlink to the Paseo desktop executable.",
commands: "sudo ln -sf /Applications/Paseo.app/Contents/MacOS/Paseo /usr/local/bin/paseo",
})
).toEqual({
title: "Add paseo to your shell",
detail: "Create a symlink to the Paseo desktop executable.",
commands: "sudo ln -sf /Applications/Paseo.app/Contents/MacOS/Paseo /usr/local/bin/paseo",
});
});
it("rejects non-object payloads", () => {
expect(() => parseCliSymlinkInstructions(null)).toThrow(
"Unexpected CLI symlink instructions response."
);
});
});
@@ -1,58 +0,0 @@
import { invokeDesktopCommand } from "@/desktop/tauri/invoke-desktop-command";
import { getTauri } from "@/utils/tauri";
export const DESKTOP_NOTIFICATION_CLICK_EVENT = "desktop-notification-click";
export interface DesktopNotificationInput {
title: string;
body?: string;
data?: Record<string, unknown>;
}
export interface DesktopNotificationClickPayload {
data?: Record<string, unknown>;
}
export type DesktopNotificationClickHandler = (
payload: DesktopNotificationClickPayload
) => void;
export type DesktopNotificationClickUnlisten = () => void;
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
export async function sendDesktopNotification(
input: DesktopNotificationInput
): Promise<boolean> {
try {
await invokeDesktopCommand("send_desktop_notification", { input });
return true;
} catch (error) {
console.warn("[OSNotifications][Desktop] Failed to send desktop notification", error);
return false;
}
}
export async function listenToDesktopNotificationClicks(
handler: DesktopNotificationClickHandler
): Promise<DesktopNotificationClickUnlisten> {
const listen = getTauri()?.event?.listen;
if (typeof listen !== "function") {
throw new Error("Tauri event API is unavailable.");
}
const unlisten = await listen(DESKTOP_NOTIFICATION_CLICK_EVENT, (event: unknown) => {
const payload = isRecord(event) && isRecord(event.payload) ? event.payload : null;
if (!payload) {
return;
}
handler({
data: isRecord(payload.data) ? payload.data : undefined,
});
});
return typeof unlisten === "function" ? unlisten : () => {};
}
@@ -4,14 +4,17 @@ type MockPlatform = 'web' | 'ios' | 'android'
type GlobalSnapshot = {
Notification: unknown
__TAURI__: unknown
navigatorDescriptor?: PropertyDescriptor
paseoDesktop: unknown
}
const originalGlobals: GlobalSnapshot = {
Notification: (globalThis as { Notification?: unknown }).Notification,
__TAURI__: (globalThis as { __TAURI__?: unknown }).__TAURI__,
navigatorDescriptor: Object.getOwnPropertyDescriptor(globalThis, 'navigator'),
paseoDesktop:
typeof window === 'undefined'
? undefined
: (window as { paseoDesktop?: unknown }).paseoDesktop,
}
function setNavigator(value: unknown): void {
@@ -24,13 +27,16 @@ function setNavigator(value: unknown): void {
function restoreGlobals(): void {
;(globalThis as { Notification?: unknown }).Notification = originalGlobals.Notification
;(globalThis as { __TAURI__?: unknown }).__TAURI__ = originalGlobals.__TAURI__
if (originalGlobals.navigatorDescriptor) {
Object.defineProperty(globalThis, 'navigator', originalGlobals.navigatorDescriptor)
} else {
delete (globalThis as { navigator?: unknown }).navigator
}
if (typeof window !== 'undefined') {
;(window as { paseoDesktop?: unknown }).paseoDesktop = originalGlobals.paseoDesktop
}
}
async function loadModuleForPlatform(platform: MockPlatform) {
@@ -47,20 +53,20 @@ describe('desktop-permissions', () => {
restoreGlobals()
})
it('shows section only in Tauri web runtime', async () => {
it('shows section only in desktop web runtime', async () => {
const { shouldShowDesktopPermissionSection } = await loadModuleForPlatform('web')
expect(shouldShowDesktopPermissionSection()).toBe(false)
;(globalThis as { __TAURI__?: unknown }).__TAURI__ = { notification: {} }
;(window as { paseoDesktop?: unknown }).paseoDesktop = {}
expect(shouldShowDesktopPermissionSection()).toBe(true)
})
it('reads notification and microphone status', async () => {
const isPermissionGranted = vi.fn(async () => false)
;(globalThis as { __TAURI__?: unknown }).__TAURI__ = {
notification: { isPermissionGranted },
class MockNotification {
static permission = 'default'
}
;(globalThis as { Notification?: unknown }).Notification = MockNotification
setNavigator({
permissions: {
query: vi.fn(async () => ({ state: 'granted' })),
@@ -73,9 +79,8 @@ describe('desktop-permissions', () => {
const { getDesktopPermissionSnapshot } = await loadModuleForPlatform('web')
const snapshot = await getDesktopPermissionSnapshot()
expect(snapshot.notifications.state).toBe('not-granted')
expect(snapshot.notifications.state).toBe('prompt')
expect(snapshot.microphone.state).toBe('granted')
expect(isPermissionGranted).toHaveBeenCalledTimes(1)
expect(snapshot.checkedAt).toBeTypeOf('number')
})
@@ -127,20 +132,21 @@ describe('desktop-permissions', () => {
)
})
it('requests notification permission via Tauri', async () => {
const requestPermission = vi.fn(async () => 'granted')
;(globalThis as { __TAURI__?: unknown }).__TAURI__ = {
notification: { requestPermission },
it('requests notification permission via the browser Notification API', async () => {
class MockNotification {
static permission = 'default'
static requestPermission = vi.fn(async () => 'granted')
}
;(globalThis as { Notification?: unknown }).Notification = MockNotification
const { requestDesktopPermission } = await loadModuleForPlatform('web')
const result = await requestDesktopPermission({ kind: 'notifications' })
expect(result.state).toBe('granted')
expect(requestPermission).toHaveBeenCalledTimes(1)
expect(MockNotification.requestPermission).toHaveBeenCalledTimes(1)
})
it('falls back to browser Notification permission when Tauri API is unavailable', async () => {
it('reads browser Notification permission when available', async () => {
class MockNotification {
static permission = 'denied'
}
@@ -1,5 +1,5 @@
import { Platform } from 'react-native'
import { getTauri, type TauriNotificationPermission } from '@/utils/tauri'
import { getDesktopHost } from '@/desktop/host'
export type DesktopPermissionKind = 'notifications' | 'microphone'
@@ -45,7 +45,7 @@ type NavigatorLike = {
}
export function shouldShowDesktopPermissionSection(): boolean {
return Platform.OS === 'web' && getTauri() !== null
return Platform.OS === 'web' && getDesktopHost() !== null
}
function status(input: DesktopPermissionStatus): DesktopPermissionStatus {
@@ -132,27 +132,6 @@ function mapNotificationPermissionString(permission: string): DesktopPermissionS
})
}
function mapTauriNotificationPermissionResult(
permission: TauriNotificationPermission
): DesktopPermissionStatus {
if (permission === 'granted') {
return status({
state: 'granted',
detail: 'Notifications are allowed by the OS.',
})
}
if (permission === 'denied') {
return status({
state: 'denied',
detail: 'Notifications are denied in system settings.',
})
}
return status({
state: 'prompt',
detail: 'Notifications have not been granted yet.',
})
}
async function getNotificationPermissionStatus(): Promise<DesktopPermissionStatus> {
if (Platform.OS !== 'web') {
return status({
@@ -161,44 +140,30 @@ async function getNotificationPermissionStatus(): Promise<DesktopPermissionStatu
})
}
const tauriNotification = getTauri()?.notification
if (tauriNotification) {
if (typeof tauriNotification.isPermissionGranted !== 'function') {
return status({
state: 'unavailable',
detail: 'Tauri notification plugin is missing isPermissionGranted().',
})
}
const desktopHost = getDesktopHost()
if (desktopHost && typeof desktopHost.notification?.isSupported === 'function') {
try {
const granted = await tauriNotification.isPermissionGranted()
if (granted) {
return status({
state: 'granted',
detail: 'Tauri reports notifications are granted.',
})
}
const supported = await desktopHost.notification.isSupported()
return status({
state: 'not-granted',
detail: 'Tauri reports notifications are not granted. Use Request to prompt.',
})
} catch (error) {
return status({
state: 'unknown',
detail: `Failed to read notification status: ${getErrorMessage(error)}`,
state: supported ? 'granted' : 'unavailable',
detail: supported
? 'Desktop notifications are supported.'
: 'Desktop notifications are not supported on this platform.',
})
} catch {
// Fall through to web API check
}
}
const NotificationConstructor = getWebNotificationConstructor()
if (!NotificationConstructor || typeof NotificationConstructor.permission !== 'string') {
return status({
state: 'unavailable',
detail: 'Web Notification API is unavailable in this environment.',
})
if (NotificationConstructor && typeof NotificationConstructor.permission === 'string') {
return mapNotificationPermissionString(NotificationConstructor.permission)
}
return mapNotificationPermissionString(NotificationConstructor.permission)
return status({
state: 'unavailable',
detail: 'Web Notification API is unavailable in this environment.',
})
}
async function getMicrophonePermissionStatus(): Promise<DesktopPermissionStatus> {
@@ -279,18 +244,11 @@ async function requestNotificationPermissionStatus(): Promise<DesktopPermissionS
})
}
const tauriNotification = getTauri()?.notification
if (tauriNotification) {
if (typeof tauriNotification.requestPermission !== 'function') {
return status({
state: 'unavailable',
detail: 'Tauri notification plugin is missing requestPermission().',
})
}
const NotificationConstructor = getWebNotificationConstructor()
if (NotificationConstructor && typeof NotificationConstructor.requestPermission === 'function') {
try {
const permission = await tauriNotification.requestPermission()
return mapTauriNotificationPermissionResult(permission)
const permission = await NotificationConstructor.requestPermission()
return mapNotificationPermissionString(permission)
} catch (error) {
return status({
state: 'unknown',
@@ -299,23 +257,10 @@ async function requestNotificationPermissionStatus(): Promise<DesktopPermissionS
}
}
const NotificationConstructor = getWebNotificationConstructor()
if (!NotificationConstructor || typeof NotificationConstructor.requestPermission !== 'function') {
return status({
state: 'unavailable',
detail: 'Web Notification API requestPermission() is unavailable.',
})
}
try {
const permission = await NotificationConstructor.requestPermission()
return mapNotificationPermissionString(permission)
} catch (error) {
return status({
state: 'unknown',
detail: `Failed to request notification permission: ${getErrorMessage(error)}`,
})
}
return status({
state: 'unavailable',
detail: 'Web Notification API requestPermission() is unavailable.',
})
}
async function requestMicrophonePermissionStatus(): Promise<DesktopPermissionStatus> {
@@ -122,7 +122,7 @@ export function useDesktopPermissions(): UseDesktopPermissionsReturn {
try {
const sent = await sendOsNotification({
title: "Paseo notification test",
body: "If you can see this, delivery works. Click it to verify the open flow.",
body: "If you can see this, desktop notifications work.",
});
if (!sent) {
console.warn("[Settings] Desktop test notification was not delivered");
+3 -3
View File
@@ -1,9 +1,9 @@
import { getTauri } from '@/utils/tauri'
import { getDesktopHost } from '@/desktop/host'
export async function pickDirectory(): Promise<string | null> {
const open = getTauri()?.dialog?.open
const open = getDesktopHost()?.dialog?.open
if (typeof open !== 'function') {
throw new Error('Tauri dialog open() is unavailable in this environment.')
throw new Error('Desktop dialog open() is unavailable in this environment.')
}
const selection = await open({
@@ -1,6 +1,6 @@
import { Platform } from 'react-native'
import { getTauri } from '@/utils/tauri'
import { invokeDesktopCommand } from '@/desktop/tauri/invoke-desktop-command'
import { isDesktop } from '@/desktop/host'
import { invokeDesktopCommand } from '@/desktop/electron/invoke'
export interface DesktopAppUpdateCheckResult {
hasUpdate: boolean
@@ -49,7 +49,7 @@ function toNumberOr(defaultValue: number, value: unknown): number {
}
export function shouldShowDesktopUpdateSection(): boolean {
return Platform.OS === 'web' && getTauri() !== null
return Platform.OS === 'web' && isDesktop()
}
export function parseLocalDaemonVersionResult(raw: unknown): LocalDaemonVersionResult {