Platform support¶
Per-platform support status and behavior, moved from the root AGENTS.md (which keeps a summary and the portable-code rules).
Panio runs on three host platforms. (Network RFB video sources are platform-independent Rust and build and test on all three in CI; the JetKVM-through-RFB path was hardware-verified from a macOS control host only.)
| Platform | Status | CI | Release artifacts |
|---|---|---|---|
| macOS (Apple Silicon) | Supported | macos job in ci.yml |
Homebrew tap (universal tarball, poured as an all bottle) |
| Linux (Debian/Ubuntu) | Supported | the ubuntu-latest jobs in ci.yml |
.deb, tarball and Homebrew bottle from release.yml |
| Windows (x86_64-msvc) | Supported; power and video hardware-verified | windows job in ci.yml |
portable zip + winget |
All ten crates build, lint clean under clippy -D warnings, and pass their unit
tests on all three. The windows CI job runs the same fmt/clippy/test triple as
the Linux jobs, since a Unix-only CI cannot tell whether the Windows #[cfg] arm
still compiles.
Hardware-verified on Windows (2026-08-28, a Shelly Plug and an Openterface KVM-GO):
- power —
shellyplugon/off/cycle, each confirmed by read-back. - video —
hdmicapenumerates the capture device by symbolic-link id and captures native 3840x2160 through the Media Foundation backend; daemon start,shotandstopwork. - hid —
ch9329over a COM port reportstarget_connected=true, and its mouse moves wake the attached machine. Keystroke delivery is confirmed by that wake, not by the CH9329 LED read-back, which stayedfalse.
Not verified on Windows:
- netbootd falls back to
send_to: the/dev/bpfraw-frame sender andSCM_RIGHTSfd handoff are Unix-only, as on Linux. DHCP/TFTP netboot against a real target is untested, andnetifhas no Windows implementation. - hidrig has no console bridge. The PTY the
serialchannel'sdevice =points at has no Windows analogue (ConPTY exposes no filesystem node another process can open). HID and control are unaffected. - OCR is absent.
Windows.Media.Ocris the in-box candidate, and thewindowscrate is already a hdmicap dependency. panio doctorcannot probe a Windows control host. Probes run natively locally, but the SSH path renders them as POSIX shell (sh -c 'test -e …'), which PowerShell cannot run. It is the one verb that speaks shell over the wire rather than pure panio; the fix is to have the remote side runpanio doctor(not done).
Dispatching to a Windows control host¶
panio <cmd> -t <target> works when the target's host is Windows. Dispatch
ships a lab slice, re-execs panio against it, and cleans the slice up; only the
re-exec was ever shell-safe, which matters wherever the remote shell is not ours to
choose.
- Shipping and cleanup go over SFTP (
ssh::sftp_put/ssh::sftp_rm), not shell. The oldmktemp … && cat > "$f"is not a command in PowerShell, andrm -ffails there ("parameter 'f' is ambiguous"). SFTP behaves identically whatever the far side runs and reuses the session's ControlMaster. - The re-exec is fine as is.
remote_commandquotes arguments POSIX-style, and PowerShell reads'C:\Users\x\lab.toml'as the same literal. TheVAR='v' cmdenv-prefix form is not portable, but dispatch passes no env. - The slice path is deliberately relative (
.panio-lab-<pid>-<ns>.toml). SFTP reports a Windows home as/C:/Users/name, which no native Windows program can open, so an absolute path frompwdis unusable as--lab. Both platforms start an SSH session in the home directory (SFTP's default), so a relative name resolves on both.
Verified end to end against the Windows bench host: hid send … info returned real
CH9329 state, hid send … move drove the KVM, video show read the channel, and
the slice was cleaned up. Unix dispatch is unchanged.
Writing portable code here¶
The POSIX primitives panio depends on live behind cli/src/platform.rs, one
implementation per platform with a documented contract each: current_uid,
runtime_root, ensure_private_dir, make_executable, pid_alive, signal_pid /
try_signal_pid / terminate_pid, is_superuser, detach, exec_replace.
Use those rather than libc or std::os::unix at a call site, so #[cfg]
stays out of the rest of the code.
A trimmed copy of the module is duplicated into hdmicap, serialcap, ch9329 and
hidrig (standalone cargo projects with no crate between them, like daemon.rs).
Keep the five copies in sync.
- Windows has no signals.
Signal::TermandSignal::Killboth land onTerminateProcess, so a Windows daemon never runs graceful shutdown (discovery-file removal, the 300 ms grace) and may leave a stale discovery file for the nextpid_aliveprobe to reap. - Windows has no uid. The runtime namespace uses an FNV hash of
%USERNAME%: stable per user, meaningless on its own, never used for authorization. The 0700-plus-ownership check on the runtime dir becomes "exists and is a directory", because the path is inside the user's profile, whose inherited ACL already excludes other non-admin users.
Testing the platform split¶
A #[cfg] that compiles is not one that works. The first Windows doctor depended
on sh -c (absent on Windows) with CI green, because its tests asserted on the
text of the generated shell script; a string-shape test passes identically on
every platform and never shows whether a command can launch.
Anything that spawns a process, touches the filesystem, or resolves a name gets a
test that executes it. Examples: cli/src/platform.rs (pid_alive against a real
child, shell_command running a command and reading its exit code,
ensure_private_dir creating then revalidating), cli/src/doctor.rs (every
Probe variant run natively via run_local), cli/src/daemons.rs (find_binary
resolving a bare name through $PATH).
Two bugs found that way:
- Non-positive pids were unguarded on Unix.
kill()reads 0 as "every process in my group" and -1 as "every process I may signal", so a zero or corrupt pid in a discovery file madepid_aliveanswer running and would have madesignal_pid(.., Kill)kill panio and its launching shell.is_real_pidnow guards every entry point in all five copies, with a test in each. - Opaque lab-file commands ran through
sh -c. Power hooks and the hidcmdare user-written strings that need a shell;platform::shell_commandsuppliesshon Unix andcmd.exeon Windows.
doctor probes are a Probe enum with two views, run_local() (native) and
to_posix() (the POSIX script for the SSH hop to a Unix host), so the local and
remote paths cannot drift. The exception is Probe::HelperDoctor (a channel's
opt-in helper doctor check): it runs a lab-file command, so locally it goes through
platform::shell_command like the hook itself.
Windows packaging¶
The Windows artifact is a portable zip, not an MSI: panio is a CLI plus nine
helpers, so the analogue of the Homebrew tap and .deb is a tarball of binaries.
The layout is flat, since Windows has no bin/libexec split; the exe's own directory
is the install prefix:
panio\
panio.exe <- the only thing that goes on PATH
libexec\
hdmicap.exe serialcap.exe netbootd.exe hidrig.exe … winocr.exe
share\panio\skills\
panio\SKILL.md kvm-puppeting\SKILL.md control-host\SKILL.md
daemons::exe_relative_dirs adds <exe dir>\libexec on Windows, and
daemons::find_binary tries <name>.exe before the bare name.
skills::skills_dirs_for_exe adds <exe dir>\share\panio\skills. (Through v0.3.0
the zip staged only exes and the CLI looked one level up, so panio skill listed
nothing; the first /release-train dry run caught it.) package-windows stages the
skills and smoke-tests the expanded zip: --help, panio skill from outside the
tree, every helper's --help, winocr decoding a tiny PNG. panio setup on
Windows only verifies the helper layout: no setuid bit, no dialout group, no OCR
helper to build.
Signing reuses the Azure Artifact Signing setup (same account, OIDC identity and action) from another project, with three differences: exes are signed before zipping (a zip carries no Authenticode signature, and SmartScreen judges the exe); it is ten files rather than three (one glob, no Burn engine detach/reattach); and the RFC3161 timestamp is asserted in a verify step, because Artifact Signing certificates live 72 hours and an untimestamped signature dies in the field three days after release.
release.yml has package-windows (build → sign → verify → zip) and a winget
job. Both no-op until credentials exist: signing skips while
vars.AZURE_SIGNING_ACCOUNT is unset, winget while secrets.WINGET_TOKEN is.
The first winget version must be submitted by hand (wingetcreate or komac);
the action only updates an existing package.
Developing against Windows¶
The Windows bench host (lab-host-1 in examples; its real name, address and
checkout path are private and live outside this repo) is reached over SSH, which
lands in PowerShell 7. $HOME there is the Windows account's C:\Users\…, a
different account name than on the Mac, and the repo is cloned under it.
BRIK_HOST=<ssh host> BRIK_DEST=<checkout, forward slashes>
scripts/sync-brik.sh <crate>… pushes sources there; both variables are required,
with no defaults, and the script excludes target/ (gigabytes; stalls the
transfer). The MSVC linker resolves without a Developer Prompt.
A local cargo check --target x86_64-pc-windows-msvc catches most #[cfg]
mistakes without leaving macOS, but only for crates with no C dependency:
anything pulling ring (anything with TLS) fails at its build script for want of
Windows headers. Those need the real host.
Per-subsystem behavior:
- OCR backend.
- macOS: Apple Vision (
visionocr.swift, compiled bypanio setup). - Linux: Tesseract (
ocr/linuxocr, copied bypanio setup; needs thetesseract-ocrpackage). - Windows: none. A port would supply a third binary behind the same stdin-PNG →
stdout-text interface, exposed via
PANIO_VISIONOCR. - Netboot privileges.
- macOS: 14+ allows DHCP (67) and TFTP (69) rootless;
sudoonly for interface config (ifconfig). - Linux: both ports need root, so
panio netboot startprependssudowhen spawningnetbootd, andip addr adduses sudo. netbootd drops to the invoking user (SUDO_UID/SUDO_GID) once its listeners are bound, so its latersudo ip neigh/sudo ip addrcalls run unprivileged. Transparent with passwordless sudoers; otherwise sudo prompts (and fails inside the daemon). - Windows: no privileged ports, so
netbootdis spawned directly, nosudo. - Interface management (
cli/src/netif.rs,netif::configure_interface()/restore_interface()). - macOS:
networksetup -setmanual <service> <ip> <mask> <router>(router = the host IP; the four-argument form is required) +ifconfig, which makes the address live; anetworksetupfailure is a warning. - Linux: iproute2:
ip addr replace, thenip addr delof any other IPv4 address (fromip -4 -o addr show dev <iface>), thenip link set up; a failed assignment never leaves the link addressless.restore_interfaceflushes withip addr flush dev <iface>. - Windows: not implemented.
- Every mode change (
mode_*,down_hard) andnetboot::startrefuse the interface carrying the default route (is_primary_interface; pure decisionis_default_route_interface, unit-tested). - ARP pinning (netbootd): macOS
arp -s; Linuxip neigh replace ... nud permanent; Windows not implemented. - Raw-frame sender (netbootd).
- macOS: BPF over
/dev/bpf*; netbootd gets the descriptor from the setuidnetbootd-bpf-helperand stays unprivileged (see the netbootd section). - Linux: no BPF; falls back to
sendto()with retry. - Windows: not implemented.
- Interface listing (
list_usb_ethernet_interfaces()): macOSnetworksetup; Linux sysfs/sys/class/net/(type, carrier); Windows not implemented. - Serial device paths (
serial::list_devices(),cli/src/serial.rs). - macOS: scans
/devfortty.usbserial-*andtty.usbmodem*. - Linux: one entry per physical port, named by its stable
/dev/serialsymlink:by-idpreferred (names the adapter),by-pathas fallback (the only stable name for adapters without a serial number), chosen bypreferred_alias(); raw/dev/ttyUSB*/ttyACM*only if no symlinks exist. Store a stable symlink path in target configs to survive re-enumeration. The serialcap--interfaceparser accepts these paths; colons in a by-path name are not mistaken for the:SENSEsuffix because onlycts,dsr,dcd,ricount as sense names. - Windows:
serialport::available_ports()returnsCOM<n>. No by-path analogue, so a lab file pinned toCOM7is less stable than a Linux by-id path; the number can move on re-enumeration. - hdmicap capture + build deps.
- macOS: our AVFoundation layer (
hdmicap/src/capture_avf.m); no packages beyond the Xcode toolchain. - Linux: V4L2 with a raw-MJPEG tee. Building needs
build-essential pkg-config libclang-dev clang(V4L2 bindgen viav4l2-sys-mit) pluscmake nasm(theturbojpegcrate builds a vendored libjpeg-turbo: Debian's libturbojpeg is too old for its pkg-config path, and itsrequire-simddefault makes nasm mandatory on x86-64).make installfails early with a hint if any are missing (check-depsin the Makefile);panio setupprints a reminder.CaptureStream::nextreturns the whole mmap'd buffer, sized once at the format's worst-casesizeimage(for MJPG the uncompressed frame, 4,147,200 bytes at 1080p), soframe()must trim it to theMetadata.bytesusedthe driver filled. Skipping the trim is invisible to consumers (JPEG decoders stop at EOI) but ships a 216 KB frame inside a 4.1 MB part, 94.8% zeros. - Windows: our Media Foundation layer (
hdmicap/src/capture_mf.rs), delivering NV12 so it reuses the macOS pixel path. It enumerates the device's native media types and selects one explicitly (as with AVFoundation), which is why an Openterface captures at 3840x2160 instead of a rescaled 1080p. MJPEG-only devices are not supported yet. Samples can pad rows to a stride wider than the visible width, soframe()takes the real pitch fromIMF2DBuffer::Lock2Dwhen available, else the media type'sMF_MT_DEFAULT_STRIDE(read once inopen()), elsewidth;pixel::compact_nv12then strips the padding into tight Y/CbCr planes before anything indexes bywidth. A negative pitch (bottom-up) is explicitly unsupported.