code2app 0.1.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 (45) hide show
  1. package/README.md +111 -0
  2. package/assets/icon-source.png +0 -0
  3. package/bin/cli.js +248 -0
  4. package/dist/index.html +24 -0
  5. package/docs/APP_STORES.md +129 -0
  6. package/docs/BUILDING.md +134 -0
  7. package/docs/LOCAL_APPS.md +116 -0
  8. package/docs/MOBILE.md +78 -0
  9. package/docs/OAUTH.md +76 -0
  10. package/package.json +66 -0
  11. package/profiles/README.md +56 -0
  12. package/profiles/example.json +37 -0
  13. package/scripts/build-sidecar.mjs +80 -0
  14. package/scripts/configure.mjs +222 -0
  15. package/scripts/generate-icons.mjs +53 -0
  16. package/scripts/generate-placeholder-icons.mjs +186 -0
  17. package/scripts/lib/png.mjs +141 -0
  18. package/scripts/profile.mjs +147 -0
  19. package/src-tauri/Cargo.lock +5832 -0
  20. package/src-tauri/Cargo.toml +47 -0
  21. package/src-tauri/build.rs +3 -0
  22. package/src-tauri/capabilities/default.json +15 -0
  23. package/src-tauri/capabilities/remote.json +17 -0
  24. package/src-tauri/icons/128x128.png +0 -0
  25. package/src-tauri/icons/128x128@2x.png +0 -0
  26. package/src-tauri/icons/32x32.png +0 -0
  27. package/src-tauri/icons/64x64.png +0 -0
  28. package/src-tauri/icons/Square107x107Logo.png +0 -0
  29. package/src-tauri/icons/Square142x142Logo.png +0 -0
  30. package/src-tauri/icons/Square150x150Logo.png +0 -0
  31. package/src-tauri/icons/Square284x284Logo.png +0 -0
  32. package/src-tauri/icons/Square30x30Logo.png +0 -0
  33. package/src-tauri/icons/Square310x310Logo.png +0 -0
  34. package/src-tauri/icons/Square44x44Logo.png +0 -0
  35. package/src-tauri/icons/Square71x71Logo.png +0 -0
  36. package/src-tauri/icons/Square89x89Logo.png +0 -0
  37. package/src-tauri/icons/StoreLogo.png +0 -0
  38. package/src-tauri/icons/icon.icns +0 -0
  39. package/src-tauri/icons/icon.ico +0 -0
  40. package/src-tauri/icons/icon.png +0 -0
  41. package/src-tauri/src/generated_config.rs +22 -0
  42. package/src-tauri/src/lib.rs +212 -0
  43. package/src-tauri/src/main.rs +6 -0
  44. package/src-tauri/tauri.conf.json +84 -0
  45. package/vitest.config.mjs +35 -0
@@ -0,0 +1,222 @@
1
+ #!/usr/bin/env node
2
+ // Regenerates everything that encodes an app's identity — src-tauri/tauri.conf.json,
3
+ // the Rust-side constants it implies, and the remote capability's origin scope —
4
+ // from a single JSON profile in profiles/. That indirection is what makes this
5
+ // package generic: swap the profile, re-run this script, and the wrapper is a
6
+ // different app. Nothing downstream (icons, CI, the OAuth handoff, the sidecar
7
+ // bridge) reads the profile directly, so there is exactly one place that
8
+ // understands the profile -> Tauri config mapping.
9
+
10
+ import { mkdirSync, rmSync, existsSync, writeFileSync } from "node:fs";
11
+ import { fileURLToPath } from "node:url";
12
+ import path from "node:path";
13
+ import { loadProfile, resolveProfileName } from "./profile.mjs";
14
+
15
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
16
+
17
+ export function configure(rootDir, profileName) {
18
+ const profile = loadProfile(rootDir, profileName);
19
+ const win = profile.window;
20
+
21
+ const tauriConf = {
22
+ $schema: "https://schema.tauri.app/config/2",
23
+ productName: profile.productName,
24
+ version: profile.version,
25
+ identifier: profile.identifier,
26
+ build: {
27
+ frontendDist: "../dist",
28
+ },
29
+ app: {
30
+ withGlobalTauri: true,
31
+ windows: [
32
+ {
33
+ label: "main",
34
+ title: win.title,
35
+ // Remote mode points the window straight at the live site; local mode
36
+ // leaves it on the bundled dist/ frontend Tauri serves itself.
37
+ url: profile.mode === "remote" ? profile.url : "index.html",
38
+ width: win.width,
39
+ height: win.height,
40
+ minWidth: win.minWidth,
41
+ minHeight: win.minHeight,
42
+ resizable: win.resizable,
43
+ fullscreen: win.fullscreen,
44
+ center: true,
45
+ },
46
+ ],
47
+ security: {
48
+ // A local-mode app has no remote content to scope, so it doesn't load
49
+ // the remote capability at all (configure removes the file too).
50
+ capabilities: profile.trustedOrigins.length > 0 ? ["default", "remote"] : ["default"],
51
+ },
52
+ },
53
+ bundle: {
54
+ active: true,
55
+ targets: "all",
56
+ publisher: profile.publisher ?? profile.copyright ?? profile.appName,
57
+ copyright: profile.copyright ?? "",
58
+ category: profile.category ?? "Productivity",
59
+ shortDescription: profile.shortDescription ?? "",
60
+ longDescription: profile.longDescription ?? profile.shortDescription ?? "",
61
+ icon: [
62
+ "icons/32x32.png",
63
+ "icons/128x128.png",
64
+ "icons/128x128@2x.png",
65
+ "icons/icon.icns",
66
+ "icons/icon.ico",
67
+ ],
68
+ windows: {
69
+ nsis: {
70
+ installMode: "currentUser",
71
+ },
72
+ },
73
+ macOS: {
74
+ minimumSystemVersion: profile.macos?.minimumSystemVersion ?? "10.15",
75
+ },
76
+ linux: {
77
+ deb: { depends: [] },
78
+ },
79
+ },
80
+ plugins: {},
81
+ };
82
+
83
+ // The updater plugin fails *initialization* without a `pubkey` — an
84
+ // `{ "active": false }` block is not an off switch, it's a crash on launch.
85
+ // So a profile that hasn't configured updates gets no updater config and no
86
+ // registered plugin at all (see UPDATER_ENABLED below).
87
+ if (profile.updater) {
88
+ tauriConf.plugins.updater = {
89
+ active: true,
90
+ pubkey: profile.updater.pubkey,
91
+ endpoints: profile.updater.endpoints,
92
+ };
93
+ }
94
+
95
+ if (profile.deepLinkScheme) {
96
+ tauriConf.plugins["deep-link"] = {
97
+ desktop: {
98
+ schemes: [profile.deepLinkScheme],
99
+ },
100
+ // Plain custom-scheme deep link (`<scheme>://...`), not a verified
101
+ // Android App Link / iOS Universal Link — the latter needs the site to
102
+ // host .well-known/assetlinks.json + apple-app-site-association, which is
103
+ // unnecessary complexity for a login handoff scheme that's never shown to
104
+ // the user as a clickable web link.
105
+ mobile: [
106
+ {
107
+ scheme: [profile.deepLinkScheme],
108
+ appLink: false,
109
+ },
110
+ ],
111
+ };
112
+ }
113
+
114
+ if (profile.sidecar) {
115
+ // Tauri resolves this to binaries/<name>-<target triple> at bundle time, so
116
+ // the build fails loudly if scripts/build-sidecar.mjs hasn't produced one
117
+ // for the platform being built.
118
+ tauriConf.bundle.externalBin = [`binaries/${profile.sidecar.name}`];
119
+ }
120
+
121
+ if (profile.android) {
122
+ tauriConf.bundle.android = {
123
+ minSdkVersion: profile.android.minSdkVersion ?? 24,
124
+ };
125
+ }
126
+
127
+ if (profile.ios) {
128
+ tauriConf.bundle.iOS = {
129
+ minimumSystemVersion: profile.ios.minimumSystemVersion ?? "14.0",
130
+ };
131
+ }
132
+
133
+ const written = [];
134
+ const write = (relPath, contents) => {
135
+ const outPath = path.join(rootDir, relPath);
136
+ mkdirSync(path.dirname(outPath), { recursive: true });
137
+ writeFileSync(outPath, contents, "utf8");
138
+ written.push(relPath);
139
+ };
140
+
141
+ write("src-tauri/tauri.conf.json", `${JSON.stringify(tauriConf, null, 2)}\n`);
142
+
143
+ // The same values the JSON config carries also have to exist on the Rust
144
+ // side: the desktop single-instance argv parser matches on the deep-link
145
+ // scheme, the OAuth handoff rebuilds an absolute callback URL from the app
146
+ // URL, and the sidecar command needs the bundled binary's name. Generating
147
+ // them into one const file is what keeps the two from drifting apart.
148
+ const rustArgs = profile.sidecar
149
+ ? `&[${profile.sidecar.args.map((a) => JSON.stringify(a)).join(", ")}]`
150
+ : "&[]";
151
+ write(
152
+ "src-tauri/src/generated_config.rs",
153
+ [
154
+ `// Generated by scripts/configure.mjs from profiles/${profile.name}.json. Do not edit by hand.`,
155
+ "",
156
+ "/// Custom URL scheme registered with the OS for the OAuth handoff, or \"\"",
157
+ "/// when the profile doesn't use one.",
158
+ `pub const DEEP_LINK_SCHEME: &str = ${JSON.stringify(profile.deepLinkScheme ?? "")};`,
159
+ "",
160
+ "/// Origin the main window loads in remote mode, or \"\" in local mode.",
161
+ `pub const APP_URL: &str = ${JSON.stringify(profile.url ?? "")};`,
162
+ "",
163
+ "/// Name of the bundled CLI exposed through the `sidecar_output` command,",
164
+ "/// or \"\" when the profile bundles none.",
165
+ `pub const SIDECAR_NAME: &str = ${JSON.stringify(profile.sidecar?.name ?? "")};`,
166
+ "",
167
+ "/// The fixed argument list that sidecar is always invoked with. Fixed, and",
168
+ "/// not passed in from the frontend, so the webview can't turn the bundled",
169
+ "/// binary into an arbitrary-argument process spawner.",
170
+ `pub const SIDECAR_ARGS: &[&str] = ${rustArgs};`,
171
+ "",
172
+ "/// Whether the profile configured auto-updates. False means the updater",
173
+ "/// plugin is never registered — registering it without a `pubkey` in",
174
+ "/// tauri.conf.json panics at startup rather than staying inert.",
175
+ `pub const UPDATER_ENABLED: bool = ${profile.updater ? "true" : "false"};`,
176
+ "",
177
+ ].join("\n"),
178
+ );
179
+
180
+ // capabilities/remote.json grants the wrapped site itself a narrow slice of
181
+ // IPC. Its scope is the profile's trustedOrigins, so generating the file is
182
+ // the only way that list can't drift from what the profile claims.
183
+ const remoteCapabilityPath = path.join(rootDir, "src-tauri/capabilities/remote.json");
184
+ if (profile.trustedOrigins.length > 0) {
185
+ write(
186
+ "src-tauri/capabilities/remote.json",
187
+ `${JSON.stringify(
188
+ {
189
+ $schema: "../gen/schemas/remote-schema.json",
190
+ identifier: "remote",
191
+ description:
192
+ "Scoped IPC access for the wrapped site itself (the profile's trustedOrigins), so its " +
193
+ "login page can ask the wrapper to open the system browser for OAuth. Nothing else is " +
194
+ "exposed to remote content.",
195
+ windows: ["main"],
196
+ remote: {
197
+ urls: profile.trustedOrigins.map((origin) => `${origin.replace(/\/+$/, "")}/*`),
198
+ },
199
+ permissions: ["opener:allow-open-url"],
200
+ },
201
+ null,
202
+ 2,
203
+ )}\n`,
204
+ );
205
+ } else if (existsSync(remoteCapabilityPath)) {
206
+ rmSync(remoteCapabilityPath);
207
+ written.push("src-tauri/capabilities/remote.json (removed — no trustedOrigins)");
208
+ }
209
+
210
+ return { profile, written };
211
+ }
212
+
213
+ if (import.meta.url === `file://${process.argv[1]}`) {
214
+ const rootDir = path.resolve(__dirname, "..");
215
+ const profileName = resolveProfileName(rootDir);
216
+ const { profile, written } = configure(rootDir, profileName);
217
+
218
+ const target = profile.mode === "remote" ? profile.url : "bundled dist/ frontend";
219
+ console.log(`[native-app-wrapper] configured profile "${profile.name}" (${profile.appName} -> ${target})`);
220
+ for (const relPath of written) console.log(` wrote ${relPath}`);
221
+ console.log(` run "npm run icons" if ${profile.name}'s iconSource changed`);
222
+ }
@@ -0,0 +1,53 @@
1
+ #!/usr/bin/env node
2
+ // Generates the full platform icon set (Windows .ico, macOS .icns, Linux/
3
+ // Android/iOS PNGs at every required size) from the active profile's single
4
+ // `iconSource` PNG, using the Tauri CLI's own icon generator so the output
5
+ // always matches what the installed @tauri-apps/cli version expects.
6
+ //
7
+ // This is the "I have real artwork" path. `node bin/cli.js icons` is the other
8
+ // one: it draws a neutral placeholder set with no artwork, no dependencies, and
9
+ // no Rust toolchain, so a freshly scaffolded copy builds before anyone has a
10
+ // logo. Run this once you do.
11
+
12
+ import { existsSync } from "node:fs";
13
+ import { fileURLToPath } from "node:url";
14
+ import path from "node:path";
15
+ import { spawnSync } from "node:child_process";
16
+ import { loadProfile, resolveProfileName } from "./profile.mjs";
17
+
18
+ const rootDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
19
+ const profile = loadProfile(rootDir, resolveProfileName(rootDir));
20
+
21
+ if (!profile.iconSource) {
22
+ throw new Error(`profiles/${profile.name}.json has no "iconSource"`);
23
+ }
24
+
25
+ const iconSource = path.resolve(path.dirname(profile.path), profile.iconSource);
26
+ if (!existsSync(iconSource)) {
27
+ throw new Error(
28
+ `profiles/${profile.name}.json's iconSource does not exist: ${iconSource}\n` +
29
+ "(run `node bin/cli.js icons` to draw a placeholder set instead)",
30
+ );
31
+ }
32
+
33
+ const outDir = path.join(rootDir, "src-tauri", "icons");
34
+ console.log(`[native-app-wrapper] generating icons from ${path.relative(rootDir, iconSource)} -> src-tauri/icons/`);
35
+
36
+ const result = spawnSync("npx", ["--yes", "@tauri-apps/cli@2", "icon", iconSource, "-o", outDir], {
37
+ cwd: rootDir,
38
+ stdio: "inherit",
39
+ });
40
+
41
+ if (result.status !== 0) {
42
+ console.error(
43
+ "[native-app-wrapper] icon generation failed. Make sure iconSource points at a square PNG " +
44
+ "(1024x1024 recommended) and that the Rust toolchain is installed (the Tauri CLI's icon " +
45
+ "command shells out to it).",
46
+ );
47
+ process.exit(result.status ?? 1);
48
+ }
49
+
50
+ console.log(
51
+ "[native-app-wrapper] icons written. Android/iOS project icons are re-derived from these the " +
52
+ "next time `npm run android:init` / `npm run ios:init` regenerates gen/android or gen/apple.",
53
+ );
@@ -0,0 +1,186 @@
1
+ #!/usr/bin/env node
2
+ // Draws a complete, buildable icon set from the active profile's colors — no
3
+ // source artwork, no image library, no network. `tauri build` refuses to bundle
4
+ // without every icon in tauri.conf.json's bundle.icon list existing, so a fresh
5
+ // copy of this package would otherwise be un-buildable until someone supplies a
6
+ // logo. This fills that gap with a neutral mark; swap in real artwork later by
7
+ // pointing the profile's `iconSource` at it and running `npm run icons`.
8
+ //
9
+ // Every size is drawn at its own resolution rather than downscaled from one
10
+ // master, so the 16px entries stay legible instead of turning to mush.
11
+
12
+ import { mkdirSync, writeFileSync } from "node:fs";
13
+ import { fileURLToPath } from "node:url";
14
+ import path from "node:path";
15
+ import {
16
+ blend,
17
+ coverage,
18
+ encodeIcns,
19
+ encodeIco,
20
+ encodePng,
21
+ hexToRgb,
22
+ roundedRectDistance,
23
+ } from "./lib/png.mjs";
24
+ import { loadProfile, resolveProfileName } from "./profile.mjs";
25
+
26
+ /** Windows Store logo assets Tauri's MSIX/AppX metadata expects alongside the core sizes. */
27
+ const SQUARE_LOGOS = {
28
+ "Square30x30Logo.png": 30,
29
+ "Square44x44Logo.png": 44,
30
+ "Square71x71Logo.png": 71,
31
+ "Square89x89Logo.png": 89,
32
+ "Square107x107Logo.png": 107,
33
+ "Square142x142Logo.png": 142,
34
+ "Square150x150Logo.png": 150,
35
+ "Square284x284Logo.png": 284,
36
+ "Square310x310Logo.png": 310,
37
+ "StoreLogo.png": 50,
38
+ };
39
+
40
+ const PLAIN_PNGS = {
41
+ "32x32.png": 32,
42
+ "64x64.png": 64,
43
+ "128x128.png": 128,
44
+ "128x128@2x.png": 256,
45
+ "icon.png": 1024,
46
+ };
47
+
48
+ const ICO_SIZES = [16, 32, 48, 64, 128, 256];
49
+
50
+ // OSType -> pixel size. These are the PNG-bearing ICNS entry types; the legacy
51
+ // uncompressed ones aren't needed by any macOS version Tauri supports.
52
+ const ICNS_TYPES = [
53
+ ["icp4", 16],
54
+ ["icp5", 32],
55
+ ["icp6", 64],
56
+ ["ic07", 128],
57
+ ["ic08", 256],
58
+ ["ic09", 512],
59
+ ["ic10", 1024],
60
+ ];
61
+
62
+ /**
63
+ * Renders the mark: a squircle in the profile's gradient with an outlined
64
+ * "app window" on it — generic enough to read as an application icon for
65
+ * anything this wrapper packages, and distinct per profile through its colors.
66
+ */
67
+ function render(size, theme) {
68
+ const rgba = Buffer.alloc(size * size * 4);
69
+ const [topColor, bottomColor] = theme.background.map(hexToRgb);
70
+ const accent = hexToRgb(theme.accent);
71
+
72
+ const bgRadius = size * 0.2235; // Apple's squircle corner ratio, near enough
73
+ const inset = size * 0.26;
74
+ const windowRadius = Math.max(size * 0.045, 1);
75
+ const stroke = Math.max(size * 0.05, 1);
76
+ const halfStroke = stroke / 2;
77
+ const titleBarY = inset + size * 0.12;
78
+ // Below ~64px the title-bar dots are smaller than a pixel of ink and just
79
+ // muddy the mark, so the small entries drop them.
80
+ const showDots = size >= 64;
81
+ const dotRadius = size * 0.018;
82
+ // Centered in the strip between the window's top edge and the title bar rule.
83
+ const dotCenterY = (inset + titleBarY) / 2;
84
+
85
+ for (let y = 0; y < size; y++) {
86
+ for (let x = 0; x < size; x++) {
87
+ const px = x + 0.5;
88
+ const py = y + 0.5;
89
+ const index = (y * size + x) * 4;
90
+
91
+ const bg = coverage(roundedRectDistance(px, py, size / 2, size / 2, size / 2, size / 2, bgRadius));
92
+ if (bg > 0) {
93
+ const t = py / size;
94
+ blend(
95
+ rgba,
96
+ index,
97
+ [0, 1, 2].map((c) => Math.round(topColor[c] + (bottomColor[c] - topColor[c]) * t)),
98
+ bg,
99
+ );
100
+ }
101
+
102
+ const windowDistance = roundedRectDistance(
103
+ px,
104
+ py,
105
+ size / 2,
106
+ size / 2,
107
+ size / 2 - inset,
108
+ size / 2 - inset,
109
+ windowRadius,
110
+ );
111
+ // |distance| - halfStroke turns the filled shape's SDF into its outline.
112
+ blend(rgba, index, accent, coverage(Math.abs(windowDistance) - halfStroke));
113
+
114
+ const onTitleBar =
115
+ windowDistance < 0 && Math.abs(py - titleBarY) <= halfStroke ? 1 : 0;
116
+ if (onTitleBar) blend(rgba, index, accent, 1);
117
+
118
+ if (showDots) {
119
+ for (let i = 0; i < 3; i++) {
120
+ const cx = inset + size * 0.06 + i * size * 0.06;
121
+ const distance = Math.hypot(px - cx, py - dotCenterY) - dotRadius;
122
+ blend(rgba, index, accent, coverage(distance));
123
+ }
124
+ }
125
+ }
126
+ }
127
+
128
+ return rgba;
129
+ }
130
+
131
+ /**
132
+ * Draws the icon set into `<rootDir>/src-tauri/icons` and the 1024px master into
133
+ * `<rootDir>/assets/icon-source.png`. The master is what the profile's
134
+ * `iconSource` points at by default, so a copy of this package is
135
+ * self-consistent — `npm run icons` works — before anyone supplies a logo.
136
+ */
137
+ export function generatePlaceholderIcons(rootDir, theme) {
138
+ const outDir = path.join(rootDir, "src-tauri", "icons");
139
+ mkdirSync(outDir, { recursive: true });
140
+ const cache = new Map();
141
+ const png = (size) => {
142
+ if (!cache.has(size)) cache.set(size, encodePng(render(size, theme), size));
143
+ return cache.get(size);
144
+ };
145
+
146
+ const written = [];
147
+ for (const [file, size] of Object.entries({ ...PLAIN_PNGS, ...SQUARE_LOGOS })) {
148
+ writeFileSync(path.join(outDir, file), png(size));
149
+ written.push(file);
150
+ }
151
+
152
+ writeFileSync(
153
+ path.join(outDir, "icon.ico"),
154
+ encodeIco(ICO_SIZES.map((size) => ({ size, png: png(size) }))),
155
+ );
156
+ written.push("icon.ico");
157
+
158
+ writeFileSync(
159
+ path.join(outDir, "icon.icns"),
160
+ encodeIcns(ICNS_TYPES.map(([type, size]) => ({ type, png: png(size) }))),
161
+ );
162
+ written.push("icon.icns");
163
+
164
+ const assetsDir = path.join(rootDir, "assets");
165
+ mkdirSync(assetsDir, { recursive: true });
166
+ writeFileSync(path.join(assetsDir, "icon-source.png"), png(1024));
167
+
168
+ return written;
169
+ }
170
+
171
+ if (import.meta.url === `file://${process.argv[1]}`) {
172
+ const rootDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
173
+ const profile = loadProfile(rootDir, resolveProfileName(rootDir));
174
+ const theme = {
175
+ background: ["#1e293b", "#0b1220"],
176
+ accent: "#f8fafc",
177
+ ...profile.placeholderIcon,
178
+ };
179
+
180
+ const written = generatePlaceholderIcons(rootDir, theme);
181
+ console.log(
182
+ `[native-app-wrapper] drew ${written.length} placeholder icons for "${profile.name}" -> src-tauri/icons/`,
183
+ );
184
+ console.log(" wrote assets/icon-source.png (1024x1024 master)");
185
+ console.log(" these are stand-ins — point the profile's iconSource at real artwork and run \"npm run icons\"");
186
+ }
@@ -0,0 +1,141 @@
1
+ // Minimal RGBA PNG / ICO / ICNS writers, plus the tiny bit of rasterization
2
+ // the placeholder icon needs. Dependency-free on purpose: generating a
3
+ // buildable icon set is a prerequisite for `tauri build`, so it can't be the
4
+ // step that makes a fresh clone need npm install, a native image library, or a
5
+ // network round trip first.
6
+
7
+ import zlib from "node:zlib";
8
+
9
+ const CRC_TABLE = (() => {
10
+ const table = new Int32Array(256);
11
+ for (let n = 0; n < 256; n++) {
12
+ let c = n;
13
+ for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
14
+ table[n] = c;
15
+ }
16
+ return table;
17
+ })();
18
+
19
+ function crc32(buf) {
20
+ let c = 0xffffffff;
21
+ for (let i = 0; i < buf.length; i++) c = CRC_TABLE[(c ^ buf[i]) & 0xff] ^ (c >>> 8);
22
+ return (c ^ 0xffffffff) >>> 0;
23
+ }
24
+
25
+ function chunk(type, data) {
26
+ const head = Buffer.alloc(8);
27
+ head.writeUInt32BE(data.length, 0);
28
+ head.write(type, 4, "ascii");
29
+ const crc = Buffer.alloc(4);
30
+ crc.writeUInt32BE(crc32(Buffer.concat([head.subarray(4), data])), 0);
31
+ return Buffer.concat([head, data, crc]);
32
+ }
33
+
34
+ /** Encodes a `size * size * 4` RGBA buffer as an 8-bit RGBA PNG. */
35
+ export function encodePng(rgba, size) {
36
+ const ihdr = Buffer.alloc(13);
37
+ ihdr.writeUInt32BE(size, 0);
38
+ ihdr.writeUInt32BE(size, 4);
39
+ ihdr[8] = 8; // bit depth
40
+ ihdr[9] = 6; // color type: RGBA
41
+ // 10..12 stay 0: deflate compression, adaptive filtering, no interlace.
42
+
43
+ // Filter type 0 ("none") on every scanline — the shapes here are flat enough
44
+ // that deflate handles them fine without per-row prediction.
45
+ const raw = Buffer.alloc(size * (size * 4 + 1));
46
+ for (let y = 0; y < size; y++) {
47
+ const rowStart = y * (size * 4 + 1);
48
+ raw[rowStart] = 0;
49
+ rgba.copy(raw, rowStart + 1, y * size * 4, (y + 1) * size * 4);
50
+ }
51
+
52
+ return Buffer.concat([
53
+ Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]),
54
+ chunk("IHDR", ihdr),
55
+ chunk("IDAT", zlib.deflateSync(raw, { level: 9 })),
56
+ chunk("IEND", Buffer.alloc(0)),
57
+ ]);
58
+ }
59
+
60
+ /**
61
+ * Packs PNGs into a Windows .ico. Every entry is stored PNG-compressed rather
62
+ * than as a BMP bitmap — supported since Vista, and the only way a 256x256
63
+ * entry stays a reasonable size.
64
+ */
65
+ export function encodeIco(entries) {
66
+ const header = Buffer.alloc(6);
67
+ header.writeUInt16LE(0, 0); // reserved
68
+ header.writeUInt16LE(1, 2); // type: icon
69
+ header.writeUInt16LE(entries.length, 4);
70
+
71
+ const directory = Buffer.alloc(16 * entries.length);
72
+ let offset = header.length + directory.length;
73
+ entries.forEach((entry, i) => {
74
+ const at = i * 16;
75
+ directory[at] = entry.size >= 256 ? 0 : entry.size; // 0 means 256
76
+ directory[at + 1] = entry.size >= 256 ? 0 : entry.size;
77
+ directory[at + 2] = 0; // palette size (0 = truecolor)
78
+ directory[at + 3] = 0; // reserved
79
+ directory.writeUInt16LE(1, at + 4); // color planes
80
+ directory.writeUInt16LE(32, at + 6); // bits per pixel
81
+ directory.writeUInt32LE(entry.png.length, at + 8);
82
+ directory.writeUInt32LE(offset, at + 12);
83
+ offset += entry.png.length;
84
+ });
85
+
86
+ return Buffer.concat([header, directory, ...entries.map((e) => e.png)]);
87
+ }
88
+
89
+ /**
90
+ * Packs PNGs into a macOS .icns. `type` is the four-character OSType that
91
+ * declares the entry's pixel size (icp4 = 16pt, ic07 = 128pt, ...); the
92
+ * PNG-bearing types used here are the ones macOS reads without a legacy
93
+ * uncompressed bitmap fallback.
94
+ */
95
+ export function encodeIcns(entries) {
96
+ const blocks = entries.map(({ type, png }) => {
97
+ const head = Buffer.alloc(8);
98
+ head.write(type, 0, "ascii");
99
+ head.writeUInt32BE(png.length + 8, 4); // length includes this header
100
+ return Buffer.concat([head, png]);
101
+ });
102
+ const body = Buffer.concat(blocks);
103
+ const head = Buffer.alloc(8);
104
+ head.write("icns", 0, "ascii");
105
+ head.writeUInt32BE(body.length + 8, 4);
106
+ return Buffer.concat([head, body]);
107
+ }
108
+
109
+ /** Signed distance from `(x, y)` to a rounded rectangle, negative inside. */
110
+ export function roundedRectDistance(x, y, cx, cy, halfW, halfH, radius) {
111
+ const dx = Math.abs(x - cx) - (halfW - radius);
112
+ const dy = Math.abs(y - cy) - (halfH - radius);
113
+ const outside = Math.hypot(Math.max(dx, 0), Math.max(dy, 0));
114
+ return outside + Math.min(Math.max(dx, dy), 0) - radius;
115
+ }
116
+
117
+ /** Antialiased coverage for a signed distance, in device pixels. */
118
+ export function coverage(distance) {
119
+ return Math.min(Math.max(0.5 - distance, 0), 1);
120
+ }
121
+
122
+ export function hexToRgb(hex) {
123
+ const m = /^#?([0-9a-f]{6})$/i.exec(hex);
124
+ if (!m) throw new Error(`not a #rrggbb color: ${hex}`);
125
+ const n = parseInt(m[1], 16);
126
+ return [(n >> 16) & 0xff, (n >> 8) & 0xff, n & 0xff];
127
+ }
128
+
129
+ /** Alpha-composites `[r, g, b]` at `alpha` over the pixel at `index`. */
130
+ export function blend(rgba, index, [r, g, b], alpha) {
131
+ if (alpha <= 0) return;
132
+ const a = Math.min(alpha, 1);
133
+ const dstA = rgba[index + 3] / 255;
134
+ const outA = a + dstA * (1 - a);
135
+ if (outA <= 0) return;
136
+ for (let c = 0; c < 3; c++) {
137
+ const src = [r, g, b][c];
138
+ rgba[index + c] = Math.round((src * a + rgba[index + c] * dstA * (1 - a)) / outA);
139
+ }
140
+ rgba[index + 3] = Math.round(outA * 255);
141
+ }