chore: finalize electron desktop migration
This commit is contained in:
@@ -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;
|
||||
};
|
||||
}
|
||||
+49
-67
@@ -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 : () => {};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
+3
-4
@@ -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();
|
||||
}
|
||||
@@ -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");
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user