diff --git a/.github/actions/setup-native/action.yml b/.github/actions/setup-native/action.yml
index 6917c5f..6ad9755 100644
--- a/.github/actions/setup-native/action.yml
+++ b/.github/actions/setup-native/action.yml
@@ -26,7 +26,7 @@ inputs:
default: "0.16.0"
native-sdk-version:
description: "@native-sdk/cli version to install"
- default: "0.8.4"
+ default: "0.9.0"
apply-patches:
description: Whether to run scripts/apply-sdk-patches.sh
default: "true"
diff --git a/.gitignore b/.gitignore
index 8b86673..736956d 100644
--- a/.gitignore
+++ b/.gitignore
@@ -3,3 +3,4 @@ zig-out/
.zig-cache/
dist/
.claude/worktrees/
+.DS_Store
diff --git a/CLAUDE.md b/CLAUDE.md
index ffb9ff6..17d3d6b 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -4,7 +4,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
## What this is
-A native desktop player (macOS + Linux + Windows) for the SUB/WAVE internet radio station, built on the **Vercel Native SDK**: declarative `.native` markup + Zig logic, rendered by the SDK's own engine — no browser, no WebView. Requires **Zig 0.16.0** and a global `@native-sdk/cli` **0.8.4+** (`npm i -g @native-sdk/cli`).
+A native desktop player (macOS + Linux + Windows) for the SUB/WAVE internet radio station, built on the **Vercel Native SDK**: declarative `.native` markup + Zig logic, rendered by the SDK's own engine — no browser, no WebView. Requires **Zig 0.16.0** and a global `@native-sdk/cli` **0.9.0+** (`npm i -g @native-sdk/cli`).
## Commands
@@ -64,8 +64,8 @@ Data flow at runtime: timers poll `/api/now-playing`, `/api/state`, `/api/themes
- **The SDK cannot resize a live window.** Per-mode window shapes apply at next launch only.
- **A `hidden_inset_tall` masthead must pad `insets.left` AND `insets.right`.** The window-control cluster sits on a different edge per platform — macOS traffic lights lead, Windows min/max/close trail — so `onChrome` maps both into `chrome_leading` / `chrome_trailing` and `player-top.native` spacers both ends. Dropping the trailing one put the gear button under the DWM caption buttons on Windows, and fed the host's per-present caption-colour sampler (which reads the pixel 8px leading of the cluster and pushes it through `DWMWA_CAPTION_COLOR` + `USE_IMMERSIVE_DARK_MODE`) an antialiased glyph edge instead of flat header — the caption buttons flickered.
- **The FFT spectrum feed only emits while a window is visibly on screen** (occlusion gate in the SDK). A flat visualizer from an app launched in the background is not a bug — activate the app first.
-- SDK 0.8.4 still has **no OS media-controls surface** (no MPNowPlayingInfoCenter / MPRemoteCommandCenter / MPRIS / hardware media keys — re-checked at the 0.8.4 upgrade). The substitutes are the tray extra, the in-window keyboard transport, and the background track toast: 0.8.4 added `fx.showNotification`, so a track change while the app is backgrounded posts a desktop notification. It is opt-in (back panel → NOTIFICATIONS) and the whole decision lives in `Model.shouldNotifyTrack` — pure, because the effect is inert and unrecorded under fake execution, so no test can observe the call itself.
-- SDK 0.8.4 also has **no audio output-device API** — the platform seam is load/play/pause/stop/seek/volume, with no enumeration and no device property, so there is no in-app "play through these speakers" picker to build. Route it at the OS (`pavucontrol`/PipeWire, Windows volume mixer; macOS has nothing native). The upstream request lives in `docs/sdk-audio-device-request.md`.
+- SDK 0.9.0 still has **no OS media-controls surface** (no MPNowPlayingInfoCenter / MPRemoteCommandCenter / MPRIS / hardware media keys — re-checked at the 0.9.0 upgrade). The substitutes are the tray extra, the in-window keyboard transport, and the background track toast: 0.8.4 added `fx.showNotification`, so a track change while the app is backgrounded posts a desktop notification. It is opt-in (back panel → NOTIFICATIONS) and the whole decision lives in `Model.shouldNotifyTrack` — pure, because the effect is inert and unrecorded under fake execution, so no test can observe the call itself.
+- SDK 0.9.0 also has **no audio output-device API** — the platform seam is load/play/pause/stop/seek/volume, with no enumeration and no device property, so there is no in-app "play through these speakers" picker to build. Route it at the OS (`pavucontrol`/PipeWire, Windows volume mixer; macOS has nothing native). The upstream request lives in `docs/sdk-audio-device-request.md`.
- **Cover art loads through `fx.loadImage`, not `fx.fetch` + `registerImageBytes`** — the fetch and the platform decode run on a worker thread, with a content-addressed disk cache under the OS caches dir. Its ImageId **is** the effect key, which is why the counter starts at `keys.cover_image_base` (1000) clear of every other effect key. An id only reaches `model.cover_id` once the runtime reports `.loaded`; anything else leaves the initials disc standing.
- Stream format is listener-selectable (`stream_format.zig`): MP3 is the always-available floor, AAC additionally decodes on macOS (AVPlayer) and Windows (Media Foundation), and the Ogg-encapsulated Opus/FLAC mounts are Linux-only (neither AVPlayer nor Media Foundation has an Ogg demuxer). Every host asserts its **full** matrix in the `platformSupports` test — Windows fell through the defensive `else` for two releases and shipped MP3-only because cross-compiling never ran the suite for the target. Linux offers Ogg mounts optimistically and `scheduleReconnect` drops a failing non-MP3 pick back to MP3 after 3 retries. The picker **lists** every platform-decodable mount and lets you tune only the ones the station advertises via the `stream` flags on `/api/now-playing` — unserved mounts say so on their detail line and their press is absorbed in `update`, so a dead value never reaches `format_pref`. Its entry point is the pressable format chip in the transport deck's SIGNAL row (plus the always-present back-panel row).
- `native automate assert` regex does **not** support `|` alternation.
diff --git a/README.md b/README.md
index 734e9c8..e52391f 100644
--- a/README.md
+++ b/README.md
@@ -115,7 +115,7 @@ ones appear.
## Build & run
-Requires **Zig 0.16.0** and `@native-sdk/cli` **0.8.4+**
+Requires **Zig 0.16.0** and `@native-sdk/cli` **0.9.0+**
(`npm i -g @native-sdk/cli`) — **plus one local SDK patch**. After every SDK
install/upgrade:
@@ -204,7 +204,7 @@ serve are listed saying exactly that rather than quietly absent. The same row
is also in the back panel under SIGNAL.
**Playing through a different output device.** Not offered in-app: the SDK's
-audio surface (re-checked at 0.8.4) is load/play/pause/stop/seek/volume, with
+audio surface (re-checked at 0.9.0) is load/play/pause/stop/seek/volume, with
no device enumeration or output-device property on any host, so there is
nothing honest to build a picker on. Route it at the OS instead —
`pavucontrol` or any PipeWire patchbay on Linux, Settings → System → Sound →
@@ -212,7 +212,7 @@ Volume mixer on Windows. macOS has no per-app routing without a third-party
virtual audio driver. The API request is written up in
[`docs/sdk-audio-device-request.md`](docs/sdk-audio-device-request.md).
-**OS media integration.** The SDK (re-checked at 0.8.4) has no system
+**OS media integration.** The SDK (re-checked at 0.9.0) has no system
now-playing or media-key surface — no `MPNowPlayingInfoCenter`/
`MPRemoteCommandCenter` on macOS, no MPRIS on Linux — so hardware play/pause
keys and the OS Now Playing widget can't be wired up yet (SDK feature request).
diff --git a/design-reference/desktop-player.dc.html b/design-reference/desktop-player.dc.html
new file mode 100644
index 0000000..7482348
--- /dev/null
+++ b/design-reference/desktop-player.dc.html
@@ -0,0 +1,826 @@
+
+
+
+ TUNESpace
+ DIAL1–5
+ MUTEM
+ VOLUME↑ ↓
+ STATIONS⌘K
+ BACK TO LIVEEsc
+
+
MODEL SW-D1 · SERIAL ∞ · MADE FOR THE INTERNET
+
+
+
+
+
+
+
+
+ TUNING OUT IN
+ {{ sleepCountdown }}
+
+
+
+
+
+
+
The radio tunes itself out when the timer lapses — drift off without playing all night.
+
+
+
+ {{ opt.label }}
+ ✓
+
+
+
← back panel
+
+
+
+
+
+ Follow station
+ Use whatever palette the station broadcasts
+
+ ✓
+
+
+
+
+
+
+
+
+
+
+ {{ th.name }}
+
+
+
+
+
+
← back panel
+
+
+
+
+
+
+
+
diff --git a/docs/sdk-notes.md b/docs/sdk-notes.md
index ddd3e88..748bed9 100644
--- a/docs/sdk-notes.md
+++ b/docs/sdk-notes.md
@@ -9,11 +9,13 @@ native test # verify
The unified diff lives at `patches/native-sdk-local.patch`, generated against
the pristine 0.7.1 npm tarball and re-verified against 0.8.0 (whose
-`gtk_host.c` is byte-identical) and 0.8.4 (whose `gtk_host.c` changed, but
-entirely outside the patched hunks — regenerating produced the same file byte
-for byte). In every case the hunks land with zero offset and the patch itself
-needed no edit; only `patch_sdk_version` below moves. Symptom of a lost patch:
-pixelated text on a fractional-scale Linux display.
+`gtk_host.c` is byte-identical), 0.8.4 and 0.9.0 (whose `gtk_host.c` both
+changed, but entirely outside the patched hunks). Through 0.8.4 the patch body
+never moved at all; 0.9.0 is the first release where the file grew enough
+*before* the patched regions that the hunk **headers** had to be re-cut — the
+eight `@@` line numbers shift, every `+`/`-` line is unchanged. The hunks still
+land with zero fuzz. Symptom of a lost patch: pixelated text on a
+fractional-scale Linux display.
## Two version pins, and they must agree
@@ -69,6 +71,53 @@ workarounds they replaced are still visible in the git log:
[native#148]: https://github.com/vercel-labs/native/issues/148
[native#149]: https://github.com/vercel-labs/native/issues/149
+## What 0.9.0 changed (upgraded 2026-08-13)
+
+A big release, and **additive everywhere this app touches** — no app edits, no
+markup edits, the same 106 passed / 1 skipped before and after with nothing
+under `src/` changed. The bulk of it is a new persistence stack plus a much wider embed/C
+API and mobile hosts, none of which a desktop Zig core has to adopt.
+
+| Surface | 0.8.4 → 0.9.0 |
+| --- | --- |
+| `Effects` `pub fn` list | additions only (~35): `persist`, the `store*` KV verbs, the `db*` relational verbs, `credentials*`, `bindSystemServices`, **`hideWindow`**, **`setDockPresence`** |
+| Platform `*_fn` services | additions only: `hide_window_fn`, `set_dock_presence_fn`, `set_launch_at_login_fn` / `launch_at_login_status_fn`, `update_tray_presentation_fn`, `format_local_time_fn`, `note_blocking_call_abandoned_fn` |
+| `PlatformFeature` enum | identical |
+| `app.zon` manifest permissions | identical — nothing new to declare |
+| Markup vocabulary | identical: no new elements, no new attributes |
+| Built-in icons | identical (still nothing for picture-in-picture — hence `src/icons/mini.svg`) |
+| Automation protocol | `0x096c8aa4730c11ec`, unchanged — existing `native automate` calls keep working |
+| `minimum_zig_version` | 0.16.0, unchanged |
+| Trace default | still `.events` — `-Dtrace=off` stays mandatory |
+| `src/platform/linux/gtk_host.c` | changed (13 hunks), but **not in the patched regions** — see below |
+
+**`fx.hideWindow` is the one worth acting on later.** `model.zig`'s
+`toggle_mini` currently calls `fx.minimizeWindow("main")` because, as the
+comment there says, minimize was "the only reversible app-driven get-it-off-the-glass
+verb the SDK has" — `fx.closeWindow` is a real close and a closed shell window
+cannot come back. 0.9.0 removes that constraint, so mini mode could hide the
+full player outright instead of animating it into the Dock/taskbar. That is a
+behavior change, not an upgrade step; it is deliberately **not** part of this
+bump. Same for `setDockPresence` (an LSUIElement-style hide) and
+`setLaunchAtLogin`, both plausible features this app does not have yet.
+
+The persistence stack (`persist` / `store*` / `db*` / `credentials*`) could in
+principle replace `settings.zig`'s hand-rolled `settings.json`. It should not,
+yet: the current path is debounced, serialized, and covered by tests, and
+swapping it buys nothing a radio player needs.
+
+**The HiDPI patch survived again, with one wrinkle.** All seven scale sites in
+the stock 0.9.0 tree still read an integer API — four `gtk_widget_get_scale_factor()`
+and three `gdk_surface_get_scale_factor()`, zero uses of the fractional
+`gdk_surface_get_scale()` — so [vercel-labs/native#156] remains open and the
+patch remains necessary. `gtk_host.c` changed in 13 hunks (0.9.0 adds
+`native_sdk_gtk_hide_window`, a libsecret-backed credentials store, and a
+deferred-show path), none overlapping the eight patched regions: a
+`patch --fuzz=0 --dry-run` against the pristine tarball still applies. Unlike
+0.8.4, though, regenerating did **not** reproduce a byte-identical file — the
+additions at line 358 push everything down, so the eight `@@` headers were
+re-cut against 0.9.0. Patch body unchanged line for line.
+
## What 0.8.4 changed (upgraded 2026-08-12)
Four releases (0.8.1–0.8.4, 5–10 August). Additive across every surface this
@@ -265,15 +314,15 @@ surface at 1.6667 yields an 1889px buffer for 1888px of screen) and maps buffer
pixels 1:1 to device pixels when it matches, letting the surplus edge column
fall outside the clip.
-0.6.0, 0.7.1, 0.8.0 and 0.8.4 all still read the integer API at every one of
-those sites (0.6.0 added `gdk_surface_get_scale_factor` calls, which is the
-*integer* GDK entry point, not the fractional `gdk_surface_get_scale`; 0.7.1
-changed none of them, 0.8.0 does not touch `gtk_host.c` at all, and 0.8.4
-changes it only outside the patched hunks), so the patch still applies — with
-zero fuzz. **Re-apply after every
+0.6.0, 0.7.1, 0.8.0, 0.8.4 and 0.9.0 all still read the integer API at every
+one of those sites (0.6.0 added `gdk_surface_get_scale_factor` calls, which is
+the *integer* GDK entry point, not the fractional `gdk_surface_get_scale`;
+0.7.1 changed none of them, 0.8.0 does not touch `gtk_host.c` at all, and
+0.8.4 and 0.9.0 change it only outside the patched hunks), so the patch still
+applies — with zero fuzz. **Re-apply after every
`npm i -g @native-sdk/cli` upgrade**, or drop it once
[vercel-labs/native#156](https://github.com/vercel-labs/native/issues/156)
-ships — still open as of 0.8.0. Integer scales (100%/200%) are unaffected, which is why it can look fine
+ships — still open as of 0.9.0. Integer scales (100%/200%) are unaffected, which is why it can look fine
on a second machine.
Verify it took on a fractional display: run the app under automation and read
diff --git a/patches/native-sdk-local.patch b/patches/native-sdk-local.patch
index dc5a16f..b323467 100644
--- a/patches/native-sdk-local.patch
+++ b/patches/native-sdk-local.patch
@@ -1,6 +1,6 @@
--- a/src/platform/linux/gtk_host.c
+++ b/src/platform/linux/gtk_host.c
-@@ -1000,6 +1000,41 @@
+@@ -1001,6 +1001,41 @@
g_free(preedit);
}
@@ -42,7 +42,7 @@
static double native_sdk_gpu_surface_width(native_sdk_gtk_native_view_t *view) {
int width = view->widget ? gtk_widget_get_width(view->widget) : 0;
if (width > 0) return (double)width;
-@@ -1057,7 +1092,7 @@
+@@ -1058,7 +1093,7 @@
const double width = native_sdk_gpu_surface_width(view);
const double height = native_sdk_gpu_surface_height(view);
if (width <= 0 || height <= 0) return;
@@ -51,7 +51,7 @@
if (native_sdk_gpu_surface_sync_geometry(view, width, height, scale)) {
gtk_widget_queue_draw(view->widget);
}
-@@ -1097,7 +1132,7 @@
+@@ -1098,7 +1133,7 @@
const double width = native_sdk_gpu_surface_width(view);
const double height = native_sdk_gpu_surface_height(view);
@@ -60,7 +60,7 @@
if (width <= 0 || height <= 0) return;
(void)native_sdk_gpu_surface_sync_geometry(view, width, height, scale);
-@@ -1207,7 +1242,7 @@
+@@ -1208,7 +1243,7 @@
const double logical_width = native_sdk_gpu_surface_width(view);
const double logical_height = native_sdk_gpu_surface_height(view);
if (logical_width <= 0 || logical_height <= 0) return;
@@ -69,7 +69,7 @@
if (native_sdk_gpu_surface_sync_geometry(view, logical_width, logical_height, scale)) {
gtk_widget_queue_draw(view->widget);
}
-@@ -1222,18 +1257,29 @@
+@@ -1223,18 +1258,29 @@
return;
}
cairo_save(cr);
@@ -110,7 +110,7 @@
cairo_set_source_surface(cr, surface, 0, 0);
cairo_pattern_set_filter(cairo_get_source(cr), exact ? CAIRO_FILTER_NEAREST : CAIRO_FILTER_BILINEAR);
} else {
-@@ -2508,7 +2554,7 @@
+@@ -2509,7 +2555,7 @@
int w = 0, h = 0;
native_sdk_window_content_size(win, &w, &h);
GdkSurface *surface = gtk_native_get_surface(GTK_NATIVE(win->gtk_window));
@@ -119,7 +119,7 @@
int focused = gtk_window_is_active(win->gtk_window) ? 1 : 0;
native_sdk_emit(host, (native_sdk_gtk_event_t){
.kind = NATIVE_SDK_GTK_EVENT_WINDOW_FRAME,
-@@ -2530,7 +2576,7 @@
+@@ -2532,7 +2578,7 @@
int w = 0, h = 0;
native_sdk_window_content_size(win, &w, &h);
GdkSurface *surface = gtk_native_get_surface(GTK_NATIVE(win->gtk_window));
@@ -128,7 +128,7 @@
native_sdk_emit(host, (native_sdk_gtk_event_t){
.kind = NATIVE_SDK_GTK_EVENT_RESIZE,
.window_id = win->id,
-@@ -2675,7 +2721,7 @@
+@@ -2677,7 +2723,7 @@
const double w = (double)gtk_widget_get_width(GTK_WIDGET(win->gtk_window));
const double h = (double)gtk_widget_get_height(GTK_WIDGET(win->gtk_window));
GdkSurface *surface = gtk_native_get_surface(GTK_NATIVE(win->gtk_window));
diff --git a/scripts/apply-sdk-patches.sh b/scripts/apply-sdk-patches.sh
index f0ff8cc..796a48f 100755
--- a/scripts/apply-sdk-patches.sh
+++ b/scripts/apply-sdk-patches.sh
@@ -10,7 +10,7 @@ set -euo pipefail
# lines, or reporting success against a tree the app can no longer build. Bump
# this together with .github/actions/setup-native's native-sdk-version whenever
# the patch is regenerated (docs/sdk-notes.md walks through it).
-patch_sdk_version="0.8.4"
+patch_sdk_version="0.9.0"
sdk="$(npm root -g)/@native-sdk/cli"
repo="$(cd "$(dirname "$0")/.." && pwd)"