chore: finalize electron desktop migration

This commit is contained in:
Mohamed Boudra
2026-03-21 01:50:00 +07:00
parent 77fb74c188
commit 111576e2ca
169 changed files with 6463 additions and 13191 deletions

View File

@@ -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;
}

View File

@@ -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}`,
};
}
}

View File

@@ -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);
});
}

View File

@@ -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);
}

View File

@@ -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;
});
}

View File

@@ -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);
});
}