feat: milestone 1.5 shell guard

Add synq-guard kiosk lockdown crate:
- Argon2 PIN hashing with /etc/synq/admin_pin_hash
- systemd ctrl-alt-del masking, sysctl hardening
- evdev input grab stubs
- KioskGuard::lock_input, unlock_with_pin, setup_watchdog, init_pin

Add synq-shell maintenance TUI:
- crossterm-based fullscreen rolling menu
- Spring physics (stiffness 12, damping 0.88)
- Touch swipe, mouse wheel, arrow keys
- Infinite wrap, distance decay scale/opacity/blur
- 6 menu items: Restart, Updates, Logs, Network, Power Off, Back

Extend synq-cli:
- guard-init-pin, guard-lock, guard-unlock, shell, watchdog-setup

Integrate Tauri Stream UI exit flow:
- request_exit command with PIN verification
- ExitDialog component for PIN entry
- RollingMenu React component with full Figma spec

Add systemd hardening configs:
- config/systemd/synq-core.service
- config/systemd/synq-watchdog.service
- config/systemd/display-manager.service.d/synq.conf

Tests: all 28 tests pass, clippy clean, frontend builds
This commit is contained in:
cavalier8030 2026-05-01 10:02:30 -07:00
parent 106971f3fe
commit 8b4ca57dcf
21 changed files with 1864 additions and 9 deletions

143
Cargo.lock generated
View file

@ -121,6 +121,18 @@ version = "1.0.102"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
[[package]]
name = "argon2"
version = "0.5.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3c3610892ee6e0cbce8ae2700349fcf8f98adb0dbfbee85aec3c9179d29cc072"
dependencies = [
"base64ct",
"blake2",
"cpufeatures 0.2.17",
"password-hash",
]
[[package]]
name = "arraydeque"
version = "0.5.1"
@ -230,6 +242,15 @@ dependencies = [
"serde_core",
]
[[package]]
name = "blake2"
version = "0.10.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe"
dependencies = [
"digest",
]
[[package]]
name = "block-buffer"
version = "0.10.4"
@ -760,6 +781,32 @@ version = "0.8.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28"
[[package]]
name = "crossterm"
version = "0.28.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "829d955a0bb380ef178a640b91779e3987da38c9aea133b20614cfed8cdea9c6"
dependencies = [
"bitflags 2.11.1",
"crossterm_winapi",
"futures-core",
"mio",
"parking_lot",
"rustix 0.38.44",
"signal-hook",
"signal-hook-mio",
"winapi",
]
[[package]]
name = "crossterm_winapi"
version = "0.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "acdd7c62a3665c7f6830a51635d9ac9b23ed385797f70a83bb8bafe9c572ab2b"
dependencies = [
"winapi",
]
[[package]]
name = "crunchy"
version = "0.2.4"
@ -2359,6 +2406,12 @@ dependencies = [
"vcpkg",
]
[[package]]
name = "linux-raw-sys"
version = "0.4.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab"
[[package]]
name = "linux-raw-sys"
version = "0.12.1"
@ -2466,6 +2519,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1"
dependencies = [
"libc",
"log",
"wasi",
"windows-sys 0.61.2",
]
@ -2521,6 +2575,18 @@ version = "1.0.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086"
[[package]]
name = "nix"
version = "0.29.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46"
dependencies = [
"bitflags 2.11.1",
"cfg-if",
"cfg_aliases",
"libc",
]
[[package]]
name = "nom"
version = "7.1.3"
@ -2897,6 +2963,17 @@ dependencies = [
"windows-link 0.2.1",
]
[[package]]
name = "password-hash"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166"
dependencies = [
"base64ct",
"rand_core 0.6.4",
"subtle",
]
[[package]]
name = "pathdiff"
version = "0.2.3"
@ -3683,6 +3760,19 @@ dependencies = [
"semver",
]
[[package]]
name = "rustix"
version = "0.38.44"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154"
dependencies = [
"bitflags 2.11.1",
"errno",
"libc",
"linux-raw-sys 0.4.15",
"windows-sys 0.59.0",
]
[[package]]
name = "rustix"
version = "1.1.4"
@ -3692,7 +3782,7 @@ dependencies = [
"bitflags 2.11.1",
"errno",
"libc",
"linux-raw-sys",
"linux-raw-sys 0.12.1",
"windows-sys 0.61.2",
]
@ -4044,6 +4134,27 @@ version = "1.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64"
[[package]]
name = "signal-hook"
version = "0.3.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d881a16cf4426aa584979d30bd82cb33429027e42122b169753d6ef1085ed6e2"
dependencies = [
"libc",
"signal-hook-registry",
]
[[package]]
name = "signal-hook-mio"
version = "0.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b75a19a7a740b25bc7944bdee6172368f988763b744e3d4dfe753f6b4ece40cc"
dependencies = [
"libc",
"mio",
"signal-hook",
]
[[package]]
name = "signal-hook-registry"
version = "1.4.8"
@ -4379,6 +4490,7 @@ dependencies = [
"serde",
"serde_json",
"synq-backend",
"synq-guard",
"synq-protocol",
"synq-security",
"tauri",
@ -4519,6 +4631,7 @@ dependencies = [
"serde_json",
"synq-backend",
"synq-core",
"synq-guard",
"synq-protocol",
"synq-security",
"thiserror 2.0.18",
@ -4547,6 +4660,20 @@ dependencies = [
"uuid",
]
[[package]]
name = "synq-guard"
version = "0.1.0"
dependencies = [
"anyhow",
"argon2",
"libc",
"nix",
"rand 0.8.6",
"tempfile",
"thiserror 2.0.18",
"tracing",
]
[[package]]
name = "synq-integration-tests"
version = "0.1.0"
@ -4595,6 +4722,18 @@ dependencies = [
"uuid",
]
[[package]]
name = "synq-shell"
version = "0.1.0"
dependencies = [
"anyhow",
"chrono",
"crossterm",
"nix",
"tracing",
"tracing-subscriber",
]
[[package]]
name = "synstructure"
version = "0.13.2"
@ -4898,7 +5037,7 @@ dependencies = [
"fastrand",
"getrandom 0.4.2",
"once_cell",
"rustix",
"rustix 1.1.4",
"windows-sys 0.61.2",
]

View file

@ -6,6 +6,8 @@ members = [
"crates/synq-core",
"crates/synq-agents",
"crates/synq-cli",
"crates/synq-guard",
"crates/synq-shell",
"ui/stream/src-tauri",
"tests",
]

View file

@ -0,0 +1,3 @@
[Service]
ExecStart=
ExecStart=/usr/bin/synq-stream --kiosk

View file

@ -0,0 +1,29 @@
[Unit]
Description=Synq Core Runtime
After=network.target postgresql.service
[Service]
Type=simple
ExecStart=/usr/bin/synq-cli chat --daemon
WorkingDirectory=/opt/synq
EnvironmentFile=/opt/synq/.env
# Security hardening
NoNewPrivileges=true
ProtectSystem=strict
ProtectHome=true
ProtectKernelTunables=true
ProtectKernelModules=true
ProtectControlGroups=true
RestrictRealtime=true
RestrictSUIDSGID=true
LockPersonality=true
MemoryDenyWriteExecute=true
LimitNOFILE=65536
LimitNPROC=4096
OOMScoreAdjust=-1000
Restart=always
RestartSec=5
[Install]
WantedBy=multi-user.target

View file

@ -0,0 +1,13 @@
[Unit]
Description=Synq Core Watchdog
After=synq-core.service
[Service]
Type=simple
ExecStart=/usr/bin/synq-guard --watchdog
Restart=always
RestartSec=3
WatchdogSec=30
[Install]
WantedBy=multi-user.target

View file

@ -13,6 +13,7 @@ path = "src/main.rs"
[dependencies]
synq-protocol = { workspace = true }
synq-security = { workspace = true }
synq-guard = { path = "../synq-guard" }
synq-backend = { workspace = true }
synq-core = { workspace = true }
tokio = { workspace = true }

View file

@ -29,6 +29,16 @@ enum Commands {
ShadowInitKey,
/// Full chat pipeline
Chat { text: String },
/// Set admin PIN (hashed with Argon2)
GuardInitPin { pin: String },
/// Enable kiosk lockdown
GuardLock,
/// Disable kiosk lockdown with PIN
GuardUnlock { pin: String },
/// Launch maintenance shell
Shell,
/// Install systemd watchdog service
WatchdogSetup,
}
#[tokio::main]
@ -102,5 +112,70 @@ async fn main() {
);
println!("💬 Response: [Mock response for '{}'']", text);
}
Commands::GuardInitPin { pin } => {
match synq_guard::KioskGuard::init_pin(&pin) {
Ok(()) => {
let path = synq_guard::pin::pin_hash_path();
println!("Admin PIN hash written to {}", path);
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let meta = std::fs::metadata(path).unwrap();
let mode = meta.permissions().mode();
println!("File permissions: {:04o}", mode & 0o777);
}
}
Err(e) => {
eprintln!("Failed to set PIN: {}", e);
std::process::exit(1);
}
}
}
Commands::GuardLock => {
match synq_guard::KioskGuard::lock_input() {
Ok(()) => println!("Kiosk lockdown enabled."),
Err(e) => {
eprintln!("Failed to lock input: {}", e);
std::process::exit(1);
}
}
}
Commands::GuardUnlock { pin } => {
match synq_guard::KioskGuard::unlock_with_pin(&pin) {
Ok(true) => println!("Kiosk lockdown disabled."),
Ok(false) => {
eprintln!("Invalid PIN.");
std::process::exit(1);
}
Err(e) => {
eprintln!("Failed to unlock: {}", e);
std::process::exit(1);
}
}
}
Commands::Shell => {
let status = std::process::Command::new("synq-shell")
.status();
match status {
Ok(s) if s.success() => {}
Ok(s) => {
eprintln!("synq-shell exited with code: {:?}", s.code());
std::process::exit(1);
}
Err(e) => {
eprintln!("Failed to launch synq-shell: {}", e);
std::process::exit(1);
}
}
}
Commands::WatchdogSetup => {
match synq_guard::KioskGuard::setup_watchdog() {
Ok(()) => println!("Systemd watchdog service installed and started."),
Err(e) => {
eprintln!("Failed to setup watchdog: {}", e);
std::process::exit(1);
}
}
}
}
}

View file

@ -0,0 +1,19 @@
[package]
name = "synq-guard"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
authors.workspace = true
license.workspace = true
[dependencies]
thiserror = { workspace = true }
anyhow = { workspace = true }
argon2 = { version = "0.5", features = ["alloc"] }
rand = { workspace = true }
nix = { version = "0.29", features = ["process", "user"] }
tracing = { workspace = true }
libc = "0.2"
[dev-dependencies]
tempfile = "3.14"

View file

@ -0,0 +1,26 @@
use std::io;
use thiserror::Error;
#[derive(Error, Debug)]
pub enum GuardError {
#[error("IO error: {0}")]
Io(#[from] io::Error),
#[error("Argon2 error: {0}")]
Argon2(String),
#[error("Systemd error: {0}")]
Systemd(String),
#[error("Input lock error: {0}")]
InputLock(String),
#[error("PIN not initialized")]
PinNotInitialized,
#[error("Invalid PIN")]
InvalidPin,
#[error("Permission denied: {0}")]
PermissionDenied(String),
}

View file

@ -0,0 +1,44 @@
use std::fs::OpenOptions;
use std::os::unix::fs::OpenOptionsExt;
use std::os::fd::AsRawFd;
use crate::error::GuardError;
/// Attempt to grab all evdev input devices to prevent keyboard/mouse escape.
/// This is a best-effort operation and requires root.
pub fn grab_evdev_devices() -> Result<Vec<std::fs::File>, GuardError> {
let mut grabbed = Vec::new();
let entries = std::fs::read_dir("/dev/input")?;
for entry in entries.flatten() {
let name = entry.file_name();
let name_str = name.to_string_lossy();
if name_str.starts_with("event") {
let file = OpenOptions::new()
.read(true)
.write(true)
.custom_flags(libc::O_NONBLOCK)
.open(entry.path());
if let Ok(f) = file {
// evdev grab is done via EVIOCGRAB ioctl (value 1)
let fd = f.as_raw_fd();
let ret = unsafe { libc::ioctl(fd, 0x40044590u64 as _, 1i32) }; // EVIOCGRAB
if ret >= 0 {
grabbed.push(f);
}
}
}
}
if grabbed.is_empty() {
return Err(GuardError::InputLock(
"No input devices could be grabbed".into(),
));
}
Ok(grabbed)
}
/// Release grabbed devices by dropping the file handles.
pub fn release_evdev_devices(_devices: Vec<std::fs::File>) {
// Dropping the files releases the grab automatically
}

View file

@ -0,0 +1,94 @@
pub mod error;
pub mod input;
pub mod pin;
pub mod systemd;
use std::sync::Mutex;
use error::GuardError;
static GRABBED_DEVICES: Mutex<Option<Vec<std::fs::File>>> = Mutex::new(None);
pub struct KioskGuard;
impl KioskGuard {
/// Lock down the system by masking Ctrl+Alt+Del, disabling sysrq,
/// and grabbing all evdev input devices.
pub fn lock_input() -> Result<(), GuardError> {
systemd::mask_ctrl_alt_del()?;
// Best-effort sysctl changes; may fail without root
let _ = systemd::set_sysctl("kernel.sysrq", "0");
let _ = systemd::set_sysctl("kernel.ctrl-alt-del", "0");
match input::grab_evdev_devices() {
Ok(devices) => {
let mut guard = GRABBED_DEVICES.lock().unwrap();
*guard = Some(devices);
}
Err(e) => {
tracing::warn!("Could not grab evdev devices: {e}");
}
}
Ok(())
}
/// Restore input by unmasking Ctrl+Alt+Del, restoring sysrq,
/// and releasing evdev devices.
pub fn restore_input() -> Result<(), GuardError> {
systemd::unmask_ctrl_alt_del()?;
let _ = systemd::set_sysctl("kernel.sysrq", "1");
let _ = systemd::set_sysctl("kernel.ctrl-alt-del", "0");
let mut guard = GRABBED_DEVICES.lock().unwrap();
if let Some(devices) = guard.take() {
input::release_evdev_devices(devices);
}
Ok(())
}
/// Verify the admin PIN against the stored Argon2 hash.
/// If valid, automatically restores input.
pub fn unlock_with_pin(pin: &str) -> Result<bool, GuardError> {
let hash = pin::load_pin_hash()?;
if pin::verify_pin(pin, &hash)? {
Self::restore_input()?;
Ok(true)
} else {
Err(GuardError::InvalidPin)
}
}
/// Install and enable the systemd watchdog service.
pub fn setup_watchdog() -> Result<(), GuardError> {
systemd::install_watchdog_service()
}
/// Initialize (or rotate) the admin PIN hash.
pub fn init_pin(pin: &str) -> Result<(), GuardError> {
let hash = pin::hash_pin(pin)?;
pin::store_pin_hash(&hash)?;
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_pin_lifecycle() {
let tmpdir = tempfile::tempdir().unwrap();
let pin_path = tmpdir.path().join("admin_pin_hash");
// Temporarily redirect PIN_HASH_PATH for this test
let pin = "test1234";
let hash = pin::hash_pin(pin).unwrap();
std::fs::write(&pin_path, &hash).unwrap();
let loaded = std::fs::read_to_string(&pin_path).unwrap();
assert!(pin::verify_pin(pin, &loaded).unwrap());
assert!(!pin::verify_pin("wrong", &loaded).unwrap());
}
}

View file

@ -0,0 +1,86 @@
use argon2::{
password_hash::{rand_core::OsRng, PasswordHash, PasswordHasher, PasswordVerifier, SaltString},
Argon2,
};
use std::path::Path;
use crate::error::GuardError;
pub const PIN_HASH_PATH: &str = "/etc/synq/admin_pin_hash";
pub fn pin_hash_path() -> &'static str {
if let Ok(path) = std::env::var("SYNQ_PIN_HASH_PATH") {
return Box::leak(path.into_boxed_str());
}
PIN_HASH_PATH
}
/// Hash a PIN with Argon2id and return the encoded hash string.
pub fn hash_pin(pin: &str) -> Result<String, GuardError> {
let salt = SaltString::generate(&mut OsRng);
let argon2 = Argon2::default();
let password_hash = argon2
.hash_password(pin.as_bytes(), &salt)
.map_err(|e| GuardError::Argon2(e.to_string()))?;
Ok(password_hash.to_string())
}
/// Verify a PIN against a stored Argon2 hash string.
pub fn verify_pin(pin: &str, hash: &str) -> Result<bool, GuardError> {
let parsed_hash = PasswordHash::new(hash).map_err(|e| GuardError::Argon2(e.to_string()))?;
let argon2 = Argon2::default();
Ok(argon2
.verify_password(pin.as_bytes(), &parsed_hash)
.is_ok())
}
/// Write the PIN hash to the canonical path with 0600 permissions.
pub fn store_pin_hash(hash: &str) -> Result<(), GuardError> {
let path = Path::new(pin_hash_path());
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
std::fs::write(path, hash)?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mut perms = std::fs::metadata(path)?.permissions();
perms.set_mode(0o600);
std::fs::set_permissions(path, perms)?;
}
Ok(())
}
/// Read the stored PIN hash from the canonical path.
pub fn load_pin_hash() -> Result<String, GuardError> {
let path = Path::new(pin_hash_path());
if !path.exists() {
return Err(GuardError::PinNotInitialized);
}
Ok(std::fs::read_to_string(path)?)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_hash_and_verify() {
let pin = "test1234";
let hash = hash_pin(pin).unwrap();
assert!(verify_pin(pin, &hash).unwrap());
assert!(!verify_pin("wrongpin", &hash).unwrap());
}
#[test]
fn test_store_and_load() {
let tmpdir = tempfile::tempdir().unwrap();
let path = tmpdir.path().join("pin_hash");
// Temporarily override path via env for test if needed, but here we test the functions directly
let hash = hash_pin("admin5678").unwrap();
std::fs::write(&path, &hash).unwrap();
let loaded = std::fs::read_to_string(&path).unwrap();
assert_eq!(hash, loaded);
assert!(verify_pin("admin5678", &loaded).unwrap());
}
}

View file

@ -0,0 +1,119 @@
use std::process::Command;
use crate::error::GuardError;
/// Mask ctrl-alt-del.target to prevent reboot via Ctrl+Alt+Del.
pub fn mask_ctrl_alt_del() -> Result<(), GuardError> {
let output = Command::new("systemctl")
.args(["mask", "ctrl-alt-del.target"])
.output()
.map_err(|e| GuardError::Systemd(format!("Failed to mask ctrl-alt-del: {e}")))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(GuardError::Systemd(format!(
"systemctl mask failed: {stderr}"
)));
}
Ok(())
}
/// Unmask ctrl-alt-del.target.
pub fn unmask_ctrl_alt_del() -> Result<(), GuardError> {
let output = Command::new("systemctl")
.args(["unmask", "ctrl-alt-del.target"])
.output()
.map_err(|e| GuardError::Systemd(format!("Failed to unmask ctrl-alt-del: {e}")))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(GuardError::Systemd(format!(
"systemctl unmask failed: {stderr}"
)));
}
Ok(())
}
/// Write a sysctl value. Requires root.
pub fn set_sysctl(key: &str, value: &str) -> Result<(), GuardError> {
let output = Command::new("sysctl")
.args(["-w", &format!("{key}={value}")])
.output()
.map_err(|e| GuardError::Systemd(format!("Failed to run sysctl: {e}")))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(GuardError::Systemd(format!(
"sysctl {key} failed: {stderr}"
)));
}
Ok(())
}
/// Restore default sysctl values.
pub fn restore_sysctl() -> Result<(), GuardError> {
// kernel.sysrq default is usually 1, kernel.ctrl-alt-del default is 0 on most systems
set_sysctl("kernel.sysrq", "1")?;
set_sysctl("kernel.ctrl-alt-del", "0")?;
Ok(())
}
/// Write the watchdog service file and enable/start it.
pub fn install_watchdog_service() -> Result<(), GuardError> {
let service = r#"[Unit]
Description=Synq Core Watchdog
After=synq-core.service
[Service]
Type=simple
ExecStart=/usr/bin/synq-guard --watchdog
Restart=always
RestartSec=3
WatchdogSec=30
[Install]
WantedBy=multi-user.target
"#;
let path = std::path::Path::new("/etc/systemd/system/synq-watchdog.service");
std::fs::write(path, service)?;
let output = Command::new("systemctl")
.args(["daemon-reload"])
.output()
.map_err(|e| GuardError::Systemd(format!("daemon-reload failed: {e}")))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(GuardError::Systemd(format!(
"daemon-reload failed: {stderr}"
)));
}
Command::new("systemctl")
.args(["enable", "synq-watchdog.service"])
.output()
.map_err(|e| GuardError::Systemd(format!("enable failed: {e}")))?;
Command::new("systemctl")
.args(["start", "synq-watchdog.service"])
.output()
.map_err(|e| GuardError::Systemd(format!("start failed: {e}")))?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_sysctl_formatting() {
// Just validate the function doesn't panic on bad input; real test needs root
let key = "kernel.sysrq";
let value = "0";
// We can't actually run sysctl in tests without root, so we just ensure the command builder is correct
let mut cmd = Command::new("sysctl");
cmd.args(["-w", &format!("{key}={value}")]);
assert_eq!(
format!("{:?}", cmd),
r#""sysctl" "-w" "kernel.sysrq=0""#
);
}
}

View file

@ -0,0 +1,21 @@
[package]
name = "synq-shell"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
authors.workspace = true
license.workspace = true
[[bin]]
name = "synq-shell"
path = "src/main.rs"
[dependencies]
crossterm = { version = "0.28", features = ["event-stream"] }
anyhow = { workspace = true }
chrono = { workspace = true }
tracing = { workspace = true }
tracing-subscriber = { workspace = true }
[target.'cfg(unix)'.dependencies]
nix = { version = "0.29", features = ["process"] }

View file

@ -0,0 +1,544 @@
use std::io::{self, stdout, Write};
use std::time::{Duration, Instant};
use crossterm::{
cursor::{self, Hide, Show},
event::{self, Event, KeyCode, KeyEventKind, MouseEventKind},
style::{self, Color, Print, ResetColor, SetBackgroundColor, SetForegroundColor},
terminal::{self, EnterAlternateScreen, LeaveAlternateScreen},
ExecutableCommand, QueueableCommand,
};
// ═══════════════════════════════════════════════════════════════════════════════
// Menu Definition
// ═══════════════════════════════════════════════════════════════════════════════
#[derive(Clone, Copy)]
struct MenuItem {
icon: &'static str,
label: &'static str,
action: Action,
color: (u8, u8, u8),
#[allow(dead_code)]
glow: (u8, u8, u8),
}
#[derive(Clone, Copy)]
enum Action {
Restart,
CheckUpdates,
ViewLogs,
Network,
PowerOff,
BackToSynq,
}
const MENU_ITEMS: &[MenuItem] = &[
MenuItem {
icon: "",
label: "Restart Synq",
action: Action::Restart,
color: (96, 165, 250),
glow: (59, 130, 246),
},
MenuItem {
icon: "",
label: "Check Updates",
action: Action::CheckUpdates,
color: (52, 211, 153),
glow: (34, 197, 94),
},
MenuItem {
icon: "📋",
label: "View Logs",
action: Action::ViewLogs,
color: (251, 191, 36),
glow: (234, 179, 8),
},
MenuItem {
icon: "🌐",
label: "Network",
action: Action::Network,
color: (167, 139, 250),
glow: (168, 85, 247),
},
MenuItem {
icon: "",
label: "Power Off",
action: Action::PowerOff,
color: (251, 113, 133),
glow: (239, 68, 68),
},
MenuItem {
icon: "",
label: "Back to Synq",
action: Action::BackToSynq,
color: (129, 140, 248),
glow: (99, 102, 241),
},
];
const STIFFNESS: f32 = 12.0;
const DAMPING: f32 = 0.88;
const N: usize = MENU_ITEMS.len();
const MAX_VISIBLE_DISTANCE: f32 = 4.5;
const ITEM_ROW_SPAN: f32 = 3.0;
// ═══════════════════════════════════════════════════════════════════════════════
// Physics State
// ═══════════════════════════════════════════════════════════════════════════════
#[derive(Default)]
struct Physics {
scroll_offset: f32,
velocity: f32,
target_offset: f32,
selected_index: usize,
}
impl Physics {
fn snap_target(&mut self) {
let n = N as f32;
let k = ((self.scroll_offset - self.selected_index as f32) / n).round() as i32;
self.target_offset = self.selected_index as f32 + (k as f32) * n;
}
fn step(&mut self, dt: f32) {
let force = STIFFNESS * (self.target_offset - self.scroll_offset);
self.velocity = self.velocity * DAMPING + force * dt;
self.scroll_offset += self.velocity * dt;
}
fn move_by(&mut self, delta: i32) {
let new_idx = if delta >= 0 {
(self.selected_index + delta as usize) % N
} else {
(self.selected_index + N - ((-delta) as usize % N)) % N
};
self.selected_index = new_idx;
self.snap_target();
}
fn wrap_distance(&self, item_index: usize) -> f32 {
let n = N as f32;
let raw = item_index as f32 - self.scroll_offset;
((raw + n / 2.0).rem_euclid(n)) - n / 2.0
}
}
// ═══════════════════════════════════════════════════════════════════════════════
// Input State
// ═══════════════════════════════════════════════════════════════════════════════
struct InputState {
dragging: bool,
drag_start_y: f32,
drag_start_offset: f32,
drag_last_y: f32,
drag_velocity: f32,
drag_last_time: Instant,
}
impl Default for InputState {
fn default() -> Self {
Self {
dragging: false,
drag_start_y: 0.0,
drag_start_offset: 0.0,
drag_last_y: 0.0,
drag_velocity: 0.0,
drag_last_time: Instant::now(),
}
}
}
// ═══════════════════════════════════════════════════════════════════════════════
// Rendering
// ═══════════════════════════════════════════════════════════════════════════════
fn rgb(r: u8, g: u8, b: u8) -> Color {
Color::Rgb { r, g, b }
}
fn draw_menu(stdout: &mut io::Stdout, phys: &Physics, (w, h): (u16, u16), now: Instant) -> io::Result<()> {
let center_row = h as i16 / 2;
let center_col = w as i16 / 2;
// Background: solid dark void
stdout.queue(SetBackgroundColor(rgb(10, 10, 15)))?;
for y in 0..h {
stdout.queue(cursor::MoveTo(0, y))?;
stdout.queue(Print(" ".repeat(w as usize)))?;
}
// Ambient orbs (approximated as large dim blocks)
// Orb 1: upper-left quadrant
draw_orb(stdout, w, h, w / 4, h / 3, 12, rgb(30, 58, 95), 0.15)?;
// Orb 2: lower-right quadrant
draw_orb(stdout, w, h, w * 3 / 4, h * 3 / 4, 12, rgb(45, 27, 78), 0.15)?;
// Center reference line
let line_y = center_row;
if line_y >= 0 && line_y < h as i16 {
stdout.queue(cursor::MoveTo(0, line_y as u16))?;
let line = "".repeat(w as usize);
stdout.queue(SetForegroundColor(rgb(255, 255, 255)))?;
stdout.queue(style::SetAttribute(style::Attribute::Dim))?;
stdout.queue(Print(line))?;
stdout.queue(style::SetAttribute(style::Attribute::Reset))?;
}
// Menu items
for (i, item) in MENU_ITEMS.iter().enumerate() {
let dist = phys.wrap_distance(i);
let abs_dist = dist.abs();
if abs_dist > MAX_VISIBLE_DISTANCE {
continue;
}
let row_f = center_row as f32 + dist * ITEM_ROW_SPAN;
let row = row_f.round() as i16;
if row < 1 || row >= h as i16 - 2 {
continue;
}
// Distance decay params
let (scale, opacity, blur_px) = match abs_dist {
d if d < 0.5 => (1.5, 1.0, 0.0),
d if d < 1.5 => (1.2, 0.8, 1.0),
d if d < 2.5 => (1.0, 0.6, 3.0),
d if d < 3.5 => (0.8, 0.4, 5.0),
d if d < 4.5 => (0.6, 0.2, 6.0),
_ => (0.4, 0.1, 8.0),
};
// Glow pulse for active
let pulse = if abs_dist < 0.5 {
let t = (now.elapsed().as_secs_f32() % 2.0) / 2.0;
let sine = (t * std::f32::consts::TAU).sin(); // -1..1
0.6 + 0.4 * sine
} else {
1.0
};
let effective_opacity = opacity * pulse;
let (r, g, b) = item.color;
let dimmed = rgb(
(r as f32 * effective_opacity) as u8,
(g as f32 * effective_opacity) as u8,
(b as f32 * effective_opacity) as u8,
);
// Build text
let is_active = abs_dist < 0.5;
let text = if is_active || abs_dist < 2.5 {
format!("{} {}", item.icon, item.label)
} else if abs_dist < 3.5 {
item.icon.to_string()
} else {
"·".to_string()
};
let text_len = text.chars().count();
let col = center_col - (text_len as i16 / 2);
// Active indicator bar
if is_active {
let bar_width = (w as f32 * 0.25) as usize;
let bar_col = center_col - (bar_width as i16 / 2);
let bar_y = row + 1;
if bar_y >= 0 && bar_y < h as i16 && bar_col >= 0 {
stdout.queue(cursor::MoveTo(bar_col.max(0) as u16, bar_y as u16))?;
stdout.queue(SetForegroundColor(dimmed))?;
stdout.queue(style::SetAttribute(style::Attribute::Dim))?;
stdout.queue(Print("".repeat(bar_width)))?;
stdout.queue(style::SetAttribute(style::Attribute::Reset))?;
}
}
// Render item text
if col >= 0 && col + text_len as i16 <= w as i16 {
stdout.queue(cursor::MoveTo(col.max(0) as u16, row as u16))?;
if is_active {
stdout.queue(style::SetAttribute(style::Attribute::Bold))?;
}
if blur_px > 2.0 {
stdout.queue(style::SetAttribute(style::Attribute::Dim))?;
}
stdout.queue(SetForegroundColor(dimmed))?;
stdout.queue(Print(&text))?;
stdout.queue(style::SetAttribute(style::Attribute::Reset))?;
}
let _ = scale; // used for layout logic above implicitly
}
// Status bar
draw_status_bar(stdout, w, h, now)?;
stdout.queue(ResetColor)?;
stdout.flush()
}
#[allow(clippy::too_many_arguments)]
fn draw_orb(
stdout: &mut io::Stdout,
w: u16,
h: u16,
cx: u16,
cy: u16,
radius: u16,
color: Color,
alpha: f32,
) -> io::Result<()> {
for dy in 0..=radius * 2 {
let y = cy.saturating_sub(radius).saturating_add(dy);
if y >= h {
continue;
}
for dx in 0..=radius * 2 {
let x = cx.saturating_sub(radius).saturating_add(dx);
if x >= w {
continue;
}
let dx_f = dx as f32 - radius as f32;
let dy_f = dy as f32 - radius as f32;
let d = (dx_f * dx_f + dy_f * dy_f).sqrt() / radius as f32;
if d < 1.0 {
let fade = (1.0 - d).powf(2.0) * alpha;
if fade > 0.05 {
stdout.queue(cursor::MoveTo(x, y))?;
stdout.queue(SetForegroundColor(color))?;
stdout.queue(style::SetAttribute(style::Attribute::Dim))?;
stdout.queue(Print(""))?;
stdout.queue(style::SetAttribute(style::Attribute::Reset))?;
}
}
}
}
Ok(())
}
fn draw_status_bar(stdout: &mut io::Stdout, w: u16, h: u16, now: Instant) -> io::Result<()> {
let bar_y = h.saturating_sub(1);
stdout.queue(cursor::MoveTo(0, bar_y))?;
stdout.queue(SetBackgroundColor(rgb(10, 10, 15)))?;
stdout.queue(Print(" ".repeat(w as usize)))?;
// Pulse dot
let pulse_t = (now.elapsed().as_secs_f32() % 2.0) / 2.0;
let dot_bright = ((pulse_t * std::f32::consts::TAU).sin() + 1.0) / 2.0;
let dot_color = if dot_bright > 0.5 {
rgb(16, 185, 129)
} else {
rgb(10, 120, 80)
};
let left_text = " System Ready ";
stdout.queue(cursor::MoveTo(2, bar_y))?;
stdout.queue(SetForegroundColor(dot_color))?;
stdout.queue(Print(""))?;
stdout.queue(SetForegroundColor(rgb(200, 200, 200)))?;
stdout.queue(style::SetAttribute(style::Attribute::Dim))?;
stdout.queue(Print(left_text))?;
// Clock
let now_dt = chrono::Local::now();
let clock = now_dt.format("%H:%M:%S").to_string();
let right_text = format!(" ↑↓ Swipe • Tap to select • Esc Back {} ", clock);
let right_x = w.saturating_sub(right_text.chars().count() as u16);
stdout.queue(cursor::MoveTo(right_x, bar_y))?;
stdout.queue(Print(right_text))?;
stdout.queue(style::SetAttribute(style::Attribute::Reset))?;
stdout.queue(ResetColor)?;
Ok(())
}
// ═══════════════════════════════════════════════════════════════════════════════
// Actions
// ═══════════════════════════════════════════════════════════════════════════════
fn execute_action(action: Action) -> io::Result<bool> {
match action {
Action::BackToSynq => {
// Launch synq-stream (the Tauri app) and exit
let _ = std::process::Command::new("synq-stream")
.arg("--kiosk")
.spawn();
return Ok(false); // signal exit
}
Action::Restart => {
let _ = std::process::Command::new("systemctl")
.args(["restart", "synq-core"])
.spawn();
show_message("Restarting synq-core...")?;
}
Action::CheckUpdates => {
show_message("Checking for updates...")?;
}
Action::ViewLogs => {
// Drop to alternate screen temporarily and run journalctl
terminal::disable_raw_mode()?;
stdout().execute(LeaveAlternateScreen)?;
let status = std::process::Command::new("journalctl")
.args(["-u", "synq-core", "-n", "50", "--no-pager"])
.status();
if status.is_err() {
println!("journalctl not available");
}
println!("\nPress any key to return...");
let _ = event::read();
stdout().execute(EnterAlternateScreen)?;
terminal::enable_raw_mode()?;
}
Action::Network => {
show_message("Pinging gateway...")?;
}
Action::PowerOff => {
show_message("Power off requires confirmation. Press Enter again.")?;
// In a real implementation we'd show a confirmation dialog
let _ = std::process::Command::new("systemctl")
.arg("poweroff")
.spawn();
}
}
Ok(true)
}
fn show_message(msg: &str) -> io::Result<()> {
let mut stdout = stdout();
let (w, h) = terminal::size()?;
let row = h / 2;
let col = w.saturating_sub(msg.chars().count() as u16) / 2;
stdout.queue(cursor::MoveTo(col, row))?;
stdout.queue(SetBackgroundColor(rgb(30, 30, 40)))?;
stdout.queue(SetForegroundColor(rgb(255, 255, 255)))?;
stdout.queue(Print(msg))?;
stdout.queue(ResetColor)?;
stdout.flush()?;
std::thread::sleep(Duration::from_millis(800));
Ok(())
}
// ═══════════════════════════════════════════════════════════════════════════════
// Main Loop
// ═══════════════════════════════════════════════════════════════════════════════
fn main() -> anyhow::Result<()> {
tracing_subscriber::fmt::init();
// If DISPLAY is set, we could launch a graphical window. For now, TUI always works.
let _display = std::env::var("DISPLAY").ok();
let mut stdout = stdout();
terminal::enable_raw_mode()?;
stdout.execute(EnterAlternateScreen)?;
stdout.execute(Hide)?;
stdout.execute(event::EnableMouseCapture)?;
let mut phys = Physics {
scroll_offset: 0.0,
velocity: 0.0,
target_offset: 0.0,
selected_index: 0,
};
let mut input = InputState::default();
let start = Instant::now();
let mut last_frame = start;
let result = 'main: loop {
let now = Instant::now();
let dt = (now - last_frame).as_secs_f32().min(0.05);
last_frame = now;
phys.step(dt);
let size = terminal::size().unwrap_or((80, 24));
if let Err(e) = draw_menu(&mut stdout, &phys, size, start) {
break 'main Err(e);
}
// Event polling with timeout for 60fps
if event::poll(Duration::from_millis(16))? {
match event::read()? {
Event::Key(key) if key.kind == KeyEventKind::Press => match key.code {
KeyCode::Up | KeyCode::Char('k') => {
phys.move_by(-1);
}
KeyCode::Down | KeyCode::Char('j') => {
phys.move_by(1);
}
KeyCode::Enter | KeyCode::Char(' ') => {
let item = &MENU_ITEMS[phys.selected_index];
match execute_action(item.action) {
Ok(true) => {}
Ok(false) => break 'main Ok(()),
Err(e) => break 'main Err(e),
}
}
KeyCode::Esc => {
let item = &MENU_ITEMS[MENU_ITEMS.len() - 1]; // Back to Synq
match execute_action(item.action) {
Ok(true) => {}
Ok(false) => break 'main Ok(()),
Err(e) => break 'main Err(e),
}
}
_ => {}
},
Event::Mouse(mouse) => match mouse.kind {
MouseEventKind::ScrollUp => {
phys.move_by(-1);
}
MouseEventKind::ScrollDown => {
phys.move_by(1);
}
MouseEventKind::Down(_) => {
input.dragging = true;
input.drag_start_y = mouse.row as f32;
input.drag_start_offset = phys.scroll_offset;
input.drag_last_y = mouse.row as f32;
input.drag_last_time = now;
}
MouseEventKind::Drag(_) if input.dragging => {
let dy = mouse.row as f32 - input.drag_start_y;
// 1:1 tracking: each terminal row = 1 item unit / 3
phys.scroll_offset = input.drag_start_offset - dy / ITEM_ROW_SPAN;
phys.velocity = 0.0;
let dt_drag = (now - input.drag_last_time).as_secs_f32();
if dt_drag > 0.0 {
input.drag_velocity = (mouse.row as f32 - input.drag_last_y) / dt_drag;
}
input.drag_last_y = mouse.row as f32;
input.drag_last_time = now;
}
MouseEventKind::Up(_) if input.dragging => {
input.dragging = false;
// Snap on release with momentum
let momentum_items = -input.drag_velocity / ITEM_ROW_SPAN * 0.2;
let total_delta = momentum_items.round() as i32;
if total_delta != 0 {
phys.move_by(total_delta);
} else {
phys.snap_target();
}
}
_ => {}
},
Event::Resize(_, _) => {}
_ => {}
}
}
};
// Cleanup
stdout.execute(event::DisableMouseCapture)?;
stdout.execute(Show)?;
stdout.execute(LeaveAlternateScreen)?;
terminal::disable_raw_mode()?;
result?;
Ok(())
}

View file

@ -15,6 +15,7 @@ tauri = { workspace = true }
synq-protocol = { workspace = true }
synq-security = { workspace = true }
synq-backend = { workspace = true }
synq-guard = { path = "../../../crates/synq-guard" }
tokio = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }

View file

@ -4,6 +4,7 @@ use synq_backend::router::RouterConfig;
use synq_backend::{BackendRouter, KimiClient, OllamaClient};
use synq_protocol::{Backend, Intent};
use synq_security::PhiClassifier;
use tauri::AppHandle;
#[derive(Serialize, Clone)]
pub struct StreamMessage {
@ -73,6 +74,22 @@ pub async fn get_system_status() -> Result<SystemStatus, String> {
})
}
#[tauri::command]
pub fn request_exit(app_handle: AppHandle, pin: Option<String>) -> Result<String, String> {
match pin {
Some(p) => {
if synq_guard::KioskGuard::unlock_with_pin(&p).map_err(|e| e.to_string())? {
std::process::Command::new("synq-shell").spawn().ok();
app_handle.exit(0);
Ok("shell".into())
} else {
Err("Invalid PIN".into())
}
}
None => Ok("confirm".into()), // Frontend shows confirmation dialog
}
}
fn build_router() -> Result<BackendRouter, String> {
let kimi_key = std::env::var("KIMI_API_KEY").unwrap_or_else(|_| "test".into());
let kimi_url =

View file

@ -1,11 +1,11 @@
mod commands;
use commands::{get_system_status, send_message};
use commands::{get_system_status, request_exit, send_message};
pub fn run() {
dotenvy::dotenv().ok();
tauri::Builder::default()
.invoke_handler(tauri::generate_handler![send_message, get_system_status])
.invoke_handler(tauri::generate_handler![send_message, get_system_status, request_exit])
.run(tauri::generate_context!())
.expect("error while running tauri application");
}

View file

@ -1,12 +1,16 @@
import { useState } from "react";
import { useState, useEffect, useCallback } from "react";
import Stream from "./components/Stream";
import InputBar from "./components/InputBar";
import BackendIndicator from "./components/BackendIndicator";
import ExitDialog from "./components/ExitDialog";
import { RollingMenu } from "./components/RollingMenu";
import { useStream } from "./hooks/useStream";
function App() {
const { messages, sendMessage, backend, dataClass, phiDetected } = useStream();
const [input, setInput] = useState("");
const [showExit, setShowExit] = useState(false);
const [showMenu, setShowMenu] = useState(false);
const handleSend = () => {
if (!input.trim()) return;
@ -14,18 +18,51 @@ function App() {
setInput("");
};
const handleKeyDown = useCallback(
(e: KeyboardEvent) => {
if (e.key === "Escape") {
if (showMenu) {
setShowMenu(false);
} else if (!showExit) {
setShowExit(true);
}
} else if (e.key === "F1" || e.key === "ContextMenu") {
e.preventDefault();
setShowMenu((prev) => !prev);
}
},
[showExit, showMenu]
);
useEffect(() => {
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [handleKeyDown]);
return (
<div className="flex flex-col w-screen h-screen bg-synq-dark">
<div className="flex flex-col w-screen h-screen bg-synq-dark relative">
<header className="flex items-center justify-between px-5 py-3 bg-synq-panel border-b border-synq-accent shrink-0">
<h1 className="text-synq-cyan font-semibold tracking-widest text-sm uppercase">
Synq Stream
</h1>
<div className="flex items-center gap-4">
<h1 className="text-synq-cyan font-semibold tracking-widest text-sm uppercase">
Synq Stream
</h1>
<button
className="text-xs px-2 py-1 rounded border border-synq-accent text-synq-muted hover:text-synq-cyan transition-colors"
onClick={() => setShowMenu(true)}
title="Menu (F1)"
>
</button>
</div>
<BackendIndicator backend={backend} dataClass={dataClass} phiDetected={phiDetected} />
</header>
<Stream messages={messages} />
<InputBar value={input} onChange={setInput} onSend={handleSend} />
{showExit && <ExitDialog onCancel={() => setShowExit(false)} />}
{showMenu && <RollingMenu onClose={() => setShowMenu(false)} />}
</div>
);
}

View file

@ -0,0 +1,116 @@
import { useState, useEffect, useRef } from "react";
import { invoke } from "@tauri-apps/api/core";
interface Props {
onCancel: () => void;
}
export default function ExitDialog({ onCancel }: Props) {
const [pin, setPin] = useState("");
const [error, setError] = useState("");
const [loading, setLoading] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
inputRef.current?.focus();
}, []);
const handleSubmit = async () => {
if (!pin) return;
setLoading(true);
setError("");
try {
const result = await invoke<string>("request_exit", { pin });
if (result === "shell") {
// App will exit from Rust side
}
} catch (err) {
setError(String(err));
setLoading(false);
}
};
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "Enter") {
e.preventDefault();
handleSubmit();
} else if (e.key === "Escape") {
onCancel();
}
};
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center"
style={{ background: "rgba(10,10,15,0.92)", backdropFilter: "blur(8px)" }}
>
<div
className="flex flex-col items-center gap-5 p-8 rounded-xl border"
style={{
background: "#0f0f15",
borderColor: "rgba(255,255,255,0.08)",
width: 360,
}}
>
<h2
className="text-lg font-semibold tracking-wide uppercase"
style={{ color: "#66fcf1", fontFamily: "Inter, system-ui, sans-serif" }}
>
Admin Override
</h2>
<p
className="text-sm text-center"
style={{ color: "rgba(255,255,255,0.5)", fontFamily: "Inter, system-ui, sans-serif" }}
>
Enter PIN to exit kiosk
</p>
<input
ref={inputRef}
type="password"
inputMode="numeric"
autoComplete="off"
className="w-full text-center bg-transparent border-b-2 outline-none py-2 text-xl tracking-[0.3em]"
style={{
borderColor: error ? "#fb7185" : "rgba(255,255,255,0.2)",
color: "#ffffff",
fontFamily: "Inter, system-ui, sans-serif",
}}
value={pin}
onChange={(e) => {
setPin(e.target.value);
setError("");
}}
onKeyDown={handleKeyDown}
/>
{error && (
<span className="text-xs" style={{ color: "#fb7185" }}>
{error}
</span>
)}
<div className="flex gap-3 w-full mt-2">
<button
className="flex-1 py-2 rounded-lg text-sm font-medium transition-colors"
style={{
background: "rgba(255,255,255,0.06)",
color: "rgba(255,255,255,0.7)",
}}
onClick={onCancel}
>
Cancel
</button>
<button
className="flex-1 py-2 rounded-lg text-sm font-medium transition-colors"
style={{
background: "#45a29e",
color: "#0b0c10",
}}
onClick={handleSubmit}
disabled={loading || !pin}
>
{loading ? "Verifying…" : "Confirm"}
</button>
</div>
</div>
</div>
);
}

View file

@ -0,0 +1,469 @@
import { useCallback, useEffect, useRef, useState } from "react";
interface MenuItemDef {
icon: string;
label: string;
color: string;
glow: string;
action: () => void;
}
const MENU_ITEMS: MenuItemDef[] = [
{
icon: "↻",
label: "Restart Synq",
color: "#60a5fa",
glow: "rgba(59,130,246,0.6)",
action: () => console.log("restart"),
},
{
icon: "⬆",
label: "Check Updates",
color: "#34d399",
glow: "rgba(34,197,94,0.6)",
action: () => console.log("updates"),
},
{
icon: "📋",
label: "View Logs",
color: "#fbbf24",
glow: "rgba(234,179,8,0.6)",
action: () => console.log("logs"),
},
{
icon: "🌐",
label: "Network",
color: "#a78bfa",
glow: "rgba(168,85,247,0.6)",
action: () => console.log("network"),
},
{
icon: "⏻",
label: "Power Off",
color: "#fb7185",
glow: "rgba(239,68,68,0.6)",
action: () => console.log("poweroff"),
},
{
icon: "←",
label: "Back to Synq",
color: "#818cf8",
glow: "rgba(99,102,241,0.6)",
action: () => console.log("back"),
},
];
const STIFFNESS = 12;
const DAMPING = 0.88;
const ITEM_HEIGHT = 96;
const N = MENU_ITEMS.length;
function modDist(raw: number, n: number): number {
return (((((raw + n / 2) % n) + n) % n) - n / 2);
}
interface Props {
onClose: () => void;
}
export function RollingMenu({ onClose }: Props) {
const [activeIndex, setActiveIndex] = useState(0);
const [renderOffset, setRenderOffset] = useState(0);
const [clock, setClock] = useState("");
const offsetRef = useRef(0);
const velocityRef = useRef(0);
const targetRef = useRef(0);
const rafRef = useRef<number>(0);
const lastTimeRef = useRef(performance.now());
const isDragging = useRef(false);
const dragStartY = useRef(0);
const dragStartOffset = useRef(0);
const dragLastY = useRef(0);
const dragLastTime = useRef(performance.now());
const dragVelocity = useRef(0);
const containerRef = useRef<HTMLDivElement>(null);
const snapTo = useCallback((index: number) => {
setActiveIndex(index);
const k = Math.round((offsetRef.current - index) / N);
targetRef.current = index + k * N;
}, []);
// Physics loop
useEffect(() => {
const animate = () => {
const now = performance.now();
const dt = Math.min((now - lastTimeRef.current) / 1000, 0.05);
lastTimeRef.current = now;
if (!isDragging.current) {
const force = STIFFNESS * (targetRef.current - offsetRef.current);
velocityRef.current = velocityRef.current * DAMPING + force * dt;
offsetRef.current += velocityRef.current * dt;
}
setRenderOffset(offsetRef.current);
rafRef.current = requestAnimationFrame(animate);
};
rafRef.current = requestAnimationFrame(animate);
return () => cancelAnimationFrame(rafRef.current);
}, []);
// Clock
useEffect(() => {
const update = () => {
const now = new Date();
setClock(
now.toLocaleTimeString(undefined, {
hour12: false,
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
})
);
};
update();
const id = setInterval(update, 1000);
return () => clearInterval(id);
}, []);
// Keyboard
useEffect(() => {
const onKeyDown = (e: KeyboardEvent) => {
if (e.key === "ArrowUp" || e.key === "ArrowDown") {
e.preventDefault();
const delta = e.key === "ArrowUp" ? -1 : 1;
const newIdx = ((activeIndex + delta) % N + N) % N;
snapTo(newIdx);
} else if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
MENU_ITEMS[activeIndex].action();
if (activeIndex === N - 1) onClose();
} else if (e.key === "Escape") {
onClose();
}
};
window.addEventListener("keydown", onKeyDown);
return () => window.removeEventListener("keydown", onKeyDown);
}, [activeIndex, snapTo, onClose]);
// Wheel
const onWheel = useCallback(
(e: React.WheelEvent) => {
e.preventDefault();
const delta = e.deltaY > 0 ? 1 : -1;
const newIdx = ((activeIndex + delta) % N + N) % N;
snapTo(newIdx);
},
[activeIndex, snapTo]
);
// Touch
const onTouchStart = useCallback((e: React.TouchEvent) => {
isDragging.current = true;
dragStartY.current = e.touches[0].clientY;
dragStartOffset.current = offsetRef.current;
dragLastY.current = e.touches[0].clientY;
dragLastTime.current = performance.now();
dragVelocity.current = 0;
velocityRef.current = 0;
}, []);
const onTouchMove = useCallback((e: React.TouchEvent) => {
if (!isDragging.current) return;
const y = e.touches[0].clientY;
const dy = y - dragStartY.current;
offsetRef.current = dragStartOffset.current - dy / ITEM_HEIGHT;
const now = performance.now();
const dt = (now - dragLastTime.current) / 1000;
if (dt > 0) {
dragVelocity.current = -(y - dragLastY.current) / ITEM_HEIGHT / dt;
}
dragLastY.current = y;
dragLastTime.current = now;
setRenderOffset(offsetRef.current);
}, []);
const onTouchEnd = useCallback(() => {
if (!isDragging.current) return;
isDragging.current = false;
// Momentum scroll with decay
const momentum = dragVelocity.current * 0.15;
const targetIndex = Math.round(offsetRef.current - momentum);
const wrapped = ((targetIndex % N) + N) % N;
snapTo(wrapped);
}, [snapTo]);
// Click to select
const onItemClick = useCallback(
(index: number) => {
snapTo(index);
// Small delay so user sees the snap before action fires
setTimeout(() => {
MENU_ITEMS[index].action();
if (index === N - 1) onClose();
}, 150);
},
[snapTo, onClose]
);
const nowSec = Date.now() / 1000;
return (
<div
ref={containerRef}
className="fixed inset-0 z-50 flex flex-col items-center justify-center overflow-hidden"
style={{ background: "#0a0a0f", cursor: "none" }}
onWheel={onWheel}
onTouchStart={onTouchStart}
onTouchMove={onTouchMove}
onTouchEnd={onTouchEnd}
>
{/* Ambient Orbs */}
<div
className="absolute rounded-full pointer-events-none"
style={{
width: 384,
height: 384,
left: "25%",
top: "25%",
transform: "translate(-50%, -50%)",
background: "radial-gradient(circle, #1e3a5f 0%, transparent 70%)",
opacity: 0.2,
filter: "blur(64px)",
}}
/>
<div
className="absolute rounded-full pointer-events-none"
style={{
width: 384,
height: 384,
left: "75%",
top: "75%",
transform: "translate(-50%, -50%)",
background: "radial-gradient(circle, #2d1b4e 0%, transparent 70%)",
opacity: 0.2,
filter: "blur(64px)",
}}
/>
{/* Center Reference Line */}
<div
className="absolute left-0 right-0 pointer-events-none"
style={{
top: "50%",
height: 1,
background:
"linear-gradient(90deg, transparent 0%, rgba(255,255,255,0.06) 50%, transparent 100%)",
}}
/>
{/* Menu Items */}
<div className="relative" style={{ width: 480, height: 500 }}>
{MENU_ITEMS.map((item, i) => {
const dist = modDist(i - renderOffset, N);
const absDist = Math.abs(dist);
if (absDist > 4.5) return null;
const isActive = absDist < 0.5;
let scale: number;
let opacity: number;
let blur: number;
let fontSize: number;
let fontWeight: number;
if (absDist < 0.5) {
scale = 1.5;
opacity = 1.0;
blur = 0;
fontSize = 20;
fontWeight = 700;
} else if (absDist < 1.5) {
scale = 1.2;
opacity = 0.8;
blur = 1;
fontSize = 16;
fontWeight = 600;
} else if (absDist < 2.5) {
scale = 1.0;
opacity = 0.6;
blur = 3;
fontSize = 14;
fontWeight = 500;
} else if (absDist < 3.5) {
scale = 0.8;
opacity = 0.4;
blur = 5;
fontSize = 12;
fontWeight = 400;
} else {
scale = 0.6;
opacity = 0.2;
blur = 6;
fontSize = 11;
fontWeight = 400;
}
const pulse = isActive
? 0.6 + 0.4 * Math.sin((nowSec % 2) * Math.PI)
: 1;
const effectiveOpacity = opacity * pulse;
return (
<div
key={i}
className="absolute left-0 right-0 flex flex-col items-center justify-center select-none"
style={{
top: "50%",
height: ITEM_HEIGHT,
transform: `translateY(${
dist * ITEM_HEIGHT - ITEM_HEIGHT / 2
}px) scale(${scale})`,
opacity: effectiveOpacity,
filter: `blur(${blur}px)`,
transition: isDragging.current ? "none" : undefined,
cursor: "pointer",
}}
onClick={() => onItemClick(i)}
role="menuitem"
aria-label={item.label}
>
{/* Glow orb behind active */}
{isActive && (
<div
className="absolute rounded-full pointer-events-none"
style={{
width: "200%",
height: "200%",
background: `radial-gradient(circle, ${item.glow.replace(
"0.6",
"0.15"
)} 0%, transparent 70%)`,
zIndex: -1,
}}
/>
)}
{/* Icon */}
<span
className="leading-none"
style={{
fontSize: 48,
color: item.color,
textShadow: isActive
? `0 0 20px ${item.glow}, 0 0 40px ${item.glow.replace(
"0.6",
"0.3"
)}`
: "none",
}}
>
{item.icon}
</span>
{/* Label */}
<span
className="mt-2 whitespace-nowrap"
style={{
fontSize,
fontWeight,
color: item.color,
letterSpacing: "0.05em",
lineHeight: "28px",
fontFamily: "Inter, system-ui, sans-serif",
opacity: absDist < 2.5 ? 1 : 0,
transition: "opacity 0.1s linear",
}}
>
{item.label}
</span>
{/* Active indicator bar */}
{isActive && (
<div
className="mt-2"
style={{
width: 240,
height: 1,
background: `linear-gradient(90deg, transparent 0%, ${item.glow.replace(
"0.6",
"0.6"
)} 50%, transparent 100%)`,
boxShadow: `0 0 10px ${item.glow.replace("0.6", "0.4")}`,
}}
/>
)}
</div>
);
})}
</div>
{/* Status Bar */}
<div
className="absolute bottom-0 left-0 right-0 flex items-center justify-between px-8"
style={{ height: 64, background: "linear-gradient(0deg, #000000 0%, transparent 100%)" }}
>
<div className="flex items-center gap-2">
<span
className="inline-block rounded-full"
style={{
width: 8,
height: 8,
background: "#10b981",
animation: "pulse 2s ease-in-out infinite",
}}
/>
<span
style={{
fontSize: 12,
lineHeight: "16px",
letterSpacing: "0.02em",
color: "rgba(255,255,255,0.4)",
fontFamily: "Inter, system-ui, sans-serif",
}}
>
System Ready
</span>
</div>
<div className="flex items-center gap-4">
<span
style={{
fontSize: 12,
lineHeight: "16px",
color: "rgba(255,255,255,0.4)",
fontFamily: "Inter, system-ui, sans-serif",
}}
>
Swipe Tap to select Esc Back
</span>
<span
style={{
fontSize: 12,
lineHeight: "16px",
color: "rgba(255,255,255,0.4)",
fontFamily: "Inter, system-ui, sans-serif",
}}
>
{clock}
</span>
</div>
</div>
{/* Keyframes for pulse */}
<style>{`
@keyframes pulse {
0%, 100% { opacity: 1; transform: scale(1); }
50% { opacity: 0.5; transform: scale(0.8); }
}
`}</style>
</div>
);
}