mirror of
https://github.com/getpaseo/paseo.git
synced 2026-08-15 04:42:45 +00:00
chore: finalize electron desktop migration
This commit is contained in:
162
packages/desktop/src/features/attachments.ts
Normal file
162
packages/desktop/src/features/attachments.ts
Normal 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;
|
||||
}
|
||||
155
packages/desktop/src/features/auto-updater.ts
Normal file
155
packages/desktop/src/features/auto-updater.ts
Normal 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}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
49
packages/desktop/src/features/dialogs.ts
Normal file
49
packages/desktop/src/features/dialogs.ts
Normal 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);
|
||||
});
|
||||
}
|
||||
92
packages/desktop/src/features/menu.ts
Normal file
92
packages/desktop/src/features/menu.ts
Normal 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);
|
||||
}
|
||||
123
packages/desktop/src/features/notifications.ts
Normal file
123
packages/desktop/src/features/notifications.ts
Normal 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;
|
||||
});
|
||||
}
|
||||
7
packages/desktop/src/features/opener.ts
Normal file
7
packages/desktop/src/features/opener.ts
Normal 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);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user