1476 lines · 44.8 KB · TypeScript
RawDownload
import * as ChildProcess from "node:child_process";import * as Crypto from "node:crypto";import * as FS from "node:fs";import * as OS from "node:os";import * as Path from "node:path"; import {  app,  BrowserWindow,  dialog,  ipcMain,  Menu,  nativeImage,  nativeTheme,  protocol,  shell,} from "electron";import type { MenuItemConstructorOptions } from "electron";import * as Effect from "effect/Effect";import type {  DesktopTheme,  DesktopUpdateActionResult,  DesktopUpdateCheckResult,  DesktopUpdateState,} from "@t3tools/contracts";import { autoUpdater } from "electron-updater"; import type { ContextMenuItem } from "@t3tools/contracts";import { NetService } from "@t3tools/shared/Net";import { RotatingFileSink } from "@t3tools/shared/logging";import { showDesktopConfirmDialog } from "./confirmDialog";import { syncShellEnvironment } from "./syncShellEnvironment";import { getAutoUpdateDisabledReason, shouldBroadcastDownloadProgress } from "./updateState";import {  createInitialDesktopUpdateState,  reduceDesktopUpdateStateOnCheckFailure,  reduceDesktopUpdateStateOnCheckStart,  reduceDesktopUpdateStateOnDownloadComplete,  reduceDesktopUpdateStateOnDownloadFailure,  reduceDesktopUpdateStateOnDownloadProgress,  reduceDesktopUpdateStateOnDownloadStart,  reduceDesktopUpdateStateOnInstallFailure,  reduceDesktopUpdateStateOnNoUpdate,  reduceDesktopUpdateStateOnUpdateAvailable,} from "./updateMachine";import { isArm64HostRunningIntelBuild, resolveDesktopRuntimeInfo } from "./runtimeArch"; syncShellEnvironment(); const PICK_FOLDER_CHANNEL = "desktop:pick-folder";const CONFIRM_CHANNEL = "desktop:confirm";const SET_THEME_CHANNEL = "desktop:set-theme";const CONTEXT_MENU_CHANNEL = "desktop:context-menu";const OPEN_EXTERNAL_CHANNEL = "desktop:open-external";const MENU_ACTION_CHANNEL = "desktop:menu-action";const UPDATE_STATE_CHANNEL = "desktop:update-state";const UPDATE_GET_STATE_CHANNEL = "desktop:update-get-state";const UPDATE_DOWNLOAD_CHANNEL = "desktop:update-download";const UPDATE_INSTALL_CHANNEL = "desktop:update-install";const UPDATE_CHECK_CHANNEL = "desktop:update-check";const GET_WS_URL_CHANNEL = "desktop:get-ws-url";const BASE_DIR = process.env.T3CODE_HOME?.trim() || Path.join(OS.homedir(), ".t3");const STATE_DIR = Path.join(BASE_DIR, "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 LINUX_DESKTOP_ENTRY_NAME = isDevelopment ? "t3code-dev.desktop" : "t3code.desktop";const LINUX_WM_CLASS = isDevelopment ? "t3code-dev" : "t3code";const USER_DATA_DIR_NAME = isDevelopment ? "t3code-dev" : "t3code";const LEGACY_USER_DATA_DIR_NAME = isDevelopment ? "T3 Code (Dev)" : "T3 Code (Alpha)";const COMMIT_HASH_PATTERN = /^[0-9a-f]{7,40}$/i;const COMMIT_HASH_DISPLAY_LENGTH = 12;const LOG_DIR = Path.join(STATE_DIR, "logs");const LOG_FILE_MAX_BYTES = 10 * 1024 * 1024;const LOG_FILE_MAX_FILES = 10;const APP_RUN_ID = Crypto.randomBytes(6).toString("hex");const AUTO_UPDATE_STARTUP_DELAY_MS = 15_000;const AUTO_UPDATE_POLL_INTERVAL_MS = 4 * 60 * 60 * 1000;const DESKTOP_UPDATE_CHANNEL = "latest";const DESKTOP_UPDATE_ALLOW_PRERELEASE = false; type DesktopUpdateErrorContext = DesktopUpdateState["errorContext"];type LinuxDesktopNamedApp = Electron.App & {  setDesktopName?: (desktopName: string) => void;}; let mainWindow: BrowserWindow | null = null;let backendProcess: ChildProcess.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 desktopLogSink: RotatingFileSink | null = null;let backendLogSink: RotatingFileSink | null = null;let restoreStdIoCapture: (() => void) | null = null; let destructiveMenuIconCache: Electron.NativeImage | null | undefined;const desktopRuntimeInfo = resolveDesktopRuntimeInfo({  platform: process.platform,  processArch: process.arch,  runningUnderArm64Translation: app.runningUnderARM64Translation === true,});const initialUpdateState = (): DesktopUpdateState =>  createInitialDesktopUpdateState(app.getVersion(), desktopRuntimeInfo); function logTimestamp(): string {  return new Date().toISOString();} function logScope(scope: string): string {  return `${scope} run=${APP_RUN_ID}`;} function sanitizeLogValue(value: string): string {  return value.replace(/\s+/g, " ").trim();} function backendChildEnv(): NodeJS.ProcessEnv {  const env = { ...process.env };  delete env.T3CODE_PORT;  delete env.T3CODE_AUTH_TOKEN;  delete env.T3CODE_MODE;  delete env.T3CODE_NO_BROWSER;  delete env.T3CODE_HOST;  delete env.T3CODE_DESKTOP_WS_URL;  return env;} function writeDesktopLogHeader(message: string): void {  if (!desktopLogSink) return;  desktopLogSink.write(`[${logTimestamp()}] [${logScope("desktop")}] ${message}\n`);} function writeBackendSessionBoundary(phase: "START" | "END", details: string): void {  if (!backendLogSink) return;  const normalizedDetails = sanitizeLogValue(details);  backendLogSink.write(    `[${logTimestamp()}] ---- APP SESSION ${phase} run=${APP_RUN_ID} ${normalizedDetails} ----\n`,  );} function formatErrorMessage(error: unknown): string {  if (error instanceof Error) {    return error.message;  }  return String(error);} function getSafeExternalUrl(rawUrl: unknown): string | null {  if (typeof rawUrl !== "string" || rawUrl.length === 0) {    return null;  }   let parsedUrl: URL;  try {    parsedUrl = new URL(rawUrl);  } catch {    return null;  }   if (parsedUrl.protocol !== "https:" && parsedUrl.protocol !== "http:") {    return null;  }   return parsedUrl.toString();} function getSafeTheme(rawTheme: unknown): DesktopTheme | null {  if (rawTheme === "light" || rawTheme === "dark" || rawTheme === "system") {    return rawTheme;  }   return null;} function writeDesktopStreamChunk(  streamName: "stdout" | "stderr",  chunk: unknown,  encoding: BufferEncoding | undefined,): void {  if (!desktopLogSink) return;  const buffer = Buffer.isBuffer(chunk)    ? chunk    : Buffer.from(String(chunk), typeof chunk === "string" ? encoding : undefined);  desktopLogSink.write(`[${logTimestamp()}] [${logScope(streamName)}] `);  desktopLogSink.write(buffer);  if (buffer.length === 0 || buffer[buffer.length - 1] !== 0x0a) {    desktopLogSink.write("\n");  }} function installStdIoCapture(): void {  if (!app.isPackaged || desktopLogSink === null || restoreStdIoCapture !== null) {    return;  }   const originalStdoutWrite = process.stdout.write.bind(process.stdout);  const originalStderrWrite = process.stderr.write.bind(process.stderr);   const patchWrite =    (streamName: "stdout" | "stderr", originalWrite: typeof process.stdout.write) =>    (      chunk: string | Uint8Array,      encodingOrCallback?: BufferEncoding | ((error?: Error | null) => void),      callback?: (error?: Error | null) => void,    ): boolean => {      const encoding = typeof encodingOrCallback === "string" ? encodingOrCallback : undefined;      writeDesktopStreamChunk(streamName, chunk, encoding);      if (typeof encodingOrCallback === "function") {        return originalWrite(chunk, encodingOrCallback);      }      if (callback !== undefined) {        return originalWrite(chunk, encoding, callback);      }      if (encoding !== undefined) {        return originalWrite(chunk, encoding);      }      return originalWrite(chunk);    };   process.stdout.write = patchWrite("stdout", originalStdoutWrite);  process.stderr.write = patchWrite("stderr", originalStderrWrite);   restoreStdIoCapture = () => {    process.stdout.write = originalStdoutWrite;    process.stderr.write = originalStderrWrite;    restoreStdIoCapture = null;  };} function initializePackagedLogging(): void {  if (!app.isPackaged) return;  try {    desktopLogSink = new RotatingFileSink({      filePath: Path.join(LOG_DIR, "desktop-main.log"),      maxBytes: LOG_FILE_MAX_BYTES,      maxFiles: LOG_FILE_MAX_FILES,    });    backendLogSink = new RotatingFileSink({      filePath: Path.join(LOG_DIR, "server-child.log"),      maxBytes: LOG_FILE_MAX_BYTES,      maxFiles: LOG_FILE_MAX_FILES,    });    installStdIoCapture();    writeDesktopLogHeader(`runtime log capture enabled logDir=${LOG_DIR}`);  } catch (error) {    // Logging setup should never block app startup.    console.error("[desktop] failed to initialize packaged logging", error);  }} function captureBackendOutput(child: ChildProcess.ChildProcess): void {  if (!app.isPackaged || backendLogSink === null) return;  const writeChunk = (chunk: unknown): void => {    if (!backendLogSink) return;    const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk), "utf8");    backendLogSink.write(buffer);  };  child.stdout?.on("data", writeChunk);  child.stderr?.on("data", writeChunk);} initializePackagedLogging(); if (process.platform === "linux") {  app.commandLine.appendSwitch("class", LINUX_WM_CLASS);} 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;  }}let updatePollTimer: ReturnType<typeof setInterval> | null = null;let updateStartupTimer: ReturnType<typeof setTimeout> | null = null;let updateCheckInFlight = false;let updateDownloadInFlight = false;let updateInstallInFlight = false;let updaterConfigured = false;let updateState: DesktopUpdateState = initialUpdateState(); function resolveUpdaterErrorContext(): DesktopUpdateErrorContext {  if (updateInstallInFlight) return "install";  if (updateDownloadInFlight) return "download";  if (updateCheckInFlight) return "check";  return updateState.errorContext;} 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();} /** Read the baked-in app-update.yml config (if applicable). */function readAppUpdateYml(): Record<string, string> | null {  try {    // electron-updater reads from process.resourcesPath in packaged builds,    // or dev-app-update.yml via app.getAppPath() in dev.    const ymlPath = app.isPackaged      ? Path.join(process.resourcesPath, "app-update.yml")      : Path.join(app.getAppPath(), "dev-app-update.yml");    const raw = FS.readFileSync(ymlPath, "utf-8");    // The YAML is simple key-value pairs — avoid pulling in a YAML parser by    // doing a line-based parse (fields: provider, owner, repo, releaseType, …).    const entries: Record<string, string> = {};    for (const line of raw.split("\n")) {      const match = line.match(/^(\w+):\s*(.+)$/);      if (match?.[1] && match[2]) entries[match[1]] = match[2].trim();    }    return entries.provider ? entries : null;  } catch {    return null;  }} 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/bin.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 handleFatalStartupError(stage: string, error: unknown): void {  const message = formatErrorMessage(error);  const detail =    error instanceof Error && typeof error.stack === "string" ? `\n${error.stack}` : "";  writeDesktopLogHeader(`fatal startup error stage=${stage} message=${message}`);  console.error(`[desktop] fatal startup error (${stage})`, error);  if (!isQuitting) {    isQuitting = true;    dialog.showErrorBox("T3 Code failed to start", `Stage: ${stage}\n${message}${detail}`);  }  stopBackend();  restoreStdIoCapture?.();  app.quit();} 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;

Lines 1–500 of 1476.