# GUSender desktop companion

This is the Wails/Go desktop companion from `docs/desktop-companion-plan.md`.

It proves that one Wails/Go process can own:

- a desktop WebView UI,
- a localhost HTTP/WebSocket facade,
- and a fake upstream grblHAL-ish HTTP/WebSocket server.

The diagnostic mode does not depend on the real sender, USB hardware, or a real
controller. It tests the risky integration surface first.

The Wails page also runs a WebGL smoke check:

- create `webgl2`, falling back to `webgl`;
- clear a canvas to a known color;
- read back one pixel and verify nonblank rendering.

## Normal desktop launcher

Launch the app without mode environment variables:

```sh
./build/bin/gusender-desktop
```

The default screen stores machine profiles in the user config directory and can
start either:

- a network controller profile, which proxies HTTP/WebSocket traffic to the real
  controller while the sender talks to localhost;
- a USB serial profile, which starts the serial facade and opens the sender
  against that localhost facade.

For a network controller that already has `gusender-config.json`, enter the
controller IP/address and press `Fetch Config`. The launcher reads the machine
name, WebSocket port, and MQTT fields from that config and fills the profile.

The config path is shown in the launcher. Set `GUSENDER_DESKTOP_CONFIG=/path` if
you need a separate test config file.

When the sender is opened through a USB serial profile, the bottom-right desktop
overlay exposes recovery controls:

- `STOP` sends feed hold plus jog cancel.
- `RESET` sends controller soft reset.
- `CLOSE` closes the USB serial facade.
- `REOPEN` reopens the saved USB serial profile and reloads the sender with the
  new localhost facade port.
- `RESTART` closes, reopens, and reloads in one step.

The sender overlay is collapsed by default. Its compact strip stays visible for
desktop zoom control: `-` zooms out, `+` zooms in, and the percentage button
resets to 100. `Ctrl/Cmd` + `+`, `Ctrl/Cmd` + `-`, and `Ctrl/Cmd` + `0` use the
same controls. The zoom value is stored in `desktop-config.json`.

## CLI check

```sh
go run ./cmd/check
```

Expected result:

- CORS preflight for the Wails page origin passes.
- HTTP fetch through the facade passes.
- Multipart upload through the facade passes.
- WebSocket with `webui-v3` through the facade passes.

## Go tests

```sh
go test ./...
```

## Read-only real controller check

When a FlexiHAL/grblHAL controller is online, run a read-only check through the
same localhost facade shape:

```sh
go run ./cmd/real-target-check -host 192.168.1.112
```

This starts the facade locally, proxies HTTP to `http://192.168.1.112`, proxies
WebSocket to `ws://192.168.1.112:81/`, then sends only:

- HTTP `GET /`
- HTTP `GET /gusender-config.json`
- WebSocket `$I`
- WebSocket `?`

It does not jog, run, reset, hold, upload, or write files.

Protocol notes from the 2026-05-07 FlexiHAL check:

- `-protocol webui-v3` connects and receives `currentID` / `activeID`, but this
  is the WebUI terminal/ownership channel and did not reliably answer raw `$I`
  or `?` after the handshake.
- `-protocol arduino` works for raw status and identity commands.
- `-protocol none` also works for raw status and identity commands.

This means the CLI check is too small to validate WebUI session behavior. It
does not mean the production facade should downgrade FlexiHAL away from
`webui-v3`. For network proxy mode, the facade should honor the sender's
requested subprotocol by default. The `arduino` / no-subprotocol checks are
diagnostic raw-command probes only.

## Wails build

On Linux systems with WebKitGTK 4.1:

```sh
~/go/bin/wails build -s -tags webkit2_41
```

Then run:

```sh
./build/bin/gusender-desktop
```

The Wails window runs the same HTTP/upload/WebSocket checks from JavaScript and
shows pass/fail status. It also shows a WebGL pass/fail row with the reported
vendor/renderer and readback pixel. The window starts maximized, not
fullscreen.

## Real sender embed mode

This mode serves the real `../public` GUSender assets in Wails and starts a
localhost facade to a real controller.

```sh
GUSENDER_DESKTOP_MODE=sender \
GUSENDER_DESKTOP_STDOUT=1 \
GUSENDER_TARGET_HTTP=http://192.168.1.112 \
GUSENDER_TARGET_WS=ws://192.168.1.112:81/ \
./build/bin/gusender-desktop
```

## USB serial sender mode

This mode serves the real `../public` GUSender assets in Wails and points the
sender's WebSocket at the local USB serial facade. The facade allows status,
exact read-only init commands, feed hold, cycle start,
and jog cancel. It also allows only guarded sender-shaped `$J=` jog commands:
`$J=G91 G20|G21 <X/Y/Z/A...> F<feed>`. It blocks non-jog motion, malformed jog
lines, reset, unlock, upload/file-write, and multi-line payloads before serial.

The built binary embeds a copy of `public/`, so this mode works when the binary
is copied to another machine. In the repo checkout it still prefers a local
`public/` directory, or `GUSENDER_PUBLIC_DIR`, so development can test fresh
sender assets without rebuilding the embedded copy.

Serial SD behavior is selected with `GUSENDER_SERIAL_SD_BACKEND`:

- `fake` is the current proof of concept. It serves the sender as
  `sd-mode=http`, answers `$FM`, `$F`, and `$FD=` locally, stores HTTP uploads
  on disk, lists and downloads them over HTTP, and still blocks fake-SD run
  paths. Use `GUSENDER_FAKE_SD_DIR=/path/to/dir` to override the storage
  directory.
- `controller` is for a real controller SD card over USB serial. It serves the
  sender as `sd-mode=ymodem`, passes `$FM`, `$F`, `$FD=`, `$F<=`, `$F=` and
  binary YMODEM packets through to the controller, and does not use the fake SD
  store.
- `disabled` hides the sender SD UI for stream-only machines.

For compatibility, setting `GUSENDER_SERIAL_SD_MODE=http` selects the `fake`
backend, `GUSENDER_SERIAL_SD_MODE=ymodem` selects `controller`, and
`GUSENDER_SERIAL_SD_MODE=disabled` selects `disabled`. The default is now
stream-only/disabled; fake SD is opt-in proof-of-concept behavior.

```sh
GUSENDER_DESKTOP_MODE=serial-sender \
GUSENDER_SERIAL_PORT=/dev/ttyACM0 \
./build/bin/gusender-desktop
```

If `GUSENDER_SERIAL_PORT` is omitted, the desktop app auto-selects the only USB
serial port, or the only serial port if there is exactly one. If multiple ports
are present, set `GUSENDER_SERIAL_PORT` explicitly. Use
`GUSENDER_DESKTOP_STDOUT=1` to print the policy and arbiter audit log to the
terminal while the real sender initializes.

## Production desktop builds

Use the build scripts from the repo root. They first run the normal production
sender build, then sync only the production runtime files into
`frontend/sender-public` for Go embedding. The stale dev `cljs-runtime` files
are deliberately not embedded. Bun is optional; npm works too.

```sh
bun run desktop:linux
```

Without Bun:

```sh
npm install
npm run desktop:linux
```

On macOS, run this on the Mac mini:

```sh
npm install
npm run desktop:macos
```

`bun run desktop:macos` also works if Bun is installed.

The macOS script defaults to `darwin/universal`. Override with
`GUSENDER_DESKTOP_PLATFORM=darwin/arm64` or `darwin/amd64` if needed. Direct
script entry points are also available:

The macOS build script installs the tracked `macos/Info.plist` into Wails'
build assets before packaging. That plist uses the stable bundle id
`com.platformis.gusender.desktop`, declares why local-network access is needed,
and allows local-network HTTP/WebSocket traffic. Unsigned development builds may
still ask for Local Network permission again after rebuilds; final distribution
needs a signing/notarization pass.

```sh
./bin/build-desktop-linux.sh
./bin/build-desktop-macos.sh
```

Useful overrides:

- `GUSENDER_SKIP_SENDER_BUILD=1` — reuse the current `public/js/main.js`
- `WAILS_BIN=/path/to/wails` — use a specific Wails binary
- `GUSENDER_DESKTOP_PLATFORM=...` — override the Wails target platform
- `GUSENDER_DESKTOP_GO_TAGS=...` — override Linux Go build tags

Legacy `GUSENDER_HARNESS_MODE` and `GUSENDER_HARNESS_STDOUT` aliases still work
for older spike commands, but new scripts and docs use `GUSENDER_DESKTOP_*`.

## USB serial mode

This mode is for the first USB spikes. It does not start the sender, does not
run files, and its automatic checks do not jog or reset.

The first check enumerates serial ports, opens the selected port, sends `$I`,
then sends `?`.

The second check starts a localhost WebSocket facade backed by the selected
serial port. The facade keeps a background serial reader running, sends a
WebUI-style `currentID` / `activeID` handshake, echoes `PING:<id>`, forwards
serial frames to the browser, and serializes writes through a narrow command
policy gate. The browser test opens that WebSocket, verifies the WebUI
handshake, sends `$I`, sends `?`, sends repeated `?` polls at sender-like
cadence, sends `G4 P0` as the first harmless manual line, verifies the Go-side
arbiter completed it with `ok`, sends realtime feed hold (`!`), cycle start
(`~`), jog cancel (`0x85`), verifies durable fake SD HTTP upload/list/download,
verifies local `$FM`/`$F` replies, then sends a normal `G1` motion
command and expects the facade to block it before it reaches serial. The facade
policy allows read-only queries (`?`, `$I`, `$I+`, `$G`, `$#`, `$$`, `$N`,
`$EG`, `$ESH`, `$ES`, `$EA`, `$EE`, `$LEV`) plus a capped no-op dwell (`G4 P0`
through `G4 P0.25`), exact realtime control bytes (`!`, `~`, jog cancel
`0x85`), guarded sender-shaped `$J=` jog commands, and local fake-SD `$FM`,
`$F`, and `$FD=`. Non-jog motion, malformed jog lines, unlock, SD run,
controller-side file writes, and multi-line payloads remain blocked and
audited. Soft reset is only exposed through the explicit desktop recovery
control, not the normal sender command stream.

On Linux, the desktop app enriches the serial library's VID/PID output with sysfs
manufacturer/product strings when the device exposes them, so the dropdown can
show names instead of only hex USB IDs.

```sh
GUSENDER_DESKTOP_MODE=serial \
./build/bin/gusender-desktop
```

Use `115200` first. If the port list is empty or open fails on Linux, check that
the user is in the `dialout` group or run a one-off test with the appropriate
device permissions.

The injected wrapper shim:

- fetches the target's real `gusender-config.json` through the facade when
  available, preserving the machine name, MQTT config, probe config, macros, and
  other sender settings;
- overrides only the desktop transport fields to a `127.0.0.1` WebUI-style
  facade machine;
- preloads localStorage with that `127.0.0.1` machine;
- rewrites `fetch` and `XMLHttpRequest` calls from `http://127.0.0.1/...` to
  the dynamic facade HTTP port;
- rewrites `WebSocket` calls from `ws://127.0.0.1:<sender-port>` to the dynamic
  facade WebSocket port;
- preserves the sender-requested WebSocket subprotocol, including `webui-v3`.
- shows a small bottom-right debug overlay with HTTP/WS rewrites and facade
  events;
- caps `window.devicePixelRatio` to `GUSENDER_DESKTOP_DPR` (`1` by default)
  to test whether WebKitGTK high-DPI rendering is causing a choppy toolpath
  view.
- shows a live perf line with `requestAnimationFrame` FPS, timer lag, DPR, and
  the selected Wails Linux GPU policy.

This wrapper mechanism proves the current sender bundle can run
unchanged inside the desktop shell without binding privileged localhost ports.

Useful variants:

```sh
GUSENDER_DESKTOP_MODE=sender GUSENDER_WEBVIEW_GPU_POLICY=ondemand ./build/bin/gusender-desktop
GUSENDER_DESKTOP_MODE=sender GUSENDER_WEBVIEW_GPU_POLICY=never ./build/bin/gusender-desktop
GUSENDER_DESKTOP_MODE=sender GUSENDER_DESKTOP_DPR=2 ./build/bin/gusender-desktop
GUSENDER_DESKTOP_MODE=sender GUSENDER_SENDER_OVERLAY=0 ./build/bin/gusender-desktop
GUSENDER_DESKTOP_MODE=sender GUSENDER_DESKTOP_MQTT_URL=ws://127.0.0.1:3000 ./build/bin/gusender-desktop
```

If the target config cannot be fetched, the app falls back to a minimal
desktop config. In that fallback case MQTT is empty unless supplied by
`GUSENDER_DESKTOP_MQTT_URL`, `GUSENDER_DESKTOP_MQTT_TOPIC_PREFIX`,
`GUSENDER_DESKTOP_MQTT_USER`, and `GUSENDER_DESKTOP_MQTT_PASS`.

The app defaults to Wails `WebviewGpuPolicyAlways`; no environment variable is
needed for the normal path. Wails v2.12 notes that a nil Linux options block
defaults the WebView GPU policy to `never`, which can make the sender unusably
slow at fullscreen on GPU-heavy views. Manual testing confirmed `never` is very
slow and `always` is smooth. Set `GUSENDER_WEBVIEW_GPU_POLICY=ondemand` or
`GUSENDER_WEBVIEW_GPU_POLICY=never` only when comparing GPU behavior.

The Radeon Vulkan ICD prefix that helps Chromium did not change Wails/WebKitGTK
behavior in the 2026-05-07 test, but it can still be tested as a process-level
prefix if a different machine needs it:

```sh
VK_DRIVER_FILES=/usr/share/vulkan/icd.d/radeon_icd.x86_64.json \
GUSENDER_DESKTOP_MODE=sender \
GUSENDER_TARGET_HTTP=http://192.168.1.112 \
GUSENDER_TARGET_WS=ws://192.168.1.112:81/ \
./build/bin/gusender-desktop
```
