262 lines · 6.7 KB · TypeScript
RawDownload
import { spawn, type ChildProcess } from "node:child_process";import { randomBytes } from "node:crypto";import fs from "node:fs";import net from "node:net";import os from "node:os";import path from "node:path"; import { app, BrowserWindow, dialog, ipcMain, Menu, shell } from "electron"; import { fixPath } from "./fixPath"; fixPath(); const PICK_FOLDER_CHANNEL = "desktop:pick-folder";const CONTEXT_MENU_CHANNEL = "desktop:context-menu";const OPEN_EXTERNAL_CHANNEL = "desktop:open-external";const ROOT_DIR = path.resolve(__dirname, "../../..");const BACKEND_ENTRY = path.join(ROOT_DIR, "apps/server/dist/index.mjs");const WEB_ENTRY = path.join(ROOT_DIR, "apps/web/dist/index.html");const STATE_DIR = path.join(os.homedir(), ".t3", "userdata");const isDevelopment = Boolean(process.env.VITE_DEV_SERVER_URL); let mainWindow: BrowserWindow | null = null;let backendProcess: ChildProcess | null = null;let backendPort = 0;let backendAuthToken = "";let backendWsUrl = "";let restartAttempt = 0;let restartTimer: ReturnType<typeof setTimeout> | null = null;let isQuitting = false; async function reserveLoopbackPort(): Promise<number> {  return new Promise((resolve, reject) => {    const probe = net.createServer();    probe.listen(0, "127.0.0.1", () => {      const address = probe.address();      const port = typeof address === "object" && address !== null ? address.port : 0;      probe.close(() => {        if (port > 0) {          resolve(port);          return;        }        reject(new Error("Failed to reserve backend port"));      });    });    probe.on("error", reject);  });} function backendEnv(): NodeJS.ProcessEnv {  return {    ...process.env,    T3CODE_MODE: "desktop",    T3CODE_NO_BROWSER: "1",    T3CODE_PORT: String(backendPort),    T3CODE_STATE_DIR: STATE_DIR,    T3CODE_AUTH_TOKEN: backendAuthToken,  };} function scheduleBackendRestart(reason: string): void {  if (isQuitting || restartTimer) return;   const delayMs = Math.min(500 * 2 ** restartAttempt, 10_000);  restartAttempt += 1;  console.error(`[desktop] backend exited unexpectedly (${reason}); restarting in ${delayMs}ms`);   restartTimer = setTimeout(() => {    restartTimer = null;    startBackend();  }, delayMs);} function startBackend(): void {  if (isQuitting || backendProcess) return;   if (!fs.existsSync(BACKEND_ENTRY)) {    scheduleBackendRestart(`missing server entry at ${BACKEND_ENTRY}`);    return;  }   const child = spawn(process.execPath, [BACKEND_ENTRY], {    cwd: ROOT_DIR,    env: backendEnv(),    stdio: "inherit",  });  backendProcess = child;   child.once("spawn", () => {    restartAttempt = 0;  });   child.on("error", (error) => {    if (backendProcess === child) {      backendProcess = null;    }    scheduleBackendRestart(error.message);  });   child.on("exit", (code, signal) => {    if (backendProcess === child) {      backendProcess = null;    }    if (isQuitting) return;    const reason = `code=${code ?? "null"} signal=${signal ?? "null"}`;    scheduleBackendRestart(reason);  });} function stopBackend(): void {  if (restartTimer) {    clearTimeout(restartTimer);    restartTimer = null;  }   const child = backendProcess;  backendProcess = null;  if (!child) return;   if (!child.killed) {    child.kill("SIGTERM");    setTimeout(() => {      if (!child.killed) {        child.kill("SIGKILL");      }    }, 2_000).unref();  }} function registerIpcHandlers(): void {  ipcMain.removeHandler(PICK_FOLDER_CHANNEL);  ipcMain.handle(PICK_FOLDER_CHANNEL, async () => {    const owner = BrowserWindow.getFocusedWindow() ?? mainWindow;    const result = owner      ? await dialog.showOpenDialog(owner, {          properties: ["openDirectory", "createDirectory"],        })      : await dialog.showOpenDialog({          properties: ["openDirectory", "createDirectory"],        });    if (result.canceled) return null;    return result.filePaths[0] ?? null;  });   ipcMain.removeHandler(CONTEXT_MENU_CHANNEL);  ipcMain.handle(CONTEXT_MENU_CHANNEL, async (_event, items: { id: string; label: string }[]) => {    const window = BrowserWindow.getFocusedWindow() ?? mainWindow;    if (!window) return null;     return new Promise<string | null>((resolve) => {      const menu = Menu.buildFromTemplate(        items.map((item) => ({          label: item.label,          click: () => resolve(item.id),        })),      );      menu.popup({        window,        callback: () => resolve(null),      });    });  });   ipcMain.removeHandler(OPEN_EXTERNAL_CHANNEL);  ipcMain.handle(OPEN_EXTERNAL_CHANNEL, async (_event, rawUrl: unknown) => {    if (typeof rawUrl !== "string" || rawUrl.length === 0) {      return false;    }     let parsedUrl: URL;    try {      parsedUrl = new URL(rawUrl);    } catch {      return false;    }     if (parsedUrl.protocol !== "https:" && parsedUrl.protocol !== "http:") {      return false;    }     try {      await shell.openExternal(parsedUrl.toString());      return true;    } catch {      return false;    }  });} function createWindow(): BrowserWindow {  const window = new BrowserWindow({    width: 1100,    height: 780,    minWidth: 840,    minHeight: 620,    show: false,    autoHideMenuBar: true,    titleBarStyle: "hiddenInset",    trafficLightPosition: { x: 16, y: 18 },    webPreferences: {      preload: path.join(__dirname, "preload.js"),      contextIsolation: true,      nodeIntegration: false,      sandbox: true,    },  });   window.webContents.setWindowOpenHandler(() => ({ action: "deny" }));  window.once("ready-to-show", () => {    window.show();  });   if (isDevelopment) {    void window.loadURL(process.env.VITE_DEV_SERVER_URL as string);    window.webContents.openDevTools({ mode: "detach" });  } else {    if (!fs.existsSync(WEB_ENTRY)) {      throw new Error(`Web bundle missing at ${WEB_ENTRY}`);    }    void window.loadFile(WEB_ENTRY);  }   window.on("closed", () => {    if (mainWindow === window) {      mainWindow = null;    }  });   return window;} async function bootstrap(): Promise<void> {  backendPort = await reserveLoopbackPort();  backendAuthToken = randomBytes(24).toString("hex");  backendWsUrl = `ws://127.0.0.1:${backendPort}/?token=${encodeURIComponent(backendAuthToken)}`;  process.env.T3CODE_DESKTOP_WS_URL = backendWsUrl;   registerIpcHandlers();  startBackend();  mainWindow = createWindow();} app.on("before-quit", () => {  isQuitting = true;  stopBackend();}); app.whenReady().then(() => {  void bootstrap();   app.on("activate", () => {    if (BrowserWindow.getAllWindows().length === 0) {      mainWindow = createWindow();    }  });}); app.on("window-all-closed", () => {  if (process.platform !== "darwin") {    app.quit();  }});