@nativedesktop/cli 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.
@@ -0,0 +1,139 @@
1
+ // App icon installation. mac: .icns/.iconset passthrough or a PNG source
2
+ // rasterized through sips + iconutil. linux: hicolor theme set + the AppDir
3
+ // root icon appimagetool requires. Resizers are probed, never hard-required:
4
+ // sips/iconutil are macOS-only tools, and a Linux box degrades with a marker.
5
+ import { cpSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
6
+ import { tmpdir } from "node:os";
7
+ import { extname, join, resolve } from "node:path";
8
+ import type { ResolvedIdentity } from "./identity.ts";
9
+ import { PLACEHOLDER_PNG } from "./templates.ts";
10
+
11
+ async function run(command: string[]): Promise<void> {
12
+ const proc = Bun.spawn(command, { stdin: "ignore", stdout: "inherit", stderr: "inherit" });
13
+ if (await proc.exited !== 0) throw new Error(`nd: icon tool failed (${command.join(" ")})`);
14
+ }
15
+
16
+ let warnedNoIcon = false;
17
+ function warnNoIcon(): void {
18
+ if (warnedNoIcon) return;
19
+ warnedNoIcon = true;
20
+ console.error("nd: no app icon configured (app.icon) - shipping the 1x1 placeholder");
21
+ }
22
+
23
+ /** The 10 filenames iconutil expects in a .iconset. */
24
+ const ICONSET_SIZES: Array<{ file: string; px: number }> = [
25
+ { file: "icon_16x16.png", px: 16 },
26
+ { file: "icon_16x16@2x.png", px: 32 },
27
+ { file: "icon_32x32.png", px: 32 },
28
+ { file: "icon_32x32@2x.png", px: 64 },
29
+ { file: "icon_128x128.png", px: 128 },
30
+ { file: "icon_128x128@2x.png", px: 256 },
31
+ { file: "icon_256x256.png", px: 256 },
32
+ { file: "icon_256x256@2x.png", px: 512 },
33
+ { file: "icon_512x512.png", px: 512 },
34
+ { file: "icon_512x512@2x.png", px: 1024 },
35
+ ];
36
+
37
+ /**
38
+ * Installs the mac icon into Contents/Resources/<slug>.icns. Returns the
39
+ * CFBundleIconFile value, or undefined when no icon is configured.
40
+ */
41
+ export async function installMacIcon(identity: ResolvedIdentity, appDir: string, resourcesDir: string): Promise<string | undefined> {
42
+ const source = identity.icon?.macos ?? identity.icon?.source;
43
+ if (!source) {
44
+ warnNoIcon();
45
+ return undefined;
46
+ }
47
+ const src = resolve(appDir, source);
48
+ const ext = extname(src).toLowerCase();
49
+ const icns = join(resourcesDir, `${identity.slug}.icns`);
50
+ if (ext === ".icns") {
51
+ cpSync(src, icns);
52
+ } else if (ext === ".iconset") {
53
+ await run(["iconutil", "-c", "icns", "-o", icns, src]);
54
+ } else if (ext === ".png") {
55
+ const tmp = mkdtempSync(join(tmpdir(), "nd-iconset-"));
56
+ const iconset = join(tmp, `${identity.slug}.iconset`);
57
+ mkdirSync(iconset, { recursive: true });
58
+ for (const { file, px } of ICONSET_SIZES) {
59
+ await run(["sips", "-z", String(px), String(px), src, "--out", join(iconset, file)]);
60
+ }
61
+ await run(["iconutil", "-c", "icns", "-o", icns, iconset]);
62
+ rmSync(tmp, { recursive: true, force: true });
63
+ } else if (ext === ".svg") {
64
+ throw new Error(
65
+ "nd: macOS cannot rasterize an SVG icon. Supply a 1024px PNG (app.icon.source) or a prebuilt .icns (app.icon.macos).",
66
+ );
67
+ } else {
68
+ throw new Error(`nd: unsupported mac icon source "${source}" (want .png, .icns, or .iconset)`);
69
+ }
70
+ return identity.slug;
71
+ }
72
+
73
+ const HICOLOR_SIZES = [16, 32, 48, 64, 128, 256, 512];
74
+
75
+ function firstOnPath(candidates: string[]): string | undefined {
76
+ return candidates.find((tool) => Bun.which(tool) !== null);
77
+ }
78
+
79
+ async function resizePng(tool: string, src: string, px: number, out: string): Promise<void> {
80
+ switch (tool) {
81
+ case "sips":
82
+ await run(["sips", "-z", String(px), String(px), src, "--out", out]);
83
+ return;
84
+ case "magick":
85
+ await run(["magick", src, "-resize", `${px}x${px}`, out]);
86
+ return;
87
+ case "convert":
88
+ await run(["convert", src, "-resize", `${px}x${px}`, out]);
89
+ return;
90
+ case "rsvg-convert":
91
+ await run(["rsvg-convert", "-w", String(px), "-h", String(px), "-o", out, src]);
92
+ return;
93
+ default:
94
+ throw new Error(`nd: unknown icon resizer "${tool}"`);
95
+ }
96
+ }
97
+
98
+ /** Installs the linux icon set into the AppDir (hicolor theme + root icon). */
99
+ export async function installLinuxIcon(identity: ResolvedIdentity, appDir: string, appdir: string): Promise<void> {
100
+ const source = identity.icon?.linux ?? identity.icon?.source;
101
+ const rootIcon = join(appdir, `${identity.slug}.png`);
102
+ if (!source) {
103
+ warnNoIcon();
104
+ writeFileSync(rootIcon, PLACEHOLDER_PNG);
105
+ return;
106
+ }
107
+ const src = resolve(appDir, source);
108
+ const ext = extname(src).toLowerCase();
109
+ if (ext === ".svg") {
110
+ const scalable = join(appdir, "usr/share/icons/hicolor/scalable/apps");
111
+ mkdirSync(scalable, { recursive: true });
112
+ cpSync(src, join(scalable, `${identity.slug}.svg`));
113
+ cpSync(src, join(appdir, `${identity.slug}.svg`));
114
+ return;
115
+ }
116
+ if (ext !== ".png") throw new Error(`nd: unsupported linux icon source "${source}" (want .png or .svg)`);
117
+ // rsvg-convert is deliberately absent: it only rasterizes SVGs and this
118
+ // branch always feeds it a PNG.
119
+ const resizer = firstOnPath(["sips", "magick", "convert"]);
120
+ if (!resizer) {
121
+ console.error("ND_PACKAGE_ICON_SKIPPED reason=no-resizer");
122
+ cpSync(src, rootIcon);
123
+ return;
124
+ }
125
+ try {
126
+ for (const px of HICOLOR_SIZES) {
127
+ const dir = join(appdir, `usr/share/icons/hicolor/${px}x${px}/apps`);
128
+ mkdirSync(dir, { recursive: true });
129
+ await resizePng(resizer, src, px, join(dir, `${identity.slug}.png`));
130
+ }
131
+ } catch (err) {
132
+ // A broken resizer degrades like a missing one: drop the (possibly
133
+ // partial) hicolor set and ship the root icon only.
134
+ rmSync(join(appdir, "usr/share/icons/hicolor"), { recursive: true, force: true });
135
+ console.error(String(err));
136
+ console.error("ND_PACKAGE_ICON_SKIPPED reason=resize-failed");
137
+ }
138
+ cpSync(src, rootIcon);
139
+ }
@@ -0,0 +1,209 @@
1
+ // Pure-surface packaging units: identity resolution and the string
2
+ // transforms stamped into Info.plist / .desktop / mime XML / AppRun. No
3
+ // filesystem writes; resolveIdentity reads a fixture package.json.
4
+ import { describe, expect, test } from "bun:test";
5
+ import { mkdtempSync, writeFileSync } from "node:fs";
6
+ import { tmpdir } from "node:os";
7
+ import { join } from "node:path";
8
+ import type { AppIdentity } from "../config.ts";
9
+ import {
10
+ buildDesktopEntry,
11
+ buildInfoPlist,
12
+ buildMimeInfoXml,
13
+ injectDesktopFile,
14
+ injectInfoPlist,
15
+ resolveIdentity,
16
+ slugify,
17
+ type ResolvedIdentity,
18
+ } from "./identity.ts";
19
+ import { appRunTemplate, desktopEntryTemplate, infoPlistSkeleton } from "./templates.ts";
20
+
21
+ const IDENTITY: ResolvedIdentity = {
22
+ id: "com.nativedesktop.gallery",
23
+ name: "Gallery",
24
+ displayName: "NativeDesktop Gallery",
25
+ slug: "gallery",
26
+ version: "0.9.0",
27
+ categories: ["Utility"],
28
+ };
29
+
30
+ const INFO_PLIST = infoPlistSkeleton({
31
+ name: "Gallery",
32
+ displayName: "NativeDesktop Gallery",
33
+ id: "com.nativedesktop.gallery",
34
+ executable: "Gallery",
35
+ version: "0.9.0",
36
+ minimumSystemVersion: "26.0",
37
+ });
38
+
39
+ const DESKTOP_ENTRY = desktopEntryTemplate({
40
+ displayName: "NativeDesktop Gallery",
41
+ slug: "gallery",
42
+ categories: ["Utility"],
43
+ });
44
+
45
+ describe("injectInfoPlist", () => {
46
+ test("stamps CFBundleIdentifier and leaves the product name in place", () => {
47
+ const out = injectInfoPlist(INFO_PLIST, { id: "es.canarycoders.canary" });
48
+ expect(out).toContain("<key>CFBundleIdentifier</key><string>es.canarycoders.canary</string>");
49
+ expect(out).toContain("<key>CFBundleName</key><string>Gallery</string>");
50
+ });
51
+
52
+ test("passes the plist through untouched when no app identity is configured", () => {
53
+ expect(injectInfoPlist(INFO_PLIST, undefined)).toBe(INFO_PLIST);
54
+ });
55
+
56
+ test("adds CFBundleDocumentTypes and CFBundleURLTypes for configured associations/schemes", () => {
57
+ const identity: AppIdentity = {
58
+ id: "es.canarycoders.canary",
59
+ fileAssociations: [{ ext: "canaryrun", name: "Canary Run", mimeType: "application/x-canary-run" }],
60
+ urlSchemes: [{ scheme: "canary" }],
61
+ };
62
+ const out = injectInfoPlist(INFO_PLIST, identity);
63
+ expect(out).toContain("<key>CFBundleDocumentTypes</key>");
64
+ expect(out).toContain("<string>canaryrun</string>");
65
+ expect(out).toContain("<key>CFBundleURLTypes</key>");
66
+ expect(out).toContain("<string>canary</string>");
67
+ });
68
+ });
69
+
70
+ describe("injectDesktopFile", () => {
71
+ test("leaves a valid .desktop entry untouched when there's nothing to declare", () => {
72
+ const out = injectDesktopFile(DESKTOP_ENTRY, { id: "com.nativedesktop.gallery" });
73
+ expect(out).toBe(DESKTOP_ENTRY);
74
+ expect(out).toContain("Name=NativeDesktop Gallery");
75
+ expect(out).toContain("Exec=AppRun");
76
+ });
77
+
78
+ test("adds a MimeType= line and %U on Exec for configured file associations", () => {
79
+ const identity: AppIdentity = {
80
+ fileAssociations: [{ ext: "canaryrun", mimeType: "application/x-canary-run" }],
81
+ };
82
+ const out = injectDesktopFile(DESKTOP_ENTRY, identity);
83
+ expect(out).toContain("MimeType=application/x-canary-run;");
84
+ expect(out).toContain("Exec=AppRun %U");
85
+ });
86
+ });
87
+
88
+ describe("buildMimeInfoXml", () => {
89
+ test("returns null with no mimeType'd file associations", () => {
90
+ expect(buildMimeInfoXml({ id: "com.nativedesktop.gallery" })).toBeNull();
91
+ });
92
+
93
+ test("declares each mimeType'd file association", () => {
94
+ const xml = buildMimeInfoXml({
95
+ fileAssociations: [{ ext: "canaryrun", name: "Canary Run", mimeType: "application/x-canary-run" }],
96
+ });
97
+ expect(xml).toContain('<mime-type type="application/x-canary-run">');
98
+ expect(xml).toContain('<glob pattern="*.canaryrun"/>');
99
+ });
100
+ });
101
+
102
+ describe("slugify", () => {
103
+ test("lowercases and dashes the product name", () => {
104
+ expect(slugify("Gallery")).toBe("gallery");
105
+ expect(slugify("My App 2")).toBe("my-app-2");
106
+ });
107
+
108
+ test("rejects a name with no usable characters", () => {
109
+ expect(() => slugify("!!!")).toThrow("empty slug");
110
+ });
111
+ });
112
+
113
+ describe("resolveIdentity", () => {
114
+ const dir = mkdtempSync(join(tmpdir(), "nd-identity-"));
115
+ writeFileSync(join(dir, "package.json"), JSON.stringify({ name: "fixture-app", version: "1.2.3" }));
116
+
117
+ test("defaults name/version from package.json", () => {
118
+ const identity = resolveIdentity({}, dir);
119
+ expect(identity.name).toBe("fixture-app");
120
+ expect(identity.displayName).toBe("fixture-app");
121
+ expect(identity.slug).toBe("fixture-app");
122
+ expect(identity.version).toBe("1.2.3");
123
+ expect(identity.categories).toEqual(["Utility"]);
124
+ });
125
+
126
+ test("config app fields win over package.json; --version wins over both", () => {
127
+ const identity = resolveIdentity({ app: { name: "Fixture", version: "2.0.0" } }, dir, { version: "3.0.0" });
128
+ expect(identity.name).toBe("Fixture");
129
+ expect(identity.slug).toBe("fixture");
130
+ expect(identity.version).toBe("3.0.0");
131
+ });
132
+
133
+ test("string icon becomes AppIcon.source", () => {
134
+ const identity = resolveIdentity({ app: { icon: "assets/icon.png" } }, dir);
135
+ expect(identity.icon).toEqual({ source: "assets/icon.png" });
136
+ });
137
+ });
138
+
139
+ describe("buildInfoPlist", () => {
140
+ test("generates the skeleton with identity fields and extraPlist entries", () => {
141
+ const xml = buildInfoPlist(IDENTITY, { extraPlist: { LSUIElement: true, NSSupportsAutomaticTermination: false } });
142
+ expect(xml).toContain("<key>CFBundleExecutable</key><string>Gallery</string>");
143
+ expect(xml).toContain("<key>CFBundleIdentifier</key><string>com.nativedesktop.gallery</string>");
144
+ expect(xml).toContain("<key>LSUIElement</key><true/>");
145
+ expect(xml).toContain("<key>NSSupportsAutomaticTermination</key><false/>");
146
+ });
147
+
148
+ test("stamps executable and version into a custom Info.plist file", () => {
149
+ const custom = infoPlistSkeleton({
150
+ name: "Old",
151
+ displayName: "Old",
152
+ id: "com.old",
153
+ executable: "NDShell",
154
+ version: "0.0.1",
155
+ minimumSystemVersion: "26.0",
156
+ });
157
+ const xml = buildInfoPlist(IDENTITY, undefined, custom);
158
+ expect(xml).toContain("<key>CFBundleExecutable</key><string>Gallery</string>");
159
+ expect(xml).toContain("<key>CFBundleShortVersionString</key><string>0.9.0</string>");
160
+ expect(xml).toContain("<key>CFBundleIdentifier</key><string>com.nativedesktop.gallery</string>");
161
+ });
162
+
163
+ test("stamps an Xcode-formatted plist (key and string on separate lines)", () => {
164
+ const custom = '<?xml version="1.0" encoding="UTF-8"?>\n'
165
+ + '<plist version="1.0">\n<dict>\n'
166
+ + "\t<key>CFBundleExecutable</key>\n\t<string>$(EXECUTABLE_NAME)</string>\n"
167
+ + "\t<key>CFBundleIdentifier</key>\n\t<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>\n"
168
+ + "\t<key>CFBundleShortVersionString</key>\n\t<string>1.0</string>\n"
169
+ + "\t<key>CFBundleVersion</key>\n\t<string>1</string>\n"
170
+ + "</dict>\n</plist>\n";
171
+ const xml = buildInfoPlist(IDENTITY, undefined, custom);
172
+ expect(xml).not.toContain("$(EXECUTABLE_NAME)");
173
+ expect(xml).not.toContain("$(PRODUCT_BUNDLE_IDENTIFIER)");
174
+ expect(xml).toContain("<string>Gallery</string>");
175
+ expect(xml).toContain("<string>com.nativedesktop.gallery</string>");
176
+ expect(xml.match(/<string>0\.9\.0<\/string>/g)?.length).toBe(2);
177
+ });
178
+
179
+ test("throws when a custom plist is missing a key it promises to stamp", () => {
180
+ const custom = '<plist version="1.0">\n<dict>\n'
181
+ + "\t<key>CFBundleExecutable</key>\n\t<string>X</string>\n"
182
+ + "</dict>\n</plist>\n";
183
+ expect(() => buildInfoPlist(IDENTITY, undefined, custom)).toThrow("CFBundleShortVersionString");
184
+ });
185
+ });
186
+
187
+ describe("buildDesktopEntry / appRunTemplate", () => {
188
+ test("desktop entry carries StartupWMClass and desktopEntry overrides", () => {
189
+ const out = buildDesktopEntry(IDENTITY, { desktopEntry: { Comment: "Widget gallery" } });
190
+ expect(out).toContain("StartupWMClass=com.nativedesktop.gallery");
191
+ expect(out).toContain("Comment=Widget gallery");
192
+ expect(out).toContain("Icon=gallery");
193
+ });
194
+
195
+ test("AppRun bakes entry, cwd, app id, and plugin paths", () => {
196
+ const script = appRunTemplate({
197
+ entry: "examples/gallery/main.tsx",
198
+ cwd: "examples/gallery",
199
+ slug: "gallery",
200
+ appId: "com.nativedesktop.gallery",
201
+ pluginPaths: ["native/libdemo.so"],
202
+ });
203
+ expect(script).toContain('export ND_SCRIPT="$HERE/app/examples/gallery/main.tsx"');
204
+ expect(script).toContain('cd "$HERE/app/examples/gallery"');
205
+ expect(script).toContain('export ND_APP_ID="com.nativedesktop.gallery"');
206
+ expect(script).toContain('export ND_PLUGIN_PATHS="$HERE/app/native/libdemo.so"');
207
+ expect(script).toContain('exec "$HERE/usr/bin/gallery" "$@"');
208
+ });
209
+ });
@@ -0,0 +1,204 @@
1
+ // App identity resolution + the pure string transforms `nd package` stamps
2
+ // into packaging outputs: macOS Info.plist, the Linux AppDir's .desktop entry,
3
+ // shared-mime-info XML, and the AppRun launcher. No filesystem writes here so
4
+ // tests can cover everything without touching disk.
5
+ import { readFileSync } from "node:fs";
6
+ import { resolve } from "node:path";
7
+ import type { AppIcon, AppIdentity, LinuxPackageConfig, MacPackageConfig, NativeDesktopConfig } from "../config.ts";
8
+ import { appRunTemplate, desktopEntryTemplate, infoPlistSkeleton, type AppRunSpec } from "./templates.ts";
9
+
10
+ function escapeXml(value: string): string {
11
+ return value.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
12
+ }
13
+
14
+ /**
15
+ * Replaces a plist key's <string> value, tolerating any whitespace between
16
+ * the tags (Xcode/plutil put the <string> on its own line). Throws when the
17
+ * key is absent so a custom plist can't ship silently unstamped.
18
+ */
19
+ function stampPlistString(xml: string, key: string, value: string): string {
20
+ const re = new RegExp(`(<key>${key}</key>\\s*<string>)[^<]*(</string>)`);
21
+ if (!re.test(xml)) throw new Error(`nd: Info.plist has no <key>${key}</key> entry to stamp (add it to the mac.infoPlist file)`);
22
+ return xml.replace(re, `$1${escapeXml(value)}$2`);
23
+ }
24
+
25
+ /** Inject CFBundleURLTypes / CFBundleDocumentTypes / CFBundleIdentifier into an Info.plist's XML text. */
26
+ export function injectInfoPlist(plistXml: string, app: AppIdentity | undefined): string {
27
+ if (!app) return plistXml;
28
+ let out = app.id ? stampPlistString(plistXml, "CFBundleIdentifier", app.id) : plistXml;
29
+
30
+ let extra = "";
31
+ if (app.urlSchemes?.length) {
32
+ extra += " <key>CFBundleURLTypes</key>\n <array>\n";
33
+ for (const s of app.urlSchemes) {
34
+ extra += " <dict>\n"
35
+ + ` <key>CFBundleURLName</key><string>${escapeXml(s.name ?? app.id ?? s.scheme)}</string>\n`
36
+ + " <key>CFBundleURLSchemes</key>\n"
37
+ + ` <array><string>${escapeXml(s.scheme)}</string></array>\n`
38
+ + " </dict>\n";
39
+ }
40
+ extra += " </array>\n";
41
+ }
42
+ if (app.fileAssociations?.length) {
43
+ extra += " <key>CFBundleDocumentTypes</key>\n <array>\n";
44
+ for (const fa of app.fileAssociations) {
45
+ const role = fa.role === "viewer" ? "Viewer" : "Editor";
46
+ extra += " <dict>\n"
47
+ + " <key>CFBundleTypeExtensions</key>\n"
48
+ + ` <array><string>${escapeXml(fa.ext)}</string></array>\n`
49
+ + ` <key>CFBundleTypeName</key><string>${escapeXml(fa.name ?? fa.ext)}</string>\n`
50
+ + ` <key>CFBundleTypeRole</key><string>${role}</string>\n`
51
+ + " <key>LSHandlerRank</key><string>Owner</string>\n"
52
+ + " </dict>\n";
53
+ }
54
+ extra += " </array>\n";
55
+ }
56
+ if (!extra) return out;
57
+ return out.replace("</dict>\n</plist>", `${extra}</dict>\n</plist>`);
58
+ }
59
+
60
+ /** Extend a .desktop entry's MimeType= line and make sure Exec receives launch args (%U). */
61
+ export function injectDesktopFile(desktop: string, app: AppIdentity | undefined): string {
62
+ if (!app) return desktop;
63
+ const mimeTypes = [
64
+ ...(app.fileAssociations ?? []).map((fa) => fa.mimeType).filter((m): m is string => !!m),
65
+ ...(app.urlSchemes ?? []).map((s) => `x-scheme-handler/${s.scheme}`),
66
+ ];
67
+ if (!mimeTypes.length) return desktop;
68
+
69
+ const mimeLine = `MimeType=${mimeTypes.join(";")};`;
70
+ let out = /^MimeType=.*$/m.test(desktop)
71
+ ? desktop.replace(/^MimeType=.*$/m, mimeLine)
72
+ : `${desktop.trimEnd()}\n${mimeLine}\n`;
73
+
74
+ const execLine = out.match(/^Exec=.*$/m)?.[0];
75
+ if (execLine && !/%[uU]/.test(execLine)) out = out.replace(execLine, `${execLine} %U`);
76
+ return out;
77
+ }
78
+
79
+ /** shared-mime-info XML declaring each mimeType'd file association (usr/share/mime/packages/<app>.xml). Returns null if there's nothing to declare. */
80
+ export function buildMimeInfoXml(app: AppIdentity | undefined): string | null {
81
+ const withMime = (app?.fileAssociations ?? []).filter((fa): fa is typeof fa & { mimeType: string } => !!fa.mimeType);
82
+ if (!withMime.length) return null;
83
+ const entries = withMime.map((fa) =>
84
+ ` <mime-type type="${escapeXml(fa.mimeType)}">\n`
85
+ + ` <comment>${escapeXml(fa.name ?? fa.ext)}</comment>\n`
86
+ + ` <glob pattern="*.${escapeXml(fa.ext)}"/>\n`
87
+ + " </mime-type>"
88
+ ).join("\n");
89
+ return `<?xml version="1.0" encoding="UTF-8"?>\n<mime-info xmlns="http://www.freedesktop.org/standards/shared-mime-info">\n${entries}\n</mime-info>\n`;
90
+ }
91
+
92
+ export interface ResolvedIdentity {
93
+ id?: string;
94
+ name: string;
95
+ displayName: string;
96
+ /** Filesystem-safe lowercase name: usr/bin/<slug>, icon names, archive basenames. */
97
+ slug: string;
98
+ version: string;
99
+ icon?: AppIcon;
100
+ categories: string[];
101
+ fileAssociations?: AppIdentity["fileAssociations"];
102
+ urlSchemes?: AppIdentity["urlSchemes"];
103
+ }
104
+
105
+ export function slugify(name: string): string {
106
+ const slug = name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
107
+ if (!slug) throw new Error(`nd: app name "${name}" produces an empty slug`);
108
+ return slug;
109
+ }
110
+
111
+ /** Name/version/id precedence: overrides.version > ND_APP_VERSION > config > package.json > "0.0.0". */
112
+ export function resolveIdentity(
113
+ config: NativeDesktopConfig,
114
+ appDir: string,
115
+ overrides: { version?: string } = {},
116
+ ): ResolvedIdentity {
117
+ let pkg: { name?: string; version?: string } = {};
118
+ try {
119
+ pkg = JSON.parse(readFileSync(resolve(appDir, "package.json"), "utf8"));
120
+ } catch {
121
+ // No package.json: config must carry the name.
122
+ }
123
+ const app = config.app ?? {};
124
+ const name = app.name ?? pkg.name;
125
+ if (!name) throw new Error(`nd: no app name (set app.name in nativedesktop.config.ts or "name" in ${appDir}/package.json)`);
126
+ const icon = typeof app.icon === "string" ? { source: app.icon } : app.icon;
127
+ return {
128
+ id: app.id,
129
+ name,
130
+ displayName: app.displayName ?? name,
131
+ slug: slugify(name),
132
+ version: overrides.version ?? process.env.ND_APP_VERSION ?? app.version ?? pkg.version ?? "0.0.0",
133
+ icon,
134
+ categories: app.categories ?? ["Utility"],
135
+ fileAssociations: app.fileAssociations,
136
+ urlSchemes: app.urlSchemes,
137
+ };
138
+ }
139
+
140
+ /**
141
+ * Info.plist XML for the bundle. `customXml` (mac.infoPlist file contents)
142
+ * replaces the generated skeleton but still gets the identity stamped in:
143
+ * CFBundleIdentifier / document types / URL schemes via injectInfoPlist, plus
144
+ * CFBundleExecutable and the version keys so the plist always matches the
145
+ * binary `nd package` actually installs.
146
+ */
147
+ export function buildInfoPlist(
148
+ identity: ResolvedIdentity,
149
+ mac: MacPackageConfig | undefined,
150
+ customXml?: string,
151
+ iconFile?: string,
152
+ ): string {
153
+ let xml = customXml ?? infoPlistSkeleton({
154
+ name: identity.name,
155
+ displayName: identity.displayName,
156
+ id: identity.id ?? `dev.nativedesktop.${identity.slug}`,
157
+ executable: identity.name,
158
+ version: identity.version,
159
+ minimumSystemVersion: mac?.minimumSystemVersion ?? "26.0",
160
+ category: mac?.category,
161
+ iconFile,
162
+ });
163
+ if (customXml) {
164
+ xml = stampPlistString(xml, "CFBundleExecutable", identity.name);
165
+ xml = stampPlistString(xml, "CFBundleShortVersionString", identity.version);
166
+ xml = stampPlistString(xml, "CFBundleVersion", identity.version);
167
+ }
168
+ xml = injectInfoPlist(xml, {
169
+ id: identity.id,
170
+ fileAssociations: identity.fileAssociations,
171
+ urlSchemes: identity.urlSchemes,
172
+ });
173
+ const extra = Object.entries(mac?.extraPlist ?? {});
174
+ if (extra.length) {
175
+ const lines = extra.map(([key, value]) => {
176
+ const v = typeof value === "boolean" ? (value ? "<true/>" : "<false/>") : typeof value === "number"
177
+ ? `<integer>${value}</integer>`
178
+ : `<string>${escapeXml(value)}</string>`;
179
+ return ` <key>${escapeXml(key)}</key>${v}\n`;
180
+ }).join("");
181
+ xml = xml.replace("</dict>\n</plist>", `${lines}</dict>\n</plist>`);
182
+ }
183
+ return xml;
184
+ }
185
+
186
+ /** .desktop entry text for the AppDir, identity-stamped (MimeType/%U/StartupWMClass). */
187
+ export function buildDesktopEntry(identity: ResolvedIdentity, linux: LinuxPackageConfig | undefined): string {
188
+ const desktop = desktopEntryTemplate({
189
+ displayName: identity.displayName,
190
+ slug: identity.slug,
191
+ categories: identity.categories,
192
+ appId: identity.id,
193
+ extra: linux?.desktopEntry,
194
+ });
195
+ return injectDesktopFile(desktop, {
196
+ id: identity.id,
197
+ fileAssociations: identity.fileAssociations,
198
+ urlSchemes: identity.urlSchemes,
199
+ });
200
+ }
201
+
202
+ export function buildAppRun(spec: AppRunSpec): string {
203
+ return appRunTemplate(spec);
204
+ }
@@ -0,0 +1,51 @@
1
+ // packageApp(): the `nd package` entry point ND's own tools call. Loads the
2
+ // app's nativedesktop.config.ts, resolves its identity, cleans the platform
3
+ // output dir, and dispatches to the platform packager. mac.ts is dynamically
4
+ // imported so linux legs never load it.
5
+ import { rmSync } from "node:fs";
6
+ import { join, resolve } from "node:path";
7
+ import { loadConfig } from "../config.ts";
8
+ import { resolveIdentity } from "./identity.ts";
9
+
10
+ export interface PackageOptions {
11
+ platform: "mac" | "linux";
12
+ /** App directory (where nativedesktop.config.ts + package.json live). Default: process.cwd(). */
13
+ cwd?: string;
14
+ /** Output root override, resolved against process.cwd(). Default: package.outDir against workspaceRoot. */
15
+ outDir?: string;
16
+ /** App-relative source entry override. */
17
+ entry?: string;
18
+ version?: string;
19
+ /** false forces raw source (--no-compile). */
20
+ compile?: boolean;
21
+ /** string: codesign identity; null: skip signing; undefined: config/env/ad-hoc. */
22
+ signIdentity?: string | null;
23
+ notarize?: boolean;
24
+ format?: "appimage" | "appdir";
25
+ }
26
+
27
+ export interface PackageResult {
28
+ bundlePath: string;
29
+ updateManifest?: string;
30
+ publicKey?: string;
31
+ }
32
+
33
+ export async function packageApp(o: PackageOptions): Promise<PackageResult> {
34
+ const appDir = resolve(process.cwd(), o.cwd ?? ".");
35
+ const config = await loadConfig(appDir);
36
+ const identity = resolveIdentity(config, appDir, { version: o.version });
37
+ const workspaceRoot = resolve(appDir, config.package?.workspaceRoot ?? ".");
38
+ const outDir = o.outDir ? resolve(process.cwd(), o.outDir) : resolve(workspaceRoot, config.package?.outDir ?? "dist");
39
+
40
+ // Assembly is not idempotent (cpSync throws on a leftover tree): always
41
+ // start the platform dir from a clean slate.
42
+ rmSync(join(outDir, o.platform), { recursive: true, force: true });
43
+
44
+ if (o.platform === "mac") {
45
+ // Dynamic import so the linux path never loads AppKit-side packaging.
46
+ const { packageMacApp } = await import("./mac.ts");
47
+ return packageMacApp(appDir, config, identity, outDir, o);
48
+ }
49
+ const { packageLinuxApp } = await import("./linux.ts");
50
+ return packageLinuxApp(appDir, config, identity, outDir, o);
51
+ }