From 49e277cea3ae70a18ea81e32d316ed32f5c47f9a Mon Sep 17 00:00:00 2001 From: Alessandro Colace Date: Sun, 14 Jun 2026 19:21:24 +0200 Subject: [PATCH 1/2] feat: port fcxd to Windows in Zig Pure-Zig Windows backend (mouse, keyboard, system_info) exporting the fcx_* C ABI over Win32 SendInput, plus a Windows entry point that drives the Zig Runner with its own stdio. No C sources or json-c on Windows. - build.zig: a Windows branch (links user32/advapi32); Linux/mac untouched. - Keyboard symbols match the app's set (ToolsLive); brightness has no SendInput virtual-key on Windows and stays unsupported. - Tests: inline Zig unit tests + a NUL-framed integration smoke test. - CI: a separate fcxd.yml workflow builds and tests on windows-latest. --- .github/workflows/fcxd.yml | 36 ++++++++++++++ fcxd/build.zig | 34 +++++++++++++ fcxd/src/main_windows.zig | 42 ++++++++++++++++ fcxd/src/windows/keyboard.zig | 76 +++++++++++++++++++++++++++++ fcxd/src/windows/mouse.zig | 92 +++++++++++++++++++++++++++++++++++ fcxd/src/windows/system.zig | 50 +++++++++++++++++++ fcxd/src/windows/win32.zig | 76 +++++++++++++++++++++++++++++ fcxd/test_windows.ps1 | 34 +++++++++++++ 8 files changed, 440 insertions(+) create mode 100644 .github/workflows/fcxd.yml create mode 100644 fcxd/src/main_windows.zig create mode 100644 fcxd/src/windows/keyboard.zig create mode 100644 fcxd/src/windows/mouse.zig create mode 100644 fcxd/src/windows/system.zig create mode 100644 fcxd/src/windows/win32.zig create mode 100644 fcxd/test_windows.ps1 diff --git a/.github/workflows/fcxd.yml b/.github/workflows/fcxd.yml new file mode 100644 index 0000000..ffe0695 --- /dev/null +++ b/.github/workflows/fcxd.yml @@ -0,0 +1,36 @@ +name: fcxd + +on: + push: + pull_request: + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +jobs: + fcxd-windows: + name: fcxd (Windows / Zig) + runs-on: windows-latest + defaults: + run: + working-directory: fcxd + steps: + - uses: actions/checkout@v6 + + - uses: mlugg/setup-zig@v2 + with: + version: "0.16.0" + + - name: Build + run: zig build + + - name: Unit tests + run: zig build test + + - name: Integration smoke test + run: ./test_windows.ps1 + shell: pwsh diff --git a/fcxd/build.zig b/fcxd/build.zig index f0a2fd7..03617cd 100644 --- a/fcxd/build.zig +++ b/fcxd/build.zig @@ -23,6 +23,40 @@ pub fn build(b: *std.Build) void { const is_darwin = target.result.os.tag.isDarwin(); + // Windows: pure-Zig build (no C sources / json-c); link the Win32 libs. + if (target.result.os.tag == .windows) { + const exe_mod = b.createModule(.{ + .root_source_file = b.path("src/main_windows.zig"), + .target = target, + .optimize = optimize, + .link_libc = true, + }); + exe_mod.addIncludePath(b.path("src")); // RequestHandler @cImports fcx_*.h + exe_mod.linkSystemLibrary("user32", .{}); + exe_mod.linkSystemLibrary("advapi32", .{}); + + const exe = b.addExecutable(.{ .name = "FullControlX", .root_module = exe_mod }); + b.installArtifact(exe); + + const run_cmd = b.addRunArtifact(exe); + run_cmd.step.dependOn(b.getInstallStep()); + if (b.args) |args| run_cmd.addArgs(args); + b.step("run", "Run the app").dependOn(&run_cmd.step); + + const tests = b.addTest(.{ .root_module = b.createModule(.{ + .root_source_file = b.path("src/main_windows.zig"), + .target = target, + .optimize = optimize, + .link_libc = true, + }) }); + tests.root_module.addIncludePath(b.path("src")); + tests.root_module.linkSystemLibrary("user32", .{}); + tests.root_module.linkSystemLibrary("advapi32", .{}); + const run_tests = b.addRunArtifact(tests); + b.step("test", "Run unit tests").dependOn(&run_tests.step); + return; + } + // Pinning os_version_min makes the target "non-native", so Zig no longer // auto-injects the macOS SDK for system headers, frameworks and libSystem. Point // each module at the active SDK explicitly. (A global b.sysroot would also rebase diff --git a/fcxd/src/main_windows.zig b/fcxd/src/main_windows.zig new file mode 100644 index 0000000..2d3981d --- /dev/null +++ b/fcxd/src/main_windows.zig @@ -0,0 +1,42 @@ +//! Windows entry point: own stdio (no POSIX fcx_app), Zig Runner, Zig devices. +const std = @import("std"); +const Runner = @import("Runner.zig"); +const mouse = @import("windows/mouse.zig"); +const keyboard = @import("windows/keyboard.zig"); +const w = @import("windows/win32.zig"); + +// Force the platform C-ABI exports (called by RequestHandler) into the binary. +comptime { + _ = mouse; + _ = keyboard; + _ = @import("windows/system.zig"); +} + +fn writeResponse(ctx: *anyopaque, bytes: []const u8) void { + var written: w.DWORD = 0; + _ = w.WriteFile(@ptrCast(ctx), bytes.ptr, @intCast(bytes.len), &written, null); +} + +pub fn main() !void { + const m = mouse.fcx_mouse_create(); + defer mouse.fcx_mouse_free(m); + const kb = keyboard.fcx_keyboard_create("us"); + defer keyboard.fcx_keyboard_free(kb); + + var runner = Runner.init(std.heap.c_allocator, m, kb); + defer runner.deinit(); + + const stdin = w.GetStdHandle(w.STD_INPUT_HANDLE); + const stdout = w.GetStdHandle(w.STD_OUTPUT_HANDLE); + + var buf: [4096]u8 = undefined; + while (true) { + var read: w.DWORD = 0; + if (w.ReadFile(stdin, &buf, buf.len, &read, null) == 0) break; + if (read == 0) break; + runner.handle(buf[0..read], writeResponse, stdout) catch |err| { + std.log.err("runner handle error: {}", .{err}); + std.process.exit(1); + }; + } +} diff --git a/fcxd/src/windows/keyboard.zig b/fcxd/src/windows/keyboard.zig new file mode 100644 index 0000000..428e2e7 --- /dev/null +++ b/fcxd/src/windows/keyboard.zig @@ -0,0 +1,76 @@ +//! fcx_keyboard_* over Win32 SendInput: Unicode text + virtual-key symbols. +const std = @import("std"); +const w = @import("win32.zig"); + +var sentinel: u8 = 0; + +pub export fn fcx_keyboard_create(keymap_name: [*:0]const u8) callconv(.c) ?*anyopaque { + _ = keymap_name; // layout-independent: text is sent as Unicode. + return &sentinel; +} + +pub export fn fcx_keyboard_free(keyboard: ?*anyopaque) callconv(.c) void { + _ = keyboard; +} + +fn sendKey(vk: w.WORD, scan: w.WORD, flags: w.DWORD) void { + var input = w.INPUT{ .type = w.INPUT_KEYBOARD, .u = .{ .ki = .{ + .wVk = vk, + .wScan = scan, + .dwFlags = flags, + .time = 0, + .dwExtraInfo = 0, + } } }; + _ = w.send(&input); +} + +fn typeUnit(unit: u16) void { + sendKey(0, unit, w.KEYEVENTF_UNICODE); + sendKey(0, unit, w.KEYEVENTF_UNICODE | w.KEYEVENTF_KEYUP); +} + +pub export fn fcx_keyboard_type_text(keyboard: ?*anyopaque, text: [*:0]const u8) callconv(.c) c_int { + _ = keyboard; + const s = std.mem.span(text); + const view = std.unicode.Utf8View.init(s) catch return 1; + var it = view.iterator(); + while (it.nextCodepoint()) |cp| { + if (cp <= 0xFFFF) { + typeUnit(@intCast(cp)); + } else { + const v = cp - 0x10000; + typeUnit(@intCast(0xD800 + (v >> 10))); + typeUnit(@intCast(0xDC00 + (v & 0x3FF))); + } + } + return 0; +} + +// Names match the symbols the app sends (ToolsLive). Brightness has no +// SendInput virtual-key on Windows, so it stays unsupported. +fn symbolToVk(name: []const u8) ?w.WORD { + const map = .{ + .{ "left", 0x25 }, .{ "up", 0x26 }, .{ "right", 0x27 }, .{ "down", 0x28 }, + .{ "volumedown", 0xAE }, .{ "volumeup", 0xAF }, .{ "mute", 0xAD }, + .{ "back", 0xB1 }, .{ "playpause", 0xB3 }, .{ "forward", 0xB0 }, + }; + inline for (map) |entry| { + if (std.mem.eql(u8, name, entry[0])) return entry[1]; + } + return null; +} + +pub export fn fcx_keyboard_type_symbol(keyboard: ?*anyopaque, symbol: [*:0]const u8) callconv(.c) c_int { + _ = keyboard; + const vk = symbolToVk(std.mem.span(symbol)) orelse return 1; + sendKey(vk, 0, 0); + sendKey(vk, 0, w.KEYEVENTF_KEYUP); + return 0; +} + +test "symbolToVk maps the app's symbols and rejects unsupported" { + try std.testing.expectEqual(@as(?w.WORD, 0xAF), symbolToVk("volumeup")); + try std.testing.expectEqual(@as(?w.WORD, 0xB3), symbolToVk("playpause")); + try std.testing.expectEqual(@as(?w.WORD, 0x27), symbolToVk("right")); + try std.testing.expectEqual(@as(?w.WORD, null), symbolToVk("brightnessup")); +} diff --git a/fcxd/src/windows/mouse.zig b/fcxd/src/windows/mouse.zig new file mode 100644 index 0000000..7e0376b --- /dev/null +++ b/fcxd/src/windows/mouse.zig @@ -0,0 +1,92 @@ +//! fcx_mouse_* over Win32 SendInput. Returns 0 on success (C contract). +const std = @import("std"); +const w = @import("win32.zig"); + +var sentinel: u8 = 0; + +pub const Location = extern struct { x: c_int, y: c_int }; + +fn mouseEvent(flags: w.DWORD, dx: w.LONG, dy: w.LONG, data: w.DWORD) c_int { + var input = w.INPUT{ .type = w.INPUT_MOUSE, .u = .{ .mi = .{ + .dx = dx, + .dy = dy, + .mouseData = data, + .dwFlags = flags, + .time = 0, + .dwExtraInfo = 0, + } } }; + return if (w.send(&input)) 0 else 1; +} + +pub export fn fcx_mouse_create() callconv(.c) ?*anyopaque { + return &sentinel; +} + +pub export fn fcx_mouse_free(mouse: ?*anyopaque) callconv(.c) void { + _ = mouse; +} + +pub export fn fcx_mouse_location(mouse: ?*anyopaque) callconv(.c) Location { + _ = mouse; + var p: w.POINT = undefined; + _ = w.GetCursorPos(&p); + return .{ .x = p.x, .y = p.y }; +} + +pub export fn fcx_mouse_move(mouse: ?*anyopaque, x: c_int, y: c_int) callconv(.c) c_int { + _ = mouse; + return mouseEvent(w.MOUSEEVENTF_MOVE, x, y, 0); +} + +// Button is already held during a drag, so it's just a relative move. +pub export fn fcx_mouse_drag(mouse: ?*anyopaque, x: c_int, y: c_int) callconv(.c) c_int { + return fcx_mouse_move(mouse, x, y); +} + +pub export fn fcx_mouse_left_down(mouse: ?*anyopaque) callconv(.c) c_int { + _ = mouse; + return mouseEvent(w.MOUSEEVENTF_LEFTDOWN, 0, 0, 0); +} + +pub export fn fcx_mouse_left_up(mouse: ?*anyopaque) callconv(.c) c_int { + _ = mouse; + return mouseEvent(w.MOUSEEVENTF_LEFTUP, 0, 0, 0); +} + +pub export fn fcx_mouse_left_click(mouse: ?*anyopaque) callconv(.c) c_int { + if (fcx_mouse_left_down(mouse) != 0) return 1; + return fcx_mouse_left_up(mouse); +} + +pub export fn fcx_mouse_right_down(mouse: ?*anyopaque) callconv(.c) c_int { + _ = mouse; + return mouseEvent(w.MOUSEEVENTF_RIGHTDOWN, 0, 0, 0); +} + +pub export fn fcx_mouse_right_up(mouse: ?*anyopaque) callconv(.c) c_int { + _ = mouse; + return mouseEvent(w.MOUSEEVENTF_RIGHTUP, 0, 0, 0); +} + +pub export fn fcx_mouse_right_click(mouse: ?*anyopaque) callconv(.c) c_int { + if (fcx_mouse_right_down(mouse) != 0) return 1; + return fcx_mouse_right_up(mouse); +} + +pub export fn fcx_mouse_double_click(mouse: ?*anyopaque) callconv(.c) c_int { + if (fcx_mouse_left_click(mouse) != 0) return 1; + return fcx_mouse_left_click(mouse); +} + +pub export fn fcx_mouse_scroll_wheel(mouse: ?*anyopaque, x: c_int, y: c_int) callconv(.c) c_int { + _ = mouse; + if (y != 0) { + const data: w.DWORD = @bitCast(@as(w.LONG, y) * w.WHEEL_DELTA); + if (mouseEvent(w.MOUSEEVENTF_WHEEL, 0, 0, data) != 0) return 1; + } + if (x != 0) { + const data: w.DWORD = @bitCast(@as(w.LONG, x) * w.WHEEL_DELTA); + if (mouseEvent(w.MOUSEEVENTF_HWHEEL, 0, 0, data) != 0) return 1; + } + return 0; +} diff --git a/fcxd/src/windows/system.zig b/fcxd/src/windows/system.zig new file mode 100644 index 0000000..e64aec0 --- /dev/null +++ b/fcxd/src/windows/system.zig @@ -0,0 +1,50 @@ +//! fcx_system_info: fills the caller's struct from Win32. +const std = @import("std"); +const w = @import("win32.zig"); + +const STR_MAX = 256; + +const SystemInfo = extern struct { + os_version: [STR_MAX]u8, + username: [STR_MAX]u8, + full_user_name: [STR_MAX]u8, + home_directory: [STR_MAX]u8, + hostname: [STR_MAX]u8, +}; + +extern "advapi32" fn GetUserNameA(lpBuffer: [*]u8, pcbBuffer: *w.DWORD) callconv(w.WINAPI) w.BOOL; +extern "kernel32" fn GetComputerNameA(lpBuffer: [*]u8, nSize: *w.DWORD) callconv(w.WINAPI) w.BOOL; +extern "kernel32" fn GetEnvironmentVariableA(lpName: [*:0]const u8, lpBuffer: [*]u8, nSize: w.DWORD) callconv(w.WINAPI) w.DWORD; + +fn setField(field: *[STR_MAX]u8, value: []const u8) void { + const n = @min(value.len, STR_MAX - 1); + @memcpy(field[0..n], value[0..n]); + field[n] = 0; +} + +pub export fn fcx_system_info(info: *SystemInfo) callconv(.c) void { + info.* = std.mem.zeroes(SystemInfo); + + setField(&info.os_version, "Windows"); + + var size: w.DWORD = STR_MAX; + if (GetUserNameA(&info.username, &size) == 0) info.username[0] = 0; + // No cheap portable display name; fall back to the login name. + setField(&info.full_user_name, std.mem.sliceTo(&info.username, 0)); + + size = STR_MAX; + if (GetComputerNameA(&info.hostname, &size) == 0) info.hostname[0] = 0; + + const n = GetEnvironmentVariableA("USERPROFILE", &info.home_directory, STR_MAX); + if (n == 0 or n >= STR_MAX) info.home_directory[0] = 0; +} + +test "setField truncates and NUL-terminates" { + var f: [STR_MAX]u8 = undefined; + setField(&f, "abc"); + try std.testing.expectEqualStrings("abc", std.mem.sliceTo(&f, 0)); + + setField(&f, "x" ** (STR_MAX + 10)); + try std.testing.expectEqual(@as(u8, 0), f[STR_MAX - 1]); + try std.testing.expectEqual(@as(usize, STR_MAX - 1), std.mem.sliceTo(&f, 0).len); +} diff --git a/fcxd/src/windows/win32.zig b/fcxd/src/windows/win32.zig new file mode 100644 index 0000000..c5bf775 --- /dev/null +++ b/fcxd/src/windows/win32.zig @@ -0,0 +1,76 @@ +//! Hand-declared Win32 bindings used by the Windows platform modules. +const std = @import("std"); + +pub const WINAPI = std.builtin.CallingConvention.winapi; +pub const BOOL = c_int; +pub const HANDLE = *anyopaque; +pub const DWORD = u32; +pub const WORD = u16; +pub const LONG = i32; +pub const ULONG_PTR = usize; + +pub const POINT = extern struct { x: LONG, y: LONG }; + +pub const MOUSEINPUT = extern struct { + dx: LONG, + dy: LONG, + mouseData: DWORD, + dwFlags: DWORD, + time: DWORD, + dwExtraInfo: ULONG_PTR, +}; + +pub const KEYBDINPUT = extern struct { + wVk: WORD, + wScan: WORD, + dwFlags: DWORD, + time: DWORD, + dwExtraInfo: ULONG_PTR, +}; + +pub const INPUT = extern struct { + type: DWORD, + u: extern union { + mi: MOUSEINPUT, + ki: KEYBDINPUT, + }, +}; + +pub const INPUT_MOUSE: DWORD = 0; +pub const INPUT_KEYBOARD: DWORD = 1; + +pub const MOUSEEVENTF_MOVE: DWORD = 0x0001; +pub const MOUSEEVENTF_LEFTDOWN: DWORD = 0x0002; +pub const MOUSEEVENTF_LEFTUP: DWORD = 0x0004; +pub const MOUSEEVENTF_RIGHTDOWN: DWORD = 0x0008; +pub const MOUSEEVENTF_RIGHTUP: DWORD = 0x0010; +pub const MOUSEEVENTF_WHEEL: DWORD = 0x0800; +pub const MOUSEEVENTF_HWHEEL: DWORD = 0x1000; +pub const WHEEL_DELTA: LONG = 120; + +pub const KEYEVENTF_KEYUP: DWORD = 0x0002; +pub const KEYEVENTF_UNICODE: DWORD = 0x0004; + +pub const STD_INPUT_HANDLE: DWORD = @bitCast(@as(i32, -10)); +pub const STD_OUTPUT_HANDLE: DWORD = @bitCast(@as(i32, -11)); + +pub extern "user32" fn SendInput(cInputs: DWORD, pInputs: [*]INPUT, cbSize: c_int) callconv(WINAPI) DWORD; +pub extern "user32" fn GetCursorPos(lpPoint: *POINT) callconv(WINAPI) BOOL; +pub extern "kernel32" fn GetStdHandle(nStdHandle: DWORD) callconv(WINAPI) HANDLE; +pub extern "kernel32" fn ReadFile(hFile: HANDLE, lpBuffer: [*]u8, n: DWORD, read: *DWORD, overlapped: ?*anyopaque) callconv(WINAPI) BOOL; +pub extern "kernel32" fn WriteFile(hFile: HANDLE, lpBuffer: [*]const u8, n: DWORD, written: *DWORD, overlapped: ?*anyopaque) callconv(WINAPI) BOOL; + +/// Send a single INPUT event. Returns true if the event was inserted. +pub fn send(input: *INPUT) bool { + return SendInput(1, @ptrCast(input), @sizeOf(INPUT)) == 1; +} + +test "INPUT layout matches the Win32 ABI" { + if (@sizeOf(usize) == 8) { + try std.testing.expectEqual(@as(usize, 32), @sizeOf(MOUSEINPUT)); + try std.testing.expectEqual(@as(usize, 40), @sizeOf(INPUT)); + } else { + try std.testing.expectEqual(@as(usize, 24), @sizeOf(MOUSEINPUT)); + try std.testing.expectEqual(@as(usize, 28), @sizeOf(INPUT)); + } +} diff --git a/fcxd/test_windows.ps1 b/fcxd/test_windows.ps1 new file mode 100644 index 0000000..0afe850 --- /dev/null +++ b/fcxd/test_windows.ps1 @@ -0,0 +1,34 @@ +# Integration smoke test: feed NUL-delimited requests, check the responses. +$ErrorActionPreference = "Stop" +$dir = Split-Path -Parent $MyInvocation.MyCommand.Definition +Set-Location $dir +$exe = "zig-out\bin\FullControlX.exe" +if (-not (Test-Path $exe)) { throw "build first with: zig build" } + +# system_info only: works headless, still exercises multi-request framing. +$requests = @( + '[1,"system_info"]', + '[2,"system_info"]' +) + +$bytes = New-Object System.Collections.Generic.List[byte] +foreach ($r in $requests) { + $bytes.AddRange([System.Text.Encoding]::UTF8.GetBytes($r)) + $bytes.Add(0) # NUL frame terminator +} +[System.IO.File]::WriteAllBytes("$dir\_in.bin", $bytes.ToArray()) + +cmd /c "$exe < _in.bin > _out.bin" + +$out = [System.Text.Encoding]::UTF8.GetString([System.IO.File]::ReadAllBytes("$dir\_out.bin")) +$responses = $out.Split([char]0) | Where-Object { $_ -ne '' } +Remove-Item "$dir\_in.bin", "$dir\_out.bin" -ErrorAction SilentlyContinue + +if ($responses.Count -ne $requests.Count) { + throw "expected $($requests.Count) responses, got $($responses.Count)" +} +foreach ($resp in $responses) { + $obj = $resp | ConvertFrom-Json + if ($null -ne $obj.error) { throw "request $($obj.id) failed: $($obj.error)" } +} +Write-Host "OK: $($responses.Count) responses, no errors" From 2151ff6e14eb740738e38ae7968c5bea9aeff6b0 Mon Sep 17 00:00:00 2001 From: Alessandro Colace Date: Sun, 14 Jun 2026 19:59:00 +0200 Subject: [PATCH 2/2] fix: harden the request loop and complete the test setup From a zig-programming audit of the runner: - a bad request (unknown command / bad params / bad envelope) no longer kills the driver: it gets an error reply and the loop carries on; only OOM stays fatal. - ignore_all is acknowledged with ok, not "command not implemented". - every module's tests now run (the build ran only a few); add Runner happy-path + multi-request framing coverage; CI prints the summary. --- .github/workflows/fcxd.yml | 2 +- fcxd/src/RequestHandler.zig | 4 ++- fcxd/src/Runner.zig | 60 +++++++++++++++++++++++++++++++++++-- fcxd/src/main_windows.zig | 13 ++++++++ 4 files changed, 75 insertions(+), 4 deletions(-) diff --git a/.github/workflows/fcxd.yml b/.github/workflows/fcxd.yml index ffe0695..141e3df 100644 --- a/.github/workflows/fcxd.yml +++ b/.github/workflows/fcxd.yml @@ -29,7 +29,7 @@ jobs: run: zig build - name: Unit tests - run: zig build test + run: zig build test --summary all - name: Integration smoke test run: ./test_windows.ps1 diff --git a/fcxd/src/RequestHandler.zig b/fcxd/src/RequestHandler.zig index 2b1371a..5ef4f1c 100644 --- a/fcxd/src/RequestHandler.zig +++ b/fcxd/src/RequestHandler.zig @@ -60,10 +60,12 @@ pub fn handle(self: *RequestHandler, allocator: std.mem.Allocator, request: Requ // System .system_info => return systemInfo(allocator, id), + // No-op acknowledgement. + .ignore_all => return Response.ok(id), + // Apps — not yet implemented under the zig Runner. .ui_apps, .apps_observe, - .ignore_all, => return Response.failure(id, "command not implemented"), } return Response.ok(id); diff --git a/fcxd/src/Runner.zig b/fcxd/src/Runner.zig index 1cc2ab8..12ddaf5 100644 --- a/fcxd/src/Runner.zig +++ b/fcxd/src/Runner.zig @@ -2,6 +2,7 @@ const std = @import("std"); const JsonParser = @import("JsonParser.zig"); const Request = @import("Request.zig"); const RequestHandler = @import("RequestHandler.zig"); +const Response = @import("Response.zig"); const Runner = @This(); @@ -42,10 +43,65 @@ pub fn handle(self: *Runner, data: []const u8, writeCallback: WriteCallback, ctx const a = self.arena.allocator(); for (try self.parser.parse(a, data)) |value| { - const request = try Request.fromJson(value); - const response = try self.request_handler.handle(a, request); + // A bad request (unknown command, wrong params, bad envelope) must not + // take the driver down: reply with an error and keep going. Only + // allocation failure is fatal. + const response = self.dispatch(a, value) catch |err| switch (err) { + error.OutOfMemory => return err, + else => Response.failure(requestId(value), "invalid request"), + }; const json = try std.json.Stringify.valueAlloc(a, response, .{}); writeCallback(ctx, json); writeCallback(ctx, &terminator); // frame terminator, matching requests } } + +fn dispatch(self: *Runner, a: std.mem.Allocator, value: std.json.Value) !Response { + return self.request_handler.handle(a, try Request.fromJson(value)); +} + +/// Best-effort request id for error replies; 0 when the envelope lacks one. +fn requestId(value: std.json.Value) u32 { + if (value == .array and value.array.items.len > 0 and value.array.items[0] == .integer) { + return std.math.cast(u32, value.array.items[0].integer) orelse 0; + } + return 0; +} + +const Collector = struct { + list: std.ArrayList(u8) = .empty, + allocator: std.mem.Allocator, + fn write(ctx: *anyopaque, bytes: []const u8) void { + const self: *Collector = @ptrCast(@alignCast(ctx)); + self.list.appendSlice(self.allocator, bytes) catch unreachable; + } +}; + +test "a bad request gets an error reply, not a crash" { + var collector = Collector{ .allocator = std.testing.allocator }; + defer collector.list.deinit(std.testing.allocator); + + var runner = Runner.init(std.testing.allocator, null, null); + defer runner.deinit(); + + // Unknown command: fromJson rejects it before any device call. + try runner.handle("[7,\"fly_to_moon\"]\x00", Collector.write, &collector); + + try std.testing.expect(std.mem.indexOf(u8, collector.list.items, "\"id\":7") != null); + try std.testing.expect(std.mem.indexOf(u8, collector.list.items, "\"error\":\"invalid request\"") != null); +} + +test "valid requests get one NUL-framed ok reply each" { + var collector = Collector{ .allocator = std.testing.allocator }; + defer collector.list.deinit(std.testing.allocator); + + var runner = Runner.init(std.testing.allocator, null, null); + defer runner.deinit(); + + // ignore_all is a device-free no-op that replies ok. + try runner.handle("[1,\"ignore_all\"]\x00[2,\"ignore_all\"]\x00", Collector.write, &collector); + + try std.testing.expectEqual(@as(usize, 2), std.mem.count(u8, collector.list.items, "\x00")); + try std.testing.expect(std.mem.indexOf(u8, collector.list.items, "{\"id\":1,\"error\":null,\"payload\":null}") != null); + try std.testing.expect(std.mem.indexOf(u8, collector.list.items, "{\"id\":2,\"error\":null,\"payload\":null}") != null); +} diff --git a/fcxd/src/main_windows.zig b/fcxd/src/main_windows.zig index 2d3981d..a4867f0 100644 --- a/fcxd/src/main_windows.zig +++ b/fcxd/src/main_windows.zig @@ -40,3 +40,16 @@ pub fn main() !void { }; } } + +test { + // Pull every module's tests into `zig build test`. + _ = @import("Runner.zig"); + _ = @import("Request.zig"); + _ = @import("Response.zig"); + _ = @import("JsonParser.zig"); + _ = @import("RequestHandler.zig"); + _ = @import("windows/win32.zig"); + _ = @import("windows/mouse.zig"); + _ = @import("windows/keyboard.zig"); + _ = @import("windows/system.zig"); +}