File size: 8,979 Bytes
c0af099
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
#!/usr/bin/env node
/**
 * Brand the dev Electron app bundle with the product name (macOS only).
 *
 * Run automatically as the `predesktop` npm hook.
 *
 * WHY THIS EXISTS
 *
 * `npm run desktop` runs the app inside Electron's own prebuilt bundle,
 * node_modules/electron/dist/Electron.app. macOS derives the name it shows
 * in the Dock tooltip, the ⌘-Tab switcher and Finder from that bundle, at
 * launch, before any JavaScript runs. No runtime API can change it β€”
 * `app.setName()`, `app.name` and package.json `productName` only drive
 * Electron's own notion of the name (menu bar, About panel, and
 * app.getPath("userData")).
 *
 * The packaged app has never had this problem: electron-builder emits a
 * bundle literally named "<productName>.app" with matching plist keys. This
 * script puts the dev bundle in that same state.
 *
 * THE BUNDLE FILENAME IS THE PART THAT ACTUALLY SHOWS
 *
 * Patching the plist alone is NOT enough β€” verified the hard way. macOS
 * prefers the bundle's filesystem name over CFBundleName/CFBundleDisplayName
 * for the Dock tooltip. Two installed apps prove each half of this:
 *
 *   DBeaver.app        CFBundleName "DBeaver Community" β†’ displays "DBeaver"
 *                      (the filename wins over the plist)
 *   Antigravity.app    CFBundleExecutable "Electron"    β†’ displays "Antigravity"
 *                      (an Electron app whose executable name is irrelevant)
 *
 * So the fix aligns every source of the name at once: the .app directory
 * name, CFBundleName and CFBundleDisplayName. That is exactly the shape of a
 * packaged build, which is known to display correctly.
 *
 * Renaming the bundle means node_modules/electron/path.txt has to move with
 * it: getElectronPath() in node_modules/electron/index.js joins path.txt onto
 * dist/ and silently re-downloads Electron (~100 MB) if the result does not
 * exist. The two are updated together, and the rename is rolled back if
 * path.txt cannot be written.
 *
 * WHY THIS IS SAFE
 *
 *   - Electron's prebuilt dist is ad-hoc *linker-signed*: `codesign -dv`
 *     reports `flags=0x20002(adhoc,linker-signed)`, `Info.plist=not bound`,
 *     `Sealed Resources=none`. The signature covers only the Mach-O, so
 *     editing Info.plist does not invalidate it and no re-signing is needed.
 *   - `npm run build:desktop` is unaffected: electron-builder packages from
 *     its own download cache (~/Library/Caches/electron/electron-v*.zip),
 *     never from node_modules/electron/dist.
 *
 * WHAT IT DELIBERATELY DOES NOT TOUCH
 *
 *   CFBundleExecutable β€” left as "Electron". Antigravity above shows it has
 *     no bearing on the displayed name; it only feeds ps / Activity Monitor.
 *     Leaving it alone keeps path.txt's trailing segments valid.
 *   CFBundleIdentifier β€” kept at com.github.Electron. Changing it would split
 *     LaunchServices / TCC state per checkout for no visible gain.
 *
 * The changes live in node_modules, which `npm ci` wipes. That is fine: the
 * `predesktop` hook re-applies them on every `npm run desktop`.
 *
 * This script must never block the desktop run β€” every failure path warns
 * and exits 0.
 */

import { execFileSync } from "node:child_process";
import { existsSync, readFileSync, renameSync, writeFileSync } from "node:fs";
import { createRequire } from "node:module";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";

const __dirname = dirname(fileURLToPath(import.meta.url));
const projectRoot = join(__dirname, "..");

// Both keys are set. CFBundleDisplayName is the one LaunchServices reports;
// CFBundleName is the fallback and shows up in other bundle-name surfaces.
const NAME_KEYS = ["CFBundleDisplayName", "CFBundleName"];

function warn(message) {
  console.warn(`[brand-dev-electron] ${message}`);
}

/** Root of the installed `electron` package, or null if it isn't resolvable. */
function resolveElectronPackage() {
  const require = createRequire(import.meta.url);
  return dirname(require.resolve("electron/package.json"));
}

/** Current value of `key`, or null when the key is absent. */
function readPlistString(plistPath, key) {
  try {
    return execFileSync(
      "plutil",
      ["-extract", key, "raw", "-o", "-", plistPath],
      { encoding: "utf8" },
    ).trim();
  } catch {
    return null;
  }
}

function writePlistString(plistPath, key, value) {
  execFileSync("plutil", ["-replace", key, "-string", value, plistPath], {
    stdio: "pipe",
  });
}

/**
 * Rename dist/<current>.app to dist/<productName>.app and repoint path.txt.
 * Returns { appDir, renamed }, or null if the bundle can't be determined.
 */
function ensureBundleName(pkgDir, productName) {
  const distDir = join(pkgDir, "dist");
  const pathFile = join(pkgDir, "path.txt");
  if (!existsSync(pathFile)) {
    warn(`No ${pathFile} β€” leaving the dev bundle alone.`);
    return null;
  }

  // e.g. "Electron.app/Contents/MacOS/Electron" β€” only the first segment
  // (the bundle directory) is ours to rename.
  const relative = readFileSync(pathFile, "utf8").trim();
  const segments = relative.split("/");
  const currentName = segments[0];
  const desiredName = `${productName}.app`;
  if (!currentName.endsWith(".app")) {
    warn(`Unexpected path.txt entry "${relative}" β€” leaving the bundle alone.`);
    return null;
  }

  const desiredDir = join(distDir, desiredName);
  if (currentName === desiredName && existsSync(desiredDir)) {
    return { appDir: desiredDir, renamed: false };
  }

  const currentDir = join(distDir, currentName);
  let renamed = false;
  if (existsSync(currentDir) && currentDir !== desiredDir) {
    if (existsSync(desiredDir)) {
      // Both present: a previous run renamed the bundle and something
      // restored the original. Prefer the correctly named one and just fix
      // path.txt rather than clobbering either bundle.
      warn(`Both ${currentName} and ${desiredName} exist β€” using the latter.`);
    } else {
      renameSync(currentDir, desiredDir);
      renamed = true;
    }
  } else if (!existsSync(desiredDir)) {
    warn(`No Electron bundle under ${distDir} β€” leaving the dev bundle alone.`);
    return null;
  }

  // path.txt MUST agree with the directory on disk; a stale entry makes
  // getElectronPath() re-download Electron on the next run.
  segments[0] = desiredName;
  try {
    writeFileSync(pathFile, segments.join("/"));
  } catch (err) {
    // Undo the rename so the checkout is left in a working state.
    if (existsSync(desiredDir) && !existsSync(currentDir)) {
      try {
        renameSync(desiredDir, currentDir);
      } catch {
        warn(`Could not roll back the bundle rename in ${distDir}.`);
      }
    }
    warn(`Could not update ${pathFile}: ${err.message}`);
    return null;
  }

  return { appDir: desiredDir, renamed };
}

function main() {
  // The Dock/⌘-Tab name is a macOS bundle concept. On Windows the dev
  // taskbar name comes from electron.exe's version resource and on Linux
  // from the .desktop file / WM_CLASS β€” neither exists until the app is
  // packaged, so there is nothing to patch.
  if (process.platform !== "darwin") return;

  const manifestPath = join(projectRoot, "electron", "package.json");
  let productName;
  try {
    productName = JSON.parse(readFileSync(manifestPath, "utf8")).productName;
  } catch (err) {
    warn(`Could not read ${manifestPath}: ${err.message}`);
    return;
  }
  if (!productName) {
    warn(`No productName in ${manifestPath} β€” leaving the dev bundle alone.`);
    return;
  }
  // The name becomes a directory entry; a "/" would silently retarget it.
  if (productName.includes("/")) {
    warn(`productName "${productName}" cannot be used as a bundle name.`);
    return;
  }

  let pkgDir;
  try {
    pkgDir = resolveElectronPackage();
  } catch (err) {
    warn(`Could not resolve the electron package: ${err.message}`);
    return;
  }

  let bundle;
  try {
    bundle = ensureBundleName(pkgDir, productName);
  } catch (err) {
    warn(`Could not rename the dev bundle: ${err.message}`);
    return;
  }
  if (!bundle) return;

  const plistPath = join(bundle.appDir, "Contents", "Info.plist");
  if (!existsSync(plistPath)) {
    warn(`No Info.plist at ${plistPath} β€” leaving the plist alone.`);
    return;
  }

  const stale = NAME_KEYS.filter(
    (key) => readPlistString(plistPath, key) !== productName,
  );

  try {
    for (const key of stale) writePlistString(plistPath, key, productName);
  } catch (err) {
    // Read-only node_modules (CI caches, sandboxes) lands here. The app still
    // runs; only the displayed name keeps saying "Electron".
    warn(`Could not patch ${plistPath}: ${err.message}`);
    return;
  }

  // Stay quiet when there was nothing to do, so repeat runs don't add noise.
  if (bundle.renamed || stale.length > 0) {
    console.log(
      `[brand-dev-electron] Dev Electron bundle now identifies as "${productName}".`,
    );
  }
}

main();