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:
parent
106971f3fe
commit
8b4ca57dcf
21 changed files with 1864 additions and 9 deletions
143
Cargo.lock
generated
143
Cargo.lock
generated
|
|
@ -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",
|
||||
]
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
]
|
||||
|
|
|
|||
3
config/systemd/display-manager.service.d/synq.conf
Normal file
3
config/systemd/display-manager.service.d/synq.conf
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
[Service]
|
||||
ExecStart=
|
||||
ExecStart=/usr/bin/synq-stream --kiosk
|
||||
29
config/systemd/synq-core.service
Normal file
29
config/systemd/synq-core.service
Normal 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
|
||||
13
config/systemd/synq-watchdog.service
Normal file
13
config/systemd/synq-watchdog.service
Normal 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
|
||||
|
|
@ -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 }
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
19
crates/synq-guard/Cargo.toml
Normal file
19
crates/synq-guard/Cargo.toml
Normal 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"
|
||||
26
crates/synq-guard/src/error.rs
Normal file
26
crates/synq-guard/src/error.rs
Normal 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),
|
||||
}
|
||||
44
crates/synq-guard/src/input.rs
Normal file
44
crates/synq-guard/src/input.rs
Normal 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
|
||||
}
|
||||
94
crates/synq-guard/src/lib.rs
Normal file
94
crates/synq-guard/src/lib.rs
Normal 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());
|
||||
}
|
||||
}
|
||||
86
crates/synq-guard/src/pin.rs
Normal file
86
crates/synq-guard/src/pin.rs
Normal 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());
|
||||
}
|
||||
}
|
||||
119
crates/synq-guard/src/systemd.rs
Normal file
119
crates/synq-guard/src/systemd.rs
Normal 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""#
|
||||
);
|
||||
}
|
||||
}
|
||||
21
crates/synq-shell/Cargo.toml
Normal file
21
crates/synq-shell/Cargo.toml
Normal 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"] }
|
||||
544
crates/synq-shell/src/main.rs
Normal file
544
crates/synq-shell/src/main.rs
Normal 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(())
|
||||
}
|
||||
|
|
@ -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 }
|
||||
|
|
|
|||
|
|
@ -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 =
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
116
ui/stream/src/components/ExitDialog.tsx
Normal file
116
ui/stream/src/components/ExitDialog.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
469
ui/stream/src/components/RollingMenu.tsx
Normal file
469
ui/stream/src/components/RollingMenu.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Loading…
Reference in a new issue