Skip to content

Commit 9c46b43

Browse files
committed
Add targeted client content replacement
1 parent e7454dd commit 9c46b43

3 files changed

Lines changed: 125 additions & 16 deletions

File tree

README.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ The current phase provides:
1313
built-in JavaScript bridge;
1414
- runtime content and resource-handler replacement through
1515
`Window.setContent()`;
16+
- targeted runtime content replacement through `Client.show()`;
1617
- explicit browser connection waiting and timeout through
1718
`Window.waitForConnection()`;
1819
- JavaScript calls to Zig bindings with return values;
@@ -116,6 +117,11 @@ navigates every connected client to it and returns the number notified. An
116117
invalid replacement leaves the current content unchanged. If client
117118
notification fails, the prepared replacement remains installed.
118119

120+
`Client.show(&running, content)` installs the same window-wide content but
121+
navigates only the selected client, matching upstream `webui_show_client()`.
122+
Other connected pages are not reloaded; later resource requests use the new
123+
window content.
124+
119125
`Window.onEvent` installs one handler for browser lifecycle, click, and
120126
navigation events. `Event.data` contains the element ID for clicks, the target
121127
URL for navigation, and is empty for connected or disconnected events.

docs/PURE_ZIG_REFACTOR.md

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -295,7 +295,6 @@ implementations.
295295

296296
| Upstream API | Current gap |
297297
|---|---|
298-
| `webui_show_client()` | `Client` cannot replace the content of only one connected browser. |
299298
| `webui_is_shown()` | There is no window-level connected/shown query. |
300299
| `webui_set_config(folder_monitor)` | Directory change monitoring and automatic browser reload are not implemented. |
301300
| `webui_set_default_root_folder()` | There is no application-wide default directory content setting. |
@@ -331,6 +330,7 @@ not implementation gaps:
331330
|---|---|
332331
| `webui_new_window()`, `webui_new_window_id()`, `webui_get_new_window_id()` | `App.createWindow()` and application-owned IDs. |
333332
| `webui_show()`, `webui_start_server()`, `webui_get_url()` | Initial `Content`, runtime `Window.setContent()`, `App.start()`, `Window.open()`, and `Window.url()`. |
333+
| `webui_show_client()` | `Client.show()` replaces the window content and navigates only the selected client. |
334334
| `webui_wait()`, `webui_wait_async()` | `Running.wait()` used directly or through `std.Io` concurrency. |
335335
| `webui_close()`, `webui_destroy()`, `webui_exit()`, `webui_clean()` | `Window.close()`, `Running.stop()`, and `App.deinit()`. |
336336
| `webui_set_context()`, `webui_get_context()` | Binding and event-handler `user_data`. |
@@ -389,7 +389,6 @@ connection waiting, and caller-provided logging.
389389

390390
### Dynamic content and client state
391391

392-
- Add targeted `Client.show()` behavior.
393392
- Add a window connected/shown query.
394393
- Add an application default directory.
395394
- Add inline and file-backed window icons.
@@ -485,6 +484,5 @@ zig build -Dtarget=aarch64-macos
485484

486485
Continue capability parity:
487486

488-
1. Add targeted `Client.show()`.
489-
2. Add a window connected/shown query.
490-
3. Add an application default directory and window icons.
487+
1. Add a window connected/shown query.
488+
2. Add an application default directory and window icons.

src/app.zig

Lines changed: 116 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1092,23 +1092,22 @@ pub const Client = struct {
10921092
state: *WindowState,
10931093
client_id: u64,
10941094

1095-
fn sendPacket(
1095+
fn retainPeer(self: Client, io: std.Io) !Linsang.WebSocketPeer {
1096+
self.state.mutex.lockUncancelable(io);
1097+
defer self.state.mutex.unlock(io);
1098+
const index = self.state.clientIndexById(self.client_id) orelse
1099+
return error.ConnectionClosed;
1100+
return self.state.clients.items[index].peer.clone();
1101+
}
1102+
1103+
fn sendPacketToPeer(
10961104
self: Client,
1097-
io: std.Io,
1105+
peer: Linsang.WebSocketPeer,
10981106
header: protocol.Header,
10991107
payload: []const u8,
11001108
) !void {
11011109
if (payload.len > self.state.limits.max_ws_message_size - protocol.header_len)
11021110
return error.MessageTooLarge;
1103-
self.state.mutex.lockUncancelable(io);
1104-
const index = self.state.clientIndexById(self.client_id) orelse {
1105-
self.state.mutex.unlock(io);
1106-
return error.ConnectionClosed;
1107-
};
1108-
var peer = self.state.clients.items[index].peer.clone();
1109-
self.state.mutex.unlock(io);
1110-
defer peer.deinit();
1111-
11121111
var packet: std.ArrayList(u8) = .empty;
11131112
defer packet.deinit(self.state.gpa);
11141113
try protocol.append(&packet, self.state.gpa, header, payload);
@@ -1118,6 +1117,17 @@ pub const Client = struct {
11181117
};
11191118
}
11201119

1120+
fn sendPacket(
1121+
self: Client,
1122+
io: std.Io,
1123+
header: protocol.Header,
1124+
payload: []const u8,
1125+
) !void {
1126+
var peer = try self.retainPeer(io);
1127+
defer peer.deinit();
1128+
try self.sendPacketToPeer(peer, header, payload);
1129+
}
1130+
11211131
fn send(
11221132
self: Client,
11231133
io: std.Io,
@@ -1140,6 +1150,31 @@ pub const Client = struct {
11401150
return self.state.clientIndexById(self.client_id) != null;
11411151
}
11421152

1153+
/// Replace the window content and navigate only this client to it.
1154+
/// If navigation fails, the replacement remains installed.
1155+
pub fn show(
1156+
self: Client,
1157+
running: *const Running,
1158+
content: Content,
1159+
) !void {
1160+
if (running.stopped or !running.app.started)
1161+
return error.NotRunning;
1162+
if (!running.app.hasWindow(self.state)) return error.UnknownWindow;
1163+
var peer = try self.retainPeer(running.inner.io);
1164+
defer peer.deinit();
1165+
1166+
try self.state.replaceContent(running.inner.io, content);
1167+
const target_url = try (Window{ .state = self.state }).url(
1168+
running,
1169+
self.state.gpa,
1170+
);
1171+
defer self.state.gpa.free(target_url);
1172+
try self.sendPacketToPeer(peer, .{
1173+
.token = self.state.token,
1174+
.command = .navigation,
1175+
}, target_url);
1176+
}
1177+
11431178
pub fn eval(
11441179
self: Client,
11451180
io: std.Io,
@@ -3734,6 +3769,59 @@ test "multi-client limits, targeting, and disconnect lifecycle" {
37343769
try std.testing.expectEqual(protocol.Command.js_quick, second_quick.header.command);
37353770
try std.testing.expectEqualStrings(first_quick.payload, second_quick.payload);
37363771

3772+
try std.testing.expectError(
3773+
error.InvalidExternalUrl,
3774+
first.show(&running, .{ .external_url = "file:///invalid" }),
3775+
);
3776+
try first.run(io, "globalThis.failedShowDidNotNavigate = true");
3777+
const failed_show_marker = try protocol.decode(try readServerFrame(
3778+
first_stream,
3779+
io,
3780+
&first_response,
3781+
));
3782+
try std.testing.expectEqual(
3783+
protocol.Command.js_quick,
3784+
failed_show_marker.header.command,
3785+
);
3786+
3787+
try first.show(&running, .{ .html = "targeted client page" });
3788+
const targeted_show = try protocol.decode(try readServerFrame(
3789+
first_stream,
3790+
io,
3791+
&first_response,
3792+
));
3793+
const targeted_url = try window.url(&running, gpa);
3794+
defer gpa.free(targeted_url);
3795+
try std.testing.expectEqual(
3796+
protocol.Command.navigation,
3797+
targeted_show.header.command,
3798+
);
3799+
try std.testing.expectEqualStrings(targeted_url, targeted_show.payload);
3800+
3801+
try second.run(io, "globalThis.otherClientWasNotNavigated = true");
3802+
const second_after_show = try protocol.decode(try readServerFrame(
3803+
second_stream,
3804+
io,
3805+
&second_response,
3806+
));
3807+
try std.testing.expectEqual(
3808+
protocol.Command.js_quick,
3809+
second_after_show.header.command,
3810+
);
3811+
{
3812+
var target: [capability_len + 2]u8 = undefined;
3813+
var response: [512]u8 = undefined;
3814+
_ = try getTestPath(
3815+
running.inner.address,
3816+
io,
3817+
try std.fmt.bufPrint(&target, "/{s}/", .{
3818+
window.state.capability,
3819+
}),
3820+
"targeted client page",
3821+
&response,
3822+
);
3823+
}
3824+
37373825
try first.navigate(io, "/first");
37383826
const raw_data = [_]u8{ 2, 3, 5 };
37393827
try second.sendRaw(io, "receiveRaw", &raw_data);
@@ -3988,6 +4076,23 @@ test "multi-client limits, targeting, and disconnect lifecycle" {
39884076
try std.testing.expect(second.isConnected(io));
39894077
try std.testing.expect(!app.closed.load(.acquire));
39904078
try std.testing.expectError(error.ConnectionClosed, first_eval.await(io));
4079+
try std.testing.expectError(
4080+
error.ConnectionClosed,
4081+
first.show(&running, .{ .html = "stale client page" }),
4082+
);
4083+
{
4084+
var target: [capability_len + 2]u8 = undefined;
4085+
var response: [512]u8 = undefined;
4086+
_ = try getTestPath(
4087+
running.inner.address,
4088+
io,
4089+
try std.fmt.bufPrint(&target, "/{s}/", .{
4090+
window.state.capability,
4091+
}),
4092+
"targeted client page",
4093+
&response,
4094+
);
4095+
}
39914096

39924097
packet.clearRetainingCapacity();
39934098
try protocol.append(&packet, gpa, .{

0 commit comments

Comments
 (0)