Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 60 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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/<addon>.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/<addon>.node
process.env.NAPI_RS_NATIVE_LIBRARY_PATH ??= path.join(
path.dirname(process.execPath),
'..',
'Frameworks',
'<addon-file-name>.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

Expand Down
87 changes: 82 additions & 5 deletions examples/compile-chat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -163,21 +172,84 @@ async function compileBinary(): Promise<void> {
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<void> {
const addonFileName = nativeAddonFileName()
writeFileSync(
APP_ENTRY_SOURCE,
[
"import path from 'node:path'",
'',
'// Contents/MacOS/chat -> ../Frameworks/<addon>.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<void> {
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')])
}
Expand Down Expand Up @@ -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)}`)
}

Expand All @@ -228,7 +305,7 @@ async function main(): Promise<void> {
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}"`)
Expand Down
Loading