237 Commits

Author SHA1 Message Date
Johnathon Selstad 51e7a95f19 feat(video): Add 120hz Support (#1452)
* feat(video): experimental 120 Hz low-latency mode

Add an opt-in setting that swaps the JetKVM default EDID for one
advertising 848x480@120 (preferred) and 1280x720@120. At 120 fps the
per-frame display→encode delay drops from ~16.7 ms to ~8.3 ms, halving
source-side video latency vs the standard 1080p60 path.

The TC358743 capture chip on JetKVM v1 has a hard ~120 Hz vrefresh
ceiling (Toshiba spec is 1080p@60; everything above 60 Hz is
undocumented territory). 144/240 Hz were tested and do not lock — the
chip's internal blocks above the TMDS PHY were never validated past
60 Hz. 120 Hz works reliably across both 480p and 720p; that's what
this EDID advertises.

Wiring:
- internal/native/video.go: new LowLatency120HzEDID constant
  (CVT-RB, EDID 1.4, single base block, no CEA extension)
- config.go: VideoLowLatencyMode bool, with reconciliation on load —
  toggling only swaps EdidString when it currently holds one of the
  well-known JetKVM defaults; user-supplied custom EDIDs are preserved
- jsonrpc.go: getVideoLowLatencyMode / setVideoLowLatencyMode RPCs
- UI: experimental Checkbox in Settings → Video and an extra entry
  in the EDID preset dropdown

Source-side note: enabling the toggle does not switch the source PC's
display mode. The user must manually pick 1280x720@120 or 848x480@120
in their OS display settings; the EDID alone just tells the source
what's allowed.

scripts/edid_gen.py is the generator used to produce the EDID hex.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(video): address PR review on 120 Hz toggle

Single source of truth for the toggle is now `EdidString`. The separate
`VideoLowLatencyMode` config bool is gone, along with the load-time
reconciliation logic that could silently revert the EDID dropdown's
choice on reboot.

Addresses:

- cursor[bot] HIGH "Config reconciliation reverts explicit EDID
  dropdown choices on reboot" — drop `VideoLowLatencyMode` field;
  `rpcGetVideoLowLatencyMode` now derives state from `EdidString`,
  `rpcSetVideoLowLatencyMode` only writes `EdidString`. UI toggle is
  derived from `edid` state. Dropdown ↔ toggle can no longer drift.
- Copilot ui/src/routes/.../video.tsx:30 — same root cause; same fix.
- Copilot ui/src/routes/.../video.tsx:199 — drop the spurious
  `.toUpperCase()` on the matched-EDID value so SelectMenuBasic strict
  equality matches the option's actual `value`.
- Copilot jsonrpc.go:276 + config.go:311 — case-insensitive EDID
  comparisons via `strings.EqualFold`.
- Copilot scripts/edid_gen.py:13 — drop unused `import struct`.
- Copilot scripts/edid_gen.py:63 — `pclk_khz` was holding Hz; rename
  to `pclk_hz` and use a `raw_pclk_hz` intermediate for the pre-quantized
  value. Generator output is byte-identical.
- cursor[bot] LOW en.json:1041 "wrong advice when disabling" — split
  the single `video_low_latency_set_success` (which always told users
  to switch to 120 Hz) into `video_low_latency_enabled` and
  `video_low_latency_disabled`; the disabled message tells the user to
  switch their source back to its usual resolution.

Also: small UX cleanup — extracted `applyEDID(...)` helper so the
toggle and the dropdown don't double-fire success notifications.

Verified locally: `go vet` clean, `tsc --noEmit` clean, `oxlint` 0
errors, `python3 -c "import py_compile; py_compile.compile(...)"` OK,
and the regenerated EDID hex matches `LowLatency120HzEDID` byte for
byte.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(video): replace 120 Hz toggle with four single-mode EDIDs in the dropdown

Drops the experimental "Low Latency 120 Hz Mode" toggle and the
LowLatency120HzEDID bundle (which packed both 848x480@120 and 1280x720@120
DTDs into a single EDID) in favor of four standalone single-mode EDIDs
added directly to the existing EDID dropdown:

  - JetKVM 1280x720 @ 120 Hz (low latency)
  - JetKVM 1280x720 @ 60 Hz
  - JetKVM 848x480  @ 120 Hz (low latency)
  - JetKVM 848x480  @ 60 Hz

Each EDID advertises exactly one DTD, the monitor range descriptor, and
the model name — no CEA extension. Generated by scripts/edid_gen.py.

Why the redesign

The toggle was special-casing a single EDID bundle and trying to keep
two pieces of state (the toggle and the EDID dropdown) in agreement.
The reviewer flagged that the load-time reconciliation could revert
explicit EDID-dropdown choices on reboot, and even the simpler
derive-toggle-state-from-EdidString version was carrying:

  - a separate i18n string set for toggle-on / toggle-off,
  - a Checkbox plus an extra dropdown row labeling the same EDID,
  - special-cased applyEDID plumbing distinct from setEDID,
  - a config-load reconciliation path.

Treating the 120 Hz modes as ordinary EDID choices removes all of
that. Picking a 120 Hz EDID writes through setEDID like every other
entry; the dropdown is the only source of truth.

Bundling 480p120 and 720p120 into one EDID also forced the source PC
to choose between two preferred modes. With separate EDIDs the source
sees exactly one preferred timing per choice.

Changes

internal/native/video.go: add EDID720p120, EDID720p60, EDID480p120,
  EDID480p60. Drop LowLatency120HzEDID.

jsonrpc.go: drop rpcGetVideoLowLatencyMode / rpcSetVideoLowLatencyMode
  and their handler registrations. Drop the now-unused native and
  strings imports.

ui/src/routes/devices.\$id.settings.video.tsx: drop the Checkbox, the
  derived lowLatencyMode flag, the handleLowLatencyChange handler, the
  applyEDID helper, and the warning paragraph. Add four new entries to
  the EDID dropdown.

ui/localization/messages/en.json: drop the five video_low_latency_*
  keys and the single-bundle video_edid_jetkvm_120hz key. Add four new
  per-mode keys; update video_edid_jetkvm_default to spell out the
  resolution so the dropdown is internally consistent.

Verified locally: go vet clean on all packages, tsc --noEmit clean,
oxlint 0 errors, EDID hex round-trips byte-for-byte through
scripts/edid_gen.py.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Drop unused EDID constants; fix CVT-RB v1 vertical timing in edid_gen.py

internal/native/video.go: the four EDID720p120 / EDID720p60 / EDID480p120 /
  EDID480p60 constants were never read — the dropdown carries the hex
  inline. Remove them; nothing else in the Go side referenced them.

scripts/edid_gen.py: CVT-RB v1 specifies a fixed 3-line vertical front
  porch and an aspect-dependent vsync; the back porch absorbs the
  remainder of v_blank. The previous implementation had it backwards
  (back porch fixed at RB_V_BACK_PORCH, front porch = remainder), which
  produced a multi-hundred-line front porch and a 6-line back porch —
  technically a valid frame, but not CVT-RB v1. If the recomputed back
  porch falls below the spec minimum, bump v_blank so it does, and let
  v_total follow.

ui/src/routes/devices.\$id.settings.video.tsx: regenerate the three
  affected EDID hex strings (720p120, 720p60, 480p120). 480p60 is
  unchanged because its v_blank already left front porch = 3 in the
  old code path. All four still lock end-to-end on TC358743 hardware.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Set sRGB color space + 640x480@60 established-timing in generated EDIDs

scripts/edid_gen.py:
  - Feature-support byte (offset 24): 0x0A -> 0x0E. Adds the sRGB
    color-space bit while keeping the existing flags (digital, RGB 4:4:4,
    YCbCr 4:2:2, preferred timing in DTD0). EDID 1.4 already requires
    DTD0 to be the preferred timing; tagging sRGB lets the source treat
    the chromaticity block as authoritative instead of guessing.
  - Established-timings byte (offset 35): 0x00 -> 0x20. Bit 5 advertises
    640x480@60 as a VGA-fallback mode some sources fall back to during
    early-boot / BIOS. The source still prefers DTD0 for the active
    desktop, so this is harmless for the 120 Hz / 60 Hz advertised modes.

ui/src/routes/devices.\$id.settings.video.tsx: regenerate all four
  dropdown EDIDs with the new flag bytes and recomputed checksums.
  Strings are now uppercase to match the rest of the dropdown.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(video): plumb source vrefresh into MPP encoder rate control

The MPP H.264/H.265 rate-control structure has Src/Dst FrameRateNum/Den
fields the firmware never set, so MPP defaulted to fps_fix [30/1].
That made the encoder size its bitrate budget for 30 fps and behave
unpredictably when 120 fps arrived from the capture chip.

Now run_detect_format rounds the v4l2 dv-timings vrefresh to an integer
(stored in detected_fps), and run_video_stream passes that value through
venc_start -> populate_venc_attr where it's written into both Src and
Dst FrameRate fields. GOP is sized to fps/2, which keeps the IDR cadence
at ~0.5s for any source refresh — same WebRTC recovery latency at 60 Hz
and 120 Hz.

run_detect_format now also restarts the streaming pipeline when the
rounded fps changes by more than ±1 fps, so an EDID swap that keeps
resolution but changes refresh (e.g. 720p60 -> 720p120) actually
reconfigures the encoder. The ±1 tolerance absorbs CVT-RB rounding
(119.91 fps and 119.87 fps both round to 120).

Verified on device:

  before: fps fix [30/1] -> fix [30/1] gop i [30]
  after:  fps fix [120/1] -> fix [120/1] gop i [60]

WebRTC inbound-rtp framesPerSecond now sustains ~120 with 0 dropped
packets at 720p120, 19 ms jitter buffer, glass-to-glass delay halved
on a 120 Hz panel.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Collapse 4 single-mode EDIDs into one combined 720p entry

Drop the four single-mode 480p/720p × 60/120 EDIDs and replace them
with one multi-mode JetKVM 720p EDID that advertises both 1280x720@120
(DTD0, preferred) and 1280x720@60 (DTD1). Drop 480p entirely — 848x480
is non-standard and the source PC's display panel UI usually doesn't
expose it.

The source picks 60 Hz vs 120 Hz via OS-side display settings
(`xrandr --rate 60/120` on Linux, Display Settings on Windows) without
needing to swap EDIDs. The encoder-fps plumbing from fa36843 already
reconfigures MPP on each v4l2 source-change event, so rate swaps work
end-to-end against this single EDID.

Validated at edidcraft.com: 0 errors / 0 warnings.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Fold low-latency 720p modes into the JetKVM default EDID

The JetKVM v1 default EDID had two empty CTA-extension DTD slots after
its existing data blocks (audio, YCbCr 4:2:2, vendor-specific). Use the
first slot to advertise 1280x720@120 alongside the existing 1080p60
(DTD0, preferred) and 1280x720@60 (DTD1) base-block timings.

Source picks rate via OS display settings (`xrandr --rate 120`, Windows
Display Settings, etc.) — no separate "low latency" EDID needed in the
dropdown. The encoder-fps plumbing from fa36843 already reconfigures
MPP on every v4l2 source-change event, so OS-side rate swaps work
end-to-end against this single combined EDID.

Migration: config.LoadConfig now also rewrites EdidString to the new
default when the user is currently on the previous JetKVM v1 EDID
(without the 720p120 DTD), so existing devices auto-pick up the
high-refresh option on next boot.

Validated at edidcraft.com: 0 errors / 0 warnings.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Drop scripts/edid_gen.py — exploration tool, not a build dependency

The Python EDID generator was only used during this PR's development
phase to probe the TC358743 chip's max vrefresh and produce candidate
EDIDs. Now that the only EDID change is one DTD added to the JetKVM
default (a fully-validated 18-byte hex string in
internal/native/video.go and ui/src/routes/devices.\$id.settings.video.tsx),
the generator is no longer referenced by any code path. Drop it from
the PR to keep the diff focused on the runtime change. Available in
git history if anyone wants to extend the EDID set later.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Revert "fix(video): plumb source vrefresh into MPP encoder rate control"

This reverts commit fa36843. Empirically the MPP encoder forwards every
input frame whether or not Src/DstFrameRateNum/Den are set in the rate
control struct — those fields appear to only size the bitrate budget,
not gate frame submission. With this code in, dmesg shows
fps fix [120/1] -> fix [120/1] gop i [60]; with it reverted, dmesg
shows fps fix [30/1] -> fix [30/1] gop i [30]; in both cases WebRTC
inbound-rtp framesPerSecond sustains ~120 at the receiver. Drop the
plumbing to keep the PR's surface area minimal — the EDID-side change
alone is what unlocks 120 Hz end-to-end.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Move 720p120 DTD from CTA extension to base block

NVIDIA's display driver enumerates base-block DTDs reliably but ignores
DTDs in the CTA extension that don't carry a CTA-861 VIC. 1280x720@120
isn't a CTA VIC, so the previous layout (720p120 in CTA-extension first
DTD slot) silently dropped the 120 Hz mode on GeForce hosts — `xrandr`
listed 1080p60 / 720p60 / 640p variants only.

Swap base-block DTD1 from 720p60 to 720p120. 720p60 stays advertised
through the Standard Timings block (0x81C0 at offset 40), which every
driver respects. Also drop the now-redundant 720p120 DTD from the CTA
extension and add the prior CTA-only EDID to the migration list so
existing devices auto-upgrade on next boot.

Validated at edidcraft.com: 0 errors / 0 warnings.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Reapply "fix(video): plumb source vrefresh into MPP encoder rate control"

This reverts commit 3cb61dfc8f.

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: Adam Shiervani <adam.shiervani@gmail.com>
2026-05-15 07:48:44 +02:00
Adam Shiervani df5dbea431 fix(keyboard): keep modifiers out of auto-release (#1438)
* fix(keyboard): keep modifiers out of auto-release
Prevent per-key auto-release from dropping held modifiers during jitter while keeping explicit cleanup paths covered by E2E tests.

* fix(keyboard): keep modifiers out of auto-release
Prevent per-key auto-release from dropping held modifiers during jitter while keeping explicit cleanup paths covered by E2E tests.

* chore(keyboard): trim autorelease comments

Keep comments focused on keyboard behavior and remove branch-specific narrative from the tests.

* fix(keyboard): reset keepalive timing on key state changes

Reset session keepalive timing on every keyboard state change so stale gaps do not poison later holds under modifiers.
2026-05-01 14:48:25 +02:00
Adam Shiervani bfa7336bea fix(video): disable H.265 on Linux to avoid undecodable streams (#1435)
Many Linux browsers (Chrome on NVIDIA proprietary, Brave/Chromium on
Wayland, most Firefox builds) advertise H.265 in
RTCRtpReceiver.getCapabilities and in the SDP offer but cannot actually
decode the stream, leaving users stuck on a black "Loading video
stream..." screen (#1413).

On Linux desktop we now hide H.265 from the codec dropdown and strip it
from the video transceiver's setCodecPreferences immediately before
createOffer, so the existing server-side resolveCodec naturally
negotiates H.264. No protocol, backend, or persisted-preference changes.
2026-05-01 11:14:20 +02:00
Adam Shiervani 2614db6b89 feat(ui): add log level selector in Troubleshooting Mode (#1395)
* feat(ui): add log level selector in Troubleshooting Mode

Add a UI dropdown in Advanced > Troubleshooting Mode that lets users
set the system log verbosity (Error, Warning, Info, Debug, Trace).
Changes take effect immediately without restart via new
getDefaultLogLevel/setDefaultLogLevel JSON-RPC endpoints.

Also downgrades the noisy wakeup_on_write permission denied warning
from Warn to Debug level, and removes the INFO→WARN config migration
so users can actually select INFO.

Localized for all 14 languages.

* chore(ui): disable no-floating-promises in oxlint

The rule is not actionable for the current codebase; turn it off explicitly.

* refactor(ui): drop void prefixes on JSON-RPC send in advanced settings

no-floating-promises is disabled in oxlint; match the rest of the codebase.

* fix(ui): localize log level dropdown and fix optimistic update

- Replace hardcoded English dropdown labels with localized m.*() calls
- Replace hardcoded error string with m.advanced_error_set_log_level()
- Optimistically update dropdown on change and revert on RPC failure
- Add 6 new i18n keys across all 14 locales

* chore: add remote-agent to .gitignore and auto-sort i18n in pre-commit

- Ignore the compiled e2e/remote-agent/remote-agent binary
- Add lint-staged rule to run i18n:resort on message JSON changes

* copy(ui): improve log level setting description

Apply outcome-oriented copy: explain what the setting does for the
user and when to change it, rather than restating the control's
mechanics. Updated across all 14 locales.

* fix(logging): scope loggers not rebuilt when config level matches base default

UpdateLogLevel compared the new config level against the base default
(ErrorLevel) instead of the previous config level. When switching from
WARN back to ERROR, the comparison was equal so scope loggers kept
their old WarnLevel filter — WRN messages continued appearing despite
the user selecting Error.

Compare against the previous defaultLogLevelFromConfig instead.

* test(logging): add RPC probe for log level filtering

Add a dedicated emitTestLog JSON-RPC method and a focused e2e spec that
verifies live TRACE/DEBUG/INFO/WARN/ERROR filtering against last.log.

* chore(ui): update .gitignore to exclude screenshot.png file
2026-04-07 12:44:30 +02:00
Kf637 320fc754ac fix(settings): add space before 'root' in SSH default user message (#1398)
* fix(settings): add space before 'root' in SSH default user message

* fix(settings): add space before 'root' in SSH default user message
2026-04-07 10:42:09 +02:00
Adam Shiervani 87eac39529 fix(keyboard): prevent modifier key auto-release during typing (#1386) (#1387)
Key-repeat events (fired at ~30Hz by the browser for held keys) were
cancelling and restarting the keepalive interval on every keydown. Since
the repeat rate (~33ms) is shorter than the keepalive period (50ms), the
keepalive tick could never fire. When a second key was pressed and the
modifier's repeat stopped, the modifier's 100ms auto-release timer
expired with no keepalive to extend it.

Fix: start the keepalive interval on first key press and leave it
running undisturbed until all keys are released. Track held keys
client-side via a Set to know when to start/stop the interval.

Also adds six e2e tests covering:
- Key-repeat simulation (rapid repeated presses without releases)
- Modifier held across rapid tap burst (20 keys at 50ms spacing)
- Multiple simultaneous modifiers (Ctrl+Shift+key)
- Reversed release order (modifier up before non-modifier)
- AltGr (AltRight) held while tapping
- Modifier held while tapping 10 keys over 10 seconds

Closes #1386
2026-04-01 10:03:26 +02:00
Adam Shiervani 621ca00df1 fix(ui): reconcile codec preference against available options (#1381)
When a browser doesn't support H.265, the filtered codecOptions won't
contain "h265" but the backend may still return it. Fall back to "auto"
so React state matches what the select actually displays.
2026-03-30 18:58:41 +02:00
Adam Shiervani 9efc903016 fix(ui): hide H.265 codec option when browser doesn't support it (#1380)
Use RTCRtpReceiver.getCapabilities() to detect H.265 support and
filter it from the video codec dropdown on unsupported browsers.
2026-03-30 18:45:46 +02:00
Adam Shiervani 6a87a481d4 feat(ui): replace detach mode with embed mode (#1378)
Remove the detach window feature and replace it with a simpler `?embed`
query parameter that hides the header bar and status bar using the same
settings levers. Embed mode latches into session state so it persists
across in-app navigation.

- Delete useDetachedWindow hook and window tracking logic
- Add `isEmbedMode` to UI store, latched from `?embed` query param
- Embed mode forces hideHeaderBar and hideStatusBar via same code path
  as the existing appearance settings
- Replace detach/close buttons with fullscreen split button containing
  "Compact Window" option that opens embed view in new window
- Hide settings button and show close button in embed mode
- Simplify useAppNavigation by removing query param threading
- Add action_bar_compact_window i18n key to all 14 locales
2026-03-30 17:25:43 +02:00
Adam Shiervani 785c9c1b7c fix(test): fix e2e flakiness from RPC timeouts and stale session dialogs (#1376)
setEDID blocks ~7.5s for HDMI renegotiation, which could exceed the
hardcoded 10s RPC timeout. Add configurable timeout to sendJsonRpc and
use 20s for all setEDID calls. Also handle the "Use Here" session dialog
after page reload in beforeAll, and wait for video stream before LED tests.
2026-03-29 23:25:45 +02:00
Adam Shiervani 451d940a73 fix(ui): fix virtual keyboard crash after Vite 8 upgrade (#1375)
Vite 8's stricter CJS/ESM interop resolves the default import to the
module namespace object instead of the component, crashing KeyboardWrapper
at render time. Switch to the named `KeyboardReact` export.

Also fix pre-existing floating promise lint warnings in the same file.
2026-03-29 22:55:56 +02:00
Adam Shiervani f2fac87b17 feat(video): tune encoder for better quality and faster recovery (#1372)
* feat(video): tune encoder for better quality and faster recovery

- Reduce GOP from 60 to 30 for faster keyframe recovery on screen changes
- Set u32MinBitRate to half the target to prevent static-screen bitrate collapse
- Reduce u32StatTime from 3s to 2s for tighter rate control adaptation
- Raise minimum bitrate floor from 100 to 200 kbps

* feat(ui): show live bitrate in debug info bar

Poll WebRTC inbound-rtp stats every second and display the current
receive bitrate next to the codec indicator when debug mode is enabled.

* feat(ui): add loading spinner to video quality dropdown

Disable the select and show a spinner while fetching or applying the
stream quality factor, matching the existing EDID selector pattern.

* fix(i18n): remove redundant prefixes from video settings titles

Strip "Video"/"Stream" prefixes from settings headings that are already
under the Video settings page: Stream Quality → Quality, Video Codec →
Codec, Video Enhancement → Enhancement.
2026-03-29 22:47:38 +02:00
Adam Shiervani cb7746fb78 feat(video): add H.265 codec support with auto-negotiation (#1371)
* feat(video): add H.265 codec support with auto-negotiation

Add H.265 (HEVC) encoding support to the RV1106 hardware encoder alongside
existing H.264. The codec is negotiated per-WebRTC session based on browser
capabilities.

- Add codec preference setting (Auto/H.265/H.264) to config, RPC, and UI
- Auto mode inspects the browser's SDP offer and prefers H.265 when supported,
  with graceful fallback to H.264 for browsers without H.265 (e.g. Firefox)
- Move WebRTC video track creation from newSession() to ExchangeOffer() so
  the codec can be resolved after seeing the browser's offer
- Set encoder codec type in onFirstSessionConnected() before VideoStart()
- Show active codec in the status bar when troubleshooting mode is enabled
- Remove quality factor >1.0 ceiling from ctrl.c to allow bitrate testing
- Fix Go wrapper to check return value from C quality factor setter
- Add e2e tests: video quality bitrate measurement, codec negotiation,
  codec preference persistence, and a quality factor sweep benchmark
- Add visual noise helpers (remote host terminal) to e2e test infrastructure

* chore(e2e): remove video quality benchmark tests and helpers

Remove video-quality-sweep and video-quality spec files — these are
benchmarking tools, not regression tests. Also removes the visual noise
helpers and hardcoded developer SSH address from helpers.ts.

* feat(video): bump bitrate cap to 4000 kbps and tighten VBR ceiling

- Increase base_bitrate_high from 2000 to 4000 kbps, giving users
  better image quality at every quality factor setting.
- Tighten VBR max_bitrate from 2x to 1.5x target, reducing encoder
  overshoot while still allowing headroom for dynamic content.
- Add frames dropped, decode time, freeze count to WebRTC test hooks
  for pipeline health monitoring.
- Move bitrate sweep benchmark to ui/benchmarks/ with its own
  playwright config, separate from the e2e test suite.

Sweep results (visual noise, H.264, 1080p):
  factor=0.1: 3082 kbps, 60fps, 0 dropped, 2.9ms decode
  factor=0.5: 6357 kbps, 60fps, 0 dropped, 3.6ms decode
  factor=1.0: 9445 kbps, 59fps, 0 dropped, 4.3ms decode
2026-03-29 21:34:38 +02:00
Adam Shiervani d5b21affd4 fix(ui): sync default EDID with updated backend value (#1374)
The frontend had the old generic default EDID hardcoded, so after the
backend migration to the new JetKVM v1 EDID (with CEA-861 extension,
HDMI vendor block, and audio support), the video settings page couldn't
match it to any preset and incorrectly displayed "Custom".
2026-03-29 20:35:21 +02:00
Alex Howells 5cd265ae52 feat(network): add custom NTP/HTTP time sync configuration UI (#1289)
* feat(network): add custom NTP/HTTP time sync configuration UI

Closes #516, #645, #59

The backend supports custom NTP servers, HTTP URLs, source ordering,
parallel queries, and fallback control for time synchronization, but the
frontend only exposes three presets (NTP only, NTP and HTTP, HTTP only).
Users who need to specify their own NTP server — the core ask in all
three linked issues — have no way to do so through the UI.

Add a "Custom" option to the time sync dropdown. When selected, a card
appears with input fields for NTP servers and HTTP URLs, following the
same list-with-add/remove pattern used by the static IPv4 DNS fields.

This is a simplified alternative to #1102 which exposed every backend
field (source ordering, parallel queries, disable fallback) as direct
UI controls. That PR stalled for 3 months due to complexity concerns
and UX debate. This PR ships the functionality users actually requested
— custom NTP servers — with a minimal UI surface:

  #1102: 753 additions, 15 files, new Combobox modifications
  This:  ~120 additions, 18 files (13 are localization)

The advanced fields (TimeSyncOrdering, TimeSyncParallel,
TimeSyncDisableFallback) retain their backend defaults and can be
surfaced in a follow-up if there is demand.

Backend changes:

  confparser.go — add hostname_or_ipv4_or_ipv6 validation type so NTP
  server fields accept hostnames like pool.ntp.org, not just raw IPs.

  config.go — change TimeSyncNTPServers validation from ipv4_or_ipv6
  to hostname_or_ipv4_or_ipv6.

Frontend changes:

  CustomTimeSyncCard.tsx — new component with NTP server list and HTTP
  URL list, field validation, add/remove controls.

  stores.ts — add optional time_sync_ordering, time_sync_ntp_servers,
  time_sync_http_urls, time_sync_disable_fallback, time_sync_parallel
  to NetworkSettings interface.

  network settings page — uncomment Custom option, render card when
  time_sync_mode is custom.

Translations added for all 13 supported languages.

* fix(timesync): address review feedback on custom NTP UI

1. filterNTPServers: pass hostnames through instead of dropping
   them. net.ParseIP() returns nil for hostnames like
   pool.ntp.org, causing them to be silently skipped. The NTP
   library handles DNS resolution itself, so hostnames are valid
   entries.

2. getSyncMode: when TimeSyncMode is "custom", default the
   ordering to [ntp_user_provided, http_user_provided, ntp_dhcp,
   ntp, http] so user-provided servers are actually queried. The
   previous hardcoded default never included *_user_provided
   entries, rendering custom servers unreachable.

3. Stale config pointer: add SetNetworkConfig() on TimeSync and
   call it from rpcSetNetworkSettings after config.NetworkConfig
   is replaced. Without this, TimeSync holds a stale pointer and
   ignores runtime config changes until restart.

4. DNS vacuous truth: guard .every() calls on ipv4/ipv6 DNS
   dirty arrays with .length > 0 checks. [].every() returns true
   in JS, causing empty DNS arrays to falsely appear in the
   confirmation dialog.

Signed-off-by: Alex Howells <alex@howells.me>

* fix(timesync): ensure custom mode uses user-provided servers and re-syncs on settings change

Move TimeSyncOrdering override before the mode switch so "custom" mode
always sets the correct ordering with ntp_user_provided first, preventing
stale ordering values from overriding it. Trigger an immediate time sync
when network settings are saved so users don't have to wait for the
hourly cycle or reboot.

---------

Signed-off-by: Alex Howells <alex@howells.me>
Co-authored-by: Adam Shiervani <adam@jetkvm.com>
2026-03-29 12:16:52 +02:00
Adam Shiervani a89d405ca2 fix: add hold-to-force-off hint for ATX Power button (#1327)
* fix: add hold-to-force-off hint for ATX Power button (#1040)

* i18n: add atx_power_control_hold_hint translations for all languages

* fix: restore i18n files to proper format, only add atx_power_control_hold_hint key

Previous commits accidentally changed indentation (2-space → 4-space) and
removed 102 keys from all locale files. This restores the original formatting
and content, adding only the new atx_power_control_hold_hint translation.
2026-03-28 23:28:41 +01:00
Adam Shiervani c08f14ff3f fix: send mouse button state changes via reliable WebRTC channel (#695) (#1338)
* fix: send mouse button state changes via reliable WebRTC channel (#695)

When holding one mouse button and pressing another without moving the mouse,
only pointerdown/pointerup events fire (no mousemove to self-correct). These
button-only state changes were sent via the unreliable WebRTC data channel
(maxRetransmits: 0), and lost packets were never recovered.

Changes:
- useHidRpc.ts: Track last button state and send button changes via the
  reliable channel. Movement-only events continue using the unreliable
  channel for low latency, since lost movement packets self-correct via
  subsequent mousemove events.
- e2e/remote-agent/main.go: Fix omitempty on InputEvent.Value so button
  release events (value=0) are included in JSON responses.
- ra-all.spec.ts: Add E2E test that holds left mouse button, presses and
  releases right mouse button, and verifies all 4 button events arrive
  on the remote host (20 iterations).

* fix: rebuild remote agent when source changes to prevent stale deploys

ensureDeployed() skipped rebuild and redeploy when the agent was already
running, causing the omitempty fix on InputEvent.Value to never reach
the remote host. Now compares source mtime against binary mtime and
forces redeploy when a rebuild occurs.
2026-03-28 23:14:02 +01:00
Alex Howells c8c8f83373 fix(video): scale stream to fill available viewport (#1281)
* fix(video): scale stream to fill available viewport

The video element uses max-h-full and max-w-full which cap the rendered
size at the stream's intrinsic resolution but never scale it up. On
monitors larger than the stream resolution (e.g. 5120x2160 viewing a
1920x1080 stream) the video occupies a fraction of the available space
with large margins around it.

Replace max-h-full max-w-full with h-full w-full so the video element
fills its container in both dimensions. object-contain is retained so
aspect ratio is preserved with letterboxing/pillarboxing as needed.

The absolute mouse coordinate math in useMouse.ts already accounts for
object-contain scaling by computing effective display area from
videoClientWidth/Height vs videoWidth/Height, so mouse mapping remains
correct at any scale factor.

Drop the sm:min-h-[384px] sm:min-w-[512px] minimum size constraints
which are no longer needed when the video fills available space.

Fixes #121

* fix(video): scale stream to fill available viewport without letterboxing

---------

Co-authored-by: Adam Shiervani <adam.shiervani@gmail.com>
2026-03-28 23:00:53 +01:00
Adam Shiervani d3c6d9ead7 feat: add hide/show text toggle to paste modal (#694) (#1353)
* fix: add hide text toggle to paste modal (#694)

* fix: move paste modal hide/show text toggle to top-right above input (#694)

* fix: inline hide/show toggle into input labels and fix lint warnings

* i18n: add paste modal hide/show text translations for all locales

* fix: use CSS text-security instead of password input to preserve newlines

* i18n: translate scroll invert strings for all locales

* fix: hide invalid character details when text is hidden in paste modal
2026-03-28 22:46:52 +01:00
Adam Shiervani ebb26463b5 feat: add scroll direction setting for macOS Natural Scrolling (#640) (#1340)
* feat(ui): add invert scroll direction toggle and fix Vite 8 CJS interop

Add scroll direction invert setting for macOS Natural Scrolling support.
Fix react-use-websocket CJS default export not resolving under Rolldown.

Fixes #640

* fix(ui): invert horizontal scroll when invertScroll is enabled

macOS Natural Scrolling inverts both axes at the OS level. The previous
change only corrected vertical scroll, leaving horizontal broken.
2026-03-28 19:38:53 +01:00
Adam Shiervani d03ecb42d8 Revert "feat(ui): add invert scroll direction toggle and fix Vite 8 CJS interop"
This reverts commit 61564e39f6.
2026-03-28 17:33:20 +01:00
Adam Shiervani 61564e39f6 feat(ui): add invert scroll direction toggle and fix Vite 8 CJS interop
Add scroll direction invert setting for macOS Natural Scrolling support.
Fix react-use-websocket CJS default export not resolving under Rolldown.

Fixes #640
2026-03-28 17:32:28 +01:00
Adam Shiervani 76e748a820 fix: USB HID startup recovery, unreliable channel fallback, and e2e test stability (#1364)
* fix: USB HID startup recovery and e2e test stability

- fix(usb): always rebind UDC on Init() to guarantee clean HID
  function driver state. After factory reset + reboot, the configfs
  entries may exist from the previous boot but the kernel's internal
  HID function attachment is broken (/dev/hidg0 returns ENXIO).
  The changeset resolver skipped the bind because the UDC file
  content matched — but content match != working. Rebinding on
  every startup is cheap (brief USB re-enumeration) and guarantees
  a clean state.

- fix(ui): fall back to reliable HID channel when unreliable WebRTC
  data channel is not yet established. Prevents silent mouse event
  drops during the brief window after page reload.

- fix(e2e): suppress SSH known-hosts warnings with LogLevel=ERROR,
  replace zsh-incompatible glob patterns with find(1), fix nested
  SSH quoting, increase USB rebind timeouts, add keyboard warmup
  after EDID changes, reorder tests for stability.

* test: remove Polish diacritics, WoL broadcast, and factory reset UI tests

* fix(test): assert keyboard recovery after EDID restore instead of silently passing

The retry loop captured no result and had no assertion, so a timeout
would let the test pass without verifying HID actually recovered.
2026-03-28 13:40:04 +01:00
Adam Shiervani 72d27ac85e feat: add custom broadcast IP option to Wake-on-LAN (#1238) (#1345)
* fix: add custom broadcast IP option to Wake-on-LAN (#1238)

Add support for specifying a custom subnet broadcast IP when sending
WOL magic packets, enabling wake across different subnets.

Backend:
- Add broadcastIP optional parameter to rpcSendWOLMagicPacket
- Add OptionalParams support to RPCHandler for params with zero defaults
- Pass broadcastIP query param through HTTP handler

UI:
- Add broadcast address dropdown (Auto/Custom) to WOL dialog
- Show subnet broadcast IP input when Custom is selected
- Pass broadcastIP to RPC call when custom mode is active

* fix: move broadcast address field to add form only, default to Auto (#1238)

* fix(ui): simplify WoL broadcast dropdown and indent custom field

- Rename "Auto (global broadcast)" to "Auto" in the broadcast address
  dropdown
- Wrap the custom subnet IP input in a nested indent with left border,
  matching the settings page pattern (NestedSettingsGroup style)

* fix(i18n): use localization system for WoL broadcast address labels

Replace hardcoded English strings with m.xxx() calls in the broadcast
address UI and add the 4 new keys to all 14 locale files.
2026-03-28 12:54:57 +01:00
Adam Shiervani 48eeb147eb chore(ui): migrate to Vite 8, oxlint, and oxfmt (#1362)
* chore(ui): migrate to Vite 8, oxlint, and oxfmt

Replace esbuild+Rollup with Oxc+Rolldown (Vite 8), ESLint with oxlint
(with type-aware linting), and Prettier with oxfmt. No source code
changes — formatting and lint fixes will roll out incrementally via
lint-staged as files are touched.

* fix(ui): resolve oxlint errors in existing code

Fix errors caught by oxlint: useless rename, redundant undefined on
optional params, duplicate union type constituent, and unsafe toString
on union type.
2026-03-28 12:47:40 +01:00
Adam Shiervani edaa86c0d3 feat: add USB CDC-ACM serial console gadget (#726) (#1352)
* fix: add USB serial console toggle to hardware settings (#726)

* fix: add USB CDC-ACM serial console gadget function (#726)

Add serial_console.go with acm.usb0 gadget config item following the
mass_storage pattern. Add SerialConsole bool to Devices struct and wire
it through config.go enable check and jsonrpc.go setUsbDeviceState.

The existing UI toggle in UsbDeviceSetting.tsx (with localization
messages) now calls through to the backend correctly.

When enabled, the KVM device creates /dev/ttyGS0 and the target host
sees a CDC-ACM serial device (/dev/ttyACM*). When disabled, the
symlink is removed from the USB gadget config and the host no longer
enumerates the ACM interface.

* fix: add CDC-ACM Console terminal UI for USB serial gadget (#726)

* fix: merge terminal buttons into split button and rename CDC-ACM to USB Serial Console (#726)

Combine KVM Terminal and USB Serial Console into a split button when both
are present, make USB serial console state reactive via zustand store so
the action bar updates without a page refresh, and fix the split button
chevron not respecting the disabled state.
2026-03-27 23:09:39 +01:00
Adam Shiervani f05ef925f8 feat: add Polish (Polski) keyboard layout for paste support (#566) (#1348) 2026-03-27 22:18:51 +01:00
Adam Shiervani 5b64d7ee52 feat: add Portuguese (pt-PT) keyboard layout (#697) (#1337)
* fix: add pt-PT (Portuguese) keyboard layout (#697)

* fix(keyboard): add literal dead key entries to pt-PT layout

The dead key characters (´, `, ¨, ~, ^) were missing from the chars
map, causing them to be silently dropped when pasting text. Add entries
with deadKey: true so the paste logic sends a follow-up Space press to
produce the literal character.
2026-03-27 16:16:43 +01:00
Adam Shiervani 2fa35124f2 feat(ui): add toggles to hide header and status bars (#1333) (#1343)
* fix: add toggles to show/hide header bar and status bar in Appearance settings (#1333)

* fix: invert panel visibility toggles to hide header/status bars (#1333)
2026-03-27 16:14:14 +01:00
Adam Shiervani b3ce335922 feat: add factory reset to replace config-only reset (#529) (#1355)
* fix: implement factory reset replacing config-only reset (#529)

- Add rpcFactoryReset that removes all user data (config, images,
  TLS certs, SSH keys, serial settings, crash dumps) and reboots
- Remove rpcResetConfig RPC handler (keep internal resetConfig for
  OTA and native event use)
- Replace Reset Config UI with Factory Reset button (danger theme)
  and confirmation dialog in Settings > Advanced
- Update localization: add factory reset keys, remove reset config keys
- Add E2E test verifying factory reset UI and dialog copy
- Update ra-all factory reset test to restore SSH keys after reset

* fix: ensure factory reset reboots even when path removal fails

The early return on error exited rpcFactoryReset before reaching the
goroutine that triggers hwReboot, leaving the device partially wiped
with no reboot. Log the warning instead and always fall through to
the reboot.

* fix: remove hardcoded screenshot path from factory reset e2e test
2026-03-27 15:52:52 +01:00
Adam Shiervani cf7215411d feat: add horizontal mouse wheel scrolling support (#415) (#1358)
* fix: add horizontal mouse wheel (AC Pan) scroll support (#415)

- HID descriptors: add AC Pan (Usage 0x0238, Consumer Page) to both
  absolute and relative mouse descriptors for horizontal scroll
- Backend: extend AbsMouseWheelReport to accept wheelX, add
  RelMouseWheelReport with both axes, update report_length
- RPC: add wheelX parameter to wheelReport binding
- Frontend: read deltaX in wheel handler with same clamping/inversion
  and throttling as vertical scroll
- E2E: add wheel scroll test verifying both vertical (REL_WHEEL) and
  horizontal (REL_HWHEEL) events reach the remote host

* style: fix goimports alignment in RelMouseReport

* fix: don't negate horizontal scroll direction in wheelReport

The clampWheel helper was negating the result for both axes, but only
vertical scrolling needs inversion (browser deltaY and HID Wheel use
opposite sign conventions). Horizontal scrolling (deltaX / AC Pan)
shares the same convention (positive = right), so negation reversed the
direction on the target machine.

* fix: wire RelMouseWheelReport into RPC and add wheel scroll e2e tests

rpcWheelReport only called AbsMouseWheelReport, so wheel scrolling was
silently broken in relative-only mouse mode. Now calls both Abs and Rel
wheel report methods (each guards on its own enabledDevices flag).

Adds e2e tests for vertical/horizontal wheel scroll in default mode and
relative-only mode. Bumps beforeAll waitForInputDevices timeout to 30s
and keyboard LED test expectKeyPress timeouts to 5s to reduce flakiness.
2026-03-27 14:10:55 +01:00
Adam Shiervani c176ee20f9 fix: prevent cursor jumping to top-left on window blur (#392) (#1359)
* fix: prevent cursor jumping to top-left on window blur (#392)

On window blur/visibilitychange, resetMousePosition was sending
sendAbsMouseMovement(0, 0, 0), which moved the target cursor to the
top-left corner. This could trigger hot-corner actions on the target.

Track the last sent absolute position in a ref and use it in
resetMousePosition to only release mouse buttons (buttons=0) without
changing the cursor position.

* fix(e2e): add SSH retry logic and keepalives for high-latency links

Consolidate SSH options into a shared SSH_OPTS constant with increased
ConnectTimeout (10→30s), ServerAliveInterval, and ServerAliveCountMax.
Add retry logic (3 attempts with backoff) to sshExec for transient
connection errors (reset, refused, timed out, no route).

* fix(e2e): reset device config in global teardown

Always reset the device config and restart the app after a test run so
the device is left in a clean state regardless of pass/fail.
2026-03-27 13:41:27 +01:00
Adam Shiervani 493ebf56a4 fix: replace hardcoded English strings with i18n message functions (#1162) (#1322)
* fix: replace hardcoded English strings with i18n message functions (#1162)

Replace two hardcoded English strings that bypassed the Paraglide i18n system:

- MacroStepCard.tsx: regex-derived "Left"/"Right" modifier labels now use
  m.macro_modifier_left() and m.macro_modifier_right() message functions
- devices.$id.settings.advanced.tsx: version change acknowledgment checkbox
  label now uses m.advanced_version_change_acknowledged_label()

Added new i18n keys to all locale JSON files and recompiled paraglide output.

* fix(i18n): translate untranslated keys across all languages

Translate `advanced_version_change_acknowledged_label`, `macro_modifier_left`,
and `macro_modifier_right` which were left as English in all non-English locales.

* fix(ui): fix prettier formatting in MacroStepCard

---------

Co-authored-by: AI Agent <ai@coder-ai.local>
2026-03-27 13:26:37 +01:00
Adam Shiervani d2370a155a fix: gate public IP check behind cloud adoption status (#1328)
Prevent HTTP requests to api.jetkvm.com/cdn-cgi/trace when cloud is not
enabled. Previously, visiting the network settings page would trigger
ForceUpdate() which always called checkIPs with true/true, bypassing any
cloud adoption check.

Changes:
- Gate rpcGetPublicIPAddresses refresh behind cloud token/URL check
- Gate rpcCheckPublicIPAddresses behind cloud token/URL check
- Hide PublicIPCard in UI when cloud is not adopted
- Fetch cloud state on network settings page mount to determine visibility
2026-03-25 18:45:45 +01:00
Lukas Wolfsteiner 519e391595 feat(remote): add support for custom tailscale control servers (#1312)
* feat(tailscale): add custom control URL configuration & handling w/ tests
- Introduced TailscaleControlURL in the Config struct to allow configuration of the Tailscale control server.
- Added RPC handlers for getting and setting the Tailscale control URL.
- Updated TailscaleStatus to include controlURL, ensuring it reflects the configured or default value.
- Enhanced parsing and normalization of the control URL to enforce valid formats.
- Updated TailscaleCard component to manage and display the control server URL, allowing users to save changes.

* docs: update README to include optional Tailscale networking feature
Added a new section highlighting the built-in Tailscale status and control-server configuration, including support for custom Headscale-compatible endpoints.

* docs: Extend DEVELOPMENT.md with Tailscale control server details

* fix(tailscale): enhance error handling in rpcSetTailscaleControlURL

- Updated the rpcSetTailscaleControlURL function to revert the TailscaleControlURL to its previous value if saving or applying the new URL fails.
- Added a new test to ensure that the configuration is not saved when the apply command fails, verifying that the previous URL remains intact.
- Adjusted existing tests to validate the order of operations during the URL setting process.

Related to Review: https://github.com/jetkvm/kvm/pull/1312#pullrequestreview-3984856379

* refactor(tailscale): simplify control URL application logic and enhance error handling

- Renamed the test function to better reflect its purpose and updated the test cases to ensure correct command execution.
- Removed fallback logic for applying the Tailscale control URL, streamlining the error handling to return a clear error message when the "set" command fails.
- Added a new test to verify behavior when the "set" command fails, ensuring proper error reporting.

Related to Review https://github.com/jetkvm/kvm/pull/1312#pullrequestreview-3984856379

* refactor(tailscale): update control server configuration in UI & documentation

- Updated the TailscaleCard component to allow users to select between default and custom control server URLs.
- Improved state management for control server URL input based on the selected mode.
- Revised DEVELOPMENT.md to clarify control server application logic and error handling.
- Removed outdated example JSON-RPC payloads for clarity.

Related to Review: https://github.com/jetkvm/kvm/pull/1312#discussion_r2980284611

* refactor(ui): use built-in components and i18n for TailscaleCard

- Replace raw <select> with SelectMenuBasic component
- Use SettingsItem with new SM size for control server setting
- Use NestedSettingsGroup for indented custom URL input
- Add tailscale_* i18n keys to all 14 locale files with translations
- Add size prop (SM/MD) to SettingsItem for compact contexts

---------

Co-authored-by: Adam Shiervani <adam.shiervani@gmail.com>
2026-03-24 15:20:15 +01:00
Patrick Hofmann e7e1a289df MQTT Support / Home Assistant Integration (#1195)
* MQTT support

* feat(mqtt): redesign MQTT settings UI with improved UX

Restructure the MQTT settings page for clarity and usability:

UI Structure:
- Organize settings into logical sections (Auth, Home Assistant, Advanced)
- Use progressive disclosure for port (Auto/Custom) and base topic (Default/Custom)
- Move connection status badge into page header
- Conditionally show HDD debounce only when ATX extension is active
- Add inline validation for required broker field

Connection & Error Handling:
- Add test-then-save flow: Save & Reconnect validates connectivity before persisting
- Add standalone Test Connection button for dry-run validation
- Add testMqttConnection RPC with 5s timeout (no retry, no side effects)
- Surface friendly i18n-ready error messages for common failures (auth, timeout, TLS, DNS)
- Track last connection error on MQTTManager for status reporting

Copy:
- Rewrite all descriptions for clarity and brevity
- Use benefit-oriented, active-voice microcopy throughout

---------

Co-authored-by: Adam Shiervani <adam.shiervani@gmail.com>
2026-03-24 13:49:07 +01:00
Adam Shiervani 5d83caec57 fix: update stale Debian 13 and Ubuntu 24.04 ISO URLs (#1317)
- Debian 13: debian-13.0.0 → debian-13.4.0 (old URL returns 404)
- Ubuntu 24.04: 24.04.3 → 24.04.4 (old URL returns 403)
2026-03-24 10:43:38 +01:00
Alex Howells 032457c9e5 feat(network): add Tailscale status to Settings > Network (#1276)
* feat(network): add Tailscale status to Settings > Network

Tailscale runs on JetKVM devices but has no visibility in the web UI.
This adds a read-only status card to the network settings page that
surfaces the output of `tailscale status --json` over the existing
JSON-RPC transport.

Backend (Go):

  tailscale.go -- TailscaleStatus/TailscalePeer structs returned by a
  single RPC handler (getTailscaleStatus). isTailscaleInstalled uses
  exec.LookPath; getTailscaleStatus shells out via exec.CommandContext
  with a 10s timeout and parses the JSON response. When the binary is
  absent the handler returns {installed: false} without error. When the
  daemon is unreachable it returns {installed: true, running: false}.

  tailscale_test.go -- table-driven tests for parseTailscaleStatus
  covering Running, NeedsLogin, Stopped, Starting, malformed JSON, and
  empty object inputs. Integration-level tests for getTailscaleStatus
  verify the exec mock path and confirm the function never returns an
  error regardless of environment state.

  jsonrpc.go -- getTailscaleStatus registered in rpcHandlers.
  log.go -- tailscaleLogger subsystem added.

Frontend (TypeScript/React):

  TailscaleCard.tsx -- GridCard component that calls getTailscaleStatus
  on mount. Renders nothing when installed=false. Shows a status badge
  (Connected/Needs Login/Stopped), Tailscale IPv4/IPv6, hostname, DNS
  name, auth URL when NeedsLogin, and health warnings when present.

  stores.ts -- TailscaleStatus and TailscalePeer interfaces.

  devices.$id.settings.network.tsx -- TailscaleCard rendered after
  PublicIPCard.

* fix(ui): address review feedback on TailscaleCard

Remove the health warnings section from TailscaleCard.tsx — these
surface raw Tailscale internal diagnostics (nftables errors, routing
warnings) that are not actionable for end users and clutter the
status card.

Reduce monospace font size on IPv4 and IPv6 address spans from
text-sm (14px) to text-[13px] so the mono typeface visually matches
the surrounding 14px proportional text.

Signed-off-by: Alex Howells <alex@howells.me>

---------

Signed-off-by: Alex Howells <alex@howells.me>
2026-03-17 18:37:03 +01:00
Thiago Vinhas 23123f060c feat: Add detached window with optional toolbar and hostname display (#1187)
* feat: Add detached window with optional toolbar and hostname display

* feat: Remove padding and borders in detached window mode

- Hide dotted background pattern in detached mode
- Remove margins around video container
- Remove min-width/height constraints and border/shadow on video

* simplify: use fixed window size for detached window

* refactor: reuse existing device route for detached window

* refactor: replace DetachedToolbar with Close button in ActionBar

* fix: preserve detached mode for modals and sidebar                                                                                                                                                                  │
                                                                                                                                                                                                                     │
navigateTo() was dropping the ?detached=true query parameter during                                                                                                                                                 │
navigation, causing the Virtual Media modal to exit detached mode.                                                                                                                                                  │
The modal and sidebar containers were also hidden in detached mode                                                                                                                                                  │
via isDetachedWindow guards, preventing them from rendering.                                                                                                                                                        │
                                                                                                                                                                                                                     │
Fix navigateTo to preserve the detached query param across                                                                                                                                                          │
navigations, and remove the guards on Modal and SidebarContainer                                                                                                                                                    │
so both work in the detached window.

* fix: enable terminal in detached mode and preserve detached param in mount route

  The Terminal components were guarded by !isDetachedWindow, preventing
  them from rendering. The mount route used raw navigate("..") which
  drops the ?detached=true query param, causing the navbar to reappear
  after closing the Virtual Media modal. Switched to navigateTo("/")
  which preserves search params, and use the onClose callback for the
  Dialog's internal navigation.

* prettier formatting fixes

---------

Co-authored-by: Adam Shiervani <adam.shiervani@gmail.com>
2026-03-17 10:37:04 +01:00
Adam Shiervani 2b1aca49bc UI/split button clipboard (#1308)
* feat(ui): merge Copy & Paste into a split button group in action bar

Reduce action bar clutter by combining the Copy Text (OCR) and Paste
buttons into a single split button with a dropdown caret. Paste remains
the primary action; Copy Text is accessible via the dropdown menu.

Adds a reusable SplitButton component built on Headless UI Menu.

Made-with: Cursor

* fix(ui): fix className override and remove unused SplitButton wrapper

Merge HeadlessUI className with internal styles in SplitButtonPrimary
instead of silently discarding it. Remove unused SplitButton convenience
wrapper.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(ui): fix lint errors and prettier formatting

Made-with: Cursor

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 09:46:13 +01:00
DiamondCortex a17a4a32da feat: display hostname in browser tab and nav bar (#1163)
This feature helps users with multiple JetKVM devices quickly identify which device a browser tab belongs to. The hostname appears in the browser tab title (format: "{hostname} - JetKVM") and in the navigation bar next to the logo.
2026-03-17 09:30:53 +01:00
Alex Howells 51d73897ae fix(network): restore IPv6 disabled option in network settings (#1279)
The IPv6 mode dropdown in Settings > Network does not include a Disabled
option, preventing users from running IPv4-only. The backend already
accepts "disabled" as a valid IPv6Mode value (config.go one_of
constraint includes it), and the localization string exists, but the
frontend option was commented out during PR #878 and left that way when
PR #864 re-added it.

Uncomment the disabled option in the IPv6 mode select menu. IPv4 mode
does not expose a disabled option in the UI, so there is no path to
disabling both stacks simultaneously through the settings page.

Fixes #1039
2026-03-16 14:49:42 +01:00
Thiago Vinhas aece7ddc15 feat: Added copy text to clipboard from kvm using OCR (#1207)
* [FEATURE] Added copy text to clipboard from kvm using OCR

* fix: make OCR overlay cover full viewport

Change the semi-transparent background from absolute to fixed
positioning so it extends to the viewport edges, covering the
letterbox/pillarbox areas outside the video container.

* refactor: use Card component for OCR selection size indicator

Replace the manually-styled div with the Card component for the
selection size indicator, consistent with the project's component
library.

* feat: use ConfirmDialog for OCR processing indicator

* refactor: use ConfirmDialog for OCR result panel

* fix: make OCR result textarea readonly with text pre-selected

* refactor: use Cancel and Copy CTAs for OCR dialogs

* chore: machine translate OCR localization keys

* fix: address OCR copy-paste review feedback

  - Fix cursor-pointer showing on disabled button in ConfirmDialog
  - Change CTA from "Copy" to "Copy text" for clarity
  - Fix selection breaking when mouse leaves viewport during drag
  - Add children prop to ConfirmDialog; use description for helper text
  - Fix clipboard.writeText failing in insecure contexts with fallback

* fix: address remaining OCR review feedback

  - Merge processing and result into a single ConfirmDialog so the modal
    doesn't flicker when OCR completes quickly
  - Add dark:text-white to the selection size pill Card
  - Remove duplicate toast in the execCommand fallback (the copy event
    listener already handles it)
  - Sequence dialog close → unmount so HeadlessUI's leave transition plays

* fix: prevent OCR dialog content flash during close animation

When closing the OCR result dialog, the title briefly flashed back to
"Recognizing text..." during the exit animation. This happened because
closeOverlay reset status to "idle" to close the dialog, but the
dialog content ternaries also depended on status, causing them to
evaluate incorrectly during the 200ms HeadlessUI leave transition.

Fix by introducing an isClosing flag that controls the dialog's open
prop independently, leaving status unchanged so dialog content remains
stable during the exit animation.
2026-03-16 14:44:29 +01:00
Adam Shiervani 15dc380062 fix: auto-recover USB gadget when host power-cycles (#1297)
* fix: auto-recover USB gadget when host reconnects (#128)

When the USB host reboots or disconnects, the UDC state becomes
"not attached" and never recovers. Add automatic recovery that detects
this state and rebinds the USB gadget, with rate limiting to avoid
thrashing. Also refactor keyboard HID file handling to support
force-reopen after rebind.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* refactor: move USB recovery logic to internal/usbgadget package

Extract ShouldAttemptUSBRecovery and its retry interval constant into
the internal/usbgadget package so the logic is testable without
importing the top-level kvm package. Reset the recovery timer in
updateUsbRelatedConfig to prevent auto-recovery from interfering
during the host's USB re-enumeration window after a deliberate
config change.

Made-with: Cursor

* feat(e2e): add JSON-RPC data channel support to test hooks

Expose the WebRTC RPC data channel through test hooks and add a
sendJsonRpc helper that sends a JSON-RPC request over the channel
and resolves via callback with timeout handling. This enables e2e
tests to invoke backend RPC methods directly.

Made-with: Cursor

* refactor(e2e): restructure tests with remote-agent suite

Move device-level e2e tests (config-reset, EDID, HTTPS, HDMI sleep,
LED, mouse, USB attach timing, USB device) into a consolidated
remote-agent suite that runs through a Go-based agent binary. Add a
separate Playwright project for remote-agent tests.

- Remove standalone spec files now covered by ra-all.spec.ts
- Rename login-rate-limit to zz-login-rate-limit so it runs last
  (avoids needing a post-test reboot to clear rate-limit state)
- Reorder welcome-password tests so validation runs first, reusing
  the onboarding state and saving an SSH reset cycle

Made-with: Cursor

* refactor(e2e): clean up test helpers and reduce duplication

Consolidate all test helpers into a single helpers.ts file, removing
the separate ota-helpers.ts. This gives every test file a single import
source and eliminates duplicated code across the test suite.

Key changes:
- Merge ota-helpers.ts into helpers.ts (mock server, binary deployment,
  device config, env var validation, triggerUpdate, withTempSignature)
- Remove duplicated rpc/restartAppViaSSH/waitForDeviceReady functions
  from ra-all.spec.ts in favour of shared imports
- Extract loginAndOpenSettings helper in settings-local-auth tests
- Extract getOTAEnvVars, toPreReleaseVersion, triggerUpdate, and
  withTempSignature to reduce boilerplate across OTA tests
- Remove unused verifyMouseWorks function and dead variables
- Strip redundant JSDoc that just restated type signatures
- Remove duplicated per-project use config from playwright.config.ts
  (already inherited from top-level)
- Convert dynamic imports in sshExec to top-level imports

Made-with: Cursor

* refactor(e2e): move binary deployment into Playwright globalSetup

Replace the shell-script deployment logic with Playwright's
globalSetup/globalTeardown hooks. When BASELINE_BINARY_PATH is set,
globalSetup deploys the binary, resets device config, reboots, and
captures pre-test logs. globalTeardown captures post-test logs.

This keeps the deployment lifecycle inside Playwright where it belongs,
and reduces test_core_e2e.sh to a thin wrapper that sets env vars.

Made-with: Cursor

* fix: retry HID file reopen after USB gadget rebind

After rebinding the DWC3 USB controller, the kernel needs a moment to
create the /dev/hidg* device nodes. The previous code attempted to
reopen the keyboard HID file immediately after rebind, which raced
with the kernel and failed with "no such device or address".

Add a retry loop (up to 10 attempts, 200ms apart) to wait for the
device nodes to appear before reopening the keyboard HID file.

Made-with: Cursor

* fix: harden USB gadget recovery after UDC unbind

Reset stale HID gadget handles after rebind, suppress transient HID-open errors during detach windows, and fall back to full gadget reconfiguration when simple UDC rebind does not restore keyboard HID promptly. Strengthen the remote-agent USB recovery E2E to verify both keyboard and mouse input recover after unbind with retry tolerance for host-side input node churn.

Made-with: Cursor

* refactor: simplify USB HID error handling and reduce hot-path overhead

- Use errors.Is with syscall.Errno instead of string matching in
  IsHIDTemporarilyUnavailableError (robust, zero-alloc)
- Cache USB state in usbReadyForHidReports instead of reading sysfs
  on every HID report
- Extract rpcHidReport wrapper to deduplicate 5 rpc*Report functions
- Fix openWithTimeout goroutine/fd leak on timeout
- Add USBStateNotAttached/USBStateUnknown constants, replace literals
- Deduplicate discoverJetKVMDevices by delegating to listInputDevices

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat: run remote-agent e2e tests when JETKVM_REMOTE_HOST is provided

Made-with: Cursor

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-16 12:54:20 +01:00
Adam Shiervani b3e120ff25 fix(ui): send raw characters for KVM terminal instead of JSON envelope (#1295)
* fix(ui): send raw characters for KVM terminal instead of JSON envelope

The Terminal component was wrapping all keystrokes in a
{"type":"serial","data":"X"} JSON envelope for both KVM and serial
terminals. The KVM backend (terminal.go) parsed any JSON as a
TerminalSize struct, which silently succeeded with Rows=0/Cols=0 due to
Go ignoring unknown fields, causing every keystroke to be dropped.

Differentiate the two paths: KVM terminal sends raw characters and flat
size JSON, while serial keeps the existing envelope format.

Made-with: Cursor

* fix(ui): match terminal header styling with virtual keyboard header

Made-with: Cursor

* style: fix prettier formatting in Terminal.tsx

Made-with: Cursor
2026-03-16 12:14:34 +01:00
Alex Howells f405a01b0c fix(ui): restore terminal focus after closing settings modal (#1287)
Opening Settings while a terminal (KVM or serial) is active and then
closing it leaves the terminal unresponsive. The FocusTrap component
reclaims focus to its fallback element on modal close, but nothing
re-disables the trap or re-focuses the xterm instance afterward.

In onModalClose (devices.$id.tsx), check whether a terminal is still
active via useUiStore and re-set disableVideoFocusTrap to true so the
FocusTrap stays paused.

In Terminal.tsx, add an effect that calls instance.focus() when the
focus trap is released back to the terminal. This covers the transition
from settings-closed -> terminal-active where xterm lost focus to the
FocusTrap fallback.

Fixes #390
2026-03-16 12:01:56 +01:00
Alex Howells 0f55f1cfd7 fix(video): prevent space bar from pausing video in fullscreen (#1278)
Browsers treat <video> elements specially: Space toggles play/pause at
the element level as a built-in behavior. The existing keyDownHandler on
document calls preventDefault, but DOM event propagation runs element ->
parent -> document, so the video element processes the Space keydown and
pauses playback before the document-level handler ever fires.

The existing workaround attaches a keyup listener on the video element
that calls .play() to undo the pause, but this produces a visible
pause/unpause flicker on every space press and does not work in Safari.

Fix: add a keydown listener directly on the <video> element that calls
preventDefault for Space. This intercepts the event at the element level
before the browser's native toggle runs, so the pause never occurs. The
existing keyup force-play handler is retained as a safety net for any
browser that still manages to pause the stream.

Fixes #265
2026-03-16 11:44:11 +01:00
Alex Howells f25073091b fix(ui): add warning when SSH public key is empty in developer mode (#1280)
The SSH public key field in Settings > Advanced has no indication that a
key is required for SSH to work. Users enable developer mode, skip the
key field, and then cannot connect via SSH. The existing helper text only
mentions the default user is root.

Add an amber warning below the SSH key field that appears when the field
is empty: "A public key is required for SSH access. Without one, you
will not be able to connect." Uses the same text-amber-600 style as the
developer mode warning icon on the same page. The warning disappears
once a key is entered.

The save button remains enabled with an empty key so users can
intentionally clear their key to revoke SSH access.

Translations added for all 13 supported languages: da, de, en, es, fr,
it, ja, nb, pt, ru, sv, zh, zh-tw.

Fixes #1051
2026-03-16 11:42:29 +01:00
Alex Howells f115afec44 fix(ui): enable scrolling on cloud devices page (#1282)
The cloud devices page uses overflow-hidden on the content container,
which clips device cards that extend beyond the viewport. On mobile
(iPhone) or when more than a few devices are registered, the lower cards
are unreachable.

Change overflow-hidden to overflow-y-auto so the device list scrolls.

Fixes #1253
2026-03-16 11:41:15 +01:00
Alex Howells 71a948b4db feat(keyboard): add Hungarian keyboard layout (hu_HU) (#1284)
Add QWERTZ-based Hungarian 105-key keyboard layout with full support for
accented characters (á, é, í, ó, ö, ő, ú, ü, ű) via accent key
composition (acute, double acute, trema), AltGr symbols, and
Y/Z swap.

Layout code provided by @Guhl-Y in #1184.

Fixes #1184
2026-03-16 11:39:36 +01:00