Skip to content
Open
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
36 changes: 36 additions & 0 deletions .github/workflows/fcxd.yml
Original file line number Diff line number Diff line change
@@ -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 --summary all

- name: Integration smoke test
run: ./test_windows.ps1
shell: pwsh
34 changes: 34 additions & 0 deletions fcxd/build.zig
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 3 additions & 1 deletion fcxd/src/RequestHandler.zig
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
60 changes: 58 additions & 2 deletions fcxd/src/Runner.zig
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand Down Expand Up @@ -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);
}
55 changes: 55 additions & 0 deletions fcxd/src/main_windows.zig
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
//! 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);
};
}
}

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");
}
76 changes: 76 additions & 0 deletions fcxd/src/windows/keyboard.zig
Original file line number Diff line number Diff line change
@@ -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"));
}
92 changes: 92 additions & 0 deletions fcxd/src/windows/mouse.zig
Original file line number Diff line number Diff line change
@@ -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;
}
Loading