Theo Browneprod builds48bf1238 months agoHistory
554 lines · 14.2 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,  type MenuItemConstructorOptions,  protocol,  shell,} from "electron"; import { showDesktopConfirmDialog } from "./confirmDialog";import { fixPath } from "./fixPath"; fixPath(); const PICK_FOLDER_CHANNEL = "desktop:pick-folder";const CONFIRM_CHANNEL = "desktop:confirm";const CONTEXT_MENU_CHANNEL = "desktop:context-menu";const OPEN_EXTERNAL_CHANNEL = "desktop:open-external";const MENU_ACTION_CHANNEL = "desktop:menu-action";const STATE_DIR = path.join(os.homedir(), ".t3", "userdata");const DESKTOP_SCHEME = "t3";const ROOT_DIR = path.resolve(__dirname, "../../..");const isDevelopment = Boolean(process.env.VITE_DEV_SERVER_URL);const APP_DISPLAY_NAME = isDevelopment ? "T3 Code (Dev)" : "T3 Code (Alpha)";const APP_USER_MODEL_ID = "com.t3tools.t3code"; 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;let desktopProtocolRegistered = false; protocol.registerSchemesAsPrivileged([  {    scheme: DESKTOP_SCHEME,    privileges: {      standard: true,      secure: true,      supportFetchAPI: true,      corsEnabled: true,    },  },]); function resolveAppRoot(): string {  if (!app.isPackaged) {    return ROOT_DIR;  }  return app.getAppPath();} function resolveBackendEntry(): string {  return path.join(resolveAppRoot(), "apps/server/dist/index.mjs");} function resolveBackendCwd(): string {  if (!app.isPackaged) {    return resolveAppRoot();  }  return os.homedir();} function resolveDesktopStaticDir(): string | null {  const appRoot = resolveAppRoot();  const candidates = [    path.join(appRoot, "apps/server/dist/client"),    path.join(appRoot, "apps/web/dist"),  ];   for (const candidate of candidates) {    if (fs.existsSync(path.join(candidate, "index.html"))) {      return candidate;    }  }   return null;} function resolveDesktopStaticPath(staticRoot: string, requestUrl: string): string {  const url = new URL(requestUrl);  const rawPath = decodeURIComponent(url.pathname);  const normalizedPath = path.posix.normalize(rawPath).replace(/^\/+/, "");  if (normalizedPath.includes("..")) {    return path.join(staticRoot, "index.html");  }   const requestedPath = normalizedPath.length > 0 ? normalizedPath : "index.html";  const resolvedPath = path.join(staticRoot, requestedPath);   if (path.extname(resolvedPath)) {    return resolvedPath;  }   const nestedIndex = path.join(resolvedPath, "index.html");  if (fs.existsSync(nestedIndex)) {    return nestedIndex;  }   return path.join(staticRoot, "index.html");} function isStaticAssetRequest(requestUrl: string): boolean {  try {    const url = new URL(requestUrl);    return path.extname(url.pathname).length > 0;  } catch {    return false;  }} function registerDesktopProtocol(): void {  if (isDevelopment || desktopProtocolRegistered) return;   const staticRoot = resolveDesktopStaticDir();  if (!staticRoot) {    throw new Error("Desktop static bundle missing. Build apps/server (with bundled client) first.");  }   const staticRootResolved = path.resolve(staticRoot);  const staticRootPrefix = `${staticRootResolved}${path.sep}`;  const fallbackIndex = path.join(staticRootResolved, "index.html");   protocol.registerFileProtocol(DESKTOP_SCHEME, (request, callback) => {    try {      const candidate = resolveDesktopStaticPath(staticRootResolved, request.url);      const resolvedCandidate = path.resolve(candidate);      const isInRoot =        resolvedCandidate === fallbackIndex || resolvedCandidate.startsWith(staticRootPrefix);      const isAssetRequest = isStaticAssetRequest(request.url);       if (!isInRoot || !fs.existsSync(resolvedCandidate)) {        if (isAssetRequest) {          callback({ error: -6 });          return;        }        callback({ path: fallbackIndex });        return;      }       callback({ path: resolvedCandidate });    } catch {      callback({ path: fallbackIndex });    }  });   desktopProtocolRegistered = true;} function dispatchMenuAction(action: string): void {  const existingWindow = BrowserWindow.getFocusedWindow() ?? mainWindow ?? BrowserWindow.getAllWindows()[0];  const targetWindow = existingWindow ?? createWindow();  if (!existingWindow) {    mainWindow = targetWindow;  }   const send = () => {    if (targetWindow.isDestroyed()) return;    targetWindow.webContents.send(MENU_ACTION_CHANNEL, action);    if (!targetWindow.isVisible()) {      targetWindow.show();    }    targetWindow.focus();  };   if (targetWindow.webContents.isLoadingMainFrame()) {    targetWindow.webContents.once("did-finish-load", send);    return;  }   send();} function configureApplicationMenu(): void {  const template: MenuItemConstructorOptions[] = [];   if (process.platform === "darwin") {    template.push({      label: app.name,      submenu: [        { role: "about" },        {          label: "Settings...",          accelerator: "CmdOrCtrl+,",          click: () => dispatchMenuAction("open-settings"),        },        { type: "separator" },        { role: "services" },        { type: "separator" },        { role: "hide" },        { role: "hideOthers" },        { role: "unhide" },        { type: "separator" },        { role: "quit" },      ],    });  }   template.push(    {      label: "File",      submenu: [        ...(process.platform === "darwin"          ? []          : [              {                label: "Settings...",                accelerator: "CmdOrCtrl+,",                click: () => dispatchMenuAction("open-settings"),              },              { type: "separator" as const },            ]),        { role: process.platform === "darwin" ? "close" : "quit" },      ],    },    { role: "editMenu" },    { role: "viewMenu" },    { role: "windowMenu" },  );   Menu.setApplicationMenu(Menu.buildFromTemplate(template));} function resolveResourcePath(fileName: string): string | null {  const candidates = [    path.join(__dirname, "../resources", fileName),    path.join(process.resourcesPath, "resources", fileName),    path.join(process.resourcesPath, fileName),  ];   for (const candidate of candidates) {    if (fs.existsSync(candidate)) {      return candidate;    }  }   return null;} function resolveIconPath(ext: "ico" | "icns" | "png"): string | null {  return resolveResourcePath(`icon.${ext}`);} function configureAppIdentity(): void {  app.setName(APP_DISPLAY_NAME);  app.setAboutPanelOptions({    applicationName: APP_DISPLAY_NAME,  });   if (process.platform === "win32") {    app.setAppUserModelId(APP_USER_MODEL_ID);  }   if (process.platform === "darwin" && app.dock) {    const iconPath = resolveIconPath("png");    if (iconPath) {      app.dock.setIcon(iconPath);    }  }} 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;   const backendEntry = resolveBackendEntry();  if (!fs.existsSync(backendEntry)) {    scheduleBackendRestart(`missing server entry at ${backendEntry}`);    return;  }   const child = spawn(process.execPath, [backendEntry], {    cwd: resolveBackendCwd(),    // In Electron main, process.execPath points to the Electron binary.    // Run the child in Node mode so this backend process does not become a GUI app instance.    env: {      ...backendEnv(),      ELECTRON_RUN_AS_NODE: "1",    },    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.exitCode === null && child.signalCode === null) {    child.kill("SIGTERM");    setTimeout(() => {      if (child.exitCode === null && child.signalCode === null) {        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(CONFIRM_CHANNEL);  ipcMain.handle(CONFIRM_CHANNEL, async (_event, message: unknown) => {    if (typeof message !== "string") {      return false;    }     const owner = BrowserWindow.getFocusedWindow() ?? mainWindow;    return showDesktopConfirmDialog(message, owner);  });   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 getIconOption(): { icon: string } | Record<string, never> {  if (process.platform === "darwin") return {}; // macOS uses .icns from app bundle  const ext = process.platform === "win32" ? "ico" : "png";  const iconPath = resolveIconPath(ext);  return iconPath ? { icon: iconPath } : {};} function createWindow(): BrowserWindow {  const window = new BrowserWindow({    width: 1100,    height: 780,    minWidth: 840,    minHeight: 620,    show: false,    autoHideMenuBar: true,    ...getIconOption(),    title: APP_DISPLAY_NAME,    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.on("page-title-updated", (event) => {    event.preventDefault();    window.setTitle(APP_DISPLAY_NAME);  });  window.webContents.on("did-finish-load", () => {    window.setTitle(APP_DISPLAY_NAME);  });  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 {    void window.loadURL(`${DESKTOP_SCHEME}://app/index.html`);  }   window.on("closed", () => {    if (mainWindow === window) {      mainWindow = null;    }  });   return window;

Lines 1–500 of 554.