feat(client-linux): native GTK4 client — stage 1, first light at 1080p60
ci / rust (push) Failing after 29s
ci / web (push) Failing after 35s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 5s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 3s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 18s
ci / docs-site (push) Failing after 38s
apple / swift (push) Successful in 1m15s
docker / deploy-docs (push) Successful in 17s

New crate crates/punktfunk-client-linux (binary punktfunk-client), the
native Linux client on the Option A architecture (2026-06-12 research):

- GTK4/libadwaita shell linking punktfunk-core directly (no C ABI):
  mDNS host list, TOFU fingerprint prompt, SPAKE2 PIN pairing dialog,
  preferences (mode/bitrate/gamepad/shortcut capture), stats overlay,
  --connect host[:port] for scripting.
- Video: FFmpeg software HEVC decode (LOW_DELAY, slice threads) ->
  RGBA -> GdkMemoryTexture inside GtkGraphicsOffload (the dmabuf
  subsurface path lights up when VAAPI lands; black-background keeps
  fullscreen scanout-eligible).
- Audio: Opus -> PipeWire playback stream, the host virtual-mic's
  adaptive jitter ring inverted.
- Input: keyboard as the exact inverse of the host VK table (evdev
  keycodes, layout-independent; unit-tested), absolute mouse through
  the Contain-fit transform, WHEEL_DELTA(120) scroll, compositor
  shortcut inhibition while streaming, Ctrl+Alt+Shift+Q release chord,
  F11 fullscreen. SDL3 gamepad capture (single pad-0 model) + rumble
  and DualSense lightbar feedback on the same thread.
- Session pump owns video+audio pulls; the gamepad thread owns
  rumble+hidout — possible because NativeClient's plane receivers are
  now mutexed, making it Sync (Arc-shared, compiler-verified per-plane
  contract instead of the ABI's manual assertion).
- Linux-gated deps + a stub main keep cargo build --workspace green on
  macOS.

Validated live against serve --native on this box: 1920x1080@60,
locked 60 fps, capture->decoded p50 ~6.4 ms (software decode, debug
build). Teardown keys off AdwNavigationPage::hidden — NavigationView
push fires a transient unmap/map cycle that must not end the session.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-06-12 20:16:30 +00:00
parent 99b4de32ee
commit 96a35ca84c
17 changed files with 2518 additions and 4 deletions
@@ -0,0 +1,93 @@
//! Preferences dialog: stream mode, bitrate, gamepad type, capture behavior. Written
//! back to disk when the dialog closes.
use crate::trust::Settings;
use adw::prelude::*;
use std::cell::RefCell;
use std::rc::Rc;
const RESOLUTIONS: &[(u32, u32)] = &[(1280, 720), (1920, 1080), (2560, 1440), (3840, 2160)];
const REFRESH: &[u32] = &[30, 60, 90, 120, 144, 165, 240];
const GAMEPADS: &[&str] = &["auto", "xbox360", "dualsense"];
pub fn show(parent: &impl IsA<gtk::Widget>, settings: Rc<RefCell<Settings>>) {
let page = adw::PreferencesPage::new();
let stream = adw::PreferencesGroup::builder().title("Stream").build();
let res_names: Vec<String> = RESOLUTIONS
.iter()
.map(|(w, h)| format!("{w} × {h}"))
.collect();
let res_row = adw::ComboRow::builder()
.title("Resolution")
.subtitle("The host creates a virtual output at exactly this size")
.model(&gtk::StringList::new(
&res_names.iter().map(String::as_str).collect::<Vec<_>>(),
))
.build();
let hz_row = adw::ComboRow::builder()
.title("Refresh rate")
.model(&gtk::StringList::new(
&REFRESH
.iter()
.map(|r| format!("{r} Hz"))
.collect::<Vec<_>>()
.iter()
.map(String::as_str)
.collect::<Vec<_>>(),
))
.build();
let bitrate_row = adw::SpinRow::with_range(0.0, 500.0, 5.0);
bitrate_row.set_title("Bitrate");
bitrate_row.set_subtitle("Mbit/s · 0 = host default");
stream.add(&res_row);
stream.add(&hz_row);
stream.add(&bitrate_row);
let input = adw::PreferencesGroup::builder().title("Input").build();
let pad_row = adw::ComboRow::builder()
.title("Gamepad type")
.subtitle("The virtual pad the host creates (DualSense needs a Linux host)")
.model(&gtk::StringList::new(&["Auto", "Xbox 360", "DualSense"]))
.build();
let inhibit_row = adw::SwitchRow::builder()
.title("Capture system shortcuts")
.subtitle("Forward Alt+Tab, Super, … to the host while streaming")
.build();
input.add(&pad_row);
input.add(&inhibit_row);
page.add(&stream);
page.add(&input);
// Seed from the current settings.
{
let s = settings.borrow();
let res_i = RESOLUTIONS
.iter()
.position(|&(w, h)| w == s.width && h == s.height)
.unwrap_or(1);
res_row.set_selected(res_i as u32);
let hz_i = REFRESH.iter().position(|&r| r == s.refresh_hz).unwrap_or(1);
hz_row.set_selected(hz_i as u32);
bitrate_row.set_value(f64::from(s.bitrate_kbps) / 1000.0);
let pad_i = GAMEPADS.iter().position(|&g| g == s.gamepad).unwrap_or(0);
pad_row.set_selected(pad_i as u32);
inhibit_row.set_active(s.inhibit_shortcuts);
}
let dialog = adw::PreferencesDialog::new();
dialog.set_title("Preferences");
dialog.add(&page);
dialog.connect_closed(move |_| {
let mut s = settings.borrow_mut();
let (w, h) = RESOLUTIONS[(res_row.selected() as usize).min(RESOLUTIONS.len() - 1)];
(s.width, s.height) = (w, h);
s.refresh_hz = REFRESH[(hz_row.selected() as usize).min(REFRESH.len() - 1)];
s.bitrate_kbps = (bitrate_row.value() * 1000.0) as u32;
s.gamepad = GAMEPADS[(pad_row.selected() as usize).min(GAMEPADS.len() - 1)].to_string();
s.inhibit_shortcuts = inhibit_row.is_active();
s.save();
});
dialog.present(Some(parent));
}