feat(bundle): ship a game as a macOS .app, with a splash it controls

`ludic build` produces a program. Double-clicked it opens a Terminal window, it
wears the generic executable icon, it calls itself whatever the file is called,
and it carries none of its assets. `ludic bundle` produces an application.

Everything it needs is in package.ludic, so the command takes no arguments: an
Info.plist and PkgInfo from `app` lines, an .icns built by sips and iconutil at
all ten sizes macOS asks for from a single source PNG, the asset pack in
Contents/Resources, and an ad-hoc signature - which is not optional on Apple
silicon, where an unsigned binary is killed rather than warned about. The bundle
identifier falls back to the package path reversed, so a project that never
thinks about it still gets a defensible one instead of two apps sharing a key
Launch Services hangs the Dock, saved state and permissions off.

A bundled game is moved to ~/Library/Application Support/<name> before main,
because Finder starts a .app with its working directory at "/" where no save
could ever be written. Reads come out of the pack, writes land somewhere real
and per-user, and the game's save code needs no change and no platform
knowledge.

The splash is the other half of looking like an application. A game that loads
165 MB spends a visible moment doing it with nothing on screen, which from the
outside is indistinguishable from a launch that failed. splash_show puts a
borderless window up from the same constructor that mounts the pack - before
main, so it appears while the process is still starting rather than after the
slow part it exists to cover - and reads the artwork out of the pack like any
other asset. It turns the run loop enough times to be mapped and composited
there and then; once composited the backing store survives a busy main thread,
so it stays up for the whole load.

Nothing hides it automatically. Only the game knows when its first real frame is
ready, and a splash that vanishes before that leaves the same black gap it was
covering, so the game calls App.splash_hide(). Headless there is no splash and
the call lowers to nothing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Orkun ÇAKILKAYA 2026-09-10 16:57:27 +03:00
parent ce5bf0fe0e
commit be74b4de6f
14 changed files with 34667 additions and 33621 deletions

View file

@ -157,6 +157,73 @@ function pack_probe_src() -> pointer {
return s
}
# `ludic bundle` has to produce something macOS actually accepts as an
# application, not a directory that looks like one: a plist that plutil parses,
# an executable where Info.plist says it is, an .icns, and a signature. The
# app's own assets go in the pack beside it, and the display name is allowed a
# space - which is where the shell quoting in the bundler earns its keep.
function bundle_case() -> void {
let lbl = "ludic bundle -> a .app with metadata, icon, assets and a signature"
if not is_darwin() { skip(lbl); return }
let work = `{tmp_dir()}/bundle`
let root = capture_line("pwd")
run(`rm -rf {work} && mkdir -p {work}/src {work}/assets`)
write_file(`{work}/assets/hello.txt`, "packed")
# a 64x64 source icon: small, but sips will scale it and iconutil accept it
run(`sips -s format png --resampleHeightWidth 64 64 /System/Library/CoreServices/CoreTypes.bundle/Contents/Resources/GenericApplicationIcon.icns --out {work}/assets/icon.png > /dev/null 2>&1`)
write_file(`{work}/package.ludic`, bundle_manifest_src())
write_file(`{work}/src/main.ludic`, bundle_probe_src())
if not shq(`cd {work} && {root}/bin/ludic bundle > bundle.out 2>&1`) {
bad2(lbl, capture_line(`tail -1 {work}/bundle.out`)); return
}
let app = `{work}/build/Probe App.app`
# quoted: the display name has a space in it, which is the whole point, and
# the prelude's file_exists builds an unquoted `test -e`
if not there(`{app}/Contents/MacOS/Probe App`) { bad2(lbl, "no executable in Contents/MacOS"); return }
if not there(`{app}/Contents/Resources/game.lpak`) { bad2(lbl, "no asset pack"); return }
if not there(`{app}/Contents/Resources/packs.index`) { bad2(lbl, "no packs.index"); return }
if not there(`{app}/Contents/Resources/AppIcon.icns`) { bad2(lbl, "no icon"); return }
if not there(`{app}/Contents/PkgInfo`) { bad2(lbl, "no PkgInfo"); return }
if not shq(`plutil -lint "{app}/Contents/Info.plist" > /dev/null 2>&1`) {
bad2(lbl, "Info.plist does not parse"); return
}
# the identifier is derived from the package path when the manifest omits it
let id = capture_line(`plutil -extract CFBundleIdentifier raw "{app}/Contents/Info.plist" 2>/dev/null`)
if id != "io.workshopsoft.probe-app" { bad2(lbl, `bundle id came out [{id}]`); return }
if not shq(`codesign --verify "{app}" > /dev/null 2>&1`) { bad2(lbl, "the signature does not verify"); return }
# and it runs from a directory with none of its assets, reading them from the pack
let got = s_trim(capture(`cd / && "{app}/Contents/MacOS/Probe App" 2>&1`))
if got != "packed" { bad2(lbl, `the bundled app printed [{got}], wanted [packed]`); return }
ok(lbl)
}
# `test -e` with the path quoted, so a bundle whose name contains a space can
# be inspected at all
function there(path: pointer) -> bool { return shq(`test -e "{path}"`) }
function bundle_manifest_src() -> pointer {
var s = "package \"git.workshopsoft.io/workshopsoft/probe-app\"" + nl()
s = s + "version \"2.1.0\"" + nl()
s = s + "kind source" + nl()
s = s + "app name \"Probe App\"" + nl()
s = s + "app icon \"assets/icon.png\"" + nl()
s = s + "app copyright \"(c) 2026 A & B\"" + nl()
s = s + "pack \"assets\"" + nl()
return s
}
function bundle_probe_src() -> pointer {
var s = "program Probe {" + nl()
s = s + " entry {" + nl()
s = s + " var t = Fs.read_text(\"assets/hello.txt\")" + nl()
s = s + " if t == null { t = \"MISS\" }" + nl()
s = s + " print(t)" + nl()
s = s + " }" + nl()
s = s + "}" + nl()
return s
}
function scaffold_names_case() -> void {
let lbl = "ludic new scaffolds a project that compiles (hyphens, digits, dots)"
let work = `{tmp_dir()}/scaffold`
@ -541,6 +608,7 @@ function cmd_dev_test() -> int {
install_layout_case()
scaffold_names_case()
pack_roundtrip_case()
bundle_case()
# install.sh is what the landing page tells people to pipe into sh, and it is
# published with the docs site — so it is checked here rather than discovered