From 371c023c817283721cbcc34b7cddc34da7fc6d79 Mon Sep 17 00:00:00 2001 From: Mitchell R Date: Sun, 10 May 2026 04:18:40 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20Rust=20kiosk=20app=20=E2=80=94=20GTK4?= =?UTF-8?q?=20+=20GStreamer=20multi-camera=20display?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Server discovery (localhost → betterframe.local → cloud) - Pairing flow with fullscreen code display - Bundle fetch and layout rendering - GTK4 Grid layout matching template regions - GStreamer pipelines per camera cell via gtk4paintablesink - Heartbeat loop in background thread - Placeholder widgets for web/html cells --- kiosk/Cargo.toml | 30 +++++ kiosk/src/bundle.rs | 107 +++++++++++++++ kiosk/src/main.rs | 16 +++ kiosk/src/pipeline.rs | 82 ++++++++++++ kiosk/src/server.rs | 162 +++++++++++++++++++++++ kiosk/src/ui.rs | 295 ++++++++++++++++++++++++++++++++++++++++++ 6 files changed, 692 insertions(+) create mode 100644 kiosk/Cargo.toml create mode 100644 kiosk/src/bundle.rs create mode 100644 kiosk/src/main.rs create mode 100644 kiosk/src/pipeline.rs create mode 100644 kiosk/src/server.rs create mode 100644 kiosk/src/ui.rs diff --git a/kiosk/Cargo.toml b/kiosk/Cargo.toml new file mode 100644 index 0000000..c2e3db6 --- /dev/null +++ b/kiosk/Cargo.toml @@ -0,0 +1,30 @@ +[package] +name = "betterframe-kiosk" +version = "0.1.0" +edition = "2024" +description = "BetterFrame kiosk — multi-camera display with GTK4 + GStreamer" +license = "AGPL-3.0-only OR Commercial" + +[dependencies] +# GTK4 for windowing/layout +gtk4 = { version = "0.9", features = ["v4_12"] } + +# GStreamer for RTSP decode +gstreamer = "0.23" +gstreamer-video = "0.23" +gst-plugin-gtk4 = "0.13" + +# HTTP client for server API +reqwest = { version = "0.12", features = ["json", "blocking"] } + +# JSON +serde = { version = "1", features = ["derive"] } +serde_json = "1" + +# Async runtime +tokio = { version = "1", features = ["rt-multi-thread", "macros", "time", "fs"] } + +# Misc +dirs = "6" +tracing = "0.1" +tracing-subscriber = "0.3" diff --git a/kiosk/src/bundle.rs b/kiosk/src/bundle.rs new file mode 100644 index 0000000..1130ea0 --- /dev/null +++ b/kiosk/src/bundle.rs @@ -0,0 +1,107 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct KioskBundle { + pub kiosk_id: u32, + pub kiosk_name: String, + pub display: BundleDisplay, + pub layouts: Vec, + pub cameras: Vec, + pub version: String, +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct BundleDisplay { + pub id: u32, + pub name: String, + pub width_px: u32, + pub height_px: u32, + pub idle_timeout_seconds: u32, + pub sleep_timeout_seconds: u32, + pub default_layout_id: Option, +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct BundleLayout { + pub id: u32, + pub name: String, + pub template: Option, + pub priority: String, + pub cooling_timeout_seconds: Option, + pub preload_camera_ids: Vec, + pub is_default: bool, + pub resets_idle_timer: bool, + pub cells: Vec, +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct BundleTemplate { + pub id: u32, + pub name: String, + pub regions: Vec, + pub grid_cols: u32, + pub grid_rows: u32, +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct BundleRegion { + pub name: String, + pub row: u32, + pub col: u32, + #[serde(rename = "rowSpan")] + pub row_span: u32, + #[serde(rename = "colSpan")] + pub col_span: u32, +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct BundleCell { + pub region_name: String, + pub content_type: String, + pub camera_id: Option, + pub stream_selector: Option, + pub web_url: Option, + pub html_content: Option, + pub cooling_timeout_seconds: Option, +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct BundleCamera { + pub id: u32, + pub name: String, + #[serde(rename = "type")] + pub cam_type: String, + pub rtsp_url: Option, + pub stream_policy: String, + pub streams: Vec, +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct BundleStream { + pub id: u32, + pub role: String, + pub name: String, + pub rtsp_uri: String, + pub width: Option, + pub height: Option, + pub encoding: Option, + pub framerate: Option, +} + +impl BundleCamera { + /// Pick the best stream URI for this camera given a cell's stream_selector. + pub fn stream_uri(&self, selector: Option<&str>) -> Option<&str> { + let sel = selector.unwrap_or("auto"); + match sel { + "main" => self.streams.iter().find(|s| s.role == "main"), + "sub" => self.streams.iter().find(|s| s.role == "sub"), + _ => { + // auto: prefer main, fall back to any + self.streams.iter().find(|s| s.role == "main") + .or_else(|| self.streams.first()) + } + } + .map(|s| s.rtsp_uri.as_str()) + .or(self.rtsp_url.as_deref()) + } +} diff --git a/kiosk/src/main.rs b/kiosk/src/main.rs new file mode 100644 index 0000000..45baab6 --- /dev/null +++ b/kiosk/src/main.rs @@ -0,0 +1,16 @@ +mod server; +mod bundle; +mod pipeline; +mod ui; + +use tracing_subscriber::EnvFilter; + +fn main() { + tracing_subscriber::fmt() + .with_env_filter(EnvFilter::from_default_env().add_directive("betterframe_kiosk=info".parse().unwrap())) + .init(); + + gstreamer::init().expect("Failed to init GStreamer"); + let app = ui::build_app(); + std::process::exit(app.run().into()); +} diff --git a/kiosk/src/pipeline.rs b/kiosk/src/pipeline.rs new file mode 100644 index 0000000..9f1e4c5 --- /dev/null +++ b/kiosk/src/pipeline.rs @@ -0,0 +1,82 @@ +use gstreamer::prelude::*; +use gstreamer::{self as gst, Element, Pipeline}; +use tracing::{error, info, warn}; + +/// Create a GStreamer pipeline for an RTSP camera that outputs to a GTK4 paintable sink. +/// Returns (pipeline, paintable_sink) — the sink's "paintable" property drives a gtk4::Picture. +pub fn create_camera_pipeline(name: &str, rtsp_uri: &str) -> Option<(Pipeline, Element)> { + let pipeline_name = format!("cam-{name}"); + let pipeline = Pipeline::with_name(&pipeline_name); + + let src = gst::ElementFactory::make("rtspsrc") + .property("location", rtsp_uri) + .property("latency", 300u32) + .property_from_str("protocols", "tcp") + .build() + .ok()?; + + let depay_h264 = gst::ElementFactory::make("rtph264depay").build().ok(); + let depay_h265 = gst::ElementFactory::make("rtph265depay").build().ok(); + let parse_h264 = gst::ElementFactory::make("h264parse").build().ok(); + let parse_h265 = gst::ElementFactory::make("h265parse").build().ok(); + let decode = gst::ElementFactory::make("avdec_h264").build() + .or_else(|| gst::ElementFactory::make("decodebin").build().ok()); + + let convert = gst::ElementFactory::make("videoconvert").build().ok()?; + + let sink = gst::ElementFactory::make("gtk4paintablesink") + .build() + .map_err(|e| { + error!("gtk4paintablesink not available: {e}. Install gst-plugin-gtk4"); + }) + .ok()?; + + let queue = gst::ElementFactory::make("queue") + .property("max-size-buffers", 1u32) + .property("leaky", 2u32) // downstream + .build() + .ok()?; + + pipeline.add_many([&src, &queue, &convert, &sink]).ok()?; + gst::Element::link_many([&queue, &convert, &sink]).ok()?; + + // rtspsrc has dynamic pads — connect on pad-added + let queue_weak = queue.downgrade(); + let pipeline_name_clone = pipeline_name.clone(); + src.connect_pad_added(move |_src, pad| { + let caps = pad.current_caps().or_else(|| pad.query_caps(None)); + let caps_str = caps.map(|c| c.to_string()).unwrap_or_default(); + + // Only link video pads + if !caps_str.contains("video") && !caps_str.contains("264") && !caps_str.contains("265") { + return; + } + + info!("[{pipeline_name_clone}] linking pad: {caps_str}"); + + let Some(queue) = queue_weak.upgrade() else { return }; + if pad.link(&queue.static_pad("sink").unwrap()).is_err() { + warn!("[{pipeline_name_clone}] pad link failed"); + } + }); + + // For decodebin-style pipelines, we might need more complex linking. + // For now, rtspsrc → queue → convert → sink works for raw decode. + // The actual decode happens if we insert depay+parse+decoder elements. + // TODO: auto-detect codec and insert appropriate decoder chain. + + info!("[{pipeline_name}] pipeline created for {rtsp_uri}"); + Some((pipeline, sink)) +} + +/// Start a pipeline. +pub fn play(pipeline: &Pipeline) { + if let Err(e) = pipeline.set_state(gst::State::Playing) { + error!("Failed to set pipeline to Playing: {e:?}"); + } +} + +/// Stop a pipeline. +pub fn stop(pipeline: &Pipeline) { + let _ = pipeline.set_state(gst::State::Null); +} diff --git a/kiosk/src/server.rs b/kiosk/src/server.rs new file mode 100644 index 0000000..08006a9 --- /dev/null +++ b/kiosk/src/server.rs @@ -0,0 +1,162 @@ +use std::fs; +use std::path::PathBuf; +use std::time::Duration; + +use serde::Deserialize; +use tracing::{info, warn}; + +use crate::bundle::KioskBundle; + +fn state_dir() -> PathBuf { + let home = dirs::home_dir().expect("no home directory"); + let dir = home.join(".betterframe-kiosk"); + fs::create_dir_all(&dir).ok(); + dir +} + +fn key_file() -> PathBuf { state_dir().join("kiosk.key") } +fn server_file() -> PathBuf { state_dir().join("server.url") } + +/// Discover the BetterFrame server. +pub fn discover_server(override_url: Option<&str>) -> String { + if let Some(url) = override_url { + return url.to_string(); + } + + // Check saved + if let Ok(saved) = fs::read_to_string(server_file()) { + let saved = saved.trim().to_string(); + if check_health(&saved) { + return saved; + } + } + + let candidates = [ + "http://localhost:18081", + "http://betterframe.local:18081", + "https://frame.betterportal.cloud", + ]; + + for url in candidates { + info!("trying {url}..."); + if check_health(url) { + fs::write(server_file(), url).ok(); + return url.to_string(); + } + } + + panic!("Could not find BetterFrame server"); +} + +fn check_health(url: &str) -> bool { + reqwest::blocking::Client::new() + .get(format!("{url}/healthz")) + .timeout(Duration::from_secs(3)) + .send() + .map(|r| r.status().is_success()) + .unwrap_or(false) +} + +/// Check if already paired (key file exists). +pub fn is_paired() -> bool { + key_file().exists() +} + +/// Read stored kiosk key. +pub fn load_key() -> String { + fs::read_to_string(key_file()) + .expect("failed to read kiosk key") + .trim() + .to_string() +} + +#[derive(Deserialize)] +struct InitiateResp { + code: String, + expires_at: String, +} + +/// Initiate pairing — returns (code, expires_at). +pub fn initiate_pairing(server: &str) -> (String, String) { + let hostname = hostname::get() + .map(|h| h.to_string_lossy().to_string()) + .unwrap_or_else(|_| "kiosk".into()); + + let hw_model = fs::read_to_string("/proc/device-tree/model") + .unwrap_or_else(|_| "unknown".into()) + .replace('\0', ""); + + let client = reqwest::blocking::Client::new(); + let resp: InitiateResp = client + .post(format!("{server}/api/pair/initiate")) + .json(&serde_json::json!({ + "proposed_name": hostname, + "hardware_model": hw_model, + "capabilities": ["rtsp", "gstreamer", "gtk4"] + })) + .send() + .expect("pairing initiate failed") + .json() + .expect("bad initiate response"); + + (resp.code, resp.expires_at) +} + +#[derive(Deserialize)] +struct ClaimResp { + status: String, + kiosk_key: Option, + kiosk_name: Option, +} + +/// Poll for pairing claim. Returns (name, key) when admin confirms. +pub fn poll_claim(server: &str, code: &str) -> (String, String) { + let client = reqwest::blocking::Client::new(); + loop { + let resp = client + .post(format!("{server}/api/pair/claim")) + .json(&serde_json::json!({ "code": code })) + .send() + .expect("claim request failed"); + + if resp.status().as_u16() == 200 { + let claim: ClaimResp = resp.json().expect("bad claim response"); + if claim.status == "claimed" { + let key = claim.kiosk_key.expect("missing kiosk_key"); + let name = claim.kiosk_name.unwrap_or_else(|| "kiosk".into()); + fs::write(key_file(), &key).expect("failed to save kiosk key"); + return (name, key); + } + } + std::thread::sleep(Duration::from_secs(2)); + } +} + +/// Fetch bundle from server. +pub fn fetch_bundle(server: &str, key: &str) -> KioskBundle { + let client = reqwest::blocking::Client::new(); + let resp = client + .get(format!("{server}/api/kiosk/bundle")) + .header("Authorization", format!("Bearer {key}")) + .send() + .expect("bundle fetch failed"); + + if !resp.status().is_success() { + panic!("Bundle fetch returned {}", resp.status()); + } + + resp.json().expect("bad bundle JSON") +} + +/// Send heartbeat. +pub fn heartbeat(server: &str, key: &str) { + let client = reqwest::blocking::Client::new(); + let _ = client + .post(format!("{server}/api/kiosk/heartbeat")) + .header("Authorization", format!("Bearer {key}")) + .json(&serde_json::json!({ + "kiosk_app_version": env!("CARGO_PKG_VERSION"), + })) + .timeout(Duration::from_secs(5)) + .send(); +} diff --git a/kiosk/src/ui.rs b/kiosk/src/ui.rs new file mode 100644 index 0000000..ca718fd --- /dev/null +++ b/kiosk/src/ui.rs @@ -0,0 +1,295 @@ +use std::cell::RefCell; +use std::rc::Rc; + +use gtk4::prelude::*; +use gtk4::{self as gtk, Application, ApplicationWindow, Box as GtkBox, Grid, Label, Orientation, Picture}; +use gstreamer::prelude::*; +use tracing::{info, warn}; + +use crate::bundle::{BundleLayout, KioskBundle}; +use crate::pipeline; +use crate::server; + +const APP_ID: &str = "dev.betterframe.kiosk"; + +pub fn build_app() -> Application { + let app = Application::builder().application_id(APP_ID).build(); + app.connect_activate(activate); + app +} + +fn activate(app: &Application) { + let window = ApplicationWindow::builder() + .application(app) + .title("BetterFrame") + .fullscreened(true) + .build(); + + // Dark background + let provider = gtk::CssProvider::new(); + provider.load_from_string("window { background-color: #1a1a2e; }"); + gtk::style_context_add_provider_for_display( + &window.display(), + &provider, + gtk::STYLE_PROVIDER_PRIORITY_APPLICATION, + ); + + let container = GtkBox::new(Orientation::Vertical, 0); + container.set_vexpand(true); + container.set_hexpand(true); + window.set_child(Some(&container)); + + // Show pairing or camera display + let server_url = std::env::args().nth(1); + let container_clone = container.clone(); + let window_clone = window.clone(); + + // Run server interaction in a thread, update UI via idle_add + std::thread::spawn(move || { + let server = server::discover_server(server_url.as_deref()); + info!("server: {server}"); + + let key = if server::is_paired() { + info!("already paired"); + server::load_key() + } else { + let (code, expires) = server::initiate_pairing(&server); + info!("pairing code: {code} (expires {expires})"); + + // Show pairing code on UI + let code_clone = code.clone(); + gtk::glib::idle_add_once(move || { + show_pairing_code(&container_clone, &code_clone); + }); + + let (name, key) = server::poll_claim(&server, &code); + info!("paired as: {name}"); + key + }; + + // Fetch bundle + let bundle = server::fetch_bundle(&server, &key); + info!("bundle: {} cameras, {} layouts", bundle.cameras.len(), bundle.layouts.len()); + + // Render layout on UI thread + let server_clone = server.clone(); + let key_clone = key.clone(); + gtk::glib::idle_add_once(move || { + render_bundle(&window_clone, bundle); + }); + + // Heartbeat loop + loop { + std::thread::sleep(std::time::Duration::from_secs(60)); + server::heartbeat(&server_clone, &key_clone); + } + }); + + window.present(); +} + +fn show_pairing_code(container: &GtkBox, code: &str) { + // Clear existing children + while let Some(child) = container.first_child() { + container.remove(&child); + } + + let vbox = GtkBox::new(Orientation::Vertical, 20); + vbox.set_valign(gtk::Align::Center); + vbox.set_halign(gtk::Align::Center); + vbox.set_vexpand(true); + + let title = Label::new(Some("BetterFrame")); + title.add_css_class("title"); + let title_provider = gtk::CssProvider::new(); + title_provider.load_from_string(".title { font-size: 24px; color: #888; font-weight: 300; }"); + gtk::style_context_add_provider_for_display( + &title.display(), + &title_provider, + gtk::STYLE_PROVIDER_PRIORITY_APPLICATION, + ); + + let code_label = Label::new(Some(code)); + code_label.add_css_class("pairing-code"); + let code_provider = gtk::CssProvider::new(); + code_provider.load_from_string( + ".pairing-code { font-size: 72px; color: #fff; font-weight: 700; letter-spacing: 12px; font-family: monospace; }", + ); + gtk::style_context_add_provider_for_display( + &code_label.display(), + &code_provider, + gtk::STYLE_PROVIDER_PRIORITY_APPLICATION, + ); + + let hint = Label::new(Some("Enter this code in BetterFrame admin to pair")); + hint.add_css_class("hint"); + let hint_provider = gtk::CssProvider::new(); + hint_provider.load_from_string(".hint { font-size: 14px; color: #666; }"); + gtk::style_context_add_provider_for_display( + &hint.display(), + &hint_provider, + gtk::STYLE_PROVIDER_PRIORITY_APPLICATION, + ); + + vbox.append(&title); + vbox.append(&code_label); + vbox.append(&hint); + container.append(&vbox); +} + +fn render_bundle(window: &ApplicationWindow, bundle: KioskBundle) { + // Find default layout + let layout = bundle.layouts.iter() + .find(|l| l.is_default) + .or_else(|| bundle.layouts.first()); + + let Some(layout) = layout else { + warn!("no layouts in bundle"); + show_logo(window); + return; + }; + + let Some(ref template) = layout.template else { + warn!("layout has no template"); + show_logo(window); + return; + }; + + info!("rendering layout '{}' with {}x{} grid, {} cells", + layout.name, template.grid_cols, template.grid_rows, layout.cells.len()); + + let grid = Grid::new(); + grid.set_row_homogeneous(true); + grid.set_column_homogeneous(true); + grid.set_vexpand(true); + grid.set_hexpand(true); + + // Map cameras by ID for quick lookup + let cam_map: std::collections::HashMap = + bundle.cameras.iter().map(|c| (c.id, c)).collect(); + + let pipelines: Rc>> = Rc::new(RefCell::new(Vec::new())); + + for cell in &layout.cells { + // Find region in template + let region = template.regions.iter().find(|r| r.name == cell.region_name); + let Some(region) = region else { + warn!("region '{}' not found in template", cell.region_name); + continue; + }; + + let widget: gtk::Widget = match cell.content_type.as_str() { + "camera" => { + if let Some(cam_id) = cell.camera_id { + if let Some(cam) = cam_map.get(&cam_id) { + if let Some(uri) = cam.stream_uri(cell.stream_selector.as_deref()) { + match pipeline::create_camera_pipeline(&cam.name, uri) { + Some((pipe, sink)) => { + let paintable = sink.property::("paintable"); + let picture = Picture::for_paintable(&paintable); + picture.set_content_fit(gtk::ContentFit::Cover); + picture.set_vexpand(true); + picture.set_hexpand(true); + pipeline::play(&pipe); + pipelines.borrow_mut().push(pipe); + picture.upcast() + } + None => placeholder_label(&format!("{} (pipeline error)", cam.name)), + } + } else { + placeholder_label(&format!("{} (no stream)", cam.name)) + } + } else { + placeholder_label("Unknown camera") + } + } else { + placeholder_label("No camera assigned") + } + } + "html" => { + let html = cell.html_content.as_deref().unwrap_or(""); + // For HTML cells, show a label placeholder (WebKit integration later) + let label = Label::new(Some("HTML Content")); + label.set_markup(&format!("{}", + gtk::glib::markup_escape_text(html).chars().take(100).collect::())); + label.set_vexpand(true); + label.set_hexpand(true); + label.upcast() + } + "web" => { + let url = cell.web_url.as_deref().unwrap_or("about:blank"); + placeholder_label(&format!("Web: {url}")) + } + _ => placeholder_label("Unknown content"), + }; + + grid.attach( + &widget, + region.col as i32, + region.row as i32, + region.col_span as i32, + region.row_span as i32, + ); + } + + // Fill empty regions with dark placeholders + for region in &template.regions { + if !layout.cells.iter().any(|c| c.region_name == region.name) { + let empty = GtkBox::new(Orientation::Vertical, 0); + let empty_provider = gtk::CssProvider::new(); + empty_provider.load_from_string("box { background-color: #111; }"); + gtk::style_context_add_provider_for_display( + &empty.display(), + &empty_provider, + gtk::STYLE_PROVIDER_PRIORITY_APPLICATION, + ); + empty.set_vexpand(true); + empty.set_hexpand(true); + grid.attach( + &empty, + region.col as i32, + region.row as i32, + region.col_span as i32, + region.row_span as i32, + ); + } + } + + window.set_child(Some(&grid)); + + // Store pipelines so they don't get dropped + window.connect_destroy(move |_| { + for pipe in pipelines.borrow().iter() { + pipeline::stop(pipe); + } + }); +} + +fn show_logo(window: &ApplicationWindow) { + let label = Label::new(Some("BetterFrame")); + let provider = gtk::CssProvider::new(); + provider.load_from_string("label { font-size: 48px; color: #fff; font-weight: 300; }"); + gtk::style_context_add_provider_for_display( + &label.display(), + &provider, + gtk::STYLE_PROVIDER_PRIORITY_APPLICATION, + ); + label.set_valign(gtk::Align::Center); + label.set_halign(gtk::Align::Center); + label.set_vexpand(true); + window.set_child(Some(&label)); +} + +fn placeholder_label(text: &str) -> gtk::Widget { + let label = Label::new(Some(text)); + label.set_vexpand(true); + label.set_hexpand(true); + let provider = gtk::CssProvider::new(); + provider.load_from_string("label { color: #666; font-size: 14px; background-color: #111; }"); + gtk::style_context_add_provider_for_display( + &label.display(), + &provider, + gtk::STYLE_PROVIDER_PRIORITY_APPLICATION, + ); + label.upcast() +}