672 lines · 17.8 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, nativeImage, protocol, shell } from "electron";import type { MenuItemConstructorOptions } from "electron"; import type { ContextMenuItem } from "@t3tools/contracts";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 =  process.env.T3CODE_STATE_DIR?.trim() || 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";const COMMIT_HASH_PATTERN = /^[0-9a-f]{7,40}$/i;const COMMIT_HASH_DISPLAY_LENGTH = 12; 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;let aboutCommitHashCache: string | null | undefined; let destructiveMenuIconCache: Electron.NativeImage | null | undefined; function getDestructiveMenuIcon(): Electron.NativeImage | undefined {  if (process.platform !== "darwin") return undefined;  if (destructiveMenuIconCache !== undefined) {    return destructiveMenuIconCache ?? undefined;  }  try {    const icon = nativeImage.createFromNamedImage("trash").resize({      width: 14,      height: 14,    });    if (icon.isEmpty()) {      destructiveMenuIconCache = null;      return undefined;    }    icon.setTemplateImage(true);    destructiveMenuIconCache = icon;    return icon;  } catch {    destructiveMenuIconCache = null;    return undefined;  }} 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 normalizeCommitHash(value: unknown): string | null {  if (typeof value !== "string") {    return null;  }  const trimmed = value.trim();  if (!COMMIT_HASH_PATTERN.test(trimmed)) {    return null;  }  return trimmed.slice(0, COMMIT_HASH_DISPLAY_LENGTH).toLowerCase();} function resolveEmbeddedCommitHash(): string | null {  const packageJsonPath = path.join(resolveAppRoot(), "package.json");  if (!fs.existsSync(packageJsonPath)) {    return null;  }   try {    const raw = fs.readFileSync(packageJsonPath, "utf8");    const parsed = JSON.parse(raw) as { t3codeCommitHash?: unknown };    return normalizeCommitHash(parsed.t3codeCommitHash);  } catch {    return null;  }} function resolveAboutCommitHash(): string | null {  if (aboutCommitHashCache !== undefined) {    return aboutCommitHashCache;  }   const envCommitHash = normalizeCommitHash(process.env.T3CODE_COMMIT_HASH);  if (envCommitHash) {    aboutCommitHashCache = envCommitHash;    return aboutCommitHashCache;  }   // Only packaged builds are required to expose commit metadata.  if (!app.isPackaged) {    aboutCommitHashCache = null;    return aboutCommitHashCache;  }   aboutCommitHashCache = resolveEmbeddedCommitHash();   return aboutCommitHashCache;} 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);  const commitHash = resolveAboutCommitHash();  app.setAboutPanelOptions({    applicationName: APP_DISPLAY_NAME,    applicationVersion: app.getVersion(),    version: commitHash ?? "unknown",  });   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: ContextMenuItem[], position?: { x: number; y: number }) => {      const normalizedItems = items        .filter((item) => typeof item.id === "string" && typeof item.label === "string")        .map((item) => ({          id: item.id,          label: item.label,          destructive: item.destructive === true,        }));      if (normalizedItems.length === 0) {        return null;      }       const popupPosition =        position &&        Number.isFinite(position.x) &&        Number.isFinite(position.y) &&        position.x >= 0 &&        position.y >= 0          ? {

Lines 1–500 of 672.

Keyboard shortcuts

General

Show keyboard shortcuts
?
Focus the current search
/

Issues and pull requests

Open filters
f

Issues, pull requests and Inbox

Select next item
j↓
Select previous item
k↑
Open selected item
Enter
Dismiss filters or clear selection
Esc

Repository code

Go to file
t