@gjsify/cli 0.64.16 → 0.64.17

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.
@@ -0,0 +1,275 @@
1
+ // Contributor credits from git history, for `gjsify credits`.
2
+ //
3
+ // Names only: an e-mail address is read to recognise a bot or an AI co-author and
4
+ // is dropped before anything is built, so no output can carry one.
5
+ import { spawnSync } from 'node:child_process';
6
+ import { posix } from 'node:path';
7
+ /**
8
+ * Patterns for people who are not contributors. `*` is the only wildcard and a
9
+ * pattern must match the WHOLE name or the WHOLE e-mail, case-insensitively.
10
+ * Weblate commits as "Weblate (bot)" with the real translator in a trailer, so
11
+ * the bot is dropped and the trailer is what counts.
12
+ */
13
+ export const DEFAULT_CREDITS_IGNORE = [
14
+ '*[bot]',
15
+ 'github-actions',
16
+ 'Weblate',
17
+ 'Weblate (bot)',
18
+ 'Hosted Weblate',
19
+ 'Weblate Translation Memory',
20
+ 'LibreTranslate',
21
+ 'Claude',
22
+ '*@anthropic.com',
23
+ 'noreply-mt-*@weblate.org',
24
+ 'hosted@weblate.org',
25
+ '*[bot]@users.noreply.github.com',
26
+ ];
27
+ const FIELD = '\x1f';
28
+ const RECORD = '\x1e';
29
+ const TRAILER_SEPARATOR = '\x1d';
30
+ function globToRegExp(pattern) {
31
+ const source = pattern
32
+ .split('*')
33
+ .map((part) => part.replace(/[.+?^${}()|[\]\\]/g, '\\$&'))
34
+ .join('.*');
35
+ return new RegExp(`^${source}$`, 'i');
36
+ }
37
+ export function isIgnored(person, patterns) {
38
+ return patterns.some((pattern) => {
39
+ const re = globToRegExp(pattern);
40
+ return re.test(person.name) || (person.email !== '' && re.test(person.email));
41
+ });
42
+ }
43
+ /** `Name <mail>` → parts. A trailer without an address keeps the whole value as the name. */
44
+ export function parsePerson(value) {
45
+ const match = /^(.*?)\s*<([^>]*)>\s*$/.exec(value.trim());
46
+ return match ? { name: match[1].trim(), email: match[2].trim() } : { name: value.trim(), email: '' };
47
+ }
48
+ const isTranslationFile = (file) => /\.pot?$/i.test(file);
49
+ /** `po/pt_BR.po` → `pt_BR`; a `.pot` template names no language. Git prints `/` on every host. */
50
+ function poLanguage(file) {
51
+ const base = posix.basename(file);
52
+ return /\.po$/i.test(base) ? base.slice(0, -3) : undefined;
53
+ }
54
+ function bump(map, name) {
55
+ map.set(name, (map.get(name) ?? 0) + 1);
56
+ }
57
+ /** Most commits first, then by name; code-point order so every host sorts alike. */
58
+ function rank(counts) {
59
+ return [...counts.entries()].sort(([a, x], [b, y]) => y - x || (a < b ? -1 : a > b ? 1 : 0)).map(([name]) => name);
60
+ }
61
+ function appendUnseen(list, names, excluded) {
62
+ const seen = new Set(list);
63
+ for (const raw of names ?? []) {
64
+ const name = raw.trim();
65
+ if (name && !seen.has(name) && !excluded(name)) {
66
+ seen.add(name);
67
+ list.push(name);
68
+ }
69
+ }
70
+ }
71
+ export function buildCredits(commits, options = {}) {
72
+ const ignore = options.ignore ?? DEFAULT_CREDITS_IGNORE;
73
+ const overrides = options.overrides ?? {};
74
+ const excludePatterns = overrides.exclude ?? [];
75
+ const excluded = (name) => isIgnored({ name, email: '' }, excludePatterns);
76
+ const developers = new Map();
77
+ const translators = new Map();
78
+ const classified = commits.map((commit) => {
79
+ const names = new Set();
80
+ for (const person of [commit.author, ...commit.coAuthors]) {
81
+ if (person.name && !isIgnored(person, ignore) && !excluded(person.name))
82
+ names.add(person.name);
83
+ }
84
+ const languages = new Set();
85
+ let touchesCode = false;
86
+ for (const file of commit.files) {
87
+ if (!isTranslationFile(file))
88
+ touchesCode = true;
89
+ const language = poLanguage(file);
90
+ if (language)
91
+ languages.add(language);
92
+ }
93
+ return { names, languages, touchesCode };
94
+ });
95
+ // A Weblate squash merge lists every translator of the batch in one trailer block and touches
96
+ // all their languages, so it cannot say who translated what. Only a commit with one person and
97
+ // one language can; an ambiguous commit credits a person to the languages such commits pin
98
+ // them to, and falls back to all its languages only for someone nothing else pins.
99
+ const pinned = new Map();
100
+ for (const { names, languages } of classified) {
101
+ if (names.size !== 1 || languages.size !== 1)
102
+ continue;
103
+ const [name] = names;
104
+ const [language] = languages;
105
+ let set = pinned.get(name);
106
+ if (!set)
107
+ pinned.set(name, (set = new Set()));
108
+ set.add(language);
109
+ }
110
+ for (const { names, languages, touchesCode } of classified) {
111
+ const ambiguous = names.size > 1 || languages.size > 1;
112
+ for (const name of names) {
113
+ if (touchesCode)
114
+ bump(developers, name);
115
+ const own = pinned.get(name);
116
+ for (const language of languages) {
117
+ if (ambiguous && own && !own.has(language))
118
+ continue;
119
+ let perLanguage = translators.get(language);
120
+ if (!perLanguage)
121
+ translators.set(language, (perLanguage = new Map()));
122
+ bump(perLanguage, name);
123
+ }
124
+ }
125
+ }
126
+ const credits = { developers: rank(developers), translators: {}, supporters: [] };
127
+ const excludeTranslators = overrides.excludeTranslators ?? {};
128
+ for (const language of [...translators.keys()].sort()) {
129
+ const patterns = [...(excludeTranslators['*'] ?? []), ...(excludeTranslators[language] ?? [])];
130
+ const list = rank(translators.get(language)).filter((name) => !isIgnored({ name, email: '' }, patterns));
131
+ if (list.length)
132
+ credits.translators[language] = list;
133
+ }
134
+ appendUnseen(credits.developers, overrides.developers, excluded);
135
+ for (const language of Object.keys(overrides.translators ?? {}).sort()) {
136
+ const list = credits.translators[language] ?? [];
137
+ appendUnseen(list, overrides.translators[language], excluded);
138
+ if (list.length)
139
+ credits.translators[language] = list;
140
+ }
141
+ credits.translators = Object.fromEntries(Object.entries(credits.translators).sort(([a], [b]) => (a < b ? -1 : 1)));
142
+ appendUnseen(credits.supporters, overrides.supporters, excluded);
143
+ return credits;
144
+ }
145
+ /** The `git log` format `parseGitLog` reads: author, address, co-author trailers, then the file names. */
146
+ export const GIT_LOG_ARGS = [
147
+ '-c',
148
+ 'core.quotepath=off',
149
+ 'log',
150
+ '--no-merges',
151
+ '--use-mailmap',
152
+ '--name-only',
153
+ `--format=${RECORD}%aN${FIELD}%aE${FIELD}%(trailers:key=Co-authored-by,valueonly,unfold,separator=%x1d)${FIELD}`,
154
+ ];
155
+ /** Splits `git log` output into commits; co-author trailers are NOT mapped yet. */
156
+ export function parseGitLog(output) {
157
+ const commits = [];
158
+ for (const record of output.split(RECORD)) {
159
+ if (!record.trim())
160
+ continue;
161
+ const [name = '', email = '', trailers = '', files = ''] = record.split(FIELD);
162
+ commits.push({
163
+ author: { name: name.trim(), email: email.trim() },
164
+ coAuthors: trailers
165
+ .split(TRAILER_SEPARATOR)
166
+ .map((value) => value.trim())
167
+ .filter(Boolean)
168
+ .map(parsePerson),
169
+ files: files
170
+ .split('\n')
171
+ .map((file) => file.trim())
172
+ .filter(Boolean),
173
+ });
174
+ }
175
+ return commits;
176
+ }
177
+ function git(cwd, args, input) {
178
+ const result = spawnSync('git', [...args], { cwd, encoding: 'utf8', input, maxBuffer: 1 << 30 });
179
+ if (result.error)
180
+ throw new Error(`cannot run git: ${result.error.message}`);
181
+ if (result.status !== 0)
182
+ throw new Error(`git ${args.join(' ')} failed: ${String(result.stderr).trim()}`);
183
+ return String(result.stdout);
184
+ }
185
+ /** Runs each trailer through `.mailmap`, so a co-author and the same person as an author merge. */
186
+ function mapTrailers(cwd, commits) {
187
+ const contacts = new Set();
188
+ for (const commit of commits) {
189
+ for (const person of commit.coAuthors)
190
+ if (person.email)
191
+ contacts.add(`${person.name} <${person.email}>`);
192
+ }
193
+ if (contacts.size === 0)
194
+ return;
195
+ const asked = [...contacts];
196
+ const answered = git(cwd, ['check-mailmap', '--stdin'], asked.join('\n') + '\n')
197
+ .split('\n')
198
+ .filter(Boolean);
199
+ if (answered.length !== asked.length)
200
+ throw new Error('git check-mailmap answered a different number of lines');
201
+ const mapped = new Map(asked.map((contact, i) => [contact, parsePerson(answered[i])]));
202
+ for (const commit of commits) {
203
+ commit.coAuthors = commit.coAuthors.map((p) => (p.email ? mapped.get(`${p.name} <${p.email}>`) : p));
204
+ }
205
+ }
206
+ /** The repo's non-merge commits with `.mailmap` applied to authors and co-authors. */
207
+ export function readCommits(cwd) {
208
+ // A shallow clone drops history, so the result would silently differ from a full one
209
+ // and `--check` would flag a perfectly current file as stale.
210
+ if (git(cwd, ['rev-parse', '--is-shallow-repository']).trim() === 'true') {
211
+ throw new Error('the repository is a shallow clone; fetch the full history first (git fetch --unshallow)');
212
+ }
213
+ const commits = parseGitLog(git(cwd, GIT_LOG_ARGS));
214
+ mapTrailers(cwd, commits);
215
+ return commits;
216
+ }
217
+ const STRING_LIST_KEYS = ['developers', 'supporters', 'exclude'];
218
+ const LANGUAGE_MAP_KEYS = ['translators', 'excludeTranslators'];
219
+ function asStringList(value, where) {
220
+ if (!Array.isArray(value) || value.some((item) => typeof item !== 'string')) {
221
+ throw new Error(`${where} must be an array of strings`);
222
+ }
223
+ return value;
224
+ }
225
+ export function parseOverrides(text, where = 'overrides') {
226
+ let data;
227
+ try {
228
+ data = JSON.parse(text);
229
+ }
230
+ catch (err) {
231
+ throw new Error(`${where} is not valid JSON: ${err instanceof Error ? err.message : String(err)}`);
232
+ }
233
+ if (typeof data !== 'object' || data === null || Array.isArray(data)) {
234
+ throw new Error(`${where} must be a JSON object`);
235
+ }
236
+ const record = data;
237
+ const known = new Set([...STRING_LIST_KEYS, ...LANGUAGE_MAP_KEYS]);
238
+ for (const key of Object.keys(record)) {
239
+ if (!known.has(key))
240
+ throw new Error(`${where} has an unknown key "${key}" (known: ${[...known].join(', ')})`);
241
+ }
242
+ const overrides = {};
243
+ for (const key of STRING_LIST_KEYS) {
244
+ if (record[key] !== undefined)
245
+ overrides[key] = asStringList(record[key], `${where}.${key}`);
246
+ }
247
+ for (const key of LANGUAGE_MAP_KEYS) {
248
+ const raw = record[key];
249
+ if (raw === undefined)
250
+ continue;
251
+ if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) {
252
+ throw new Error(`${where}.${key} must map a language to an array of names`);
253
+ }
254
+ const map = {};
255
+ for (const [language, names] of Object.entries(raw)) {
256
+ map[language] = asStringList(names, `${where}.${key}.${language}`);
257
+ }
258
+ overrides[key] = map;
259
+ }
260
+ return overrides;
261
+ }
262
+ export function serializeCredits(credits) {
263
+ return JSON.stringify(credits, null, 2) + '\n';
264
+ }
265
+ /** True when `existing` (the file on disk, or `null` when absent) is not what `credits` serializes to. */
266
+ export function isStale(existing, credits) {
267
+ if (existing === null)
268
+ return true;
269
+ try {
270
+ return JSON.stringify(JSON.parse(existing)) !== JSON.stringify(credits);
271
+ }
272
+ catch {
273
+ return true;
274
+ }
275
+ }
@@ -1,9 +1,9 @@
1
1
  /**
2
2
  * Default GNOME-Platform runtime version. Bumped per release window.
3
- * GNOME 50 = April 2026 stable; tracked in
3
+ * GNOME 51 = September 2026 stable (on Freedesktop 26.08); tracked in
4
4
  * https://docs.flathub.org/docs/for-app-authors/requirements.
5
5
  */
6
- export declare const DEFAULT_GNOME_RUNTIME_VERSION = "50";
6
+ export declare const DEFAULT_GNOME_RUNTIME_VERSION = "51";
7
7
  /** Default Freedesktop-Platform runtime version (LTS-ish). */
8
8
  export declare const DEFAULT_FREEDESKTOP_RUNTIME_VERSION = "24.08";
9
9
  /** Permissive GUI defaults for GTK4 + Adwaita apps. */
@@ -9,10 +9,10 @@
9
9
  // GNOME releases from the same `package.json`.
10
10
  /**
11
11
  * Default GNOME-Platform runtime version. Bumped per release window.
12
- * GNOME 50 = April 2026 stable; tracked in
12
+ * GNOME 51 = September 2026 stable (on Freedesktop 26.08); tracked in
13
13
  * https://docs.flathub.org/docs/for-app-authors/requirements.
14
14
  */
15
- export const DEFAULT_GNOME_RUNTIME_VERSION = '50';
15
+ export const DEFAULT_GNOME_RUNTIME_VERSION = '51';
16
16
  /** Default Freedesktop-Platform runtime version (LTS-ish). */
17
17
  export const DEFAULT_FREEDESKTOP_RUNTIME_VERSION = '24.08';
18
18
  /** Permissive GUI defaults for GTK4 + Adwaita apps. */
@@ -0,0 +1,109 @@
1
+ /** Bump when the OUTPUT changes for the same inputs, so every project's `--check` goes stale once. */
2
+ export declare const ANDROID_ICONS_GENERATOR_VERSION = 1;
3
+ export declare const ANDROID_ICONS_STAMP = ".gjsify-android-icons.json";
4
+ export declare const DEFAULT_RESOURCES_DIR = "App_Resources";
5
+ export declare const DEFAULT_BACKGROUND = "#FFFFFF";
6
+ export interface AndroidDensity {
7
+ name: 'ldpi' | 'mdpi' | 'hdpi' | 'xhdpi' | 'xxhdpi' | 'xxxhdpi';
8
+ /** Pixels per dp. */
9
+ scale: number;
10
+ }
11
+ /** Launcher icons have no ldpi bucket any more; the splash set keeps the template's. */
12
+ export declare const LAUNCHER_DENSITIES: readonly AndroidDensity[];
13
+ export declare const SPLASH_DENSITIES: readonly AndroidDensity[];
14
+ /** Legacy launcher icon edge in dp, and the adaptive layers' (the visible window is the inner 72dp). */
15
+ export declare const LEGACY_DP = 48;
16
+ export declare const ADAPTIVE_DP = 108;
17
+ /** The part of the adaptive canvas no launcher mask may cut. */
18
+ export declare const SAFE_ZONE_DP = 66;
19
+ /** The splash logo's edge in dp; the template's wordmark is 200dp wide, a square mark reads smaller. */
20
+ export declare const SPLASH_DP = 96;
21
+ /** How much of a round legacy icon's circle the picture fills. */
22
+ export declare const ROUND_INNER_RATIO = 0.72;
23
+ export type AndroidPngKind = 'legacy' | 'round' | 'foreground' | 'monochrome' | 'splash';
24
+ export interface AndroidPngSpec {
25
+ /** Relative to the `res/` directory. */
26
+ path: string;
27
+ kind: AndroidPngKind;
28
+ /** Square edge in pixels. */
29
+ size: number;
30
+ }
31
+ export interface AndroidTextSpec {
32
+ path: string;
33
+ content: string;
34
+ }
35
+ export interface AndroidIconPlan {
36
+ png: AndroidPngSpec[];
37
+ text: AndroidTextSpec[];
38
+ }
39
+ /**
40
+ * Every file the generator owns, from two facts: the background colour and
41
+ * whether a monochrome layer exists. `<monochrome>` is omitted without a symbolic
42
+ * icon — a launcher with an empty one would theme the icon to nothing.
43
+ */
44
+ export declare function planAndroidIcons(opts: {
45
+ background: string;
46
+ monochrome: boolean;
47
+ }): AndroidIconPlan;
48
+ export declare function backgroundXml(color: string): string;
49
+ export declare function adaptiveIconXml(monochrome: boolean): string;
50
+ /** A `#rgb` or `#rrggbb` colour as `#RRGGBB`, or null. */
51
+ export declare function normalizeColor(value: string): string | null;
52
+ /**
53
+ * The metainfo's primary brand colour for the LIGHT scheme: launchers draw the
54
+ * adaptive background in both themes, and the light one is the one that reads as
55
+ * "the" app colour. Falls back to an untagged primary colour, else null.
56
+ */
57
+ export declare function parseMetainfoBranding(xml: string): string | null;
58
+ /** `appResourcesPath` out of a `nativescript.config.ts`, read as text: evaluating it would need its imports. */
59
+ export declare function parseAppResourcesPath(configText: string): string | null;
60
+ export declare function resolveResourcesName(input: {
61
+ flag?: string;
62
+ configured?: string;
63
+ nativescriptConfig: string | null;
64
+ }): string;
65
+ export declare function resolveBackground(input: {
66
+ flag?: string;
67
+ configured?: string;
68
+ fromMetainfo: string | null;
69
+ }): string;
70
+ /**
71
+ * Which of the discovered icon files is the app icon and which the symbolic one.
72
+ * The `scalable/` SVG wins like in `resolveAppIcon`; a symbolic icon named for it
73
+ * (`<id>-symbolic.svg`) wins over a stranger.
74
+ */
75
+ export declare function pickIconSources(files: readonly string[]): {
76
+ icon: string | null;
77
+ symbolic: string | null;
78
+ };
79
+ /** Where the hicolor theme keeps the symbolic twin of `…/scalable/apps/<id>.svg`. */
80
+ export declare function symbolicSibling(iconPath: string): string;
81
+ /**
82
+ * The SVG to rasterize for one kind. `legacy` and `splash` render the icon
83
+ * itself (a GNOME icon carries its own silhouette, so a legacy launcher entry
84
+ * needs no plate behind it); the others are a wrapper that embeds the icon as a
85
+ * data URI, because the rasterizer renders a whole document at a size and cannot
86
+ * place a picture inside a larger transparent canvas.
87
+ */
88
+ export declare function wrapperSvg(kind: 'round' | 'foreground' | 'monochrome', icon: Uint8Array, background: string): string;
89
+ export interface AndroidIconsStamp {
90
+ generator: number;
91
+ /** sha256 over the inputs. */
92
+ inputs: string;
93
+ /** Relative to the resources directory, posix. */
94
+ files: string[];
95
+ }
96
+ export declare function hashInputs(input: {
97
+ icon: Uint8Array;
98
+ symbolic: Uint8Array | null;
99
+ background: string;
100
+ }): string;
101
+ /** The stamp's paths are relative to the resources directory, so it survives a move of the project. */
102
+ export declare function buildStamp(inputs: string, plan: AndroidIconPlan, resPrefix: string): AndroidIconsStamp;
103
+ export type StampVerdict = {
104
+ current: true;
105
+ } | {
106
+ current: false;
107
+ reason: string;
108
+ };
109
+ export declare function checkStamp(existing: string | null, expectedInputs: string, fileExists: (relativePath: string) => boolean): StampVerdict;
@@ -0,0 +1,215 @@
1
+ // The Android launcher icon and splash logo, derived from the ONE app icon.
2
+ //
3
+ // A NativeScript app's `App_Resources/Android` ships the template's blue "N" until
4
+ // someone hand-maintains five densities of `ic_launcher.png`, an adaptive-icon
5
+ // foreground, a background colour and a splash logo. A GNOME app already declares
6
+ // its icon once (`hicolor/scalable/apps/<id>.svg`, optionally `…-symbolic.svg`, and
7
+ // the metainfo's `<branding>` colour), so this module holds the PURE half of
8
+ // translating that into Android resources: geometry, XML text, the stamp. The
9
+ // rasterizing (`rasterizeSvg`, librsvg in a child GJS — see icons.ts) is the
10
+ // command's, so none of this needs a GJS to be tested.
11
+ //
12
+ // THE RASTER IS NEVER COMPARED. librsvg versions may antialias differently, so
13
+ // `--check` reads a stamp (a hash of the INPUTS plus the files written) instead of
14
+ // diffing PNG bytes — same reason icons.ts says the stage, not the raster, is the
15
+ // reproducible thing.
16
+ import { createHash } from 'node:crypto';
17
+ import { basename, dirname, extname, join, posix } from 'node:path';
18
+ import { isSymbolicIcon } from './icons.js';
19
+ /** Bump when the OUTPUT changes for the same inputs, so every project's `--check` goes stale once. */
20
+ export const ANDROID_ICONS_GENERATOR_VERSION = 1;
21
+ export const ANDROID_ICONS_STAMP = '.gjsify-android-icons.json';
22
+ export const DEFAULT_RESOURCES_DIR = 'App_Resources';
23
+ export const DEFAULT_BACKGROUND = '#FFFFFF';
24
+ /** Launcher icons have no ldpi bucket any more; the splash set keeps the template's. */
25
+ export const LAUNCHER_DENSITIES = [
26
+ { name: 'mdpi', scale: 1 },
27
+ { name: 'hdpi', scale: 1.5 },
28
+ { name: 'xhdpi', scale: 2 },
29
+ { name: 'xxhdpi', scale: 3 },
30
+ { name: 'xxxhdpi', scale: 4 },
31
+ ];
32
+ export const SPLASH_DENSITIES = [{ name: 'ldpi', scale: 0.75 }, ...LAUNCHER_DENSITIES];
33
+ /** Legacy launcher icon edge in dp, and the adaptive layers' (the visible window is the inner 72dp). */
34
+ export const LEGACY_DP = 48;
35
+ export const ADAPTIVE_DP = 108;
36
+ /** The part of the adaptive canvas no launcher mask may cut. */
37
+ export const SAFE_ZONE_DP = 66;
38
+ /** The splash logo's edge in dp; the template's wordmark is 200dp wide, a square mark reads smaller. */
39
+ export const SPLASH_DP = 96;
40
+ /** How much of a round legacy icon's circle the picture fills. */
41
+ export const ROUND_INNER_RATIO = 0.72;
42
+ function px(dp, scale) {
43
+ return Math.round(dp * scale);
44
+ }
45
+ /**
46
+ * Every file the generator owns, from two facts: the background colour and
47
+ * whether a monochrome layer exists. `<monochrome>` is omitted without a symbolic
48
+ * icon — a launcher with an empty one would theme the icon to nothing.
49
+ */
50
+ export function planAndroidIcons(opts) {
51
+ const png = [];
52
+ for (const { name, scale } of LAUNCHER_DENSITIES) {
53
+ const dir = `mipmap-${name}`;
54
+ png.push({ path: `${dir}/ic_launcher.png`, kind: 'legacy', size: px(LEGACY_DP, scale) });
55
+ png.push({ path: `${dir}/ic_launcher_round.png`, kind: 'round', size: px(LEGACY_DP, scale) });
56
+ png.push({ path: `${dir}/ic_launcher_foreground.png`, kind: 'foreground', size: px(ADAPTIVE_DP, scale) });
57
+ if (opts.monochrome) {
58
+ png.push({ path: `${dir}/ic_launcher_monochrome.png`, kind: 'monochrome', size: px(ADAPTIVE_DP, scale) });
59
+ }
60
+ }
61
+ for (const { name, scale } of SPLASH_DENSITIES) {
62
+ png.push({ path: `drawable-${name}/logo.png`, kind: 'splash', size: px(SPLASH_DP, scale) });
63
+ }
64
+ const adaptive = adaptiveIconXml(opts.monochrome);
65
+ return {
66
+ png,
67
+ text: [
68
+ { path: 'values/ic_launcher_background.xml', content: backgroundXml(opts.background) },
69
+ { path: 'mipmap-anydpi-v26/ic_launcher.xml', content: adaptive },
70
+ { path: 'mipmap-anydpi-v26/ic_launcher_round.xml', content: adaptive },
71
+ ],
72
+ };
73
+ }
74
+ export function backgroundXml(color) {
75
+ return `<?xml version="1.0" encoding="utf-8"?>
76
+ <resources>
77
+ <color name="ic_launcher_background">${color}</color>
78
+ </resources>
79
+ `;
80
+ }
81
+ export function adaptiveIconXml(monochrome) {
82
+ return `<?xml version="1.0" encoding="utf-8"?>
83
+ <adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
84
+ <background android:drawable="@color/ic_launcher_background"/>
85
+ <foreground android:drawable="@mipmap/ic_launcher_foreground"/>
86
+ ${monochrome ? ' <monochrome android:drawable="@mipmap/ic_launcher_monochrome"/>\n' : ''}</adaptive-icon>
87
+ `;
88
+ }
89
+ /** A `#rgb` or `#rrggbb` colour as `#RRGGBB`, or null. */
90
+ export function normalizeColor(value) {
91
+ const match = /^#([0-9a-f]{3}|[0-9a-f]{6})$/i.exec(value.trim());
92
+ if (match === null)
93
+ return null;
94
+ const hex = match[1];
95
+ const full = hex.length === 3 ? [...hex].map((c) => c + c).join('') : hex;
96
+ return `#${full.toUpperCase()}`;
97
+ }
98
+ /**
99
+ * The metainfo's primary brand colour for the LIGHT scheme: launchers draw the
100
+ * adaptive background in both themes, and the light one is the one that reads as
101
+ * "the" app colour. Falls back to an untagged primary colour, else null.
102
+ */
103
+ export function parseMetainfoBranding(xml) {
104
+ let untagged = null;
105
+ for (const match of xml.matchAll(/<color\b([^>]*)>\s*([^<\s]+)\s*<\/color>/g)) {
106
+ const attrs = match[1] ?? '';
107
+ if (!/type\s*=\s*["']primary["']/.test(attrs))
108
+ continue;
109
+ const color = normalizeColor(match[2] ?? '');
110
+ if (color === null)
111
+ continue;
112
+ const scheme = /scheme_preference\s*=\s*["']([^"']+)["']/.exec(attrs)?.[1];
113
+ if (scheme === 'light')
114
+ return color;
115
+ if (scheme === undefined && untagged === null)
116
+ untagged = color;
117
+ }
118
+ return untagged;
119
+ }
120
+ /** `appResourcesPath` out of a `nativescript.config.ts`, read as text: evaluating it would need its imports. */
121
+ export function parseAppResourcesPath(configText) {
122
+ return /\bappResourcesPath\s*:\s*["'`]([^"'`]+)["'`]/.exec(configText)?.[1] ?? null;
123
+ }
124
+ export function resolveResourcesName(input) {
125
+ return (input.flag ??
126
+ input.configured ??
127
+ (input.nativescriptConfig === null ? null : parseAppResourcesPath(input.nativescriptConfig)) ??
128
+ DEFAULT_RESOURCES_DIR);
129
+ }
130
+ export function resolveBackground(input) {
131
+ const explicit = input.flag ?? input.configured;
132
+ if (explicit !== undefined) {
133
+ const color = normalizeColor(explicit);
134
+ if (color === null) {
135
+ throw new Error(`gjsify android-icons: \`${explicit}\` is not a colour — write it as #rrggbb.`);
136
+ }
137
+ return color;
138
+ }
139
+ return input.fromMetainfo ?? DEFAULT_BACKGROUND;
140
+ }
141
+ /**
142
+ * Which of the discovered icon files is the app icon and which the symbolic one.
143
+ * The `scalable/` SVG wins like in `resolveAppIcon`; a symbolic icon named for it
144
+ * (`<id>-symbolic.svg`) wins over a stranger.
145
+ */
146
+ export function pickIconSources(files) {
147
+ const svgs = files.filter((file) => extname(file).toLowerCase() === '.svg');
148
+ const scalable = svgs.filter((file) => !isSymbolicIcon(file));
149
+ const icon = scalable.find((file) => /(?:^|[\\/])scalable[\\/]/.test(file)) ?? scalable[0] ?? null;
150
+ const symbolics = svgs.filter(isSymbolicIcon);
151
+ const stem = icon === null ? null : `${basename(icon, '.svg')}-symbolic`;
152
+ const symbolic = symbolics.find((file) => basename(file, '.svg') === stem) ?? symbolics[0] ?? null;
153
+ return { icon, symbolic };
154
+ }
155
+ /** Where the hicolor theme keeps the symbolic twin of `…/scalable/apps/<id>.svg`. */
156
+ export function symbolicSibling(iconPath) {
157
+ return join(dirname(dirname(dirname(iconPath))), 'symbolic', 'apps', `${basename(iconPath, '.svg')}-symbolic.svg`);
158
+ }
159
+ function dataUri(svg) {
160
+ return `data:image/svg+xml;base64,${Buffer.from(svg).toString('base64')}`;
161
+ }
162
+ /**
163
+ * The SVG to rasterize for one kind. `legacy` and `splash` render the icon
164
+ * itself (a GNOME icon carries its own silhouette, so a legacy launcher entry
165
+ * needs no plate behind it); the others are a wrapper that embeds the icon as a
166
+ * data URI, because the rasterizer renders a whole document at a size and cannot
167
+ * place a picture inside a larger transparent canvas.
168
+ */
169
+ export function wrapperSvg(kind, icon, background) {
170
+ const xmlns = 'xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"';
171
+ if (kind === 'round') {
172
+ const inner = 100 * ROUND_INNER_RATIO;
173
+ const offset = (100 - inner) / 2;
174
+ return (`<svg ${xmlns} width="100" height="100" viewBox="0 0 100 100">` +
175
+ `<circle cx="50" cy="50" r="50" fill="${background}"/>` +
176
+ `<image x="${offset}" y="${offset}" width="${inner}" height="${inner}" xlink:href="${dataUri(icon)}"/></svg>`);
177
+ }
178
+ const offset = (ADAPTIVE_DP - SAFE_ZONE_DP) / 2;
179
+ return (`<svg ${xmlns} width="${ADAPTIVE_DP}" height="${ADAPTIVE_DP}" viewBox="0 0 ${ADAPTIVE_DP} ${ADAPTIVE_DP}">` +
180
+ `<image x="${offset}" y="${offset}" width="${SAFE_ZONE_DP}" height="${SAFE_ZONE_DP}" xlink:href="${dataUri(icon)}"/></svg>`);
181
+ }
182
+ function sha256(bytes) {
183
+ return createHash('sha256').update(bytes).digest('hex');
184
+ }
185
+ export function hashInputs(input) {
186
+ return sha256(JSON.stringify({
187
+ generator: ANDROID_ICONS_GENERATOR_VERSION,
188
+ icon: sha256(input.icon),
189
+ symbolic: input.symbolic === null ? null : sha256(input.symbolic),
190
+ background: input.background,
191
+ }));
192
+ }
193
+ /** The stamp's paths are relative to the resources directory, so it survives a move of the project. */
194
+ export function buildStamp(inputs, plan, resPrefix) {
195
+ const files = [...plan.png.map((p) => p.path), ...plan.text.map((t) => t.path)].map((p) => posix.join(resPrefix, p));
196
+ return { generator: ANDROID_ICONS_GENERATOR_VERSION, inputs, files: files.sort() };
197
+ }
198
+ export function checkStamp(existing, expectedInputs, fileExists) {
199
+ if (existing === null)
200
+ return { current: false, reason: 'was never generated' };
201
+ let stamp;
202
+ try {
203
+ stamp = JSON.parse(existing);
204
+ }
205
+ catch {
206
+ return { current: false, reason: 'has an unreadable stamp' };
207
+ }
208
+ if (stamp.generator !== ANDROID_ICONS_GENERATOR_VERSION || stamp.inputs !== expectedInputs) {
209
+ return { current: false, reason: 'is out of date with the icon, colour or generator' };
210
+ }
211
+ const missing = (stamp.files ?? []).find((file) => !fileExists(file));
212
+ if (missing !== undefined)
213
+ return { current: false, reason: `lacks ${missing}` };
214
+ return { current: true };
215
+ }
@@ -69,7 +69,7 @@ export type FormatId = 'deb' | 'rpm' | 'flatpak' | 'appimage' | 'macos-app' | 'm
69
69
  * instead of being rediscovered per call site. A `.deb` says
70
70
  * `Depends: gir1.2-gtk-4.0`, an `.rpm` says `Requires: gtk4`, and both are
71
71
  * package names in a distribution's namespace. A Flatpak has no such field: it
72
- * declares ONE dependency, its runtime (`org.gnome.Platform//50`), in the
72
+ * declares ONE dependency, its runtime (`org.gnome.Platform//51`), in the
73
73
  * manifest — and inside that runtime a typelib is either present or nothing on
74
74
  * the system provides it, so there is no package to name and no gap a name
75
75
  * could close.