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