feat(app): App.set_icon, so a game without a bundle still has an icon

`ludic bundle` builds an AppIcon.icns and macOS reads it out of the .app, so a
shipped game has an icon. `ludic build` produces a bare executable: no bundle, no
CFBundleIconFile, and so no icon at all - macOS draws the generic green "exec"
tile. That is the build a developer runs every day, which is why "the game has no
icon" can be true for months while the bundle is perfect. It was here: the .app's
icns validated against iconutil, the plist was right, the signature was right,
and NSWorkspace rendered the artwork - and the binary beside it still had the
exec tile.

App.set_icon(path) takes the bytes through lp_pak_open, so a packed path and a
loose one both work and this does not repeat Audio.load's trick of taking a
filesystem path only. NSData copies them, so the buffer goes straight back. A
missing or undecodable image leaves the existing icon alone rather than clearing
it; a bundled app is unaffected; headless links no AppKit and compiles it away.

Verified the Cocoa sequence against an ObjC twin doing the same message sends:
before, a bare binary's applicationIconImage is the generic 128x128 tile; after,
it is the 1024x1024 artwork.

Backend change, so selfhost/ludicc.seed.ll is reseeded: the bootstrap fixpoint
and the C-free rebuild from the seed both pass.
This commit is contained in:
Orkun ÇAKILKAYA 2026-09-12 12:55:12 +03:00
parent 925d133466
commit 38b6b81cd7
7 changed files with 34345 additions and 34143 deletions

19
changes/app-set-icon.md Normal file
View file

@ -0,0 +1,19 @@
bump: minor
type: feat
**`App.set_icon(path)`** — an icon for a binary that has no bundle to take one from.
`ludic bundle` builds an `AppIcon.icns` and macOS reads it from the `.app`, so a
shipped game has an icon. `ludic build` produces a bare executable, which has no
bundle, no `CFBundleIconFile` and therefore no icon at all — macOS draws the generic
green "exec" tile in the Dock. That is the build a developer runs all day and the one
they see every session, so "my game has no icon" is true long before it ships.
```ludic
App.set_icon("assets/app/icon.png")
```
The path resolves through the pack first and the filesystem second, like every other
asset, so the same call works packed and unpacked — it does not repeat `Audio.load`'s
trick of taking a filesystem path only. An image that cannot be found or decoded
leaves the current icon alone rather than clearing it, calling it in a bundled app is
a harmless no-op, and a headless build compiles it away to nothing.

View file

@ -0,0 +1,31 @@
---
id: app-set_icon
name: App.set_icon
category: app
kind: namespace-method
tokens: App.set_icon
sig: App.set_icon(path) -> void
tip: Set the Dock icon from an image the game ships.
order: 2
ns: App
member: set_icon
---
Sets the application's icon — the Dock tile, the Cmd-Tab switcher, the About box — from an image the game ships.
A **bundled** app already has one: `ludic bundle` builds `AppIcon.icns` from the manifest's `app icon` and macOS reads it from the bundle, so calling this there is harmless and changes nothing you can see. A **plain binary** is the case this exists for. `ludic build` produces an executable with no bundle around it, so macOS has nowhere to find an icon and draws the generic one — which is what a developer looks at every time they run the game.
The path is resolved the same way every other asset is, through the pack first and the filesystem second, so the same call works in a packed build and in a project directory. An image that cannot be found or decoded leaves the current icon alone rather than clearing it.
Parameters:
- `path` — an image the game ships (PNG, JPEG or TIFF); 1024x1024 is the size macOS wants
```ludic
program Titled {
entry {
App.set_icon("assets/app/icon.png")
}
}
```
Headless builds have no AppKit, so this compiles to nothing there.

View file

@ -1455,6 +1455,49 @@ out:
@.s_setbg = private unnamed_addr constant [20 x i8] c"setBackgroundColor:\00"
@.s_finlau = private unnamed_addr constant [16 x i8] c"finishLaunching\00"
@.s_setappicon = private unnamed_addr constant [25 x i8] c"setApplicationIconImage:\00"
; ---------------------------------------------------------------------------
; app_set_icon(bytes, len) - the Dock tile, the Cmd-Tab switcher, the About box.
;
; A BUNDLED app gets this from CFBundleIconFile and needs nothing here. A plain
; binary - which is what `ludic build` produces and what a developer runs all day
; - has no bundle and therefore no icon at all: macOS draws the generic green
; "exec" tile for it. That is not a cosmetic difference to someone who only ever
; sees the game that way.
;
; Bytes, not a path, for the same reason splash_show takes bytes: the caller has
; already resolved it through the pack. NSData copies them, so the caller may free
; its buffer immediately. Anything unreadable leaves the existing icon alone.
define void @app_set_icon(ptr %bytes, i32 %len) {
entry:
%nodata = icmp eq ptr %bytes, null
%empty = icmp sle i32 %len, 0
%skip = or i1 %nodata, %empty
br i1 %skip, label %out, label %go
go:
%appcls = call ptr @objc_getClass(ptr @.c_app)
%sel_sh = call ptr @sel_registerName(ptr @.s_shared)
%nsapp = call ptr @objc_msgSend(ptr %appcls, ptr %sel_sh)
%datacls = call ptr @objc_getClass(ptr @.c_data)
%sel_dwb = call ptr @sel_registerName(ptr @.s_datawb)
%lz = zext i32 %len to i64
%data = call ptr (ptr, ptr, ptr, i64) @objc_msgSend(ptr %datacls, ptr %sel_dwb, ptr %bytes, i64 %lz)
%imgcls = call ptr @objc_getClass(ptr @.c_image)
%sel_alloc = call ptr @sel_registerName(ptr @.s_alloc)
%i0 = call ptr @objc_msgSend(ptr %imgcls, ptr %sel_alloc)
%sel_iwd = call ptr @sel_registerName(ptr @.s_initdat)
%img = call ptr (ptr, ptr, ptr) @objc_msgSend(ptr %i0, ptr %sel_iwd, ptr %data)
%noimg = icmp eq ptr %img, null
br i1 %noimg, label %out, label %set
set:
%sel_seti = call ptr @sel_registerName(ptr @.s_setappicon)
%r = call ptr (ptr, ptr, ptr) @objc_msgSend(ptr %nsapp, ptr %sel_seti, ptr %img)
br label %out
out:
ret void
}
@W_splash = internal global ptr null
define void @splash_show(ptr %bytes, i32 %len, i32 %bg) {

View file

@ -249,6 +249,15 @@ function emit_ns_call(ns: pointer, meth: pointer, e: Node) -> Val {
if g_windowed { emit(" call void @splash_hide()\n") }
return val("0", "void")
}
if (meth == "set_icon") {
# The Dock tile. A bundled app takes it from CFBundleIconFile and this is a
# harmless no-op there; a bare `ludic build` binary has no bundle and no icon
# at all without it. Headless has no AppKit, so it compiles to nothing.
use_pak()
let ip = arg_code(e, 0)
if g_windowed { emit(` call void @lp_app_icon(ptr {ip})\n`) }
return val("0", "void")
}
perr(`unknown builtin App.{meth}`)
}
if (ns == "Pool") {

View file

@ -117,6 +117,7 @@ function emit_pak_prelude() -> void {
if g_windowed {
emith("declare void @splash_show(ptr, i32, i32)\n")
emith("declare void @splash_hide()\n")
emith("declare void @app_set_icon(ptr, i32)\n")
}
emit_pak_mount()
@ -300,5 +301,19 @@ function emit_pak_boot() -> void {
emith("show:\n %len = load i32, ptr %lp\n %bg = load i32, ptr @L_pak_splashbg\n")
emith(" call void @splash_show(ptr %blob, i32 %len, i32 %bg)\n br label %out\n")
emith("out:\n ret void\n}\n")
# App.set_icon(path): the Dock tile for a binary that has no bundle to take one
# from. Read through lp_pak_open, so a packed path and a loose one both work and
# this does not repeat Audio.load's trick of taking a filesystem path only.
# NSData copies the bytes, so the buffer goes straight back.
emith("define void @lp_app_icon(ptr %path) {\n")
emith("entry:\n %f = call ptr @lp_pak_open(ptr %path, ptr @.pak_rb)\n %bad = icmp eq ptr %f, null\n br i1 %bad, label %out, label %go\n")
emith("go:\n %s1 = call i32 @fseek(ptr %f, i64 0, i32 2)\n %n64 = call i64 @ftell(ptr %f)\n %s2 = call i32 @fseek(ptr %f, i64 0, i32 0)\n")
emith(" %tiny = icmp sle i64 %n64, 0\n br i1 %tiny, label %shut, label %read\n")
emith("read:\n %buf = call ptr @malloc(i64 %n64)\n %rd = call i64 @fread(ptr %buf, i64 1, i64 %n64, ptr %f)\n")
emith(" %c1 = call i32 @fclose(ptr %f)\n %n32 = trunc i64 %rd to i32\n")
emith(" call void @app_set_icon(ptr %buf, i32 %n32)\n call void @free(ptr %buf)\n ret void\n")
emith("shut:\n %c2 = call i32 @fclose(ptr %f)\n ret void\n")
emith("out:\n ret void\n}\n")
}
}

File diff suppressed because it is too large Load diff

View file

@ -157,6 +157,41 @@ function pack_probe_src() -> pointer {
return s
}
# App.set_icon: the Dock tile for a binary with no bundle around it. The tile itself
# cannot be asserted from a test process, so what is checked is the part that can go
# wrong silently - that it compiles and runs in a windowed build, that a missing or
# unreadable image is a no-op rather than a crash or a cleared icon, and that a headless
# build (which has no AppKit linked at all) compiles it away to nothing.
function app_icon_case() -> void {
let lbl = "App.set_icon -> an icon for a binary with no bundle, and nothing headless"
if not is_darwin() { skip(lbl); return }
let work = `{tmp_dir()}/appicon`
let root = capture_line("pwd")
run(`rm -rf {work} && mkdir -p {work}`)
# a real PNG to set, made the same way the bundle test makes its icon
run(`sips -s format png --resampleHeightWidth 64 64 /System/Library/CoreServices/CoreTypes.bundle/Contents/Resources/GenericApplicationIcon.icns --out {work}/icon.png > /dev/null 2>&1`)
var src = "program IconSet {" + nl()
src = src + " entry {" + nl()
src = src + " App.set_icon(\"icon.png\")" + nl()
src = src + " App.set_icon(\"nope.png\")" + nl() # missing: must not crash
src = src + " print(\"ok\")" + nl()
src = src + " }" + nl()
src = src + "}" + nl()
write_file(`{work}/i.ludic`, src)
if not shq(`cd {work} && {root}/bin/ludicc i.ludic -o i > cc.out 2>&1`) {
bad2(lbl, capture_line(`tail -1 {work}/cc.out`)); return
}
let got = s_trim(capture(`cd {work} && ./i 2>&1`))
if got != "ok" { bad2(lbl, `windowed build printed [{got}]`); return }
# headless links no AppKit: it has to compile and run just the same
if not shq(`cd {work} && {root}/bin/ludicc i.ludic --headless -o ih > hh.out 2>&1`) {
bad2(lbl, capture_line(`tail -1 {work}/hh.out`)); return
}
let goth = s_trim(capture(`cd {work} && ./ih 2>&1`))
if goth != "ok" { bad2(lbl, `headless build printed [{goth}]`); return }
ok(lbl)
}
# Os.save_dir / config_dir / cache_dir have to name the directory the PLATFORM keeps
# those things in - that is the entire reason a program calls them instead of building a
# path. They were the macOS layout on every platform, so a game built on Linux wrote its
@ -719,6 +754,7 @@ function cmd_dev_test() -> int {
pack_roundtrip_case()
packignore_case()
os_dirs_case()
app_icon_case()
bundle_case()
# install.sh is what the landing page tells people to pipe into sh, and it is