@mirinjs/cli 0.1.0-alpha.19 → 0.1.0-alpha.20

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mirinjs/cli",
3
- "version": "0.1.0-alpha.19",
3
+ "version": "0.1.0-alpha.20",
4
4
  "description": "CLI for mirin apps: dev, build, init.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -27,15 +27,15 @@
27
27
  "bun": ">=1.2.0"
28
28
  },
29
29
  "dependencies": {
30
- "create-mirinjs": "0.1.0-alpha.19",
31
- "mirinjs": "0.1.0-alpha.19"
30
+ "create-mirinjs": "0.1.0-alpha.20",
31
+ "mirinjs": "0.1.0-alpha.20"
32
32
  },
33
33
  "optionalDependencies": {
34
- "@mirinjs/darwin-arm64": "0.1.0-alpha.19",
35
- "@mirinjs/win32-x64": "0.1.0-alpha.19",
36
- "@mirinjs/win32-arm64": "0.1.0-alpha.19",
37
- "@mirinjs/linux-arm64": "0.1.0-alpha.19",
38
- "@mirinjs/linux-x64": "0.1.0-alpha.19"
34
+ "@mirinjs/darwin-arm64": "0.1.0-alpha.20",
35
+ "@mirinjs/win32-x64": "0.1.0-alpha.20",
36
+ "@mirinjs/win32-arm64": "0.1.0-alpha.20",
37
+ "@mirinjs/linux-arm64": "0.1.0-alpha.20",
38
+ "@mirinjs/linux-x64": "0.1.0-alpha.20"
39
39
  },
40
40
  "publishConfig": {
41
41
  "access": "public"
package/src/build.ts CHANGED
@@ -66,10 +66,10 @@ function patchPeToGuiSubsystem(exePath: string): void {
66
66
  async function brandWindowsExe(
67
67
  exe: string,
68
68
  opts: {
69
- projectDir: string;
70
69
  work: string;
71
70
  appName: string;
72
71
  version: string;
72
+ /** Absolute path to the icon source. */
73
73
  icon?: string;
74
74
  publisher: string;
75
75
  },
@@ -83,7 +83,7 @@ async function brandWindowsExe(
83
83
  }
84
84
  const args: string[] = [];
85
85
  if (opts.icon) {
86
- const ico = makeWindowsIcon(join(opts.projectDir, opts.icon), join(opts.work, "exe-icon.ico"), {
86
+ const ico = makeWindowsIcon(opts.icon, join(opts.work, "exe-icon.ico"), {
87
87
  onlyLargest: true,
88
88
  });
89
89
  if (ico) args.push("--set-icon", ico);
@@ -194,6 +194,7 @@ export async function build(
194
194
  const linux: boolean | import("mirinjs").LinuxConfig = config.linux ?? true;
195
195
  const publisher: string = config.publisher ?? appName;
196
196
  const cefLocales = normalizeCefLocales(config.cef?.locales);
197
+ const appIconPath: string | undefined = config.icon ? join(projectDir, config.icon) : undefined;
197
198
 
198
199
  console.log(`[mirin build] ${appName} ${version}`);
199
200
 
@@ -221,11 +222,10 @@ export async function build(
221
222
  if (IS_WINDOWS) {
222
223
  patchPeToGuiSubsystem(hostExe);
223
224
  await brandWindowsExe(hostExe, {
224
- projectDir,
225
225
  work,
226
226
  appName,
227
227
  version,
228
- icon: config.icon,
228
+ icon: appIconPath,
229
229
  publisher,
230
230
  });
231
231
  }
@@ -266,7 +266,7 @@ export async function build(
266
266
  helperExe: artifacts.helperBin,
267
267
  cefPath: artifacts.cefPath,
268
268
  cefLocales,
269
- icon: config.icon ? join(projectDir, config.icon) : undefined,
269
+ icon: appIconPath,
270
270
  resources: { ...resources, sidecars: sidecars.map((s) => ({ name: s.name, src: s.src })) },
271
271
  })
272
272
  : IS_LINUX
@@ -278,7 +278,7 @@ export async function build(
278
278
  helperExe: artifacts.helperBin,
279
279
  cefPath: artifacts.cefPath,
280
280
  cefLocales,
281
- icon: config.icon ? join(projectDir, config.icon) : undefined,
281
+ icon: appIconPath,
282
282
  resources: {
283
283
  ...resources,
284
284
  sidecars: sidecars.map((s) => ({ name: s.name, src: s.src })),
@@ -294,7 +294,7 @@ export async function build(
294
294
  cefPath: artifacts.cefPath,
295
295
  cefLocales,
296
296
  version,
297
- icon: config.icon ? join(projectDir, config.icon) : undefined,
297
+ icon: appIconPath,
298
298
  signIdentity,
299
299
  urlSchemes: config.urlSchemes,
300
300
  resources: { ...resources, sidecars },
@@ -316,7 +316,7 @@ export async function build(
316
316
  publisher,
317
317
  outDir,
318
318
  projectDir,
319
- icon: config.icon ? join(projectDir, config.icon) : undefined,
319
+ icon: appIconPath,
320
320
  options: typeof linux === "object" ? linux : {},
321
321
  formats,
322
322
  });
@@ -340,7 +340,7 @@ export async function build(
340
340
  inno,
341
341
  linux,
342
342
  publisher,
343
- icon: config.icon ? join(projectDir, config.icon) : undefined,
343
+ icon: appIconPath,
344
344
  signIdentity,
345
345
  };
346
346
  }
@@ -22,6 +22,7 @@ import {
22
22
  } from "node:fs";
23
23
  import { join } from "node:path";
24
24
  import { $ } from "bun";
25
+ import { isIconComposerDoc, writeAppearanceCatalog } from "../../icons/macos/index.ts";
25
26
  import { pruneMacCefLocales } from "../shared/cef-locales.ts";
26
27
 
27
28
  const FRAMEWORK = "Chromium Embedded Framework.framework";
@@ -46,7 +47,10 @@ export interface BundleOptions {
46
47
  cefLocales?: string[];
47
48
  /** App version → Info.plist CFBundleShortVersionString/CFBundleVersion. */
48
49
  version?: string;
49
- /** App icon source: a .icns, a .iconset dir, or a square .png. Optional. */
50
+ /**
51
+ * App icon source: a .icns, a .iconset dir, a square .png, or an Icon Composer
52
+ * .icon document (the only form carrying appearance variants). Optional.
53
+ */
50
54
  icon?: string;
51
55
  /** Codesign identity; "-" (default) is ad-hoc. Set to a Developer ID to ship. */
52
56
  signIdentity?: string;
@@ -109,19 +113,8 @@ const ICONSET_RENDITIONS = [
109
113
  { name: "icon_512x512@2x.png", px: 1024 },
110
114
  ];
111
115
 
112
- /**
113
- * Render the app icon to `Resources/icon.icns` and return the CFBundleIconFile
114
- * stem ("icon"), or undefined if there's no usable source. Accepts a `.icns`
115
- * (copied), a `.iconset` directory (iconutil), or a square `.png` (rendered to
116
- * a full iconset via sips, then iconutil).
117
- */
118
- async function writeIcon(iconSrc: string, resources: string): Promise<string | undefined> {
119
- if (!existsSync(iconSrc)) {
120
- console.warn(`[mirin] icon not found, skipping: ${iconSrc}`);
121
- return undefined;
122
- }
123
- const icns = join(resources, "icon.icns");
124
-
116
+ /** Render one icon source to `Resources/icon.icns`. */
117
+ async function writeIcns(iconSrc: string, icns: string, resources: string): Promise<void> {
125
118
  if (iconSrc.endsWith(".icns")) {
126
119
  cpSync(iconSrc, icns);
127
120
  } else if (iconSrc.endsWith(".iconset")) {
@@ -136,7 +129,38 @@ async function writeIcon(iconSrc: string, resources: string): Promise<string | u
136
129
  await $`iconutil -c icns ${iconset} -o ${icns}`.quiet();
137
130
  rmSync(iconset, { recursive: true, force: true });
138
131
  }
139
- return "icon";
132
+ }
133
+
134
+ /** The Info.plist keys an icon contributes, empty when there's no usable source. */
135
+ interface IconKeys {
136
+ /** CFBundleIconFile — `Resources/icon.icns`, read by macOS 25 and earlier. */
137
+ iconFile?: string;
138
+ /** CFBundleIconName — `Resources/Assets.car`, preferred on macOS 26+. */
139
+ iconName?: string;
140
+ }
141
+
142
+ /**
143
+ * Render the app icon into Resources. An `.icon` document compiles to an
144
+ * `Assets.car` (macOS 26+ light/dark/tinted) and contributes the `.icns` actool
145
+ * derives from it; every other source takes the plain iconutil path.
146
+ */
147
+ async function writeIcon(iconSrc: string, resources: string, work: string): Promise<IconKeys> {
148
+ if (!existsSync(iconSrc)) {
149
+ console.warn(`[mirin] icon not found, skipping: ${iconSrc}`);
150
+ return {};
151
+ }
152
+
153
+ const icns = join(resources, "icon.icns");
154
+ if (isIconComposerDoc(iconSrc)) {
155
+ // An Icon Composer document can't go through iconutil, so the only .icns
156
+ // available for it is the one actool derives.
157
+ const catalog = await writeAppearanceCatalog(iconSrc, resources, work);
158
+ if (catalog?.icns) cpSync(catalog.icns, icns);
159
+ return { iconFile: existsSync(icns) ? "icon" : undefined, iconName: catalog?.name };
160
+ }
161
+
162
+ await writeIcns(iconSrc, icns, resources);
163
+ return { iconFile: "icon" };
140
164
  }
141
165
 
142
166
  type PlistValue = string | boolean | PlistValue[] | { [k: string]: PlistValue };
@@ -188,8 +212,12 @@ export async function buildAppBundle(opts: BundleOptions): Promise<{ app: string
188
212
  cpSync(opts.coreDylib, join(macos, "libmirin_core.dylib"));
189
213
 
190
214
  // Render the icon (if any) into Resources before writing the plist, so we
191
- // only set CFBundleIconFile when an icon was actually produced.
192
- const iconFile = opts.icon ? await writeIcon(opts.icon, join(contents, "Resources")) : undefined;
215
+ // only set the icon keys when an icon was actually produced.
216
+ const iconWork = join(opts.outDir, ".mirin-icon");
217
+ const { iconFile, iconName } = opts.icon
218
+ ? await writeIcon(opts.icon, join(contents, "Resources"), iconWork)
219
+ : ({} as IconKeys);
220
+ rmSync(iconWork, { recursive: true, force: true });
193
221
  const version = opts.version ?? "0.0.1";
194
222
 
195
223
  const info: Record<string, PlistValue> = {
@@ -209,6 +237,9 @@ export async function buildAppBundle(opts: BundleOptions): Promise<{ app: string
209
237
  LSEnvironment: { MallocNanoZone: "0" },
210
238
  };
211
239
  if (iconFile) info.CFBundleIconFile = iconFile;
240
+ // macOS 26+ resolves the appearance-aware icon out of Assets.car via this key
241
+ // and falls back to CFBundleIconFile when the catalog has no match.
242
+ if (iconName) info.CFBundleIconName = iconName;
212
243
  // Deep-link schemes: register this app as the macOS handler for app://-style
213
244
  // URLs (e.g. anko://…). Delivered at runtime via app.on("open-url").
214
245
  if (opts.urlSchemes?.length) {
@@ -0,0 +1,96 @@
1
+ /**
2
+ * Compile macOS appearance-variant app icons (light / dark / tinted, macOS 26+).
3
+ *
4
+ * `.icns` has no notion of appearance — the Dock picks a variant out of a
5
+ * compiled asset catalog keyed by `CFBundleIconName`, and the only input that
6
+ * produces one is an Icon Composer `.icon` document. `actool` compiles it to
7
+ * the same `Assets.car` shape Apple's own bundled apps ship.
8
+ *
9
+ * Best-effort throughout: `actool` only ships with a full Xcode, so a machine
10
+ * with just Command Line Tools warns and keeps the plain `.icns`.
11
+ */
12
+
13
+ import { cpSync, existsSync, mkdirSync, rmSync } from "node:fs";
14
+ import { join, resolve } from "node:path";
15
+ import { $ } from "bun";
16
+
17
+ /** True when the source is an Icon Composer document, which actool reads directly. */
18
+ export const isIconComposerDoc = (path: string): boolean => path.endsWith(".icon");
19
+
20
+ /** Asset-catalog icon name; also the `CFBundleIconName` written to Info.plist. */
21
+ const ICON_NAME = "AppIcon";
22
+
23
+ /**
24
+ * Locate `actool`. `xcrun` honours the selected developer dir, which is often
25
+ * Command Line Tools (no actool), so fall back to a full Xcode if one is
26
+ * installed but not selected.
27
+ */
28
+ async function findActool(): Promise<string | undefined> {
29
+ const xcrun = await $`xcrun --find actool`.nothrow().quiet();
30
+ if (xcrun.exitCode === 0) {
31
+ const path = xcrun.stdout.toString().trim();
32
+ if (path && existsSync(path)) return path;
33
+ }
34
+ for (const app of ["/Applications/Xcode.app", "/Applications/Xcode-beta.app"]) {
35
+ const path = join(app, "Contents/Developer/usr/bin/actool");
36
+ if (existsSync(path)) return path;
37
+ }
38
+ return undefined;
39
+ }
40
+
41
+ export interface AppearanceCatalog {
42
+ /** Goes in Info.plist as `CFBundleIconName`. */
43
+ name: string;
44
+ /** The legacy `.icns` actool derives alongside the catalog, if it emitted one. */
45
+ icns?: string;
46
+ }
47
+
48
+ /**
49
+ * Compile `icon` into `<Resources>/Assets.car`, or return undefined if the
50
+ * catalog could not be built (no actool, unreadable art) — callers then keep
51
+ * the plain `.icns` path.
52
+ */
53
+ export async function writeAppearanceCatalog(
54
+ iconDoc: string,
55
+ resourcesDir: string,
56
+ workDir: string,
57
+ ): Promise<AppearanceCatalog | undefined> {
58
+ // actool resolves its own arguments, so hand it absolute paths regardless of
59
+ // what the caller passed and of the subprocess working directory.
60
+ const resources = resolve(resourcesDir);
61
+ const work = resolve(workDir);
62
+ const actool = await findActool();
63
+ if (!actool) {
64
+ console.warn(
65
+ "[mirin] actool not found — appearance variants need a full Xcode " +
66
+ "(Command Line Tools alone is not enough); shipping the default icon only.",
67
+ );
68
+ return undefined;
69
+ }
70
+
71
+ rmSync(work, { recursive: true, force: true });
72
+ mkdirSync(work, { recursive: true });
73
+
74
+ const doc = join(work, `${ICON_NAME}.icon`);
75
+ cpSync(iconDoc, doc, { recursive: true });
76
+
77
+ const compiled = join(work, "compiled");
78
+ mkdirSync(compiled, { recursive: true });
79
+ const res =
80
+ await $`${actool} --output-format human-readable-text --notices --warnings --app-icon ${ICON_NAME} --output-partial-info-plist ${join(work, "partial.plist")} --enable-on-demand-resources NO --target-device mac --minimum-deployment-target 26.0 --platform macosx --compile ${compiled} ${doc}`
81
+ .nothrow()
82
+ .quiet();
83
+
84
+ const car = join(compiled, "Assets.car");
85
+ if (res.exitCode !== 0 || !existsSync(car)) {
86
+ console.warn(
87
+ `[mirin] actool failed (exit ${res.exitCode}) — shipping the default icon only.\n` +
88
+ res.stdout.toString().trim(),
89
+ );
90
+ return undefined;
91
+ }
92
+
93
+ cpSync(car, join(resources, "Assets.car"));
94
+ const icns = join(compiled, `${ICON_NAME}.icns`);
95
+ return { name: ICON_NAME, icns: existsSync(icns) ? icns : undefined };
96
+ }
@@ -0,0 +1 @@
1
+ export { isIconComposerDoc, writeAppearanceCatalog } from "./appearance.ts";