402a1681
feat: expose correlated selection replies to web
a73x 2026-08-18 12:27
Commit message
src/client.zig
| Old | New | ||
|---|---|---|---|
| @@ -1335,6 +1335,7 @@ fn session( | |||
| 1335 | effect, | 1335 | effect, |
| 1336 | appendHostEffect, | 1336 | appendHostEffect, |
| 1337 | ), | 1337 | ), |
| 1338 | .reply => {}, | ||
| 1338 | } | 1339 | } |
| 1339 | }, | 1340 | }, |
| 1340 | .term_title => { | 1341 | .term_title => { |
src/client_core.zig
| Old | New | ||
|---|---|---|---|
| @@ -18,31 +18,52 @@ pub const Effect = union(enum) { | |||
| 18 | bell, | 18 | bell, |
| 19 | }; | 19 | }; |
| 20 | 20 | ||
| 21 | pub const Reply = union(enum) { | ||
| 22 | selection: proto.SelectionReply, | ||
| 23 | }; | ||
| 24 | |||
| 21 | pub const Result = union(enum) { | 25 | pub const Result = union(enum) { |
| 22 | ignored, | 26 | ignored, |
| 23 | state: State, | 27 | state: State, |
| 24 | effect: Effect, | 28 | effect: Effect, |
| 29 | reply: Reply, | ||
| 25 | }; | 30 | }; |
| 26 | 31 | ||
| 27 | pub const ClientCore = struct { | 32 | pub const ClientCore = struct { |
| 28 | terminal_modes: proto.TermModes = .{ .bracketed_paste = false }, | 33 | terminal_modes: proto.TermModes = .{ .bracketed_paste = false }, |
| 34 | pending_selection_id: ?u32 = null, | ||
| 29 | 35 | ||
| 30 | /// Decode one daemon frame's semantic terminal state or event. Any | 36 | /// Decode one daemon frame's semantic terminal state or event. Any |
| 31 | /// borrowed clipboard bytes in the result remain valid only while | 37 | /// borrowed clipboard bytes or selection reply text in the result remain |
| 32 | /// `payload` remains valid and unchanged. | 38 | /// valid only while `payload` remains valid and unchanged. |
| 33 | pub fn receive(self: *ClientCore, msg_type: proto.MsgType, payload: []const u8) Result { | 39 | pub fn receive(self: *ClientCore, msg_type: proto.MsgType, payload: []const u8) Result { |
| 34 | return switch (msg_type) { | 40 | return switch (msg_type) { |
| 35 | .term_modes => self.receiveModes(payload), | 41 | .term_modes => self.receiveModes(payload), |
| 36 | .term_event => receiveEvent(payload), | 42 | .term_event => receiveEvent(payload), |
| 43 | .selection_reply => self.receiveSelectionReply(payload), | ||
| 37 | else => .ignored, | 44 | else => .ignored, |
| 38 | }; | 45 | }; |
| 39 | } | 46 | } |
| 40 | 47 | ||
| 48 | /// Start (or replace) the single correlated selection operation. | ||
| 49 | pub fn beginSelection(self: *ClientCore, req: proto.SelectionReq) [proto.selection_req_len]u8 { | ||
| 50 | self.pending_selection_id = req.id; | ||
| 51 | return proto.encodeSelectionReq(req); | ||
| 52 | } | ||
| 53 | |||
| 41 | fn receiveModes(self: *ClientCore, payload: []const u8) Result { | 54 | fn receiveModes(self: *ClientCore, payload: []const u8) Result { |
| 42 | const modes = proto.decodeTermModes(payload) catch return .ignored; | 55 | const modes = proto.decodeTermModes(payload) catch return .ignored; |
| 43 | self.terminal_modes = modes; | 56 | self.terminal_modes = modes; |
| 44 | return .{ .state = .{ .terminal_modes = modes } }; | 57 | return .{ .state = .{ .terminal_modes = modes } }; |
| 45 | } | 58 | } |
| 59 | |||
| 60 | fn receiveSelectionReply(self: *ClientCore, payload: []const u8) Result { | ||
| 61 | const reply = proto.decodeSelectionReply(payload) catch return .ignored; | ||
| 62 | const pending_id = self.pending_selection_id orelse return .ignored; | ||
| 63 | if (reply.id != pending_id) return .ignored; | ||
| 64 | self.pending_selection_id = null; | ||
| 65 | return .{ .reply = .{ .selection = reply } }; | ||
| 66 | } | ||
| 46 | }; | 67 | }; |
| 47 | 68 | ||
| 48 | fn receiveEvent(payload: []const u8) Result { | 69 | fn receiveEvent(payload: []const u8) Result { |
| @@ -254,6 +275,105 @@ test "client core ignores unknown message types" { | |||
| 254 | try expectIgnored(core.receive(@enumFromInt(0xa0), &payload)); | 275 | try expectIgnored(core.receive(@enumFromInt(0xa0), &payload)); |
| 255 | } | 276 | } |
| 256 | 277 | ||
| 278 | test "client core begins selection with exact request bytes" { | ||
| 279 | var core = ClientCore{}; | ||
| 280 | const req: proto.SelectionReq = .{ | ||
| 281 | .id = 0x78563412, | ||
| 282 | .anchor = .{ .row = 0x44332211, .col = 0x6655 }, | ||
| 283 | .active = .{ .row = 0xaa998877, .col = 0xccbb }, | ||
| 284 | }; | ||
| 285 | |||
| 286 | const encoded = core.beginSelection(req); | ||
| 287 | try std.testing.expectEqualSlices(u8, &.{ | ||
| 288 | 0x12, 0x34, 0x56, 0x78, | ||
| 289 | 0x11, 0x22, 0x33, 0x44, | ||
| 290 | 0x55, 0x66, 0x77, 0x88, | ||
| 291 | 0x99, 0xaa, 0xbb, 0xcc, | ||
| 292 | }, &encoded); | ||
| 293 | try std.testing.expectEqualDeep(req, try proto.decodeSelectionReq(&encoded)); | ||
| 294 | try std.testing.expectEqual(@as(?u32, req.id), core.pending_selection_id); | ||
| 295 | } | ||
| 296 | |||
| 297 | test "client core ignores stale selection reply then accepts matching reply once" { | ||
| 298 | var core = ClientCore{}; | ||
| 299 | _ = core.beginSelection(.{ | ||
| 300 | .id = 22, | ||
| 301 | .anchor = .{ .row = 1, .col = 2 }, | ||
| 302 | .active = .{ .row = 3, .col = 4 }, | ||
| 303 | }); | ||
| 304 | const stale = [_]u8{ 21, 0, 0, 0, 0, 'n', 'o' }; | ||
| 305 | const matching = [_]u8{ 22, 0, 0, 0, 0, 'o', 'k' }; | ||
| 306 | |||
| 307 | try expectIgnored(core.receive(.selection_reply, &stale)); | ||
| 308 | try std.testing.expectEqual(@as(?u32, 22), core.pending_selection_id); | ||
| 309 | try expectSelection(core.receive(.selection_reply, &matching), 22, .ok, "ok"); | ||
| 310 | try std.testing.expectEqual(@as(?u32, null), core.pending_selection_id); | ||
| 311 | try expectIgnored(core.receive(.selection_reply, &matching)); | ||
| 312 | } | ||
| 313 | |||
| 314 | test "client core latest selection begin replaces the older pending id" { | ||
| 315 | var core = ClientCore{}; | ||
| 316 | _ = core.beginSelection(.{ | ||
| 317 | .id = 7, | ||
| 318 | .anchor = .{ .row = 0, .col = 0 }, | ||
| 319 | .active = .{ .row = 0, .col = 1 }, | ||
| 320 | }); | ||
| 321 | _ = core.beginSelection(.{ | ||
| 322 | .id = 8, | ||
| 323 | .anchor = .{ .row = 2, .col = 3 }, | ||
| 324 | .active = .{ .row = 4, .col = 5 }, | ||
| 325 | }); | ||
| 326 | |||
| 327 | try expectIgnored(core.receive(.selection_reply, &.{ 7, 0, 0, 0, 0, 'x' })); | ||
| 328 | try std.testing.expectEqual(@as(?u32, 8), core.pending_selection_id); | ||
| 329 | try expectSelection(core.receive(.selection_reply, &.{ 8, 0, 0, 0, 0, 'y' }), 8, .ok, "y"); | ||
| 330 | } | ||
| 331 | |||
| 332 | test "client core malformed matching selection reply preserves pending request" { | ||
| 333 | var core = ClientCore{}; | ||
| 334 | _ = core.beginSelection(.{ | ||
| 335 | .id = 9, | ||
| 336 | .anchor = .{ .row = 0, .col = 0 }, | ||
| 337 | .active = .{ .row = 0, .col = 0 }, | ||
| 338 | }); | ||
| 339 | |||
| 340 | try expectIgnored(core.receive(.selection_reply, &.{ 9, 0, 0, 0, 1, 'x' })); | ||
| 341 | try std.testing.expectEqual(@as(?u32, 9), core.pending_selection_id); | ||
| 342 | try expectSelection(core.receive(.selection_reply, &.{ 9, 0, 0, 0, 0 }), 9, .ok, ""); | ||
| 343 | } | ||
| 344 | |||
| 345 | test "client core delivers every matching non-ok selection status with empty text" { | ||
| 346 | const statuses = [_]proto.SelectionStatus{ .invalid, .too_large, .unavailable }; | ||
| 347 | for (statuses, 0..) |status, i| { | ||
| 348 | var core = ClientCore{}; | ||
| 349 | const id: u32 = @intCast(100 + i); | ||
| 350 | _ = core.beginSelection(.{ | ||
| 351 | .id = id, | ||
| 352 | .anchor = .{ .row = 0, .col = 0 }, | ||
| 353 | .active = .{ .row = 0, .col = 0 }, | ||
| 354 | }); | ||
| 355 | var payload = [_]u8{ 0, 0, 0, 0, @intFromEnum(status) }; | ||
| 356 | std.mem.writeInt(u32, payload[0..4], id, .little); | ||
| 357 | |||
| 358 | try expectSelection(core.receive(.selection_reply, &payload), id, status, ""); | ||
| 359 | try std.testing.expectEqual(@as(?u32, null), core.pending_selection_id); | ||
| 360 | } | ||
| 361 | } | ||
| 362 | |||
| 363 | test "client core selection reply text borrows the frame payload" { | ||
| 364 | var core = ClientCore{}; | ||
| 365 | _ = core.beginSelection(.{ | ||
| 366 | .id = 1, | ||
| 367 | .anchor = .{ .row = 0, .col = 0 }, | ||
| 368 | .active = .{ .row = 0, .col = 0 }, | ||
| 369 | }); | ||
| 370 | var payload = [_]u8{ 1, 0, 0, 0, 0, 'h', 'i' }; | ||
| 371 | |||
| 372 | const result = core.receive(.selection_reply, &payload); | ||
| 373 | payload[5] = 'H'; | ||
| 374 | try expectSelection(result, 1, .ok, "Hi"); | ||
| 375 | } | ||
| 376 | |||
| 257 | fn expectIgnored(result: Result) !void { | 377 | fn expectIgnored(result: Result) !void { |
| 258 | switch (result) { | 378 | switch (result) { |
| 259 | .ignored => {}, | 379 | .ignored => {}, |
| @@ -269,3 +389,21 @@ fn expectModes(result: Result, expected: bool) !void { | |||
| 269 | else => return error.ExpectedModes, | 389 | else => return error.ExpectedModes, |
| 270 | } | 390 | } |
| 271 | } | 391 | } |
| 392 | |||
| 393 | fn expectSelection( | ||
| 394 | result: Result, | ||
| 395 | id: u32, | ||
| 396 | status: proto.SelectionStatus, | ||
| 397 | text: []const u8, | ||
| 398 | ) !void { | ||
| 399 | switch (result) { | ||
| 400 | .reply => |reply| switch (reply) { | ||
| 401 | .selection => |selection| { | ||
| 402 | try std.testing.expectEqual(id, selection.id); | ||
| 403 | try std.testing.expectEqual(status, selection.status); | ||
| 404 | try std.testing.expectEqualStrings(text, selection.text); | ||
| 405 | }, | ||
| 406 | }, | ||
| 407 | else => return error.ExpectedSelection, | ||
| 408 | } | ||
| 409 | } | ||
src/wasm_core.zig
| Old | New | ||
|---|---|---|---|
| @@ -58,6 +58,9 @@ const Core = struct { | |||
| 58 | /// Borrows from input_buf. It is valid only until the host next stages | 58 | /// Borrows from input_buf. It is valid only until the host next stages |
| 59 | /// a frame, exactly like ClientCore's payload-borrowing result. | 59 | /// a frame, exactly like ClientCore's payload-borrowing result. |
| 60 | clipboard: client_core.ClipboardSet = .{ .target = 0, .base64 = &.{} }, | 60 | clipboard: client_core.ClipboardSet = .{ .target = 0, .base64 = &.{} }, |
| 61 | /// Borrows from input_buf. The text is valid only until the host next | ||
| 62 | /// stages or writes input, or starts another selection request. | ||
| 63 | selection: proto.SelectionReply = .{ .id = 0, .status = .unavailable, .text = &.{} }, | ||
| 61 | /// Grid the readout buffers are sized for; follows rep.grid. | 64 | /// Grid the readout buffers are sized for; follows rep.grid. |
| 62 | cols: u16, | 65 | cols: u16, |
| 63 | rows: u16, | 66 | rows: u16, |
| @@ -79,12 +82,9 @@ const Core = struct { | |||
| 79 | var core: ?*Core = null; | 82 | var core: ?*Core = null; |
| 80 | 83 | ||
| 81 | /// Bytes in (frame payloads, paste bytes) cross through this staging | 84 | /// Bytes in (frame payloads, paste bytes) cross through this staging |
| 82 | /// buffer: one memcpy from JS, no malloc protocol to get wrong. 256 KiB — | 85 | /// buffer: one memcpy from JS, no malloc protocol to get wrong. It must fit |
| 83 | /// a full snapshot payload must fit in ONE frame; verify.js asserts a | 86 | /// both a full snapshot and the protocol's largest selection reply. |
| 84 | /// real snapshot at the default scrollback does. The hub's own inbound | 87 | var input_buf: [@max(256 * 1024, proto.selection_reply_prefix_len + proto.selection_text_max)]u8 = undefined; |
| 85 | /// bound is far smaller (64 KiB) because browser->hub messages are keys | ||
| 86 | /// and pastes; daemon->browser frames ride hub->browser with no bound. | ||
| 87 | var input_buf: [256 * 1024]u8 = undefined; | ||
| 88 | 88 | ||
| 89 | /// Variable-length results out (encoded keys, attach payloads, dumps). | 89 | /// Variable-length results out (encoded keys, attach payloads, dumps). |
| 90 | var output_buf: [64 * 1024]u8 = undefined; | 90 | var output_buf: [64 * 1024]u8 = undefined; |
| @@ -95,6 +95,7 @@ const ClientAction = enum(i32) { | |||
| 95 | terminal_modes = 1, | 95 | terminal_modes = 1, |
| 96 | clipboard = 2, | 96 | clipboard = 2, |
| 97 | bell = 3, | 97 | bell = 3, |
| 98 | selection = 4, | ||
| 98 | }; | 99 | }; |
| 99 | 100 | ||
| 100 | // --------------------------------------------------------------------- | 101 | // --------------------------------------------------------------------- |
| @@ -250,11 +251,13 @@ export fn mux_apply_frame(msg_type: u32, len: u32) i32 { | |||
| 250 | } | 251 | } |
| 251 | 252 | ||
| 252 | /// Decode one staged daemon frame through the same semantic core used by | 253 | /// Decode one staged daemon frame through the same semantic core used by |
| 253 | /// the native client. Clipboard bytes remain borrowed from input_buf until | 254 | /// the native client. Clipboard and selection bytes remain borrowed from |
| 254 | /// the host stages the next frame; getters never copy or allocate them. | 255 | /// input_buf until the host stages or writes the next input; getters never |
| 256 | /// copy or allocate them. | ||
| 255 | export fn mux_client_frame(msg_type: u32, len: u32) i32 { | 257 | export fn mux_client_frame(msg_type: u32, len: u32) i32 { |
| 256 | const c = core orelse return -1; | 258 | const c = core orelse return -1; |
| 257 | c.clipboard = .{ .target = 0, .base64 = &.{} }; | 259 | c.clipboard = .{ .target = 0, .base64 = &.{} }; |
| 260 | c.selection = .{ .id = 0, .status = .unavailable, .text = &.{} }; | ||
| 258 | if (len > input_buf.len) return -2; | 261 | if (len > input_buf.len) return -2; |
| 259 | if (msg_type > 0xff) return @intFromEnum(ClientAction.ignored); | 262 | if (msg_type > 0xff) return @intFromEnum(ClientAction.ignored); |
| 260 | 263 | ||
| @@ -273,6 +276,12 @@ export fn mux_client_frame(msg_type: u32, len: u32) i32 { | |||
| 273 | }, | 276 | }, |
| 274 | .bell => @intFromEnum(ClientAction.bell), | 277 | .bell => @intFromEnum(ClientAction.bell), |
| 275 | }, | 278 | }, |
| 279 | .reply => |reply| switch (reply) { | ||
| 280 | .selection => |selection| blk: { | ||
| 281 | c.selection = selection; | ||
| 282 | break :blk @intFromEnum(ClientAction.selection); | ||
| 283 | }, | ||
| 284 | }, | ||
| 276 | }; | 285 | }; |
| 277 | } | 286 | } |
| 278 | 287 | ||
| @@ -297,6 +306,51 @@ export fn mux_clipboard_len() u32 { | |||
| 297 | return @intCast(c.clipboard.base64.len); | 306 | return @intCast(c.clipboard.base64.len); |
| 298 | } | 307 | } |
| 299 | 308 | ||
| 309 | /// Begin a correlated selection request and write its exact protocol payload | ||
| 310 | /// to output_buf. Invalid u16 columns do not disturb an existing pending id. | ||
| 311 | export fn mux_selection_request( | ||
| 312 | id: u32, | ||
| 313 | anchor_row: u32, | ||
| 314 | anchor_col: u32, | ||
| 315 | active_row: u32, | ||
| 316 | active_col: u32, | ||
| 317 | ) i32 { | ||
| 318 | output_len = 0; | ||
| 319 | const c = core orelse return -1; | ||
| 320 | if (anchor_col > std.math.maxInt(u16) or active_col > std.math.maxInt(u16)) return -3; | ||
| 321 | |||
| 322 | const payload = c.client.beginSelection(.{ | ||
| 323 | .id = id, | ||
| 324 | .anchor = .{ .row = anchor_row, .col = @intCast(anchor_col) }, | ||
| 325 | .active = .{ .row = active_row, .col = @intCast(active_col) }, | ||
| 326 | }); | ||
| 327 | c.selection = .{ .id = 0, .status = .unavailable, .text = &.{} }; | ||
| 328 | @memcpy(output_buf[0..payload.len], &payload); | ||
| 329 | output_len = payload.len; | ||
| 330 | return @intCast(payload.len); | ||
| 331 | } | ||
| 332 | |||
| 333 | export fn mux_selection_id() u32 { | ||
| 334 | const c = core orelse return 0; | ||
| 335 | return c.selection.id; | ||
| 336 | } | ||
| 337 | |||
| 338 | export fn mux_selection_status() u32 { | ||
| 339 | const c = core orelse return @intFromEnum(proto.SelectionStatus.unavailable); | ||
| 340 | return @intFromEnum(c.selection.status); | ||
| 341 | } | ||
| 342 | |||
| 343 | export fn mux_selection_ptr() [*]const u8 { | ||
| 344 | const c = core orelse return &input_buf; | ||
| 345 | if (c.selection.text.len == 0) return &input_buf; | ||
| 346 | return c.selection.text.ptr; | ||
| 347 | } | ||
| 348 | |||
| 349 | export fn mux_selection_len() u32 { | ||
| 350 | const c = core orelse return 0; | ||
| 351 | return @intCast(c.selection.text.len); | ||
| 352 | } | ||
| 353 | |||
| 300 | /// Force a full repaint on the next mux_read_viewport (scroll-mode exit, | 354 | /// Force a full repaint on the next mux_read_viewport (scroll-mode exit, |
| 301 | /// a canvas the host lost, first paint after tab restore). | 355 | /// a canvas the host lost, first paint after tab restore). |
| 302 | export fn mux_mark_all_dirty() void { | 356 | export fn mux_mark_all_dirty() void { |
web/verify.js
| Old | New | ||
|---|---|---|---|
| @@ -24,6 +24,7 @@ const clientAction = Object.freeze({ | |||
| 24 | terminalModes: 1, | 24 | terminalModes: 1, |
| 25 | clipboard: 2, | 25 | clipboard: 2, |
| 26 | bell: 3, | 26 | bell: 3, |
| 27 | selection: 4, | ||
| 27 | }); | 28 | }); |
| 28 | 29 | ||
| 29 | function check(name, got, want) { | 30 | function check(name, got, want) { |
| @@ -817,6 +818,110 @@ async function main() { | |||
| 817 | check('wide type clears clipboard len', e.mux_clipboard_len(), 0); | 818 | check('wide type clears clipboard len', e.mux_clipboard_len(), 0); |
| 818 | check('wide type clears clipboard target', e.mux_clipboard_target(), 0); | 819 | check('wide type clears clipboard target', e.mux_clipboard_target(), 0); |
| 819 | 820 | ||
| 821 | const selectionReply = (id, status, text = '') => { | ||
| 822 | const body = Buffer.from(text, 'utf8'); | ||
| 823 | const reply = Buffer.alloc(5 + body.length); | ||
| 824 | reply.writeUInt32LE(id, 0); | ||
| 825 | reply[4] = status; | ||
| 826 | body.copy(reply, 5); | ||
| 827 | return reply; | ||
| 828 | }; | ||
| 829 | const selectionText = () => Buffer.from(mem().subarray( | ||
| 830 | e.mux_selection_ptr(), | ||
| 831 | e.mux_selection_ptr() + e.mux_selection_len(), | ||
| 832 | )); | ||
| 833 | const firstSelectionId = 0x78563412; | ||
| 834 | check('selection request len', e.mux_selection_request(firstSelectionId, 0x44332211, 0x6655, 0xaa998877, 0xccbb), 16); | ||
| 835 | check('selection request output len', e.mux_output_len(), 16); | ||
| 836 | check( | ||
| 837 | 'selection request golden bytes', | ||
| 838 | outBytes().toString('hex'), | ||
| 839 | '12345678112233445566778899aabbcc', | ||
| 840 | ); | ||
| 841 | check('selection invalid anchor col', e.mux_selection_request(30, 1, 65536, 2, 3), -3); | ||
| 842 | check('selection invalid anchor col clears output', e.mux_output_len(), 0); | ||
| 843 | check('selection invalid active col', e.mux_selection_request(30, 1, 2, 3, 65536), -3); | ||
| 844 | check('selection invalid active col clears output', e.mux_output_len(), 0); | ||
| 845 | check( | ||
| 846 | 'selection invalid columns preserve pending id', | ||
| 847 | e.mux_client_frame(0x90, stage(selectionReply(firstSelectionId, 0, 'preserved'))), | ||
| 848 | clientAction.selection, | ||
| 849 | ); | ||
| 850 | |||
| 851 | const supersededSelectionId = 0x01020304; | ||
| 852 | check('selection superseded request', e.mux_selection_request(supersededSelectionId, 5, 6, 7, 8), 16); | ||
| 853 | const latestSelectionId = 0x10203040; | ||
| 854 | check('selection latest request replaces pending', e.mux_selection_request(latestSelectionId, 9, 10, 11, 12), 16); | ||
| 855 | check( | ||
| 856 | 'selection request survives enlarged input staging capacity', | ||
| 857 | outBytes().toString('hex'), | ||
| 858 | '40302010090000000a000b0000000c00', | ||
| 859 | ); | ||
| 860 | check( | ||
| 861 | 'selection stale reply ignored', | ||
| 862 | e.mux_client_frame(0x90, stage(selectionReply(supersededSelectionId, 0, 'stale'))), | ||
| 863 | clientAction.ignored, | ||
| 864 | ); | ||
| 865 | check('selection stale reply leaves getters empty', e.mux_selection_len(), 0); | ||
| 866 | check( | ||
| 867 | 'selection malformed matching reply ignored', | ||
| 868 | e.mux_client_frame(0x90, stage(selectionReply(latestSelectionId, 1, 'bad'))), | ||
| 869 | clientAction.ignored, | ||
| 870 | ); | ||
| 871 | check( | ||
| 872 | 'selection valid after malformed matching reply', | ||
| 873 | e.mux_client_frame(0x90, stage(selectionReply(latestSelectionId, 0, 'selected 漢'))), | ||
| 874 | clientAction.selection, | ||
| 875 | ); | ||
| 876 | check('selection getter id', e.mux_selection_id(), latestSelectionId); | ||
| 877 | check('selection getter status', e.mux_selection_status(), 0); | ||
| 878 | check('selection getter len', e.mux_selection_len(), Buffer.byteLength('selected 漢')); | ||
| 879 | check('selection getter borrowed text', selectionText().toString('utf8'), 'selected 漢'); | ||
| 880 | check( | ||
| 881 | 'selection repeated reply after consume ignored', | ||
| 882 | e.mux_client_frame(0x90, stage(selectionReply(latestSelectionId, 0, 'again'))), | ||
| 883 | clientAction.ignored, | ||
| 884 | ); | ||
| 885 | check('selection repeated reply clears getter', e.mux_selection_len(), 0); | ||
| 886 | |||
| 887 | for (const [name, status] of [['invalid', 1], ['too large', 2], ['unavailable', 3]]) { | ||
| 888 | const id = 100 + status; | ||
| 889 | check(`selection ${name} request`, e.mux_selection_request(id, 0, 0, 0, 0), 16); | ||
| 890 | check( | ||
| 891 | `selection ${name} action`, | ||
| 892 | e.mux_client_frame(0x90, stage(selectionReply(id, status))), | ||
| 893 | clientAction.selection, | ||
| 894 | ); | ||
| 895 | check(`selection ${name} id`, e.mux_selection_id(), id); | ||
| 896 | check(`selection ${name} status`, e.mux_selection_status(), status); | ||
| 897 | check(`selection ${name} empty text`, e.mux_selection_len(), 0); | ||
| 898 | } | ||
| 899 | |||
| 900 | const populateSelection = (id) => { | ||
| 901 | check(`selection ${id} request`, e.mux_selection_request(id, 0, 0, 0, 0), 16); | ||
| 902 | check( | ||
| 903 | `selection ${id} reply`, | ||
| 904 | e.mux_client_frame(0x90, stage(selectionReply(id, 0, 'x'))), | ||
| 905 | clientAction.selection, | ||
| 906 | ); | ||
| 907 | }; | ||
| 908 | populateSelection(201); | ||
| 909 | check('bell after selection', e.mux_client_frame(0x8f, stage(Buffer.from([1]))), clientAction.bell); | ||
| 910 | check('bell clears selection getter', e.mux_selection_len(), 0); | ||
| 911 | check('empty selection pointer is input buffer', e.mux_selection_ptr(), e.mux_input_ptr()); | ||
| 912 | populateSelection(202); | ||
| 913 | check('ignored after selection', e.mux_client_frame(0x40, 0), clientAction.ignored); | ||
| 914 | check('ignored clears selection getter', e.mux_selection_len(), 0); | ||
| 915 | populateSelection(203); | ||
| 916 | check('oversize after selection', e.mux_client_frame(0x90, e.mux_input_cap() + 1), -2); | ||
| 917 | check('oversize clears selection getter', e.mux_selection_len(), 0); | ||
| 918 | populateSelection(204); | ||
| 919 | check('wide type after selection', e.mux_client_frame(0x100, 0), clientAction.ignored); | ||
| 920 | check('wide type clears selection getter', e.mux_selection_len(), 0); | ||
| 921 | populateSelection(205); | ||
| 922 | check('valid new request after selection', e.mux_selection_request(206, 1, 2, 3, 4), 16); | ||
| 923 | check('valid new request clears selection getter', e.mux_selection_len(), 0); | ||
| 924 | |||
| 820 | // --- attach payload before any state: quotes (0,0); wall spelling 1x1 --- | 925 | // --- attach payload before any state: quotes (0,0); wall spelling 1x1 --- |
| 821 | check('attach len', e.mux_attach_payload(1, 1, 0), 20); | 926 | check('attach len', e.mux_attach_payload(1, 1, 0), 20); |
| 822 | let att = outBytes(); | 927 | let att = outBytes(); |
| @@ -952,10 +1057,9 @@ async function main() { | |||
| 952 | check('dump starts', outBytes().toString('utf8').startsWith('漢字'), true); | 1057 | check('dump starts', outBytes().toString('utf8').startsWith('漢字'), true); |
| 953 | 1058 | ||
| 954 | // --- a real-sized snapshot fits the staging buffer --- | 1059 | // --- a real-sized snapshot fits the staging buffer --- |
| 955 | // 100x30 grid fully painted with styled cells is well under 256K; the | 1060 | // The staging cap is pinned to the largest selection reply: its five-byte |
| 956 | // pin here is the CAP ITSELF: input_cap must hold the biggest payload | 1061 | // correlation/status prefix plus the protocol's one-MiB text maximum. |
| 957 | // the daemon sends in one frame for the wall's grids. | 1062 | check('input cap', e.mux_input_cap(), 1024 * 1024 + 5); |
| 958 | check('input cap', e.mux_input_cap(), 256 * 1024); | ||
| 959 | 1063 | ||
| 960 | // --- the shell's ACTUAL call list, read out of mux.js --- | 1064 | // --- the shell's ACTUAL call list, read out of mux.js --- |
| 961 | // Everything above pins exports this file happens to name. This pins | 1065 | // Everything above pins exports this file happens to name. This pins |
| @@ -1197,12 +1301,24 @@ async function main() { | |||
| 1197 | check('clipboard before lifecycle reset', e.mux_client_frame(0x8f, stage(clipboard)), clientAction.clipboard); | 1301 | check('clipboard before lifecycle reset', e.mux_client_frame(0x8f, stage(clipboard)), clientAction.clipboard); |
| 1198 | check('clipboard populated before lifecycle reset', e.mux_clipboard_len(), 4); | 1302 | check('clipboard populated before lifecycle reset', e.mux_clipboard_len(), 4); |
| 1199 | check('modes populated before lifecycle reset', e.mux_bracketed_paste(), 1); | 1303 | check('modes populated before lifecycle reset', e.mux_bracketed_paste(), 1); |
| 1304 | check('selection before lifecycle reset request', e.mux_selection_request(301, 0, 0, 0, 0), 16); | ||
| 1305 | check('selection before lifecycle reset reply', e.mux_client_frame(0x90, stage(selectionReply(301, 0, 'reset'))), clientAction.selection); | ||
| 1306 | check('selection populated before lifecycle reset', e.mux_selection_len(), 5); | ||
| 1200 | e.mux_deinit(); | 1307 | e.mux_deinit(); |
| 1201 | check('apply after deinit', e.mux_apply_frame(0x81, 0), -1); | 1308 | check('apply after deinit', e.mux_apply_frame(0x81, 0), -1); |
| 1202 | check('client frame after deinit', e.mux_client_frame(0x8d, 0), -1); | 1309 | check('client frame after deinit', e.mux_client_frame(0x8d, 0), -1); |
| 1310 | check('selection id after deinit', e.mux_selection_id(), 0); | ||
| 1311 | check('selection status after deinit', e.mux_selection_status(), 3); | ||
| 1312 | check('selection len after deinit', e.mux_selection_len(), 0); | ||
| 1313 | check('selection ptr after deinit is input buffer', e.mux_selection_ptr(), e.mux_input_ptr()); | ||
| 1314 | check('selection request after deinit', e.mux_selection_request(302, 0, 0, 0, 0), -1); | ||
| 1315 | check('selection request error clears output', e.mux_output_len(), 0); | ||
| 1203 | check('re-init', e.mux_init(80, 24), 0); | 1316 | check('re-init', e.mux_init(80, 24), 0); |
| 1204 | check('re-init resets bracketed paste', e.mux_bracketed_paste(), 0); | 1317 | check('re-init resets bracketed paste', e.mux_bracketed_paste(), 0); |
| 1205 | check('re-init resets clipboard', e.mux_clipboard_len(), 0); | 1318 | check('re-init resets clipboard', e.mux_clipboard_len(), 0); |
| 1319 | check('deinit and re-init reset selection id', e.mux_selection_id(), 0); | ||
| 1320 | check('deinit and re-init reset selection status', e.mux_selection_status(), 3); | ||
| 1321 | check('deinit and re-init reset selection len', e.mux_selection_len(), 0); | ||
| 1206 | e.mux_deinit(); | 1322 | e.mux_deinit(); |
| 1207 | 1323 | ||
| 1208 | console.log(`verify: ${passed} passed, ${failed} failed`); | 1324 | console.log(`verify: ${passed} passed, ${failed} failed`); |