diff --git a/README.md b/README.md index ac72b1a2..21799a51 100644 --- a/README.md +++ b/README.md @@ -180,6 +180,10 @@ The binary carries the renderer, so it runs with no Bun and no Node install. Keep `--production`: without it the binary bundles React's development build, which in the chat example costs about 20 MB of memory. +This embeds `@gpuix/native`'s `.node` addon in the executable, which a +single-file binary you hand someone should keep — see the next step for a +`.app`, which has room to avoid the embedding cost instead. + ### 5. Wrap it in an app with an icon A raw Mach-O has no Dock icon. Use @@ -237,7 +241,62 @@ open "bundle/My App.app" | Windows | `"nsis"` | setup `.exe` | | Linux | `"appimage"` | `.AppImage` | -On this machine the Bun chat `.app` is **82 MB**. +On this machine the Bun chat `.app` packed this way is **82 MB**, with the +addon embedded in `dist/app` as in step 4. That embedding costs a macOS +`.app` more than it costs a single-file binary: `bun build --compile` +extracts the addon to `$TMPDIR` on first launch, `dlopen`s it from there, and +pays a Gatekeeper scan on that extracted copy; the extraction is purged +periodically, so a later launch re-pays it. A `.app` has room to avoid this +by shipping the addon as a real file next to the executable instead — the +addon is then scanned once, when the app is installed, not each time +`$TMPDIR` is purged. + +**Keep the addon out of the executable.** Compile with `--external '*.node'` +so Bun leaves the addon's `require()` call alone instead of embedding the +file it points to, then ship the addon at `Contents/Frameworks/.node` +and point `@gpuix/native`'s loader at it before your entry file's first +import of `@gpuix/react` runs. The loader — napi-rs's generated `index.js` — +checks `NAPI_RS_NATIVE_LIBRARY_PATH` before anything else, so setting that +env var is enough; no GPUIX-internal API needed: + +```ts +// app-entry.ts — compile this instead of app.tsx directly +import path from 'node:path' + +// Contents/MacOS/app -> ../Frameworks/.node +process.env.NAPI_RS_NATIVE_LIBRARY_PATH ??= path.join( + path.dirname(process.execPath), + '..', + 'Frameworks', + '.node', // e.g. gpuix-native.darwin-arm64.node +) + +await import('./app.tsx') +``` + +```bash +bun build --compile --production --external '*.node' app-entry.ts --outfile dist/app +``` + +`cargo packager`'s `binaries` config wraps a binary as-is; it does not know +about the addon. After packing, copy the addon your platform loads (the file +under `packages/native/*.node`, or `node_modules/@gpuix/native/` in an +installed app) into the bundle yourself, then ad-hoc sign both — there is no +Developer ID certificate in most CI environments, and cargo-packager may +already have signed the app once, before the addon was added to it: + +```bash +mkdir -p "bundle/My App.app/Contents/Frameworks" +cp node_modules/@gpuix/native/gpuix-native.darwin-arm64.node \ + "bundle/My App.app/Contents/Frameworks/" +codesign --force --sign - "bundle/My App.app/Contents/Frameworks/gpuix-native.darwin-arm64.node" +codesign --force --deep --sign - "bundle/My App.app" +``` + +This also shrinks the executable: about 30 MB smaller for the chat example, +whose addon is about 29 MB. `examples/compile-chat.ts` does all of this for +the chat example without cargo-packager, wrapping `dist/chat` by hand; read +it for the same steps end to end. ### 6. Auto-update diff --git a/examples/compile-chat.ts b/examples/compile-chat.ts index a70697a3..8d340b46 100644 --- a/examples/compile-chat.ts +++ b/examples/compile-chat.ts @@ -21,6 +21,15 @@ const WINDOWS = const BINARY = path.join(DIST, outputName()) const APP_NAME = 'GPUIX Chat' const APP_BUNDLE = path.join(DIST, `${APP_NAME}.app`) +const NATIVE_DIR = path.join(ROOT, '..', 'packages', 'native') +const APP_ENTRY_SOURCE = path.join(ROOT, '.app-entry.generated.ts') + +// The `.node` napi-rs picks for this host. `wrapMacApp` only runs when +// compiling on macOS for macOS, so the running process's arch is the one +// that matters here. +function nativeAddonFileName(): string { + return `gpuix-native.darwin-${process.arch === 'arm64' ? 'arm64' : 'x64'}.node` +} function outputName(): string { const requested = process.env.COMPILE_OUT @@ -163,21 +172,84 @@ async function compileBinary(): Promise { log(`wrote ${path.relative(ROOT, output)}`) } -function wrapMacApp(): void { +/** + * Compile the `.app`'s executable so it does not embed the native addon. + * + * `bun build --compile` embeds any `.node` file it finds statically required + * from the entrypoint, extracting it to `$TMPDIR` on first launch and paying + * a Gatekeeper scan there every time that extraction is purged. Marking + * `.node` files external stops the embedding; the addon then ships beside + * the executable instead, in `Contents/Frameworks`. + * + * A plain `require('@gpuix/native')` can't find that file relative to a + * bundled, single-file executable, so this writes a small entry file that + * points `NAPI_RS_NATIVE_LIBRARY_PATH` — the environment variable napi-rs's + * generated loader checks before anything else — at the addon's real path + * next to the running executable, then loads the real entry. An app author + * can reproduce this without any GPUIX-internal knowledge: set the env var + * before importing anything that loads `@gpuix/native`, from `node:path` + * and `process.execPath` alone. + */ +async function compileAppExecutable(executable: string): Promise { + const addonFileName = nativeAddonFileName() + writeFileSync( + APP_ENTRY_SOURCE, + [ + "import path from 'node:path'", + '', + '// Contents/MacOS/chat -> ../Frameworks/.node', + 'const addon = path.join(', + ' path.dirname(process.execPath),', + " '..',", + " 'Frameworks',", + ` ${JSON.stringify(addonFileName)},`, + ')', + 'process.env.NAPI_RS_NATIVE_LIBRARY_PATH ??= addon', + '', + "await import('./chat.tsx')", + '', + ].join('\n'), + ) + try { + log(`bundling ${path.basename(APP_ENTRY_SOURCE)} into ${path.relative(ROOT, executable)}`) + const result = await Bun.build({ + entrypoints: [APP_ENTRY_SOURCE], + compile: { outfile: executable }, + external: ['*.node'], + minify: true, + define: { 'process.env.NODE_ENV': JSON.stringify('production') }, + }) + if (!result.success) { + for (const message of result.logs) console.error(message) + throw new Error('bun build --compile failed for the .app executable') + } + } finally { + rmSync(APP_ENTRY_SOURCE, { force: true }) + } + run('chmod', ['+x', executable]) +} + +async function wrapMacApp(): Promise { if (process.env.COMPILE_SKIP_APP === '1') return if (process.platform !== 'darwin') return if (COMPILE_TARGET && !COMPILE_TARGET.includes('darwin')) return - log(`wrapping ${path.relative(ROOT, BINARY)} in ${path.basename(APP_BUNDLE)}`) + log(`wrapping chat.tsx in ${path.basename(APP_BUNDLE)}`) rmSync(APP_BUNDLE, { recursive: true, force: true }) const macos = path.join(APP_BUNDLE, 'Contents', 'MacOS') const resources = path.join(APP_BUNDLE, 'Contents', 'Resources') + const frameworks = path.join(APP_BUNDLE, 'Contents', 'Frameworks') mkdirSync(macos, { recursive: true }) mkdirSync(resources, { recursive: true }) + mkdirSync(frameworks, { recursive: true }) + + const addonFileName = nativeAddonFileName() + const addon = path.join(frameworks, addonFileName) + run('cp', [path.join(NATIVE_DIR, addonFileName), addon]) + run('codesign', ['--force', '--sign', '-', addon]) const executable = path.join(macos, 'chat') - run('cp', [BINARY, executable]) - run('chmod', ['+x', executable]) + await compileAppExecutable(executable) if (existsSync(ICNS)) { run('cp', [ICNS, path.join(resources, 'AppIcon.icns')]) } @@ -219,6 +291,11 @@ function wrapMacApp(): void { ].join('\n') writeFileSync(path.join(APP_BUNDLE, 'Contents', 'Info.plist'), plist) run('touch', [APP_BUNDLE]) + + // Ad-hoc: there is no Developer ID certificate in this environment. This + // reseals Contents/_CodeSignature over the addon now sitting in + // Frameworks, which the earlier per-file signature alone doesn't cover. + run('codesign', ['--force', '--deep', '--sign', '-', APP_BUNDLE]) log(`wrote ${path.relative(ROOT, APP_BUNDLE)}`) } @@ -228,7 +305,7 @@ async function main(): Promise { mkdirSync(DIST, { recursive: true }) await buildIcons() await compileBinary() - wrapMacApp() + await wrapMacApp() log('done') if (process.platform === 'darwin' && existsSync(APP_BUNDLE)) { log(`run: open "${APP_BUNDLE}"`)