chore: finalize electron desktop migration
This commit is contained in:
@@ -0,0 +1,569 @@
|
||||
import { spawn, type ChildProcess } from "node:child_process";
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { app, ipcMain } from "electron";
|
||||
import {
|
||||
loadConfig,
|
||||
resolvePaseoHome,
|
||||
getOrCreateServerId,
|
||||
} from "@getpaseo/server";
|
||||
import {
|
||||
copyAttachmentFileToManagedStorage,
|
||||
deleteManagedAttachmentFile,
|
||||
garbageCollectManagedAttachmentFiles,
|
||||
readManagedFileBase64,
|
||||
writeAttachmentBase64,
|
||||
} from "../features/attachments.js";
|
||||
import {
|
||||
checkForAppUpdate,
|
||||
downloadAndInstallUpdate,
|
||||
} from "../features/auto-updater.js";
|
||||
import {
|
||||
openLocalTransportSession,
|
||||
sendLocalTransportMessage,
|
||||
closeLocalTransportSession,
|
||||
} from "./local-transport.js";
|
||||
import {
|
||||
createElectronNodeEnv,
|
||||
resolveDaemonRunnerEntrypoint,
|
||||
} from "./runtime-paths.js";
|
||||
|
||||
const DAEMON_LOG_FILENAME = "daemon.log";
|
||||
const DAEMON_PID_FILENAME = "paseo.pid";
|
||||
const PID_POLL_INTERVAL_MS = 100;
|
||||
const STARTUP_POLL_INTERVAL_MS = 200;
|
||||
const STARTUP_POLL_MAX_ATTEMPTS = 150;
|
||||
const STOP_TIMEOUT_MS = 15_000;
|
||||
const KILL_TIMEOUT_MS = 3_000;
|
||||
const DETACHED_STARTUP_GRACE_MS = 1200;
|
||||
const DEFAULT_ELECTRON_DEV_SERVER_URL = "http://localhost:8081";
|
||||
|
||||
type DesktopDaemonState = "starting" | "running" | "stopped" | "errored";
|
||||
|
||||
type DesktopDaemonStatus = {
|
||||
serverId: string;
|
||||
status: DesktopDaemonState;
|
||||
listen: string;
|
||||
hostname: string | null;
|
||||
pid: number | null;
|
||||
home: string;
|
||||
error: string | null;
|
||||
};
|
||||
|
||||
type DesktopDaemonLogs = {
|
||||
logPath: string;
|
||||
contents: string;
|
||||
};
|
||||
|
||||
type DesktopPairingOffer = {
|
||||
relayEnabled: boolean;
|
||||
url: string | null;
|
||||
qr: string | null;
|
||||
};
|
||||
|
||||
type CliSymlinkInstructions = {
|
||||
title: string;
|
||||
detail: string;
|
||||
commands: string;
|
||||
};
|
||||
|
||||
type DesktopCommandHandler = (args?: Record<string, unknown>) => Promise<unknown> | unknown;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Utilities
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function getPaseoHome(): string {
|
||||
return resolvePaseoHome(process.env);
|
||||
}
|
||||
|
||||
function pidFilePath(): string {
|
||||
return path.join(getPaseoHome(), DAEMON_PID_FILENAME);
|
||||
}
|
||||
|
||||
function logFilePath(): string {
|
||||
return path.join(getPaseoHome(), DAEMON_LOG_FILENAME);
|
||||
}
|
||||
|
||||
function isProcessRunning(pid: number): boolean {
|
||||
try {
|
||||
process.kill(pid, 0);
|
||||
return true;
|
||||
} catch (err) {
|
||||
if (typeof err === "object" && err !== null && "code" in err && err.code === "EPERM") {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function signalProcessSafely(pid: number, signal: NodeJS.Signals): boolean {
|
||||
if (!Number.isInteger(pid) || pid <= 1 || pid === process.pid) return false;
|
||||
try {
|
||||
process.kill(pid, signal);
|
||||
return true;
|
||||
} catch (err) {
|
||||
if (typeof err === "object" && err !== null && "code" in err) {
|
||||
if (err.code === "ESRCH") return false;
|
||||
if (err.code === "EPERM") return true;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
function signalProcessGroupSafely(pid: number, signal: NodeJS.Signals): boolean {
|
||||
if (!Number.isInteger(pid) || pid <= 1 || pid === process.pid) return false;
|
||||
if (process.platform === "win32") return signalProcessSafely(pid, signal);
|
||||
try {
|
||||
process.kill(-pid, signal);
|
||||
return true;
|
||||
} catch (err) {
|
||||
if (typeof err === "object" && err !== null && "code" in err) {
|
||||
if (err.code === "ESRCH") return signalProcessSafely(pid, signal);
|
||||
if (err.code === "EPERM") return true;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
function sleep(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
setTimeout(resolve, ms);
|
||||
});
|
||||
}
|
||||
|
||||
async function waitForPidExit(pid: number, timeoutMs: number): Promise<boolean> {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
while (Date.now() < deadline) {
|
||||
if (!isProcessRunning(pid)) return true;
|
||||
await sleep(PID_POLL_INTERVAL_MS);
|
||||
}
|
||||
return !isProcessRunning(pid);
|
||||
}
|
||||
|
||||
function tailFile(filePath: string, lines = 50): string {
|
||||
try {
|
||||
const content = readFileSync(filePath, "utf-8");
|
||||
return content.split("\n").filter(Boolean).slice(-lines).join("\n");
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
function buildDesktopDaemonCorsOriginsEnv(): string | undefined {
|
||||
const origins = new Set(
|
||||
(process.env.PASEO_CORS_ORIGINS ?? "")
|
||||
.split(",")
|
||||
.map((value) => value.trim())
|
||||
.filter((value) => value.length > 0)
|
||||
);
|
||||
|
||||
const devServerUrl = process.env.EXPO_DEV_URL ?? DEFAULT_ELECTRON_DEV_SERVER_URL;
|
||||
try {
|
||||
const parsed = new URL(devServerUrl);
|
||||
origins.add(parsed.origin);
|
||||
|
||||
if (parsed.hostname === "localhost") {
|
||||
origins.add(`${parsed.protocol}//127.0.0.1${parsed.port ? `:${parsed.port}` : ""}`);
|
||||
} else if (parsed.hostname === "127.0.0.1") {
|
||||
origins.add(`${parsed.protocol}//localhost${parsed.port ? `:${parsed.port}` : ""}`);
|
||||
}
|
||||
} catch {
|
||||
// Ignore malformed dev server URLs and preserve any explicit env configuration.
|
||||
}
|
||||
|
||||
return origins.size > 0 ? Array.from(origins).join(",") : undefined;
|
||||
}
|
||||
|
||||
function toTrimmedString(value: unknown): string | null {
|
||||
if (typeof value !== "string") {
|
||||
return null;
|
||||
}
|
||||
const trimmed = value.trim();
|
||||
return trimmed.length > 0 ? trimmed : null;
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function resolveTcpHostFromListen(listen: string): string | null {
|
||||
const normalized = listen.trim();
|
||||
if (!normalized) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (
|
||||
normalized.startsWith("/") ||
|
||||
normalized.startsWith("unix://") ||
|
||||
normalized.startsWith("pipe://") ||
|
||||
normalized.startsWith("\\\\.\\pipe\\")
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (/^\d+$/.test(normalized)) {
|
||||
return `127.0.0.1:${normalized}`;
|
||||
}
|
||||
|
||||
if (normalized.includes(":")) {
|
||||
return normalized;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function buildDaemonHttpBaseUrl(listen: string): string | null {
|
||||
const endpoint = resolveTcpHostFromListen(listen);
|
||||
if (!endpoint) {
|
||||
return null;
|
||||
}
|
||||
return new URL(`http://${endpoint}`).toString().replace(/\/$/, "");
|
||||
}
|
||||
|
||||
function resolveDesktopAppVersion(): string {
|
||||
if (app.isPackaged) {
|
||||
return app.getVersion();
|
||||
}
|
||||
|
||||
try {
|
||||
const packageJsonPath = path.join(__dirname, "..", "..", "package.json");
|
||||
const pkg = JSON.parse(readFileSync(packageJsonPath, "utf-8")) as {
|
||||
version?: unknown;
|
||||
};
|
||||
if (typeof pkg.version === "string" && pkg.version.trim().length > 0) {
|
||||
return pkg.version.trim();
|
||||
}
|
||||
} catch {
|
||||
// Fall back to Electron's default version if the package metadata is unavailable.
|
||||
}
|
||||
|
||||
return app.getVersion();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Daemon lifecycle
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function resolveStatus(): DesktopDaemonStatus {
|
||||
const home = getPaseoHome();
|
||||
const config = loadConfig(home, { env: process.env });
|
||||
const pidPath = pidFilePath();
|
||||
|
||||
let pid: number | null = null;
|
||||
let hostname: string | null = null;
|
||||
let listen: string = config.listen;
|
||||
|
||||
try {
|
||||
if (existsSync(pidPath)) {
|
||||
const parsed = JSON.parse(readFileSync(pidPath, "utf-8")) as Record<string, unknown>;
|
||||
const pidValue = parsed.pid;
|
||||
if (typeof pidValue === "number" && Number.isInteger(pidValue) && pidValue > 0) {
|
||||
pid = pidValue;
|
||||
hostname = typeof parsed.hostname === "string" ? parsed.hostname : null;
|
||||
const pidListen =
|
||||
typeof parsed.listen === "string"
|
||||
? parsed.listen
|
||||
: typeof parsed.sockPath === "string"
|
||||
? (parsed.sockPath as string)
|
||||
: null;
|
||||
if (pidListen) listen = pidListen;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// PID file missing or malformed — treat as stopped.
|
||||
}
|
||||
|
||||
const running = pid !== null && isProcessRunning(pid);
|
||||
|
||||
let serverId = "";
|
||||
try {
|
||||
serverId = getOrCreateServerId(home);
|
||||
} catch {
|
||||
// Ignore — server-id may not exist yet.
|
||||
}
|
||||
|
||||
return {
|
||||
serverId,
|
||||
status: running ? "running" : "stopped",
|
||||
listen,
|
||||
hostname: running ? hostname : null,
|
||||
pid: running ? pid : null,
|
||||
home,
|
||||
error: null,
|
||||
};
|
||||
}
|
||||
|
||||
async function startDaemon(): Promise<DesktopDaemonStatus> {
|
||||
const current = resolveStatus();
|
||||
if (current.status === "running") return current;
|
||||
|
||||
const home = getPaseoHome();
|
||||
const daemonRunner = resolveDaemonRunnerEntrypoint();
|
||||
const corsOrigins = buildDesktopDaemonCorsOriginsEnv();
|
||||
|
||||
const child: ChildProcess = spawn(
|
||||
process.execPath,
|
||||
[...daemonRunner.execArgv, daemonRunner.entryPath],
|
||||
{
|
||||
detached: true,
|
||||
env: createElectronNodeEnv({
|
||||
...process.env,
|
||||
PASEO_HOME: home,
|
||||
...(corsOrigins ? { PASEO_CORS_ORIGINS: corsOrigins } : {}),
|
||||
}),
|
||||
stdio: ["ignore", "ignore", "ignore"],
|
||||
}
|
||||
);
|
||||
|
||||
child.unref();
|
||||
|
||||
// Wait for process to survive the grace period
|
||||
const exitedEarly = await new Promise<boolean>((resolve) => {
|
||||
let settled = false;
|
||||
const finish = (value: boolean) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
resolve(value);
|
||||
};
|
||||
|
||||
const timer = setTimeout(() => finish(false), DETACHED_STARTUP_GRACE_MS);
|
||||
|
||||
child.once("error", () => {
|
||||
clearTimeout(timer);
|
||||
finish(true);
|
||||
});
|
||||
child.once("exit", () => {
|
||||
clearTimeout(timer);
|
||||
finish(true);
|
||||
});
|
||||
});
|
||||
|
||||
if (exitedEarly) {
|
||||
const logs = tailFile(logFilePath(), 15);
|
||||
throw new Error(
|
||||
`Daemon failed to start.${logs ? `\n\nRecent logs:\n${logs}` : ""}`
|
||||
);
|
||||
}
|
||||
|
||||
// Poll for PID file with server ID
|
||||
for (let attempt = 0; attempt < STARTUP_POLL_MAX_ATTEMPTS; attempt++) {
|
||||
const status = resolveStatus();
|
||||
if (status.status === "running" && status.serverId) return status;
|
||||
await sleep(STARTUP_POLL_INTERVAL_MS);
|
||||
}
|
||||
|
||||
return resolveStatus();
|
||||
}
|
||||
|
||||
async function stopDaemon(): Promise<DesktopDaemonStatus> {
|
||||
const status = resolveStatus();
|
||||
if (status.status !== "running" || !status.pid) return status;
|
||||
|
||||
const pid = status.pid;
|
||||
signalProcessSafely(pid, "SIGTERM");
|
||||
|
||||
let stopped = await waitForPidExit(pid, STOP_TIMEOUT_MS);
|
||||
if (!stopped) {
|
||||
signalProcessGroupSafely(pid, "SIGKILL");
|
||||
stopped = await waitForPidExit(pid, KILL_TIMEOUT_MS);
|
||||
}
|
||||
|
||||
if (!stopped) {
|
||||
throw new Error(`Timed out waiting for daemon PID ${pid} to stop`);
|
||||
}
|
||||
|
||||
return resolveStatus();
|
||||
}
|
||||
|
||||
async function restartDaemon(): Promise<DesktopDaemonStatus> {
|
||||
await stopDaemon();
|
||||
return startDaemon();
|
||||
}
|
||||
|
||||
function getDaemonLogs(): DesktopDaemonLogs {
|
||||
const logPath = logFilePath();
|
||||
return {
|
||||
logPath,
|
||||
contents: tailFile(logPath, 100),
|
||||
};
|
||||
}
|
||||
|
||||
async function getDaemonPairing(): Promise<DesktopPairingOffer> {
|
||||
const status = resolveStatus();
|
||||
if (status.status !== "running") {
|
||||
return {
|
||||
relayEnabled: false,
|
||||
url: null,
|
||||
qr: null,
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const baseUrl = buildDaemonHttpBaseUrl(status.listen);
|
||||
if (!baseUrl) {
|
||||
throw new Error(`Daemon listen target is not a TCP endpoint: ${status.listen}`);
|
||||
}
|
||||
|
||||
const response = await fetch(`${baseUrl}/pairing`);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Daemon pairing request failed with ${response.status}`);
|
||||
}
|
||||
|
||||
const payload = (await response.json()) as unknown;
|
||||
if (!isRecord(payload)) {
|
||||
throw new Error("Daemon pairing response was not an object.");
|
||||
}
|
||||
|
||||
return {
|
||||
relayEnabled: payload.relayEnabled === true,
|
||||
url: toTrimmedString(payload.url),
|
||||
qr: toTrimmedString(payload.qr),
|
||||
};
|
||||
} catch {
|
||||
return {
|
||||
relayEnabled: false,
|
||||
url: null,
|
||||
qr: null,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async function getLocalDaemonVersion(): Promise<{
|
||||
version: string | null;
|
||||
error: string | null;
|
||||
}> {
|
||||
const status = resolveStatus();
|
||||
if (status.status !== "running") {
|
||||
return {
|
||||
version: null,
|
||||
error: "Daemon is not running.",
|
||||
};
|
||||
}
|
||||
|
||||
const baseUrl = buildDaemonHttpBaseUrl(status.listen);
|
||||
if (!baseUrl) {
|
||||
return { version: null, error: `Daemon listen target is not a TCP endpoint: ${status.listen}` };
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`${baseUrl}/api/status`);
|
||||
if (!response.ok) {
|
||||
return { version: null, error: `Daemon status request failed with ${response.status}` };
|
||||
}
|
||||
const payload = (await response.json()) as Record<string, unknown>;
|
||||
const version = typeof payload.version === "string" ? payload.version.trim() : null;
|
||||
return {
|
||||
version: version && version.length > 0 ? version : null,
|
||||
error: version ? null : "Running daemon did not report a version.",
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
version: null,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async function resolveCurrentUpdateVersion(): Promise<string> {
|
||||
const daemonVersion = await getLocalDaemonVersion();
|
||||
if (daemonVersion.version) {
|
||||
return daemonVersion.version;
|
||||
}
|
||||
return resolveDesktopAppVersion();
|
||||
}
|
||||
|
||||
function getCliSymlinkInstructions(): CliSymlinkInstructions {
|
||||
const electronExePath = app.getPath("exe");
|
||||
const cliShimFilename = process.platform === "win32" ? "paseo.cmd" : "paseo";
|
||||
|
||||
if (process.platform === "darwin") {
|
||||
const appBundle = electronExePath.replace(/\/Contents\/MacOS\/.+$/, "");
|
||||
const cliPath = path.join(appBundle, "Contents", "MacOS", cliShimFilename);
|
||||
return {
|
||||
title: "Add paseo to your shell",
|
||||
detail: "Create a symlink to the bundled Paseo CLI shim.",
|
||||
commands: `sudo ln -sf "${cliPath}" /usr/local/bin/paseo`,
|
||||
};
|
||||
}
|
||||
|
||||
if (process.platform === "win32") {
|
||||
return {
|
||||
title: "Add paseo to your PATH",
|
||||
detail: "Add the Paseo installation directory to your system PATH so paseo.cmd is available.",
|
||||
commands: `setx PATH "%PATH%;${path.dirname(electronExePath)}"`,
|
||||
};
|
||||
}
|
||||
|
||||
// Linux
|
||||
return {
|
||||
title: "Add paseo to your shell",
|
||||
detail: "Create a symlink to the bundled Paseo CLI shim.",
|
||||
commands: `sudo ln -sf "${path.join(path.dirname(electronExePath), cliShimFilename)}" /usr/local/bin/paseo`,
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// IPC registration
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function createDaemonCommandHandlers(): Record<string, DesktopCommandHandler> {
|
||||
return {
|
||||
desktop_daemon_status: () => resolveStatus(),
|
||||
start_desktop_daemon: () => startDaemon(),
|
||||
stop_desktop_daemon: () => stopDaemon(),
|
||||
restart_desktop_daemon: () => restartDaemon(),
|
||||
desktop_daemon_logs: () => getDaemonLogs(),
|
||||
desktop_daemon_pairing: () => getDaemonPairing(),
|
||||
cli_symlink_instructions: () => getCliSymlinkInstructions(),
|
||||
write_attachment_base64: (args) => writeAttachmentBase64(args ?? {}),
|
||||
copy_attachment_file: (args) => copyAttachmentFileToManagedStorage(args ?? {}),
|
||||
read_file_base64: (args) => readManagedFileBase64(args ?? {}),
|
||||
delete_attachment_file: (args) => deleteManagedAttachmentFile(args ?? {}),
|
||||
garbage_collect_attachment_files: (args) =>
|
||||
garbageCollectManagedAttachmentFiles(args ?? {}),
|
||||
open_local_daemon_transport: async (args) => {
|
||||
const target = args as { transportType: "socket" | "pipe"; transportPath: string };
|
||||
return await openLocalTransportSession(target);
|
||||
},
|
||||
send_local_daemon_transport_message: async (args) => {
|
||||
await sendLocalTransportMessage(args as { sessionId: string; text?: string; binaryBase64?: string });
|
||||
},
|
||||
close_local_daemon_transport: (args) => {
|
||||
const sessionId = typeof args === "object" && args !== null && "sessionId" in args
|
||||
? (args as { sessionId: string }).sessionId
|
||||
: "";
|
||||
if (sessionId) closeLocalTransportSession(sessionId);
|
||||
},
|
||||
check_app_update: async () => {
|
||||
const currentVersion = await resolveCurrentUpdateVersion();
|
||||
return checkForAppUpdate(currentVersion);
|
||||
},
|
||||
install_app_update: async () => {
|
||||
const currentVersion = await resolveCurrentUpdateVersion();
|
||||
return downloadAndInstallUpdate(currentVersion);
|
||||
},
|
||||
get_local_daemon_version: () => getLocalDaemonVersion(),
|
||||
webview_log: (args) => {
|
||||
const level = typeof args?.level === "number" ? args.level : 1;
|
||||
const message = typeof args?.message === "string" ? args.message : "";
|
||||
const method = level === 0 ? "debug" : level === 2 ? "warn" : level >= 3 ? "error" : "info";
|
||||
console[method]("[webview]", message);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function registerDaemonManager(): void {
|
||||
const handlers = createDaemonCommandHandlers();
|
||||
|
||||
ipcMain.handle(
|
||||
"paseo:invoke",
|
||||
async (_event, command: string, args?: Record<string, unknown>) => {
|
||||
const handler = handlers[command];
|
||||
if (!handler) {
|
||||
throw new Error(`Unknown desktop command: ${command}`);
|
||||
}
|
||||
return await handler(args);
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,212 @@
|
||||
import { BrowserWindow } from "electron";
|
||||
import WebSocket from "ws";
|
||||
|
||||
type TransportTarget = {
|
||||
transportType: "socket" | "pipe";
|
||||
transportPath: string;
|
||||
};
|
||||
|
||||
type TransportEventPayload = {
|
||||
sessionId: string;
|
||||
kind: "open" | "message" | "close" | "error";
|
||||
text?: string | null;
|
||||
binaryBase64?: string | null;
|
||||
code?: number | null;
|
||||
reason?: string | null;
|
||||
error?: string | null;
|
||||
};
|
||||
|
||||
type Session = {
|
||||
id: string;
|
||||
ws: WebSocket;
|
||||
state: "opening" | "open" | "closing" | "closed";
|
||||
};
|
||||
|
||||
const WS_ENDPOINT_PATH = "/ws";
|
||||
|
||||
let nextSessionId = 0;
|
||||
const sessions = new Map<string, Session>();
|
||||
|
||||
function emitTransportEvent(payload: TransportEventPayload): void {
|
||||
for (const win of BrowserWindow.getAllWindows()) {
|
||||
win.webContents.send("paseo:event:local-daemon-transport-event", payload);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a WebSocket URL that connects through a Unix domain socket or Windows
|
||||
* named pipe. The `ws` library supports these via the `ws+unix://` scheme:
|
||||
*
|
||||
* ws+unix:///path/to/socket:/ws
|
||||
* ws+unix://./pipe/paseo:/ws (Windows named pipe)
|
||||
*
|
||||
* The part before `:` is the IPC path, the part after is the HTTP request
|
||||
* path used during the WebSocket upgrade handshake.
|
||||
*/
|
||||
function buildLocalWebSocketUrl(target: TransportTarget): string {
|
||||
const ipcPath = target.transportPath;
|
||||
return `ws+unix://${ipcPath}:${WS_ENDPOINT_PATH}`;
|
||||
}
|
||||
|
||||
function describeTransportTarget(target: TransportTarget): string {
|
||||
return target.transportType === "pipe" ? "local daemon pipe" : "local daemon socket";
|
||||
}
|
||||
|
||||
function decodeTransportMessage(input: {
|
||||
text?: string;
|
||||
binaryBase64?: string;
|
||||
}): string | Buffer {
|
||||
if (typeof input.text === "string") {
|
||||
return input.text;
|
||||
}
|
||||
|
||||
if (typeof input.binaryBase64 === "string") {
|
||||
return Buffer.from(input.binaryBase64, "base64");
|
||||
}
|
||||
|
||||
throw new Error("Local transport send requires text or binary payload.");
|
||||
}
|
||||
|
||||
export function openLocalTransportSession(target: TransportTarget): Promise<string> {
|
||||
const sessionId = `local-session-${++nextSessionId}`;
|
||||
const url = buildLocalWebSocketUrl(target);
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const ws = new WebSocket(url);
|
||||
const session: Session = {
|
||||
id: sessionId,
|
||||
ws,
|
||||
state: "opening",
|
||||
};
|
||||
sessions.set(sessionId, session);
|
||||
|
||||
let openSettled = false;
|
||||
|
||||
const finalizeOpenFailure = (message: string): void => {
|
||||
if (openSettled) {
|
||||
return;
|
||||
}
|
||||
|
||||
openSettled = true;
|
||||
session.state = "closed";
|
||||
sessions.delete(sessionId);
|
||||
reject(new Error(message));
|
||||
};
|
||||
|
||||
ws.once("open", () => {
|
||||
openSettled = true;
|
||||
session.state = "open";
|
||||
resolve(sessionId);
|
||||
emitTransportEvent({ sessionId, kind: "open" });
|
||||
});
|
||||
|
||||
ws.on("message", (data: WebSocket.RawData, isBinary: boolean) => {
|
||||
if (isBinary || data instanceof Buffer) {
|
||||
const buf = Buffer.isBuffer(data) ? data : Buffer.from(data as ArrayBuffer);
|
||||
emitTransportEvent({
|
||||
sessionId,
|
||||
kind: "message",
|
||||
binaryBase64: buf.toString("base64"),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
emitTransportEvent({
|
||||
sessionId,
|
||||
kind: "message",
|
||||
text: data.toString(),
|
||||
});
|
||||
});
|
||||
|
||||
ws.on("close", (code: number, reason?: Buffer | string) => {
|
||||
const shouldEmitClose = session.state === "open" || session.state === "closing";
|
||||
session.state = "closed";
|
||||
sessions.delete(sessionId);
|
||||
|
||||
if (!openSettled) {
|
||||
finalizeOpenFailure(
|
||||
`${describeTransportTarget(target)} closed before the session became ready.`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (shouldEmitClose) {
|
||||
emitTransportEvent({
|
||||
sessionId,
|
||||
kind: "close",
|
||||
code,
|
||||
reason: reason ? String(reason) : "",
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
ws.on("error", (err: Error) => {
|
||||
if (!openSettled) {
|
||||
finalizeOpenFailure(
|
||||
`Failed to connect to ${describeTransportTarget(target)}: ${err.message}`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
emitTransportEvent({
|
||||
sessionId,
|
||||
kind: "error",
|
||||
error: err.message,
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export async function sendLocalTransportMessage(input: {
|
||||
sessionId: string;
|
||||
text?: string;
|
||||
binaryBase64?: string;
|
||||
}): Promise<void> {
|
||||
const session = sessions.get(input.sessionId);
|
||||
if (!session) {
|
||||
throw new Error(`Local transport session not found: ${input.sessionId}`);
|
||||
}
|
||||
|
||||
if (session.state !== "open" || session.ws.readyState !== WebSocket.OPEN) {
|
||||
throw new Error(
|
||||
session.state === "opening"
|
||||
? "Local transport session is not open yet."
|
||||
: "Local transport session is closed."
|
||||
);
|
||||
}
|
||||
|
||||
const payload = decodeTransportMessage(input);
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
session.ws.send(payload, (error) => {
|
||||
if (error) {
|
||||
reject(new Error(`Local transport write failed: ${error.message}`));
|
||||
return;
|
||||
}
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export function closeLocalTransportSession(sessionId: string): void {
|
||||
const session = sessions.get(sessionId);
|
||||
if (!session) return;
|
||||
|
||||
try {
|
||||
if (session.ws.readyState === WebSocket.CONNECTING) {
|
||||
session.state = "closed";
|
||||
session.ws.terminate();
|
||||
} else {
|
||||
session.state = "closing";
|
||||
session.ws.close();
|
||||
}
|
||||
} catch {
|
||||
// ignore close errors
|
||||
}
|
||||
sessions.delete(sessionId);
|
||||
}
|
||||
|
||||
export function closeAllTransportSessions(): void {
|
||||
for (const [id] of sessions) {
|
||||
closeLocalTransportSession(id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import { spawnSync, type SpawnSyncReturns } from "node:child_process";
|
||||
import { createRequire } from "node:module";
|
||||
import path from "node:path";
|
||||
import { app } from "electron";
|
||||
|
||||
const CLI_PACKAGE_NAME = "@getpaseo/cli";
|
||||
const SERVER_PACKAGE_NAME = "@getpaseo/server";
|
||||
const CLI_BIN_ENTRY = `${CLI_PACKAGE_NAME}/bin/paseo`;
|
||||
const IGNORED_GUI_LAUNCH_ARG_PREFIX = "-psn_";
|
||||
|
||||
type PackageInfo = {
|
||||
root: string;
|
||||
};
|
||||
|
||||
export type NodeEntrypointSpec = {
|
||||
entryPath: string;
|
||||
execArgv: string[];
|
||||
};
|
||||
|
||||
const esmRequire = createRequire(__filename);
|
||||
|
||||
function findPackageRootFromResolvedPath(input: {
|
||||
resolvedPath: string;
|
||||
packageName: string;
|
||||
}): PackageInfo {
|
||||
let currentDir = path.dirname(input.resolvedPath);
|
||||
|
||||
while (true) {
|
||||
const packageJsonPath = path.join(currentDir, "package.json");
|
||||
if (existsSync(packageJsonPath)) {
|
||||
try {
|
||||
const pkg = JSON.parse(readFileSync(packageJsonPath, "utf-8")) as {
|
||||
name?: string;
|
||||
};
|
||||
if (pkg.name === input.packageName) {
|
||||
return {
|
||||
root: currentDir,
|
||||
};
|
||||
}
|
||||
} catch {
|
||||
// Ignore malformed package metadata while walking up.
|
||||
}
|
||||
}
|
||||
|
||||
const parent = path.dirname(currentDir);
|
||||
if (parent === currentDir) {
|
||||
break;
|
||||
}
|
||||
currentDir = parent;
|
||||
}
|
||||
|
||||
throw new Error(`Unable to resolve ${input.packageName} package root`);
|
||||
}
|
||||
|
||||
function resolveServerPackageInfo(): PackageInfo {
|
||||
const serverExportPath = esmRequire.resolve(SERVER_PACKAGE_NAME);
|
||||
return findPackageRootFromResolvedPath({
|
||||
resolvedPath: serverExportPath,
|
||||
packageName: SERVER_PACKAGE_NAME,
|
||||
});
|
||||
}
|
||||
|
||||
function resolveCliPackageInfo(): PackageInfo {
|
||||
const cliBinPath = esmRequire.resolve(CLI_BIN_ENTRY);
|
||||
return findPackageRootFromResolvedPath({
|
||||
resolvedPath: cliBinPath,
|
||||
packageName: CLI_PACKAGE_NAME,
|
||||
});
|
||||
}
|
||||
|
||||
function resolvePackagedAsarPath(): string {
|
||||
return path.join(process.resourcesPath, "app.asar");
|
||||
}
|
||||
|
||||
function assertPathExists(input: {
|
||||
label: string;
|
||||
filePath: string;
|
||||
}): string {
|
||||
if (!existsSync(input.filePath)) {
|
||||
throw new Error(`${input.label} is missing at ${input.filePath}`);
|
||||
}
|
||||
|
||||
return input.filePath;
|
||||
}
|
||||
|
||||
export function parseCliPassthroughArgsFromArgv(argv: string[]): string[] | null {
|
||||
const startIndex = process.defaultApp ? 2 : 1;
|
||||
const effective = argv
|
||||
.slice(startIndex)
|
||||
.filter((arg) => !arg.startsWith(IGNORED_GUI_LAUNCH_ARG_PREFIX));
|
||||
|
||||
return effective.length > 0 ? effective : null;
|
||||
}
|
||||
|
||||
export function resolveDaemonRunnerEntrypoint(): NodeEntrypointSpec {
|
||||
if (app.isPackaged) {
|
||||
return {
|
||||
entryPath: assertPathExists({
|
||||
label: "Bundled daemon runner",
|
||||
filePath: path.join(
|
||||
resolvePackagedAsarPath(),
|
||||
"node_modules",
|
||||
"@getpaseo",
|
||||
"server",
|
||||
"dist",
|
||||
"scripts",
|
||||
"daemon-runner.js"
|
||||
),
|
||||
}),
|
||||
execArgv: [],
|
||||
};
|
||||
}
|
||||
|
||||
const serverPackage = resolveServerPackageInfo();
|
||||
const distRunner = path.join(serverPackage.root, "dist", "scripts", "daemon-runner.js");
|
||||
if (existsSync(distRunner)) {
|
||||
return {
|
||||
entryPath: distRunner,
|
||||
execArgv: [],
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
entryPath: assertPathExists({
|
||||
label: "Daemon runner source",
|
||||
filePath: path.join(serverPackage.root, "scripts", "daemon-runner.ts"),
|
||||
}),
|
||||
execArgv: ["--import", "tsx"],
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveCliEntrypoint(): NodeEntrypointSpec {
|
||||
if (app.isPackaged) {
|
||||
return {
|
||||
entryPath: assertPathExists({
|
||||
label: "Bundled CLI entrypoint",
|
||||
filePath: path.join(
|
||||
resolvePackagedAsarPath(),
|
||||
"node_modules",
|
||||
"@getpaseo",
|
||||
"cli",
|
||||
"dist",
|
||||
"index.js"
|
||||
),
|
||||
}),
|
||||
execArgv: [],
|
||||
};
|
||||
}
|
||||
|
||||
const cliPackage = resolveCliPackageInfo();
|
||||
const distEntry = path.join(cliPackage.root, "dist", "index.js");
|
||||
if (existsSync(distEntry)) {
|
||||
return {
|
||||
entryPath: distEntry,
|
||||
execArgv: [],
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
entryPath: assertPathExists({
|
||||
label: "CLI source entrypoint",
|
||||
filePath: path.join(cliPackage.root, "src", "index.ts"),
|
||||
}),
|
||||
execArgv: ["--import", "tsx"],
|
||||
};
|
||||
}
|
||||
|
||||
export function createElectronNodeEnv(baseEnv: NodeJS.ProcessEnv): NodeJS.ProcessEnv {
|
||||
return {
|
||||
...baseEnv,
|
||||
ELECTRON_RUN_AS_NODE: "1",
|
||||
};
|
||||
}
|
||||
|
||||
function spawnCliProcess(args: string[]): SpawnSyncReturns<Buffer> {
|
||||
const cli = resolveCliEntrypoint();
|
||||
|
||||
return spawnSync(process.execPath, [...cli.execArgv, cli.entryPath, ...args], {
|
||||
env: createElectronNodeEnv(process.env),
|
||||
stdio: "inherit",
|
||||
});
|
||||
}
|
||||
|
||||
export function runCliPassthroughCommand(args: string[]): number {
|
||||
const result = spawnCliProcess(args);
|
||||
if (result.error) {
|
||||
throw result.error;
|
||||
}
|
||||
|
||||
if (typeof result.status === "number") {
|
||||
return result.status;
|
||||
}
|
||||
|
||||
return result.signal ? 1 : 0;
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
import { copyFile, mkdir, readFile, readdir, rm, stat, writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { resolvePaseoHome } from "@getpaseo/server";
|
||||
|
||||
const ATTACHMENTS_DIRNAME = "desktop-attachments";
|
||||
const ATTACHMENT_ID_PATTERN = /^[A-Za-z0-9_-]+$/;
|
||||
const EXTENSION_PATTERN = /^\.[A-Za-z0-9]{1,16}$/;
|
||||
|
||||
type AttachmentFileResult = {
|
||||
path: string;
|
||||
byteSize: number;
|
||||
};
|
||||
|
||||
function attachmentsDirPath(): string {
|
||||
return path.join(resolvePaseoHome(process.env), ATTACHMENTS_DIRNAME);
|
||||
}
|
||||
|
||||
async function ensureAttachmentsDir(): Promise<string> {
|
||||
const dirPath = attachmentsDirPath();
|
||||
await mkdir(dirPath, { recursive: true });
|
||||
return dirPath;
|
||||
}
|
||||
|
||||
function normalizeAttachmentId(value: unknown): string {
|
||||
if (typeof value !== "string") {
|
||||
throw new Error("Attachment id is required.");
|
||||
}
|
||||
const normalized = value.trim();
|
||||
if (!ATTACHMENT_ID_PATTERN.test(normalized)) {
|
||||
throw new Error(`Invalid attachment id: ${value}`);
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function normalizeExtension(value: unknown): string {
|
||||
if (value == null || value === "") {
|
||||
return ".bin";
|
||||
}
|
||||
if (typeof value !== "string") {
|
||||
throw new Error("Attachment extension must be a string.");
|
||||
}
|
||||
const normalized = value.trim().toLowerCase();
|
||||
if (!EXTENSION_PATTERN.test(normalized)) {
|
||||
throw new Error(`Invalid attachment extension: ${value}`);
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
async function buildManagedAttachmentPath(input: {
|
||||
attachmentId: unknown;
|
||||
extension: unknown;
|
||||
}): Promise<string> {
|
||||
const dirPath = await ensureAttachmentsDir();
|
||||
const attachmentId = normalizeAttachmentId(input.attachmentId);
|
||||
const extension = normalizeExtension(input.extension);
|
||||
return path.join(dirPath, `${attachmentId}${extension}`);
|
||||
}
|
||||
|
||||
function resolveManagedAttachmentPath(inputPath: unknown): string {
|
||||
if (typeof inputPath !== "string" || inputPath.trim().length === 0) {
|
||||
throw new Error("Attachment path is required.");
|
||||
}
|
||||
const resolvedDir = `${path.resolve(attachmentsDirPath())}${path.sep}`;
|
||||
const resolvedPath = path.resolve(inputPath.trim());
|
||||
if (!resolvedPath.startsWith(resolvedDir)) {
|
||||
throw new Error("Attachment path must stay within desktop-managed storage.");
|
||||
}
|
||||
return resolvedPath;
|
||||
}
|
||||
|
||||
export async function writeAttachmentBase64(input: {
|
||||
attachmentId?: unknown;
|
||||
base64?: unknown;
|
||||
extension?: unknown;
|
||||
}): Promise<AttachmentFileResult> {
|
||||
const base64 = typeof input.base64 === "string" ? input.base64.trim() : "";
|
||||
if (base64.length === 0) {
|
||||
throw new Error("Attachment base64 payload is required.");
|
||||
}
|
||||
|
||||
const targetPath = await buildManagedAttachmentPath({
|
||||
attachmentId: input.attachmentId,
|
||||
extension: input.extension,
|
||||
});
|
||||
await writeFile(targetPath, Buffer.from(base64, "base64"));
|
||||
const fileInfo = await stat(targetPath);
|
||||
return {
|
||||
path: targetPath,
|
||||
byteSize: fileInfo.size,
|
||||
};
|
||||
}
|
||||
|
||||
export async function copyAttachmentFileToManagedStorage(input: {
|
||||
attachmentId?: unknown;
|
||||
sourcePath?: unknown;
|
||||
extension?: unknown;
|
||||
}): Promise<AttachmentFileResult> {
|
||||
if (typeof input.sourcePath !== "string" || input.sourcePath.trim().length === 0) {
|
||||
throw new Error("Attachment source path is required.");
|
||||
}
|
||||
|
||||
const sourcePath = path.resolve(input.sourcePath.trim());
|
||||
const targetPath = await buildManagedAttachmentPath({
|
||||
attachmentId: input.attachmentId,
|
||||
extension: input.extension,
|
||||
});
|
||||
|
||||
if (sourcePath !== targetPath) {
|
||||
await copyFile(sourcePath, targetPath);
|
||||
}
|
||||
|
||||
const fileInfo = await stat(targetPath);
|
||||
return {
|
||||
path: targetPath,
|
||||
byteSize: fileInfo.size,
|
||||
};
|
||||
}
|
||||
|
||||
export async function readManagedFileBase64(input: { path?: unknown }): Promise<string> {
|
||||
const filePath = resolveManagedAttachmentPath(input.path);
|
||||
const bytes = await readFile(filePath);
|
||||
return bytes.toString("base64");
|
||||
}
|
||||
|
||||
export async function deleteManagedAttachmentFile(input: {
|
||||
path?: unknown;
|
||||
}): Promise<boolean> {
|
||||
const filePath = resolveManagedAttachmentPath(input.path);
|
||||
await rm(filePath, { force: true });
|
||||
return true;
|
||||
}
|
||||
|
||||
export async function garbageCollectManagedAttachmentFiles(input: {
|
||||
referencedIds?: unknown;
|
||||
}): Promise<number> {
|
||||
const dirPath = await ensureAttachmentsDir();
|
||||
const referencedIds = Array.isArray(input.referencedIds)
|
||||
? new Set(
|
||||
input.referencedIds
|
||||
.filter((value): value is string => typeof value === "string")
|
||||
.map((value) => value.trim())
|
||||
.filter((value) => ATTACHMENT_ID_PATTERN.test(value))
|
||||
)
|
||||
: new Set<string>();
|
||||
|
||||
const entries = await readdir(dirPath, { withFileTypes: true });
|
||||
let deletedCount = 0;
|
||||
|
||||
for (const entry of entries) {
|
||||
if (!entry.isFile()) {
|
||||
continue;
|
||||
}
|
||||
const attachmentId = path.parse(entry.name).name;
|
||||
if (referencedIds.has(attachmentId)) {
|
||||
continue;
|
||||
}
|
||||
await rm(path.join(dirPath, entry.name), { force: true });
|
||||
deletedCount += 1;
|
||||
}
|
||||
|
||||
return deletedCount;
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
import { app } from "electron";
|
||||
import { autoUpdater, type UpdateInfo } from "electron-updater";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type AppUpdateCheckResult = {
|
||||
hasUpdate: boolean;
|
||||
currentVersion: string;
|
||||
latestVersion: string;
|
||||
body: string | null;
|
||||
date: string | null;
|
||||
};
|
||||
|
||||
export type AppUpdateInstallResult = {
|
||||
installed: boolean;
|
||||
version: string | null;
|
||||
message: string;
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// State
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
let cachedUpdateInfo: UpdateInfo | null = null;
|
||||
let downloading = false;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Configuration
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function configureAutoUpdater(): void {
|
||||
// Don't auto-download — the user triggers install explicitly.
|
||||
autoUpdater.autoDownload = false;
|
||||
autoUpdater.autoInstallOnAppQuit = true;
|
||||
|
||||
// Suppress built-in dialogs; the renderer handles UI.
|
||||
autoUpdater.autoRunAppAfterInstall = true;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public API
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export async function checkForAppUpdate(currentVersion: string): Promise<AppUpdateCheckResult> {
|
||||
if (!app.isPackaged) {
|
||||
return {
|
||||
hasUpdate: false,
|
||||
currentVersion,
|
||||
latestVersion: currentVersion,
|
||||
body: null,
|
||||
date: null,
|
||||
};
|
||||
}
|
||||
|
||||
configureAutoUpdater();
|
||||
|
||||
try {
|
||||
const result = await autoUpdater.checkForUpdates();
|
||||
|
||||
if (!result || !result.updateInfo) {
|
||||
return {
|
||||
hasUpdate: false,
|
||||
currentVersion,
|
||||
latestVersion: currentVersion,
|
||||
body: null,
|
||||
date: null,
|
||||
};
|
||||
}
|
||||
|
||||
const info = result.updateInfo;
|
||||
const latestVersion = info.version;
|
||||
const hasUpdate = latestVersion !== currentVersion;
|
||||
|
||||
if (hasUpdate) {
|
||||
cachedUpdateInfo = info;
|
||||
}
|
||||
|
||||
return {
|
||||
hasUpdate,
|
||||
currentVersion,
|
||||
latestVersion,
|
||||
body: typeof info.releaseNotes === "string" ? info.releaseNotes : null,
|
||||
date: typeof info.releaseDate === "string" ? info.releaseDate : null,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("[auto-updater] Failed to check for updates:", error);
|
||||
return {
|
||||
hasUpdate: false,
|
||||
currentVersion,
|
||||
latestVersion: currentVersion,
|
||||
body: null,
|
||||
date: null,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export async function downloadAndInstallUpdate(currentVersion: string): Promise<AppUpdateInstallResult> {
|
||||
if (!app.isPackaged) {
|
||||
return {
|
||||
installed: false,
|
||||
version: currentVersion,
|
||||
message: "Auto-update is not available in development mode.",
|
||||
};
|
||||
}
|
||||
|
||||
if (downloading) {
|
||||
return {
|
||||
installed: false,
|
||||
version: currentVersion,
|
||||
message: "Update already in progress.",
|
||||
};
|
||||
}
|
||||
|
||||
if (!cachedUpdateInfo) {
|
||||
return {
|
||||
installed: false,
|
||||
version: currentVersion,
|
||||
message: "No update available. Check for updates first.",
|
||||
};
|
||||
}
|
||||
|
||||
configureAutoUpdater();
|
||||
|
||||
downloading = true;
|
||||
|
||||
try {
|
||||
await autoUpdater.downloadUpdate();
|
||||
// quitAndInstall restarts the app with the new version.
|
||||
// Use a short delay to allow the renderer to receive the response.
|
||||
setTimeout(() => {
|
||||
try {
|
||||
autoUpdater.quitAndInstall(/* isSilent */ false, /* isForceRunAfter */ true);
|
||||
} catch (error) {
|
||||
console.error("[auto-updater] quitAndInstall failed:", error);
|
||||
}
|
||||
}, 1500);
|
||||
|
||||
return {
|
||||
installed: true,
|
||||
version: cachedUpdateInfo.version,
|
||||
message: "Update downloaded. The app will restart shortly.",
|
||||
};
|
||||
} catch (error) {
|
||||
downloading = false;
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
console.error("[auto-updater] Failed to download/install update:", message);
|
||||
return {
|
||||
installed: false,
|
||||
version: currentVersion,
|
||||
message: `Update failed: ${message}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { dialog, ipcMain, BrowserWindow } from "electron";
|
||||
|
||||
type AskOptions = {
|
||||
title?: string;
|
||||
okLabel?: string;
|
||||
cancelLabel?: string;
|
||||
kind?: "info" | "warning" | "error";
|
||||
};
|
||||
|
||||
type OpenOptions = {
|
||||
title?: string;
|
||||
defaultPath?: string;
|
||||
directory?: boolean;
|
||||
multiple?: boolean;
|
||||
filters?: Array<{ name: string; extensions: string[] }>;
|
||||
};
|
||||
|
||||
export function registerDialogHandlers(): void {
|
||||
ipcMain.handle("paseo:dialog:ask", async (event, message: string, options?: AskOptions) => {
|
||||
const win = BrowserWindow.fromWebContents(event.sender);
|
||||
const result = await dialog.showMessageBox(win ?? BrowserWindow.getFocusedWindow()!, {
|
||||
type: options?.kind === "warning" ? "warning" : options?.kind === "error" ? "error" : "question",
|
||||
title: options?.title ?? "Confirm",
|
||||
message,
|
||||
buttons: [options?.cancelLabel ?? "Cancel", options?.okLabel ?? "OK"],
|
||||
defaultId: 1,
|
||||
cancelId: 0,
|
||||
});
|
||||
return result.response === 1;
|
||||
});
|
||||
|
||||
ipcMain.handle("paseo:dialog:open", async (event, options?: OpenOptions) => {
|
||||
const win = BrowserWindow.fromWebContents(event.sender);
|
||||
const properties: Electron.OpenDialogOptions["properties"] = [];
|
||||
if (options?.directory) properties.push("openDirectory");
|
||||
if (options?.multiple) properties.push("multiSelections");
|
||||
if (!options?.directory) properties.push("openFile");
|
||||
|
||||
const result = await dialog.showOpenDialog(win ?? BrowserWindow.getFocusedWindow()!, {
|
||||
title: options?.title,
|
||||
defaultPath: options?.defaultPath,
|
||||
properties,
|
||||
filters: options?.filters,
|
||||
});
|
||||
|
||||
if (result.canceled) return null;
|
||||
return options?.multiple ? result.filePaths : (result.filePaths[0] ?? null);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import { app, Menu, BrowserWindow } from "electron";
|
||||
|
||||
function withBrowserWindow(
|
||||
callback: (win: BrowserWindow) => void
|
||||
): (_item: Electron.MenuItem, baseWin: Electron.BaseWindow | undefined) => void {
|
||||
return (_item, baseWin) => {
|
||||
const win = baseWin instanceof BrowserWindow ? baseWin : BrowserWindow.getFocusedWindow();
|
||||
if (win) callback(win);
|
||||
};
|
||||
}
|
||||
|
||||
export function setupApplicationMenu(): void {
|
||||
const isMac = process.platform === "darwin";
|
||||
|
||||
const template: Electron.MenuItemConstructorOptions[] = [
|
||||
...(isMac
|
||||
? [
|
||||
{
|
||||
label: app.name,
|
||||
submenu: [
|
||||
{ role: "about" as const },
|
||||
{ type: "separator" as const },
|
||||
{ role: "services" as const },
|
||||
{ type: "separator" as const },
|
||||
{ role: "hide" as const },
|
||||
{ role: "hideOthers" as const },
|
||||
{ role: "unhide" as const },
|
||||
{ type: "separator" as const },
|
||||
{ role: "quit" as const },
|
||||
],
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
label: "Edit",
|
||||
submenu: [
|
||||
{ role: "undo" },
|
||||
{ role: "redo" },
|
||||
{ type: "separator" },
|
||||
{ role: "cut" },
|
||||
{ role: "copy" },
|
||||
{ role: "paste" },
|
||||
{ role: "selectAll" },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "View",
|
||||
submenu: [
|
||||
{
|
||||
label: "Zoom In",
|
||||
accelerator: "CmdOrCtrl+=",
|
||||
click: withBrowserWindow((win) => {
|
||||
win.webContents.setZoomLevel(win.webContents.getZoomLevel() + 0.5);
|
||||
}),
|
||||
},
|
||||
{
|
||||
label: "Zoom Out",
|
||||
accelerator: "CmdOrCtrl+-",
|
||||
click: withBrowserWindow((win) => {
|
||||
win.webContents.setZoomLevel(win.webContents.getZoomLevel() - 0.5);
|
||||
}),
|
||||
},
|
||||
{
|
||||
label: "Actual Size",
|
||||
accelerator: "CmdOrCtrl+0",
|
||||
click: withBrowserWindow((win) => {
|
||||
win.webContents.setZoomLevel(0);
|
||||
}),
|
||||
},
|
||||
{ type: "separator" },
|
||||
{ role: "reload" },
|
||||
{ role: "forceReload" },
|
||||
{ role: "toggleDevTools" },
|
||||
{ type: "separator" },
|
||||
{ role: "togglefullscreen" },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "Window",
|
||||
submenu: [
|
||||
{ role: "minimize" },
|
||||
{ role: "zoom" },
|
||||
...(isMac
|
||||
? [{ type: "separator" as const }, { role: "front" as const }]
|
||||
: [{ role: "close" as const }]),
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const menu = Menu.buildFromTemplate(template);
|
||||
Menu.setApplicationMenu(menu);
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
import path from "node:path";
|
||||
import { existsSync } from "node:fs";
|
||||
import { app, BrowserWindow, Notification, ipcMain, nativeImage } from "electron";
|
||||
|
||||
type NotificationInput = {
|
||||
title?: unknown;
|
||||
body?: unknown;
|
||||
data?: unknown;
|
||||
};
|
||||
|
||||
type NotificationClickPayload = {
|
||||
data?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
const activeNotifications = new Set<Notification>();
|
||||
|
||||
function toTrimmedString(value: unknown): string | null {
|
||||
if (typeof value !== "string") {
|
||||
return null;
|
||||
}
|
||||
const trimmed = value.trim();
|
||||
return trimmed.length > 0 ? trimmed : null;
|
||||
}
|
||||
|
||||
function toRecord(value: unknown): Record<string, unknown> | undefined {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value)
|
||||
? (value as Record<string, unknown>)
|
||||
: undefined;
|
||||
}
|
||||
|
||||
function getNotificationIcon(): Electron.NativeImage | null {
|
||||
const candidates = [
|
||||
path.resolve(__dirname, "../assets/icon.png"),
|
||||
path.resolve(__dirname, "../assets/64x64.png"),
|
||||
path.resolve(__dirname, "../assets/128x128.png"),
|
||||
];
|
||||
|
||||
for (const iconPath of candidates) {
|
||||
if (!existsSync(iconPath)) {
|
||||
continue;
|
||||
}
|
||||
const icon = nativeImage.createFromPath(iconPath);
|
||||
if (!icon.isEmpty()) {
|
||||
return icon;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function focusSenderWindow(sender: Electron.WebContents): BrowserWindow | null {
|
||||
const win = BrowserWindow.fromWebContents(sender) ?? BrowserWindow.getAllWindows()[0] ?? null;
|
||||
if (!win || win.isDestroyed()) {
|
||||
return null;
|
||||
}
|
||||
win.show();
|
||||
if (win.isMinimized()) {
|
||||
win.restore();
|
||||
}
|
||||
win.focus();
|
||||
return win;
|
||||
}
|
||||
|
||||
/**
|
||||
* macOS requires a notification to have been shown at least once before
|
||||
* the app appears in System Preferences > Notifications. We fire a
|
||||
* silent no-op notification during startup to ensure registration.
|
||||
*/
|
||||
export function ensureNotificationCenterRegistration(): void {
|
||||
if (process.platform !== "darwin" || !Notification.isSupported()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const probe = new Notification({ title: app.name, silent: true });
|
||||
probe.on("show", () => probe.close());
|
||||
setTimeout(() => probe.close(), 2_000);
|
||||
probe.show();
|
||||
}
|
||||
|
||||
export function registerNotificationHandlers(): void {
|
||||
ipcMain.handle("paseo:notification:isSupported", () => {
|
||||
return Notification.isSupported();
|
||||
});
|
||||
|
||||
ipcMain.handle("paseo:notification:send", async (event, rawInput?: NotificationInput) => {
|
||||
if (!Notification.isSupported()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const title = toTrimmedString(rawInput?.title);
|
||||
if (!title) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const body = toTrimmedString(rawInput?.body) ?? undefined;
|
||||
const data = toRecord(rawInput?.data);
|
||||
const icon = getNotificationIcon();
|
||||
const notification = new Notification({
|
||||
title,
|
||||
...(body ? { body } : {}),
|
||||
...(icon ? { icon } : {}),
|
||||
silent: true,
|
||||
});
|
||||
|
||||
activeNotifications.add(notification);
|
||||
|
||||
notification.on("click", () => {
|
||||
const win = focusSenderWindow(event.sender);
|
||||
if (win && data && Object.keys(data).length > 0) {
|
||||
const payload: NotificationClickPayload = { data };
|
||||
win.webContents.send("paseo:event:notification-click", payload);
|
||||
}
|
||||
activeNotifications.delete(notification);
|
||||
});
|
||||
|
||||
notification.on("close", () => {
|
||||
activeNotifications.delete(notification);
|
||||
});
|
||||
|
||||
notification.show();
|
||||
return true;
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { shell, ipcMain } from "electron";
|
||||
|
||||
export function registerOpenerHandlers(): void {
|
||||
ipcMain.handle("paseo:opener:openUrl", async (_event, url: string) => {
|
||||
await shell.openExternal(url);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
import path from "node:path";
|
||||
import { existsSync } from "node:fs";
|
||||
import { app, BrowserWindow, nativeImage } from "electron";
|
||||
import { registerDaemonManager } from "./daemon/daemon-manager.js";
|
||||
import { parseCliPassthroughArgsFromArgv, runCliPassthroughCommand } from "./daemon/runtime-paths.js";
|
||||
import { closeAllTransportSessions } from "./daemon/local-transport.js";
|
||||
import { registerWindowManager, setupWindowResizeEvents, setupDragDropPrevention } from "./window/window-manager.js";
|
||||
import { registerDialogHandlers } from "./features/dialogs.js";
|
||||
import { registerNotificationHandlers, ensureNotificationCenterRegistration } from "./features/notifications.js";
|
||||
import { registerOpenerHandlers } from "./features/opener.js";
|
||||
import { setupApplicationMenu } from "./features/menu.js";
|
||||
|
||||
const DEV_SERVER_URL = process.env.EXPO_DEV_URL ?? "http://localhost:8081";
|
||||
app.setName("Paseo");
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Window creation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function getPreloadPath(): string {
|
||||
return path.join(__dirname, "preload.js");
|
||||
}
|
||||
|
||||
function getProductionEntryPath(): string {
|
||||
if (app.isPackaged) {
|
||||
return path.join(process.resourcesPath, "app-dist", "index.html");
|
||||
}
|
||||
|
||||
return path.resolve(__dirname, "../../app/dist/index.html");
|
||||
}
|
||||
|
||||
function getWindowIconPath(): string | null {
|
||||
const candidates = app.isPackaged
|
||||
? process.platform === "win32"
|
||||
? [path.join(process.resourcesPath, "icon.ico"), path.join(process.resourcesPath, "icon.png")]
|
||||
: [path.join(process.resourcesPath, "icon.png")]
|
||||
: process.platform === "darwin"
|
||||
? [path.resolve(__dirname, "../assets/icon.png")]
|
||||
: process.platform === "win32"
|
||||
? [path.resolve(__dirname, "../assets/icon.ico"), path.resolve(__dirname, "../assets/icon.png")]
|
||||
: [path.resolve(__dirname, "../assets/icon.png")];
|
||||
|
||||
return candidates.find((candidate) => existsSync(candidate)) ?? null;
|
||||
}
|
||||
|
||||
function applyAppIcon(): void {
|
||||
if (process.platform !== "darwin") {
|
||||
return;
|
||||
}
|
||||
|
||||
const iconPath = path.resolve(__dirname, "../assets/icon.png");
|
||||
if (!existsSync(iconPath)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const icon = nativeImage.createFromPath(iconPath);
|
||||
if (icon.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
app.dock?.setIcon(icon);
|
||||
}
|
||||
|
||||
async function createMainWindow(): Promise<void> {
|
||||
const isMac = process.platform === "darwin";
|
||||
const iconPath = getWindowIconPath();
|
||||
|
||||
const mainWindow = new BrowserWindow({
|
||||
width: 1200,
|
||||
height: 800,
|
||||
show: false,
|
||||
...(iconPath ? { icon: iconPath } : {}),
|
||||
titleBarStyle: isMac ? "hidden" : "default",
|
||||
trafficLightPosition: isMac ? { x: 16, y: 20 } : undefined,
|
||||
webPreferences: {
|
||||
preload: getPreloadPath(),
|
||||
contextIsolation: true,
|
||||
nodeIntegration: false,
|
||||
},
|
||||
});
|
||||
|
||||
setupWindowResizeEvents(mainWindow);
|
||||
setupDragDropPrevention(mainWindow);
|
||||
|
||||
mainWindow.once("ready-to-show", () => {
|
||||
mainWindow.show();
|
||||
});
|
||||
|
||||
if (!app.isPackaged) {
|
||||
await mainWindow.loadURL(DEV_SERVER_URL);
|
||||
mainWindow.webContents.openDevTools({ mode: "detach" });
|
||||
return;
|
||||
}
|
||||
|
||||
await mainWindow.loadFile(getProductionEntryPath());
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// App lifecycle
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function setupSingleInstanceLock(): boolean {
|
||||
const gotLock = app.requestSingleInstanceLock();
|
||||
if (!gotLock) {
|
||||
app.quit();
|
||||
return false;
|
||||
}
|
||||
|
||||
app.on("second-instance", () => {
|
||||
const win = BrowserWindow.getAllWindows()[0];
|
||||
if (win) {
|
||||
win.show();
|
||||
if (win.isMinimized()) win.restore();
|
||||
win.focus();
|
||||
}
|
||||
});
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
async function runCliPassthroughIfRequested(): Promise<boolean> {
|
||||
const cliArgs = parseCliPassthroughArgsFromArgv(process.argv);
|
||||
if (!cliArgs) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
const exitCode = runCliPassthroughCommand(cliArgs);
|
||||
process.exit(exitCode);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.stack ?? error.message : String(error);
|
||||
process.stderr.write(`${message}\n`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
async function bootstrap(): Promise<void> {
|
||||
if (await runCliPassthroughIfRequested()) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!setupSingleInstanceLock()) {
|
||||
return;
|
||||
}
|
||||
|
||||
await app.whenReady();
|
||||
applyAppIcon();
|
||||
setupApplicationMenu();
|
||||
ensureNotificationCenterRegistration();
|
||||
registerDaemonManager();
|
||||
registerWindowManager();
|
||||
registerDialogHandlers();
|
||||
registerNotificationHandlers();
|
||||
registerOpenerHandlers();
|
||||
await createMainWindow();
|
||||
|
||||
app.on("activate", async () => {
|
||||
if (BrowserWindow.getAllWindows().length === 0) {
|
||||
await createMainWindow();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void bootstrap().catch((error) => {
|
||||
const message = error instanceof Error ? error.stack ?? error.message : String(error);
|
||||
process.stderr.write(`${message}\n`);
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
app.on("before-quit", () => {
|
||||
closeAllTransportSessions();
|
||||
});
|
||||
|
||||
app.on("window-all-closed", () => {
|
||||
if (process.platform !== "darwin") {
|
||||
app.quit();
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,51 @@
|
||||
import { contextBridge, ipcRenderer } from "electron";
|
||||
|
||||
type EventHandler = (payload: unknown) => void;
|
||||
|
||||
contextBridge.exposeInMainWorld("paseoDesktop", {
|
||||
platform: process.platform,
|
||||
invoke: (command: string, args?: Record<string, unknown>) =>
|
||||
ipcRenderer.invoke("paseo:invoke", command, args),
|
||||
events: {
|
||||
on: (event: string, handler: EventHandler): Promise<() => void> => {
|
||||
const listener = (_ipcEvent: Electron.IpcRendererEvent, payload: unknown) => {
|
||||
handler(payload);
|
||||
};
|
||||
ipcRenderer.on(`paseo:event:${event}`, listener);
|
||||
return Promise.resolve(() => {
|
||||
ipcRenderer.removeListener(`paseo:event:${event}`, listener);
|
||||
});
|
||||
},
|
||||
},
|
||||
window: {
|
||||
getCurrentWindow: () => ({
|
||||
startDragging: () => ipcRenderer.invoke("paseo:window:startDragging"),
|
||||
toggleMaximize: () => ipcRenderer.invoke("paseo:window:toggleMaximize"),
|
||||
isFullscreen: () => ipcRenderer.invoke("paseo:window:isFullscreen"),
|
||||
onResized: (handler: EventHandler): (() => void) => {
|
||||
const listener = (_ipcEvent: Electron.IpcRendererEvent, payload: unknown) => {
|
||||
handler(payload);
|
||||
};
|
||||
ipcRenderer.on("paseo:window:resized", listener);
|
||||
return () => {
|
||||
ipcRenderer.removeListener("paseo:window:resized", listener);
|
||||
};
|
||||
},
|
||||
setBadgeCount: (count?: number) => ipcRenderer.invoke("paseo:window:setBadgeCount", count),
|
||||
}),
|
||||
},
|
||||
dialog: {
|
||||
ask: (message: string, options?: Record<string, unknown>) =>
|
||||
ipcRenderer.invoke("paseo:dialog:ask", message, options),
|
||||
open: (options?: Record<string, unknown>) =>
|
||||
ipcRenderer.invoke("paseo:dialog:open", options),
|
||||
},
|
||||
notification: {
|
||||
isSupported: () => ipcRenderer.invoke("paseo:notification:isSupported"),
|
||||
sendNotification: (payload: { title: string; body?: string; data?: Record<string, unknown> }) =>
|
||||
ipcRenderer.invoke("paseo:notification:send", payload),
|
||||
},
|
||||
opener: {
|
||||
openUrl: (url: string) => ipcRenderer.invoke("paseo:opener:openUrl", url),
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,61 @@
|
||||
import { app, BrowserWindow, ipcMain } from "electron";
|
||||
|
||||
export function registerWindowManager(): void {
|
||||
ipcMain.handle("paseo:window:startDragging", (event) => {
|
||||
const win = BrowserWindow.fromWebContents(event.sender);
|
||||
// Desktop dragging is handled via CSS `-webkit-app-region: drag`.
|
||||
// This handler exists only to satisfy the renderer bridge contract.
|
||||
if (win) {
|
||||
// No-op.
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle("paseo:window:toggleMaximize", (event) => {
|
||||
const win = BrowserWindow.fromWebContents(event.sender);
|
||||
if (!win) return;
|
||||
if (win.isMaximized()) {
|
||||
win.unmaximize();
|
||||
} else {
|
||||
win.maximize();
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle("paseo:window:isFullscreen", (event) => {
|
||||
const win = BrowserWindow.fromWebContents(event.sender);
|
||||
return win?.isFullScreen() ?? false;
|
||||
});
|
||||
|
||||
ipcMain.handle("paseo:window:setBadgeCount", (_event, count?: number) => {
|
||||
if (process.platform === "darwin" || process.platform === "linux") {
|
||||
app.setBadgeCount(count ?? 0);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export function setupWindowResizeEvents(win: BrowserWindow): void {
|
||||
win.on("resize", () => {
|
||||
win.webContents.send("paseo:window:resized", {});
|
||||
});
|
||||
|
||||
win.on("enter-full-screen", () => {
|
||||
win.webContents.send("paseo:window:resized", {});
|
||||
});
|
||||
|
||||
win.on("leave-full-screen", () => {
|
||||
win.webContents.send("paseo:window:resized", {});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Prevent Electron from navigating to files dragged onto the window.
|
||||
* The renderer handles drag-drop via standard HTML5 APIs instead.
|
||||
*/
|
||||
export function setupDragDropPrevention(win: BrowserWindow): void {
|
||||
win.webContents.on("will-navigate", (event, url) => {
|
||||
// Allow normal navigation (e.g. dev server hot-reload) but block file:// URLs
|
||||
// that result from dropping files onto the window.
|
||||
if (url.startsWith("file://")) {
|
||||
event.preventDefault();
|
||||
}
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user