chore: finalize electron desktop migration
This commit is contained in:
@@ -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;
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,214 @@
|
||||
import { getDesktopHost, isDesktop } from '@/desktop/host'
|
||||
import { invokeDesktopCommand } from '@/desktop/electron/invoke'
|
||||
|
||||
export type DesktopDaemonState = 'starting' | 'running' | 'stopped' | 'errored'
|
||||
|
||||
export type DesktopDaemonStatus = {
|
||||
serverId: string
|
||||
status: DesktopDaemonState
|
||||
listen: string
|
||||
hostname: string | null
|
||||
pid: number | null
|
||||
home: string
|
||||
error: string | null
|
||||
}
|
||||
|
||||
export type DesktopDaemonLogs = {
|
||||
logPath: string
|
||||
contents: string
|
||||
}
|
||||
|
||||
export type DesktopPairingOffer = {
|
||||
relayEnabled: boolean
|
||||
url: string | null
|
||||
qr: string | null
|
||||
}
|
||||
|
||||
export type CliSymlinkInstructions = {
|
||||
title: string
|
||||
detail: string
|
||||
commands: string
|
||||
}
|
||||
|
||||
export type LocalTransportTarget = {
|
||||
transportType: 'socket' | 'pipe'
|
||||
transportPath: string
|
||||
}
|
||||
|
||||
type LocalTransportEventPayload = {
|
||||
sessionId: string
|
||||
kind: 'open' | 'message' | 'close' | 'error'
|
||||
text?: string | null
|
||||
binaryBase64?: string | null
|
||||
code?: number | null
|
||||
reason?: string | null
|
||||
error?: string | null
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null
|
||||
}
|
||||
|
||||
function toStringOrNull(value: unknown): string | null {
|
||||
return typeof value === 'string' && value.trim().length > 0 ? value : null
|
||||
}
|
||||
|
||||
function toNumberOrNull(value: unknown): number | null {
|
||||
return typeof value === 'number' && Number.isFinite(value) ? value : null
|
||||
}
|
||||
|
||||
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 parseDesktopDaemonStatus(raw: unknown): DesktopDaemonStatus {
|
||||
if (!isRecord(raw)) {
|
||||
throw new Error('Unexpected desktop daemon status response.')
|
||||
}
|
||||
return {
|
||||
serverId: toStringOrNull(raw.serverId) ?? '',
|
||||
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 parseDesktopDaemonLogs(raw: unknown): DesktopDaemonLogs {
|
||||
if (!isRecord(raw)) {
|
||||
throw new Error('Unexpected desktop daemon logs response.')
|
||||
}
|
||||
return {
|
||||
logPath: toStringOrNull(raw.logPath) ?? '',
|
||||
contents: typeof raw.contents === 'string' ? raw.contents : '',
|
||||
}
|
||||
}
|
||||
|
||||
function parseDesktopPairingOffer(raw: unknown): DesktopPairingOffer {
|
||||
if (!isRecord(raw)) {
|
||||
throw new Error('Unexpected desktop daemon pairing response.')
|
||||
}
|
||||
return {
|
||||
relayEnabled: raw.relayEnabled === true,
|
||||
url: toStringOrNull(raw.url),
|
||||
qr: toStringOrNull(raw.qr),
|
||||
}
|
||||
}
|
||||
|
||||
function parseCliSymlinkInstructionsInternal(raw: unknown): CliSymlinkInstructions | null {
|
||||
if (!isRecord(raw)) {
|
||||
return null
|
||||
}
|
||||
return {
|
||||
title: toStringOrNull(raw.title) ?? '',
|
||||
detail: toStringOrNull(raw.detail) ?? '',
|
||||
commands: toStringOrNull(raw.commands) ?? '',
|
||||
}
|
||||
}
|
||||
|
||||
export function shouldUseDesktopDaemon(): boolean {
|
||||
return isDesktop()
|
||||
}
|
||||
|
||||
export async function getDesktopDaemonStatus(): Promise<DesktopDaemonStatus> {
|
||||
return parseDesktopDaemonStatus(await invokeDesktopCommand('desktop_daemon_status'))
|
||||
}
|
||||
|
||||
export async function startDesktopDaemon(): Promise<DesktopDaemonStatus> {
|
||||
return parseDesktopDaemonStatus(await invokeDesktopCommand('start_desktop_daemon'))
|
||||
}
|
||||
|
||||
export async function stopDesktopDaemon(): Promise<DesktopDaemonStatus> {
|
||||
return parseDesktopDaemonStatus(await invokeDesktopCommand('stop_desktop_daemon'))
|
||||
}
|
||||
|
||||
export async function restartDesktopDaemon(): Promise<DesktopDaemonStatus> {
|
||||
return parseDesktopDaemonStatus(await invokeDesktopCommand('restart_desktop_daemon'))
|
||||
}
|
||||
|
||||
export async function getDesktopDaemonLogs(): Promise<DesktopDaemonLogs> {
|
||||
return parseDesktopDaemonLogs(await invokeDesktopCommand('desktop_daemon_logs'))
|
||||
}
|
||||
|
||||
export async function getDesktopDaemonPairing(): Promise<DesktopPairingOffer> {
|
||||
return parseDesktopPairingOffer(await invokeDesktopCommand('desktop_daemon_pairing'))
|
||||
}
|
||||
|
||||
export function parseCliSymlinkInstructions(raw: unknown): CliSymlinkInstructions {
|
||||
const instructions = parseCliSymlinkInstructionsInternal(raw)
|
||||
if (!instructions) {
|
||||
throw new Error('Unexpected CLI symlink instructions response.')
|
||||
}
|
||||
return instructions
|
||||
}
|
||||
|
||||
export async function getCliSymlinkInstructions(): Promise<CliSymlinkInstructions> {
|
||||
return parseCliSymlinkInstructions(await invokeDesktopCommand('cli_symlink_instructions'))
|
||||
}
|
||||
|
||||
export type LocalTransportEventUnlisten = () => void
|
||||
export type LocalTransportEventHandler = (payload: LocalTransportEventPayload) => void
|
||||
|
||||
export async function listenToLocalTransportEvents(
|
||||
handler: LocalTransportEventHandler
|
||||
): Promise<LocalTransportEventUnlisten> {
|
||||
const listen = getDesktopHost()?.events?.on
|
||||
if (typeof listen !== 'function') {
|
||||
throw new Error('Desktop events API is unavailable.')
|
||||
}
|
||||
const unlisten = await listen('local-daemon-transport-event', (payload: unknown) => {
|
||||
if (!isRecord(payload)) {
|
||||
return
|
||||
}
|
||||
handler({
|
||||
sessionId: toStringOrNull(payload.sessionId) ?? '',
|
||||
kind: (toStringOrNull(payload.kind) ?? 'error') as LocalTransportEventPayload['kind'],
|
||||
text: toStringOrNull(payload.text),
|
||||
binaryBase64: toStringOrNull(payload.binaryBase64),
|
||||
code: toNumberOrNull(payload.code),
|
||||
reason: toStringOrNull(payload.reason),
|
||||
error: toStringOrNull(payload.error),
|
||||
})
|
||||
})
|
||||
return typeof unlisten === 'function' ? unlisten : () => {}
|
||||
}
|
||||
|
||||
export async function openLocalTransportSession(target: LocalTransportTarget): Promise<string> {
|
||||
const raw = await invokeDesktopCommand<unknown>('open_local_daemon_transport', target)
|
||||
if (typeof raw !== 'string' || raw.trim().length === 0) {
|
||||
throw new Error('Unexpected local transport session response.')
|
||||
}
|
||||
return raw
|
||||
}
|
||||
|
||||
export async function sendLocalTransportMessage(input: {
|
||||
sessionId: string
|
||||
text?: string
|
||||
binaryBase64?: string
|
||||
}): Promise<void> {
|
||||
await invokeDesktopCommand('send_local_daemon_transport_message', {
|
||||
sessionId: input.sessionId,
|
||||
...(input.text ? { text: input.text } : {}),
|
||||
...(input.binaryBase64 ? { binaryBase64: input.binaryBase64 } : {}),
|
||||
})
|
||||
}
|
||||
|
||||
export async function closeLocalTransportSession(sessionId: string): Promise<void> {
|
||||
await invokeDesktopCommand('close_local_daemon_transport', { sessionId })
|
||||
}
|
||||
Reference in New Issue
Block a user