Skip to content
Source
Sign inSign up
Open History to see the last commit

electron-launcher.mjs

474 lines15.4 KB

1import * as NodeChildProcess from "node:child_process";
2import * as NodeFS from "node:fs";
3import * as NodeModule from "node:module";
4import * as NodeOS from "node:os";
5import * as NodePath from "node:path";
6import * as NodeURL from "node:url";
7import { ensureElectronRuntime } from "./ensure-electron-runtime.mjs";
8​
9const isDevelopment = Boolean(process.env.VITE_DEV_SERVER_URL);
10const __dirname = NodePath.dirname(NodeURL.fileURLToPath(import.meta.url));
11export const desktopDir = NodePath.resolve(__dirname, "..");
12const repoRoot = NodePath.resolve(desktopDir, "..", "..");
13const devBundleIdSuffix = NodePath.basename(repoRoot)
14 .toLowerCase()
15 .replaceAll(/[^a-z0-9]+/g, "");
16const APP_DISPLAY_NAME = isDevelopment ? "T3 Code (Dev)" : "T3 Code (Beta)";
17const APP_BUNDLE_ID = isDevelopment
18 ? `com.t3tools.t3code.dev.${devBundleIdSuffix || "local"}`
19 : "com.t3tools.t3code";
20const APP_PROTOCOL_SCHEMES = isDevelopment ? ["t3code-dev"] : ["t3code"];
21const LAUNCHER_VERSION = 20;
22const developmentMacIconPngPath = NodePath.join(
23 repoRoot,
24 "assets",
25 "dev",
26 "blueprint-macos-1024.png",
27);
28const productionMacIconPngPath = NodePath.join(repoRoot, "assets", "prod", "black-macos-1024.png");
29// oxlint-disable-next-line t3code/no-global-process-runtime -- Standalone launcher script has no Effect runtime.
30const hostPlatform = NodeOS.platform();
31​
32function setPlistString(plistPath, key, value) {
33 const replaceResult = NodeChildProcess.spawnSync(
34 "plutil",
35 ["-replace", key, "-string", value, plistPath],
36 {
37 encoding: "utf8",
38 },
39 );
40 if (replaceResult.status === 0) {
41 return;
42 }
43​
44 const insertResult = NodeChildProcess.spawnSync(
45 "plutil",
46 ["-insert", key, "-string", value, plistPath],
47 {
48 encoding: "utf8",
49 },
50 );
51 if (insertResult.status === 0) {
52 return;
53 }
54​
55 const details = [replaceResult.stderr, insertResult.stderr].filter(Boolean).join("\n");
56 throw new Error(`Failed to update plist key "${key}" at ${plistPath}: ${details}`.trim());
58​
59function setPlistJson(plistPath, key, value) {
60 const serialized = JSON.stringify(value);
61 const replaceResult = NodeChildProcess.spawnSync(
62 "plutil",
63 ["-replace", key, "-json", serialized, plistPath],
64 {
65 encoding: "utf8",
66 },
67 );
68 if (replaceResult.status === 0) {
69 return;
70 }
71​
72 const insertResult = NodeChildProcess.spawnSync(
73 "plutil",
74 ["-insert", key, "-json", serialized, plistPath],
75 {
76 encoding: "utf8",
77 },
78 );
79 if (insertResult.status === 0) {
80 return;
81 }
82​
83 const details = [replaceResult.stderr, insertResult.stderr].filter(Boolean).join("\n");
84 throw new Error(`Failed to update plist key "${key}" at ${plistPath}: ${details}`.trim());
86​
87function runChecked(command, args) {
88 const result = NodeChildProcess.spawnSync(command, args, { encoding: "utf8" });
89 if (result.status === 0) {
90 return;
91 }
92​
93 const details = [result.stdout, result.stderr].filter(Boolean).join("\n");
94 throw new Error(`Failed to run ${command} ${args.join(" ")}: ${details}`.trim());
96​
97function resolveMacCodeSignArguments(appBundlePath) {
98 return ["--force", "--deep", "--sign", "-", "--timestamp=none", appBundlePath];
100​
101function signMacLauncherBundle(appBundlePath) {
102 runChecked("codesign", resolveMacCodeSignArguments(appBundlePath));
104​
105function shellSingleQuote(value) {
106 return `'${value.replaceAll("'", "'\\''")}'`;
108​
109export function makeDevelopmentEnvironmentScript(environment) {
110 const envEntries = [
111 ["VITE_DEV_SERVER_URL", environment.VITE_DEV_SERVER_URL],
112 ["T3CODE_PORT", environment.T3CODE_PORT],
113 ["T3CODE_HOME", environment.T3CODE_HOME],
114 ["T3CODE_COMMIT_HASH", environment.T3CODE_COMMIT_HASH],
115 ["T3CODE_OTLP_TRACES_URL", environment.T3CODE_OTLP_TRACES_URL],
116 ["T3CODE_OTLP_EXPORT_INTERVAL_MS", environment.T3CODE_OTLP_EXPORT_INTERVAL_MS],
117 ["T3CODE_OTLP_HEADERS", environment.T3CODE_OTLP_HEADERS],
118 ["T3CODE_OTLP_PROTOCOL", environment.T3CODE_OTLP_PROTOCOL],
119 ["T3CODE_DESKTOP_APP_USER_MODEL_ID", APP_BUNDLE_ID],
120 ].filter((entry) => typeof entry[1] === "string" && entry[1].trim().length > 0);
121 return [
122 ...envEntries.map(
123 ([name, value]) =>
124 `if [ -z "\${${name}:-}" ]; then export ${name}=${shellSingleQuote(value)}; fi`,
125 ),
126 "",
127 ].join("\n");
129​
130export function makeDevelopmentLauncherScript({
131 electronBinaryPath,
132 mainEntryPath,
133 desktopRoot,
134 environmentFilePath,
135}) {
136 return [
137 "#!/bin/sh",
138 `if [ -f ${shellSingleQuote(environmentFilePath)} ]; then . ${shellSingleQuote(environmentFilePath)}; fi`,
139 `exec ${shellSingleQuote(electronBinaryPath)} --t3code-dev-root=${shellSingleQuote(desktopRoot)} ${shellSingleQuote(mainEntryPath)} "$@"`,
140 "",
141 ].join("\n");
143​
144const developmentEnvironmentFilePath = NodePath.join(
145 desktopDir,
146 ".electron-runtime",
147 "dev-environment.sh",
148);
149​
150function writeDevelopmentEnvironmentScript() {
151 NodeFS.mkdirSync(NodePath.dirname(developmentEnvironmentFilePath), { recursive: true });
152 NodeFS.writeFileSync(
153 developmentEnvironmentFilePath,
154 makeDevelopmentEnvironmentScript(process.env),
155 );
157​
158export function writeDevelopmentLauncherScript(targetBinaryPath, electronBinaryPath) {
159 const script = makeDevelopmentLauncherScript({
160 electronBinaryPath,
161 mainEntryPath: NodePath.join(desktopDir, "dist-electron", "main.cjs"),
162 desktopRoot: desktopDir,
163 environmentFilePath: developmentEnvironmentFilePath,
164 });
165 if (
166 NodeFS.existsSync(targetBinaryPath) &&
167 NodeFS.readFileSync(targetBinaryPath, "utf8") === script
168 ) {
169 NodeFS.chmodSync(targetBinaryPath, 0o755);
170 return false;
171 }
172 NodeFS.writeFileSync(targetBinaryPath, script);
173 NodeFS.chmodSync(targetBinaryPath, 0o755);
174 return true;
176​
177function registerMacLauncherBundle(appBundlePath) {
178 runChecked(
179 "/System/Library/Frameworks/CoreServices.framework/Frameworks/LaunchServices.framework/Support/lsregister",
180 ["-f", appBundlePath],
181 );
182​
183 if (!isDevelopment) {
184 return;
185 }
186​
187 for (const scheme of APP_PROTOCOL_SCHEMES) {
188 runChecked("osascript", [
189 "-l",
190 "JavaScript",
191 "-e",
192 [
193 'ObjC.import("CoreServices");',
194 `const scheme = $.NSString.alloc.initWithUTF8String(${JSON.stringify(scheme)});`,
195 `const bundle = $.NSString.alloc.initWithUTF8String(${JSON.stringify(APP_BUNDLE_ID)});`,
196 "const status = $.LSSetDefaultHandlerForURLScheme(scheme, bundle);",
197 "if (status !== 0) throw new Error(`LSSetDefaultHandlerForURLScheme failed: ${status}`);",
198 ].join(" "),
199 ]);
200 }
202​
203// Bundle-internal paths are macOS paths whatever host builds them.
204function resolveMacLauncherIconPaths(runtimeDir, development = isDevelopment) {
205 return {
206 sourceIconPath: development ? developmentMacIconPngPath : productionMacIconPngPath,
207 generatedIconPath: NodePath.posix.join(
208 runtimeDir,
209 development ? "icon-dev.icns" : "icon-prod.icns",
210 ),
211 };
213​
214function ensureMacIconIcns(runtimeDir) {
215 const { sourceIconPath, generatedIconPath } = resolveMacLauncherIconPaths(runtimeDir);
216 NodeFS.mkdirSync(runtimeDir, { recursive: true });
217​
218 if (!NodeFS.existsSync(sourceIconPath)) {
219 throw new Error(`Desktop macOS icon source is missing at ${sourceIconPath}`);
220 }
221​
222 const sourceMtimeMs = NodeFS.statSync(sourceIconPath).mtimeMs;
223 if (
224 NodeFS.existsSync(generatedIconPath) &&
225 NodeFS.statSync(generatedIconPath).mtimeMs >= sourceMtimeMs
226 ) {
227 return generatedIconPath;
228 }
229​
230 const iconsetRoot = NodeFS.mkdtempSync(NodePath.join(runtimeDir, "dev-iconset-"));
231 const iconsetDir = NodePath.join(iconsetRoot, "icon.iconset");
232 NodeFS.mkdirSync(iconsetDir, { recursive: true });
233​
234 try {
235 for (const size of [16, 32, 128, 256, 512]) {
236 runChecked("sips", [
237 "-z",
238 String(size),
239 String(size),
240 sourceIconPath,
241 "--out",
242 NodePath.join(iconsetDir, `icon_${size}x${size}.png`),
243 ]);
244​
245 const retinaSize = size * 2;
246 runChecked("sips", [
247 "-z",
248 String(retinaSize),
249 String(retinaSize),
250 sourceIconPath,
251 "--out",
252 NodePath.join(iconsetDir, `icon_${size}x${size}@2x.png`),
253 ]);
254 }
255​
256 runChecked("iconutil", ["-c", "icns", iconsetDir, "-o", generatedIconPath]);
257 return generatedIconPath;
258 } finally {
259 NodeFS.rmSync(iconsetRoot, { recursive: true, force: true });
260 }
262​
263function resolveMacBundleInfoPlistStrings(executableName) {
264 return {
265 CFBundleDisplayName: APP_DISPLAY_NAME,
266 CFBundleName: APP_DISPLAY_NAME,
267 CFBundleIdentifier: APP_BUNDLE_ID,
268 CFBundleExecutable: executableName,
269 CFBundleIconFile: "icon.icns",
270 NSScreenCaptureUsageDescription:
271 "T3 Code captures the active window when you use the snapshot shortcut.",
272 NSDocumentsFolderUsageDescription: "T3 Code reads project files you open in the desktop app.",
273 };
275​
276function patchMainBundleInfoPlist(appBundlePath, iconPath, executableName) {
277 const infoPlistPath = NodePath.join(appBundlePath, "Contents", "Info.plist");
278 for (const [key, value] of Object.entries(resolveMacBundleInfoPlistStrings(executableName))) {
279 setPlistString(infoPlistPath, key, value);
280 }
281 // macOS 26 offers Messages/Mail codes in every text field unless opted out.
282 setPlistJson(infoPlistPath, "NSAutoFillRequiresTextContentTypeForOneTimeCodeOnMac", true);
283 setPlistJson(infoPlistPath, "CFBundleURLTypes", [
284 {
285 CFBundleURLName: APP_BUNDLE_ID,
286 CFBundleURLSchemes: APP_PROTOCOL_SCHEMES,
287 },
288 ]);
289​
290 const resourcesDir = NodePath.join(appBundlePath, "Contents", "Resources");
291 NodeFS.copyFileSync(iconPath, NodePath.join(resourcesDir, "icon.icns"));
292 NodeFS.copyFileSync(iconPath, NodePath.join(resourcesDir, "electron.icns"));
294​
295function patchHelperBundleInfoPlists(appBundlePath) {
296 const helperBundleNames = [
297 ["Electron Helper.app", "helper", `${APP_DISPLAY_NAME} Helper`],
298 ["Electron Helper (GPU).app", "helper.gpu", `${APP_DISPLAY_NAME} Helper (GPU)`],
299 ["Electron Helper (Plugin).app", "helper.plugin", `${APP_DISPLAY_NAME} Helper (Plugin)`],
300 ["Electron Helper (Renderer).app", "helper.renderer", `${APP_DISPLAY_NAME} Helper (Renderer)`],
301 ];
302​
303 for (const [bundleName, bundleIdentifierSuffix, bundleDisplayName] of helperBundleNames) {
304 const infoPlistPath = NodePath.join(
305 appBundlePath,
306 "Contents",
307 "Frameworks",
308 bundleName,
309 "Contents",
310 "Info.plist",
311 );
312 if (!NodeFS.existsSync(infoPlistPath)) {
313 continue;
314 }
315​
316 setPlistString(infoPlistPath, "CFBundleDisplayName", bundleDisplayName);
317 setPlistString(infoPlistPath, "CFBundleName", bundleDisplayName);
318 setPlistString(
319 infoPlistPath,
320 "CFBundleIdentifier",
321 `${APP_BUNDLE_ID}.${bundleIdentifierSuffix}`,
322 );
323 }
325​
326function readJson(path) {
327 try {
328 return JSON.parse(NodeFS.readFileSync(path, "utf8"));
329 } catch {
330 return null;
331 }
333​
334export function resolveMacLauncherPaths(appBundlePath, displayName = APP_DISPLAY_NAME) {
335 const executableDir = NodePath.posix.join(appBundlePath, "Contents", "MacOS");
336 const launcherExecutableName = `${displayName} Launcher`;
337 return {
338 launcherExecutableName,
339 launcherBinaryPath: NodePath.posix.join(executableDir, launcherExecutableName),
340 runtimeElectronBinaryPath: NodePath.posix.join(executableDir, "Electron"),
341 };
343​
344function buildMacLauncher(electronBinaryPath) {
345 const sourceAppBundlePath = NodePath.resolve(NodePath.dirname(electronBinaryPath), "../..");
346 const runtimeDir = NodePath.join(desktopDir, ".electron-runtime");
347 const targetAppBundlePath = NodePath.join(runtimeDir, `${APP_DISPLAY_NAME}.app`);
348 const developmentPaths = resolveMacLauncherPaths(targetAppBundlePath);
349 const runtimeElectronBinaryPath = developmentPaths.runtimeElectronBinaryPath;
350 const launcherBinaryPath = isDevelopment
351 ? developmentPaths.launcherBinaryPath
352 : runtimeElectronBinaryPath;
353 const iconPath = ensureMacIconIcns(runtimeDir);
354 const metadataPath = NodePath.join(runtimeDir, "metadata.json");
355​
356 NodeFS.mkdirSync(runtimeDir, { recursive: true });
357​
358 const expectedMetadata = {
359 launcherVersion: LAUNCHER_VERSION,
360 sourceAppBundlePath,
361 sourceAppMtimeMs: NodeFS.statSync(sourceAppBundlePath).mtimeMs,
362 iconMtimeMs: NodeFS.statSync(iconPath).mtimeMs,
363 appBundleId: APP_BUNDLE_ID,
364 appProtocolSchemes: APP_PROTOCOL_SCHEMES,
365 };
366​
367 const currentMetadata = readJson(metadataPath);
368 if (
369 NodeFS.existsSync(launcherBinaryPath) &&
370 (!isDevelopment || NodeFS.existsSync(runtimeElectronBinaryPath)) &&
371 currentMetadata &&
372 JSON.stringify(currentMetadata) === JSON.stringify(expectedMetadata)
373 ) {
374 if (isDevelopment) {
375 // Protocol activations bypass the dev runner, so refresh the fallback environment on every launch.
376 writeDevelopmentEnvironmentScript();
377 if (writeDevelopmentLauncherScript(launcherBinaryPath, runtimeElectronBinaryPath)) {
378 signMacLauncherBundle(targetAppBundlePath);
379 }
380 }
381 registerMacLauncherBundle(targetAppBundlePath);
382 return launcherBinaryPath;
383 }
384​
385 NodeFS.rmSync(targetAppBundlePath, { recursive: true, force: true });
386 // Without verbatimSymlinks, cpSync makes framework symlinks point outside the bundle and crashes helpers.
387 NodeFS.cpSync(sourceAppBundlePath, targetAppBundlePath, {
388 recursive: true,
389 verbatimSymlinks: true,
390 });
391 patchMainBundleInfoPlist(
392 targetAppBundlePath,
393 iconPath,
394 isDevelopment ? developmentPaths.launcherExecutableName : "Electron",
395 );
396 patchHelperBundleInfoPlists(targetAppBundlePath);
397 if (isDevelopment) {
398 // Electron.app's executable name keeps default-app mode active without making app.isPackaged true.
399 writeDevelopmentEnvironmentScript();
400 writeDevelopmentLauncherScript(launcherBinaryPath, runtimeElectronBinaryPath);
401 }
402 signMacLauncherBundle(targetAppBundlePath);
403 NodeFS.writeFileSync(metadataPath, `${JSON.stringify(expectedMetadata, null, 2)}\n`);
404 registerMacLauncherBundle(targetAppBundlePath);
405​
406 return launcherBinaryPath;
408​
409function isLinuxSetuidSandboxConfigured(electronBinaryPath) {
410 if (hostPlatform !== "linux") {
411 return true;
412 }
413​
414 const sandboxPath = NodePath.join(NodePath.dirname(electronBinaryPath), "chrome-sandbox");
415 try {
416 const sandboxStat = NodeFS.statSync(sandboxPath);
417 return sandboxStat.uid === 0 && (sandboxStat.mode & 0o4777) === 0o4755;
418 } catch {
419 return false;
420 }
422​
423function resolveLinuxSandboxArgs(electronBinaryPath) {
424 if (isLinuxSetuidSandboxConfigured(electronBinaryPath)) {
425 return [];
426 }
427​
428 console.warn(
429 "[desktop-launcher] Electron chrome-sandbox is not root-owned with mode 4755; launching local Electron with --no-sandbox.",
430 );
431 return ["--no-sandbox"];
433​
434function resolveElectronPath() {
435 const electronBinaryPath = resolveElectronBinaryPath();
436​
437 if (hostPlatform !== "darwin") {
438 return electronBinaryPath;
439 }
440​
441 return buildMacLauncher(electronBinaryPath);
443​
444export function resolveElectronLaunchCommand(args = []) {
445 const electronPath = resolveElectronPath();
446 return {
447 electronPath,
448 args: [...resolveLinuxSandboxArgs(electronPath), ...args],
449 };
451​
452export function resolveElectronBinaryPath({
453 ensureRuntime = ensureElectronRuntime,
454 createRequire = NodeModule.createRequire,
455 moduleUrl = import.meta.url,
456} = {}) {
457 ensureRuntime();
458​
459 const require = createRequire(moduleUrl);
460 return require("electron");
462​
463export function resolveDevProtocolClient() {
464 if (hostPlatform !== "darwin" || !isDevelopment) {
465 return null;
466 }
467​
468 const electronBinaryPath = resolveElectronBinaryPath();
469 const launcherBinaryPath = buildMacLauncher(electronBinaryPath);
470 return {
471 appBundlePath: NodePath.resolve(launcherBinaryPath, "..", "..", ".."),
472 appBundleId: APP_BUNDLE_ID,
473 };