2175 lines · 68.6 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,  type BrowserWindowConstructorOptions,  clipboard,  dialog,  ipcMain,  Menu,  nativeImage,  nativeTheme,  protocol,  safeStorage,  shell,} from "electron";import type { MenuItemConstructorOptions, OpenDialogOptions } from "electron";import type {  ClientSettings,  DesktopTheme,  DesktopAppBranding,  DesktopServerExposureMode,  DesktopServerExposureState,  DesktopUpdateChannel,  PersistedSavedEnvironmentRecord,  DesktopUpdateActionResult,  DesktopUpdateCheckResult,  DesktopUpdateState,} from "@t3tools/contracts";import { autoUpdater } from "electron-updater"; import type { ContextMenuItem } from "@t3tools/contracts";import { RotatingFileSink } from "@t3tools/shared/logging";import { parsePersistedServerObservabilitySettings } from "@t3tools/shared/serverSettings";import { DEFAULT_DESKTOP_BACKEND_PORT, resolveDesktopBackendPort } from "./backendPort.ts";import {  DEFAULT_DESKTOP_SETTINGS,  readDesktopSettings,  setDesktopServerExposurePreference,  setDesktopUpdateChannelPreference,  writeDesktopSettings,} from "./desktopSettings.ts";import {  readClientSettings,  readSavedEnvironmentRegistry,  readSavedEnvironmentSecret,  removeSavedEnvironmentSecret,  writeClientSettings,  writeSavedEnvironmentRegistry,  writeSavedEnvironmentSecret,} from "./clientPersistence.ts";import { isBackendReadinessAborted, waitForHttpReady } from "./backendReadiness.ts";import { showDesktopConfirmDialog } from "./confirmDialog.ts";import { resolveDesktopServerExposure } from "./serverExposure.ts";import { syncShellEnvironment } from "./syncShellEnvironment.ts";import { waitForBackendStartupReady } from "./backendStartupReadiness.ts";import { getAutoUpdateDisabledReason, shouldBroadcastDownloadProgress } from "./updateState.ts";import { doesVersionMatchDesktopUpdateChannel } from "./updateChannels.ts";import { ServerListeningDetector } from "./serverListeningDetector.ts";import {  createInitialDesktopUpdateState,  reduceDesktopUpdateStateOnCheckFailure,  reduceDesktopUpdateStateOnCheckStart,  reduceDesktopUpdateStateOnDownloadComplete,  reduceDesktopUpdateStateOnDownloadFailure,  reduceDesktopUpdateStateOnDownloadProgress,  reduceDesktopUpdateStateOnDownloadStart,  reduceDesktopUpdateStateOnInstallFailure,  reduceDesktopUpdateStateOnNoUpdate,  reduceDesktopUpdateStateOnUpdateAvailable,} from "./updateMachine.ts";import { isArm64HostRunningIntelBuild, resolveDesktopRuntimeInfo } from "./runtimeArch.ts";import { resolveDesktopAppBranding } from "./appBranding.ts";import { bindFirstRevealTrigger, type RevealSubscription } from "./windowReveal.ts"; 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_SET_CHANNEL_CHANNEL = "desktop:update-set-channel";const UPDATE_DOWNLOAD_CHANNEL = "desktop:update-download";const UPDATE_INSTALL_CHANNEL = "desktop:update-install";const UPDATE_CHECK_CHANNEL = "desktop:update-check";const GET_APP_BRANDING_CHANNEL = "desktop:get-app-branding";const GET_LOCAL_ENVIRONMENT_BOOTSTRAP_CHANNEL = "desktop:get-local-environment-bootstrap";const GET_CLIENT_SETTINGS_CHANNEL = "desktop:get-client-settings";const SET_CLIENT_SETTINGS_CHANNEL = "desktop:set-client-settings";const GET_SAVED_ENVIRONMENT_REGISTRY_CHANNEL = "desktop:get-saved-environment-registry";const SET_SAVED_ENVIRONMENT_REGISTRY_CHANNEL = "desktop:set-saved-environment-registry";const GET_SAVED_ENVIRONMENT_SECRET_CHANNEL = "desktop:get-saved-environment-secret";const SET_SAVED_ENVIRONMENT_SECRET_CHANNEL = "desktop:set-saved-environment-secret";const REMOVE_SAVED_ENVIRONMENT_SECRET_CHANNEL = "desktop:remove-saved-environment-secret";const GET_SERVER_EXPOSURE_STATE_CHANNEL = "desktop:get-server-exposure-state";const SET_SERVER_EXPOSURE_MODE_CHANNEL = "desktop:set-server-exposure-mode";const BASE_DIR = process.env.T3CODE_HOME?.trim() || Path.join(OS.homedir(), ".t3");const STATE_DIR = Path.join(BASE_DIR, "userdata");const DESKTOP_SETTINGS_PATH = Path.join(STATE_DIR, "desktop-settings.json");const CLIENT_SETTINGS_PATH = Path.join(STATE_DIR, "client-settings.json");const SAVED_ENVIRONMENT_REGISTRY_PATH = Path.join(STATE_DIR, "saved-environments.json");const DESKTOP_SCHEME = "t3";const ROOT_DIR = Path.resolve(__dirname, "../../..");const isDevelopment = Boolean(process.env.VITE_DEV_SERVER_URL);const desktopAppBranding: DesktopAppBranding = resolveDesktopAppBranding({  isDevelopment,  appVersion: app.getVersion(),});const APP_DISPLAY_NAME = desktopAppBranding.displayName;const APP_USER_MODEL_ID = isDevelopment ? "com.t3tools.t3code.dev" : "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 SERVER_SETTINGS_PATH = Path.join(STATE_DIR, "settings.json");const AUTO_UPDATE_STARTUP_DELAY_MS = 15_000;const AUTO_UPDATE_POLL_INTERVAL_MS = 4 * 60 * 60 * 1000; function resolvePickFolderDefaultPath(rawOptions: unknown): string | undefined {  if (typeof rawOptions !== "object" || rawOptions === null) {    return undefined;  }   const { initialPath } = rawOptions as { initialPath?: unknown };  if (typeof initialPath !== "string") {    return undefined;  }   const trimmedPath = initialPath.trim();  if (trimmedPath.length === 0) {    return undefined;  }   if (trimmedPath === "~") {    return OS.homedir();  }   if (trimmedPath.startsWith("~/") || trimmedPath.startsWith("~\\")) {    return Path.join(OS.homedir(), trimmedPath.slice(2));  }   return Path.resolve(trimmedPath);}const DESKTOP_LOOPBACK_HOST = "127.0.0.1";const DESKTOP_REQUIRED_PORT_PROBE_HOSTS = ["0.0.0.0", "::"] as const;const TITLEBAR_HEIGHT = 40;const TITLEBAR_COLOR = "#01000000"; // #00000000 does not work correctly on Linuxconst TITLEBAR_LIGHT_SYMBOL_COLOR = "#1f2937";const TITLEBAR_DARK_SYMBOL_COLOR = "#f8fafc"; function normalizeContextMenuItems(source: readonly ContextMenuItem[]): ContextMenuItem[] {  const normalizedItems: ContextMenuItem[] = [];   for (const sourceItem of source) {    if (typeof sourceItem.id !== "string" || typeof sourceItem.label !== "string") {      continue;    }     const normalizedItem: ContextMenuItem = {      id: sourceItem.id,      label: sourceItem.label,      destructive: sourceItem.destructive === true,      disabled: sourceItem.disabled === true,    };     if (sourceItem.children) {      const normalizedChildren = normalizeContextMenuItems(sourceItem.children);      if (normalizedChildren.length === 0) {        continue;      }      normalizedItem.children = normalizedChildren;    }     normalizedItems.push(normalizedItem);  }   return normalizedItems;} type WindowTitleBarOptions = Pick<  BrowserWindowConstructorOptions,  "titleBarOverlay" | "titleBarStyle" | "trafficLightPosition">; 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 backendBindHost = DESKTOP_LOOPBACK_HOST;let backendBootstrapToken = "";let backendHttpUrl = "";let backendWsUrl = "";let backendEndpointUrl: string | null = null;let backendAdvertisedHost: string | null = null;let backendReadinessAbortController: AbortController | null = null;let backendInitialWindowOpenInFlight: Promise<void> | null = null;let backendListeningDetector: ServerListeningDetector | null = null;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 backendObservabilitySettings = readPersistedBackendObservabilitySettings();let desktopSettings = readDesktopSettings(DESKTOP_SETTINGS_PATH, app.getVersion());let desktopServerExposureMode: DesktopServerExposureMode = desktopSettings.serverExposureMode; let destructiveMenuIconCache: Electron.NativeImage | null | undefined;const expectedBackendExitChildren = new WeakSet<ChildProcess.ChildProcess>();const desktopRuntimeInfo = resolveDesktopRuntimeInfo({  platform: process.platform,  processArch: process.arch,  runningUnderArm64Translation: app.runningUnderARM64Translation === true,});const initialUpdateState = (): DesktopUpdateState =>  createInitialDesktopUpdateState(    app.getVersion(),    desktopRuntimeInfo,    desktopSettings.updateChannel,  ); 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 readPersistedBackendObservabilitySettings(): {  readonly otlpTracesUrl: string | undefined;  readonly otlpMetricsUrl: string | undefined;} {  try {    if (!FS.existsSync(SERVER_SETTINGS_PATH)) {      return { otlpTracesUrl: undefined, otlpMetricsUrl: undefined };    }    return parsePersistedServerObservabilitySettings(FS.readFileSync(SERVER_SETTINGS_PATH, "utf8"));  } catch (error) {    console.warn("[desktop] failed to read persisted backend observability settings", error);    return { otlpTracesUrl: undefined, otlpMetricsUrl: undefined };  }} function resolveConfiguredDesktopBackendPort(rawPort: string | undefined): number | undefined {  if (!rawPort) {    return undefined;  }   const parsedPort = Number.parseInt(rawPort, 10);  if (!Number.isInteger(parsedPort) || parsedPort < 1 || parsedPort > 65_535) {    return undefined;  }   return parsedPort;} function resolveDesktopDevServerUrl(): string {  const devServerUrl = process.env.VITE_DEV_SERVER_URL?.trim();  if (!devServerUrl) {    throw new Error("VITE_DEV_SERVER_URL is required in desktop development.");  }   return devServerUrl;} function backendChildEnv(): NodeJS.ProcessEnv {  const env = { ...process.env };  delete env.T3CODE_PORT;  delete env.T3CODE_MODE;  delete env.T3CODE_NO_BROWSER;  delete env.T3CODE_HOST;  delete env.T3CODE_DESKTOP_WS_URL;  delete env.T3CODE_DESKTOP_LAN_ACCESS;  delete env.T3CODE_DESKTOP_LAN_HOST;  return env;} function getDesktopServerExposureState(): DesktopServerExposureState {  return {    mode: desktopServerExposureMode,    endpointUrl: backendEndpointUrl,    advertisedHost: backendAdvertisedHost,  };} function getDesktopSecretStorage() {  return {    isEncryptionAvailable: () => safeStorage.isEncryptionAvailable(),    encryptString: (value: string) => safeStorage.encryptString(value),    decryptString: (value: Buffer) => safeStorage.decryptString(value),  } as const;} function resolveAdvertisedHostOverride(): string | undefined {  const override = process.env.T3CODE_DESKTOP_LAN_HOST?.trim();  return override && override.length > 0 ? override : undefined;} async function applyDesktopServerExposureMode(  mode: DesktopServerExposureMode,  options?: { readonly persist?: boolean; readonly rejectIfUnavailable?: boolean },): Promise<DesktopServerExposureState> {  const advertisedHostOverride = resolveAdvertisedHostOverride();  const requestedMode = mode;  let exposure = resolveDesktopServerExposure({    mode,    port: backendPort,    networkInterfaces: OS.networkInterfaces(),    ...(advertisedHostOverride ? { advertisedHostOverride } : {}),  });   if (requestedMode === "network-accessible" && exposure.endpointUrl === null) {    if (options?.rejectIfUnavailable) {      throw new Error("No reachable network address is available for this desktop right now.");    }    exposure = resolveDesktopServerExposure({      mode: "local-only",      port: backendPort,      networkInterfaces: OS.networkInterfaces(),      ...(advertisedHostOverride ? { advertisedHostOverride } : {}),    });  }   desktopServerExposureMode = exposure.mode;  desktopSettings = setDesktopServerExposurePreference(desktopSettings, requestedMode);  backendBindHost = exposure.bindHost;  backendHttpUrl = exposure.localHttpUrl;  backendWsUrl = exposure.localWsUrl;  backendEndpointUrl = exposure.endpointUrl;  backendAdvertisedHost = exposure.advertisedHost;   if (options?.persist) {    writeDesktopSettings(DESKTOP_SETTINGS_PATH, desktopSettings);  }   return getDesktopServerExposureState();} function relaunchDesktopApp(reason: string): void {  writeDesktopLogHeader(`desktop relaunch requested reason=${reason}`);  setImmediate(() => {    isQuitting = true;    clearUpdatePollTimer();    cancelBackendReadinessWait();    void stopBackendAndWaitForExit()      .catch((error) => {        writeDesktopLogHeader(          `desktop relaunch backend shutdown warning message=${formatErrorMessage(error)}`,        );      })      .finally(() => {        restoreStdIoCapture?.();        if (isDevelopment) {          app.exit(75);          return;        }        app.relaunch({          execPath: process.execPath,          args: process.argv.slice(1),        });        app.exit(0);      });  });} 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;} async function waitForBackendHttpReady(  baseUrl: string,  options?: Parameters<typeof waitForHttpReady>[1],): Promise<void> {  cancelBackendReadinessWait();  const controller = new AbortController();  backendReadinessAbortController = controller;   try {    await waitForHttpReady(baseUrl, {      ...options,      signal: controller.signal,    });  } finally {    if (backendReadinessAbortController === controller) {      backendReadinessAbortController = null;    }  }} function cancelBackendReadinessWait(): void {  backendReadinessAbortController?.abort();  backendReadinessAbortController = null;} async function waitForBackendWindowReady(baseUrl: string): Promise<"listening" | "http"> {  return await waitForBackendStartupReady({    listeningPromise: backendListeningDetector?.promise ?? null,    waitForHttpReady: () =>      waitForBackendHttpReady(baseUrl, {        timeoutMs: 60_000,      }),    cancelHttpWait: cancelBackendReadinessWait,  });} function ensureInitialBackendWindowOpen(): void {  const existingWindow = mainWindow ?? BrowserWindow.getAllWindows()[0] ?? null;  if (isDevelopment || existingWindow !== null || backendInitialWindowOpenInFlight !== null) {    return;  }   const nextOpen = waitForBackendWindowReady(backendHttpUrl)    .then((source) => {      writeDesktopLogHeader(`bootstrap backend ready source=${source}`);      if (mainWindow ?? BrowserWindow.getAllWindows()[0]) {        return;      }      mainWindow = createWindow();      writeDesktopLogHeader("bootstrap main window created");    })    .catch((error) => {      if (isBackendReadinessAborted(error)) {        return;      }      writeDesktopLogHeader(        `bootstrap backend readiness warning message=${formatErrorMessage(error)}`,      );      console.warn("[desktop] backend readiness check timed out during packaged bootstrap", error);    })    .finally(() => {      if (backendInitialWindowOpenInFlight === nextOpen) {        backendInitialWindowOpenInFlight = null;

Lines 1–500 of 2175.