Stamping the bundle id onto Legacy Mode windows (slice 19.D-β-2a)

Kyle entry

The chrome stub from 19.D-β-1 finds the bundle window by walking `_NET_CLIENT_LIST` and matching `WM_CLASS.res_name` against the bundle id. That works for native MoonBase apps because we control how they spawn. But the whole point of Legacy Mode is to wrap an unmodified Qt or GTK binary in a `.app`, and an unmodified kate will set its `res_name` to "kate", not "show.blizzard.testqt". The chrome would never find its window.

19.D-β-2a is the fix: a one-key TOML hint in `Info.appc` that tells `moonbase-launch` how to make the inner toolkit cooperate.

[wrap] toolkit = "qt6" # or qt5, gtk3, gtk4, native (default)

The launcher reads it, validates against the allowlist (anything else is a hard error per the schema's "unknown value in an enumerated key" rule), and rewrites bwrap's argv accordingly:

* native — no rewrites. Native MoonBase apps already call `moonbase_window_create()` and the chrome stub finds them via the IPC handshake (a post-β path; today's stub still uses WM_CLASS for native too, but that's fine because native apps already set their own res_name).

* qt5 / qt6 — bwrap gets `--argv0 <bundle.id>` AND the inner exec gets the trailing argv `-name <bundle.id>`. Qt's `-name` switch is documented to set `XClassHint.res_name`. The `--argv0` is belt-and-suspenders — Qt actually keys off `-name` first, but a future toolkit might not.

* gtk3 / gtk4 — `--argv0 <bundle.id>` only. GTK derives `g_get_prgname()` from `g_path_get_basename(argv[0])`, which in turn seeds `WM_CLASS.res_name`. There's no equivalent of `-name` on the GTK side, but `--argv0` is enough.

Forward-compat is preserved both ways. Newer launchers that don't understand a future `[wrap].toolkit = "qt7"` value will hard-error (the schema says enum values are strict). But older launchers that don't understand `[wrap]` at all will skip the whole table entirely under the schema's "unknown top-level table → warn, do not error" rule and just exec the bundle without the WM_CLASS stamp. The chrome won't find the window on those older launchers, but the bundle still runs — graceful degradation instead of a hard refusal.

Smoke test on the Legion: built a tiny C binary inside `/tmp/wraptest.appdev` that just printed its argv, then ran it under the launcher with three different `[wrap].toolkit` values:

qt6 → argv = [show.blizzard.testqt, -name, show.blizzard.testqt] native → argv = [/path/to/exe] (no rewrites) gtk3 → argv = [show.blizzard.testqt] (argv0 only)

Three lines of TOML pluming through to three different argv shapes, exactly the matrix the schema doc promises.

What this unblocks: 19.D-β-2b (DBusMenu import) can now assume the bundle window's WM_CLASS instance equals the bundle id, so the chrome stub can call `com.canonical.AppMenu.Registrar.GetMenuForWindow(xid)` and import the legacy app's menu tree using the bundle id as the lookup key.

What this doesn't change: menubar's app-name lookup, moonrock's window-rules, and inputsession's per-window context all key on `WM_CLASS.res_class` (the second string), not `res_name`. Both strings get stamped — Qt sets res_name from `-name` and Qt's default res_class from the binary's basename, so the class naturally stays toolkit-conventional. Side note: there's a single edge case at `moonrock_plugin.c:768` where user-configured plugin rules also key on `WM_CLASS`, so a user who hand-wrote a rule matching `kate` won't see it apply when kate is wrapped in a bundle with a stamped res_name. Non-blocking, worth a CLAUDE.md addendum later.

AI perspective

The decision worth highlighting is "drop the env-var plumbing." The advisor's first sketch had the launcher set `MOONBASE_BUNDLE_ID` in the bwrap environment, and bwrap or a shim would read that env var to pick the right argv. That works, but it's an extra moving piece — every Legacy Mode app would have to either be relaunched through a shim, or the launcher would have to inject a `bash -c 'exec ...'` wrapper. Both add a fork to the critical path of every app launch, and both put the bundle id in two places (the manifest AND the env var) which means two ways for them to get out of sync.

Reading `bundle.info.wrap_toolkit` directly inside `moonbase-launch.c` and emitting the argv at argv-build time is strictly less code, fewer processes, and one source of truth. The manifest is already the authoritative thing the launcher reads to know what to launch — keeping the stamping rule co- located with the rest of the manifest-consuming logic is the obvious shape in retrospect.

The forward-compat asymmetry (unknown table = warn, unknown enum value = hard error) was already in the schema doc, but 19.D-β-2a is the first slice that exercises it on the launcher side. Older launchers see `[wrap]` and shrug; they keep working, the chrome stub just won't dock onto Legacy Mode bundles. That's the right failure mode — the worst case is "pre-β-2a launchers can't do Legacy Mode," not "pre-β-2a launchers refuse to launch β-2a-era bundles." The latter would be an upgrade trap.

The unit tests landed in this slice (`test_wrap_toolkit_qt6`, `test_wrap_toolkit_unknown`) deliberately don't test argv construction. That belongs in a moonbase-launch integration test slice, not the schema parser tests — schema-level tests should verify "we read the value correctly," and argv-level tests should verify "we emit the right argv given a value." Keeping those two concerns in separate test files is what made the diff this slice small (4 files, +119 lines).