diff --git a/apps/desktop/.gitignore b/apps/desktop/.gitignore index acb4535b60e5107bc2fac12705d43e40b23c2d18..45b848af6524e510fc183d1ac21891d95444e769 100644 --- a/apps/desktop/.gitignore +++ b/apps/desktop/.gitignore @@ -1 +1,2 @@ dist-electron/ +.electron-runtime/ diff --git a/apps/desktop/package.json b/apps/desktop/package.json index deb8409801a882321bebe6e7a9448958816ae7b7..7961e131bc690c66d19aa9f8b5a88b58391b36ed 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -9,7 +9,7 @@ "dev:bundle": "tsdown --watch", "dev:electron": "bun run scripts/dev-electron.mjs", "build": "tsdown", - "start": "electron dist-electron/main.js", + "start": "bun run scripts/start-electron.mjs", "typecheck": "tsc --noEmit", "test": "vitest run --passWithNoTests", "smoke-test": "node scripts/smoke-test.mjs" diff --git a/apps/desktop/scripts/dev-electron.mjs b/apps/desktop/scripts/dev-electron.mjs index be7d8380fb7172b0d35436d3e8b4a714f3d13810..3ccebb280228ec9f4b98df1a4b28ba3dfd373a97 100644 --- a/apps/desktop/scripts/dev-electron.mjs +++ b/apps/desktop/scripts/dev-electron.mjs @@ -1,7 +1,8 @@ -import { spawn } from "node:child_process"; - +import electronmon from "electronmon"; import waitOn from "wait-on"; +import { desktopDir, resolveElectronPath } from "./electron-launcher.mjs"; + const port = Number(process.env.ELECTRON_RENDERER_PORT ?? 5173); const devServerUrl = `http://localhost:${port}`; @@ -14,18 +15,15 @@ await waitOn({ ], }); -const command = process.platform === "win32" ? "electronmon.cmd" : "electronmon"; const childEnv = { ...process.env }; delete childEnv.ELECTRON_RUN_AS_NODE; -const child = spawn(command, ["dist-electron/main.js"], { - stdio: "inherit", +await electronmon({ + cwd: desktopDir, + args: ["dist-electron/main.js"], env: { ...childEnv, VITE_DEV_SERVER_URL: devServerUrl, }, -}); - -child.on("exit", (code) => { - process.exit(code ?? 0); + electronPath: resolveElectronPath(), }); diff --git a/apps/desktop/scripts/electron-launcher.mjs b/apps/desktop/scripts/electron-launcher.mjs new file mode 100644 index 0000000000000000000000000000000000000000..0af803ad496b8a3ca00f59454ca607b66e5bde7c --- /dev/null +++ b/apps/desktop/scripts/electron-launcher.mjs @@ -0,0 +1,172 @@ +// This file mostly exists because we want dev mode to say "T3 Code" instead of "electron" + +import { spawnSync } from "node:child_process"; +import { + copyFileSync, + cpSync, + existsSync, + mkdirSync, + readFileSync, + readdirSync, + rmSync, + statSync, + writeFileSync, +} from "node:fs"; +import { createRequire } from "node:module"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const APP_DISPLAY_NAME = "T3 Code"; +const APP_BUNDLE_ID = "com.t3tools.t3code"; +const LAUNCHER_VERSION = 1; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +export const desktopDir = resolve(__dirname, ".."); + +function setPlistString(plistPath, key, value) { + const replaceResult = spawnSync( + "plutil", + ["-replace", key, "-string", value, plistPath], + { + encoding: "utf8", + }, + ); + if (replaceResult.status === 0) { + return; + } + + const insertResult = spawnSync( + "plutil", + ["-insert", key, "-string", value, plistPath], + { + encoding: "utf8", + }, + ); + if (insertResult.status === 0) { + return; + } + + const details = [replaceResult.stderr, insertResult.stderr] + .filter(Boolean) + .join("\n"); + throw new Error( + `Failed to update plist key "${key}" at ${plistPath}: ${details}`.trim(), + ); +} + +function patchMainBundleInfoPlist(appBundlePath, iconPath) { + const infoPlistPath = join(appBundlePath, "Contents", "Info.plist"); + setPlistString(infoPlistPath, "CFBundleDisplayName", APP_DISPLAY_NAME); + setPlistString(infoPlistPath, "CFBundleName", APP_DISPLAY_NAME); + setPlistString(infoPlistPath, "CFBundleIdentifier", APP_BUNDLE_ID); + setPlistString(infoPlistPath, "CFBundleIconFile", "icon.icns"); + + const resourcesDir = join(appBundlePath, "Contents", "Resources"); + copyFileSync(iconPath, join(resourcesDir, "icon.icns")); + copyFileSync(iconPath, join(resourcesDir, "electron.icns")); +} + +function patchHelperBundleInfoPlists(appBundlePath) { + const frameworksDir = join(appBundlePath, "Contents", "Frameworks"); + if (!existsSync(frameworksDir)) { + return; + } + + for (const entry of readdirSync(frameworksDir, { withFileTypes: true })) { + if (!entry.isDirectory() || !entry.name.endsWith(".app")) { + continue; + } + if (!entry.name.startsWith("Electron Helper")) { + continue; + } + + const helperPlistPath = join( + frameworksDir, + entry.name, + "Contents", + "Info.plist", + ); + if (!existsSync(helperPlistPath)) { + continue; + } + + const suffix = entry.name + .replace("Electron Helper", "") + .replace(".app", "") + .trim(); + const helperName = suffix + ? `${APP_DISPLAY_NAME} Helper ${suffix}` + : `${APP_DISPLAY_NAME} Helper`; + const helperIdSuffix = suffix + .replace(/[()]/g, "") + .trim() + .toLowerCase() + .replace(/\s+/g, "-"); + const helperBundleId = helperIdSuffix + ? `${APP_BUNDLE_ID}.helper.${helperIdSuffix}` + : `${APP_BUNDLE_ID}.helper`; + + setPlistString(helperPlistPath, "CFBundleDisplayName", helperName); + setPlistString(helperPlistPath, "CFBundleName", helperName); + setPlistString(helperPlistPath, "CFBundleIdentifier", helperBundleId); + } +} + +function readJson(path) { + try { + return JSON.parse(readFileSync(path, "utf8")); + } catch { + return null; + } +} + +function buildMacLauncher(electronBinaryPath) { + const sourceAppBundlePath = resolve(electronBinaryPath, "../../.."); + const runtimeDir = join(desktopDir, ".electron-runtime"); + const targetAppBundlePath = join(runtimeDir, `${APP_DISPLAY_NAME}.app`); + const targetBinaryPath = join( + targetAppBundlePath, + "Contents", + "MacOS", + "Electron", + ); + const iconPath = join(desktopDir, "resources", "icon.icns"); + const metadataPath = join(runtimeDir, "metadata.json"); + + mkdirSync(runtimeDir, { recursive: true }); + + const expectedMetadata = { + launcherVersion: LAUNCHER_VERSION, + sourceAppBundlePath, + sourceAppMtimeMs: statSync(sourceAppBundlePath).mtimeMs, + iconMtimeMs: statSync(iconPath).mtimeMs, + }; + + const currentMetadata = readJson(metadataPath); + if ( + existsSync(targetBinaryPath) && + currentMetadata && + JSON.stringify(currentMetadata) === JSON.stringify(expectedMetadata) + ) { + return targetBinaryPath; + } + + rmSync(targetAppBundlePath, { recursive: true, force: true }); + cpSync(sourceAppBundlePath, targetAppBundlePath, { recursive: true }); + patchMainBundleInfoPlist(targetAppBundlePath, iconPath); + patchHelperBundleInfoPlists(targetAppBundlePath); + writeFileSync(metadataPath, `${JSON.stringify(expectedMetadata, null, 2)}\n`); + + return targetBinaryPath; +} + +export function resolveElectronPath() { + const require = createRequire(import.meta.url); + const electronBinaryPath = require("electron"); + + if (process.platform !== "darwin") { + return electronBinaryPath; + } + + return buildMacLauncher(electronBinaryPath); +} diff --git a/apps/desktop/scripts/start-electron.mjs b/apps/desktop/scripts/start-electron.mjs new file mode 100644 index 0000000000000000000000000000000000000000..bf93adb6b0db0c2b5658faa08d5fd9ec3f6ee9e7 --- /dev/null +++ b/apps/desktop/scripts/start-electron.mjs @@ -0,0 +1,20 @@ +import { spawn } from "node:child_process"; + +import { desktopDir, resolveElectronPath } from "./electron-launcher.mjs"; + +const childEnv = { ...process.env }; +delete childEnv.ELECTRON_RUN_AS_NODE; + +const child = spawn(resolveElectronPath(), ["dist-electron/main.js"], { + stdio: "inherit", + cwd: desktopDir, + env: childEnv, +}); + +child.on("exit", (code, signal) => { + if (signal) { + process.kill(process.pid, signal); + return; + } + process.exit(code ?? 0); +}); diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index 4e046415593daae3479411b5875e3c493f94f8c7..bb82ffb6f710617777582f867359bea63c6d89ab 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -55,6 +55,9 @@ function resolveIconPath(ext: "ico" | "icns" | "png"): string | null { function configureAppIdentity(): void { app.setName(APP_DISPLAY_NAME); + app.setAboutPanelOptions({ + applicationName: APP_DISPLAY_NAME, + }); if (process.platform === "win32") { app.setAppUserModelId(APP_USER_MODEL_ID); @@ -269,6 +272,13 @@ function createWindow(): BrowserWindow { }); 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(); }); @@ -311,6 +321,7 @@ app.on("before-quit", () => { }); app.whenReady().then(() => { + configureAppIdentity(); void bootstrap(); app.on("activate", () => {