@gjsify/cli 0.51.1 → 0.53.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (126) hide show
  1. package/dist/cli.gjs.mjs +539 -493
  2. package/lib/actions/build.d.ts +22 -0
  3. package/lib/actions/build.js +37 -9
  4. package/lib/bundler-pick.d.ts +37 -1
  5. package/lib/bundler-pick.js +108 -17
  6. package/lib/cli-app.js +7 -2
  7. package/lib/commands/affected-classify.d.ts +7 -1
  8. package/lib/commands/affected-classify.js +66 -9
  9. package/lib/commands/affected-script-refs.d.ts +20 -0
  10. package/lib/commands/affected-script-refs.js +218 -0
  11. package/lib/commands/affected.js +24 -2
  12. package/lib/commands/env.d.ts +31 -0
  13. package/lib/commands/env.js +114 -0
  14. package/lib/commands/exec.d.ts +10 -0
  15. package/lib/commands/exec.js +208 -0
  16. package/lib/commands/flatpak/diff.js +8 -2
  17. package/lib/commands/flatpak/init.js +4 -1
  18. package/lib/commands/flatpak/sources.js +4 -3
  19. package/lib/commands/flatpak/sync-flathub.d.ts +2 -0
  20. package/lib/commands/flatpak/sync-flathub.js +149 -7
  21. package/lib/commands/flatpak/utils.d.ts +10 -0
  22. package/lib/commands/flatpak/utils.js +10 -0
  23. package/lib/commands/foreach.js +5 -97
  24. package/lib/commands/format.js +26 -6
  25. package/lib/commands/index.d.ts +4 -0
  26. package/lib/commands/index.js +4 -0
  27. package/lib/commands/install.d.ts +2 -2
  28. package/lib/commands/install.js +81 -5
  29. package/lib/commands/link.d.ts +13 -0
  30. package/lib/commands/link.js +243 -0
  31. package/lib/commands/onboard.js +18 -2
  32. package/lib/commands/publish.d.ts +34 -0
  33. package/lib/commands/publish.js +153 -8
  34. package/lib/commands/self-update.js +2 -1
  35. package/lib/commands/ship.js +19 -1
  36. package/lib/commands/test.d.ts +18 -9
  37. package/lib/commands/test.js +56 -11
  38. package/lib/commands/tsc.js +4 -4
  39. package/lib/commands/upgrade.d.ts +15 -0
  40. package/lib/commands/upgrade.js +76 -2
  41. package/lib/commands/webext/index.d.ts +2 -0
  42. package/lib/commands/webext/index.js +15 -0
  43. package/lib/commands/webext/subcommands.d.ts +16 -0
  44. package/lib/commands/webext/subcommands.js +204 -0
  45. package/lib/types/config-data.d.ts +2 -2
  46. package/lib/utils/bin-shim.d.ts +8 -2
  47. package/lib/utils/bin-shim.js +6 -5
  48. package/lib/utils/bundle-inputs.d.ts +84 -0
  49. package/lib/utils/bundle-inputs.js +268 -0
  50. package/lib/utils/check-system-deps.d.ts +5 -19
  51. package/lib/utils/check-system-deps.js +31 -94
  52. package/lib/utils/dev-link.d.ts +225 -0
  53. package/lib/utils/dev-link.js +684 -0
  54. package/lib/utils/exec-bin.d.ts +137 -0
  55. package/lib/utils/exec-bin.js +321 -0
  56. package/lib/utils/gi-runtime-paths.js +3 -2
  57. package/lib/utils/gitignore.d.ts +15 -0
  58. package/lib/utils/gitignore.js +178 -0
  59. package/lib/utils/gjsify-shim.d.ts +47 -0
  60. package/lib/utils/gjsify-shim.js +97 -8
  61. package/lib/utils/hang-watchdog.d.ts +95 -0
  62. package/lib/utils/hang-watchdog.js +194 -0
  63. package/lib/utils/install-backend-native.d.ts +33 -1
  64. package/lib/utils/install-backend-native.js +184 -20
  65. package/lib/utils/install-extraneous.d.ts +90 -0
  66. package/lib/utils/install-extraneous.js +120 -0
  67. package/lib/utils/install-lock.js +4 -25
  68. package/lib/utils/install-tarball-cache.d.ts +37 -9
  69. package/lib/utils/install-tarball-cache.js +59 -14
  70. package/lib/utils/native-skip-scan.d.ts +20 -0
  71. package/lib/utils/native-skip-scan.js +343 -0
  72. package/lib/utils/node-script.js +1 -1
  73. package/lib/utils/normalize-bundler-options.js +11 -0
  74. package/lib/utils/oxc-resolve.js +42 -3
  75. package/lib/utils/process-table.d.ts +22 -0
  76. package/lib/utils/process-table.js +188 -0
  77. package/lib/utils/prune-prefix.js +1 -1
  78. package/lib/utils/publish-pins.d.ts +38 -0
  79. package/lib/utils/publish-pins.js +47 -0
  80. package/lib/utils/publish-readback.d.ts +103 -17
  81. package/lib/utils/publish-readback.js +326 -47
  82. package/lib/utils/run-gjs.d.ts +15 -1
  83. package/lib/utils/run-gjs.js +98 -13
  84. package/lib/utils/run-node.d.ts +2 -0
  85. package/lib/utils/run-node.js +2 -1
  86. package/lib/utils/runtimes.js +14 -13
  87. package/lib/utils/ship/gzip.js +1 -1
  88. package/lib/utils/ship/icns.d.ts +14 -0
  89. package/lib/utils/ship/icns.js +83 -0
  90. package/lib/utils/ship/ico.d.ts +25 -0
  91. package/lib/utils/ship/ico.js +120 -0
  92. package/lib/utils/ship/icons.d.ts +197 -0
  93. package/lib/utils/ship/icons.js +399 -0
  94. package/lib/utils/ship/launcher.js +16 -1
  95. package/lib/utils/ship/layout.d.ts +32 -0
  96. package/lib/utils/ship/layout.js +112 -38
  97. package/lib/utils/ship/msi.d.ts +1 -1
  98. package/lib/utils/ship/msi.js +30 -5
  99. package/lib/utils/ship/pe-launcher.d.ts +18 -0
  100. package/lib/utils/ship/pe-launcher.js +125 -4
  101. package/lib/utils/ship/plan.d.ts +1 -34
  102. package/lib/utils/ship/plan.js +7 -56
  103. package/lib/utils/ship/plist.d.ts +20 -10
  104. package/lib/utils/ship/plist.js +29 -12
  105. package/lib/utils/ship/settings.js +3 -2
  106. package/lib/utils/spawn.d.ts +13 -0
  107. package/lib/utils/spawn.js +10 -5
  108. package/lib/utils/watch-loop.d.ts +15 -1
  109. package/lib/utils/watch-loop.js +9 -5
  110. package/lib/utils/webext/build.d.ts +42 -0
  111. package/lib/utils/webext/build.js +120 -0
  112. package/lib/utils/webext/config.d.ts +72 -0
  113. package/lib/utils/webext/config.js +234 -0
  114. package/lib/utils/webext/icons.d.ts +24 -0
  115. package/lib/utils/webext/icons.js +83 -0
  116. package/lib/utils/webext/launch.d.ts +32 -0
  117. package/lib/utils/webext/launch.js +77 -0
  118. package/lib/utils/webext/manifest.d.ts +53 -0
  119. package/lib/utils/webext/manifest.js +219 -0
  120. package/lib/utils/webext/pages.d.ts +26 -0
  121. package/lib/utils/webext/pages.js +61 -0
  122. package/lib/utils/webext/targets.d.ts +13 -0
  123. package/lib/utils/webext/targets.js +36 -0
  124. package/lib/utils/win32-command.d.ts +17 -0
  125. package/lib/utils/win32-command.js +21 -6
  126. package/package.json +20 -20
@@ -21,8 +21,10 @@
21
21
  // the data tree is addressed by `appId`. Nothing here is derived from gjsify's own
22
22
  // tree, and nothing here has a gjsify-specific default.
23
23
  import { posix } from 'node:path';
24
+ import { buildIcns, ICNS_SIZES } from './icns.js';
25
+ import { ICO_SIZES, iconResources } from './ico.js';
24
26
  import { buildGuiLauncher } from './pe-launcher.js';
25
- import { BUNDLE_INFO_PLIST, BUNDLE_PKGINFO, renderInfoPlist, renderPkgInfo } from './plist.js';
27
+ import { BUNDLE_INFO_PLIST, BUNDLE_PKGINFO, BUNDLE_RESOURCES, renderInfoPlist, renderPkgInfo } from './plist.js';
26
28
  import { SHARE } from './share-dirs.js';
27
29
  /**
28
30
  * Characters a `<App>.app` directory name may not contain.
@@ -63,6 +65,27 @@ function appBundleDir(identity) {
63
65
  }
64
66
  return `${identity.name}.app`;
65
67
  }
68
+ /**
69
+ * The rasters a row writes its icon from, or nothing for a CLI — and a refusal
70
+ * for an application that arrives without them.
71
+ *
72
+ * The second of two guards. `commands/ship.ts` renders the icon for every row
73
+ * that declares {@link Layout.icon}; this is the row itself checking it was
74
+ * given one, so a stage assembled through any other path (a test, a future
75
+ * caller) cannot produce an application whose Start-menu entry or Finder icon is
76
+ * the generic one without saying so. That silence is the measured defect the
77
+ * whole seam exists against (`icons.ts`).
78
+ */
79
+ function iconFor(input, layout) {
80
+ if (input.kind !== 'app')
81
+ return undefined;
82
+ if (input.icon === undefined) {
83
+ throw new Error(`gjsify ship: the ${layout} layout writes the application's icon and was given none. ` +
84
+ 'A `kind: "app"` stage is rendered its icon by `resolveAppIcon` before the layout runs; ' +
85
+ 'reaching this row without one is a caller that skipped that step.');
86
+ }
87
+ return input.icon;
88
+ }
66
89
  export const LAYOUTS = {
67
90
  linux: {
68
91
  name: 'linux',
@@ -102,15 +125,15 @@ export const LAYOUTS = {
102
125
  'macOS ships no Node, and there is no RELOCATABLE GJS (`build-gtk-runtime-darwin.mjs`: "GJS ' +
103
126
  'ships no relocation"), so ADR 0024 § 4 derives Node here. A self-contained bundle needs `@gjsify/node-runtime-darwin-<arch>` and a ' +
104
127
  '`--app node` payload (#1354 M0), or `@gjsify/gjs-runtime-darwin-<arch>` (ADR 0024 stage 7).',
105
- // THE ONE LAYOUT WHERE THE ENV VARIABLE IS NOT THE MECHANISM, and assuming it
106
- // was is the mistake this comment exists to stop being made again. macOS Pango
107
- // is CORETEXT, not fontconfig: GTK's own `meson.build` carries
108
- // `fontconfig_dep = [] # only used in x11 backend` and Homebrew's gtk4 formula
109
- // builds `-Dx11-backend=false -Dmacos-backend=true`, while cairo's `quartz`
128
+ // THE LAYOUT WHERE THE ENV VARIABLE IS NOT THE MECHANISM BY DEFAULT, and assuming
129
+ // it was is the mistake this comment exists to stop being made again. macOS Pango
130
+ // resolves CORETEXT unless something chooses otherwise: GTK's own `meson.build`
131
+ // carries `fontconfig_dep = [] # only used in x11 backend` and Homebrew's gtk4
132
+ // formula builds `-Dx11-backend=false -Dmacos-backend=true`, while cairo's `quartz`
110
133
  // option defaults to `auto` and auto-enables on darwin — so a `fonts.conf`
111
- // inside a `.app` would be inert twice over, wrong Pango backend AND no
134
+ // inside a `.app` is inert twice over there, wrong Pango backend AND no
112
135
  // GTK-side fontconfig. `XDG_DATA_DIRS` still carries the icons and the schemas
113
- // here; it does not carry the faces.
136
+ // in that case; it does not carry the faces.
114
137
  //
115
138
  // What does is `ATSApplicationFontsPath`, emitted into `Info.plist` by
116
139
  // `metadata` above — Apple's own per-app activation, and the ORDERING is why it
@@ -120,14 +143,25 @@ export const LAYOUTS = {
120
143
  // path in `pangocoretext-fontmap.c`, so a face registered after the font map
121
144
  // initialises is not recoverable by poking it. The system activates this key's
122
145
  // directory at LAUNCH, before any of the app's code runs.
146
+ //
147
+ // WHAT CHOOSES OTHERWISE is `@gjsify/node-gi`'s loader, which sets
148
+ // `PANGOCAIRO_BACKEND=fc` for the bundled windowing runtime (ADR 0038 § Amendment
149
+ // 3) — so a `--app node` `.app` carrying that bundle reads the faces through
150
+ // fontconfig and registers them like the other two layouts. The declarative route
151
+ // stays emitted all the same: it is what a CoreText map still needs, and nothing
152
+ // here can know which runtime a given `.app` ends up carrying. Both routes named,
153
+ // neither assumed — which is also why `fontGap` prints the probe that answers it
154
+ // for a particular build rather than a claim about the platform.
123
155
  fontGap: 'the faces are staged in `share/fonts/<appId>` and `Info.plist` carries `ATSApplicationFontsPath` ' +
124
- "at it, which is macOS's own per-app activation — NOT the XDG_DATA_DIRS path, because Pango on " +
125
- 'macOS is CoreText-backed and GTK is not built against fontconfig there. That the activation ' +
126
- "reaches Pango's CoreText font map is UNVERIFIED: no leg in this repository runs a `.app`. " +
127
- 'Confirm with `PangoCairo.FontMap.get_default().list_families()` in the shipped bundle, and ' +
128
- '`PANGOCAIRO_BACKEND=bogus` to print which backend it was built with. An app that calls ' +
129
- '`initFonts()` from `@gjsify/gtk-host/fonts` for the Windows row needs no branch around it: the ' +
130
- 'CoreText map answers G_IO_ERROR_NOT_SUPPORTED and that call deliberately does nothing here.',
156
+ "at it, which is macOS's own per-app activation and the route that matters whenever Pango on " +
157
+ 'macOS resolves CoreText by default — which it does on a Homebrew GTK, because that one is not ' +
158
+ 'built against fontconfig. A `.app` carrying the bundled GTK runtime reads the same faces through ' +
159
+ "fontconfig instead: `@gjsify/node-gi`'s loader selects it with `PANGOCAIRO_BACKEND=fc` " +
160
+ '(ADR 0038 § Amendment 3), and `initFonts()` from `@gjsify/gtk-host/fonts` registers them as it ' +
161
+ 'does on Windows. Either way no branch is needed in your app: a CoreText map answers ' +
162
+ 'G_IO_ERROR_NOT_SUPPORTED and the call reports that as declined. Confirm in the shipped bundle ' +
163
+ 'with `PangoCairo.FontMap.get_default().list_families()`, and `PANGOCAIRO_BACKEND=bogus` to ' +
164
+ 'print which backends it was built with.',
131
165
  launcherExt: '',
132
166
  root: appBundleDir,
133
167
  // Apple's, all four. `Contents/MacOS` holds executables, `Contents/Resources`
@@ -148,18 +182,40 @@ export const LAYOUTS = {
148
182
  // nothing tells LaunchServices which file under `Contents/MacOS` to exec,
149
183
  // and a `*.app` with no `Info.plist` is a folder with a suffix — which is
150
184
  // exactly what M1 staged. `plist.ts` carries the per-key citations.
151
- metadata: (input, payload) => [
152
- {
153
- path: `${appBundleDir(input)}/${BUNDLE_INFO_PLIST}`,
154
- mode: 0o644,
155
- source: { kind: 'text', text: renderInfoPlist(input, darwinFontsPath(input, payload)) },
156
- },
157
- {
158
- path: `${appBundleDir(input)}/${BUNDLE_PKGINFO}`,
159
- mode: 0o644,
160
- source: { kind: 'text', text: renderPkgInfo() },
161
- },
162
- ],
185
+ //
186
+ // AND THE ICON, the third file — `Contents/Resources/<binaryName>.icns`,
187
+ // named by `CFBundleIconFile`. Both halves or neither: the key without the
188
+ // file is a dangling reference the Finder resolves to the generic icon, and
189
+ // the file without the key is bytes nothing reads. Measured absent on the
190
+ // released 0.8.0 bundle of one app (macOS 15.7, 2026-09-13): no key, no
191
+ // `.icns`, generic icon — the twin of the Windows defect `pe-launcher.ts`
192
+ // records.
193
+ icon: { sizes: ICNS_SIZES, writes: 'the macOS bundle icon (`Contents/Resources/<binaryName>.icns`)' },
194
+ metadata: (input, payload) => {
195
+ const icon = iconFor(input, 'darwin');
196
+ const iconFile = icon === undefined ? undefined : `${input.binaryName}.icns`;
197
+ return [
198
+ {
199
+ path: `${appBundleDir(input)}/${BUNDLE_INFO_PLIST}`,
200
+ mode: 0o644,
201
+ source: { kind: 'text', text: renderInfoPlist(input, darwinFontsPath(input, payload), iconFile) },
202
+ },
203
+ {
204
+ path: `${appBundleDir(input)}/${BUNDLE_PKGINFO}`,
205
+ mode: 0o644,
206
+ source: { kind: 'text', text: renderPkgInfo() },
207
+ },
208
+ ...(icon === undefined || iconFile === undefined
209
+ ? []
210
+ : [
211
+ {
212
+ path: `${appBundleDir(input)}/${BUNDLE_RESOURCES}/${iconFile}`,
213
+ mode: 0o644,
214
+ source: { kind: 'bytes', data: buildIcns(icon) },
215
+ },
216
+ ]),
217
+ ];
218
+ },
163
219
  // The two architectures macOS runs on, which is also exactly what
164
220
  // `@gjsify/gtk-runtime-darwin-*` and `@gjsify/node-runtime-darwin-*` are
165
221
  // published for. Nothing upstream blocks a third; there is no third.
@@ -227,8 +283,10 @@ export const LAYOUTS = {
227
283
  // warning, which is exactly what the probe family did BEFORE the call.
228
284
  fontGap: 'the faces are staged in `share/fonts/<appId>` and the launcher exports GJSIFY_FONT_DIR at it, ' +
229
285
  'but WINDOWS HAS NO DECLARATIVE FONT ACTIVATION and nothing here reaches a bundled face on its ' +
230
- 'own: GTK4 uses pangowin32, whose font map is populated from DirectWrite and never from ' +
231
- 'fontconfig or from GDI. MEASURED on Windows 11 / GTK 4.22.4 rather than inferred (ADR 0038 ' +
286
+ 'own: GTK4 resolves pangowin32, whose font map is populated from DirectWrite and never from ' +
287
+ 'fontconfig or from GDI, unless something selects otherwise — which the bundled GTK runtime does ' +
288
+ '(`PANGOCAIRO_BACKEND=fc`, ADR 0038 § Amendment 3), and an app may. MEASURED on the DirectWrite ' +
289
+ 'map, Windows 11 / GTK 4.22.4, rather than inferred (ADR 0038 ' +
232
290
  'W1-W5): a FONTCONFIG_FILE naming the staged directory moves the default font map by nothing ' +
233
291
  'even when it is the ONLY configuration present — that map does not read fontconfig, and exposes ' +
234
292
  'no config_changed or set_config with which to make it — while add_font_file on the same map a ' +
@@ -272,15 +330,31 @@ export const LAYOUTS = {
272
330
  // refusal, about our own file). `arches.only` already says there is no
273
331
  // other Windows architecture; a stage for one stays assemblable and stays
274
332
  // unpackable, which is the split `Layout.runtimeGap` draws everywhere else.
275
- metadata: (input) => input.arch === 'x64'
276
- ? [
277
- {
278
- path: windowsGuiLauncherPath(input),
279
- mode: 0o755,
280
- source: { kind: 'bytes', data: buildGuiLauncher({ logLeaf: windowsLaunchLogLeaf(input) }) },
281
- },
282
- ]
283
- : [],
333
+ //
334
+ // THE ICON RIDES INSIDE THAT PROGRAM. A shortcut with no icon of its own
335
+ // shows its target's, and Explorer, the Start menu and the taskbar all read
336
+ // the target's out of its resource directory — so the `.exe` carries the
337
+ // six sizes as `RT_ICON`/`RT_GROUP_ICON` (`ico.ts`, `pe-launcher.ts`) and
338
+ // there is no `.ico` file in the program directory to fall out of sync.
339
+ icon: { sizes: ICO_SIZES, writes: 'the Windows launcher icon (`.rsrc` of `<binaryName>.exe`)' },
340
+ metadata: (input) => {
341
+ const icon = iconFor(input, 'windows');
342
+ return input.arch === 'x64'
343
+ ? [
344
+ {
345
+ path: windowsGuiLauncherPath(input),
346
+ mode: 0o755,
347
+ source: {
348
+ kind: 'bytes',
349
+ data: buildGuiLauncher({
350
+ logLeaf: windowsLaunchLogLeaf(input),
351
+ ...(icon === undefined ? {} : { icon: iconResources(icon) }),
352
+ }),
353
+ },
354
+ },
355
+ ]
356
+ : [];
357
+ },
284
358
  // ONE, and the blocker is a project we do not own. `wingtk/gvsbuild`
285
359
  // hardcodes `self.platform = "x64"` in `utils/base_project.py` and its last
286
360
  // five releases publish exactly two assets each, both x64 — so there is no
@@ -60,7 +60,7 @@ export declare function msiProductVersion(version: string): string;
60
60
  * agree still differ. `renderWxs` asserts uniqueness anyway rather than trusting
61
61
  * 48 bits, because the failure of a collision is two files fighting over one row.
62
62
  */
63
- export declare function msiIdentifier(prefix: string, path: string): string;
63
+ export declare function msiIdentifier(prefix: string, path: string, suffix?: string): string;
64
64
  /** One payload file, as the installer will address it. */
65
65
  export interface MsiFile {
66
66
  /** Program-directory-relative path, POSIX-separated. */
@@ -175,11 +175,13 @@ export function msiProductVersion(version) {
175
175
  * agree still differ. `renderWxs` asserts uniqueness anyway rather than trusting
176
176
  * 48 bits, because the failure of a collision is two files fighting over one row.
177
177
  */
178
- export function msiIdentifier(prefix, path) {
178
+ export function msiIdentifier(prefix, path, suffix = '') {
179
179
  const digest = createHash('sha1').update(new TextEncoder().encode(path)).digest('hex').slice(0, 12);
180
180
  const safe = path.replace(/[^A-Za-z0-9_.]/g, '_');
181
- const room = 72 - prefix.length - 2 - digest.length;
182
- return `${prefix}_${safe.length > room ? safe.slice(safe.length - room) : safe}_${digest}`;
181
+ // The suffix is part of the 72, or an `Icon` id (which has to END in `.exe`)
182
+ // built from a long path would be the one identifier over the limit.
183
+ const room = 72 - prefix.length - 2 - digest.length - suffix.length;
184
+ return `${prefix}_${safe.length > room ? safe.slice(safe.length - room) : safe}_${digest}${suffix}`;
183
185
  }
184
186
  /**
185
187
  * What Windows Installer will not hold in a `FileName`/`DefaultDir` cell.
@@ -264,9 +266,27 @@ export function renderWxs(input) {
264
266
  identifiers.set(id, what);
265
267
  return id;
266
268
  };
269
+ // THE ICON IS THE LAUNCHER ITSELF. The Icon table holds a file — an `.ico`,
270
+ // or an `.exe`/`.dll` the installer reads an icon OUT OF by `IconIndex` — and
271
+ // the stub already carries the app's icon in its resource directory
272
+ // (`pe-launcher.ts`). Naming the launcher as the icon source means one file
273
+ // holds the pixels the shortcut, Add/Remove Programs and Explorer all show,
274
+ // and nothing in `ship/msi/` can drift from what is installed.
275
+ //
276
+ // WHY THE MSI NEEDS ITS OWN ROW when the `.exe` already has the icon. A
277
+ // shortcut with no `Icon_` shows its target's icon once the target exists;
278
+ // this one is ADVERTISED, and Windows Installer's Shortcut table documents the
279
+ // `Icon_` column as the icon shown for an advertised shortcut — whose target
280
+ // is a descriptor the shell resolves through the installer, not the file. And
281
+ // `ARPPRODUCTICON` is the only route to an icon in Add/Remove Programs at all;
282
+ // the `.exe` is never consulted there. Two consumers the embedded icon cannot
283
+ // reach, one row. Windows Installer requires the Icon NAME to carry the
284
+ // extension of the file it holds, so the id ends in `.exe`.
285
+ const launcherIconId = claim(msiIdentifier('i', launcher, '.exe'), 'the launcher icon');
267
286
  const lines = [];
268
287
  const componentIds = [];
269
288
  let launcherFound = false;
289
+ let launcherSource;
270
290
  const emitDir = (node, indent) => {
271
291
  for (const file of node.files) {
272
292
  const componentId = claim(msiIdentifier('c', file.path), file.path);
@@ -289,12 +309,13 @@ export function renderWxs(input) {
289
309
  // with it.
290
310
  if (file.path === launcher) {
291
311
  launcherFound = true;
312
+ launcherSource = file.source;
292
313
  lines.push(`${indent} <File Id="${fileId}" Name="${name}" ` +
293
314
  `Source="${xmlEscape(file.source)}" KeyPath="yes">`);
294
315
  lines.push(`${indent} <Shortcut Id="${claim('s_startmenu', 'the Start-Menu shortcut')}" ` +
295
316
  `Directory="ProgramMenuFolder" Name="${xmlEscape(settings.name)}" ` +
296
317
  `WorkingDirectory="INSTALLDIR" Description="${xmlEscape(settings.summary)}" ` +
297
- `Advertise="yes" />`);
318
+ `Icon="${launcherIconId}" IconIndex="0" Advertise="yes" />`);
298
319
  lines.push(`${indent} </File>`);
299
320
  }
300
321
  else {
@@ -317,7 +338,7 @@ export function renderWxs(input) {
317
338
  if (componentIds.length === 0) {
318
339
  throw new Error('gjsify ship: the windows payload is empty, so there is nothing for an `.msi` to install.');
319
340
  }
320
- if (!launcherFound) {
341
+ if (!launcherFound || launcherSource === undefined) {
321
342
  throw new Error(`gjsify ship: the windows payload carries no "${launcher}" at the root of the program directory, ` +
322
343
  'so the installer would have nothing to point a Start-Menu shortcut at — an application a user ' +
323
344
  'installs and\n cannot start. `windows-dir` puts the launcher there; a payload without one ' +
@@ -343,6 +364,10 @@ export function renderWxs(input) {
343
364
  // downloads rather than an `.msi` plus loose `.cab` siblings that a user
344
365
  // will separate from it.
345
366
  ' <Media Id="1" Cabinet="app.cab" EmbedCab="yes" />',
367
+ // The launcher, a second time, as the Icon table's binary — see
368
+ // `launcherIconId` above for why the same file is both.
369
+ ` <Icon Id="${launcherIconId}" SourceFile="${xmlEscape(launcherSource)}" />`,
370
+ ` <Property Id="ARPPRODUCTICON" Value="${launcherIconId}" />`,
346
371
  ...about,
347
372
  ' <Directory Id="TARGETDIR" Name="SourceDir">',
348
373
  ' <Directory Id="ProgramFiles64Folder">',
@@ -1,3 +1,4 @@
1
+ import { type IconResources } from './ico.js';
1
2
  /**
2
3
  * `IMAGE_SUBSYSTEM_WINDOWS_GUI` — the one field this whole file exists to set.
3
4
  *
@@ -17,7 +18,24 @@ export interface GuiLauncherInput {
17
18
  * Files\<App>` is read-only for the user who runs the app.
18
19
  */
19
20
  logLeaf: string;
21
+ /**
22
+ * The application icon as PE resources, or absent for a launcher with none.
23
+ *
24
+ * Absent is what a `kind: 'cli'` project gets; an `app` always has one, and
25
+ * the layout refuses to stage otherwise (`layout.ts`, `LAYOUTS.windows`), so
26
+ * "no `.rsrc`" is never how an application quietly loses its icon again.
27
+ */
28
+ icon?: IconResources;
20
29
  }
30
+ /** `IMAGE_DIRECTORY_ENTRY_RESOURCE` — the data directory slot the `.rsrc` section is named in. */
31
+ export declare const PE_DIRECTORY_RESOURCE = 2;
32
+ /**
33
+ * The language every resource is filed under: `en-US`, 0x0409, which is what
34
+ * `rc.exe` and `windres` emit when a script names none. Icon lookup does not
35
+ * depend on it — `FindResource` falls back through neutral to the first language
36
+ * present — so one language is enough and it is the conventional one.
37
+ */
38
+ export declare const RESOURCE_LANGUAGE = 1033;
21
39
  /**
22
40
  * The GUI-subsystem launcher, as a complete PE32+ image.
23
41
  *
@@ -51,6 +51,22 @@
51
51
  // `objdump -x` (binutils' `pei-x86-64`) and `.github/ship-oracle/verify-program-dir.py`
52
52
  // both parse the emitted file, and `tests/e2e/ship-windows` drives the emitted
53
53
  // image through the same `readBinaryArch` that reads a real `node.exe`.
54
+ //
55
+ // IT CARRIES THE APP'S ICON, AND THAT IS WHY IT IS THE FILE THE SHORTCUT NAMES.
56
+ // Explorer, the Start menu, the taskbar, Alt-Tab and a pinned entry all read an
57
+ // application's icon out of the EXECUTABLE's resource directory; the running
58
+ // window's own icon comes from GTK at runtime and is the one place the icon
59
+ // showed before this section existed. Measured on Windows 11 against Learn6502
60
+ // 0.8.0: taskbar button iconed, Start-menu entry the generic blank document —
61
+ // the shortcut asked this file, and this file had nothing. So the image gets a
62
+ // third section, `.rsrc`, holding the three-level resource tree Windows expects
63
+ // (type → id → language) with one `RT_GROUP_ICON` naming `n` `RT_ICON` entries
64
+ // (`ico.ts` builds both payloads). Three things in it are silent mistakes and
65
+ // are therefore written down: a data entry's `OffsetToData` is an RVA and not a
66
+ // file offset; directory entries must be SORTED ascending by id, because the
67
+ // loader binary-searches them; and the group's entries name resources by id, not
68
+ // by the file offset an `.ico` carries.
69
+ import { RT_GROUP_ICON, RT_ICON } from './ico.js';
54
70
  /**
55
71
  * `IMAGE_SUBSYSTEM_WINDOWS_GUI` — the one field this whole file exists to set.
56
72
  *
@@ -228,6 +244,88 @@ const CREATE_ALWAYS = 2;
228
244
  const FILE_ATTRIBUTE_NORMAL = 0x80;
229
245
  /** Capacities in WCHARs. `cmdBuf` holds the whole assembled command line. */
230
246
  const BUFFERS = { sysBuf: 264, selfBuf: 520, tmpBuf: 264, logBuf: 600, cmdBuf: 4096 };
247
+ /** `IMAGE_DIRECTORY_ENTRY_RESOURCE` — the data directory slot the `.rsrc` section is named in. */
248
+ export const PE_DIRECTORY_RESOURCE = 2;
249
+ /**
250
+ * The language every resource is filed under: `en-US`, 0x0409, which is what
251
+ * `rc.exe` and `windres` emit when a script names none. Icon lookup does not
252
+ * depend on it — `FindResource` falls back through neutral to the first language
253
+ * present — so one language is enough and it is the conventional one.
254
+ */
255
+ export const RESOURCE_LANGUAGE = 0x0409;
256
+ /**
257
+ * The `.rsrc` section: the resource tree, its data entries, and the payloads.
258
+ *
259
+ * ONE FIXED SHAPE, laid out top to bottom so every offset is arithmetic:
260
+ *
261
+ * root directory 2 entries: RT_ICON (3), RT_GROUP_ICON (14) — ascending
262
+ * RT_ICON directory n entries: ids 1..n
263
+ * n language dirs 1 entry each: 0x0409 → a data entry
264
+ * RT_GROUP_ICON dir 1 entry: id 1
265
+ * 1 language dir 1 entry: 0x0409 → a data entry
266
+ * n + 1 data entries OffsetToData is an RVA into this section's payloads
267
+ * payloads the n PNGs, then the group, each 8-aligned
268
+ *
269
+ * `sectionRva` is what turns a payload's position into the RVA the data entry
270
+ * needs; every other offset in the tree is relative to the section start, with
271
+ * the high bit set on an entry that points at a subdirectory. Both conventions
272
+ * are the format's and both are exercised by `verify-program-dir.py`, which walks
273
+ * this tree with CPython and reassembles the `.ico`.
274
+ */
275
+ function buildResourceSection(icon, sectionRva) {
276
+ const n = icon.icons.length;
277
+ const DIRECTORY = 16;
278
+ const ENTRY = 8;
279
+ const DATA_ENTRY = 16;
280
+ const SUBDIRECTORY = 0x80000000;
281
+ const rootAt = 0;
282
+ const iconDirAt = rootAt + DIRECTORY + 2 * ENTRY;
283
+ const iconLangAt = (index) => iconDirAt + DIRECTORY + n * ENTRY + index * (DIRECTORY + ENTRY);
284
+ const groupDirAt = iconLangAt(n);
285
+ const groupLangAt = groupDirAt + DIRECTORY + ENTRY;
286
+ const dataEntriesAt = groupLangAt + DIRECTORY + ENTRY;
287
+ const dataEntryAt = (index) => dataEntriesAt + index * DATA_ENTRY;
288
+ let payloadAt = align(dataEntryAt(n + 1), 8);
289
+ const payloads = [];
290
+ for (const bytes of [...icon.icons, icon.group]) {
291
+ payloads.push({ at: payloadAt, bytes });
292
+ payloadAt = align(payloadAt + bytes.length, 8);
293
+ }
294
+ const section = Buffer.alloc(payloadAt);
295
+ const directory = (at, count) => {
296
+ // Characteristics, TimeDateStamp, Major/MinorVersion: all zero; then the
297
+ // named-entry count (none here) and the id-entry count.
298
+ section.writeUInt16LE(0, at + 12);
299
+ section.writeUInt16LE(count, at + 14);
300
+ };
301
+ const entry = (at, id, target, subdirectory) => {
302
+ section.writeUInt32LE(id, at);
303
+ section.writeUInt32LE(subdirectory ? (SUBDIRECTORY | target) >>> 0 : target, at + 4);
304
+ };
305
+ const dataEntry = (index, payload) => {
306
+ section.writeUInt32LE(sectionRva + payload.at, dataEntryAt(index)); // an RVA, not a file offset
307
+ section.writeUInt32LE(payload.bytes.length, dataEntryAt(index) + 4);
308
+ // CodePage and Reserved stay zero.
309
+ };
310
+ directory(rootAt, 2);
311
+ entry(rootAt + DIRECTORY, RT_ICON, iconDirAt, true);
312
+ entry(rootAt + DIRECTORY + ENTRY, RT_GROUP_ICON, groupDirAt, true);
313
+ directory(iconDirAt, n);
314
+ for (let index = 0; index < n; index++) {
315
+ entry(iconDirAt + DIRECTORY + index * ENTRY, index + 1, iconLangAt(index), true);
316
+ directory(iconLangAt(index), 1);
317
+ entry(iconLangAt(index) + DIRECTORY, RESOURCE_LANGUAGE, dataEntryAt(index), false);
318
+ dataEntry(index, payloads[index]);
319
+ }
320
+ directory(groupDirAt, 1);
321
+ entry(groupDirAt + DIRECTORY, 1, groupLangAt, true);
322
+ directory(groupLangAt, 1);
323
+ entry(groupLangAt + DIRECTORY, RESOURCE_LANGUAGE, dataEntryAt(n), false);
324
+ dataEntry(n, payloads[n]);
325
+ for (const payload of payloads)
326
+ section.set(payload.bytes, payload.at);
327
+ return new Uint8Array(section);
328
+ }
231
329
  /**
232
330
  * The stub's instruction stream.
233
331
  *
@@ -601,7 +699,18 @@ export function buildGuiLauncher(input) {
601
699
  const headerSize = FILE_ALIGNMENT;
602
700
  const textOffset = headerSize;
603
701
  const dataOffset = textOffset + textRaw;
604
- const image = Buffer.alloc(dataOffset + dataRaw);
702
+ // The icon's section, APPENDED after `.data` so no existing RVA or file
703
+ // offset moves — inserting it would mean re-resolving every RIP-relative
704
+ // fixup for a file the code never touches.
705
+ const rsrcRva = dataRva + align(dataSize, SECTION_ALIGNMENT);
706
+ const rsrc = input.icon === undefined ? undefined : buildResourceSection(input.icon, rsrcRva);
707
+ const rsrcRaw = rsrc === undefined ? 0 : align(rsrc.length, FILE_ALIGNMENT);
708
+ const rsrcOffset = dataOffset + dataRaw;
709
+ const sectionCount = rsrc === undefined ? 2 : 3;
710
+ const lastRva = rsrc === undefined
711
+ ? dataRva + align(dataSize, SECTION_ALIGNMENT)
712
+ : rsrcRva + align(rsrc.length, SECTION_ALIGNMENT);
713
+ const image = Buffer.alloc(rsrcOffset + rsrcRaw);
605
714
  // ── DOS header ───────────────────────────────────────────────────────────
606
715
  image.write('MZ', 0, 'ascii');
607
716
  image.write('This program cannot be run in DOS mode.\r\r\n$', 0x40, 'ascii');
@@ -610,7 +719,7 @@ export function buildGuiLauncher(input) {
610
719
  image.write('PE\0\0', PE_OFFSET, 'ascii');
611
720
  const coff = PE_OFFSET + 4;
612
721
  image.writeUInt16LE(MACHINE_AMD64, coff);
613
- image.writeUInt16LE(2, coff + 2); // NumberOfSections
722
+ image.writeUInt16LE(sectionCount, coff + 2); // NumberOfSections
614
723
  image.writeUInt32LE(0, coff + 4); // TimeDateStamp — zero, so the image is reproducible
615
724
  image.writeUInt16LE(240, coff + 16); // SizeOfOptionalHeader (112 + 16 * 8)
616
725
  // EXECUTABLE_IMAGE | LARGE_ADDRESS_AWARE | RELOCS_STRIPPED. The stub is
@@ -622,7 +731,7 @@ export function buildGuiLauncher(input) {
622
731
  image.writeUInt16LE(0x20b, opt); // PE32+
623
732
  image.writeUInt8(14, opt + 2); // MajorLinkerVersion — cosmetic
624
733
  image.writeUInt32LE(textRaw, opt + 4); // SizeOfCode
625
- image.writeUInt32LE(dataRaw, opt + 8); // SizeOfInitializedData
734
+ image.writeUInt32LE(dataRaw + rsrcRaw, opt + 8); // SizeOfInitializedData — `.data` and `.rsrc` are both
626
735
  image.writeUInt32LE(textRva, opt + 16); // AddressOfEntryPoint
627
736
  image.writeUInt32LE(textRva, opt + 20); // BaseOfCode
628
737
  image.writeBigUInt64LE(IMAGE_BASE, opt + 24);
@@ -630,7 +739,7 @@ export function buildGuiLauncher(input) {
630
739
  image.writeUInt32LE(FILE_ALIGNMENT, opt + 36);
631
740
  image.writeUInt16LE(6, opt + 40); // MajorOperatingSystemVersion
632
741
  image.writeUInt16LE(6, opt + 48); // MajorSubsystemVersion
633
- image.writeUInt32LE(dataRva + align(dataSize, SECTION_ALIGNMENT), opt + 56); // SizeOfImage
742
+ image.writeUInt32LE(lastRva, opt + 56); // SizeOfImage — through the last section, whichever that is
634
743
  image.writeUInt32LE(headerSize, opt + 60); // SizeOfHeaders
635
744
  image.writeUInt16LE(PE_SUBSYSTEM_GUI, opt + 68);
636
745
  image.writeUInt16LE(0x0100 | 0x8000, opt + 70); // NX_COMPAT | TERMINAL_SERVER_AWARE
@@ -644,6 +753,12 @@ export function buildGuiLauncher(input) {
644
753
  image.writeUInt32LE(40, directories + 1 * 8 + 4);
645
754
  image.writeUInt32LE(rvaOf('iat.GetModuleFileNameW'), directories + 12 * 8); // IAT
646
755
  image.writeUInt32LE((IMPORTS.length + 1) * 8, directories + 12 * 8 + 4);
756
+ if (rsrc !== undefined) {
757
+ // RESOURCE: the whole section, RVA and byte size. Without this slot the
758
+ // section is bytes the loader maps and the shell never looks at.
759
+ image.writeUInt32LE(rsrcRva, directories + PE_DIRECTORY_RESOURCE * 8);
760
+ image.writeUInt32LE(rsrc.length, directories + PE_DIRECTORY_RESOURCE * 8 + 4);
761
+ }
647
762
  // ── section table ────────────────────────────────────────────────────────
648
763
  const sections = directories + 16 * 8;
649
764
  const writeSection = (index, name, rva, virtualSize, raw, rawSize, characteristics) => {
@@ -657,7 +772,13 @@ export function buildGuiLauncher(input) {
657
772
  };
658
773
  writeSection(0, '.text', textRva, codeSize, textOffset, textRaw, 0x60000020);
659
774
  writeSection(1, '.data', dataRva, dataSize, dataOffset, dataRaw, 0xc0000040);
775
+ // INITIALIZED_DATA | MEM_READ and nothing else: resources are read by the
776
+ // shell and the loader, never written and never executed.
777
+ if (rsrc !== undefined)
778
+ writeSection(2, '.rsrc', rsrcRva, rsrc.length, rsrcOffset, rsrcRaw, 0x40000040);
660
779
  image.set(text, textOffset);
661
780
  image.set(data, dataOffset);
781
+ if (rsrc !== undefined)
782
+ image.set(rsrc, rsrcOffset);
662
783
  return new Uint8Array(image);
663
784
  }
@@ -66,40 +66,7 @@ export declare function planOverlay(settings: ShipSettings, format: FormatDescri
66
66
  * version can no longer reach it.
67
67
  */
68
68
  export declare function assertOverlayIsLicensed(formatId: FormatId, binaryName: string, overlay: readonly StagedFile[]): void;
69
- /**
70
- * Whether an icon belongs in the theme's SYMBOLIC context rather than at a size.
71
- *
72
- * `symbolic` is a directory of its own in a hicolor theme and NOT a size value.
73
- * Measured in `refs/adwaita-icon-theme/index.theme`, which lists `symbolic/apps`
74
- * in `Directories=` beside `scalable/apps` and `16x16/apps` and gives it its own
75
- * section — `Context=Applications`, `Size=16`, `MinSize=8`, `MaxSize=512`,
76
- * `Type=Scalable` — against the scalable row's `Size=128`. The freedesktop icon
77
- * theme specification has no notion of `symbolic` at all: a theme expresses it by
78
- * giving the directory a section, which is exactly why reading the EXTENSION
79
- * cannot see it, and why this question has to be asked before the size one.
80
- *
81
- * TWO SIGNALS, the same pair `iconThemeDir` already reads for a size: the
82
- * directory an author put the file in, and the name they gave it. GTK resolves a
83
- * symbolic icon by the `-symbolic` name suffix, so an author who wrote the name
84
- * has said as much as one who wrote the path.
85
- *
86
- * SVG ONLY, because the symbolic directory is declared `Type=Scalable`: the
87
- * raster form GTK's own `gtk-encode-symbolic-svg` produces
88
- * (`<name>-symbolic.symbolic.png`) is installed at a SIZE instead. So a PNG keeps
89
- * answering the size question, and one that cannot answer it stays refused rather
90
- * than quietly becoming a scalable icon that does not scale.
91
- */
92
- export declare function isSymbolicIcon(iconPath: string): boolean;
93
- /**
94
- * The hicolor subdirectory for an icon: the `symbolic` CONTEXT, else `scalable`
95
- * for an SVG, else the pixel size read from a `<n>x<n>` path component or a
96
- * trailing number in the filename.
97
- *
98
- * Named for the theme DIRECTORY rather than a size from the moment the first of
99
- * those answers stopped being one: `iconSizeDir` returning `symbolic` would be a
100
- * name every caller has to read past.
101
- */
102
- export declare function iconThemeDir(iconPath: string): string;
69
+ export { iconThemeDir, isSymbolicIcon } from './icons.js';
103
70
  /**
104
71
  * A native module inside the payload has to keep its executable bit.
105
72
  *
@@ -4,6 +4,7 @@
4
4
  // its own inputs can only be tested by building a real project.
5
5
  import { renderDebianChangelog } from './changelog.js';
6
6
  import { gzipDeterministic, POLICY_MAX_COMPRESSION } from './gzip.js';
7
+ import { iconThemeDir, isSymbolicIcon } from './icons.js';
7
8
  import { renderMimePackage } from './mime.js';
8
9
  import { SHARE } from './share-dirs.js';
9
10
  import { basename, extname, posix } from 'node:path';
@@ -267,62 +268,12 @@ function planIcons(settings) {
267
268
  }
268
269
  return out;
269
270
  }
270
- /**
271
- * Whether an icon belongs in the theme's SYMBOLIC context rather than at a size.
272
- *
273
- * `symbolic` is a directory of its own in a hicolor theme and NOT a size value.
274
- * Measured in `refs/adwaita-icon-theme/index.theme`, which lists `symbolic/apps`
275
- * in `Directories=` beside `scalable/apps` and `16x16/apps` and gives it its own
276
- * section — `Context=Applications`, `Size=16`, `MinSize=8`, `MaxSize=512`,
277
- * `Type=Scalable` — against the scalable row's `Size=128`. The freedesktop icon
278
- * theme specification has no notion of `symbolic` at all: a theme expresses it by
279
- * giving the directory a section, which is exactly why reading the EXTENSION
280
- * cannot see it, and why this question has to be asked before the size one.
281
- *
282
- * TWO SIGNALS, the same pair `iconThemeDir` already reads for a size: the
283
- * directory an author put the file in, and the name they gave it. GTK resolves a
284
- * symbolic icon by the `-symbolic` name suffix, so an author who wrote the name
285
- * has said as much as one who wrote the path.
286
- *
287
- * SVG ONLY, because the symbolic directory is declared `Type=Scalable`: the
288
- * raster form GTK's own `gtk-encode-symbolic-svg` produces
289
- * (`<name>-symbolic.symbolic.png`) is installed at a SIZE instead. So a PNG keeps
290
- * answering the size question, and one that cannot answer it stays refused rather
291
- * than quietly becoming a scalable icon that does not scale.
292
- */
293
- export function isSymbolicIcon(iconPath) {
294
- if (extname(iconPath).toLowerCase() !== '.svg')
295
- return false;
296
- if (/(?:^|[\\/])symbolic[\\/]/.test(iconPath))
297
- return true;
298
- return basename(iconPath, extname(iconPath)).endsWith('-symbolic');
299
- }
300
- /**
301
- * The hicolor subdirectory for an icon: the `symbolic` CONTEXT, else `scalable`
302
- * for an SVG, else the pixel size read from a `<n>x<n>` path component or a
303
- * trailing number in the filename.
304
- *
305
- * Named for the theme DIRECTORY rather than a size from the moment the first of
306
- * those answers stopped being one: `iconSizeDir` returning `symbolic` would be a
307
- * name every caller has to read past.
308
- */
309
- export function iconThemeDir(iconPath) {
310
- if (isSymbolicIcon(iconPath))
311
- return 'symbolic';
312
- if (extname(iconPath).toLowerCase() === '.svg')
313
- return 'scalable';
314
- const square = /(?:^|[\\/])(\d{1,4})x\1(?:[\\/]|$)/.exec(iconPath);
315
- if (square)
316
- return `${square[1]}x${square[1]}`;
317
- const tokens = basename(iconPath, extname(iconPath)).split(/[-_.]/);
318
- for (let i = tokens.length - 1; i >= 0; i--) {
319
- const token = tokens[i];
320
- if (token !== undefined && /^\d{1,4}$/.test(token))
321
- return `${token}x${token}`;
322
- }
323
- throw new Error(`gjsify ship: cannot tell what size ${iconPath} is. ` +
324
- 'Put it in a `<size>x<size>/` directory, end its name with the size (`icon-128.png`), or ship an SVG.');
325
- }
271
+ // The two icon questions the plan asks — symbolic or sized, and which theme
272
+ // directory — are answered by `icons.ts` since the icon became a CONVERTED
273
+ // artifact on two of the three layouts, and re-exported here so the plan's
274
+ // readers keep one import. One definition: a copy here would be the one that
275
+ // stages `symbolic/` under a size the day the other changes.
276
+ export { iconThemeDir, isSymbolicIcon } from './icons.js';
326
277
  /** A staged path must stay under the prefix — no absolute paths, no `..`. */
327
278
  function assertInsidePrefix(dest) {
328
279
  const normalised = posix.normalize(dest);