@gjsify/cli 0.45.0 → 0.46.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.
@@ -93,11 +93,46 @@ export function planStage(settings, inputs) {
93
93
  source: { kind: 'file', path: locale.abs },
94
94
  });
95
95
  }
96
+ for (const font of planFonts(settings))
97
+ files.push(font);
96
98
  for (const [dest, source] of Object.entries(settings.extraFiles)) {
97
99
  files.push({ path: assertInsidePrefix(dest), mode: 0o644, source: { kind: 'file', path: source } });
98
100
  }
99
101
  return normalise(files);
100
102
  }
103
+ /**
104
+ * Font faces, under a directory named after the app id (ADR 0038).
105
+ *
106
+ * `share/fonts` is shared with every other package on a `/usr` prefix, exactly as
107
+ * `share/glib-2.0/schemas` and `share/mime/packages` are — so the app id is what
108
+ * keeps a face called `Regular.ttf` from being one of two files claiming the same
109
+ * path, with install order deciding the winner. Unlike those two the collision is
110
+ * not refused by NAME: a font's filename is the foundry's and there is no
111
+ * convention to hold it to, so the directory carries the id and the basenames are
112
+ * left alone. fontconfig scans recursively, so nesting them costs nothing —
113
+ * measured across fontconfig 2.14.1 → 2.18.3.
114
+ *
115
+ * A basename appearing twice IS refused, for the reason `planIcons` refuses one:
116
+ * two source files installing as one path means one of them is silently not
117
+ * shipped, and a missing face is a substituted typeface rather than an error.
118
+ */
119
+ function planFonts(settings) {
120
+ const out = [];
121
+ const seen = new Map();
122
+ for (const font of settings.fontFiles) {
123
+ const path = `${SHARE.fonts}/${settings.appId}/${basename(font)}`;
124
+ const previous = seen.get(path);
125
+ if (previous !== undefined) {
126
+ throw new Error(`gjsify ship: ${font} and ${previous} both install as ${path}. ` +
127
+ 'Two faces cannot share one filename — one would silently not ship, and a missing face ' +
128
+ 'is a substituted typeface rather than an error. Rename one, or point ' +
129
+ '`gjsify.ship.fonts` at a directory holding only the faces you mean to ship.');
130
+ }
131
+ seen.set(path, font);
132
+ out.push({ path, mode: 0o644, source: { kind: 'file', path: font } });
133
+ }
134
+ return out;
135
+ }
101
136
  /**
102
137
  * The per-format additions. Only the licence differs today, and it differs in
103
138
  * BOTH place and shape: Debian policy wants a machine-readable `copyright` in
@@ -38,8 +38,32 @@ export declare const BUNDLE_PKGINFO = "Contents/PkgInfo";
38
38
  * which `flatpak.spec.ts` reds. The unblocker is an independent Linux ICNS reader
39
39
  * entering the CI image; until one does, the hicolor PNG/SVG the payload already
40
40
  * carries stays the only icon, unread on macOS.
41
+ *
42
+ * A TWELFTH KEY, `ATSApplicationFontsPath`, is emitted when the bundle carries
43
+ * faces, and it is the first one here NOT cited to `refs/` — so the exception is
44
+ * stated rather than left to be noticed (ADR 0038). The rule this module opens
45
+ * with exists against DECORATION: the five absent keys are cosmetic, nothing a
46
+ * reader here can open contains them, and emitting one would be a guess with no
47
+ * observable behind it. This key is a MECHANISM, and its citation is Apple's own
48
+ * *Information Property List Key Reference*, which states the scope in the terms
49
+ * this command needs: *"If present, macOS activates the fonts at the specified
50
+ * path for use by the bundled app. The fonts are activated only for the bundled
51
+ * app and not for the system as a whole."* The value is a path relative to
52
+ * `Contents/Resources`.
53
+ *
54
+ * It is also the only route there is. Pango on macOS is CoreText-backed, GTK is
55
+ * not built against fontconfig on that platform, and
56
+ * `pango_font_map_add_font_file()` answers `G_IO_ERROR_NOT_SUPPORTED` on the
57
+ * CoreText map — so the alternatives are this key or a `CTFontManager` call made
58
+ * from inside somebody else's application, which is not a packaging command's
59
+ * business.
60
+ *
61
+ * WHAT THE ORACLE CAN AND CANNOT SEE: `plistlib` reads the key back like any
62
+ * other, so the FILE is checked. That macOS activates the directory, and that
63
+ * Pango's CoreText map then holds the family, is unverified here — which is what
64
+ * `Layout.fontGap` says out loud rather than letting a green stage imply.
41
65
  */
42
- export declare function renderInfoPlist(input: LayoutMetadataInput): string;
66
+ export declare function renderInfoPlist(input: LayoutMetadataInput, fontsPath?: string): string;
43
67
  /**
44
68
  * Render `Contents/PkgInfo`: the package type and the signature, eight bytes, no
45
69
  * newline.
@@ -56,3 +80,19 @@ export declare function renderInfoPlist(input: LayoutMetadataInput): string;
56
80
  * the same reason this function takes none — both values are constants here.
57
81
  */
58
82
  export declare function renderPkgInfo(): string;
83
+ /**
84
+ * Render the entitlements plist `codesign --entitlements` takes.
85
+ *
86
+ * A SEPARATE FILE FROM `Info.plist` and a separate function, because it is a
87
+ * different document with a different reader: `Info.plist` describes the bundle
88
+ * to LaunchServices and ships inside it, while this one is an INPUT to the
89
+ * signature — `codesign` embeds the claims and the file itself is never staged.
90
+ *
91
+ * Every value is `<true/>`. An entitlement of this class is a boolean capability,
92
+ * and a `<false/>` entry is not "off", it is the same as absent — so a renderer
93
+ * taking values would offer a spelling with no meaning.
94
+ *
95
+ * WHICH KEYS, and why the set is not the reference's, is
96
+ * `utils/ship/signing.ts`'s decision; this function renders what it is given.
97
+ */
98
+ export declare function renderEntitlements(keys: readonly string[]): string;
@@ -147,8 +147,32 @@ function arrayEntry(key, values) {
147
147
  * which `flatpak.spec.ts` reds. The unblocker is an independent Linux ICNS reader
148
148
  * entering the CI image; until one does, the hicolor PNG/SVG the payload already
149
149
  * carries stays the only icon, unread on macOS.
150
+ *
151
+ * A TWELFTH KEY, `ATSApplicationFontsPath`, is emitted when the bundle carries
152
+ * faces, and it is the first one here NOT cited to `refs/` — so the exception is
153
+ * stated rather than left to be noticed (ADR 0038). The rule this module opens
154
+ * with exists against DECORATION: the five absent keys are cosmetic, nothing a
155
+ * reader here can open contains them, and emitting one would be a guess with no
156
+ * observable behind it. This key is a MECHANISM, and its citation is Apple's own
157
+ * *Information Property List Key Reference*, which states the scope in the terms
158
+ * this command needs: *"If present, macOS activates the fonts at the specified
159
+ * path for use by the bundled app. The fonts are activated only for the bundled
160
+ * app and not for the system as a whole."* The value is a path relative to
161
+ * `Contents/Resources`.
162
+ *
163
+ * It is also the only route there is. Pango on macOS is CoreText-backed, GTK is
164
+ * not built against fontconfig on that platform, and
165
+ * `pango_font_map_add_font_file()` answers `G_IO_ERROR_NOT_SUPPORTED` on the
166
+ * CoreText map — so the alternatives are this key or a `CTFontManager` call made
167
+ * from inside somebody else's application, which is not a packaging command's
168
+ * business.
169
+ *
170
+ * WHAT THE ORACLE CAN AND CANNOT SEE: `plistlib` reads the key back like any
171
+ * other, so the FILE is checked. That macOS activates the directory, and that
172
+ * Pango's CoreText map then holds the family, is unverified here — which is what
173
+ * `Layout.fontGap` says out loud rather than letting a green stage imply.
150
174
  */
151
- export function renderInfoPlist(input) {
175
+ export function renderInfoPlist(input, fontsPath) {
152
176
  const name = assertPlistText(input.name, 'CFBundleName', 'gjsify.ship.name');
153
177
  const executable = assertPlistText(input.binaryName, 'CFBundleExecutable', 'gjsify.ship.binaryName');
154
178
  const appId = assertPlistText(input.appId, 'CFBundleIdentifier', 'gjsify.ship.appId');
@@ -162,6 +186,12 @@ export function renderInfoPlist(input) {
162
186
  // → ShortVersionString → SupportedPlatforms → Version, and `v8/gni`'s
163
187
  // CFBundle keys sort the same way. Matching them keeps a diff against a
164
188
  // real bundle readable; no claim is made about what Xcode emits.
189
+ //
190
+ // `ATSApplicationFontsPath` sorts before every `CFBundle*` key, which is
191
+ // where alphabetical order puts it and also where a reader looks for it.
192
+ // Absent, not empty, when the bundle carries no face: an empty string is a
193
+ // path, and macOS would activate the Resources directory itself.
194
+ ...(fontsPath === undefined ? [] : [stringEntry('ATSApplicationFontsPath', fontsPath)]),
165
195
  stringEntry('CFBundleDevelopmentRegion', 'en'),
166
196
  stringEntry('CFBundleDisplayName', name),
167
197
  stringEntry('CFBundleExecutable', executable),
@@ -196,3 +226,31 @@ export function renderInfoPlist(input) {
196
226
  export function renderPkgInfo() {
197
227
  return `${PACKAGE_TYPE}${SIGNATURE}`;
198
228
  }
229
+ /**
230
+ * Render the entitlements plist `codesign --entitlements` takes.
231
+ *
232
+ * A SEPARATE FILE FROM `Info.plist` and a separate function, because it is a
233
+ * different document with a different reader: `Info.plist` describes the bundle
234
+ * to LaunchServices and ships inside it, while this one is an INPUT to the
235
+ * signature — `codesign` embeds the claims and the file itself is never staged.
236
+ *
237
+ * Every value is `<true/>`. An entitlement of this class is a boolean capability,
238
+ * and a `<false/>` entry is not "off", it is the same as absent — so a renderer
239
+ * taking values would offer a spelling with no meaning.
240
+ *
241
+ * WHICH KEYS, and why the set is not the reference's, is
242
+ * `utils/ship/signing.ts`'s decision; this function renders what it is given.
243
+ */
244
+ export function renderEntitlements(keys) {
245
+ const entries = keys.map((key) => `\t<key>${xmlText(key)}</key>\n\t<true/>`).join('\n');
246
+ return [
247
+ '<?xml version="1.0" encoding="UTF-8"?>',
248
+ '<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">',
249
+ '<plist version="1.0">',
250
+ '<dict>',
251
+ entries,
252
+ '</dict>',
253
+ '</plist>',
254
+ '',
255
+ ].join('\n');
256
+ }
@@ -139,6 +139,13 @@ const SYSTEM_OWNED_DIRECTORIES = new Set([
139
139
  // directory the whole system's schemas live in. Verified with `rpm -qf`.
140
140
  '/usr/share/glib-2.0',
141
141
  `/usr/${SHARE.schemas}`,
142
+ // Owned by `fonts-filesystem` — measured, `rpm -qf /usr/share/fonts` →
143
+ // `fonts-filesystem-5.0.0-2.fc44`. DERIVED although it is a PARENT rather than a
144
+ // directory the planner stages into: `plan.ts` writes `share/fonts/<appId>`,
145
+ // which this package legitimately owns, and the parent happens to BE
146
+ // `SHARE.fonts` exactly — so deriving it costs nothing and cannot drift the way
147
+ // the two literal parents above can.
148
+ `/usr/${SHARE.fonts}`,
142
149
  '/etc',
143
150
  '/opt',
144
151
  '/var',
@@ -1,4 +1,4 @@
1
- import type { AppMetadata, ConfigDataFlatpak, ConfigDataShip, DescriptionBlock } from '../../types/config-data.js';
1
+ import type { AppMetadata, ConfigDataFlatpak, ConfigDataShip, DescriptionBlock, ShipAppOptions } from '../../types/config-data.js';
2
2
  import type { HostOs, ShipSettings } from './types.js';
3
3
  /** The subset of `package.json` the resolver reads. */
4
4
  export interface ShipPackageManifest {
@@ -34,6 +34,8 @@ export interface DiscoveredPayload {
34
34
  rel: string;
35
35
  abs: string;
36
36
  }[];
37
+ /** Absolute paths of the font faces the application ships itself. */
38
+ fontFiles: string[];
37
39
  licenseFile?: string;
38
40
  }
39
41
  export interface SettingsInput {
@@ -59,13 +61,82 @@ export interface SettingsInput {
59
61
  };
60
62
  discovered: DiscoveredPayload;
61
63
  /**
62
- * `gjsify.app` as DECLARED — `undefined` when the project declares nothing.
64
+ * THIS TARGET's runtime, as {@link resolveShipApp} resolved it — never the
65
+ * raw `gjsify.app`.
63
66
  *
64
- * Deliberately the raw config value and not `Config.forBuild`'s resolved one:
65
- * see where it is defaulted below.
67
+ * The TYPE is the enforcement: a caller handing the project field straight
68
+ * through no longer compiles. That is exactly the regression this field used
69
+ * to carry in silence, since every generator downstream reads `settings.app`.
66
70
  */
67
- app?: string;
71
+ app: 'gjs' | 'node';
68
72
  }
73
+ /** The runtime one ship target runs, and which key decided it. */
74
+ export interface ResolvedShipApp {
75
+ /** What the launcher execs, what the package depends on, and which formats can wrap it. */
76
+ app: 'gjs' | 'node';
77
+ /** The config key the answer came from — named in the refusal and in the notice. */
78
+ key: string;
79
+ /** Whether a per-target key overrode the project default. */
80
+ overridden: boolean;
81
+ }
82
+ /**
83
+ * The ONE place a ship target's runtime is decided: `gjsify.app` as the DEFAULT,
84
+ * `gjsify.ship.app.<os>` as this target's override.
85
+ *
86
+ * WHY IT IS PER TARGET, measured on a GTK4 application rather than derived from
87
+ * the schema: with one field, `gjsify ship darwin` refused its formats until
88
+ * `gjsify.app` was `"node"` — and setting it turned the LINUX `.deb`'s
89
+ * `Depends: gjs (>= 1.86)` into `Depends: nodejs (>= 24)`, which apt refuses on
90
+ * trixie, Ubuntu 24.04 and Ubuntu 26.04. A macOS decision made the Linux package
91
+ * uninstallable, because every generator read the PROJECT field instead of the
92
+ * resolved runtime of its OWN target (#1486, ADR 0024 § A22). Nothing downstream
93
+ * reads either key; they all read `settings.app`, which is this result.
94
+ *
95
+ * NOT PER LAYOUT BY DEFAULT, and the first cut of the layout axis got this
96
+ * exactly backwards. Reading ADR § 4's runtime table as a per-layout
97
+ * REQUIREMENT refused `gjsify.app: "gjs"` for the macOS layout — the entire
98
+ * audience of that command — while a project with no `gjsify.app` key sailed
99
+ * through and staged a launcher naming `node` in front of a GJS bundle. § 4
100
+ * derives the runtime a shipped artifact CARRIES, which is
101
+ * `Layout.shippedRuntime`; what an artifact runs is what its author says, which
102
+ * is this. So an absent override means the project field and never the layout's
103
+ * derived answer.
104
+ *
105
+ * MUST NOT be fed `Config.forBuild`'s resolution, whose fallback is the HOST
106
+ * runtime. That fallback is right for a build (`gjsify build` under Node should
107
+ * produce a Node bundle by default) and catastrophic for a package: running this
108
+ * command under Node would silently turn every undeclared GJS project into one
109
+ * whose launcher execs `node`. An undeclared target is the common case for a GJS
110
+ * app, and it means GJS.
111
+ *
112
+ * TWO REFUSALS, and each is here because the alternative to refusing is SILENT.
113
+ * A `browser` or `nativescript` value has no artifact to be — no process to
114
+ * start, an APK/IPA through another pipeline (§ 4) — and would otherwise leave
115
+ * here as a `.deb` a stranger installs and cannot run, with no gate between here
116
+ * and their machine; `Layout.runtimeGap` only WARNS because what it describes is
117
+ * a staged tree rather than an artifact, and the per-FORMAT half of the question
118
+ * is `assertFormatCanRunInterpreter`, which asks what a container can EXECUTE.
119
+ * A key outside {@link SHIP_APP_OSES} — `windows`, the `gjsify ship <os>`
120
+ * POSITIONAL's spelling — would resolve to nothing and leave that target on the
121
+ * project-wide answer with every gate green, which is why the author who typed
122
+ * it reads the result as gjsify not supporting the split at all. Both refusals
123
+ * name the KEY that carried the value: with two keys, "this project declares" no
124
+ * longer locates it.
125
+ *
126
+ * `gjsify ship --from-stage` reads neither key and needs neither: the stage
127
+ * manifest records the RESOLVED value, so the packing host reads an answer
128
+ * rather than a default, and `assertLauncherMatchesInterpreter` still compares
129
+ * the staged `bin/<name>`'s own `exec` line against the dependency about to be
130
+ * written.
131
+ */
132
+ export declare function resolveShipApp(input: {
133
+ /** `gjsify.app` as DECLARED — `undefined` when the project declares nothing. */
134
+ project?: string;
135
+ /** `gjsify.ship.app`, the per-layout override table. */
136
+ perTarget?: ShipAppOptions;
137
+ /** The layout this run assembles. */
138
+ layoutOs: HostOs;
139
+ }): ResolvedShipApp;
69
140
  export interface ResolvedSettings {
70
141
  settings: ShipSettings;
71
142
  /** The merged metadata the renderers read. */
@@ -14,6 +14,90 @@ import { basename } from 'node:path';
14
14
  import { DEFAULT_GJS_FLOOR, DEFAULT_NODE_FLOOR } from './depends.js';
15
15
  import { resolveShipFlatpakSettings } from './flatpak-config.js';
16
16
  import { assertRelease, normaliseVersion } from './version.js';
17
+ /**
18
+ * The OS keys `gjsify.ship.app` may carry — {@link HostOs}, the
19
+ * `process.platform` spelling.
20
+ *
21
+ * `satisfies Record<HostOs, true>` rather than a bare list: a fourth layout
22
+ * cannot arrive without this object failing to compile, so the accepted set
23
+ * stays total without a second place to remember to update.
24
+ */
25
+ const SHIP_APP_OSES = { linux: true, darwin: true, win32: true };
26
+ /**
27
+ * The ONE place a ship target's runtime is decided: `gjsify.app` as the DEFAULT,
28
+ * `gjsify.ship.app.<os>` as this target's override.
29
+ *
30
+ * WHY IT IS PER TARGET, measured on a GTK4 application rather than derived from
31
+ * the schema: with one field, `gjsify ship darwin` refused its formats until
32
+ * `gjsify.app` was `"node"` — and setting it turned the LINUX `.deb`'s
33
+ * `Depends: gjs (>= 1.86)` into `Depends: nodejs (>= 24)`, which apt refuses on
34
+ * trixie, Ubuntu 24.04 and Ubuntu 26.04. A macOS decision made the Linux package
35
+ * uninstallable, because every generator read the PROJECT field instead of the
36
+ * resolved runtime of its OWN target (#1486, ADR 0024 § A22). Nothing downstream
37
+ * reads either key; they all read `settings.app`, which is this result.
38
+ *
39
+ * NOT PER LAYOUT BY DEFAULT, and the first cut of the layout axis got this
40
+ * exactly backwards. Reading ADR § 4's runtime table as a per-layout
41
+ * REQUIREMENT refused `gjsify.app: "gjs"` for the macOS layout — the entire
42
+ * audience of that command — while a project with no `gjsify.app` key sailed
43
+ * through and staged a launcher naming `node` in front of a GJS bundle. § 4
44
+ * derives the runtime a shipped artifact CARRIES, which is
45
+ * `Layout.shippedRuntime`; what an artifact runs is what its author says, which
46
+ * is this. So an absent override means the project field and never the layout's
47
+ * derived answer.
48
+ *
49
+ * MUST NOT be fed `Config.forBuild`'s resolution, whose fallback is the HOST
50
+ * runtime. That fallback is right for a build (`gjsify build` under Node should
51
+ * produce a Node bundle by default) and catastrophic for a package: running this
52
+ * command under Node would silently turn every undeclared GJS project into one
53
+ * whose launcher execs `node`. An undeclared target is the common case for a GJS
54
+ * app, and it means GJS.
55
+ *
56
+ * TWO REFUSALS, and each is here because the alternative to refusing is SILENT.
57
+ * A `browser` or `nativescript` value has no artifact to be — no process to
58
+ * start, an APK/IPA through another pipeline (§ 4) — and would otherwise leave
59
+ * here as a `.deb` a stranger installs and cannot run, with no gate between here
60
+ * and their machine; `Layout.runtimeGap` only WARNS because what it describes is
61
+ * a staged tree rather than an artifact, and the per-FORMAT half of the question
62
+ * is `assertFormatCanRunInterpreter`, which asks what a container can EXECUTE.
63
+ * A key outside {@link SHIP_APP_OSES} — `windows`, the `gjsify ship <os>`
64
+ * POSITIONAL's spelling — would resolve to nothing and leave that target on the
65
+ * project-wide answer with every gate green, which is why the author who typed
66
+ * it reads the result as gjsify not supporting the split at all. Both refusals
67
+ * name the KEY that carried the value: with two keys, "this project declares" no
68
+ * longer locates it.
69
+ *
70
+ * `gjsify ship --from-stage` reads neither key and needs neither: the stage
71
+ * manifest records the RESOLVED value, so the packing host reads an answer
72
+ * rather than a default, and `assertLauncherMatchesInterpreter` still compares
73
+ * the staged `bin/<name>`'s own `exec` line against the dependency about to be
74
+ * written.
75
+ */
76
+ export function resolveShipApp(input) {
77
+ const perTarget = input.perTarget ?? {};
78
+ // The WHOLE table, not only this run's key: a mis-keyed override is silent by
79
+ // construction, so `gjsify ship linux` is the run that has to say `windows` is
80
+ // not a key. Checking only the resolving key would leave the typo to be found
81
+ // by the `gjsify ship windows` that already reads as unsupported.
82
+ for (const os of Object.keys(perTarget)) {
83
+ if (Object.hasOwn(SHIP_APP_OSES, os))
84
+ continue;
85
+ throw new Error(`gjsify ship: \`gjsify.ship.app.${os}\` is not an OS this command assembles for. Known: ` +
86
+ `${Object.keys(SHIP_APP_OSES).join(', ')} — the \`process.platform\` spelling, not the ` +
87
+ "`gjsify ship <os>` positional's. A runtime under a key nothing reads leaves that target on " +
88
+ '`gjsify.app` with nothing to say so.');
89
+ }
90
+ const override = perTarget[input.layoutOs];
91
+ const overridden = override !== undefined;
92
+ const key = overridden ? `gjsify.ship.app.${input.layoutOs}` : 'gjsify.app';
93
+ const declared = overridden ? override : input.project;
94
+ if (declared !== undefined && declared !== 'gjs' && declared !== 'node') {
95
+ throw new Error(`gjsify ship: \`${key}\` is "${declared}", and only \`gjs\` and \`node\` can be ` +
96
+ 'packaged. A browser bundle has no process to launch, and a NativeScript app ships as an APK/IPA ' +
97
+ 'through a different pipeline. ADR 0024 § 4 has the runtime-per-OS table.');
98
+ }
99
+ return { app: declared === 'node' ? 'node' : 'gjs', key, overridden };
100
+ }
17
101
  /** Freedesktop main category → deb `Section:` / rpm `Group:`. First match wins. */
18
102
  const CATEGORY_SECTIONS = [
19
103
  ['Development', { section: 'devel', group: 'Development/Tools' }],
@@ -49,14 +133,10 @@ export function resolveShipSettings(input) {
49
133
  const appId = ship.appId ?? flatpak.appId ?? reverseDnsOrThrow(pkg.name, binaryName);
50
134
  const name = metadata.name ?? titleCase(binaryName);
51
135
  const kind = metadata.kind ?? 'app';
52
- // `gjsify.app`, defaulted HERE to `'gjs'` rather than taken from
53
- // `Config.forBuild`'s resolution, which falls back to the HOST runtime. That
54
- // fallback is right for a build (`gjsify build` under Node should produce a
55
- // Node bundle by default) and catastrophic for a package: running this
56
- // command under Node would silently turn every undeclared GJS project into
57
- // one whose launcher execs `node`. An undeclared target is the common case
58
- // for a GJS app, and it means GJS.
59
- const app = input.app === 'node' ? 'node' : 'gjs';
136
+ // NOT resolved here: `resolveShipApp` already did, before the format list was
137
+ // decided from it. A second defaulting rule in this function would be the
138
+ // second path those two answers could come apart on.
139
+ const app = input.app;
60
140
  const rawVersion = ship.version ?? pkg.version;
61
141
  if (!rawVersion) {
62
142
  throw new Error('gjsify ship: no version — set `version` in package.json or `gjsify.ship.version`.');
@@ -110,6 +190,7 @@ export function resolveShipSettings(input) {
110
190
  schemaFiles: discovered.schemaFiles,
111
191
  typelibFiles: discovered.typelibFiles,
112
192
  localeFiles: discovered.localeFiles,
193
+ fontFiles: discovered.fontFiles,
113
194
  extraFiles: ship.extraFiles ?? {},
114
195
  execArgs: ship.execArgs ?? [],
115
196
  outDir: input.cli.outDir ?? ship.outDir ?? 'ship',
@@ -18,6 +18,36 @@ export declare const SHARE: {
18
18
  readonly metainfo: "share/metainfo";
19
19
  /** Compiled gettext catalogues, read directly — no install step. */
20
20
  readonly locale: "share/locale";
21
+ /**
22
+ * Font faces the application ships — ONE directory, and three different things
23
+ * reach it (ADR 0038). No install step on any of them.
24
+ *
25
+ * On LINUX fontconfig scans it, by two rules that are both the stock
26
+ * `fonts.conf`'s rather than ours: `<dir>/usr/share/fonts</dir>` unconditionally,
27
+ * which covers a `.deb`/`.rpm`, and `<dir prefix="xdg">fonts</dir>`, which
28
+ * fontconfig expands over `XDG_DATA_DIRS` as well as `XDG_DATA_HOME` — so the
29
+ * variable the launcher already exports covers `/app` in a Flatpak and any other
30
+ * prefix. `fonts-conf(5)` documents only `XDG_DATA_HOME` there; the expansion was
31
+ * measured in EIGHT independently built fontconfigs (2.14.1 through 2.18.3), in
32
+ * every list position, recursively, with a cold cache and a negative control.
33
+ * Behaviour, not documentation — which is why it is written here and in ADR 0038
34
+ * § *What was measured* rather than re-derived from the man page.
35
+ *
36
+ * On MACOS none of that applies and the directory is the same: Pango there is
37
+ * CoreText-backed and GTK is not built against fontconfig, so `Info.plist`'s
38
+ * `ATSApplicationFontsPath` points at this directory instead (`layout.ts`).
39
+ *
40
+ * On WINDOWS nothing reaches it by configuration at all — measured there, not
41
+ * inferred: the default font map does not read fontconfig even when the staged
42
+ * directory is the ONLY configuration it is given, while `add_font_file` on that
43
+ * same map does register the family. So the launcher's `GJSIFY_FONT_DIR` hands
44
+ * the directory to the app to register. `Layout.fontGap` carries the numbers and
45
+ * the three candidates that look like they would work and do not.
46
+ *
47
+ * That the PATH is one while the mechanisms are three is the point: `planStage`
48
+ * keeps emitting one payload, and every per-OS answer is a layout decision.
49
+ */
50
+ readonly fonts: "share/fonts";
21
51
  };
22
52
  /** Is `path` inside `dir`? Directory-boundary aware, so `share/mimetypes` is not inside `share/mime`. */
23
53
  export declare function isUnder(path: string, dir: string): boolean;
@@ -39,6 +39,36 @@ export const SHARE = {
39
39
  metainfo: 'share/metainfo',
40
40
  /** Compiled gettext catalogues, read directly — no install step. */
41
41
  locale: 'share/locale',
42
+ /**
43
+ * Font faces the application ships — ONE directory, and three different things
44
+ * reach it (ADR 0038). No install step on any of them.
45
+ *
46
+ * On LINUX fontconfig scans it, by two rules that are both the stock
47
+ * `fonts.conf`'s rather than ours: `<dir>/usr/share/fonts</dir>` unconditionally,
48
+ * which covers a `.deb`/`.rpm`, and `<dir prefix="xdg">fonts</dir>`, which
49
+ * fontconfig expands over `XDG_DATA_DIRS` as well as `XDG_DATA_HOME` — so the
50
+ * variable the launcher already exports covers `/app` in a Flatpak and any other
51
+ * prefix. `fonts-conf(5)` documents only `XDG_DATA_HOME` there; the expansion was
52
+ * measured in EIGHT independently built fontconfigs (2.14.1 through 2.18.3), in
53
+ * every list position, recursively, with a cold cache and a negative control.
54
+ * Behaviour, not documentation — which is why it is written here and in ADR 0038
55
+ * § *What was measured* rather than re-derived from the man page.
56
+ *
57
+ * On MACOS none of that applies and the directory is the same: Pango there is
58
+ * CoreText-backed and GTK is not built against fontconfig, so `Info.plist`'s
59
+ * `ATSApplicationFontsPath` points at this directory instead (`layout.ts`).
60
+ *
61
+ * On WINDOWS nothing reaches it by configuration at all — measured there, not
62
+ * inferred: the default font map does not read fontconfig even when the staged
63
+ * directory is the ONLY configuration it is given, while `add_font_file` on that
64
+ * same map does register the family. So the launcher's `GJSIFY_FONT_DIR` hands
65
+ * the directory to the app to register. `Layout.fontGap` carries the numbers and
66
+ * the three candidates that look like they would work and do not.
67
+ *
68
+ * That the PATH is one while the mechanisms are three is the point: `planStage`
69
+ * keeps emitting one payload, and every per-OS answer is a layout decision.
70
+ */
71
+ fonts: 'share/fonts',
42
72
  };
43
73
  /** Is `path` inside `dir`? Directory-boundary aware, so `share/mimetypes` is not inside `share/mime`. */
44
74
  export function isUnder(path, dir) {
@@ -28,7 +28,34 @@ export interface SignerDescriptor {
28
28
  */
29
29
  signs: 'macho' | 'pe';
30
30
  /** argv after {@link tool}, for ONE file. */
31
- args: (identity: string, file: string) => readonly string[];
31
+ args: (input: SignArgs) => readonly string[];
32
+ /**
33
+ * The entitlements this signer asks for, or `[]` where the OS has none.
34
+ *
35
+ * `[]` is an ANSWER and not an omission — the same rule `Layout.metadata`
36
+ * states. Windows has no equivalent concept at all, and a signer that granted
37
+ * nothing on macOS would be a different decision from one that has nothing to
38
+ * grant.
39
+ */
40
+ entitlements: readonly string[];
41
+ /**
42
+ * Does this signer seal the ARTIFACT ROOT after signing the images inside it?
43
+ *
44
+ * Only true where the OS has a bundle format, which is darwin alone. The root
45
+ * itself is not on the descriptor because it belongs to the LAYOUT
46
+ * (`Layout.root`), and a second copy here is where the two would come apart.
47
+ */
48
+ sealsBundle: boolean;
49
+ /**
50
+ * Payload paths a seal is allowed to ADD, given the artifact root.
51
+ *
52
+ * A PREFIX rather than a free hand: `codesign` on a bundle writes
53
+ * `Contents/_CodeSignature/{CodeResources,CodeDirectory,CodeRequirements,
54
+ * CodeRequirements-1,CodeSignature}` and nothing else, so anything appearing
55
+ * outside that directory is something else having run — which is exactly the
56
+ * class {@link signPayload}'s file-set check exists to catch.
57
+ */
58
+ sealAddPrefix: (bundleRoot: string) => string;
32
59
  /** The config key a project spells its default identity under. */
33
60
  configKey: string;
34
61
  /** What a signature from this signer does NOT yet cover — printed once per run. */
@@ -91,6 +118,42 @@ export declare function resolveSignPlan(input: {
91
118
  * knowable before anything is packed.
92
119
  */
93
120
  export declare function assertHostCanSign(signer: SignerDescriptor): void;
121
+ /**
122
+ * What a signing run did to the payload's FILE SET, split three ways.
123
+ *
124
+ * PURE, and extracted from {@link signPayload} for one reason: it is the whole of
125
+ * the seal decision and the only half of it that can be checked without a macOS
126
+ * host. Everything else about a bundle seal needs `codesign`, which this
127
+ * repository has on exactly one CI leg; this function needs nothing.
128
+ *
129
+ * `sealPrefix` absent means "this signer seals nothing", and then EVERY addition
130
+ * is unexpected — which is what the file did before ADR 0040 and is still the
131
+ * right answer for `signtool`.
132
+ */
133
+ export declare function partitionSignedFileSet(input: {
134
+ /** Paths found under the scratch directory after the tool ran. */
135
+ arrived: readonly string[];
136
+ /** Paths the payload had before it. */
137
+ planned: readonly string[];
138
+ /** Directory prefix the seal is allowed to write into, when there is one. */
139
+ sealPrefix?: string;
140
+ }): {
141
+ sealed: string[];
142
+ unexpected: string[];
143
+ removed: string[];
144
+ };
145
+ /** What one invocation of a signing tool is given. */
146
+ export interface SignArgs {
147
+ identity: string;
148
+ /** Absolute path of the file — or, for a bundle seal, of the bundle DIRECTORY. */
149
+ file: string;
150
+ /** Absolute path of the entitlements plist this run wrote, or absent when the signer grants none. */
151
+ entitlements?: string;
152
+ /** Is {@link identity} the reserved ad-hoc `-`? */
153
+ adhoc: boolean;
154
+ }
155
+ /** `codesign --sign -`: the one identity value that names no identity at all. */
156
+ export declare const ADHOC_IDENTITY = "-";
94
157
  export interface SignPayloadInput {
95
158
  /** What `readStage` returned — the PRE-sign tree, already held against the manifest. */
96
159
  payload: readonly PayloadEntry[];
@@ -98,6 +161,22 @@ export interface SignPayloadInput {
98
161
  signer: SignerDescriptor;
99
162
  /** A scratch directory this function owns and WIPES. Never the arriving stage. */
100
163
  workDir: string;
164
+ /**
165
+ * `Layout.root` — the payload-relative directory that IS the artifact, or `''`.
166
+ *
167
+ * Read only by a signer that seals: it is what `codesign` is pointed at, and
168
+ * it comes from the layout rather than from this table because the layout is
169
+ * what decided the `<App>.app` name in the first place.
170
+ */
171
+ bundleRoot: string;
172
+ /**
173
+ * Where to write the entitlements plist, OUTSIDE {@link workDir}.
174
+ *
175
+ * Outside is not a preference: the file-set check below lists `workDir` and
176
+ * compares it with the payload, so an entitlements file written inside would
177
+ * read as a file the signer added.
178
+ */
179
+ entitlementsPath: string;
101
180
  /** Print every invocation. */
102
181
  verbose: boolean;
103
182
  log: (line: string) => void;
@@ -112,11 +191,16 @@ export interface SignPayloadInput {
112
191
  * same tree fail `readStage`'s size comparison (measured — see the header), and
113
192
  * `writeStage`'s wipe would delete the payload it was about to pack.
114
193
  *
115
- * THE FILE SET MAY NOT CHANGE, and that is asserted rather than assumed. Both
116
- * tools rewrite an image in place, so a payload that gained or lost a path here
117
- * means something else ran — a bundle seal writing `_CodeSignature/`, say, whose
118
- * files would then be dropped on the way back into the payload without anyone
119
- * noticing.
194
+ * TWO STEPS ON DARWIN, ONE EVERYWHERE ELSE: every Mach-O in the closure, then the
195
+ * bundle itself. The second is what `codesign --verify <App>.app` reads, and it
196
+ * writes five regular files into `Contents/_CodeSignature/` that the returned
197
+ * payload carries — so the seal reaches the `.zip` and the `.dmg` alike, which is
198
+ * the correction ADR 0040 makes to this file's previous claim that it could not.
199
+ *
200
+ * THE FILE SET MAY ONLY GROW WHERE THE SEAL WRITES, and that is asserted rather
201
+ * than assumed. Both tools rewrite an image in place, so a payload that gained a
202
+ * path outside `sealAddPrefix` — or lost one anywhere — means something else ran,
203
+ * and its files would be packed with no mode from the plan.
120
204
  */
121
205
  export declare function signPayload(input: SignPayloadInput): Promise<PayloadEntry[]>;
122
206
  /**
@@ -193,3 +277,35 @@ export declare function notarizeArtifact(input: {
193
277
  verbose: boolean;
194
278
  log: (line: string) => void;
195
279
  }): Promise<void>;
280
+ /**
281
+ * Attach the notarisation ticket to an artifact that can hold one.
282
+ *
283
+ * WHY IT IS A SEPARATE CALL AND NOT THE TAIL OF {@link notarizeArtifact}: not
284
+ * every artifact the notary service ACCEPTS can carry a ticket, and the pair that
285
+ * comes apart is exactly the one this command produces. Apple:
286
+ * *"While you can notarize a ZIP archive, you can't staple to it directly.
287
+ * Instead, run stapler against each item that you added to the archive. Then
288
+ * create a new ZIP file containing the stapled items for distribution."*
289
+ * `stapler(1)` gives the accepting side of the same list: *"stapler works only
290
+ * with UDIF disk images, signed \"flat\" installer packages, and certain
291
+ * code-signed executable bundles such as \".app\"."* So the `.dmg` is stapled
292
+ * here and the `.zip` is told, in the artifact log, what a person has to do
293
+ * instead — which is a fact about the CONTAINER and therefore lives on the format
294
+ * (`canCarryTicket`), not in a branch here.
295
+ *
296
+ * ⚠️ UNVERIFIED END TO END, for the same reason `notarizeArtifact` is: stapling
297
+ * needs a real notarisation, which needs an Apple account (ADR 0024 § A15, § A17).
298
+ * The argv is unit-tested and the invocation has never run in this repository.
299
+ *
300
+ * `stapler(1)` also states the constraint that decides the ORDER: *"Stapling does
301
+ * not invalidate the code signature and must be run after an executable or archive
302
+ * has been code-signed and notarized … Code-signing a supported file format
303
+ * invalidates any stapled tickets"* — so nothing may re-sign after this, and
304
+ * nothing in `packOne` does.
305
+ */
306
+ export declare function stapleArtifact(input: {
307
+ notary: NotaryDescriptor;
308
+ artifact: string;
309
+ verbose: boolean;
310
+ log: (line: string) => void;
311
+ }): Promise<void>;