code2app 0.1.45 → 0.1.47

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": "code2app",
3
- "version": "0.1.45",
3
+ "version": "0.1.47",
4
4
  "description": "Tauri scaffold that packages a website or a bundled CLI as a native desktop + mobile app, configured from a single JSON profile",
5
5
  "type": "module",
6
6
  "author": "vtempest",
@@ -0,0 +1,159 @@
1
+ import fs from "node:fs";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
5
+
6
+ /**
7
+ * `bin/cli.js` runs on import, reading `process.argv`. Only the subcommands that
8
+ * write somewhere other than the package itself are exercised in-process: `init`
9
+ * scaffolds into a temp directory, while `configure` and `icons` would rewrite
10
+ * this package's own tracked `src-tauri/` files.
11
+ */
12
+ let tmp;
13
+ let out;
14
+ let errs;
15
+ let exits;
16
+ let origArgv;
17
+ let origCwd;
18
+
19
+ class Exit extends Error {}
20
+
21
+ async function cli(...args) {
22
+ vi.resetModules();
23
+ process.argv = ["node", "cli.js", ...args];
24
+ await import("../bin/cli.js");
25
+ }
26
+
27
+ const read = (...p) => fs.readFileSync(path.join(tmp, ...p), "utf8");
28
+
29
+ beforeEach(() => {
30
+ tmp = fs.mkdtempSync(path.join(os.tmpdir(), "naw-cli-"));
31
+ out = [];
32
+ errs = [];
33
+ exits = [];
34
+ origArgv = process.argv;
35
+ origCwd = process.cwd();
36
+ process.chdir(tmp);
37
+ vi.spyOn(console, "log").mockImplementation((...a) => void out.push(a.join(" ")));
38
+ vi.spyOn(console, "error").mockImplementation((...a) => void errs.push(a.join(" ")));
39
+ vi.spyOn(process, "exit").mockImplementation((code) => {
40
+ exits.push(code);
41
+ throw new Exit(`exit ${code}`);
42
+ });
43
+ });
44
+
45
+ afterEach(() => {
46
+ process.chdir(origCwd);
47
+ process.argv = origArgv;
48
+ vi.restoreAllMocks();
49
+ fs.rmSync(tmp, { recursive: true, force: true });
50
+ });
51
+
52
+ describe("help", () => {
53
+ it("prints usage and exits 0 with no command", async () => {
54
+ await cli().catch(() => {});
55
+ expect(out.join("\n")).toContain("init <dir>");
56
+ expect(out.join("\n")).toContain("configure [--profile <name>]");
57
+ expect(out.join("\n")).toContain("icons [--profile <name>]");
58
+ expect(exits[0]).toBe(0);
59
+ });
60
+
61
+ it("prints usage and exits 1 for an unknown command", async () => {
62
+ await cli("bogus").catch(() => {});
63
+ expect(out.join("\n")).toContain("native-app-wrapper");
64
+ expect(exits[0]).toBe(1);
65
+ });
66
+ });
67
+
68
+ describe("init", () => {
69
+ it("requires a target directory", async () => {
70
+ await cli("init").catch(() => {});
71
+ expect(errs.join("\n")).toContain("init needs a target directory");
72
+ expect(exits[0]).toBe(1);
73
+ });
74
+
75
+ it("rejects an unknown profile name", async () => {
76
+ await cli("init", "app", "--profile", "nope").catch(() => {});
77
+ expect(errs.join("\n")).toContain('no profile named "nope"');
78
+ expect(exits[0]).toBe(1);
79
+ });
80
+
81
+ it("rejects a flag with no value", async () => {
82
+ await cli("init", "app", "--profile").catch(() => {});
83
+ expect(errs.join("\n")).toContain("--profile needs a value");
84
+ });
85
+
86
+ it("refuses a non-empty target without --force", async () => {
87
+ fs.mkdirSync("busy");
88
+ fs.writeFileSync(path.join("busy", "keep.txt"), "x");
89
+ await cli("init", "busy").catch(() => {});
90
+ expect(errs.join("\n")).toContain("already exists and is not empty");
91
+ expect(exits[0]).toBe(1);
92
+ });
93
+
94
+ it("scaffolds a standalone wrapper from the example profile", { timeout: 120_000 }, async () => {
95
+ await cli("init", "myapp");
96
+
97
+ expect(exits).toEqual([]);
98
+ const pkg = JSON.parse(read("myapp", "package.json"));
99
+ expect(pkg.name).toBe("example-app-native");
100
+ expect(pkg.private).toBe(true);
101
+ expect(pkg.version).toBe("0.1.0");
102
+ expect(pkg.bin).toBeUndefined();
103
+ expect(pkg.scripts.init).toBeUndefined();
104
+ expect(pkg.scripts["android:dev"]).toBeDefined(); // example profile enables mobile
105
+
106
+ const conf = JSON.parse(read("myapp", "src-tauri", "tauri.conf.json"));
107
+ expect(conf.productName).toBe("Example App");
108
+ expect(read("myapp", "src-tauri", "Cargo.toml")).toContain('name = "example-app-native"');
109
+ expect(read("myapp", "src-tauri", "Cargo.toml")).toContain('name = "example_app_native_lib"');
110
+ expect(read("myapp", "src-tauri", "src", "main.rs")).toContain("example_app_native_lib::run()");
111
+
112
+ expect(fs.existsSync(path.join(tmp, "myapp", "profiles", "example.json"))).toBe(true);
113
+ expect(fs.existsSync(path.join(tmp, "myapp", "bin", "cli.js"))).toBe(true);
114
+ expect(fs.existsSync(path.join(tmp, "myapp", "src-tauri", "icons", "icon.ico"))).toBe(true);
115
+ expect(fs.existsSync(path.join(tmp, "myapp", "docs", "BUILDING.md"))).toBe(true);
116
+ expect(fs.existsSync(path.join(tmp, "myapp", "docs", "MOBILE.md"))).toBe(true);
117
+ expect(fs.existsSync(path.join(tmp, "myapp", "docs", "OAUTH.md"))).toBe(true);
118
+
119
+ const log = out.join("\n");
120
+ expect(log).toContain("scaffolded Example App into myapp/");
121
+ expect(log).toContain("wrote src-tauri/tauri.conf.json");
122
+ expect(log).toContain("Next: cd myapp && npm install && npm run dev");
123
+ });
124
+
125
+ it("scaffolds from a custom --profile-file (local mode with a sidecar, no mobile)", { timeout: 120_000 }, async () => {
126
+ fs.writeFileSync(
127
+ "tool.json",
128
+ JSON.stringify({
129
+ appName: "Local Tool",
130
+ identifier: "com.local.tool",
131
+ version: "3.1.4",
132
+ mode: "local",
133
+ sidecar: { name: "tool", build: "make {out}" },
134
+ placeholderIcon: { accent: "#ff0000" },
135
+ }),
136
+ );
137
+ fs.mkdirSync("dest");
138
+
139
+ await cli("init", "dest", "--profile-file", "tool.json", "--force");
140
+
141
+ const pkg = JSON.parse(read("dest", "package.json"));
142
+ expect(pkg.name).toBe("local-tool-native");
143
+ expect(pkg.version).toBe("3.1.4");
144
+ expect(pkg.scripts["build:sidecar"]).toBe("node scripts/build-sidecar.mjs");
145
+ expect(pkg.scripts.predev).toContain("build-sidecar");
146
+ expect(Object.keys(pkg.scripts).some((k) => /^(pre)?(android|ios):/.test(k))).toBe(false);
147
+ expect(fs.existsSync(path.join(tmp, "dest", "docs", "LOCAL_APPS.md"))).toBe(true);
148
+ expect(fs.existsSync(path.join(tmp, "dest", "docs", "MOBILE.md"))).toBe(false);
149
+ expect(fs.existsSync(path.join(tmp, "dest", "docs", "OAUTH.md"))).toBe(false);
150
+ expect(fs.existsSync(path.join(tmp, "dest", "profiles", "tool.json"))).toBe(true);
151
+ expect(read("dest", "src-tauri", "src", "generated_config.rs")).toContain('SIDECAR_NAME: &str = "tool"');
152
+ });
153
+
154
+ it("allows an existing empty target directory", { timeout: 120_000 }, async () => {
155
+ fs.mkdirSync("empty");
156
+ await cli("init", "empty");
157
+ expect(fs.existsSync(path.join(tmp, "empty", "package.json"))).toBe(true);
158
+ });
159
+ });
@@ -0,0 +1,163 @@
1
+ import fs from "node:fs";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+ import { afterEach, beforeEach, describe, expect, it } from "vitest";
5
+ import { configure } from "../scripts/configure.mjs";
6
+
7
+ let root;
8
+
9
+ const profile = (name, data) => {
10
+ fs.mkdirSync(path.join(root, "profiles"), { recursive: true });
11
+ fs.writeFileSync(path.join(root, "profiles", `${name}.json`), JSON.stringify(data));
12
+ };
13
+ const read = (rel) => fs.readFileSync(path.join(root, rel), "utf8");
14
+ const conf = () => JSON.parse(read("src-tauri/tauri.conf.json"));
15
+
16
+ const remote = {
17
+ appName: "Remote App",
18
+ identifier: "com.remote.app",
19
+ version: "2.0.0",
20
+ url: "https://remote.example",
21
+ };
22
+
23
+ beforeEach(() => {
24
+ root = fs.mkdtempSync(path.join(os.tmpdir(), "naw-configure-"));
25
+ });
26
+
27
+ afterEach(() => {
28
+ fs.rmSync(root, { recursive: true, force: true });
29
+ });
30
+
31
+ describe("configure — remote profile", () => {
32
+ it("writes tauri.conf.json pointing the window at the url", () => {
33
+ profile("r", remote);
34
+ const { profile: p, written } = configure(root, "r");
35
+
36
+ expect(p.name).toBe("r");
37
+ expect(written).toEqual(["src-tauri/tauri.conf.json", "src-tauri/src/generated_config.rs"]);
38
+ const c = conf();
39
+ expect(c).toMatchObject({
40
+ productName: "Remote App",
41
+ version: "2.0.0",
42
+ identifier: "com.remote.app",
43
+ build: { frontendDist: "../dist" },
44
+ });
45
+ expect(c.app.windows[0]).toMatchObject({ label: "main", url: "https://remote.example", center: true });
46
+ expect(c.app.security.capabilities).toEqual(["default"]);
47
+ expect(c.bundle).toMatchObject({
48
+ publisher: "Remote App",
49
+ copyright: "",
50
+ category: "Productivity",
51
+ shortDescription: "",
52
+ longDescription: "",
53
+ });
54
+ expect(c.bundle.macOS.minimumSystemVersion).toBe("10.15");
55
+ expect(c.bundle.icon).toHaveLength(5);
56
+ expect(c.bundle.externalBin).toBeUndefined();
57
+ expect(c.plugins).toEqual({});
58
+ });
59
+
60
+ it("generates the Rust constants", () => {
61
+ profile("r", { ...remote, deepLinkScheme: "remoteapp" });
62
+ configure(root, "r");
63
+ const rs = read("src-tauri/src/generated_config.rs");
64
+ expect(rs).toContain('pub const DEEP_LINK_SCHEME: &str = "remoteapp";');
65
+ expect(rs).toContain('pub const APP_URL: &str = "https://remote.example";');
66
+ expect(rs).toContain('pub const SIDECAR_NAME: &str = "";');
67
+ expect(rs).toContain("pub const SIDECAR_ARGS: &[&str] = &[];");
68
+ expect(rs).toContain("pub const UPDATER_ENABLED: bool = false;");
69
+ expect(rs).toContain("profiles/r.json");
70
+ });
71
+
72
+ it("includes updater, deep-link, android and ios settings when configured", () => {
73
+ profile("full", {
74
+ ...remote,
75
+ publisher: "Pub",
76
+ copyright: "© Me",
77
+ category: "Utilities",
78
+ shortDescription: "short",
79
+ longDescription: "long",
80
+ macos: { minimumSystemVersion: "12.0" },
81
+ updater: { pubkey: "PK", endpoints: ["https://u.example/latest.json"] },
82
+ deepLinkScheme: "fullapp",
83
+ android: { minSdkVersion: 26 },
84
+ ios: { minimumSystemVersion: "16.0" },
85
+ });
86
+ configure(root, "full");
87
+ const c = conf();
88
+ expect(c.bundle).toMatchObject({
89
+ publisher: "Pub",
90
+ copyright: "© Me",
91
+ category: "Utilities",
92
+ shortDescription: "short",
93
+ longDescription: "long",
94
+ macOS: { minimumSystemVersion: "12.0" },
95
+ android: { minSdkVersion: 26 },
96
+ iOS: { minimumSystemVersion: "16.0" },
97
+ });
98
+ expect(c.plugins.updater).toEqual({ active: true, pubkey: "PK", endpoints: ["https://u.example/latest.json"] });
99
+ expect(c.plugins["deep-link"]).toEqual({
100
+ desktop: { schemes: ["fullapp"] },
101
+ mobile: [{ scheme: ["fullapp"], appLink: false }],
102
+ });
103
+ expect(read("src-tauri/src/generated_config.rs")).toContain("UPDATER_ENABLED: bool = true;");
104
+ });
105
+
106
+ it("defaults android/ios minimums and falls back publisher → copyright → app name", () => {
107
+ profile("d", { ...remote, copyright: "C", android: {}, ios: {} });
108
+ configure(root, "d");
109
+ const c = conf();
110
+ expect(c.bundle.publisher).toBe("C");
111
+ expect(c.bundle.android.minSdkVersion).toBe(24);
112
+ expect(c.bundle.iOS.minimumSystemVersion).toBe("14.0");
113
+ });
114
+
115
+ it("writes a remote capability scoped to the trusted origins", () => {
116
+ profile("t", { ...remote, trustedOrigins: ["https://remote.example/", "https://*.remote.example"] });
117
+ const { written } = configure(root, "t");
118
+ expect(written).toContain("src-tauri/capabilities/remote.json");
119
+ expect(conf().app.security.capabilities).toEqual(["default", "remote"]);
120
+ const cap = JSON.parse(read("src-tauri/capabilities/remote.json"));
121
+ expect(cap).toMatchObject({
122
+ identifier: "remote",
123
+ windows: ["main"],
124
+ permissions: ["opener:allow-open-url"],
125
+ });
126
+ expect(cap.remote.urls).toEqual(["https://remote.example/*", "https://*.remote.example/*"]);
127
+ });
128
+
129
+ it("removes a stale remote capability when no origins are trusted", () => {
130
+ profile("t", { ...remote, trustedOrigins: ["https://remote.example"] });
131
+ configure(root, "t");
132
+ expect(fs.existsSync(path.join(root, "src-tauri/capabilities/remote.json"))).toBe(true);
133
+
134
+ profile("t", remote);
135
+ const { written } = configure(root, "t");
136
+ expect(fs.existsSync(path.join(root, "src-tauri/capabilities/remote.json"))).toBe(false);
137
+ expect(written).toContain("src-tauri/capabilities/remote.json (removed — no trustedOrigins)");
138
+ });
139
+ });
140
+
141
+ describe("configure — local profile with a sidecar", () => {
142
+ it("loads index.html, bundles the binary and passes fixed args to Rust", () => {
143
+ profile("l", {
144
+ appName: "Local App",
145
+ identifier: "com.local.app",
146
+ version: "1.0.0",
147
+ mode: "local",
148
+ sidecar: { name: "mytool", args: ["--json", "a b"], build: "make {out}" },
149
+ });
150
+ configure(root, "l");
151
+ const c = conf();
152
+ expect(c.app.windows[0].url).toBe("index.html");
153
+ expect(c.bundle.externalBin).toEqual(["binaries/mytool"]);
154
+ const rs = read("src-tauri/src/generated_config.rs");
155
+ expect(rs).toContain('pub const SIDECAR_NAME: &str = "mytool";');
156
+ expect(rs).toContain('pub const SIDECAR_ARGS: &[&str] = &["--json", "a b"];');
157
+ expect(rs).toContain('pub const APP_URL: &str = "";');
158
+ });
159
+
160
+ it("propagates profile errors", () => {
161
+ expect(() => configure(root, "missing")).toThrow("no such profile");
162
+ });
163
+ });
@@ -0,0 +1,128 @@
1
+ import { execFileSync } from "node:child_process";
2
+ import fs from "node:fs";
3
+ import os from "node:os";
4
+ import path from "node:path";
5
+ import zlib from "node:zlib";
6
+ import { afterEach, beforeEach, describe, expect, it } from "vitest";
7
+ import { generatePlaceholderIcons } from "../scripts/generate-placeholder-icons.mjs";
8
+ import { buildSidecar, hostTargetTriple } from "../scripts/build-sidecar.mjs";
9
+
10
+ let root;
11
+
12
+ beforeEach(() => {
13
+ root = fs.mkdtempSync(path.join(os.tmpdir(), "naw-icons-"));
14
+ });
15
+
16
+ afterEach(() => {
17
+ fs.rmSync(root, { recursive: true, force: true });
18
+ });
19
+
20
+ const hasRustc = (() => {
21
+ try {
22
+ execFileSync("rustc", ["--version"], { stdio: "ignore" });
23
+ return true;
24
+ } catch {
25
+ return false;
26
+ }
27
+ })();
28
+
29
+ describe("generatePlaceholderIcons", () => {
30
+ const theme = { background: ["#1e293b", "#0b1220"], accent: "#f8fafc" };
31
+
32
+ it("draws the full Tauri icon set plus a 1024px master", { timeout: 60_000 }, () => {
33
+ const written = generatePlaceholderIcons(root, theme);
34
+ const icons = path.join(root, "src-tauri", "icons");
35
+
36
+ for (const file of [
37
+ "32x32.png", "64x64.png", "128x128.png", "128x128@2x.png", "icon.png",
38
+ "Square30x30Logo.png", "Square310x310Logo.png", "StoreLogo.png",
39
+ "icon.ico", "icon.icns",
40
+ ]) {
41
+ expect(written).toContain(file);
42
+ expect(fs.statSync(path.join(icons, file)).size).toBeGreaterThan(0);
43
+ }
44
+ expect(written).toHaveLength(17);
45
+ expect(fs.existsSync(path.join(root, "assets", "icon-source.png"))).toBe(true);
46
+
47
+ const png = fs.readFileSync(path.join(icons, "128x128.png"));
48
+ expect(png.readUInt32BE(16)).toBe(128);
49
+ expect(png.readUInt32BE(20)).toBe(128);
50
+
51
+ const ico = fs.readFileSync(path.join(icons, "icon.ico"));
52
+ expect(ico.readUInt16LE(4)).toBe(6);
53
+ const icns = fs.readFileSync(path.join(icons, "icon.icns"));
54
+ expect(icns.toString("ascii", 0, 4)).toBe("icns");
55
+ });
56
+
57
+ it("paints a rounded, gradient-filled tile with transparent corners", { timeout: 60_000 }, () => {
58
+ generatePlaceholderIcons(root, theme);
59
+ const png = fs.readFileSync(path.join(root, "src-tauri", "icons", "128x128.png"));
60
+ // Locate IDAT and inflate it.
61
+ let at = 8;
62
+ let idat;
63
+ while (at < png.length) {
64
+ const len = png.readUInt32BE(at);
65
+ if (png.toString("ascii", at + 4, at + 8) === "IDAT") idat = png.subarray(at + 8, at + 8 + len);
66
+ at += 12 + len;
67
+ }
68
+ const raw = zlib.inflateSync(idat);
69
+ const px = (x, y) => [...raw.subarray(y * (128 * 4 + 1) + 1 + x * 4).subarray(0, 4)];
70
+ expect(px(0, 0)[3]).toBe(0); // rounded corner is transparent
71
+ expect(px(64, 4)[3]).toBe(255); // top edge of the tile is opaque
72
+ const top = px(64, 4);
73
+ const bottom = px(64, 123);
74
+ expect(top[0]).toBeGreaterThan(bottom[0]); // background gradient darkens downward
75
+ });
76
+ });
77
+
78
+ describe("buildSidecar", () => {
79
+ const profile = (sidecar) => ({ name: "p", sidecar });
80
+
81
+ it("is skipped when the profile has no sidecar", () => {
82
+ expect(buildSidecar(root, profile(null))).toEqual({ skipped: "the profile bundles no sidecar" });
83
+ });
84
+
85
+ it("requires a build command", () => {
86
+ expect(() => buildSidecar(root, profile({ name: "x" }))).toThrow('has no "build" command');
87
+ });
88
+
89
+ it.skipIf(!hasRustc)("reports the host target triple", () => {
90
+ expect(hostTargetTriple()).toMatch(/^[\w.-]+-[\w.-]+-[\w.-]+/);
91
+ });
92
+
93
+ it.skipIf(!hasRustc)("runs the build command with {out} substituted and marks it executable", () => {
94
+ const result = buildSidecar(
95
+ root,
96
+ profile({ name: "tool", build: `node -e "require('fs').writeFileSync(process.argv[1], 'bin')" "{out}"` }),
97
+ );
98
+ expect(result.triple).toBe(hostTargetTriple());
99
+ const out = path.join(root, result.outPath);
100
+ expect(fs.readFileSync(out, "utf8")).toBe("bin");
101
+ expect(result.outPath.startsWith(path.join("src-tauri", "binaries", "tool-"))).toBe(true);
102
+ if (process.platform !== "win32") expect(fs.statSync(out).mode & 0o111).not.toBe(0);
103
+ });
104
+
105
+ it.skipIf(!hasRustc)("runs the build in buildCwd", () => {
106
+ fs.mkdirSync(path.join(root, "sub"));
107
+ const result = buildSidecar(
108
+ root,
109
+ profile({
110
+ name: "tool",
111
+ buildCwd: "sub",
112
+ build: `node -e "require('fs').writeFileSync(process.argv[1], process.cwd())" "{out}"`,
113
+ }),
114
+ );
115
+ expect(fs.realpathSync(fs.readFileSync(path.join(root, result.outPath), "utf8"))).toBe(
116
+ fs.realpathSync(path.join(root, "sub")),
117
+ );
118
+ });
119
+
120
+ it.skipIf(!hasRustc)("fails when the command fails or forgets to write {out}", () => {
121
+ expect(() => buildSidecar(root, profile({ name: "t", build: "exit 3 # {out}" }))).toThrow(
122
+ "sidecar build command failed with exit code 3",
123
+ );
124
+ expect(() => buildSidecar(root, profile({ name: "t", build: "true # {out}" }))).toThrow(
125
+ "wrote nothing",
126
+ );
127
+ });
128
+ });
@@ -0,0 +1,186 @@
1
+ import zlib from "node:zlib";
2
+ import { describe, expect, it } from "vitest";
3
+ import {
4
+ blend,
5
+ coverage,
6
+ encodeIcns,
7
+ encodeIco,
8
+ encodePng,
9
+ hexToRgb,
10
+ roundedRectDistance,
11
+ } from "../scripts/lib/png.mjs";
12
+
13
+ const solid = (size, [r, g, b, a]) => {
14
+ const buf = Buffer.alloc(size * size * 4);
15
+ for (let i = 0; i < size * size; i++) buf.set([r, g, b, a], i * 4);
16
+ return buf;
17
+ };
18
+
19
+ /** Parse a PNG's chunks, checking each CRC. */
20
+ function chunks(png) {
21
+ const out = [];
22
+ let at = 8;
23
+ while (at < png.length) {
24
+ const len = png.readUInt32BE(at);
25
+ const type = png.toString("ascii", at + 4, at + 8);
26
+ const data = png.subarray(at + 8, at + 8 + len);
27
+ out.push({ type, data });
28
+ at += 12 + len;
29
+ }
30
+ return out;
31
+ }
32
+
33
+ describe("encodePng", () => {
34
+ it("writes a valid PNG that decodes back to the same pixels", () => {
35
+ const rgba = solid(4, [10, 20, 30, 255]);
36
+ rgba.set([255, 0, 0, 128], 0);
37
+ const png = encodePng(rgba, 4);
38
+
39
+ expect([...png.subarray(0, 8)]).toEqual([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
40
+ const parts = chunks(png);
41
+ expect(parts.map((c) => c.type)).toEqual(["IHDR", "IDAT", "IEND"]);
42
+
43
+ const ihdr = parts[0].data;
44
+ expect(ihdr.readUInt32BE(0)).toBe(4);
45
+ expect(ihdr.readUInt32BE(4)).toBe(4);
46
+ expect(ihdr[8]).toBe(8);
47
+ expect(ihdr[9]).toBe(6);
48
+
49
+ const raw = zlib.inflateSync(parts[1].data);
50
+ expect(raw.length).toBe(4 * (4 * 4 + 1));
51
+ // Each scanline starts with filter byte 0, followed by its RGBA pixels.
52
+ expect(raw[0]).toBe(0);
53
+ expect([...raw.subarray(1, 5)]).toEqual([255, 0, 0, 128]);
54
+ expect([...raw.subarray(5, 9)]).toEqual([10, 20, 30, 255]);
55
+ expect(raw[17]).toBe(0);
56
+ });
57
+
58
+ it("checksums each chunk with CRC-32", () => {
59
+ const png = encodePng(solid(2, [1, 2, 3, 4]), 2);
60
+ let at = 8;
61
+ while (at < png.length) {
62
+ const len = png.readUInt32BE(at);
63
+ const body = png.subarray(at + 4, at + 8 + len);
64
+ const stored = png.readUInt32BE(at + 8 + len);
65
+ expect(stored).toBe(zlib.crc32(body));
66
+ at += 12 + len;
67
+ }
68
+ });
69
+ });
70
+
71
+ describe("encodeIco", () => {
72
+ it("writes a directory entry per image and stores the PNGs verbatim", () => {
73
+ const small = encodePng(solid(16, [0, 0, 0, 255]), 16);
74
+ const big = encodePng(solid(2, [0, 0, 0, 255]), 2);
75
+ const ico = encodeIco([
76
+ { size: 16, png: small },
77
+ { size: 256, png: big },
78
+ ]);
79
+
80
+ expect(ico.readUInt16LE(0)).toBe(0);
81
+ expect(ico.readUInt16LE(2)).toBe(1);
82
+ expect(ico.readUInt16LE(4)).toBe(2);
83
+
84
+ const entry = (i) => 6 + i * 16;
85
+ expect(ico[entry(0)]).toBe(16);
86
+ expect(ico[entry(1)]).toBe(0); // 256 is encoded as 0
87
+ expect(ico[entry(1) + 1]).toBe(0);
88
+ expect(ico.readUInt16LE(entry(0) + 4)).toBe(1);
89
+ expect(ico.readUInt16LE(entry(0) + 6)).toBe(32);
90
+ expect(ico.readUInt32LE(entry(0) + 8)).toBe(small.length);
91
+
92
+ const firstOffset = ico.readUInt32LE(entry(0) + 12);
93
+ expect(firstOffset).toBe(6 + 32);
94
+ expect(ico.subarray(firstOffset, firstOffset + small.length).equals(small)).toBe(true);
95
+ const secondOffset = ico.readUInt32LE(entry(1) + 12);
96
+ expect(secondOffset).toBe(firstOffset + small.length);
97
+ expect(ico.subarray(secondOffset).equals(big)).toBe(true);
98
+ });
99
+ });
100
+
101
+ describe("encodeIcns", () => {
102
+ it("wraps each PNG in a typed block under an icns header", () => {
103
+ const a = Buffer.from("AAAA");
104
+ const b = Buffer.from("BBBBBB");
105
+ const icns = encodeIcns([
106
+ { type: "icp4", png: a },
107
+ { type: "ic07", png: b },
108
+ ]);
109
+ expect(icns.toString("ascii", 0, 4)).toBe("icns");
110
+ expect(icns.readUInt32BE(4)).toBe(icns.length);
111
+
112
+ expect(icns.toString("ascii", 8, 12)).toBe("icp4");
113
+ expect(icns.readUInt32BE(12)).toBe(a.length + 8);
114
+ expect(icns.subarray(16, 20).equals(a)).toBe(true);
115
+ expect(icns.toString("ascii", 20, 24)).toBe("ic07");
116
+ expect(icns.readUInt32BE(24)).toBe(b.length + 8);
117
+ });
118
+ });
119
+
120
+ describe("rasterization helpers", () => {
121
+ it("roundedRectDistance is negative inside, zero on the edge, positive outside", () => {
122
+ expect(roundedRectDistance(50, 50, 50, 50, 40, 40, 10)).toBeLessThan(0);
123
+ expect(roundedRectDistance(90, 50, 50, 50, 40, 40, 10)).toBeCloseTo(0, 5);
124
+ expect(roundedRectDistance(100, 50, 50, 50, 40, 40, 10)).toBeGreaterThan(0);
125
+ });
126
+
127
+ it("rounds the corners: the corner point is farther out than the straight edge", () => {
128
+ const edge = roundedRectDistance(90, 50, 50, 50, 40, 40, 10);
129
+ const corner = roundedRectDistance(90, 90, 50, 50, 40, 40, 10);
130
+ expect(corner).toBeGreaterThan(edge);
131
+ });
132
+
133
+ it("coverage maps signed distance to a clamped 0..1 alpha", () => {
134
+ expect(coverage(-5)).toBe(1);
135
+ expect(coverage(-0.5)).toBe(1);
136
+ expect(coverage(0)).toBe(0.5);
137
+ expect(coverage(0.5)).toBe(0);
138
+ expect(coverage(9)).toBe(0);
139
+ });
140
+
141
+ it("hexToRgb parses with or without #, any case", () => {
142
+ expect(hexToRgb("#ff8000")).toEqual([255, 128, 0]);
143
+ expect(hexToRgb("0B1220")).toEqual([11, 18, 32]);
144
+ });
145
+
146
+ it.each(["#fff", "red", "#12345", "#gggggg", ""])("hexToRgb rejects %j", (bad) => {
147
+ expect(() => hexToRgb(bad)).toThrow(/not a #rrggbb color/);
148
+ });
149
+ });
150
+
151
+ describe("blend", () => {
152
+ it("ignores non-positive alpha", () => {
153
+ const px = Buffer.from([1, 2, 3, 4]);
154
+ blend(px, 0, [255, 255, 255], 0);
155
+ blend(px, 0, [255, 255, 255], -1);
156
+ expect([...px]).toEqual([1, 2, 3, 4]);
157
+ });
158
+
159
+ it("writes the source color over a transparent pixel", () => {
160
+ const px = Buffer.alloc(4);
161
+ blend(px, 0, [200, 100, 50], 1);
162
+ expect([...px]).toEqual([200, 100, 50, 255]);
163
+ });
164
+
165
+ it("mixes with what is underneath, weighted by alpha", () => {
166
+ const px = Buffer.from([0, 0, 0, 255]);
167
+ blend(px, 0, [200, 100, 50], 0.5);
168
+ expect([...px]).toEqual([100, 50, 25, 255]);
169
+ });
170
+
171
+ it("caps alpha at 1 and tracks resulting opacity over a partly transparent pixel", () => {
172
+ const px = Buffer.from([0, 0, 0, 0]);
173
+ blend(px, 0, [255, 0, 0], 5);
174
+ expect(px[3]).toBe(255);
175
+ const half = Buffer.from([0, 0, 0, 0]);
176
+ blend(half, 0, [255, 0, 0], 0.5);
177
+ expect(half[3]).toBe(128);
178
+ expect(half[0]).toBe(255);
179
+ });
180
+
181
+ it("respects the pixel index offset", () => {
182
+ const px = Buffer.alloc(8);
183
+ blend(px, 4, [9, 8, 7], 1);
184
+ expect([...px]).toEqual([0, 0, 0, 0, 9, 8, 7, 255]);
185
+ });
186
+ });
@@ -0,0 +1,170 @@
1
+ import fs from "node:fs";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+ import { afterEach, beforeEach, describe, expect, it } from "vitest";
5
+ import { loadProfile, resolveProfileName } from "../scripts/profile.mjs";
6
+
7
+ let root;
8
+ let origEnv;
9
+
10
+ const write = (name, profile) => {
11
+ fs.mkdirSync(path.join(root, "profiles"), { recursive: true });
12
+ fs.writeFileSync(
13
+ path.join(root, "profiles", `${name}.json`),
14
+ typeof profile === "string" ? profile : JSON.stringify(profile),
15
+ );
16
+ };
17
+
18
+ const base = { appName: "My App", identifier: "com.my.app", version: "1.2.3", url: "https://my.app" };
19
+
20
+ beforeEach(() => {
21
+ root = fs.mkdtempSync(path.join(os.tmpdir(), "naw-profile-"));
22
+ origEnv = process.env.WRAPPER_PROFILE;
23
+ delete process.env.WRAPPER_PROFILE;
24
+ });
25
+
26
+ afterEach(() => {
27
+ if (origEnv === undefined) delete process.env.WRAPPER_PROFILE;
28
+ else process.env.WRAPPER_PROFILE = origEnv;
29
+ fs.rmSync(root, { recursive: true, force: true });
30
+ });
31
+
32
+ describe("resolveProfileName", () => {
33
+ it("takes --profile <name>", () => {
34
+ expect(resolveProfileName(root, ["--profile", "foo"])).toBe("foo");
35
+ });
36
+
37
+ it("rejects --profile without a value", () => {
38
+ expect(() => resolveProfileName(root, ["--profile"])).toThrow("--profile needs a profile name");
39
+ expect(() => resolveProfileName(root, ["--profile", "--other"])).toThrow("--profile needs a profile name");
40
+ });
41
+
42
+ it("falls back to WRAPPER_PROFILE", () => {
43
+ process.env.WRAPPER_PROFILE = "from-env";
44
+ expect(resolveProfileName(root, [])).toBe("from-env");
45
+ });
46
+
47
+ it("uses the only non-example profile", () => {
48
+ write("example", base);
49
+ write("solo", base);
50
+ expect(resolveProfileName(root, [])).toBe("solo");
51
+ });
52
+
53
+ it("uses example.json when it is the only one", () => {
54
+ write("example", base);
55
+ expect(resolveProfileName(root, [])).toBe("example");
56
+ });
57
+
58
+ it("explains an empty or missing profiles directory", () => {
59
+ expect(() => resolveProfileName(root, [])).toThrow("no profile to use");
60
+ fs.mkdirSync(path.join(root, "profiles"));
61
+ expect(() => resolveProfileName(root, [])).toThrow("no profile to use");
62
+ });
63
+
64
+ it("asks for a choice when several profiles exist", () => {
65
+ write("a", base);
66
+ write("b", base);
67
+ expect(() => resolveProfileName(root, [])).toThrow(/2 profiles \(a, b\)/);
68
+ });
69
+ });
70
+
71
+ describe("loadProfile", () => {
72
+ it("fills in defaults for a minimal remote profile", () => {
73
+ write("min", base);
74
+ const p = loadProfile(root, "min");
75
+ expect(p).toMatchObject({
76
+ name: "min",
77
+ mode: "remote",
78
+ productName: "My App",
79
+ trustedOrigins: [],
80
+ sidecar: null,
81
+ });
82
+ expect(p.path).toBe(path.join(root, "profiles", "min.json"));
83
+ expect(p.window).toEqual({
84
+ width: 1280,
85
+ height: 800,
86
+ minWidth: 480,
87
+ minHeight: 480,
88
+ fullscreen: false,
89
+ resizable: true,
90
+ title: "My App",
91
+ });
92
+ });
93
+
94
+ it("lets the profile override window and product name", () => {
95
+ write("custom", { ...base, productName: "Pretty", window: { width: 900, title: "Hello" } });
96
+ const p = loadProfile(root, "custom");
97
+ expect(p.productName).toBe("Pretty");
98
+ expect(p.window.width).toBe(900);
99
+ expect(p.window.title).toBe("Hello");
100
+ expect(p.window.height).toBe(800);
101
+ });
102
+
103
+ it("accepts http://localhost for remote dev", () => {
104
+ write("dev", { ...base, url: "http://localhost:3000" });
105
+ expect(loadProfile(root, "dev").url).toBe("http://localhost:3000");
106
+ });
107
+
108
+ it("accepts a local profile with a sidecar", () => {
109
+ write("local", {
110
+ appName: "L",
111
+ identifier: "com.l",
112
+ version: "1",
113
+ mode: "local",
114
+ sidecar: { name: "tool", build: "make OUT={out}" },
115
+ });
116
+ const p = loadProfile(root, "local");
117
+ expect(p.mode).toBe("local");
118
+ expect(p.sidecar).toEqual({ args: [], name: "tool", build: "make OUT={out}" });
119
+ });
120
+
121
+ it("reports a missing profile", () => {
122
+ expect(() => loadProfile(root, "ghost")).toThrow("no such profile: profiles/ghost.json");
123
+ });
124
+
125
+ it("reports invalid JSON", () => {
126
+ write("bad", "{not json");
127
+ expect(() => loadProfile(root, "bad")).toThrow(/bad.json is not valid JSON/);
128
+ });
129
+
130
+ it.each(["appName", "identifier", "version"])("requires %s", (field) => {
131
+ write("p", { ...base, [field]: undefined });
132
+ expect(() => loadProfile(root, "p")).toThrow(`missing required field "${field}"`);
133
+ });
134
+
135
+ it("rejects an unknown mode", () => {
136
+ write("p", { ...base, mode: "hybrid" });
137
+ expect(() => loadProfile(root, "p")).toThrow('unknown mode "hybrid"');
138
+ });
139
+
140
+ it("remote mode needs a url, and it must be https or localhost", () => {
141
+ write("p", { ...base, url: undefined });
142
+ expect(() => loadProfile(root, "p")).toThrow('needs a "url"');
143
+ write("p", { ...base, url: "http://example.com" });
144
+ expect(() => loadProfile(root, "p")).toThrow("must be https://");
145
+ });
146
+
147
+ it("local mode must not set a url", () => {
148
+ write("p", { appName: "A", identifier: "i", version: "1", mode: "local", url: "https://x" });
149
+ expect(() => loadProfile(root, "p")).toThrow('mode "local" but also sets "url"');
150
+ });
151
+
152
+ it("validates the sidecar block", () => {
153
+ const local = { appName: "A", identifier: "i", version: "1", mode: "local" };
154
+ write("p", { ...local, sidecar: {} });
155
+ expect(() => loadProfile(root, "p")).toThrow('no "name"');
156
+ write("p", { ...local, sidecar: { name: "x", build: "make" } });
157
+ expect(() => loadProfile(root, "p")).toThrow('must contain "{out}"');
158
+ write("p", { ...base, sidecar: { name: "x" } });
159
+ expect(() => loadProfile(root, "p")).toThrow('pairs a "sidecar" with mode "remote"');
160
+ });
161
+
162
+ it("validates the updater block", () => {
163
+ write("p", { ...base, updater: { pubkey: "k" } });
164
+ expect(() => loadProfile(root, "p")).toThrow('"updater" needs both a "pubkey"');
165
+ write("p", { ...base, updater: { pubkey: "k", endpoints: [] } });
166
+ expect(() => loadProfile(root, "p")).toThrow('"updater" needs both');
167
+ write("p", { ...base, updater: { pubkey: "k", endpoints: ["https://u"] } });
168
+ expect(loadProfile(root, "p").updater.endpoints).toEqual(["https://u"]);
169
+ });
170
+ });