605 Commits

Author SHA1 Message Date
Adam Shiervani 3ae3fae421 Don't trust proxy headers for client IP (#1492)
The device is reached directly rather than behind a reverse proxy, but gin
trusts all proxies by default, so c.ClientIP() returns the client-controlled
X-Forwarded-For/X-Real-IP value. A caller could rotate that header to get a
fresh rate-limit key on every request and bypass the login/setup throttle.

Call SetTrustedProxies(nil) so ClientIP() uses the real socket peer.
2026-06-11 10:55:39 +02:00
Adam Shiervani de90126017 Fix e2e flakyness release/0.5.9-dev202606110823 2026-06-10 11:33:14 +02:00
Adam Shiervani d5d473bdbd Fix e2e flakyness 2026-06-09 20:50:45 +02:00
iain MacDonnell da2999b578 UI: Add Save & Disconnect button for MQTT on disable (#1486)
Adds a button that's visible (only) when MQTT is disabled, such that the
new setting may be saved and effected, and a corresponding notification
message.

Closes: #1485
2026-06-09 14:47:29 +02:00
Adam Shiervani 800c0cbb53 Add USB audio gadget permission setting (#1479)
* fix disabled audio gadget

* add usb audio device setting

* polish usb audio disabled hint

* stabilize disconnect key release test

* clear audio when usb audio is disabled

* guard audio config with usb gadget
2026-05-29 08:57:10 +02:00
Adam Shiervani f332ceefbf feat: add idle host display setting (#1476)
* feat: hide host display while idle

* feat: add idle display setting

* fix: handle host display advertisement errors

* fix: serialize host display advertisement decisions

* fix: suppress transient HDMI startup error

* docs: explain HDMI startup grace period

* fix: avoid redundant EDID reapply on sessions

* fix: reset video playing state on reconnect
release/0.5.9-dev202605250953
2026-05-25 11:52:22 +02:00
Adam Shiervani c0fcfcba61 Stabilize remote agent input E2E tests (#1477) 2026-05-25 11:51:45 +02:00
Caedis 93029a0ac3 feat(ui/mouse): forward mouse X1/X2 buttons to remote (#1473)
Browser default Back/Forward nav was eating buttons 4/5 before the HID
path ever saw them.
Suppress nav with mousedown/mouseup/auxclick preventDefault, and mask
buttons to bits 0..4 so pen-eraser and any future high bits don't leak
onto the wire.
HID descriptors and RPC already carry the full byte; no backend change
needed.
2026-05-25 10:06:44 +02:00
Adam Shiervani 90750bf609 Experimental Audio Support (#1475)
* WIP: USB audio capture via UAC1 gadget with G.722 + PCMU encoding

Adds ALSA capture from the USB UAC1 gadget, G.722/PCMU encoding, a WebRTC
audio track, and an e2e remote-agent flow that plays a tone on the remote
host and verifies it reaches the browser.

Snapshot of codex-driven implementation before simplification.

* Simplify USB audio path: drop residual scaffolding from long session

Backend:
- audio.go: drop source rotation + "no-data reopen with next device" loop.
  One source (UAC1Gadget; falls back to hw:1,0 only if sysfs lookup fails).
- internal/audio: remove unused Reader interface and unavailableCapture stub.
  Stub now returns the concrete type with an error.
- webrtc.go: inline single-use resolveAudioCodec helper; DRY video/audio RTCP
  drain into drainRTCP; fold startSessionAudio into the connect callback.

Frontend:
- devices.$id.tsx: drop remoteMediaStreamRef track-merging. Backend tracks
  share stream ID "kvm", so pion delivers them in one MediaStream — just
  assign event.streams[0].
- WebRTCVideo.tsx: replace dynamic per-track <audio> creation + ref array
  with a single hidden <audio> bound to mediaStream.

Remote agent:
- Drop PipeWire/wpctl detection path; plughw: works directly.
- Drop killStaleAudioToneProcesses pkill workaround; the (cmd, cancel, done)
  trio collapses to a single *exec.Cmd field with Start/Kill/Wait.

E2E:
- ra-audio.spec.ts: drop attachAudioDiagnostics scaffold and openReadyPage
  duplicate. Spec is now linear: setup → wait for track → diff stats → tone.

Net: ~355 LOC removed.

* Fix onSessionConnected race; replace onFirst/CurrentSessionConnected split

The previous codex split routed audio start through onCurrentSessionConnected,
gated on session == currentSession. But currentSession is assigned by the
caller (web.go, cloud.go) AFTER ExchangeOffer returns, while
OnICEConnectionStateChange can fire from inside ExchangeOffer or shortly
after — racing the assignment. When the race hits, the equality check fails,
the callback is skipped, and audio never starts.

Pass session into the callback directly so the per-session setup uses the
session in hand, not whatever currentSession happens to point to at that
instant. Keep stopVideoSleepModeTicker on the count-edge (still only on
first-session) and let onSessionConnected handle the rest unconditionally.

* Pin WebRTC playout-delay to zero so receivers don't ratchet under stress

Chrome's adaptive receive-side jitter buffer grows under stress (e.g.
playing a video on the controlled machine) and does not reliably shrink
back; the Connection Stats "Playback Delay" graph used to climb to
~300 ms and stay there until the page was reloaded.

The trigger is the USB UAC1 audio path, not video motion per se — once
real audio starts flowing, Chrome's AV-sync layer pulls the video
jitter buffer up to whatever the audio path settles at, and the ratchet
locks in. Receiver-side hints (jitterBufferTarget, playoutDelayHint,
setMinimumJitterBufferDelay) cap the steady state but don't release a
buffer that has already grown.

Fix: register the WebRTC playout-delay RTP header extension on both
audio and video and stamp min=max=0 on every outgoing packet via a
pion interceptor. Chrome treats this as an authoritative override of
its adaptive logic and keeps both buffers at the decoder floor through
and after stress, with no peer-connection rebuild needed.

Test: drive the host display with a real audio+video file via
gst-launch playbin (audio routed through PipeWire to the USB UAC1
sink) and assert receive-side video delay stays bounded both during
and after playback.

* Simplify audio capture loop and ALSA reader

- Drop short-read zero-fill in ALSA reader; return ErrNoAudioData so the
  capture loop emits no frame for the cycle instead of half-silent audio.
- Replace ErrNoAudioData = io.ErrNoProgress (wrong semantic) with a domain
  sentinel and remove the unused idleReads debug counter.
- Encoders sum all source samples before one divide — better precision,
  fewer ops; clampS16 and sampleS16 helpers gone.
- Resolve audio codec inline in runAudioCapture; drop the audioCodecForTrack
  wrapper. Caller checks AudioTrack != nil so startAudio no longer accepts
  nil as a stop signal.
- Use C.GoString instead of hand-rolled cString helper.
- Add a why-comment on the separate <audio> element (video stays muted).

* Trim playoutdelay per-symbol comments

Keep the package-level "why" (Chrome's one-way jitter buffer); drop
restate-the-signature comments on Factory, NewFactory, NewInterceptor,
and BindLocalStream.

* e2e: extract shared audio helpers and drop dev-only override

- Lift ensureNoPasswordViaAPI and waitForAudioStream into helpers.ts (the
  audio spec was inlining both, the latter as a copy of waitForVideoStream).
- ra-audio.spec.ts shrinks from 78 to 55 lines.
- Remove the JETKVM_AUDIO_DEVICE override in the remote agent: it fabricated
  an AudioDeviceInfo with is_jetkvm=true regardless of what device the env
  var pointed at, silently lying to the spec's assertion. Audio device
  discovery via aplay + /proc/asound/.../usbid is reliable; if no JetKVM
  device is present the spec already skips.

* Reopen ALSA capture on persistent read errors

The C-side recovers EPIPE/ESTRPIPE via snd_pcm_recover; the errors that
surface to Go (EBADFD, ENODEV, …) usually mean the handle is dead —
typically a USB gadget rebuild or host reattach mid-session, which used
to leave audio silent until the session disconnected.

After 5 consecutive non-idle read errors, close and reopen the capture
with exponential backoff (100 ms → 2 s cap). Initial open uses the same
helper so we keep retrying instead of giving up if the gadget isn't ready
yet. Re-resolves the card each attempt so a USB re-enumeration that
shifts the card number is picked up automatically.

* Add Audio settings page with Enable Audio toggle (experimental)

Audio is opt-in via device config. New Audio nav entry in Settings sits
next to Video, with a single "Enable Audio" item marked Experimental
(mirrors HTTPS Mode in Access).

Backend:
- Config.AudioEnabled (default false), persisted to /userdata/kvm_config.json
- getAudioConfig / setAudioConfig JSON-RPC handlers
- webrtc.go: extract attachAudioTrack helper; skip track creation when
  disabled or when the offer advertises no supported codec. The SDP
  answer leaves the audio m-line inactive, so flipping the toggle
  requires a fresh connection (page reload).

Frontend:
- New devices.$id.settings.audio.tsx — fetches state via getAudioConfig,
  saves via setAudioConfig, optimistic UI with rollback on error.
- devices.$id.tsx always offers audio in the SDP; backend decides.
- en.json + 13 locale files: 5 keys each (audio_*, settings_audio) with
  proper translations honoring per-language formality.

E2E:
- ra-audio.spec.ts: connect, enable via RPC, reload, verify audio energy.
  Restores disabled state in a finally block so other specs aren't
  affected. 9 s on kvm-2 + .180.

* Reload page after enabling audio so the browser prompts for autoplay

Re-negotiation only happens on a fresh WebRTC session, and the autoplay
overlay needs a user gesture to play the new audio track. A simple reload
covers both — the toggle's user click acts as the gesture, the new offer
includes audio, and the overlay surfaces normally.

Disable path is unchanged (audio stops naturally on the next connect).

* Reload page on disable too so audio stops immediately

* Trim audio_* copy across all 14 locales

Page header and item description previously said roughly the same thing
in long form. Now: page-level describes the topic ("Stream audio from
the host to your browser"); item-level is a terse one-liner ("Stream
HDMI audio from the host."). Drops the "Requires a fresh connection"
clause — the page auto-reloads on toggle, so it's no longer accurate.

Per-language tone follows I18N_BEST_PRACTICES.md: formal Sie/vous/usted/
вы/chi (de/fr/es/ru/cy), informal du (sv/nb/da), polite です/ます (ja),
infinitive (it), European Portuguese (pt).

* Fold audio e2e into the standard remote-agent project

Drop the dedicated test_audio_e2e Makefile target and the separate
remote-agent-audio Playwright project. The remote-agent project now
matches every ra-*.spec.ts under e2e/remote-agent, so make test_e2e
runs ra-audio.spec.ts alongside ra-all.spec.ts in the same worker.

* Fall back to bare-track MediaStream when ontrack streams[] is empty

Hit a state where pc.getReceivers() showed live video and audio tracks
but useRTCStore.mediaStream stayed undefined — the SDP answer arrived
without a=msid, so event.streams[0] was undefined and
setMediaStream(undefined) left the store empty even though RTP was
flowing. Only a hard reload recovered.

Now: when the event carries a stream, use it as before. When it doesn't,
get-or-create a MediaStream and append the track. Re-using the existing
store value across both ontrack invocations keeps audio + video on the
same MediaStream so the autoplay/video pipeline downstream is unchanged.

* Close peer connection before reload-on-toggle

Firefox's soft reload doesn't always tear down the RTCPeerConnection,
which leaves the post-reload page in a half-renegotiated state: tracks
arrive on receivers but never attach to a MediaStream, so video stays
stuck on "Loading…" (or the page falls back to the pre-connect blue
background) until a hard refresh. Closing the PC explicitly before
reload guarantees a clean start.

* Aggregate ontrack into one canonical MediaStream

The answer SDP from pion omits a=msid for the audio track in some
configurations (visible on Firefox: video keeps its msid, audio doesn't).
The previous handler called setMediaStream(event.streams[0]) on each
ontrack, so:

  1. video ontrack → setMediaStream(streamA)  [has video]
  2. audio ontrack → setMediaStream(streamB)  [synthetic, audio only]

streamB replaces streamA, video disappears. Hard refresh only "fixed" it
incidentally — the same SDP would break the next negotiation too.

Now: ignore event.streams[0], maintain one canonical MediaStream in the
store, and addTrack into it on every ontrack. Browsers render tracks
added to a live MediaStream that's already attached to srcObject, so
both audio and video stay attached regardless of which order ontrack
fires or whether the SDP carried msid.

* Only render <audio> element when device audio is enabled

The backend keeps the m=audio section in the SDP even when audio is
disabled (just inactive direction), so Firefox still attaches a muted
audio track to the MediaStream. The autoplay <audio> element then
triggers Firefox's "block audio" policy on a stream that will never
actually play any sound.

Fetch getAudioConfig once the RPC channel is up, then conditionally
render the <audio> element. No autoplay prompt when audio is off.

* Drop stale mediaStream on reconnect; release audio capture when owner session ends

* Back off briefly on idle ALSA reads to avoid CPU spin

* Re-wire <audio> when track arrives late; gate VideoStart to first session
2026-05-22 10:43:30 +02:00
Adam Shiervani e3595cdea2 Add explicit USB wake control (#1471) 2026-05-22 10:22:48 +02:00
Adam Shiervani 5427b80248 test(e2e): preserve remote agent diagnostics (#1457)
* test(e2e): lower s3 wake version gate

* test(e2e): preserve device logs across restarts

Capture failing device logs before later restarts can truncate last.log, and keep a rotated copy available during global teardown.
release/0.5.9-dev202605220745
2026-05-19 10:34:32 +02:00
Adam Shiervani 6419d049a2 Fix webrtc session shutdown race (#1468) 2026-05-18 10:22:15 +02:00
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 c2e29cd3c8 fix(video): signal limited-range BT.709/BT.601 colorimetry in encoder VUI (#1460)
Annotate the H.264/H.265 bitstream with proper VUI colour_primaries,
transfer_characteristics, and matrix_coefficients (BT.709 for >576p,
SMPTE 170M otherwise) plus video_full_range_flag=0. Without this,
browser WebRTC decoders fall back to BT.601/full-range guesses,
producing tinted output on HD sources.

Also set the matching V4L2 capture colorimetry hints and hoist
constant per-stream fields out of the per-frame SendFrame loop.
2026-05-13 09:39:56 +02:00
Adam Shiervani b878ba9e20 fix: align S3 wake test version gate (#1458) release/0.5.9-dev202605120910 2026-05-12 10:27:14 +02:00
Maurus Cuelenaere 5806c80e6a fix(websecure): cap serial numbers at 128 bits for Apple TLS clients (#1453)
The 4096-bit limit produced ~500-byte serials that violate RFC 5280
§4.1.2.2's 20-octet cap. Apple's DER parser enforces this strictly,
so URLSession, NWConnection, AVFoundation and every other client on
macOS/iOS/tvOS rejected the cert with "Unknown format in import"
before any trust evaluation ran.

Lower the limit to 128 bits (matching Go stdlib's generate_cert.go
example) and add a one-shot migration that drops any already-baked
oversized CA, plus the leaves it issued, on startup so existing
devices recover without manual SSH cleanup.

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-12 09:44:16 +02:00
Marc Brooks d266805097 Fix tzdata generator to emit go fmt formatted file (#1442)
Regeneration added "America/Coyhaique"
2026-05-12 09:20:20 +02:00
Dmitry Lisovsky 79c23ec89f fix: make mouse jiggler use tiny random movement near current position (#1424) 2026-05-05 23:41:58 +02:00
Adam Shiervani c8da5966a8 Bump version to 0.5.9 2026-05-04 11:19:56 +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.
release/0.5.8 release/0.5.8-dev202605011250
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 4536a88b08 feat(release): upload app binary per SKU to R2 (#1432)
The cloud-api releases endpoint now resolves OTA artifacts under
app/<version>/skus/<sku>/, so each known SKU needs its own folder. Add
an APP_SKUS list (jetkvm-v2, jetkvm-v2-sdmmc) and have both dev_release
and release upload the same jetkvm_app binary (plus .sha256, plus .sig
for production) into every SKU folder. Keep APP_SKUS in sync with
KNOWN_SKUS in cloud-api/scripts/sync-releases.ts.

Show the planned R2 destination paths and SHA256 in the final pre-upload
prompt so the operator can verify destinations before pushing, mirroring
the rv1106-system release_r2.sh pattern. Also relax the existence
pre-flight (rclone lsf | grep -q .) so it catches the new skus/
subfolder layout.
2026-04-28 23:30:18 +02:00
Adam Shiervani d54309cfff Bump version to 0.5.8 2026-04-27 12:13:50 +02:00
Lian Duan eb56468d25 feat(ota): include device SKU in update requests (#1429)
* feat(ota): include device SKU in update requests

Read /etc/jetkvm-sku at startup and pass the value as a sku query
parameter when checking for OTA updates, enabling the cloud API to
serve variant-specific firmware for jetkvm-v2 (eMMC) and
jetkvm-v2-sdmmc hardware. Falls back to "jetkvm-v2" when the file
is absent for backwards compatibility.

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

* feat(ota): include SKU in auto-update loop

The background auto-update loop in main.go also constructs UpdateParams
and was missing the SKU field, so periodic updates would not transmit
the device SKU to the cloud API.

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

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
release/0.5.7 release/0.5.7-dev202604270923
2026-04-25 23:36:29 +02:00
Adam Shiervani 38bb8c0107 Bump version to 0.5.7 2026-04-09 11:49:31 +02:00
Adam Shiervani da5793a117 fix(logging): reset troubleshooting log level after reboot (#1404)
* fix(logging): reset troubleshooting log level after reboot

Restore WARN on boot so temporary troubleshooting verbosity cannot silently fill device storage before rolling logs land. Update the settings copy to describe the reboot reset, add an e2e regression test for the INFO-to-WARN behavior, and make the i18n resort helper accept lint-staged file arguments.

* fix(i18n): avoid formatter churn in locale files

Keep localization message JSON on the repo's existing resort_messages.py formatting so translation-only changes do not explode into full-file diffs. Remove JSON from the generic oxfmt lint-staged rule and restage the locale files in their canonical format.
release/0.5.6 release/0.5.6-dev202604090907
2026-04-09 11:07:04 +02:00
Adam Shiervani 73d78ef984 fix(test): tighten remote-agent wake gating
Retry relative-only horizontal wheel input after USB re-enumeration and require system 0.2.9 for S3 wake tests so hardware-dependent cases only run where the wake path is expected to work.
2026-04-08 12:39:41 +02:00
Adam Shiervani 1c478b1264 fix(test): harden remote agent serial console checks
Improve the remote-agent e2e helpers to escape SSH commands, time out stuck SSH calls, and retry transient failures. Replace fixed ttyACM sleeps with polling so serial console tests wait for the actual remote host state.
release/0.5.6-dev202604071221
2026-04-07 14:09:01 +02:00
Adam Shiervani a53fd6e731 fix(test): use relative mouse wake for S3 suspend (#1402)
Use relative mouse reports for the S3 wake e2e path because absolute mouse movement is not a reliable wake source on some hosts. This keeps the test aligned with a wake path that passes against the current JetKVM and remote host setup.
2026-04-07 13:06:43 +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 49f30d9dde test(e2e): add S3 suspend/wake tests via USB remote wakeup (#1392)
Add two e2e tests that verify JetKVM can wake a host from S3 sleep
using HID input (keyboard and mouse). Tests trigger suspend via
`echo mem > /sys/power/state`, wait for the host to become
unreachable, then send HID reports and verify recovery.

Both tests skip gracefully when system firmware is below 0.2.8
(missing wakeup_on_write kernel support) or when the remote host
doesn't support S3 deep sleep.

Also adds a `semverGte` helper for version comparisons.
2026-04-02 15:01:14 +02:00
Adam Shiervani 73a67e88ab fix(mouse): prevent double wheel scroll by sending to only one HID device (#1390)
rpcWheelReport was unconditionally sending wheel events to both the
absolute and relative mouse HID devices. The host OS sees two separate
USB HID devices each producing a scroll event, so it processes both,
doubling the effective scroll distance (e.g. 6 lines instead of 3 on
Windows).

Send wheel events to the absolute mouse device when enabled, falling
back to the relative mouse device otherwise. This mirrors how pointer
reports already work (absMouseReport vs relMouseReport are separate).

Add an E2E test that verifies exactly one wheel event is received per
wheelReport RPC call when both mouse devices are enabled.
release/0.5.6-dev202604010901
2026-04-01 10:58:58 +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 8d4c29321f Bump version to 0.5.6 2026-03-31 11:25:38 +02:00
Adam Shiervani 0806c23f6b fix(test): add wakeDisplay and waitForVideoStream to video codec tests (#1385)
Ensure display is active and video stream is flowing before asserting
codec stats, preventing flaky failures on idle/sleeping displays.
release/0.5.5
2026-03-31 11:04:16 +02:00
Adam Shiervani 238ab07d43 fix(test): add ota-upgrade-to-signed project to dev_release target (#1384) 2026-03-31 10:09:55 +02:00
Adam Shiervani 41ea6b93ca fix(test): fix e2e flakiness from hook timeouts, EDID drops, and USB recovery (#1382)
OTA beforeAll/afterAll hooks were using the global 60s timeout while
performing multi-reboot sequences that need minutes. Add explicit
test.setTimeout(420000) inside each hook.

setEDID RPC can drop the WebSocket on some devices during HDMI re-link.
Tolerate RPC timeouts, reconnect page + WebRTC afterwards, and retry
agent resolution checks with a 15s polling loop.

EBUSY CDROM test used sysfs traversal that broke across kernel/host
variations. Switch to lsblk and skip gracefully when the host doesn't
enumerate USB mass storage as sr* devices.

USB recovery test left the device bricked (initUsbGadget crash loop)
when auto-recovery failed. Add try/catch that re-binds the UDC and
reboots on failure.

OTA reconnectAfterReboot retries increased from 15 to 30 (65s → 95s)
to handle slower devices.
release/0.5.5-dev202603301741
2026-03-30 19:26:09 +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 c7344ed673 fix(test): fix e2e flakiness from HDMI signal not detected during beforeAll (#1377)
The remote host display may enter DPMS sleep during the build/deploy
phase, causing the KVM to report "No HDMI signal detected" when the
remote-agent tests start. Wake the display via DPMS before waiting,
increase the timeout from 15s to 30s, and add a page-reload retry
if the WebRTC session connected before the signal was detected.

Authored-by: Adam Shiervani <adam.shiervani@gmail.com>
2026-03-30 00:16:05 +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 a5bb97eb7b test(ota): add e2e test for upgrading to a signed release (#1373)
Validates that the locally-built binary can accept a GPG-signed OTA
update. Deploys the dev build, then serves the latest production binary
and its real signature via a mock update server. Runs in the regular
test_e2e lane — no signing key required.
2026-03-29 20:36:54 +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
Adam Shiervani 879a8559e2 test(timesync): add e2e tests for custom NTP configuration (#1370)
Verify that setting a custom NTP server via RPC results in the server
being queried (validated through Prometheus metrics), and that an invalid
hostname gracefully falls back to default NTP servers.

Co-authored-by: Adam Shiervani <adam@jetkvm.com>
2026-03-29 13:07:26 +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