@nativedesktop/host 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.
package/package.json ADDED
@@ -0,0 +1,27 @@
1
+ {
2
+ "name": "@nativedesktop/host",
3
+ "version": "0.1.0",
4
+ "description": "Resolves the prebuilt NativeDesktop host binary for the current platform",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/FormalSnake/NativeDesktop.git",
10
+ "directory": "packages/host"
11
+ },
12
+ "homepage": "https://github.com/FormalSnake/NativeDesktop#readme",
13
+ "bugs": "https://github.com/FormalSnake/NativeDesktop/issues",
14
+ "publishConfig": {
15
+ "access": "public"
16
+ },
17
+ "files": ["src"],
18
+ "main": "./src/index.ts",
19
+ "types": "./src/index.ts",
20
+ "exports": {
21
+ ".": "./src/index.ts"
22
+ },
23
+ "optionalDependencies": {
24
+ "@nativedesktop/host-darwin-arm64": "0.1.0",
25
+ "@nativedesktop/host-linux-x64": "0.1.0"
26
+ }
27
+ }
@@ -0,0 +1,84 @@
1
+ // Resolution matrix for @nativedesktop/host: backend selection across
2
+ // platform × env × flag, and the per-backend platform-package + fresh-artifact
3
+ // layout. Run with: bun test packages/host/
4
+ import { test, expect } from "bun:test";
5
+ import { resolve } from "node:path";
6
+ import { hostBinaryCandidates, hostPackageName, hostPlatformKey, resolveBackend } from "./index.ts";
7
+
8
+ // --- backend selection: platform × env × flag ------------------------------
9
+
10
+ test("default backend follows the platform", () => {
11
+ expect(resolveBackend({}, {}, "darwin")).toBe("appkit");
12
+ expect(resolveBackend({}, {}, "linux")).toBe("gtk");
13
+ expect(resolveBackend({}, {}, "win32")).toBe("gtk");
14
+ });
15
+
16
+ test("ND_BACKEND overrides the platform default", () => {
17
+ expect(resolveBackend({}, { ND_BACKEND: "gtk" }, "darwin")).toBe("gtk");
18
+ expect(resolveBackend({}, { ND_BACKEND: "appkit" }, "darwin")).toBe("appkit");
19
+ });
20
+
21
+ test("explicit backend option beats ND_BACKEND and the default", () => {
22
+ expect(resolveBackend({ backend: "gtk" }, { ND_BACKEND: "appkit" }, "darwin")).toBe("gtk");
23
+ expect(resolveBackend({ backend: "appkit" }, { ND_BACKEND: "gtk" }, "darwin")).toBe("appkit");
24
+ });
25
+
26
+ test("appkit is macOS-only: requesting it elsewhere errors", () => {
27
+ expect(() => resolveBackend({ backend: "appkit" }, {}, "linux")).toThrow(/macOS-only/);
28
+ expect(() => resolveBackend({}, { ND_BACKEND: "appkit" }, "linux")).toThrow(/macOS-only/);
29
+ });
30
+
31
+ test("an unknown backend errors", () => {
32
+ expect(() => resolveBackend({ backend: "qt" as never }, {}, "linux")).toThrow(/unknown backend/);
33
+ expect(() => resolveBackend({}, { ND_BACKEND: "qt" }, "linux")).toThrow(/unknown backend/);
34
+ });
35
+
36
+ // --- platform packages: backend × platform × arch --------------------------
37
+
38
+ test("each shipping target maps to its platform package", () => {
39
+ expect(hostPackageName("appkit", "darwin-arm64")).toBe("@nativedesktop/host-darwin-arm64");
40
+ expect(hostPackageName("gtk", "linux-x64")).toBe("@nativedesktop/host-linux-x64");
41
+ });
42
+
43
+ test("combinations without a prebuilt have no package", () => {
44
+ expect(hostPackageName("gtk", "darwin-arm64")).toBeUndefined();
45
+ expect(hostPackageName("appkit", "linux-x64")).toBeUndefined();
46
+ expect(hostPackageName("gtk", "windows-x64")).toBeUndefined();
47
+ });
48
+
49
+ // --- candidates: package name, binary name, fresh artifacts ----------------
50
+
51
+ const PKG = resolve(import.meta.dir, "..");
52
+
53
+ test("gtk resolves the linux platform package + fresh zig-out artifact", () => {
54
+ const c = hostBinaryCandidates("gtk", { platform: "linux", arch: "x64", packageDir: PKG });
55
+ expect(c.packageName).toBe("@nativedesktop/host-linux-x64");
56
+ expect(c.binaryName).toBe("nd-hello");
57
+ expect(c.fresh).toEqual([resolve(c.repoRoot, "zig-out", "bin", "nd-hello")]);
58
+ });
59
+
60
+ test("appkit resolves the darwin platform package + fresh swift artifacts (release before debug)", () => {
61
+ const c = hostBinaryCandidates("appkit", { platform: "darwin", arch: "arm64", packageDir: PKG });
62
+ expect(c.packageName).toBe("@nativedesktop/host-darwin-arm64");
63
+ expect(c.binaryName).toBe("nd-shell");
64
+ expect(c.fresh).toEqual([
65
+ resolve(c.repoRoot, "swift", ".build", "release", "NDShell"),
66
+ resolve(c.repoRoot, "swift", ".build", "debug", "NDShell"),
67
+ ]);
68
+ });
69
+
70
+ test("windows gtk binary carries the .exe suffix and has no package", () => {
71
+ const c = hostBinaryCandidates("gtk", { platform: "win32", arch: "x64", packageDir: PKG });
72
+ expect(c.binaryName).toBe("nd-hello.exe");
73
+ expect(c.packageName).toBeUndefined();
74
+ });
75
+
76
+ test("repoRoot is two levels above the package", () => {
77
+ const c = hostBinaryCandidates("gtk", { platform: "linux", arch: "x64", packageDir: PKG });
78
+ expect(c.repoRoot).toBe(resolve(PKG, "..", ".."));
79
+ });
80
+
81
+ test("hostPlatformKey rejects unsupported platforms", () => {
82
+ expect(hostPlatformKey("darwin", "arm64")).toBe("darwin-arm64");
83
+ expect(() => hostPlatformKey("sunos", "arm64")).toThrow(/unsupported platform/);
84
+ });
package/src/index.ts ADDED
@@ -0,0 +1,225 @@
1
+ // @nativedesktop/host: resolves the host binary that draws the app for the
2
+ // current platform. Two backends exist: the GTK/Zig host (`nd-hello`, native on
3
+ // Linux, GTK-via-Quartz on macOS) and the AppKit/SwiftPM host (`nd-shell`,
4
+ // macOS-only). The default follows the platform (appkit on darwin, gtk on
5
+ // linux), overridable by an explicit `{ backend }` option or the
6
+ // ND_BACKEND env var.
7
+ //
8
+ // How binaries get resolved:
9
+ // - published install: the binary ships in a per-platform package
10
+ // (@nativedesktop/host-darwin-arm64, @nativedesktop/host-linux-x64) listed
11
+ // as optionalDependencies of this package, the Electron/esbuild model. npm
12
+ // and bun install only the package matching the machine's os/cpu.
13
+ // - source-checkout fallback: when this package sits inside the NativeDesktop
14
+ // repo, a missing prebuilt falls back to the freshly built zig-out / swift
15
+ // .build artifacts, and if those are missing too the requested backend is
16
+ // built on first run.
17
+ import { existsSync, statSync } from "node:fs";
18
+ import { createRequire } from "node:module";
19
+ import { resolve } from "node:path";
20
+
21
+ const OS_NAMES: Record<string, string> = { darwin: "darwin", linux: "linux", win32: "windows" };
22
+ const ARCH_NAMES: Record<string, string> = { arm64: "arm64", x64: "x64" };
23
+
24
+ export type Backend = "gtk" | "appkit";
25
+
26
+ /** Host binary basename per backend. */
27
+ const BINARY_NAMES: Record<Backend, string> = { gtk: "nd-hello", appkit: "nd-shell" };
28
+
29
+ /** Platform packages that ship a prebuilt host binary, keyed backend:os-arch.
30
+ * gtk-on-macOS has no entry by design: the GTK host links Homebrew paths and
31
+ * is a source-checkout-only dev path there. */
32
+ const PLATFORM_PACKAGES: Record<string, string> = {
33
+ "appkit:darwin-arm64": "@nativedesktop/host-darwin-arm64",
34
+ "gtk:linux-x64": "@nativedesktop/host-linux-x64",
35
+ };
36
+
37
+ /** `<os>-<arch>` key, e.g. "darwin-arm64". */
38
+ export function hostPlatformKey(platform: string = process.platform, arch: string = process.arch): string {
39
+ const os = OS_NAMES[platform];
40
+ const cpu = ARCH_NAMES[arch];
41
+ if (!os || !cpu) {
42
+ throw new Error(`@nativedesktop/host: unsupported platform "${platform}-${arch}"`);
43
+ }
44
+ return `${os}-${cpu}`;
45
+ }
46
+
47
+ /** The npm package carrying the prebuilt binary for a backend on a platform
48
+ * key, or undefined when no prebuilt exists for that combination. */
49
+ export function hostPackageName(backend: Backend, key: string): string | undefined {
50
+ return PLATFORM_PACKAGES[`${backend}:${key}`];
51
+ }
52
+
53
+ /** Default backend for a platform: appkit ships on macOS, gtk everywhere else. */
54
+ function defaultBackend(platform: string): Backend {
55
+ return platform === "darwin" ? "appkit" : "gtk";
56
+ }
57
+
58
+ /**
59
+ * Resolve the requested backend from an explicit option, then ND_BACKEND, then
60
+ * the platform default. The appkit backend is macOS-only; requesting it
61
+ * elsewhere is a hard error rather than a silent GTK fallback.
62
+ */
63
+ export function resolveBackend(
64
+ opts: { backend?: Backend } = {},
65
+ env: Record<string, string | undefined> = process.env,
66
+ platform: string = process.platform,
67
+ ): Backend {
68
+ const requested = opts.backend ?? (env.ND_BACKEND as string | undefined) ?? defaultBackend(platform);
69
+ if (requested !== "gtk" && requested !== "appkit") {
70
+ throw new Error(`@nativedesktop/host: unknown backend "${requested}" (expected "gtk" or "appkit")`);
71
+ }
72
+ if (requested === "appkit" && platform !== "darwin") {
73
+ throw new Error(
74
+ `@nativedesktop/host: the appkit backend is macOS-only (requested on "${platform}"). ` +
75
+ `Use the gtk backend, which runs natively on Linux.`,
76
+ );
77
+ }
78
+ return requested;
79
+ }
80
+
81
+ interface Candidates {
82
+ /** Platform package expected to carry the prebuilt binary, if one exists. */
83
+ packageName: string | undefined;
84
+ /** Binary basename inside the platform package's bin/ (with .exe on win32). */
85
+ binaryName: string;
86
+ /** Repo root two levels up from the package, for the source-checkout fallback. */
87
+ repoRoot: string;
88
+ /** Freshly built artifacts, in preference order. */
89
+ fresh: string[];
90
+ }
91
+
92
+ /** Pure path computation for a backend on a given platform, the resolution matrix under test. */
93
+ export function hostBinaryCandidates(
94
+ backend: Backend,
95
+ { platform = process.platform, arch = process.arch, packageDir = resolve(import.meta.dir, "..") }: {
96
+ platform?: string;
97
+ arch?: string;
98
+ packageDir?: string;
99
+ } = {},
100
+ ): Candidates {
101
+ const key = hostPlatformKey(platform, arch);
102
+ const binaryName = platform === "win32" ? `${BINARY_NAMES[backend]}.exe` : BINARY_NAMES[backend];
103
+ const repoRoot = resolve(packageDir, "..", "..");
104
+ const fresh = backend === "gtk"
105
+ ? [resolve(repoRoot, "zig-out", "bin", "nd-hello")]
106
+ : [resolve(repoRoot, "swift", ".build", "release", "NDShell"), resolve(repoRoot, "swift", ".build", "debug", "NDShell")];
107
+ return { packageName: hostPackageName(backend, key), binaryName, repoRoot, fresh };
108
+ }
109
+
110
+ /**
111
+ * The prebuilt binary from the installed platform package, or undefined when
112
+ * the package is absent for this machine or its bin/ is empty. The existsSync
113
+ * guard matters inside this repo: `bun install` symlinks the workspace
114
+ * platform packages with no staged binary, so resolve can succeed while the
115
+ * file itself is missing.
116
+ */
117
+ export function prebuiltHostBinary(backend: Backend): string | undefined {
118
+ const { packageName, binaryName } = hostBinaryCandidates(backend);
119
+ if (!packageName) return undefined;
120
+ try {
121
+ const path = createRequire(import.meta.url).resolve(`${packageName}/bin/${binaryName}`);
122
+ return existsSync(path) ? path : undefined;
123
+ } catch {
124
+ return undefined;
125
+ }
126
+ }
127
+
128
+ /** A checkout of the NativeDesktop monorepo, where source builds are possible. */
129
+ function isSourceCheckout(repoRoot: string): boolean {
130
+ return existsSync(resolve(repoRoot, "build.zig")) && existsSync(resolve(repoRoot, "swift", "Package.swift"));
131
+ }
132
+
133
+ /**
134
+ * Absolute path to the host binary for the requested backend, building it on
135
+ * first run when inside a source checkout. Resolution order per backend:
136
+ * 1. prebuilt binary from the installed @nativedesktop/host-<os>-<arch> package
137
+ * 2. (source checkout only) freshly built zig-out / swift .build artifacts
138
+ * 3. (source checkout only) build the backend, then return the built artifact
139
+ * Outside a checkout with no prebuilt, throws naming the missing platform
140
+ * package (or the supported target list when none exists for this machine).
141
+ */
142
+ export async function resolveHostBinary(opts: { backend?: Backend } = {}): Promise<string> {
143
+ const backend = resolveBackend(opts);
144
+ const { packageName, binaryName, repoRoot, fresh } = hostBinaryCandidates(backend);
145
+ const prebuilt = prebuiltHostBinary(backend);
146
+ const source = isSourceCheckout(repoRoot);
147
+
148
+ if (prebuilt) {
149
+ // In a source checkout, a newer zig-out/swift artifact wins over a stale
150
+ // prebuilt; otherwise every dev/e2e run silently tests whatever was last
151
+ // staged into the platform package, not the code just built (a real bite:
152
+ // a Jul 16 prebuilt masked an entire wave of terminal fixes).
153
+ if (source) {
154
+ const built = fresh.find(existsSync);
155
+ if (built && statSync(built).mtimeMs > statSync(prebuilt).mtimeMs) return built;
156
+ }
157
+ return prebuilt;
158
+ }
159
+
160
+ if (source) {
161
+ const built = fresh.find(existsSync);
162
+ if (built) return built;
163
+ return buildBackend(backend, repoRoot, fresh);
164
+ }
165
+
166
+ const hint = packageName
167
+ ? `Expected ${packageName}/bin/${binaryName} (an optionalDependency of @nativedesktop/host); ` +
168
+ `reinstall without --no-optional, or build in a NativeDesktop checkout (${backendBuildHint(backend)}).`
169
+ : `No prebuilt package exists for this target; supported targets are darwin-arm64 (appkit) and ` +
170
+ `linux-x64 (gtk). Build in a NativeDesktop checkout (${backendBuildHint(backend)}).`;
171
+ throw new Error(`@nativedesktop/host: no ${backend} host binary for "${hostPlatformKey()}". ${hint}`);
172
+ }
173
+
174
+ function backendBuildHint(backend: Backend): string {
175
+ return backend === "gtk"
176
+ ? "`zig build`"
177
+ : "`zig build libnd -Dbackend=abi` then `cd swift && swift build -c release`";
178
+ }
179
+
180
+ /** Build the requested backend from source, logging one line, and return the artifact. */
181
+ async function buildBackend(backend: Backend, repoRoot: string, fresh: string[]): Promise<string> {
182
+ process.stderr.write(`nd: building ${backend} host (first run)…\n`);
183
+ if (backend === "gtk") {
184
+ await run(["zig", "build"], repoRoot, process.env);
185
+ const out = resolve(repoRoot, "zig-out", "bin", "nd-hello");
186
+ if (!existsSync(out)) throw new Error(`@nativedesktop/host: gtk build produced no ${out}`);
187
+ return out;
188
+ }
189
+ // appkit: build the GTK-free static core, repack the archive for Apple's ld,
190
+ // then link the Swift shell. Same recipe as scripts/mac/build-appkit-host.sh.
191
+ const env = appkitBuildEnv();
192
+ await run(["zig", "build", "libnd", "-Dbackend=abi"], repoRoot, env);
193
+ await repackLibnd(repoRoot, env);
194
+ await run(["swift", "build", "-c", "release"], resolve(repoRoot, "swift"), env);
195
+ const out = resolve(repoRoot, "swift", ".build", "release", "NDShell");
196
+ if (!existsSync(out)) throw new Error(`@nativedesktop/host: appkit build produced no ${out}`);
197
+ return out;
198
+ }
199
+
200
+ /** Zig's archiver emits members Apple's ld rejects ("not 8-byte aligned") and
201
+ * extracts them 0-permission; repack with the system ar/libtool before linking. */
202
+ async function repackLibnd(repoRoot: string, env: Record<string, string | undefined>): Promise<void> {
203
+ const lib = resolve(repoRoot, "zig-out", "lib", "libnd.a");
204
+ const recipe = `workdir="$(mktemp -d)"; cd "$workdir" && ar x "${lib}" && chmod 644 *.o && libtool -static -o "${lib}" *.o; status=$?; rm -rf "$workdir"; exit $status`;
205
+ await run(["bash", "-euo", "pipefail", "-c", recipe], repoRoot, env);
206
+ }
207
+
208
+ /** Env for the appkit build: scrub Nix/SDK overrides that break xcrun/swiftc so
209
+ * the system Swift toolchain resolves its own SDK (mirrors quick-start's
210
+ * `env -u SDKROOT -u DEVELOPER_DIR` and packages/nd's plugin build env). */
211
+ function appkitBuildEnv(): Record<string, string | undefined> {
212
+ const env: Record<string, string | undefined> = { ...process.env };
213
+ delete env.SDKROOT;
214
+ delete env.DEVELOPER_DIR;
215
+ delete env.DEVELOPER_SDK_DIR;
216
+ delete env.NIX_CFLAGS_COMPILE;
217
+ delete env.NIX_LDFLAGS;
218
+ return env;
219
+ }
220
+
221
+ async function run(command: string[], cwd: string, env: Record<string, string | undefined>): Promise<void> {
222
+ const proc = Bun.spawn(command, { cwd, env, stdin: "ignore", stdout: "inherit", stderr: "inherit" });
223
+ const status = await proc.exited;
224
+ if (status !== 0) throw new Error(`@nativedesktop/host: build step failed (${command.join(" ")})`);
225
+ }