diff --git a/cli/src/index.ts b/cli/src/index.ts index 30ecaa36..0caaf842 100644 --- a/cli/src/index.ts +++ b/cli/src/index.ts @@ -503,9 +503,9 @@ function showLogs(name: string, follow: boolean): Promise | void { const oldPath = `${path}.old`; const text = [oldPath, path].filter(existsSync).map((file) => readFileSync(file, "utf8")).join(""); const lines = text.split(/\r?\n/).filter((line) => line.length > 0).slice(-200); + const follower = follow ? followLogFile(name, true) : undefined; if (lines.length > 0) process.stdout.write(`${lines.join("\n")}\n`); - if (!follow) return; - const follower = followLogFile(name, true); + if (!follower) return; return new Promise((resolvePromise) => { const stop = (): void => { follower.stop(); resolvePromise(); }; process.once("SIGINT", stop); diff --git a/cli/test/install-smoke.mjs b/cli/test/install-smoke.mjs index 8ce17059..f53d8260 100644 --- a/cli/test/install-smoke.mjs +++ b/cli/test/install-smoke.mjs @@ -56,9 +56,11 @@ function ownedEntries() { return existsSync(widgetsRoot) ? readdirSync(widgetsRoot).sort() : []; } -function waitForOutput(child, token, timeoutMs = 5_000) { +function waitForOutput(child, token, timeoutMs = 30_000) { return new Promise((resolvePromise, rejectPromise) => { let output = ""; + // Shared CI filesystems can delay visibility to a separate tailing process; + // keep this smoke bounded without treating a five-second delay as failure. const timeout = setTimeout(() => rejectPromise(new Error(`Timed out waiting for log follower token ${token}\noutput:\n${output}`)), timeoutMs); child.stdout.on("data", (bytes) => { output += bytes; diff --git a/docs/media-evidence/pr01-now-playing-playing.png b/docs/media-evidence/pr01-now-playing-playing.png new file mode 100644 index 00000000..591c1833 Binary files /dev/null and b/docs/media-evidence/pr01-now-playing-playing.png differ diff --git a/docs/media-evidence/pr01-now-playing.png b/docs/media-evidence/pr01-now-playing.png new file mode 100644 index 00000000..d338a46b Binary files /dev/null and b/docs/media-evidence/pr01-now-playing.png differ diff --git a/docs/media-evidence/pr01-visual.md b/docs/media-evidence/pr01-visual.md new file mode 100644 index 00000000..f7eaa37b --- /dev/null +++ b/docs/media-evidence/pr01-visual.md @@ -0,0 +1,39 @@ +# Media PR 01 visual and live evidence + +Date: 2026-07-25 + +Machine: Windows 11, Spotify Premium desktop client + +Example: `examples/now-playing` + +The example passed `weaver check` and ran under `weaver dev`. The widget window +rectangle was read from the real `weaver-widget.exe` window. Other windows were +minimized through `Shell.Application.MinimizeAll`, the exact 360x142 region was +captured with `System.Drawing.Graphics.CopyFromScreen`, and each PNG below was +opened and viewed at original resolution. + +## Paused frame + +Capture: `docs/media-evidence/pr01-now-playing.png` + +| Element | Present | Positioned | Styled | Correct live data | Result | +|---|---|---|---|---|---| +| Rounded dark shell | yes | fills 360x142 capture | rounded corners, translucent-dark surface | n/a | PASS | +| Now-playing marker and label | yes | top-left | purple marker and label | n/a | PASS | +| Playback status | yes | top-right | muted uppercase text | `PAUSED` while Spotify was paused | PASS | +| Track title | yes | first metadata line | large white text | `Music Baby (leroy Remix)` | PASS | +| Artist | yes | second metadata line | muted gray text | `Jane Remover` | PASS | +| Source application | yes | third metadata line | smaller muted text | honest raw SMTC ID `Spotify.exe` | PASS | +| Segmented progress | yes | bottom row | purple filled and slate unfilled segments | nonzero live timeline | PASS | + +## Playing frame + +Capture: `docs/media-evidence/pr01-now-playing-playing.png` + +| Element | Present | Positioned | Styled | Correct live data | Result | +|---|---|---|---|---|---| +| Playback status | yes | top-right | muted uppercase text | changed to `PLAYING` within the next poll after sending the real player play/pause command | PASS | +| Track metadata | yes | unchanged metadata area | unchanged styling | title, artist, and source remained live and correct | PASS | +| Progress | yes | bottom row | unchanged styling | advanced while playing | PASS | + +No capture was black, empty, or occluded. All visual checklist items pass. diff --git a/docs/media-run-status.md b/docs/media-run-status.md new file mode 100644 index 00000000..e6a047cf --- /dev/null +++ b/docs/media-run-status.md @@ -0,0 +1,53 @@ +# Media v2 unattended run status + +Last updated: 2026-07-25 + +## Stack map + +| Layer | Branch | Parent | State | +|---|---|---|---| +| 01/05 | `media/01-status-sourceapp` | `master` (`b1199b5`) | DRAFT PR #32 (`ce7e60b`) | +| 02/05 | `media/02-album-art` | layer 01 | NOT STARTED | +| 03/05 | `media/03-transport` | layer 02 | NOT STARTED | +| 04/05 | `media/04-macos-adapter` | layer 03 | SPIKE-GATED / NOT STARTED | +| 05/05 | `media/05-noro-gate` | layer 04 if proven, otherwise layer 03 | NOT STARTED | + +The Native SDK submodule remains at `3f6a68b606e110087b5992cbe75f700051f1b7f3`. +This run will not change the submodule pointer. + +## Completed gates + +- Required implementation brief, contracts, ADRs, roadmap, API orders, and + styling autonomy/visual-gate brief read in full. +- Virgin `master` verified at `b1199b5f6cc3a09536c448a43d1a0cc1d3e59c39`. +- `npm test`: PASS, 62/62 tests. +- `npm run typecheck`: PASS. +- `runtime`: `zig build -Dweb-layer=exclude -Dtrace=off`: PASS with Zig 0.16.0. +- `host`: `zig build`: PASS with Zig 0.16.0. +- Layer 01 `npm test`: PASS, 62/62 tests. +- Layer 01 `npm run typecheck`: PASS. +- Layer 01 `runtime`: `zig build test -Dweb-layer=exclude -Dtrace=off`: PASS. +- Layer 01 `host`: `zig build test`: PASS. +- Layer 01 example: `weaver check examples/now-playing`: PASS. +- Layer 01 live Spotify check: paused and playing states observed on the next + provider poll; live title, artist, source ID, and timeline rendered. +- Layer 01 visual gate: PASS. Viewed captures and per-element checklist are in + `docs/media-evidence/pr01-visual.md`. + +## Blockers and unverified gates + +- None for the Windows stack. +- PR 04 remains spike-gated. This Windows run cannot claim the required real + macOS metadata frame or delivered command without recorded external Mac/CI + execution evidence. + +## Current work + +Layer 01 is committed, pushed, and open as draft PR +`https://github.com/SunkenInTime/weaver/pull/32`. + +## Next executable task + +Create `media/02-album-art` from layer 01 and trace SMTC thumbnail ownership, +host cache lifecycle, runtime path containment, and dynamic image +re-registration before editing. diff --git a/examples/now-playing/widget.tsx b/examples/now-playing/widget.tsx index 8397d1ee..470bc418 100644 --- a/examples/now-playing/widget.tsx +++ b/examples/now-playing/widget.tsx @@ -27,11 +27,12 @@ export default widget({ /> NOW PLAYING - {media.playing ? "PLAYING" : "PAUSED"} + {media.status.toUpperCase()} {media.title || "Nothing playing"} {media.artist || media.album || (media.title ? "Unknown artist" : "Open a media app to begin")} + {media.sourceApp || "Unknown source"} {Array.from({ length: segmentCount }, (_, index) => ( diff --git a/host/src/media.zig b/host/src/media.zig index fa8d03c2..9de87e71 100644 --- a/host/src/media.zig +++ b/host/src/media.zig @@ -5,6 +5,32 @@ const native = @cImport({ }); pub const max_text_bytes: usize = 512; +pub const max_source_app_bytes: usize = 256; +pub const max_art_path_bytes: usize = 259; +pub const max_json_escape_bytes: usize = 6; + +// The bound reserves the PR 02 artPath key now so the transport framing does +// not change underneath a stacked runtime. Every source byte may expand to a +// six-byte JSON \u00XX escape; the fixed form uses the longest status/bool and +// two maximum-width u64 values. +const max_media_frame_fixed = + "{\"provider\":\"media\",\"value\":{\"title\":\"\",\"artist\":\"\",\"album\":\"\",\"playing\":false,\"status\":\"stopped\",\"sourceApp\":\"\",\"artPath\":\"\",\"positionMs\":18446744073709551615,\"durationMs\":18446744073709551615}}\n"; +pub const max_media_frame_bytes: usize = max_media_frame_fixed.len + + max_json_escape_bytes * (3 * max_text_bytes + max_source_app_bytes + max_art_path_bytes); + +pub const Status = enum { + playing, + paused, + stopped, + + fn wire(self: Status) []const u8 { + return switch (self) { + .playing => "playing", + .paused => "paused", + .stopped => "stopped", + }; + } +}; pub const Frame = struct { title: [max_text_bytes]u8 = @splat(0), @@ -13,13 +39,24 @@ pub const Frame = struct { artist_len: usize = 0, album: [max_text_bytes]u8 = @splat(0), album_len: usize = 0, - playing: bool = false, + status: Status = .stopped, + source_app: [max_source_app_bytes]u8 = @splat(0), + source_app_len: usize = 0, position_ms: u64 = 0, duration_ms: u64 = 0, - pub fn titleSlice(self: *const Frame) []const u8 { return self.title[0..self.title_len]; } - pub fn artistSlice(self: *const Frame) []const u8 { return self.artist[0..self.artist_len]; } - pub fn albumSlice(self: *const Frame) []const u8 { return self.album[0..self.album_len]; } + pub fn titleSlice(self: *const Frame) []const u8 { + return self.title[0..self.title_len]; + } + pub fn artistSlice(self: *const Frame) []const u8 { + return self.artist[0..self.artist_len]; + } + pub fn albumSlice(self: *const Frame) []const u8 { + return self.album[0..self.album_len]; + } + pub fn sourceAppSlice(self: *const Frame) []const u8 { + return self.source_app[0..self.source_app_len]; + } }; pub const Provider = struct { @@ -27,7 +64,9 @@ pub const Provider = struct { next_open_ms: u64 = 0, next_poll_ms: u64 = 0, - pub fn deinit(self: *Provider) void { self.close(); } + pub fn deinit(self: *Provider) void { + self.close(); + } pub fn setActive(self: *Provider, active: bool, now_ms: u64) void { if (!active) { @@ -56,13 +95,14 @@ pub const Provider = struct { } if (result == 0) return .{}; var frame: Frame = .{ - .playing = source.playing != 0, + .status = statusFromNative(source.status), .position_ms = @intCast(@max(0, source.position_ms)), .duration_ms = @intCast(@max(0, source.duration_ms)), }; copyText(&frame.title, &frame.title_len, std.mem.sliceTo(&source.title, 0)); copyText(&frame.artist, &frame.artist_len, std.mem.sliceTo(&source.artist, 0)); copyText(&frame.album, &frame.album_len, std.mem.sliceTo(&source.album, 0)); + copyText(&frame.source_app, &frame.source_app_len, std.mem.sliceTo(&source.source_app, 0)); return frame; } @@ -96,7 +136,11 @@ pub fn formatFrame(frame: *const Frame, output: []u8) ![]const u8 { try json.objectField("album"); try json.write(frame.albumSlice()); try json.objectField("playing"); - try json.write(frame.playing); + try json.write(frame.status == .playing); + try json.objectField("status"); + try json.write(frame.status.wire()); + try json.objectField("sourceApp"); + try json.write(frame.sourceAppSlice()); try json.objectField("positionMs"); try json.write(frame.position_ms); try json.objectField("durationMs"); @@ -107,18 +151,79 @@ pub fn formatFrame(frame: *const Frame, output: []u8) ![]const u8 { return writer.buffered(); } -fn copyText(destination: *[max_text_bytes]u8, length: *usize, source: []const u8) void { +fn copyText(destination: anytype, length: *usize, source: []const u8) void { length.* = @min(source.len, destination.len); @memcpy(destination[0..length.*], source[0..length.*]); } test "media provider frame escapes metadata and uses contract fields" { - var frame: Frame = .{ .playing = true, .position_ms = 1200, .duration_ms = 8000 }; + var frame: Frame = .{ .status = .playing, .position_ms = 1200, .duration_ms = 8000 }; copyText(&frame.title, &frame.title_len, "A \"quoted\" song"); copyText(&frame.artist, &frame.artist_len, "Artist"); - var output: [2048]u8 = undefined; + copyText(&frame.source_app, &frame.source_app_len, "Spotify"); + var output: [max_media_frame_bytes]u8 = undefined; try std.testing.expectEqualStrings( - "{\"provider\":\"media\",\"value\":{\"title\":\"A \\\"quoted\\\" song\",\"artist\":\"Artist\",\"album\":\"\",\"playing\":true,\"positionMs\":1200,\"durationMs\":8000}}\n", + "{\"provider\":\"media\",\"value\":{\"title\":\"A \\\"quoted\\\" song\",\"artist\":\"Artist\",\"album\":\"\",\"playing\":true,\"status\":\"playing\",\"sourceApp\":\"Spotify\",\"positionMs\":1200,\"durationMs\":8000}}\n", try formatFrame(&frame, &output), ); } + +test "media frame bound covers maximum escaped fields and future art path" { + try std.testing.expectEqual(@as(usize, 12_502), max_media_frame_bytes); + var frame: Frame = .{ + .status = .stopped, + .position_ms = std.math.maxInt(u64), + .duration_ms = std.math.maxInt(u64), + }; + @memset(&frame.title, 0x01); + frame.title_len = frame.title.len; + @memset(&frame.artist, 0x01); + frame.artist_len = frame.artist.len; + @memset(&frame.album, 0x01); + frame.album_len = frame.album.len; + @memset(&frame.source_app, 0x01); + frame.source_app_len = frame.source_app.len; + var output: [max_media_frame_bytes]u8 = undefined; + const encoded = try formatFrame(&frame, &output); + const fixed_without_art = + "{\"provider\":\"media\",\"value\":{\"title\":\"\",\"artist\":\"\",\"album\":\"\",\"playing\":false,\"status\":\"stopped\",\"sourceApp\":\"\",\"positionMs\":18446744073709551615,\"durationMs\":18446744073709551615}}\n"; + try std.testing.expectEqual( + fixed_without_art.len + max_json_escape_bytes * (3 * max_text_bytes + max_source_app_bytes), + encoded.len, + ); + try std.testing.expect(encoded.len <= max_media_frame_bytes); + try std.testing.expect(std.mem.indexOf(u8, encoded, "\\u0001") != null); +} + +test "source app mapping prefers packaged display names and honestly falls back" { + const fixtures = [_]struct { + raw: [:0]const u8, + resolved: [:0]const u8, + expected: []const u8, + }{ + .{ .raw = "SpotifyAB.SpotifyMusic_zpdnekdrzrea0!Spotify", .resolved = "Spotify", .expected = "Spotify" }, + .{ .raw = "Spotify.exe", .resolved = "", .expected = "Spotify.exe" }, + .{ .raw = "Missing.Package_123!App", .resolved = "", .expected = "Missing.Package_123!App" }, + .{ .raw = "", .resolved = "", .expected = "" }, + }; + for (fixtures) |fixture| { + var output: [max_source_app_bytes + 1]u8 = @splat(0); + native.weaver_media_select_source_app(fixture.raw.ptr, fixture.resolved.ptr, &output); + try std.testing.expectEqualStrings(fixture.expected, std.mem.sliceTo(&output, 0)); + } +} + +fn statusFromNative(value: c_int) Status { + return switch (value) { + native.WEAVER_MEDIA_STATUS_PLAYING => .playing, + native.WEAVER_MEDIA_STATUS_PAUSED => .paused, + else => .stopped, + }; +} + +test "native playback status maps to the frozen tri-state" { + try std.testing.expectEqual(Status.playing, statusFromNative(native.WEAVER_MEDIA_STATUS_PLAYING)); + try std.testing.expectEqual(Status.paused, statusFromNative(native.WEAVER_MEDIA_STATUS_PAUSED)); + try std.testing.expectEqual(Status.stopped, statusFromNative(native.WEAVER_MEDIA_STATUS_STOPPED)); + try std.testing.expectEqual(Status.stopped, statusFromNative(99)); +} diff --git a/host/src/windows_host.zig b/host/src/windows_host.zig index 287829a5..c4632909 100644 --- a/host/src/windows_host.zig +++ b/host/src/windows_host.zig @@ -108,7 +108,7 @@ const Host = struct { previous_memory: [512]u8 = undefined, previous_memory_len: usize = 0, system_frames: u64 = 0, - previous_media: [2048]u8 = undefined, + previous_media: [media.max_media_frame_bytes]u8 = undefined, previous_media_len: usize = 0, audio_pipe_frames: u64 = 0, media_pipe_frames: u64 = 0, @@ -430,8 +430,11 @@ const Host = struct { self.media_provider.setActive(active, now_ms); if (!active) return; const frame = self.media_provider.poll(now_ms) orelse return; - var buffer: [2048]u8 = undefined; - const encoded = media.formatFrame(&frame, &buffer) catch return; + var buffer: [media.max_media_frame_bytes]u8 = undefined; + const encoded = media.formatFrame(&frame, &buffer) catch |err| { + std.log.err("media frame exceeded protocol bound or failed to encode: {s}", .{@errorName(err)}); + return; + }; const changed = !std.mem.eql(u8, encoded, self.previous_media[0..self.previous_media_len]); var delivered = false; for (&self.slots) |*slot| { diff --git a/host/src/windows_providers.cpp b/host/src/windows_providers.cpp index 14b3930b..5c9901ea 100644 --- a/host/src/windows_providers.cpp +++ b/host/src/windows_providers.cpp @@ -7,6 +7,7 @@ #include #include #include +#include #include #include #include @@ -161,13 +162,42 @@ struct WeaverMediaSession { } }; -static void copy_text(char (&destination)[512], const winrt::hstring &source) { - const std::string utf8 = winrt::to_string(source); - const size_t length = std::min(utf8.size(), sizeof(destination) - 1); - std::memcpy(destination, utf8.data(), length); +template +static void copy_text(char (&destination)[Capacity], const std::string &source) { + const size_t length = std::min(source.size(), sizeof(destination) - 1); + std::memcpy(destination, source.data(), length); destination[length] = '\0'; } +template +static void copy_text(char (&destination)[Capacity], const winrt::hstring &source) { + copy_text(destination, winrt::to_string(source)); +} + +static std::string select_source_app(const std::string &raw_id, const std::string &resolved_name) { + return resolved_name.empty() ? raw_id : resolved_name; +} + +static std::string source_app_name(const winrt::hstring &source_id) { + const std::string raw_id = winrt::to_string(source_id); + if (raw_id.empty()) return {}; + try { + const auto app_info = winrt::Windows::ApplicationModel::AppInfo::GetFromAppUserModelId(source_id); + if (!app_info || !app_info.Package()) return raw_id; + return select_source_app(raw_id, winrt::to_string(app_info.DisplayInfo().DisplayName())); + } catch (...) { + return raw_id; + } +} + +extern "C" void weaver_media_select_source_app(const char *raw_id, const char *resolved_name, char output[257]) { + if (!output) return; + const std::string selected = select_source_app(raw_id ? raw_id : "", resolved_name ? resolved_name : ""); + const size_t length = std::min(selected.size(), 256); + std::memcpy(output, selected.data(), length); + output[length] = '\0'; +} + extern "C" WeaverMediaSession *weaver_media_create(void) { try { auto state = std::make_unique(); @@ -192,8 +222,20 @@ extern "C" int weaver_media_poll(WeaverMediaSession *state, WeaverMediaState *ou copy_text(output->title, properties.Title()); copy_text(output->artist, properties.Artist()); copy_text(output->album, properties.AlbumTitle()); + copy_text(output->source_app, source_app_name(session.SourceAppUserModelId())); const auto playback = session.GetPlaybackInfo(); - output->playing = playback.PlaybackStatus() == winrt::Windows::Media::Control::GlobalSystemMediaTransportControlsSessionPlaybackStatus::Playing; + switch (playback.PlaybackStatus()) { + case winrt::Windows::Media::Control::GlobalSystemMediaTransportControlsSessionPlaybackStatus::Playing: + output->status = WEAVER_MEDIA_STATUS_PLAYING; + break; + case winrt::Windows::Media::Control::GlobalSystemMediaTransportControlsSessionPlaybackStatus::Paused: + output->status = WEAVER_MEDIA_STATUS_PAUSED; + break; + default: + output->status = WEAVER_MEDIA_STATUS_STOPPED; + break; + } + output->playing = output->status == WEAVER_MEDIA_STATUS_PLAYING; const auto timeline = session.GetTimelineProperties(); output->position_ms = std::chrono::duration_cast(timeline.Position()).count(); output->duration_ms = std::chrono::duration_cast(timeline.EndTime() - timeline.StartTime()).count(); diff --git a/host/src/windows_providers.h b/host/src/windows_providers.h index 67b1c884..c716e9a9 100644 --- a/host/src/windows_providers.h +++ b/host/src/windows_providers.h @@ -19,16 +19,23 @@ typedef struct WeaverMediaState { char title[512]; char artist[512]; char album[512]; + char source_app[257]; int playing; + int status; int64_t position_ms; int64_t duration_ms; } WeaverMediaState; +#define WEAVER_MEDIA_STATUS_STOPPED 0 +#define WEAVER_MEDIA_STATUS_PLAYING 1 +#define WEAVER_MEDIA_STATUS_PAUSED 2 + typedef struct WeaverMediaSession WeaverMediaSession; WeaverMediaSession *weaver_media_create(void); void weaver_media_destroy(WeaverMediaSession *session); int weaver_media_poll(WeaverMediaSession *session, WeaverMediaState *state); +void weaver_media_select_source_app(const char *raw_id, const char *resolved_name, char output[257]); #ifdef __cplusplus } diff --git a/runtime/src/js_engine.zig b/runtime/src/js_engine.zig index 994a6550..1ba408a2 100644 --- a/runtime/src/js_engine.zig +++ b/runtime/src/js_engine.zig @@ -158,7 +158,7 @@ pub const Engine = struct { pub fn drainProviders(self: *Engine) Error!usize { self.beginTurn(); defer self.endTurn(); - var line_buffer: [8192]u8 = undefined; + var line_buffer: [provider_mod.max_line_bytes]u8 = undefined; var count: usize = 0; while (self.provider.take(&line_buffer)) |line| { if (!bridge.dispatchProvider(self.context, &self.bridge_state, line)) return self.reportException(); diff --git a/runtime/src/media_protocol.zig b/runtime/src/media_protocol.zig new file mode 100644 index 00000000..c1042692 --- /dev/null +++ b/runtime/src/media_protocol.zig @@ -0,0 +1,19 @@ +const std = @import("std"); + +pub const max_text_bytes: usize = 512; +pub const max_source_app_bytes: usize = 256; +pub const max_art_path_bytes: usize = 259; +pub const max_json_escape_bytes: usize = 6; + +const max_media_frame_fixed = + "{\"provider\":\"media\",\"value\":{\"title\":\"\",\"artist\":\"\",\"album\":\"\",\"playing\":false,\"status\":\"stopped\",\"sourceApp\":\"\",\"artPath\":\"\",\"positionMs\":18446744073709551615,\"durationMs\":18446744073709551615}}\n"; + +/// Must match host/src/media.zig. It is the complete newline-terminated v2 +/// media-frame bound, including the optional PR 02 artPath field. +pub const max_media_frame_bytes: usize = max_media_frame_fixed.len + + max_json_escape_bytes * (3 * max_text_bytes + max_source_app_bytes + max_art_path_bytes); + +test "runtime media line bound matches the frozen worst-case formula" { + try std.testing.expectEqual(@as(usize, 12_502), max_media_frame_bytes); + try std.testing.expect(max_media_frame_bytes > 8192); +} diff --git a/runtime/src/provider.zig b/runtime/src/provider.zig index 2a16fa50..a3f5226d 100644 --- a/runtime/src/provider.zig +++ b/runtime/src/provider.zig @@ -1,4 +1,5 @@ const builtin = @import("builtin"); +const media_protocol = @import("media_protocol.zig"); const implementation = switch (builtin.os.tag) { .windows => @import("provider_windows.zig"), @@ -7,6 +8,7 @@ const implementation = switch (builtin.os.tag) { }; pub const Client = implementation.Client; +pub const max_line_bytes = media_protocol.max_media_frame_bytes; test "provider client is inert only without an endpoint" { const std = @import("std"); diff --git a/runtime/src/provider_macos.zig b/runtime/src/provider_macos.zig index 13ca9035..5a11d2c6 100644 --- a/runtime/src/provider_macos.zig +++ b/runtime/src/provider_macos.zig @@ -1,6 +1,7 @@ const std = @import("std"); +const media_protocol = @import("media_protocol.zig"); -const line_capacity: usize = 8192; +const line_capacity: usize = media_protocol.max_media_frame_bytes; const queue_capacity: usize = 4; const reader_stack_bytes: usize = 256 * 1024; diff --git a/runtime/src/provider_windows.zig b/runtime/src/provider_windows.zig index 43d1a83f..b30bbb02 100644 --- a/runtime/src/provider_windows.zig +++ b/runtime/src/provider_windows.zig @@ -3,8 +3,9 @@ const win = @cImport({ @cDefine("WIN32_LEAN_AND_MEAN", "1"); @cInclude("windows.h"); }); +const media_protocol = @import("media_protocol.zig"); -const line_capacity: usize = 8192; +const line_capacity: usize = media_protocol.max_media_frame_bytes; const queue_capacity: usize = 4; const reader_stack_bytes: usize = 256 * 1024; diff --git a/sdk/CONTRACT.md b/sdk/CONTRACT.md index 93592d27..af8b0bec 100644 --- a/sdk/CONTRACT.md +++ b/sdk/CONTRACT.md @@ -538,3 +538,51 @@ The contract-table unit test freezes both row sets and compiles a representative for every syntax branch. Gradients, transitions, positioned layout, arbitrary state prefixes, and every utility absent from these tables remain loud `UtilityError` failures with a fix-it. + +--- + +# Media v2 amendment (v0.5) + +This amendment supersedes the M3 media frame's boolean-only playback state. +Everything else above stands. + +## PR 01: playback status and source application + +```ts +interface MediaData { + title: string; + artist: string; + album: string; + status: "playing" | "paused" | "stopped"; + playing: boolean; + sourceApp: string; + positionMs: number; + durationMs: number; +} +``` + +`playing` remains on the wire and in the SDK for source and installed-bundle +compatibility. It is always exactly `status === "playing"`. The host maps the +platform's playing state to `"playing"`, paused state to `"paused"`, and every +other playback state to `"stopped"`. + +`sourceApp` is display-only. The host uses the installed package's display +name when the source identifier resolves to a package without prompting; +otherwise the raw identifier is returned verbatim. It is `""` only when the +platform supplies no source identifier. Widget logic must not branch on this +display string. + +No observable media session has one canonical frame: empty title, artist, +album, and source; zero position and duration; `status: "stopped"`; +`playing: false`. + +The newline-terminated provider frame has a protocol maximum of 12,502 bytes. +That bound covers worst-case six-byte JSON escaping for each byte of three +512-byte metadata fields, the 256-byte source field, and the reserved 259-byte +host art-cache path, plus the longest fixed fields and two 20-digit unsigned +timeline values. Host formatting, host dedupe storage, and runtime line +accumulators use this same bound. Encoding failure is logged and never silently +dropped. + +Album art and media transport are deliberately absent at this layer; they land +in subsequent v0.5 stack amendments. diff --git a/sdk/index.d.ts b/sdk/index.d.ts index 9cffca21..53993116 100644 --- a/sdk/index.d.ts +++ b/sdk/index.d.ts @@ -34,7 +34,16 @@ export interface TimeData { export interface CpuData { percent: number; perCore: number[] } export interface MemoryData { usedMb: number; totalMb: number; percent: number } export interface AudioData { rms: number; bands: number[] } -export interface MediaData { title: string; artist: string; album: string; playing: boolean; positionMs: number; durationMs: number } +export interface MediaData { + title: string; + artist: string; + album: string; + status: "playing" | "paused" | "stopped"; + playing: boolean; + sourceApp: string; + positionMs: number; + durationMs: number; +} export interface WFetchInit { method?: "GET" | "POST"; diff --git a/sdk/src/reconciler.ts b/sdk/src/reconciler.ts index 65748119..44d5234e 100644 --- a/sdk/src/reconciler.ts +++ b/sdk/src/reconciler.ts @@ -38,7 +38,16 @@ export interface TimeData { export interface CpuData { percent: number; perCore: number[] } export interface MemoryData { usedMb: number; totalMb: number; percent: number } export interface AudioData { rms: number; bands: number[] } -export interface MediaData { title: string; artist: string; album: string; playing: boolean; positionMs: number; durationMs: number } +export interface MediaData { + title: string; + artist: string; + album: string; + status: "playing" | "paused" | "stopped"; + playing: boolean; + sourceApp: string; + positionMs: number; + durationMs: number; +} export interface WFetchInit { method?: "GET" | "POST"; headers?: Record; body?: string } export interface WFetchResponse { status: number; ok: boolean; text(): Promise; json(): Promise } export interface CanvasFrame { t: number; dt: number } @@ -295,7 +304,16 @@ export function useProvider(name: ProviderName): TimeData | CpuData | MemoryData useEffect(() => hostProviders.subscribeAudio(setValue), []); return value; } - const [value, setValue] = useState(() => ({ title: "", artist: "", album: "", playing: false, positionMs: 0, durationMs: 0 })); + const [value, setValue] = useState(() => ({ + title: "", + artist: "", + album: "", + status: "stopped", + playing: false, + sourceApp: "", + positionMs: 0, + durationMs: 0, + })); useEffect(() => hostProviders.subscribeMedia(setValue), []); return value; } diff --git a/sdk/test/reconciler.test.mjs b/sdk/test/reconciler.test.mjs index f0f8f6f3..fb723e6b 100644 --- a/sdk/test/reconciler.test.mjs +++ b/sdk/test/reconciler.test.mjs @@ -78,6 +78,7 @@ test("widget renders one native generation and providers use native timers", asy sdk.h("text", null, cpu.percent.toFixed(1)), sdk.h("text", null, audio.bands[0].toFixed(2)), sdk.h("text", null, media.title), + sdk.h("text", null, `${media.status}:${media.sourceApp}`), sdk.h("icon", { iconPath: "M 5 5 L 19 12 L 5 19 Z", iconViewBox: "0 0 24 24", @@ -204,11 +205,12 @@ test("widget renders one native generation and providers use native timers", asy assert.equal(typeof providerCallback, "function"); providerCallback('{"provider":"cpu","value":{"percent":37.5,"perCore":[30,45]}}'); providerCallback('{"provider":"audio","value":{"rms":0.25,"bands":[0.75]}}'); - providerCallback('{"provider":"media","value":{"title":"Test Song","artist":"Artist","album":"Album","playing":true,"positionMs":10,"durationMs":20}}'); + providerCallback('{"provider":"media","value":{"title":"Test Song","artist":"Artist","album":"Album","playing":true,"status":"playing","sourceApp":"Test Player","positionMs":10,"durationMs":20}}'); await Promise.resolve(); assert.ok(operations.some((operation) => operation[0] === "setText" && operation[2] === "37.5")); assert.ok(operations.some((operation) => operation[0] === "setText" && operation[2] === "0.75")); assert.ok(operations.some((operation) => operation[0] === "setText" && operation[2] === "Test Song")); + assert.ok(operations.some((operation) => operation[0] === "setText" && operation[2] === "playing:Test Player")); const canvasNodes = operations.filter((operation) => operation[0] === "createNode" && operation[1] === "canvas"); const pausedCanvasNode = canvasNodes[0][2]; const canvasNode = canvasNodes[1][2];