Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions cli/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -503,9 +503,9 @@ function showLogs(name: string, follow: boolean): Promise<void> | 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<void>((resolvePromise) => {
const stop = (): void => { follower.stop(); resolvePromise(); };
process.once("SIGINT", stop);
Expand Down
4 changes: 3 additions & 1 deletion cli/test/install-smoke.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Binary file added docs/media-evidence/pr01-now-playing-playing.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/media-evidence/pr01-now-playing.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
39 changes: 39 additions & 0 deletions docs/media-evidence/pr01-visual.md
Original file line number Diff line number Diff line change
@@ -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.
53 changes: 53 additions & 0 deletions docs/media-run-status.md
Original file line number Diff line number Diff line change
@@ -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.
3 changes: 2 additions & 1 deletion examples/now-playing/widget.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,11 +27,12 @@ export default widget({
/>
<text class="text-xs text-[#8b5cf6] font-semibold">NOW PLAYING</text>
</row>
<text class="text-xs text-[#94a3b8]">{media.playing ? "PLAYING" : "PAUSED"}</text>
<text class="text-xs text-[#94a3b8]">{media.status.toUpperCase()}</text>
</row>
<column class="w-[328px] gap-1">
<text class="text-xl text-[#f8fafc] font-medium truncate">{media.title || "Nothing playing"}</text>
<text class="text-sm text-[#94a3b8] truncate">{media.artist || media.album || (media.title ? "Unknown artist" : "Open a media app to begin")}</text>
<text class="text-xs text-[#64748b] truncate">{media.sourceApp || "Unknown source"}</text>
</column>
<row class="w-[328px] h-[4px] gap-1">
{Array.from({ length: segmentCount }, (_, index) => (
Expand Down
127 changes: 116 additions & 11 deletions host/src/media.zig
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand All @@ -13,21 +39,34 @@ 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 {
session: ?*native.WeaverMediaSession = null,
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) {
Expand Down Expand Up @@ -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;
}

Expand Down Expand Up @@ -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");
Expand All @@ -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));
}
9 changes: 6 additions & 3 deletions host/src/windows_host.zig
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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| {
Expand Down
Loading
Loading