From ce7e60bf57c7a8f965e7bf316b420199c7f29b29 Mon Sep 17 00:00:00 2001 From: Dara Adedeji Date: Sat, 25 Jul 2026 08:46:39 -0700 Subject: [PATCH 1/6] media: add status and source app frame fields --- .../pr01-now-playing-playing.png | Bin 0 -> 9710 bytes docs/media-evidence/pr01-now-playing.png | Bin 0 -> 9713 bytes docs/media-evidence/pr01-visual.md | 39 ++++++ docs/media-run-status.md | 52 +++++++ examples/now-playing/widget.tsx | 3 +- host/src/media.zig | 127 ++++++++++++++++-- host/src/windows_host.zig | 9 +- host/src/windows_providers.cpp | 52 ++++++- host/src/windows_providers.h | 7 + runtime/src/media_protocol.zig | 19 +++ runtime/src/provider_macos.zig | 3 +- runtime/src/provider_windows.zig | 3 +- sdk/CONTRACT.md | 47 +++++++ sdk/index.d.ts | 11 +- sdk/src/reconciler.ts | 22 ++- sdk/test/reconciler.test.mjs | 4 +- 16 files changed, 372 insertions(+), 26 deletions(-) create mode 100644 docs/media-evidence/pr01-now-playing-playing.png create mode 100644 docs/media-evidence/pr01-now-playing.png create mode 100644 docs/media-evidence/pr01-visual.md create mode 100644 docs/media-run-status.md create mode 100644 runtime/src/media_protocol.zig 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 0000000000000000000000000000000000000000..591c183301d05d4ede49f437bdde7e968163dc6c GIT binary patch literal 9710 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 G{Qm*1KDOHc literal 0 HcmV?d00001 diff --git a/docs/media-evidence/pr01-now-playing.png b/docs/media-evidence/pr01-now-playing.png new file mode 100644 index 0000000000000000000000000000000000000000..d338a46b82f37f5969a5f10de2acdc6cfc6ffb14 GIT binary patch literal 9713 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 z 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/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_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..d322fa69 100644 --- a/sdk/CONTRACT.md +++ b/sdk/CONTRACT.md @@ -538,3 +538,50 @@ 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"`. Windows maps SMTC +`Playing` to `"playing"`, `Paused` to `"paused"`, and every other playback +state to `"stopped"`. + +`sourceApp` is display-only. Windows uses the installed package's display name +when SMTC's source AUMID resolves to a package without prompting, otherwise the +raw AUMID is returned verbatim. It is `""` only when SMTC 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]; From 2b75ab9381e8566d1246c8b3fd7dfe6aa6c9f92c Mon Sep 17 00:00:00 2001 From: Dara Adedeji Date: Sat, 25 Jul 2026 08:47:18 -0700 Subject: [PATCH 2/6] docs: record media layer 01 draft PR --- docs/media-run-status.md | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/docs/media-run-status.md b/docs/media-run-status.md index ed77f93f..e6a047cf 100644 --- a/docs/media-run-status.md +++ b/docs/media-run-status.md @@ -6,7 +6,7 @@ Last updated: 2026-07-25 | Layer | Branch | Parent | State | |---|---|---|---| -| 01/05 | `media/01-status-sourceapp` | `master` (`b1199b5`) | VERIFYING / NOT YET PUSHED | +| 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 | @@ -43,10 +43,11 @@ This run will not change the submodule pointer. ## Current work -Layer 01 implementation and local verification are complete. Commit, push, and -draft PR creation remain. +Layer 01 is committed, pushed, and open as draft PR +`https://github.com/SunkenInTime/weaver/pull/32`. ## Next executable task -Review the layer 01 diff, commit only intentional files, push the branch, and -open its draft stacked PR targeting `master`. +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. From 2feb70032805c7fa3b6f4f203411e9d54175baf1 Mon Sep 17 00:00:00 2001 From: Dara Adedeji Date: Sat, 25 Jul 2026 08:48:26 -0700 Subject: [PATCH 3/6] runtime: drain full bounded media frames --- runtime/src/js_engine.zig | 2 +- runtime/src/provider.zig | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) 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/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"); From fc863772bd742d33eed7beab48ab7baeaf7b3baf Mon Sep 17 00:00:00 2001 From: Dara Adedeji Date: Sat, 25 Jul 2026 12:33:46 -0700 Subject: [PATCH 4/6] docs: keep media contract platform-neutral --- sdk/CONTRACT.md | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/sdk/CONTRACT.md b/sdk/CONTRACT.md index d322fa69..af8b0bec 100644 --- a/sdk/CONTRACT.md +++ b/sdk/CONTRACT.md @@ -562,14 +562,15 @@ interface MediaData { ``` `playing` remains on the wire and in the SDK for source and installed-bundle -compatibility. It is always exactly `status === "playing"`. Windows maps SMTC -`Playing` to `"playing"`, `Paused` to `"paused"`, and every other playback -state to `"stopped"`. - -`sourceApp` is display-only. Windows uses the installed package's display name -when SMTC's source AUMID resolves to a package without prompting, otherwise the -raw AUMID is returned verbatim. It is `""` only when SMTC supplies no source -identifier. Widget logic must not branch on this display string. +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"`; From 1b848847e185bab33c9a8893486aae94ef7f876f Mon Sep 17 00:00:00 2001 From: Dara Adedeji Date: Sat, 25 Jul 2026 12:50:25 -0700 Subject: [PATCH 5/6] test: tolerate delayed log follower visibility --- cli/test/install-smoke.mjs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) 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; From 4dcf19c05fd34bafc2d2e11e0483070e37517ec4 Mon Sep 17 00:00:00 2001 From: Dara Adedeji Date: Sat, 25 Jul 2026 13:25:30 -0700 Subject: [PATCH 6/6] fix: arm log follower before initial output --- cli/src/index.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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);