Add kiosk desktop app
Tauri app for the Pi. Media runs through the native LiveKit SDK because WebKitGTK has no WebRTC, with frames encoded in Rust and drawn to a canvas under the annotation overlay.
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"recommendations": [
|
||||
"svelte.svelte-vscode",
|
||||
"tauri-apps.tauri-vscode",
|
||||
"rust-lang.rust-analyzer"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"svelte.enable-ts-plugin": true
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
{
|
||||
"name": "@pistation/kiosk-client",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite dev",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
||||
"tauri": "tauri",
|
||||
"tauri:dev": "tauri dev",
|
||||
"tauri:build": "tauri build"
|
||||
},
|
||||
"dependencies": {
|
||||
"@excalidraw/excalidraw": "^0.17.6",
|
||||
"@pistation/client-core": "workspace:*",
|
||||
"@pistation/shared-types": "workspace:*",
|
||||
"@pistation/ui": "workspace:*",
|
||||
"@tauri-apps/api": "^2",
|
||||
"@tauri-apps/plugin-opener": "^2",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@iconify/svelte": "^4.0.2",
|
||||
"@sveltejs/adapter-static": "^3.0.6",
|
||||
"@sveltejs/kit": "^2.9.0",
|
||||
"@sveltejs/vite-plugin-svelte": "^5.0.0",
|
||||
"@tailwindcss/vite": "^4.0.0",
|
||||
"@tauri-apps/cli": "^2",
|
||||
"@types/node": "^22.10.2",
|
||||
"@types/react": "^18.3.12",
|
||||
"@types/react-dom": "^18.3.1",
|
||||
"svelte": "^5.0.0",
|
||||
"svelte-check": "^4.0.0",
|
||||
"tailwindcss": "^4.0.0",
|
||||
"typescript": "~5.6.2",
|
||||
"vite": "^6.0.3"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
# Generated by Cargo
|
||||
# will have compiled files and executables
|
||||
/target/
|
||||
|
||||
# Generated by Tauri
|
||||
# will have schema files for capabilities auto-completion
|
||||
/gen/schemas
|
||||
@@ -0,0 +1,29 @@
|
||||
[package]
|
||||
name = "pistation"
|
||||
version = "0.1.0"
|
||||
description = "A Tauri App"
|
||||
authors = ["you"]
|
||||
edition = "2021"
|
||||
|
||||
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
||||
|
||||
[lib]
|
||||
# The `_lib` suffix may seem redundant but it is necessary
|
||||
# to make the lib name unique and wouldn't conflict with the bin name.
|
||||
# This seems to be only an issue on Windows, see https://github.com/rust-lang/cargo/issues/8519
|
||||
name = "pistation_lib"
|
||||
crate-type = ["staticlib", "cdylib", "rlib"]
|
||||
|
||||
[build-dependencies]
|
||||
tauri-build = { version = "2", features = [] }
|
||||
|
||||
[dependencies]
|
||||
tauri = { version = "2", features = [] }
|
||||
tauri-plugin-opener = "2"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
livekit = { version = "0.8", features = ["rustls-tls-native-roots"] }
|
||||
tokio = { version = "1", features = ["rt-multi-thread", "sync", "macros"] }
|
||||
futures-util = "0.3"
|
||||
image = { version = "0.25", default-features = false, features = ["jpeg"] }
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
fn main() {
|
||||
tauri_build::build()
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"$schema": "../gen/schemas/desktop-schema.json",
|
||||
"identifier": "default",
|
||||
"description": "Capability for the main window",
|
||||
"windows": ["main"],
|
||||
"permissions": [
|
||||
"core:default",
|
||||
"opener:default"
|
||||
]
|
||||
}
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
|
After Width: | Height: | Size: 2.5 KiB |
|
After Width: | Height: | Size: 1010 B |
|
After Width: | Height: | Size: 1.2 KiB |
|
After Width: | Height: | Size: 2.3 KiB |
|
After Width: | Height: | Size: 2.5 KiB |
|
After Width: | Height: | Size: 2.6 KiB |
|
After Width: | Height: | Size: 3.4 KiB |
|
After Width: | Height: | Size: 1.3 KiB |
|
After Width: | Height: | Size: 3.5 KiB |
|
After Width: | Height: | Size: 1.8 KiB |
|
After Width: | Height: | Size: 2.3 KiB |
|
After Width: | Height: | Size: 2.3 KiB |
|
After Width: | Height: | Size: 1.8 KiB |
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
|
||||
<background android:drawable="@color/ic_launcher_background"/>
|
||||
</adaptive-icon>
|
||||
|
After Width: | Height: | Size: 2.1 KiB |
|
After Width: | Height: | Size: 2.6 KiB |
|
After Width: | Height: | Size: 2.2 KiB |
|
After Width: | Height: | Size: 1.9 KiB |
|
After Width: | Height: | Size: 2.7 KiB |
|
After Width: | Height: | Size: 2.1 KiB |
|
After Width: | Height: | Size: 2.0 KiB |
|
After Width: | Height: | Size: 3.4 KiB |
|
After Width: | Height: | Size: 2.9 KiB |
|
After Width: | Height: | Size: 3.2 KiB |
|
After Width: | Height: | Size: 3.7 KiB |
|
After Width: | Height: | Size: 3.8 KiB |
|
After Width: | Height: | Size: 2.2 KiB |
|
After Width: | Height: | Size: 3.5 KiB |
|
After Width: | Height: | Size: 2.5 KiB |
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<color name="ic_launcher_background">#fff</color>
|
||||
</resources>
|
||||
|
After Width: | Height: | Size: 9.6 KiB |
|
After Width: | Height: | Size: 3.5 KiB |
|
After Width: | Height: | Size: 887 B |
|
After Width: | Height: | Size: 1.5 KiB |
|
After Width: | Height: | Size: 1.5 KiB |
|
After Width: | Height: | Size: 2.0 KiB |
|
After Width: | Height: | Size: 1.2 KiB |
|
After Width: | Height: | Size: 2.1 KiB |
|
After Width: | Height: | Size: 2.1 KiB |
|
After Width: | Height: | Size: 2.3 KiB |
|
After Width: | Height: | Size: 1.5 KiB |
|
After Width: | Height: | Size: 1.4 KiB |
|
After Width: | Height: | Size: 1.4 KiB |
|
After Width: | Height: | Size: 2.2 KiB |
|
After Width: | Height: | Size: 5.7 KiB |
|
After Width: | Height: | Size: 2.2 KiB |
|
After Width: | Height: | Size: 3.1 KiB |
|
After Width: | Height: | Size: 2.2 KiB |
|
After Width: | Height: | Size: 2.7 KiB |
|
After Width: | Height: | Size: 2.7 KiB |
@@ -0,0 +1,157 @@
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tauri::{AppHandle, Manager};
|
||||
|
||||
const CONFIG_FILE_NAME: &str = "kiosk.json";
|
||||
const PROVISION_PATHS: [&str; 2] = ["/boot/firmware/pistation.json", "/boot/pistation.json"];
|
||||
|
||||
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct KioskConfig {
|
||||
#[serde(default)]
|
||||
pub server_url: String,
|
||||
#[serde(default)]
|
||||
pub enrollment_token: String,
|
||||
#[serde(default)]
|
||||
pub kiosk_token: String,
|
||||
#[serde(default)]
|
||||
pub kiosk_id: String,
|
||||
#[serde(default)]
|
||||
pub room_name: String,
|
||||
#[serde(default)]
|
||||
pub livekit_url: String,
|
||||
#[serde(default)]
|
||||
pub join_url: String,
|
||||
}
|
||||
|
||||
fn config_path(app: &AppHandle) -> Result<PathBuf, String> {
|
||||
let directory = app
|
||||
.path()
|
||||
.app_config_dir()
|
||||
.map_err(|error| format!("no config directory: {error}"))?;
|
||||
|
||||
fs::create_dir_all(&directory).map_err(|error| format!("cannot create config dir: {error}"))?;
|
||||
Ok(directory.join(CONFIG_FILE_NAME))
|
||||
}
|
||||
|
||||
fn read_provisioned() -> Option<KioskConfig> {
|
||||
for path in PROVISION_PATHS {
|
||||
let Ok(contents) = fs::read_to_string(path) else {
|
||||
continue;
|
||||
};
|
||||
if let Ok(config) = serde_json::from_str::<KioskConfig>(&contents) {
|
||||
return Some(config);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn env_value(key: &str) -> Option<String> {
|
||||
std::env::var(key).ok().filter(|value| !value.is_empty())
|
||||
}
|
||||
|
||||
/// Works out the effective configuration.
|
||||
///
|
||||
/// Addresses come from, in order of increasing authority: what was saved last, the file on
|
||||
/// the boot partition, then the environment. Someone who edits the boot file or sets a
|
||||
/// variable is stating where this screen should point, and that has to beat whatever was
|
||||
/// saved on a previous run, otherwise a kiosk can never be moved to a new server.
|
||||
///
|
||||
/// Credentials work the other way. The kiosk token is earned at enrolment and is only
|
||||
/// discarded when it is provably useless, which is when the server address changes.
|
||||
fn merge_provisioned(mut config: KioskConfig) -> KioskConfig {
|
||||
let provisioned = read_provisioned();
|
||||
|
||||
let mut server_url = config.server_url.clone();
|
||||
let mut join_url = config.join_url.clone();
|
||||
let mut enrollment_token = config.enrollment_token.clone();
|
||||
|
||||
if let Some(provisioned) = provisioned {
|
||||
if !provisioned.server_url.is_empty() {
|
||||
server_url = provisioned.server_url;
|
||||
}
|
||||
if !provisioned.join_url.is_empty() {
|
||||
join_url = provisioned.join_url;
|
||||
}
|
||||
if !provisioned.enrollment_token.is_empty() {
|
||||
enrollment_token = provisioned.enrollment_token;
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(value) = env_value("PISTATION_SERVER_URL") {
|
||||
server_url = value;
|
||||
}
|
||||
if let Some(value) = env_value("PISTATION_JOIN_URL") {
|
||||
join_url = value;
|
||||
}
|
||||
if let Some(value) = env_value("PISTATION_ENROLLMENT_TOKEN") {
|
||||
enrollment_token = value;
|
||||
}
|
||||
|
||||
// A token issued by one server means nothing to another, so pointing somewhere new
|
||||
// has to force a fresh enrolment rather than looping on rejected requests.
|
||||
let is_moving = !config.server_url.is_empty() && config.server_url != server_url;
|
||||
if is_moving {
|
||||
eprintln!(
|
||||
"[pistation] server changed from {} to {}, re-enrolling",
|
||||
config.server_url, server_url
|
||||
);
|
||||
config.kiosk_token.clear();
|
||||
config.kiosk_id.clear();
|
||||
}
|
||||
|
||||
config.server_url = server_url;
|
||||
config.join_url = join_url;
|
||||
|
||||
// An enrolment token is only of use while there is no kiosk token to replace it.
|
||||
config.enrollment_token = if config.kiosk_token.is_empty() {
|
||||
enrollment_token
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
|
||||
config
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn load_config(app: AppHandle) -> Result<KioskConfig, String> {
|
||||
let path = config_path(&app)?;
|
||||
|
||||
let stored = fs::read_to_string(&path)
|
||||
.ok()
|
||||
.and_then(|contents| serde_json::from_str::<KioskConfig>(&contents).ok())
|
||||
.unwrap_or_default();
|
||||
|
||||
Ok(merge_provisioned(stored))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn save_config(app: AppHandle, config: KioskConfig) -> Result<(), String> {
|
||||
let path = config_path(&app)?;
|
||||
let contents = serde_json::to_string_pretty(&config)
|
||||
.map_err(|error| format!("cannot serialize config: {error}"))?;
|
||||
|
||||
fs::write(&path, contents).map_err(|error| format!("cannot write config: {error}"))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn hardware_id() -> String {
|
||||
for path in ["/etc/machine-id", "/var/lib/dbus/machine-id"] {
|
||||
if let Ok(contents) = fs::read_to_string(path) {
|
||||
let trimmed = contents.trim();
|
||||
if !trimmed.is_empty() {
|
||||
return trimmed.to_string();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
hostname()
|
||||
}
|
||||
|
||||
fn hostname() -> String {
|
||||
fs::read_to_string("/etc/hostname")
|
||||
.map(|value| value.trim().to_string())
|
||||
.unwrap_or_else(|_| "unknown-kiosk".to_string())
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
mod config;
|
||||
mod metrics;
|
||||
mod room;
|
||||
|
||||
use tauri::Manager;
|
||||
|
||||
#[tauri::command]
|
||||
fn is_kiosk_mode() -> bool {
|
||||
kiosk_mode_enabled()
|
||||
}
|
||||
|
||||
fn kiosk_mode_enabled() -> bool {
|
||||
match std::env::var("PISTATION_KIOSK").ok().as_deref() {
|
||||
Some("1") | Some("true") => true,
|
||||
Some("0") | Some("false") => false,
|
||||
_ => !cfg!(debug_assertions),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||
pub fn run() {
|
||||
tauri::Builder::default()
|
||||
.plugin(tauri_plugin_opener::init())
|
||||
.manage(room::RoomHandle::default())
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
config::load_config,
|
||||
config::save_config,
|
||||
config::hardware_id,
|
||||
metrics::collect_metrics,
|
||||
is_kiosk_mode,
|
||||
room::room_connect,
|
||||
room::room_disconnect,
|
||||
room::video_subscribe,
|
||||
room::video_unsubscribe
|
||||
])
|
||||
.setup(|app| {
|
||||
if let Some(window) = app.get_webview_window("main") {
|
||||
if kiosk_mode_enabled() {
|
||||
let _ = window.set_fullscreen(true);
|
||||
let _ = window.set_always_on_top(true);
|
||||
let _ = window.set_cursor_visible(false);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
.run(tauri::generate_context!())
|
||||
.expect("error while running tauri application");
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
// Prevents additional console window on Windows in release, DO NOT REMOVE!!
|
||||
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||
|
||||
fn main() {
|
||||
pistation_lib::run()
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
use std::fs;
|
||||
use std::sync::Mutex;
|
||||
|
||||
use serde::Serialize;
|
||||
|
||||
/// Previous CPU sample, so usage can be reported as a delta between heartbeats rather
|
||||
/// than as the meaningless since-boot average.
|
||||
static LAST_CPU_SAMPLE: Mutex<Option<CpuSample>> = Mutex::new(None);
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
struct CpuSample {
|
||||
total: u64,
|
||||
idle: u64,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct WifiMetrics {
|
||||
pub interface: String,
|
||||
pub link_quality: f32,
|
||||
pub signal_dbm: f32,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Metrics {
|
||||
pub cpu_percent: Option<f32>,
|
||||
pub memory_used_bytes: u64,
|
||||
pub memory_total_bytes: u64,
|
||||
pub uptime_seconds: u64,
|
||||
pub temperature_celsius: Option<f32>,
|
||||
pub wifi: Option<WifiMetrics>,
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn collect_metrics() -> Metrics {
|
||||
let memory = read_memory().unwrap_or((0, 0));
|
||||
|
||||
Metrics {
|
||||
cpu_percent: read_cpu_percent(),
|
||||
memory_used_bytes: memory.0,
|
||||
memory_total_bytes: memory.1,
|
||||
uptime_seconds: read_uptime().unwrap_or(0),
|
||||
temperature_celsius: read_temperature(),
|
||||
wifi: read_wifi(),
|
||||
}
|
||||
}
|
||||
|
||||
fn read_cpu_percent() -> Option<f32> {
|
||||
let contents = fs::read_to_string("/proc/stat").ok()?;
|
||||
let line = contents.lines().next()?;
|
||||
if !line.starts_with("cpu ") {
|
||||
return None;
|
||||
}
|
||||
|
||||
let values: Vec<u64> = line
|
||||
.split_whitespace()
|
||||
.skip(1)
|
||||
.filter_map(|value| value.parse().ok())
|
||||
.collect();
|
||||
|
||||
if values.len() < 5 {
|
||||
return None;
|
||||
}
|
||||
|
||||
let total: u64 = values.iter().sum();
|
||||
let idle = values[3] + values[4];
|
||||
let sample = CpuSample { total, idle };
|
||||
|
||||
let mut guard = LAST_CPU_SAMPLE.lock().ok()?;
|
||||
let previous = guard.replace(sample);
|
||||
|
||||
let previous = previous?;
|
||||
let total_delta = total.checked_sub(previous.total)?;
|
||||
let idle_delta = idle.checked_sub(previous.idle)?;
|
||||
|
||||
if total_delta == 0 {
|
||||
return None;
|
||||
}
|
||||
|
||||
let busy = total_delta.saturating_sub(idle_delta) as f32;
|
||||
Some((busy / total_delta as f32) * 100.0)
|
||||
}
|
||||
|
||||
fn read_memory() -> Option<(u64, u64)> {
|
||||
let contents = fs::read_to_string("/proc/meminfo").ok()?;
|
||||
let mut total_kb = 0u64;
|
||||
let mut available_kb = 0u64;
|
||||
|
||||
for line in contents.lines() {
|
||||
let mut parts = line.split_whitespace();
|
||||
let key = parts.next()?;
|
||||
let value: u64 = parts.next().and_then(|value| value.parse().ok())?;
|
||||
|
||||
match key {
|
||||
"MemTotal:" => total_kb = value,
|
||||
"MemAvailable:" => available_kb = value,
|
||||
_ => {}
|
||||
}
|
||||
|
||||
if total_kb > 0 && available_kb > 0 {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if total_kb == 0 {
|
||||
return None;
|
||||
}
|
||||
|
||||
let used_kb = total_kb.saturating_sub(available_kb);
|
||||
Some((used_kb * 1024, total_kb * 1024))
|
||||
}
|
||||
|
||||
fn read_uptime() -> Option<u64> {
|
||||
let contents = fs::read_to_string("/proc/uptime").ok()?;
|
||||
let seconds: f64 = contents.split_whitespace().next()?.parse().ok()?;
|
||||
Some(seconds as u64)
|
||||
}
|
||||
|
||||
fn read_temperature() -> Option<f32> {
|
||||
let raw = fs::read_to_string("/sys/class/thermal/thermal_zone0/temp").ok()?;
|
||||
let millidegrees: f32 = raw.trim().parse().ok()?;
|
||||
Some(millidegrees / 1000.0)
|
||||
}
|
||||
|
||||
fn read_wifi() -> Option<WifiMetrics> {
|
||||
let contents = fs::read_to_string("/proc/net/wireless").ok()?;
|
||||
|
||||
for line in contents.lines().skip(2) {
|
||||
let Some((interface, rest)) = line.split_once(':') else {
|
||||
continue;
|
||||
};
|
||||
|
||||
let values: Vec<&str> = rest.split_whitespace().collect();
|
||||
if values.len() < 3 {
|
||||
continue;
|
||||
}
|
||||
|
||||
let link_quality = parse_wireless_number(values[1])?;
|
||||
let signal_dbm = parse_wireless_number(values[2])?;
|
||||
|
||||
return Some(WifiMetrics {
|
||||
interface: interface.trim().to_string(),
|
||||
link_quality,
|
||||
signal_dbm,
|
||||
});
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
fn parse_wireless_number(raw: &str) -> Option<f32> {
|
||||
raw.trim_end_matches('.').parse().ok()
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::sync::Arc;
|
||||
|
||||
use livekit::track::{RemoteTrack, TrackSource};
|
||||
use livekit::{Room, RoomEvent, RoomOptions};
|
||||
use tauri::{AppHandle, Emitter};
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
use super::events::{
|
||||
DataPayload, ParticipantView, ParticipantsPayload, StatusPayload, DATA_EVENT,
|
||||
PARTICIPANTS_EVENT, STATUS_EVENT,
|
||||
};
|
||||
use super::video;
|
||||
|
||||
/// Bumped on every connect attempt. Tasks belonging to a superseded connection compare
|
||||
/// against this before emitting anything, so a room we deliberately replaced cannot
|
||||
/// report itself as disconnected and trigger a pointless reconnect.
|
||||
static GENERATION: AtomicU64 = AtomicU64::new(0);
|
||||
|
||||
pub fn is_current(generation: u64) -> bool {
|
||||
GENERATION.load(Ordering::SeqCst) == generation
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct RoomHandle {
|
||||
room: Mutex<Option<Arc<Room>>>,
|
||||
}
|
||||
|
||||
impl RoomHandle {
|
||||
pub async fn connect(&self, app: AppHandle, url: String, token: String) -> Result<(), String> {
|
||||
let generation = GENERATION.fetch_add(1, Ordering::SeqCst) + 1;
|
||||
|
||||
self.close_current().await;
|
||||
video::stop(&app);
|
||||
|
||||
emit_status(&app, generation, StatusPayload::new("connecting"));
|
||||
|
||||
let (room, mut events) = Room::connect(&url, &token, RoomOptions::default())
|
||||
.await
|
||||
.map_err(|error| error.to_string())?;
|
||||
|
||||
if !is_current(generation) {
|
||||
let _ = room.close().await;
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
*self.room.lock().await = Some(Arc::new(room));
|
||||
emit_status(&app, generation, StatusPayload::new("connected"));
|
||||
|
||||
let room_for_task = self.room.lock().await.clone();
|
||||
|
||||
tauri::async_runtime::spawn(async move {
|
||||
if let Some(room) = room_for_task.as_ref() {
|
||||
emit_participants(&app, generation, room);
|
||||
}
|
||||
|
||||
while let Some(event) = events.recv().await {
|
||||
if !is_current(generation) {
|
||||
return;
|
||||
}
|
||||
|
||||
if matches!(
|
||||
event,
|
||||
RoomEvent::ParticipantConnected(_) | RoomEvent::ParticipantDisconnected(_)
|
||||
) {
|
||||
if let Some(room) = room_for_task.as_ref() {
|
||||
emit_participants(&app, generation, room);
|
||||
}
|
||||
}
|
||||
|
||||
handle_event(&app, generation, event);
|
||||
}
|
||||
|
||||
if is_current(generation) {
|
||||
video::stop(&app);
|
||||
emit_status(&app, generation, StatusPayload::new("disconnected"));
|
||||
}
|
||||
});
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn disconnect(&self) {
|
||||
GENERATION.fetch_add(1, Ordering::SeqCst);
|
||||
self.close_current().await;
|
||||
}
|
||||
|
||||
async fn close_current(&self) {
|
||||
let existing = self.room.lock().await.take();
|
||||
if let Some(room) = existing {
|
||||
let _ = room.close().await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_event(app: &AppHandle, generation: u64, event: RoomEvent) {
|
||||
match event {
|
||||
RoomEvent::Connected { .. } => emit_status(app, generation, StatusPayload::new("connected")),
|
||||
|
||||
RoomEvent::Reconnecting => {
|
||||
emit_status(app, generation, StatusPayload::new("reconnecting"))
|
||||
}
|
||||
|
||||
RoomEvent::Reconnected => emit_status(app, generation, StatusPayload::new("connected")),
|
||||
|
||||
RoomEvent::Disconnected { reason } => {
|
||||
video::stop(app);
|
||||
emit_status(
|
||||
app,
|
||||
generation,
|
||||
StatusPayload::with_detail("disconnected", format!("{reason:?}")),
|
||||
);
|
||||
}
|
||||
|
||||
RoomEvent::DataReceived { payload, .. } => {
|
||||
if let Ok(envelope) = String::from_utf8(payload.to_vec()) {
|
||||
let _ = app.emit(DATA_EVENT, DataPayload { envelope });
|
||||
}
|
||||
}
|
||||
|
||||
RoomEvent::TrackSubscribed {
|
||||
track, publication, ..
|
||||
} => {
|
||||
let priority = match publication.source() {
|
||||
TrackSource::Screenshare => video::PRIORITY_SCREEN,
|
||||
TrackSource::Camera => video::PRIORITY_CAMERA,
|
||||
_ => return,
|
||||
};
|
||||
|
||||
if let RemoteTrack::Video(video_track) = track {
|
||||
video::start(
|
||||
app.clone(),
|
||||
generation,
|
||||
video_track,
|
||||
priority,
|
||||
publication.sid().to_string(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
RoomEvent::TrackUnsubscribed { publication, .. } => {
|
||||
video::stop_track(app, &publication.sid().to_string());
|
||||
}
|
||||
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn emit_participants(app: &AppHandle, generation: u64, room: &Room) {
|
||||
if !is_current(generation) {
|
||||
return;
|
||||
}
|
||||
|
||||
let participants = room
|
||||
.remote_participants()
|
||||
.values()
|
||||
.map(|participant| {
|
||||
let identity = participant.identity().to_string();
|
||||
let name = participant.name();
|
||||
|
||||
ParticipantView {
|
||||
display_name: if name.is_empty() {
|
||||
identity.clone()
|
||||
} else {
|
||||
name
|
||||
},
|
||||
identity,
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
let _ = app.emit(PARTICIPANTS_EVENT, ParticipantsPayload { participants });
|
||||
}
|
||||
|
||||
fn emit_status(app: &AppHandle, generation: u64, payload: StatusPayload) {
|
||||
if !is_current(generation) {
|
||||
return;
|
||||
}
|
||||
let _ = app.emit(STATUS_EVENT, payload);
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
use serde::Serialize;
|
||||
|
||||
pub const STATUS_EVENT: &str = "room://status";
|
||||
pub const DATA_EVENT: &str = "room://data";
|
||||
pub const VIDEO_EVENT: &str = "room://video";
|
||||
pub const PARTICIPANTS_EVENT: &str = "room://participants";
|
||||
|
||||
#[derive(Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ParticipantView {
|
||||
pub identity: String,
|
||||
pub display_name: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ParticipantsPayload {
|
||||
pub participants: Vec<ParticipantView>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct StatusPayload {
|
||||
pub status: &'static str,
|
||||
pub detail: Option<String>,
|
||||
}
|
||||
|
||||
impl StatusPayload {
|
||||
pub fn new(status: &'static str) -> Self {
|
||||
Self {
|
||||
status,
|
||||
detail: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with_detail(status: &'static str, detail: String) -> Self {
|
||||
Self {
|
||||
status,
|
||||
detail: Some(detail),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct DataPayload {
|
||||
pub envelope: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct VideoPayload {
|
||||
pub active: bool,
|
||||
pub width: u32,
|
||||
pub height: u32,
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
use std::sync::Mutex;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use image::codecs::jpeg::JpegEncoder;
|
||||
use image::ExtendedColorType;
|
||||
use tauri::ipc::{Channel, InvokeResponseBody};
|
||||
|
||||
/// Where encoded frames go. The webview opens this channel once and keeps it for the life
|
||||
/// of the app, so video never has to travel as an event payload.
|
||||
static SINK: Mutex<Option<Channel<InvokeResponseBody>>> = Mutex::new(None);
|
||||
static LAST_SENT: Mutex<Option<Instant>> = Mutex::new(None);
|
||||
|
||||
const DEFAULT_MAX_WIDTH: u32 = 1920;
|
||||
const DEFAULT_FPS: u32 = 60;
|
||||
const DEFAULT_QUALITY: u8 = 85;
|
||||
|
||||
/// Read once at startup. A Pi will not keep up with the desktop defaults, so these are
|
||||
/// tunable without a rebuild: PISTATION_VIDEO_MAX_WIDTH, _FPS and _QUALITY.
|
||||
pub struct VideoSettings {
|
||||
pub max_width: u32,
|
||||
pub frame_interval: Duration,
|
||||
pub quality: u8,
|
||||
}
|
||||
|
||||
fn settings() -> &'static VideoSettings {
|
||||
static SETTINGS: std::sync::OnceLock<VideoSettings> = std::sync::OnceLock::new();
|
||||
|
||||
SETTINGS.get_or_init(|| {
|
||||
let max_width = env_number("PISTATION_VIDEO_MAX_WIDTH", DEFAULT_MAX_WIDTH as u64) as u32;
|
||||
let fps = (env_number("PISTATION_VIDEO_FPS", DEFAULT_FPS as u64) as u32).clamp(1, 120);
|
||||
let quality = env_number("PISTATION_VIDEO_QUALITY", DEFAULT_QUALITY as u64).clamp(1, 100);
|
||||
|
||||
VideoSettings {
|
||||
max_width: max_width.max(160),
|
||||
frame_interval: Duration::from_secs_f64(1.0 / fps as f64),
|
||||
quality: quality as u8,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn env_number(key: &str, fallback: u64) -> u64 {
|
||||
std::env::var(key)
|
||||
.ok()
|
||||
.and_then(|value| value.parse().ok())
|
||||
.unwrap_or(fallback)
|
||||
}
|
||||
|
||||
pub fn set_sink(channel: Option<Channel<InvokeResponseBody>>) {
|
||||
if let Ok(mut guard) = SINK.lock() {
|
||||
*guard = channel;
|
||||
}
|
||||
if let Ok(mut guard) = LAST_SENT.lock() {
|
||||
*guard = None;
|
||||
}
|
||||
}
|
||||
|
||||
pub fn has_sink() -> bool {
|
||||
SINK.lock().map(|guard| guard.is_some()).unwrap_or(false)
|
||||
}
|
||||
|
||||
/// Returns the size this frame should be encoded at, or None when it should be dropped
|
||||
/// because the previous one went out too recently.
|
||||
pub fn next_target(width: u32, height: u32) -> Option<(u32, u32)> {
|
||||
if width == 0 || height == 0 || !has_sink() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let config = settings();
|
||||
let mut guard = LAST_SENT.lock().ok()?;
|
||||
|
||||
if let Some(last) = *guard {
|
||||
if last.elapsed() < config.frame_interval {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
|
||||
*guard = Some(Instant::now());
|
||||
|
||||
if width <= config.max_width {
|
||||
return Some((width, height));
|
||||
}
|
||||
|
||||
let scaled_height = ((height as f32) * (config.max_width as f32 / width as f32)).round() as u32;
|
||||
Some((config.max_width, scaled_height.max(1)))
|
||||
}
|
||||
|
||||
/// Packs RGB into `rgb` from an R,G,B,A source and encodes into `encoded`.
|
||||
///
|
||||
/// Both buffers are owned by the caller and reused across frames. At sixty frames a second
|
||||
/// the allocations alone were costing more than the encode.
|
||||
pub fn send(source: &[u8], rgb: &mut Vec<u8>, encoded: &mut Vec<u8>, width: u32, height: u32) {
|
||||
let pixels = (width as usize) * (height as usize);
|
||||
rgb.clear();
|
||||
rgb.reserve(pixels * 3);
|
||||
|
||||
for pixel in source.chunks_exact(4) {
|
||||
rgb.extend_from_slice(&pixel[..3]);
|
||||
}
|
||||
|
||||
encoded.clear();
|
||||
let mut encoder = JpegEncoder::new_with_quality(&mut *encoded, settings().quality);
|
||||
|
||||
if let Err(error) = encoder.encode(rgb, width, height, ExtendedColorType::Rgb8) {
|
||||
report_once(&format!("jpeg encode failed: {error}"));
|
||||
return;
|
||||
}
|
||||
|
||||
let Ok(guard) = SINK.lock() else {
|
||||
return;
|
||||
};
|
||||
|
||||
if let Some(channel) = guard.as_ref() {
|
||||
if let Err(error) = channel.send(InvokeResponseBody::Raw(std::mem::take(encoded))) {
|
||||
report_once(&format!("could not push a frame to the webview: {error}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Frames arrive many times a second, so a failure that repeats must not flood the log.
|
||||
fn report_once(message: &str) {
|
||||
static REPORTED: Mutex<Option<String>> = Mutex::new(None);
|
||||
|
||||
if let Ok(mut guard) = REPORTED.lock() {
|
||||
if guard.as_deref() == Some(message) {
|
||||
return;
|
||||
}
|
||||
*guard = Some(message.to_string());
|
||||
}
|
||||
|
||||
eprintln!("[pistation] {message}");
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
pub mod client;
|
||||
mod events;
|
||||
mod frames;
|
||||
mod video;
|
||||
|
||||
pub use client::RoomHandle;
|
||||
|
||||
use tauri::ipc::{Channel, InvokeResponseBody};
|
||||
use tauri::{AppHandle, State};
|
||||
|
||||
/// The webview opens one channel for decoded video and keeps it open. Frames are JPEG
|
||||
/// encoded in Rust and drawn onto a canvas, which keeps the annotation overlay working
|
||||
/// exactly as it does in the browser.
|
||||
#[tauri::command]
|
||||
pub fn video_subscribe(channel: Channel<InvokeResponseBody>) {
|
||||
frames::set_sink(Some(channel));
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn video_unsubscribe() {
|
||||
frames::set_sink(None);
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn room_connect(
|
||||
app: AppHandle,
|
||||
handle: State<'_, RoomHandle>,
|
||||
url: String,
|
||||
token: String,
|
||||
) -> Result<(), String> {
|
||||
handle.connect(app, url, token).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn room_disconnect(handle: State<'_, RoomHandle>) -> Result<(), String> {
|
||||
handle.disconnect().await;
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
use std::sync::atomic::{AtomicBool, AtomicU64, AtomicU8, Ordering};
|
||||
use std::sync::Mutex;
|
||||
|
||||
use futures_util::StreamExt;
|
||||
use livekit::track::RemoteVideoTrack;
|
||||
use livekit::webrtc::video_frame::native::VideoFrameBufferExt;
|
||||
use livekit::webrtc::video_frame::VideoFormatType;
|
||||
use livekit::webrtc::video_stream::native::NativeVideoStream;
|
||||
use tauri::{AppHandle, Emitter};
|
||||
|
||||
use super::client::is_current;
|
||||
use super::events::{VideoPayload, VIDEO_EVENT};
|
||||
use super::frames;
|
||||
|
||||
static IS_STREAMING: AtomicBool = AtomicBool::new(false);
|
||||
|
||||
/// Bumped whenever the displayed track changes, so a frame loop for a track we have moved
|
||||
/// off stops without having to be cancelled directly.
|
||||
static STREAM_EPOCH: AtomicU64 = AtomicU64::new(0);
|
||||
static CURRENT_PRIORITY: AtomicU8 = AtomicU8::new(0);
|
||||
static CURRENT_SID: Mutex<Option<String>> = Mutex::new(None);
|
||||
|
||||
/// A shared screen always wins over a camera, so plugging in a presentation takes the
|
||||
/// display back from whoever was pointing a phone at the room.
|
||||
pub const PRIORITY_CAMERA: u8 = 1;
|
||||
pub const PRIORITY_SCREEN: u8 = 2;
|
||||
|
||||
/// Consumes decoded frames from the subscribed screen share.
|
||||
///
|
||||
/// Frames are converted to BGRA, which on a little endian machine is the same layout as
|
||||
/// cairo's ARgb32, and handed to the native drawing surface sitting under the webview.
|
||||
pub fn start(
|
||||
app: AppHandle,
|
||||
generation: u64,
|
||||
track: RemoteVideoTrack,
|
||||
priority: u8,
|
||||
sid: String,
|
||||
) {
|
||||
// A camera must not displace a screen share that is already on the display.
|
||||
if IS_STREAMING.load(Ordering::SeqCst) && CURRENT_PRIORITY.load(Ordering::SeqCst) > priority {
|
||||
return;
|
||||
}
|
||||
|
||||
let epoch = STREAM_EPOCH.fetch_add(1, Ordering::SeqCst) + 1;
|
||||
CURRENT_PRIORITY.store(priority, Ordering::SeqCst);
|
||||
IS_STREAMING.store(true, Ordering::SeqCst);
|
||||
|
||||
if let Ok(mut guard) = CURRENT_SID.lock() {
|
||||
*guard = Some(sid);
|
||||
}
|
||||
|
||||
tauri::async_runtime::spawn(async move {
|
||||
let mut stream = NativeVideoStream::new(track.rtc_track());
|
||||
let mut last_size = (0u32, 0u32);
|
||||
|
||||
// Reused across frames so a sixty frame per second stream does not allocate
|
||||
// several megabytes per frame.
|
||||
let mut rgba = Vec::new();
|
||||
let mut rgb = Vec::new();
|
||||
let mut encoded = Vec::new();
|
||||
|
||||
while let Some(frame) = stream.next().await {
|
||||
if STREAM_EPOCH.load(Ordering::SeqCst) != epoch || !is_current(generation) {
|
||||
return;
|
||||
}
|
||||
|
||||
let width = frame.buffer.width();
|
||||
let height = frame.buffer.height();
|
||||
if width == 0 || height == 0 {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (width, height) != last_size {
|
||||
last_size = (width, height);
|
||||
let _ = app.emit(
|
||||
VIDEO_EVENT,
|
||||
VideoPayload {
|
||||
active: true,
|
||||
width,
|
||||
height,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
let Some((target_width, target_height)) = frames::next_target(width, height) else {
|
||||
continue;
|
||||
};
|
||||
|
||||
// to_argb converts, it does not resample, so the buffer has to be scaled
|
||||
// first. Doing it in I420 is cheaper than scaling four channels of RGBA.
|
||||
let mut i420 = frame.buffer.to_i420();
|
||||
let source = if (target_width, target_height) == (width, height) {
|
||||
i420
|
||||
} else {
|
||||
i420.scale(target_width as i32, target_height as i32)
|
||||
};
|
||||
|
||||
let stride = target_width * 4;
|
||||
rgba.resize((stride * target_height) as usize, 0);
|
||||
|
||||
// libyuv names formats after the 32 bit word, not the byte order. On little
|
||||
// endian its "RGBA" lands in memory as A,B,G,R, so taking the first three
|
||||
// bytes as red, green and blue picked up alpha as red and tinted everything.
|
||||
// "ABGR" is the one that lays out as R,G,B,A in memory.
|
||||
source.to_argb(
|
||||
VideoFormatType::ABGR,
|
||||
&mut rgba,
|
||||
stride,
|
||||
target_width as i32,
|
||||
target_height as i32,
|
||||
);
|
||||
|
||||
frames::send(&rgba, &mut rgb, &mut encoded, target_width, target_height);
|
||||
}
|
||||
|
||||
// The stream ended on its own rather than being replaced.
|
||||
if STREAM_EPOCH.load(Ordering::SeqCst) == epoch && is_current(generation) {
|
||||
stop(&app);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Clears the display only if the track that went away is the one being shown. A camera
|
||||
/// leaving must not blank a screen share that took over from it.
|
||||
pub fn stop_track(app: &AppHandle, sid: &str) {
|
||||
let is_current_track = CURRENT_SID
|
||||
.lock()
|
||||
.map(|guard| guard.as_deref() == Some(sid))
|
||||
.unwrap_or(false);
|
||||
|
||||
if is_current_track {
|
||||
stop(app);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn stop(app: &AppHandle) {
|
||||
STREAM_EPOCH.fetch_add(1, Ordering::SeqCst);
|
||||
CURRENT_PRIORITY.store(0, Ordering::SeqCst);
|
||||
|
||||
if let Ok(mut guard) = CURRENT_SID.lock() {
|
||||
*guard = None;
|
||||
}
|
||||
|
||||
if !IS_STREAMING.swap(false, Ordering::SeqCst) {
|
||||
return;
|
||||
}
|
||||
|
||||
let _ = app.emit(
|
||||
VIDEO_EVENT,
|
||||
VideoPayload {
|
||||
active: false,
|
||||
width: 0,
|
||||
height: 0,
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "pistation-kiosk",
|
||||
"version": "0.1.0",
|
||||
"identifier": "co.sirblob.pistation",
|
||||
"build": {
|
||||
"beforeDevCommand": "bun run dev",
|
||||
"devUrl": "http://localhost:1420",
|
||||
"beforeBuildCommand": "bun run build",
|
||||
"frontendDist": "../build"
|
||||
},
|
||||
"app": {
|
||||
"windows": [
|
||||
{
|
||||
"title": "PiStation",
|
||||
"width": 1280,
|
||||
"height": 720,
|
||||
"fullscreen": false,
|
||||
"decorations": false,
|
||||
"resizable": true,
|
||||
"focus": true
|
||||
}
|
||||
],
|
||||
"security": {
|
||||
"csp": null
|
||||
}
|
||||
},
|
||||
"bundle": {
|
||||
"active": true,
|
||||
"targets": ["deb", "appimage"],
|
||||
"icon": [
|
||||
"icons/32x32.png",
|
||||
"icons/128x128.png",
|
||||
"icons/128x128@2x.png",
|
||||
"icons/icon.icns",
|
||||
"icons/icon.ico"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
@source "../../../packages/ui/src";
|
||||
|
||||
@theme {
|
||||
--color-surface-0: #0b0d10;
|
||||
--color-surface-1: #14181d;
|
||||
--color-surface-2: #1c2229;
|
||||
--color-ink-0: #f4f6f8;
|
||||
--color-ink-1: #a8b3c0;
|
||||
--color-ink-2: #6b7885;
|
||||
--color-accent: #4f7cff;
|
||||
--color-danger: #ff3b52;
|
||||
--color-success: #21c17a;
|
||||
--font-sans: "Inter", system-ui, sans-serif;
|
||||
--font-mono: "JetBrains Mono", "SF Mono", Menlo, monospace;
|
||||
}
|
||||
|
||||
* {
|
||||
border-radius: 0 !important;
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
overflow: hidden;
|
||||
background-color: var(--color-surface-0);
|
||||
color: var(--color-ink-0);
|
||||
font-family: var(--font-sans);
|
||||
user-select: none;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="en" class="h-full">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<link rel="icon" href="%sveltekit.assets%/logo.svg" type="image/svg+xml" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>PiStation Kiosk</title>
|
||||
%sveltekit.head%
|
||||
</head>
|
||||
<body data-sveltekit-preload-data="hover" class="h-full">
|
||||
<div style="display: contents">%sveltekit.body%</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,86 @@
|
||||
import type { KioskLayout, KioskPinResponse, KioskSessionResponse } from "@pistation/shared-types";
|
||||
|
||||
export interface RegisterResult {
|
||||
kioskId: string;
|
||||
kioskToken: string;
|
||||
roomName: string;
|
||||
livekitUrl: string;
|
||||
rotationSeconds: number;
|
||||
}
|
||||
|
||||
export class KioskApiError extends Error {
|
||||
readonly status: number;
|
||||
|
||||
constructor(status: number, message: string) {
|
||||
super(message);
|
||||
this.status = status;
|
||||
}
|
||||
}
|
||||
|
||||
async function request<T>(
|
||||
serverUrl: string,
|
||||
path: string,
|
||||
options: { method?: string; token?: string; body?: unknown } = {}
|
||||
): Promise<T> {
|
||||
const headers: Record<string, string> = {};
|
||||
if (options.body !== undefined) headers["content-type"] = "application/json";
|
||||
if (options.token) headers["authorization"] = `Bearer ${options.token}`;
|
||||
|
||||
let response: Response;
|
||||
|
||||
try {
|
||||
response = await fetch(`${serverUrl.replace(/\/$/, "")}${path}`, {
|
||||
method: options.method ?? "GET",
|
||||
headers,
|
||||
body: options.body === undefined ? undefined : JSON.stringify(options.body)
|
||||
});
|
||||
} catch {
|
||||
throw new KioskApiError(0, `could not reach the server at ${serverUrl}`);
|
||||
}
|
||||
|
||||
if (response.status === 204) return undefined as T;
|
||||
|
||||
const payload = await response.json().catch(() => null);
|
||||
|
||||
if (!response.ok) {
|
||||
const message = (payload as { message?: string })?.message ?? `request failed: ${path}`;
|
||||
throw new KioskApiError(response.status, message);
|
||||
}
|
||||
|
||||
return payload as T;
|
||||
}
|
||||
|
||||
export function registerKiosk(
|
||||
serverUrl: string,
|
||||
enrollmentToken: string,
|
||||
hardwareId: string
|
||||
): Promise<RegisterResult> {
|
||||
return request<RegisterResult>(serverUrl, "/api/kiosk/register", {
|
||||
method: "POST",
|
||||
body: { enrollmentToken, hardwareId }
|
||||
});
|
||||
}
|
||||
|
||||
export function fetchPin(serverUrl: string, token: string): Promise<KioskPinResponse> {
|
||||
return request<KioskPinResponse>(serverUrl, "/api/kiosk/pin", { token });
|
||||
}
|
||||
|
||||
export function fetchSession(serverUrl: string, token: string): Promise<KioskSessionResponse> {
|
||||
return request<KioskSessionResponse>(serverUrl, "/api/kiosk/session", { token });
|
||||
}
|
||||
|
||||
export function fetchLayout(serverUrl: string, token: string): Promise<KioskLayout> {
|
||||
return request<KioskLayout>(serverUrl, "/api/kiosk/layout", { token });
|
||||
}
|
||||
|
||||
export function sendHeartbeat(
|
||||
serverUrl: string,
|
||||
token: string,
|
||||
metrics: unknown
|
||||
): Promise<void> {
|
||||
return request<void>(serverUrl, "/api/kiosk/heartbeat", {
|
||||
method: "POST",
|
||||
token,
|
||||
body: { metrics }
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
<script lang="ts">
|
||||
import { formatPin } from "@pistation/shared-types";
|
||||
|
||||
let {
|
||||
pin,
|
||||
joinUrl
|
||||
}: {
|
||||
pin: string | null;
|
||||
joinUrl: string;
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
{#if pin}
|
||||
<div class="absolute top-6 right-6 flex flex-col items-end gap-1 bg-surface-0/80 px-5 py-3">
|
||||
{#if joinUrl}
|
||||
<p class="text-xs tracking-wide text-ink-2 uppercase">Join at {joinUrl}</p>
|
||||
{:else}
|
||||
<p class="text-xs tracking-wide text-ink-2 uppercase">Join code</p>
|
||||
{/if}
|
||||
<p class="font-mono text-3xl leading-none font-bold tracking-[0.08em] text-ink-0">
|
||||
{formatPin(pin)}
|
||||
</p>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -0,0 +1,50 @@
|
||||
<script lang="ts">
|
||||
import Icon from "@iconify/svelte";
|
||||
import { Logo } from "@pistation/ui";
|
||||
import { getCurrentWindow } from "@tauri-apps/api/window";
|
||||
|
||||
let { status }: { status: string } = $props();
|
||||
|
||||
const appWindow = getCurrentWindow();
|
||||
|
||||
async function toggleMaximize() {
|
||||
if (await appWindow.isMaximized()) {
|
||||
await appWindow.unmaximize();
|
||||
} else {
|
||||
await appWindow.maximize();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<header
|
||||
data-tauri-drag-region
|
||||
class="flex h-9 shrink-0 items-center gap-3 bg-surface-1 pr-px pl-3 select-none"
|
||||
>
|
||||
<Logo size={18} />
|
||||
<span data-tauri-drag-region class="text-xs font-medium text-ink-1">PiStation kiosk</span>
|
||||
<span class="text-xs text-ink-2">{status}</span>
|
||||
|
||||
<div class="ml-auto flex h-full">
|
||||
<button
|
||||
onclick={() => appWindow.minimize()}
|
||||
aria-label="Minimise"
|
||||
class="flex h-full w-11 items-center justify-center text-ink-1 transition-colors hover:bg-surface-2"
|
||||
>
|
||||
<Icon icon="ph:minus-bold" width="14" />
|
||||
</button>
|
||||
<button
|
||||
onclick={toggleMaximize}
|
||||
aria-label="Maximise"
|
||||
class="flex h-full w-11 items-center justify-center text-ink-1 transition-colors hover:bg-surface-2"
|
||||
>
|
||||
<Icon icon="ph:square-bold" width="12" />
|
||||
</button>
|
||||
<button
|
||||
onclick={() => appWindow.close()}
|
||||
aria-label="Close"
|
||||
class="flex h-full w-11 items-center justify-center text-ink-1 transition-colors hover:bg-danger hover:text-white"
|
||||
>
|
||||
<Icon icon="ph:x-bold" width="14" />
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
@@ -0,0 +1,84 @@
|
||||
<script lang="ts">
|
||||
import { Channel, invoke } from "@tauri-apps/api/core";
|
||||
import { onMount } from "svelte";
|
||||
|
||||
let { visible = false }: { visible?: boolean } = $props();
|
||||
|
||||
let canvas = $state<HTMLCanvasElement | null>(null);
|
||||
let hasFrame = $state(false);
|
||||
|
||||
// The channel is opened once for the life of the page. Opening it per presentation left
|
||||
// Rust holding a callback id the webview had already discarded.
|
||||
onMount(() => {
|
||||
const channel = new Channel<ArrayBuffer | number[]>();
|
||||
let disposed = false;
|
||||
let isDecoding = false;
|
||||
let pending: ImageBitmap | null = null;
|
||||
let frameRequest = 0;
|
||||
|
||||
channel.onmessage = (message) => {
|
||||
// Decoding one frame at a time means a slow decode drops frames instead of
|
||||
// building a backlog, which would show up as growing latency.
|
||||
if (disposed || isDecoding) return;
|
||||
|
||||
const bytes = message instanceof ArrayBuffer ? message : new Uint8Array(message);
|
||||
isDecoding = true;
|
||||
|
||||
void createImageBitmap(new Blob([bytes], { type: "image/jpeg" }))
|
||||
.then((bitmap) => {
|
||||
if (disposed) {
|
||||
bitmap.close();
|
||||
return;
|
||||
}
|
||||
pending?.close();
|
||||
pending = bitmap;
|
||||
})
|
||||
.catch(() => undefined)
|
||||
.finally(() => {
|
||||
isDecoding = false;
|
||||
});
|
||||
};
|
||||
|
||||
// Painting on the animation frame ties output to the display refresh, so frames are
|
||||
// never drawn twice or torn between refreshes.
|
||||
function paint() {
|
||||
frameRequest = requestAnimationFrame(paint);
|
||||
|
||||
if (!pending || !canvas) return;
|
||||
|
||||
const context = canvas.getContext("2d", { alpha: false });
|
||||
if (!context) return;
|
||||
|
||||
if (canvas.width !== pending.width || canvas.height !== pending.height) {
|
||||
canvas.width = pending.width;
|
||||
canvas.height = pending.height;
|
||||
}
|
||||
|
||||
context.drawImage(pending, 0, 0);
|
||||
pending.close();
|
||||
pending = null;
|
||||
hasFrame = true;
|
||||
}
|
||||
|
||||
frameRequest = requestAnimationFrame(paint);
|
||||
|
||||
void invoke("video_subscribe", { channel }).catch((error) =>
|
||||
console.error("[pistation] video_subscribe failed", error)
|
||||
);
|
||||
|
||||
return () => {
|
||||
disposed = true;
|
||||
cancelAnimationFrame(frameRequest);
|
||||
pending?.close();
|
||||
void invoke("video_unsubscribe").catch(() => undefined);
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
||||
<canvas
|
||||
bind:this={canvas}
|
||||
class="absolute inset-0 h-full w-full bg-black"
|
||||
class:hidden={!visible}
|
||||
class:opacity-0={!hasFrame}
|
||||
style="object-fit: contain; image-rendering: auto;"
|
||||
></canvas>
|
||||
@@ -0,0 +1,293 @@
|
||||
import type {
|
||||
DataEnvelope,
|
||||
KioskLayout,
|
||||
RoomMode,
|
||||
WhiteboardElement
|
||||
} from "@pistation/shared-types";
|
||||
import {
|
||||
isNightModeActive,
|
||||
isTopic,
|
||||
mergeWhiteboardElements,
|
||||
PIN_GRACE_SECONDS
|
||||
} from "@pistation/shared-types";
|
||||
import type { ConnectionStatus } from "@pistation/client-core";
|
||||
import type { AnnotationState } from "@pistation/client-core/annotations";
|
||||
import {
|
||||
applyAnnotationEvent,
|
||||
createAnnotationState,
|
||||
prunePointers
|
||||
} from "@pistation/client-core/annotations";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
|
||||
import { NativeRoom, type RoomParticipant } from "./native-room";
|
||||
|
||||
import {
|
||||
fetchLayout,
|
||||
fetchPin,
|
||||
fetchSession,
|
||||
KioskApiError,
|
||||
registerKiosk,
|
||||
sendHeartbeat
|
||||
} from "./api";
|
||||
|
||||
const HEARTBEAT_INTERVAL_MS = 30_000;
|
||||
const LAYOUT_INTERVAL_MS = 60_000;
|
||||
const PIN_FALLBACK_INTERVAL_MS = 20_000;
|
||||
|
||||
interface KioskConfig {
|
||||
serverUrl: string;
|
||||
enrollmentToken: string;
|
||||
kioskToken: string;
|
||||
kioskId: string;
|
||||
roomName: string;
|
||||
livekitUrl: string;
|
||||
joinUrl: string;
|
||||
}
|
||||
|
||||
export class KioskController {
|
||||
status = $state<ConnectionStatus>("idle");
|
||||
provisioningError = $state<string | null>(null);
|
||||
pin = $state<string | null>(null);
|
||||
layout = $state<KioskLayout | null>(null);
|
||||
mode = $state<RoomMode>("idle");
|
||||
annotations = $state<AnnotationState>(createAnnotationState());
|
||||
whiteboardElements = $state<WhiteboardElement[]>([]);
|
||||
participants = $state<RoomParticipant[]>([]);
|
||||
isScreenActive = $state(false);
|
||||
screenWidth = $state(0);
|
||||
screenHeight = $state(0);
|
||||
joinUrl = $state("");
|
||||
mediaBaseUrl = $state("");
|
||||
isNight = $state(false);
|
||||
connectionError = $state<string | null>(null);
|
||||
|
||||
private config: KioskConfig | null = null;
|
||||
private connection: NativeRoom | null = null;
|
||||
private timers: ReturnType<typeof setInterval>[] = [];
|
||||
private pinTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
private hasRetriedEnrollment = false;
|
||||
|
||||
async start(): Promise<void> {
|
||||
try {
|
||||
this.config = await invoke<KioskConfig>("load_config");
|
||||
} catch {
|
||||
this.provisioningError = "Could not read the kiosk configuration file.";
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.config.serverUrl) {
|
||||
this.provisioningError = "No server URL configured. Provision this kiosk first.";
|
||||
return;
|
||||
}
|
||||
|
||||
this.joinUrl = this.config.joinUrl;
|
||||
this.mediaBaseUrl = this.config.serverUrl;
|
||||
|
||||
if (!this.config.kioskToken) {
|
||||
const registered = await this.register();
|
||||
if (!registered) return;
|
||||
}
|
||||
|
||||
void this.refreshPin();
|
||||
void this.refreshLayout();
|
||||
void this.connectToRoom();
|
||||
|
||||
this.addInterval(() => void this.heartbeat(), HEARTBEAT_INTERVAL_MS);
|
||||
this.addInterval(() => void this.refreshLayout(), LAYOUT_INTERVAL_MS);
|
||||
this.addInterval(() => {
|
||||
this.annotations = prunePointers(this.annotations, Date.now());
|
||||
}, 1000);
|
||||
this.addInterval(() => this.evaluateNightMode(), 20_000);
|
||||
}
|
||||
|
||||
async stop(): Promise<void> {
|
||||
for (const timer of this.timers) clearInterval(timer);
|
||||
this.timers = [];
|
||||
if (this.pinTimer) clearTimeout(this.pinTimer);
|
||||
this.pinTimer = null;
|
||||
await this.connection?.stop();
|
||||
this.connection = null;
|
||||
}
|
||||
|
||||
private addInterval(action: () => void, intervalMs: number): void {
|
||||
this.timers.push(setInterval(action, intervalMs));
|
||||
}
|
||||
|
||||
private async register(): Promise<boolean> {
|
||||
if (!this.config) return false;
|
||||
|
||||
if (!this.config.enrollmentToken) {
|
||||
this.provisioningError = "This kiosk has no enrollment token. Add one in the admin panel.";
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
const hardwareId = await invoke<string>("hardware_id");
|
||||
const result = await registerKiosk(
|
||||
this.config.serverUrl,
|
||||
this.config.enrollmentToken,
|
||||
hardwareId
|
||||
);
|
||||
|
||||
this.config = {
|
||||
...this.config,
|
||||
kioskId: result.kioskId,
|
||||
kioskToken: result.kioskToken,
|
||||
roomName: result.roomName,
|
||||
livekitUrl: result.livekitUrl,
|
||||
enrollmentToken: ""
|
||||
};
|
||||
|
||||
await invoke("save_config", { config: this.config });
|
||||
this.provisioningError = null;
|
||||
return true;
|
||||
} catch (error) {
|
||||
this.provisioningError =
|
||||
error instanceof Error ? error.message : "Enrollment failed. Check the token.";
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private async handleRejectedToken(): Promise<boolean> {
|
||||
if (!this.config) return false;
|
||||
|
||||
if (this.hasRetriedEnrollment) {
|
||||
this.provisioningError =
|
||||
"This kiosk's credentials were rejected. Rotate its enrollment token in the admin panel and run the installer again.";
|
||||
return false;
|
||||
}
|
||||
|
||||
this.hasRetriedEnrollment = true;
|
||||
this.config = { ...this.config, kioskToken: "" };
|
||||
await invoke("save_config", { config: this.config });
|
||||
|
||||
this.config = await invoke<KioskConfig>("load_config");
|
||||
if (!(await this.register())) return false;
|
||||
|
||||
void this.refreshPin();
|
||||
void this.refreshLayout();
|
||||
void this.connectToRoom();
|
||||
return true;
|
||||
}
|
||||
|
||||
private async refreshPin(): Promise<void> {
|
||||
if (!this.config?.kioskToken) return;
|
||||
|
||||
let delayMs = PIN_FALLBACK_INTERVAL_MS;
|
||||
|
||||
try {
|
||||
const issued = await fetchPin(this.config.serverUrl, this.config.kioskToken);
|
||||
this.pin = issued.pin;
|
||||
delayMs = Math.max(5000, issued.expiresAt - Date.now() - PIN_GRACE_SECONDS * 1000);
|
||||
} catch (error) {
|
||||
this.pin = null;
|
||||
if (error instanceof KioskApiError && error.status === 401) {
|
||||
if (await this.handleRejectedToken()) return;
|
||||
}
|
||||
}
|
||||
|
||||
if (this.pinTimer) clearTimeout(this.pinTimer);
|
||||
this.pinTimer = setTimeout(() => void this.refreshPin(), delayMs);
|
||||
}
|
||||
|
||||
private async refreshLayout(): Promise<void> {
|
||||
if (!this.config?.kioskToken) return;
|
||||
|
||||
const layout = await fetchLayout(this.config.serverUrl, this.config.kioskToken).catch(
|
||||
() => null
|
||||
);
|
||||
if (!layout) return;
|
||||
|
||||
this.layout = layout;
|
||||
this.evaluateNightMode();
|
||||
}
|
||||
|
||||
private evaluateNightMode(): void {
|
||||
this.isNight = this.layout ? isNightModeActive(this.layout.nightMode) : false;
|
||||
}
|
||||
|
||||
private async heartbeat(): Promise<void> {
|
||||
if (!this.config?.kioskToken) return;
|
||||
|
||||
const metrics = await invoke("collect_metrics").catch(() => null);
|
||||
await sendHeartbeat(this.config.serverUrl, this.config.kioskToken, metrics).catch(
|
||||
() => undefined
|
||||
);
|
||||
}
|
||||
|
||||
private async connectToRoom(): Promise<void> {
|
||||
if (!this.config?.kioskToken) return;
|
||||
|
||||
const config = this.config;
|
||||
|
||||
this.connection = new NativeRoom(
|
||||
async () => {
|
||||
const session = await fetchSession(config.serverUrl, config.kioskToken);
|
||||
return { livekitUrl: session.livekitUrl, accessToken: session.accessToken };
|
||||
},
|
||||
{
|
||||
onStatus: (status, detail) => {
|
||||
this.status = status;
|
||||
|
||||
if (status === "connected") {
|
||||
this.connectionError = null;
|
||||
return;
|
||||
}
|
||||
|
||||
if (status === "disconnected" || status === "failed") {
|
||||
this.connectionError = detail;
|
||||
this.isScreenActive = false;
|
||||
this.mode = "idle";
|
||||
this.participants = [];
|
||||
this.annotations = createAnnotationState();
|
||||
}
|
||||
},
|
||||
onEnvelope: (envelope) => this.handleEnvelope(envelope),
|
||||
onVideo: (active, width, height) => {
|
||||
this.isScreenActive = active;
|
||||
this.screenWidth = width;
|
||||
this.screenHeight = height;
|
||||
|
||||
if (active && this.mode === "idle") this.mode = "presentation";
|
||||
if (!active && this.mode === "presentation") this.mode = "idle";
|
||||
},
|
||||
onParticipants: (participants) => {
|
||||
this.participants = participants;
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
await this.connection.start();
|
||||
}
|
||||
|
||||
private handleEnvelope(envelope: DataEnvelope): void {
|
||||
if (isTopic(envelope, "annotation")) {
|
||||
this.annotations = applyAnnotationEvent(
|
||||
this.annotations,
|
||||
envelope.payload,
|
||||
envelope.senderId
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (isTopic(envelope, "control")) {
|
||||
const event = envelope.payload;
|
||||
if (event.type === "mode.set") this.mode = event.mode;
|
||||
if (event.type === "room.state") this.mode = event.state.mode;
|
||||
return;
|
||||
}
|
||||
|
||||
if (isTopic(envelope, "whiteboard")) {
|
||||
const event = envelope.payload;
|
||||
if (event.type === "whiteboard.patch") {
|
||||
this.whiteboardElements = mergeWhiteboardElements(this.whiteboardElements, event.elements);
|
||||
}
|
||||
if (event.type === "whiteboard.snapshot") {
|
||||
this.whiteboardElements = event.elements;
|
||||
}
|
||||
if (event.type === "whiteboard.clear") {
|
||||
this.whiteboardElements = [];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
import type { ConnectionStatus } from "@pistation/client-core";
|
||||
import type { DataEnvelope } from "@pistation/shared-types";
|
||||
import { parseEnvelope } from "@pistation/shared-types";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { listen, type UnlistenFn } from "@tauri-apps/api/event";
|
||||
|
||||
interface StatusEvent {
|
||||
status: string;
|
||||
detail: string | null;
|
||||
}
|
||||
|
||||
interface DataEvent {
|
||||
envelope: string;
|
||||
}
|
||||
|
||||
interface VideoEvent {
|
||||
active: boolean;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
export interface RoomParticipant {
|
||||
identity: string;
|
||||
displayName: string;
|
||||
}
|
||||
|
||||
interface ParticipantsEvent {
|
||||
participants: RoomParticipant[];
|
||||
}
|
||||
|
||||
export interface Credentials {
|
||||
livekitUrl: string;
|
||||
accessToken: string;
|
||||
}
|
||||
|
||||
export interface NativeRoomHandlers {
|
||||
onStatus(status: ConnectionStatus, detail: string | null): void;
|
||||
onEnvelope(envelope: DataEnvelope): void;
|
||||
onVideo(active: boolean, width: number, height: number): void;
|
||||
onParticipants(participants: RoomParticipant[]): void;
|
||||
}
|
||||
|
||||
const RETRY_DELAYS_MS = [1000, 2000, 4000, 8000, 15000, 30000];
|
||||
|
||||
function toConnectionStatus(status: string): ConnectionStatus {
|
||||
switch (status) {
|
||||
case "connecting":
|
||||
case "connected":
|
||||
case "reconnecting":
|
||||
case "disconnected":
|
||||
return status;
|
||||
default:
|
||||
return "failed";
|
||||
}
|
||||
}
|
||||
|
||||
/// Media lives in Rust. This drives the native LiveKit client over Tauri commands and
|
||||
/// turns its events back into the same shape the web client works with.
|
||||
export class NativeRoom {
|
||||
private fetchCredentials: () => Promise<Credentials>;
|
||||
private handlers: NativeRoomHandlers;
|
||||
private unlisteners: UnlistenFn[] = [];
|
||||
private retryIndex = 0;
|
||||
private retryTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
private stopped = false;
|
||||
|
||||
constructor(fetchCredentials: () => Promise<Credentials>, handlers: NativeRoomHandlers) {
|
||||
this.fetchCredentials = fetchCredentials;
|
||||
this.handlers = handlers;
|
||||
}
|
||||
|
||||
async start(): Promise<void> {
|
||||
this.stopped = false;
|
||||
await this.bindEvents();
|
||||
await this.attempt();
|
||||
}
|
||||
|
||||
async stop(): Promise<void> {
|
||||
this.stopped = true;
|
||||
this.clearRetry();
|
||||
|
||||
for (const unlisten of this.unlisteners) unlisten();
|
||||
this.unlisteners = [];
|
||||
|
||||
await invoke("room_disconnect").catch(() => undefined);
|
||||
}
|
||||
|
||||
private async bindEvents(): Promise<void> {
|
||||
this.unlisteners.push(
|
||||
await listen<StatusEvent>("room://status", (event) => {
|
||||
const status = toConnectionStatus(event.payload.status);
|
||||
this.handlers.onStatus(status, event.payload.detail);
|
||||
|
||||
if (status === "connected") {
|
||||
this.retryIndex = 0;
|
||||
return;
|
||||
}
|
||||
|
||||
if (status === "disconnected" || status === "failed") {
|
||||
this.scheduleRetry();
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
this.unlisteners.push(
|
||||
await listen<DataEvent>("room://data", (event) => {
|
||||
const envelope = parseEnvelope(event.payload.envelope);
|
||||
if (envelope) this.handlers.onEnvelope(envelope);
|
||||
})
|
||||
);
|
||||
|
||||
this.unlisteners.push(
|
||||
await listen<VideoEvent>("room://video", (event) => {
|
||||
this.handlers.onVideo(event.payload.active, event.payload.width, event.payload.height);
|
||||
})
|
||||
);
|
||||
|
||||
this.unlisteners.push(
|
||||
await listen<ParticipantsEvent>("room://participants", (event) => {
|
||||
this.handlers.onParticipants(event.payload.participants);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
private async attempt(): Promise<void> {
|
||||
if (this.stopped) return;
|
||||
|
||||
try {
|
||||
const credentials = await this.fetchCredentials();
|
||||
await invoke("room_connect", {
|
||||
url: credentials.livekitUrl,
|
||||
token: credentials.accessToken
|
||||
});
|
||||
} catch (error) {
|
||||
const detail = error instanceof Error ? error.message : String(error);
|
||||
console.error(`[pistation] native room connect failed: ${detail}`);
|
||||
this.handlers.onStatus("failed", detail);
|
||||
this.scheduleRetry();
|
||||
}
|
||||
}
|
||||
|
||||
private scheduleRetry(): void {
|
||||
if (this.stopped || this.retryTimer) return;
|
||||
|
||||
const delay = RETRY_DELAYS_MS[Math.min(this.retryIndex, RETRY_DELAYS_MS.length - 1)];
|
||||
this.retryIndex += 1;
|
||||
|
||||
this.retryTimer = setTimeout(() => {
|
||||
this.retryTimer = null;
|
||||
void this.attempt();
|
||||
}, delay);
|
||||
}
|
||||
|
||||
private clearRetry(): void {
|
||||
if (this.retryTimer) {
|
||||
clearTimeout(this.retryTimer);
|
||||
this.retryTimer = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
<script lang="ts">
|
||||
import "../app.css";
|
||||
|
||||
let { children } = $props();
|
||||
</script>
|
||||
|
||||
{@render children()}
|
||||
@@ -0,0 +1,5 @@
|
||||
// Tauri doesn't have a Node.js server to do proper SSR
|
||||
// so we use adapter-static with a fallback to index.html to put the site in SPA mode
|
||||
// See: https://svelte.dev/docs/kit/single-page-apps
|
||||
// See: https://v2.tauri.app/start/frontend/sveltekit/ for more info
|
||||
export const ssr = false;
|
||||
@@ -0,0 +1,117 @@
|
||||
<script lang="ts">
|
||||
import Icon from "@iconify/svelte";
|
||||
import {
|
||||
AnnotationOverlay,
|
||||
Logo,
|
||||
NightSurface,
|
||||
WhiteboardCanvas,
|
||||
WidgetSurface
|
||||
} from "@pistation/ui";
|
||||
import { onDestroy, onMount } from "svelte";
|
||||
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
|
||||
import JoinBadge from "$lib/components/JoinBadge.svelte";
|
||||
import TitleBar from "$lib/components/TitleBar.svelte";
|
||||
import VideoCanvas from "$lib/components/VideoCanvas.svelte";
|
||||
import { KioskController } from "$lib/kiosk.svelte";
|
||||
|
||||
let controller = $state<KioskController | null>(null);
|
||||
let isKioskMode = $state(true);
|
||||
|
||||
const participantLabels = $derived(
|
||||
new Map((controller?.participants ?? []).map((p) => [p.identity, p.displayName]))
|
||||
);
|
||||
|
||||
onMount(() => {
|
||||
void invoke<boolean>("is_kiosk_mode")
|
||||
.then((value) => (isKioskMode = value))
|
||||
.catch(() => (isKioskMode = true));
|
||||
|
||||
const instance = new KioskController();
|
||||
controller = instance;
|
||||
void instance.start();
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
void controller?.stop();
|
||||
});
|
||||
</script>
|
||||
|
||||
{#if controller}
|
||||
{@const kiosk = controller}
|
||||
{@const isPresenting = kiosk.mode === "presentation" && kiosk.isScreenActive}
|
||||
<div class="flex h-screen w-screen flex-col overflow-hidden bg-surface-0">
|
||||
{#if !isKioskMode}
|
||||
<TitleBar status={kiosk.status} />
|
||||
{/if}
|
||||
|
||||
<div class="relative min-h-0 flex-1 overflow-hidden">
|
||||
<VideoCanvas visible={isPresenting} />
|
||||
|
||||
{#if kiosk.provisioningError}
|
||||
<div class="flex h-full flex-col items-center justify-center gap-6 px-16 text-center">
|
||||
<Logo size={96} />
|
||||
<div>
|
||||
<h1 class="text-3xl font-semibold">This kiosk is not set up yet</h1>
|
||||
<p class="mt-3 max-w-2xl text-lg text-ink-2">{kiosk.provisioningError}</p>
|
||||
</div>
|
||||
<p class="text-sm text-ink-2">
|
||||
Add the server URL and enrollment token to /boot/firmware/pistation.json and restart.
|
||||
</p>
|
||||
</div>
|
||||
{:else if kiosk.mode === "whiteboard"}
|
||||
<WhiteboardCanvas elements={kiosk.whiteboardElements} readOnly={true} />
|
||||
{:else if isPresenting}
|
||||
<AnnotationOverlay annotations={kiosk.annotations} labels={participantLabels} />
|
||||
{:else if kiosk.layout && kiosk.isNight}
|
||||
<NightSurface layout={kiosk.layout} pin={kiosk.pin} />
|
||||
{:else if kiosk.layout}
|
||||
<WidgetSurface
|
||||
layout={kiosk.layout}
|
||||
pin={kiosk.pin}
|
||||
joinUrl={kiosk.joinUrl}
|
||||
mediaBaseUrl={kiosk.mediaBaseUrl}
|
||||
/>
|
||||
{:else}
|
||||
<div class="flex h-full items-center justify-center">
|
||||
<Icon icon="ph:circle-notch-bold" width="48" class="animate-spin text-ink-2" />
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if (isPresenting || kiosk.mode === "whiteboard") && !kiosk.provisioningError}
|
||||
<JoinBadge pin={kiosk.pin} joinUrl={kiosk.joinUrl} />
|
||||
{/if}
|
||||
|
||||
{#if kiosk.participants.length > 0 && !kiosk.provisioningError}
|
||||
<div
|
||||
class="absolute bottom-6 left-6 flex items-center gap-2 bg-surface-1/90 px-4 py-2.5 text-ink-1"
|
||||
>
|
||||
<Icon icon="ph:users-bold" width="18" />
|
||||
<span class="text-lg font-semibold text-ink-0">{kiosk.participants.length}</span>
|
||||
<span class="text-sm">
|
||||
{kiosk.participants.length === 1 ? "person connected" : "people connected"}
|
||||
</span>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if !kiosk.provisioningError && kiosk.status !== "connected" && kiosk.status !== "idle"}
|
||||
<div class="absolute right-6 bottom-6 flex max-w-lg items-start gap-2 bg-surface-1 px-4 py-2">
|
||||
<Icon
|
||||
icon={kiosk.status === "connecting" ? "ph:circle-notch-bold" : "ph:wifi-slash-bold"}
|
||||
width="18"
|
||||
class={kiosk.status === "connecting" ? "mt-0.5 animate-spin text-ink-2" : "mt-0.5 text-danger"}
|
||||
/>
|
||||
<div class="min-w-0">
|
||||
<span class="text-sm text-ink-1">
|
||||
{kiosk.status === "connecting" ? "Connecting" : "Reconnecting"}
|
||||
</span>
|
||||
{#if kiosk.connectionError}
|
||||
<p class="text-xs break-words text-ink-2">{kiosk.connectionError}</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -0,0 +1,24 @@
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="64"
|
||||
height="64"
|
||||
viewBox="0 0 32 32"
|
||||
shape-rendering="crispEdges"
|
||||
role="img"
|
||||
aria-label="PiStation"
|
||||
>
|
||||
<title>PiStation</title>
|
||||
|
||||
<rect x="2" y="4" width="28" height="19" fill="#4f7cff" />
|
||||
<rect x="4" y="6" width="24" height="14" fill="#14181d" />
|
||||
|
||||
<rect x="8" y="8" width="4" height="4" fill="#f4f6f8" />
|
||||
<rect x="14" y="8" width="4" height="4" fill="#f4f6f8" />
|
||||
<rect x="20" y="8" width="4" height="4" fill="#f4f6f8" />
|
||||
<rect x="8" y="14" width="4" height="4" fill="#f4f6f8" />
|
||||
<rect x="14" y="14" width="4" height="4" fill="#f4f6f8" />
|
||||
<rect x="20" y="14" width="4" height="4" fill="#4f7cff" />
|
||||
|
||||
<rect x="14" y="23" width="4" height="3" fill="#4f7cff" />
|
||||
<rect x="9" y="26" width="14" height="2" fill="#4f7cff" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 804 B |
@@ -0,0 +1,18 @@
|
||||
// Tauri doesn't have a Node.js server to do proper SSR
|
||||
// so we use adapter-static with a fallback to index.html to put the site in SPA mode
|
||||
// See: https://svelte.dev/docs/kit/single-page-apps
|
||||
// See: https://v2.tauri.app/start/frontend/sveltekit/ for more info
|
||||
import adapter from "@sveltejs/adapter-static";
|
||||
import { vitePreprocess } from "@sveltejs/vite-plugin-svelte";
|
||||
|
||||
/** @type {import('@sveltejs/kit').Config} */
|
||||
const config = {
|
||||
preprocess: vitePreprocess(),
|
||||
kit: {
|
||||
adapter: adapter({
|
||||
fallback: "index.html",
|
||||
}),
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"extends": "./.svelte-kit/tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"allowJs": true,
|
||||
"checkJs": true,
|
||||
"esModuleInterop": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"resolveJsonModule": true,
|
||||
"skipLibCheck": true,
|
||||
"sourceMap": true,
|
||||
"strict": true,
|
||||
"moduleResolution": "bundler"
|
||||
}
|
||||
// Path aliases are handled by https://svelte.dev/docs/kit/configuration#alias
|
||||
// except $lib which is handled by https://svelte.dev/docs/kit/configuration#files
|
||||
//
|
||||
// If you want to overwrite includes/excludes, make sure to copy over the relevant includes/excludes
|
||||
// from the referenced tsconfig.json - TypeScript does not merge them in
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { sveltekit } from "@sveltejs/kit/vite";
|
||||
import tailwindcss from "@tailwindcss/vite";
|
||||
import { defineConfig, searchForWorkspaceRoot } from "vite";
|
||||
|
||||
const host = process.env.TAURI_DEV_HOST;
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [tailwindcss(), sveltekit()],
|
||||
|
||||
ssr: {
|
||||
noExternal: ["@pistation/shared-types", "@pistation/client-core", "@pistation/ui"]
|
||||
},
|
||||
|
||||
optimizeDeps: {
|
||||
include: ["react", "react-dom", "react-dom/client"]
|
||||
},
|
||||
|
||||
define: {
|
||||
"process.env.IS_PREACT": JSON.stringify("false")
|
||||
},
|
||||
|
||||
clearScreen: false,
|
||||
|
||||
server: {
|
||||
port: 1420,
|
||||
strictPort: true,
|
||||
host: host || false,
|
||||
hmr: host ? { protocol: "ws", host, port: 1421 } : undefined,
|
||||
fs: {
|
||||
allow: [searchForWorkspaceRoot(process.cwd())]
|
||||
},
|
||||
watch: {
|
||||
ignored: ["**/src-tauri/**"]
|
||||
}
|
||||
}
|
||||
});
|
||||