239 lines
6.4 KiB
TypeScript
239 lines
6.4 KiB
TypeScript
import { app, BrowserWindow, Menu, nativeImage, ipcMain } from "electron";
|
|
import { join } from "path";
|
|
import { existsSync } from "fs";
|
|
import { setupTray } from "./tray";
|
|
import { setupIPC } from "./ipc";
|
|
import { setupUpdater } from "./updater";
|
|
import { checkSingleInstance, getWebUrl, getApiPath, isDev } from "./utils";
|
|
|
|
// ─── Constants ───────────────────────────────────
|
|
const WEB_PORT = 3000;
|
|
const API_PORT = 3001;
|
|
const APP_NAME = "oaflow";
|
|
|
|
// ─── Single Instance Lock ────────────────────────
|
|
if (!checkSingleInstance()) {
|
|
app.quit();
|
|
process.exit(0);
|
|
}
|
|
|
|
// ─── State ───────────────────────────────────────
|
|
let mainWindow: BrowserWindow | null = null;
|
|
let apiProcess: ReturnType<typeof import("child_process").spawn> | null = null;
|
|
|
|
// ─── Create Window ───────────────────────────────
|
|
function createWindow(): BrowserWindow {
|
|
const preloadPath = join(__dirname, "preload.js");
|
|
|
|
const win = new BrowserWindow({
|
|
title: APP_NAME,
|
|
width: 1400,
|
|
height: 900,
|
|
minWidth: 800,
|
|
minHeight: 600,
|
|
show: false,
|
|
icon: getIconPath(),
|
|
webPreferences: {
|
|
preload: preloadPath,
|
|
contextIsolation: true,
|
|
nodeIntegration: false,
|
|
sandbox: false,
|
|
},
|
|
titleBarStyle: process.platform === "darwin" ? "hiddenInset" : "default",
|
|
trafficLightPosition: { x: 16, y: 16 },
|
|
});
|
|
|
|
// ── Load content ──
|
|
const url = getWebUrl(isDev(), WEB_PORT);
|
|
if (url.startsWith("http")) {
|
|
win.loadURL(url);
|
|
} else {
|
|
win.loadFile(url);
|
|
}
|
|
|
|
// ── Show when ready ──
|
|
win.once("ready-to-show", () => {
|
|
win.show();
|
|
if (isDev()) {
|
|
win.webContents.openDevTools({ mode: "detach" });
|
|
}
|
|
});
|
|
|
|
// ── External links → default browser ──
|
|
win.webContents.setWindowOpenHandler(({ url }) => {
|
|
if (url.startsWith("http")) {
|
|
require("electron").shell.openExternal(url);
|
|
}
|
|
return { action: "deny" };
|
|
});
|
|
|
|
// ── Prevent navigation away ──
|
|
win.webContents.on("will-navigate", (event, url) => {
|
|
const allowed = isDev()
|
|
? url.startsWith("http://localhost:3000")
|
|
: url.startsWith("file://");
|
|
if (!allowed) event.preventDefault();
|
|
});
|
|
|
|
return win;
|
|
}
|
|
|
|
// ─── Icon Path ───────────────────────────────────
|
|
function getIconPath(): string {
|
|
const candidates = [
|
|
join(__dirname, "..", "assets", "icon.png"),
|
|
join(__dirname, "..", "assets", "icon.icns"),
|
|
join(__dirname, "..", "assets", "icon.ico"),
|
|
];
|
|
for (const p of candidates) {
|
|
if (existsSync(p)) return p;
|
|
}
|
|
return "";
|
|
}
|
|
|
|
// ─── API Server (Production) ─────────────────────
|
|
function startApiServer(): void {
|
|
if (isDev()) return;
|
|
|
|
const apiPath = getApiPath();
|
|
if (!apiPath || !existsSync(apiPath)) {
|
|
console.warn("[main] API server not found at", apiPath);
|
|
return;
|
|
}
|
|
|
|
const { spawn } = require("child_process");
|
|
apiProcess = spawn("node", [apiPath], {
|
|
env: {
|
|
...process.env,
|
|
NODE_ENV: "production",
|
|
PORT: String(API_PORT),
|
|
HOST: "127.0.0.1",
|
|
},
|
|
stdio: ["ignore", "pipe", "pipe"],
|
|
});
|
|
|
|
apiProcess!.stdout?.on("data", (data: Buffer) => {
|
|
console.log("[api]", data.toString().trim());
|
|
});
|
|
apiProcess!.stderr?.on("data", (data: Buffer) => {
|
|
console.error("[api]", data.toString().trim());
|
|
});
|
|
apiProcess!.on("exit", (code: number) => {
|
|
console.warn("[main] API server exited with code", code);
|
|
apiProcess = null;
|
|
});
|
|
}
|
|
|
|
function stopApiServer(): void {
|
|
if (apiProcess) {
|
|
apiProcess.kill("SIGTERM");
|
|
apiProcess = null;
|
|
}
|
|
}
|
|
|
|
// ─── Menu ────────────────────────────────────────
|
|
function buildMenu(): 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: [
|
|
{ role: "reload" },
|
|
{ role: "forceReload" },
|
|
{ role: "toggleDevTools" },
|
|
{ type: "separator" },
|
|
{ role: "resetZoom" },
|
|
{ role: "zoomIn" },
|
|
{ role: "zoomOut" },
|
|
{ type: "separator" },
|
|
{ role: "togglefullscreen" },
|
|
],
|
|
},
|
|
{
|
|
label: "Window",
|
|
submenu: [
|
|
{ role: "minimize" },
|
|
{ role: "zoom" },
|
|
...(isMac
|
|
? [
|
|
{ type: "separator" as const },
|
|
{ role: "front" as const },
|
|
]
|
|
: [{ role: "close" as const }]),
|
|
],
|
|
},
|
|
];
|
|
|
|
Menu.setApplicationMenu(Menu.buildFromTemplate(template));
|
|
}
|
|
|
|
// ─── App Lifecycle ───────────────────────────────
|
|
app.whenReady().then(() => {
|
|
startApiServer();
|
|
mainWindow = createWindow();
|
|
buildMenu();
|
|
if (mainWindow) {
|
|
setupTray(mainWindow, APP_NAME);
|
|
setupIPC(mainWindow);
|
|
}
|
|
setupUpdater(APP_NAME);
|
|
|
|
app.on("activate", () => {
|
|
if (BrowserWindow.getAllWindows().length === 0) {
|
|
mainWindow = createWindow();
|
|
if (mainWindow) {
|
|
setupTray(mainWindow, APP_NAME);
|
|
setupIPC(mainWindow);
|
|
}
|
|
}
|
|
});
|
|
});
|
|
|
|
app.on("window-all-closed", () => {
|
|
stopApiServer();
|
|
if (process.platform !== "darwin") {
|
|
app.quit();
|
|
}
|
|
});
|
|
|
|
app.on("before-quit", () => {
|
|
stopApiServer();
|
|
});
|
|
|
|
app.on("gpu-process-crashed" as any, () => {
|
|
console.warn("[main] GPU process crashed — continuing");
|
|
});
|
|
|
|
process.on("exit", () => {
|
|
stopApiServer();
|
|
});
|