cdedcd36
feat: expose client semantics through wasm
a73x 2026-08-18 12:27
Commit message
src/wasm_core.zig
| Old | New | ||
|---|---|---|---|
| @@ -22,6 +22,7 @@ const Engine = @import("engine").Engine; | |||
| 22 | const Replica = @import("replica").Replica; | 22 | const Replica = @import("replica").Replica; |
| 23 | const keymap = @import("keymap"); | 23 | const keymap = @import("keymap"); |
| 24 | const proto = @import("protocol"); | 24 | const proto = @import("protocol"); |
| 25 | const client_core = @import("client_core"); | ||
| 25 | 26 | ||
| 26 | /// std.heap.wasm_allocator grows linear memory with @wasmMemoryGrow and | 27 | /// std.heap.wasm_allocator grows linear memory with @wasmMemoryGrow and |
| 27 | /// needs no libc, no syscalls, no host imports. | 28 | /// needs no libc, no syscalls, no host imports. |
| @@ -53,6 +54,10 @@ pub const std_options: std.Options = .{ | |||
| 53 | const Core = struct { | 54 | const Core = struct { |
| 54 | eng: *Engine, | 55 | eng: *Engine, |
| 55 | rep: Replica, | 56 | rep: Replica, |
| 57 | client: client_core.ClientCore = .{}, | ||
| 58 | /// Borrows from input_buf. It is valid only until the host next stages | ||
| 59 | /// a frame, exactly like ClientCore's payload-borrowing result. | ||
| 60 | clipboard: client_core.ClipboardSet = .{ .target = 0, .base64 = &.{} }, | ||
| 56 | /// Grid the readout buffers are sized for; follows rep.grid. | 61 | /// Grid the readout buffers are sized for; follows rep.grid. |
| 57 | cols: u16, | 62 | cols: u16, |
| 58 | rows: u16, | 63 | rows: u16, |
| @@ -85,6 +90,13 @@ var input_buf: [256 * 1024]u8 = undefined; | |||
| 85 | var output_buf: [64 * 1024]u8 = undefined; | 90 | var output_buf: [64 * 1024]u8 = undefined; |
| 86 | var output_len: u32 = 0; | 91 | var output_len: u32 = 0; |
| 87 | 92 | ||
| 93 | const ClientAction = enum(i32) { | ||
| 94 | ignored = 0, | ||
| 95 | terminal_modes = 1, | ||
| 96 | clipboard = 2, | ||
| 97 | bell = 3, | ||
| 98 | }; | ||
| 99 | |||
| 88 | // --------------------------------------------------------------------- | 100 | // --------------------------------------------------------------------- |
| 89 | // Lifecycle | 101 | // Lifecycle |
| 90 | // --------------------------------------------------------------------- | 102 | // --------------------------------------------------------------------- |
| @@ -237,6 +249,54 @@ export fn mux_apply_frame(msg_type: u32, len: u32) i32 { | |||
| 237 | return 0; | 249 | return 0; |
| 238 | } | 250 | } |
| 239 | 251 | ||
| 252 | /// 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 host stages the next frame; getters never copy or allocate them. | ||
| 255 | export fn mux_client_frame(msg_type: u32, len: u32) i32 { | ||
| 256 | const c = core orelse return -1; | ||
| 257 | if (len > input_buf.len) return -2; | ||
| 258 | if (msg_type > 0xff) return @intFromEnum(ClientAction.ignored); | ||
| 259 | |||
| 260 | // MsgType is deliberately non-exhaustive, so every u8 is a valid enum | ||
| 261 | // value. Unknown wire bytes reach ClientCore and are ignored safely. | ||
| 262 | const t: proto.MsgType = @enumFromInt(@as(u8, @intCast(msg_type))); | ||
| 263 | c.clipboard = .{ .target = 0, .base64 = &.{} }; | ||
| 264 | return switch (c.client.receive(t, input_buf[0..len])) { | ||
| 265 | .ignored => @intFromEnum(ClientAction.ignored), | ||
| 266 | .state => |state| switch (state) { | ||
| 267 | .terminal_modes => @intFromEnum(ClientAction.terminal_modes), | ||
| 268 | }, | ||
| 269 | .effect => |effect| switch (effect) { | ||
| 270 | .clipboard_set => |clipboard| blk: { | ||
| 271 | c.clipboard = clipboard; | ||
| 272 | break :blk @intFromEnum(ClientAction.clipboard); | ||
| 273 | }, | ||
| 274 | .bell => @intFromEnum(ClientAction.bell), | ||
| 275 | }, | ||
| 276 | }; | ||
| 277 | } | ||
| 278 | |||
| 279 | export fn mux_bracketed_paste() u32 { | ||
| 280 | const c = core orelse return 0; | ||
| 281 | return @intFromBool(c.client.terminal_modes.bracketed_paste); | ||
| 282 | } | ||
| 283 | |||
| 284 | export fn mux_clipboard_target() u32 { | ||
| 285 | const c = core orelse return 0; | ||
| 286 | return c.clipboard.target; | ||
| 287 | } | ||
| 288 | |||
| 289 | export fn mux_clipboard_ptr() [*]const u8 { | ||
| 290 | const c = core orelse return &input_buf; | ||
| 291 | if (c.clipboard.base64.len == 0) return &input_buf; | ||
| 292 | return c.clipboard.base64.ptr; | ||
| 293 | } | ||
| 294 | |||
| 295 | export fn mux_clipboard_len() u32 { | ||
| 296 | const c = core orelse return 0; | ||
| 297 | return @intCast(c.clipboard.base64.len); | ||
| 298 | } | ||
| 299 | |||
| 240 | /// Force a full repaint on the next mux_read_viewport (scroll-mode exit, | 300 | /// Force a full repaint on the next mux_read_viewport (scroll-mode exit, |
| 241 | /// a canvas the host lost, first paint after tab restore). | 301 | /// a canvas the host lost, first paint after tab restore). |
| 242 | export fn mux_mark_all_dirty() void { | 302 | export fn mux_mark_all_dirty() void { |
| @@ -364,18 +424,29 @@ export fn mux_text_encode(len: u32) i32 { | |||
| 364 | /// acts on the marker acts on it right there: vim leaves paste mode | 424 | /// acts on the marker acts on it right there: vim leaves paste mode |
| 365 | /// 32 KiB in and re-indents the rest. | 425 | /// 32 KiB in and re-indents the rest. |
| 366 | /// | 426 | /// |
| 367 | /// UNCONDITIONAL, a known v1 limitation: the daemon's pty_mode frame | ||
| 368 | /// carries only icanon/echo, so the hub cannot know whether the | ||
| 369 | /// application asked for mode 2004. An application that did not ask sees | ||
| 370 | /// the literal markers. Teaching pty_mode to carry the bit is a daemon | ||
| 371 | /// protocol change and is deferred. | ||
| 372 | export fn mux_paste_begin() i32 { | 427 | export fn mux_paste_begin() i32 { |
| 428 | const c = core orelse { | ||
| 429 | output_len = 0; | ||
| 430 | return 0; | ||
| 431 | }; | ||
| 432 | if (!c.client.terminal_modes.bracketed_paste) { | ||
| 433 | output_len = 0; | ||
| 434 | return 0; | ||
| 435 | } | ||
| 373 | @memcpy(output_buf[0..keymap.paste_begin.len], keymap.paste_begin); | 436 | @memcpy(output_buf[0..keymap.paste_begin.len], keymap.paste_begin); |
| 374 | output_len = @intCast(keymap.paste_begin.len); | 437 | output_len = @intCast(keymap.paste_begin.len); |
| 375 | return @intCast(keymap.paste_begin.len); | 438 | return @intCast(keymap.paste_begin.len); |
| 376 | } | 439 | } |
| 377 | 440 | ||
| 378 | export fn mux_paste_end() i32 { | 441 | export fn mux_paste_end() i32 { |
| 442 | const c = core orelse { | ||
| 443 | output_len = 0; | ||
| 444 | return 0; | ||
| 445 | }; | ||
| 446 | if (!c.client.terminal_modes.bracketed_paste) { | ||
| 447 | output_len = 0; | ||
| 448 | return 0; | ||
| 449 | } | ||
| 379 | @memcpy(output_buf[0..keymap.paste_end.len], keymap.paste_end); | 450 | @memcpy(output_buf[0..keymap.paste_end.len], keymap.paste_end); |
| 380 | output_len = @intCast(keymap.paste_end.len); | 451 | output_len = @intCast(keymap.paste_end.len); |
| 381 | return @intCast(keymap.paste_end.len); | 452 | return @intCast(keymap.paste_end.len); |
web/verify.js
| Old | New | ||
|---|---|---|---|
| @@ -18,6 +18,13 @@ const wasmPath = process.argv[2] || | |||
| 18 | path.join(__dirname, '..', 'zig-out', 'bin', 'mux_core.wasm'); | 18 | path.join(__dirname, '..', 'zig-out', 'bin', 'mux_core.wasm'); |
| 19 | 19 | ||
| 20 | let passed = 0, failed = 0; | 20 | let passed = 0, failed = 0; |
| 21 | const clientAction = Object.freeze({ | ||
| 22 | ignored: 0, | ||
| 23 | terminalModes: 1, | ||
| 24 | clipboard: 2, | ||
| 25 | bell: 3, | ||
| 26 | }); | ||
| 27 | |||
| 21 | function check(name, got, want) { | 28 | function check(name, got, want) { |
| 22 | const ok = Object.is(got, want); // SameValue compares BigInts by value | 29 | const ok = Object.is(got, want); // SameValue compares BigInts by value |
| 23 | if (ok) { passed++; } | 30 | if (ok) { passed++; } |
| @@ -95,6 +102,38 @@ async function main() { | |||
| 95 | check('cols', e.mux_cols(), 80); | 102 | check('cols', e.mux_cols(), 80); |
| 96 | check('rows', e.mux_rows(), 24); | 103 | check('rows', e.mux_rows(), 24); |
| 97 | 104 | ||
| 105 | // --- shared client semantics ABI --- | ||
| 106 | // Pasting is raw until the daemon explicitly samples mode 2004 as on. | ||
| 107 | check('paste begin disabled', e.mux_paste_begin(), 0); | ||
| 108 | check('paste begin disabled clears output', e.mux_output_len(), 0); | ||
| 109 | check('paste end disabled', e.mux_paste_end(), 0); | ||
| 110 | check('paste end disabled clears output', e.mux_output_len(), 0); | ||
| 111 | |||
| 112 | const modesOn = Buffer.from([1, 0, 0, 0]); | ||
| 113 | const modesOff = Buffer.from([0, 0, 0, 0]); | ||
| 114 | check('client modes on action', e.mux_client_frame(0x8d, stage(modesOn)), clientAction.terminalModes); | ||
| 115 | check('bracketed paste on', e.mux_bracketed_paste(), 1); | ||
| 116 | check('client repeated modes action', e.mux_client_frame(0x8d, stage(modesOn)), clientAction.terminalModes); | ||
| 117 | check('client modes off action', e.mux_client_frame(0x8d, stage(modesOff)), clientAction.terminalModes); | ||
| 118 | check('bracketed paste off', e.mux_bracketed_paste(), 0); | ||
| 119 | check('client malformed modes ignored', e.mux_client_frame(0x8d, stage(Buffer.from([1, 0]))), clientAction.ignored); | ||
| 120 | check('malformed modes preserve state', e.mux_bracketed_paste(), 0); | ||
| 121 | |||
| 122 | const clipboard = Buffer.from([0, 'c'.charCodeAt(0), ...Buffer.from('aGk=', 'ascii')]); | ||
| 123 | check('client clipboard action', e.mux_client_frame(0x8f, stage(clipboard)), clientAction.clipboard); | ||
| 124 | check('clipboard target', e.mux_clipboard_target(), 'c'.charCodeAt(0)); | ||
| 125 | check('clipboard len', e.mux_clipboard_len(), 4); | ||
| 126 | check( | ||
| 127 | 'clipboard borrowed bytes', | ||
| 128 | Buffer.from(mem().subarray(e.mux_clipboard_ptr(), e.mux_clipboard_ptr() + e.mux_clipboard_len())).toString('ascii'), | ||
| 129 | 'aGk=', | ||
| 130 | ); | ||
| 131 | check('client bell action', e.mux_client_frame(0x8f, stage(Buffer.from([1]))), clientAction.bell); | ||
| 132 | check('bell clears clipboard getter', e.mux_clipboard_len(), 0); | ||
| 133 | check('client unsafe clipboard ignored', e.mux_client_frame(0x8f, stage(Buffer.from([0, 'X'.charCodeAt(0), 'A'.charCodeAt(0)]))), clientAction.ignored); | ||
| 134 | check('ignored event clears clipboard getter', e.mux_clipboard_len(), 0); | ||
| 135 | check('client unknown u8 type ignored', e.mux_client_frame(0x40, 0), clientAction.ignored); | ||
| 136 | |||
| 98 | // --- attach payload before any state: quotes (0,0); wall spelling 1x1 --- | 137 | // --- attach payload before any state: quotes (0,0); wall spelling 1x1 --- |
| 99 | check('attach len', e.mux_attach_payload(1, 1, 0), 20); | 138 | check('attach len', e.mux_attach_payload(1, 1, 0), 20); |
| 100 | let att = outBytes(); | 139 | let att = outBytes(); |
| @@ -196,6 +235,9 @@ async function main() { | |||
| 196 | check('text len', e.mux_text_encode(9), 9); | 235 | check('text len', e.mux_text_encode(9), 9); |
| 197 | check('text bytes: no wrap', outBytes().toString('latin1'), 'two\nlines'); | 236 | check('text bytes: no wrap', outBytes().toString('latin1'), 'two\nlines'); |
| 198 | 237 | ||
| 238 | // Fresh sample: this assertion must not inherit mode state from the | ||
| 239 | // client-ABI checks above. | ||
| 240 | check('paste section modes on', e.mux_client_frame(0x8d, stage(modesOn)), clientAction.terminalModes); | ||
| 199 | check('paste begin len', e.mux_paste_begin(), 6); | 241 | check('paste begin len', e.mux_paste_begin(), 6); |
| 200 | const pasteWire = [outBytes().toString('latin1')]; | 242 | const pasteWire = [outBytes().toString('latin1')]; |
| 201 | for (const chunk of ['two\n', 'lines']) { | 243 | for (const chunk of ['two\n', 'lines']) { |
| @@ -264,6 +306,7 @@ async function main() { | |||
| 264 | // --- deinit / re-init --- | 306 | // --- deinit / re-init --- |
| 265 | e.mux_deinit(); | 307 | e.mux_deinit(); |
| 266 | check('apply after deinit', e.mux_apply_frame(0x81, 0), -1); | 308 | check('apply after deinit', e.mux_apply_frame(0x81, 0), -1); |
| 309 | check('client frame after deinit', e.mux_client_frame(0x8d, 0), -1); | ||
| 267 | check('re-init', e.mux_init(80, 24), 0); | 310 | check('re-init', e.mux_init(80, 24), 0); |
| 268 | e.mux_deinit(); | 311 | e.mux_deinit(); |
| 269 | 312 | ||