Skip to content

Migrate to Tauri v2 (full Rust rewrite) with self-update#31

Merged
statik merged 18 commits into
mainfrom
claude/tauri-migration-self-update-74d57g
Jun 21, 2026
Merged

Migrate to Tauri v2 (full Rust rewrite) with self-update#31
statik merged 18 commits into
mainfrom
claude/tauri-migration-self-update-74d57g

Conversation

@statik

@statik statik commented Jun 21, 2026

Copy link
Copy Markdown
Member

Ports hyperdeck-adapter from the Go tray daemon to a Tauri v2 desktop app over a full Rust rewrite, adding in-app self-update — modeled on kindlyops/cuesheet. This is the integration PR that lands the whole migration (steps #13#30) on main, retiring the Go app there.

What changes

  • crates/hyperdeck-core — OS-independent domain, ports (traits), application services (Session / VirtualDeck / LockManager / Reconciler), the HyperDeck protocol parser + responder + TCP server, profile/selection config, clip sources, and state probes. The Go _test.go suites are ported as Rust unit tests (71 tests).
  • crates/hyperdeck-os — per-OS driven adapters behind the core traits: keystroke injection + window enumeration (macOS CoreGraphics/AppKit, Windows WinAPI), Windows UI Automation, the VLC HTTP controller, and the injcheck on-device diagnostic.
  • src-tauri — the tray-only Tauri app: wires the core to the OS adapters, owns the tray menu (Status · Profile · Re-home · Check for Updates… · Quit), runs as a macOS menu-bar agent, and provides self-update via tauri-plugin-updater.
  • CI/releaseci.yml runs rustfmt + a clippy/test matrix (ubuntu/macos/windows) + a Tauri build job (macos/windows); release.yml mirrors cuesheet's tauri-action pipeline (macOS universal + Windows, updater-artifact + Apple/Windows signing guards, draft release).
  • Cutover — removes cmd/, internal/, go.mod/go.sum, and the Go-era build/ packaging; rewrites justfile and README.md for the Rust/Tauri toolchain.

The TCP protocol (:9993) and scripts/hyperdeck-demo.py are unchanged, so existing Companion/ATEM controllers keep working. The site/ landing page and its release-asset resolution are unaffected.

Self-update / signing — maintainer action required

Per the security constraint for this work, no keys or certificates were generated. plugins.updater.pubkey is committed empty and the release workflow auto-disables updater artifacts when no signing key is present, so builds succeed unsigned. The in-app updater will not verify until the maintainer adds the keys/secrets — full instructions are in the maintainer key-setup issue (#12); on-device test plans are in #10 (macOS) and #11 (Windows).

Verification

🤖 Generated with Claude Code


Generated by Claude Code

statik and others added 18 commits June 21, 2026 09:15
Begin the Tauri/Rust migration with the pure, OS-independent core. This
adds a Cargo workspace and the `hyperdeck-core` crate, porting the Go
`internal/core/domain` package 1:1:

- Chord parser (parse_chord) with case-insensitive modifiers
- Profile / Match / Keymap / ControlMode / InjectionMode and the
  uses_controller, has_action, matches_window methods
- TransportState (+ HyperDeck status mapping), Clip/ClipList, Window/LockState

All Go domain unit tests are ported as Rust #[test]s (7 tests, green).
A `rust` CI job (fmt + clippy -D warnings + test) runs alongside the
existing Go job so both stay green during the migration.


Claude-Session: https://claude.ai/code/session_01R8X1NxFiaLGJzwUD5wgRTv

Co-authored-by: Claude <noreply@anthropic.com>
Port the driving adapter's pure logic from
internal/adapter/driving/hyperdeck and the inbound port interfaces:

- error::DeckError (port of app.ErrNotLocked + injector/other variants)
- port::{Transport, Query} inbound traits
- protocol::parse_command (single- and multi-line forms; never panics)
- protocol::Responder over a deck implementing both inbound ports,
  including relative/absolute goto and the \r\n response formatting

Go parser + responder tests ported as Rust #[test]s, with a property-
style "never panics" smoke test mirroring the rapid check. 18 tests green
(fmt + clippy -D warnings clean).


Claude-Session: https://claude.ai/code/session_01R8X1NxFiaLGJzwUD5wgRTv

Co-authored-by: Claude <noreply@anthropic.com>
Port internal/core/app session + virtual deck plus the driven (outbound)
port traits they need:

- port::{KeyInjector, WindowEnumerator, PlayerController, ClipSource,
  StateProbe, StatusPresenter}
- app::Session: mutex-guarded shared state; clip source / probe held as
  Arc so the reconciler can call them without holding the lock
- app::VirtualDeck: Transport + Query impls with the play/stop toggle vs
  discrete-stop logic, cue-on-navigate, relative/clamped goto, rehome,
  and out-of-band controller dispatch
- testsupport::MockInjector (port of injector.Mock) shared by unit tests

All Go session + virtualdeck + hardening transport tests ported as Rust
#[test]s. 37 tests green (fmt + clippy -D warnings clean).


Claude-Session: https://claude.ai/code/session_01R8X1NxFiaLGJzwUD5wgRTv

Co-authored-by: Claude <noreply@anthropic.com>
Port the remaining application services from internal/core/app:

- app::LockManager: poll/lock/unlock by profile matching, the pinned-
  profile filter (set_active / Auto), and presenter notifications
- app::Reconciler: per-tick clip-list refresh + probe-driven state
  correction, no-op when unlocked
- testsupport gains FakePresenter, FakeClipSource, NoProbe, PlayingProbe

The clock-driven Run loops are intentionally deferred to the async
runtime wiring; this ports the pure poll()/tick() logic. All Go
lockmanager + reconciler + relock tests ported. 44 tests green.


Claude-Session: https://claude.ai/code/session_01R8X1NxFiaLGJzwUD5wgRTv

Co-authored-by: Claude <noreply@anthropic.com>
Port the remaining pure driven adapters into hyperdeck-core:

- config::Store (serde_norway YAML loader + full validation), config::
  SelectionStore (serde_json pinned-profile persistence), config::
  ensure_default (embeds examples/profiles.yaml as the first-run seed)
- clipsource: Positional, Mitti (positional fallback), Playlist (.m3u +
  .xspf via quick-xml serde), and the type-driven factory
- stateprobe: TitleRegex, NoneProbe, and Uia behind a pure ElementNamer
  trait (Windows namer supplied later by the OS crate), plus the factory
- port::ProfileStore; domain::KeyName::parse (unmodeled actions like
  Mitti's "pause" are validated then dropped, matching Go's open KeyName)

New deps: serde, serde_json, serde_norway (maintained serde_yaml fork),
quick-xml. All Go config/clipsource/stateprobe tests ported, including
loading testdata fixtures and the embedded default seed. 70 tests green.


Claude-Session: https://claude.ai/code/session_01R8X1NxFiaLGJzwUD5wgRTv

Co-authored-by: Claude <noreply@anthropic.com>
Split rustfmt into its own job and run the Rust clippy + test job across
ubuntu, macOS, and Windows. This proves the pure core compiles and passes
on every target platform and gates the OS-specific adapters (injection,
window enumeration, UIA) that land next behind cfg(target_os).


Claude-Session: https://claude.ai/code/session_01R8X1NxFiaLGJzwUD5wgRTv

Co-authored-by: Claude <noreply@anthropic.com>
Introduce the hyperdeck-os crate that will host the OS- and player-specific
driven adapters (injection, window enumeration, UIA) which need external or
platform crates and so live outside the pure core.

First adapter: vlchttp::VlcController (port of internal/adapter/driven/
vlchttp), a PlayerController for api.type "vlc_http" that drives VLC's HTTP
requests interface (pl_play/pl_stop/pl_next/pl_previous) with empty-user
Basic auth and a 4s timeout. Uses ureq (default-features off: plain HTTP,
no TLS/system deps) so it compiles cleanly on the Linux/macOS/Windows CI
matrix.

Ported all Go vlchttp tests against an in-process TCP stub server (mapped
commands + Basic auth, unmapped-key no-op, 401/500 errors, empty base url).
5 tests green; workspace 75 total.


Claude-Session: https://claude.ai/code/session_01R8X1NxFiaLGJzwUD5wgRTv

Co-authored-by: Claude <noreply@anthropic.com>
Port injector_windows.go + keymap_windows.go into hyperdeck-os behind
cfg(windows), with a no-op fallback elsewhere:

- injector::keymap_windows: pure VK-code / modifier / extended-key mapping
  (unit-tested)
- injector::windows_impl::WinInput (windows crate): Focus (SetForegroundWindow
  with the AttachThreadInput + foreground-lock-timeout dance), SendKeys
  (SendInput when the target is foreground, PostMessage WM_KEYDOWN/UP when
  background), and OpenWindows (EnumWindows + Toolhelp pid->exe names)
- injector::{injector, enumerator, request_accessibility} composition entry
  points; non-Windows returns a no-op injector/enumerator (macOS impl follows)

Adds the windows crate as a cfg(windows)-only dependency. Verified locally
with `cargo check/clippy --target x86_64-pc-windows-gnu` (real FFI) and the
apple-darwin + linux targets (no-op path); workspace fmt/clippy/tests green.


Claude-Session: https://claude.ai/code/session_01R8X1NxFiaLGJzwUD5wgRTv

Co-authored-by: Claude <noreply@anthropic.com>
Port injector_darwin.go + keymap_darwin.go + cinject_darwin.m into
hyperdeck-os behind cfg(target_os = "macos"):

- injector::keymap_darwin: pure kVK key codes + CGEventFlags modifier
  masks (unit-tested)
- injector::macos_impl::MacInjector: Focus (NSRunningApplication activate
  by pid), SendKeys (CGEvent keyboard events posted via CGEventPostToPid
  for background, focus-free injection), OpenWindows (CGWindowList info
  dictionaries -> pid/owner/title)
- request_accessibility() via AXIsProcessTrustedWithOptions (prompting),
  wired into the injector module's macOS dispatch

Uses core-graphics (elcapitan feature for post_to_pid), core-foundation,
and objc2-app-kit (libc feature). Verified locally with
`cargo check/clippy --target aarch64-apple-darwin` plus the windows-gnu
and linux targets; all three clippy-clean, workspace tests green.


Claude-Session: https://claude.ai/code/session_01R8X1NxFiaLGJzwUD5wgRTv

Co-authored-by: Claude <noreply@anthropic.com>
Port internal/adapter/driven/uia into hyperdeck-os behind cfg(windows),
using the windows crate's typed COM interfaces instead of raw vtable
dispatch:

- uia::Engine owns an MTA COM apartment + IUIAutomation on a dedicated
  worker thread, serving requests serially over a channel (COM thread
  affinity), so it is Send + Sync for use as a shared adapter
- implements PlayerController (Invoke the AutomationId-mapped control for
  control: uia profiles) and ElementNamer (read an element's Name for the
  UIA state probe); missing controls are an acked no-op / not-detected
- ElementFromHandle + CreatePropertyCondition(AutomationId) + FindFirst
  (descendants) + GetCurrentPatternAs(Invoke) / CurrentName

Adds windows features Win32_UI_Accessibility, Win32_System_Com,
Win32_System_Variant. Verified with cargo check/clippy --target
x86_64-pc-windows-gnu (real COM) + apple-darwin/linux; all clippy-clean.

With this, every Go driven adapter is ported to Rust.


Claude-Session: https://claude.ai/code/session_01R8X1NxFiaLGJzwUD5wgRTv

Co-authored-by: Claude <noreply@anthropic.com>
* feat(core): port the HyperDeck TCP server

Port internal/adapter/driving/hyperdeck/server.go: protocol::Server
accepts controller connections on a TcpListener, greets with the 500
connection-info banner, reads single- and block-form command blocks,
dispatches them through the Responder, and closes on quit. Each
connection is handled on its own thread over a shared Arc<Deck>.

Adds blanket Transport/Query impls for Arc<T> so the shared deck
satisfies the inbound ports with cheap per-connection clones.

Ported the Go end-to-end server test (TcpListener on 127.0.0.1:0 ->
greeting -> "play" -> "200 ok" + recorded keystroke via the mock
injector). 71 core tests green; this completes the protocol runtime.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01R8X1NxFiaLGJzwUD5wgRTv

* style: apply rustfmt 1.9 (stable) formatting to server test

CI's newer stable rustfmt expands struct literals in calls; align the
committed formatting with it.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01R8X1NxFiaLGJzwUD5wgRTv

---------

Co-authored-by: Claude <noreply@anthropic.com>
* feat(tauri): scaffold tray-only Tauri shell with self-update

Add the src-tauri Tauri v2 application (its own workspace, excluded from
the library workspace so the Linux CI never needs webkit):

- tauri.conf.json: tray-only (no windows), bundle targets dmg/app/nsis/msi,
  createUpdaterArtifacts, and the updater plugin pointed at the GitHub
  releases latest.json endpoint. pubkey is intentionally empty (TODO: the
  maintainer fills in their minisign public key per issue #12).
- minimal main.rs: registers the updater/process/dialog plugins, sets the
  macOS Accessory activation policy (menu-bar agent, no Dock icon), and
  builds a tray menu with "Check for Updates…" (download+install+relaunch)
  and Quit. Core wiring (server/lock/reconcile/profile menu) lands next.
- generated desktop icon set, a minimal frontend stub (ui/), capabilities,
  and a committed Cargo.lock.
- CI: new `tauri` job builds + clippies + fmt-checks the app on macOS and
  Windows (where the webview prerequisites ship with the runner).

The Tauri app cannot be built in the Linux dev sandbox (no webkit); it is
verified by the macOS/Windows CI job and, at release time, tauri-action.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01R8X1NxFiaLGJzwUD5wgRTv

* fix(tauri): add serde_json dep for generate_context!, drop unused import

tauri::generate_context! expands to code referencing serde_json in the
app crate root, so it must be a direct dependency. Also remove the unused
tauri::Manager import (clippy -D warnings).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01R8X1NxFiaLGJzwUD5wgRTv

---------

Co-authored-by: Claude <noreply@anthropic.com>
…25)

Rewrite release.yml to build and publish the Tauri app via
tauri-apps/tauri-action@v0.6.2 on tag v* / manual dispatch, matching the
cuesheet pattern:

- matrix: macOS universal-apple-darwin + Windows (no Linux, like cuesheet)
- projectPath src-tauri; tag v__VERSION__, draft release
  "HyperDeck Adapter v__VERSION__"
- TAURI_SIGNING_PRIVATE_KEY(+_PASSWORD) at job env; updater artifacts are
  auto-disabled when the signing key is absent (so unsigned builds still
  succeed)
- Apple Developer ID cert import + "Configure Apple signing" guard (avoids
  the empty-identity failure); Windows Authenticode cert import ->
  thumbprint passed via --config bundle.windows.certificateThumbprint

tauri-action generates latest.json + signed updater bundles once the
signing key (issue #12) is configured. Triggers only on tags, so this is
inert until a release is cut.


Claude-Session: https://claude.ai/code/session_01R8X1NxFiaLGJzwUD5wgRTv

Co-authored-by: Claude <noreply@anthropic.com>
* feat(tauri): wire the HyperDeck core into the app

Connect the tray shell to the ported core (analog of cmd/hyperdeck-adapter/
main.go):

- backend.rs composition root: loads profiles.yaml (seeding the default on
  first run) + selection.json, builds the injector/enumerator, a control
  router (api -> VLC HTTP, uia -> UI Automation on Windows), Session,
  VirtualDeck, LockManager, Reconciler; binds the HyperDeck TCP server on
  0.0.0.0:9993 and spawns server / poll / reconcile threads; applies the
  persisted profile selection on startup
- main.rs: tray menu gains Re-home (VirtualDeck::rehome) alongside Check
  for Updates + Quit; a TrayPresenter reflects lock status in the tray
  tooltip; --check-accessibility and --no-tray/--headless flags (parity
  with the Go CLI) with a LogPresenter for headless runs

The adapter is now functional in Auto mode; the tray profile submenu for
pinning a profile lands next. Adds the dirs dependency for the OS config
path. Verified via cargo fmt; compiled by the macOS/Windows tauri CI job.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01R8X1NxFiaLGJzwUD5wgRTv

* fix(tauri): drop unused tauri::Manager import

tray_by_id / handle / default_window_icon are inherent on App/AppHandle in
Tauri 2; the Manager import is unused under clippy -D warnings.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01R8X1NxFiaLGJzwUD5wgRTv

---------

Co-authored-by: Claude <noreply@anthropic.com>
The full Rust rewrite (hyperdeck-core + hyperdeck-os + src-tauri) reaches
parity with the Go daemon, so retire the Go tree and its packaging:

- Delete cmd/, internal/, go.mod, go.sum, and build/ (NSIS/Info.plist/
  signing scripts/nfpm/gen_appicon) — superseded by the Tauri bundler and
  tauri-action.
- ci.yml: drop the Go test job; keep rustfmt, the rust matrix, and the
  tauri job.
- justfile: rewrite recipes for cargo/tauri (test/fmt/build/run/serve/demo/
  trust).
- README.md: rewrite for the Rust/Tauri architecture, install, dev, config,
  and releasing (with the maintainer key-setup note).
- .gitignore: drop Go-only entries.


Claude-Session: https://claude.ai/code/session_01R8X1NxFiaLGJzwUD5wgRTv

Co-authored-by: Claude <noreply@anthropic.com>
Replicate the Go tray's Profile menu in the Tauri tray:

- A "Profile" submenu with "Auto (match any)" plus one checkbox per profile
  id; the checkmark starts on the persisted selection (Auto when none or the
  pinned id no longer names a loaded profile).
- Selecting an entry persists the id via SelectionStore, re-points the
  LockManager (which re-polls immediately so the lock reflects the choice),
  and moves the checkmark to the chosen entry.
- Port the Go `checkedProfile` helper verbatim, with its unit tests.

backend.rs now consumes lock_manager/selection/profile_ids/active, so drop
the placeholder #[allow(dead_code)] on Backend.


Claude-Session: https://claude.ai/code/session_01R8X1NxFiaLGJzwUD5wgRTv

Co-authored-by: Claude <noreply@anthropic.com>
Complete tray parity with the Go app's status display:

- Add a disabled status line at the top of the tray menu ("Locked: <id>
  (<title>)" / "Disconnected — no player"); the presenter updates its text
  from the poll thread via a shared handle populated when the tray is built.
- The TrayPresenter now also sets a menu-bar lock indicator (HD● / HD○,
  rendered on macOS where tray titles show) alongside the existing tooltip.


Claude-Session: https://claude.ai/code/session_01R8X1NxFiaLGJzwUD5wgRTv

Co-authored-by: Claude <noreply@anthropic.com>
Add a small Rust diagnostic binary (port of the Go cmd/injcheck) for
verifying the real OS injector against a running player during on-device
macOS/Windows testing:

- `injcheck trust` — check / prompt for the input permission (macOS).
- `injcheck list [filter]` — list on-screen windows.
- `injcheck focus <pid>` — bring an app to the foreground.
- `injcheck keys|bgkeys <pid> <chord...>` — send chords with / without
  stealing focus.

It drives the existing hyperdeck-os injector/enumerator entry points, so it
also gives those per-OS adapters compile coverage in the rust CI matrix. Add
a `just injcheck` recipe and document it in the README.


Claude-Session: https://claude.ai/code/session_01R8X1NxFiaLGJzwUD5wgRTv

Co-authored-by: Claude <noreply@anthropic.com>
@statik
statik marked this pull request as ready for review June 21, 2026 16:10
@statik
statik merged commit de6e44d into main Jun 21, 2026
6 checks passed
@statik
statik deleted the claude/tauri-migration-self-update-74d57g branch June 21, 2026 16:10
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant