390 lines
12 KiB
Rust
390 lines
12 KiB
Rust
#![allow(non_snake_case)]
|
|
|
|
use dioxus::prelude::*;
|
|
use dioxus_components::{Button, ButtonSize, ButtonVariant, Control, ControlKind, Prompt};
|
|
use dioxus_icons::lucide::{Bell, Bot, Check, Copy, LogIn, Mic, Moon, Power, Settings, Shield, Sun, Wifi};
|
|
|
|
use crate::{read_hub_state, sleep_ms, start_login};
|
|
|
|
fn copy_to_clipboard(text: &str) {
|
|
let mut literal = String::from('"');
|
|
for c in text.chars() {
|
|
match c {
|
|
'"' => literal.push_str("\\\""),
|
|
'\\' => literal.push_str("\\\\"),
|
|
'\n' => literal.push_str("\\n"),
|
|
'\r' => literal.push_str("\\r"),
|
|
'\t' => literal.push_str("\\t"),
|
|
c if c.is_control() => literal.push_str(&format!("\\u{:04x}", c as u32)),
|
|
c => literal.push(c),
|
|
}
|
|
}
|
|
literal.push('"');
|
|
let _ = document::eval(&format!("navigator.clipboard.writeText({literal})"));
|
|
}
|
|
|
|
const APP_CSS: &str = r#"
|
|
html, body {
|
|
width: 100%;
|
|
height: 100%;
|
|
margin: 0;
|
|
background: #171D18;
|
|
color: #F2F4EA;
|
|
font-family: "Segoe UI", "IBM Plex Sans", "Helvetica Neue", sans-serif;
|
|
}
|
|
#main, #main > div {
|
|
width: 100%;
|
|
height: 100%;
|
|
}
|
|
.mme-hub {
|
|
/* Palette: dark defaults; light overrides below. Values match the mize token table. */
|
|
--bg: #171D18;
|
|
--surface: #242D25;
|
|
--surface-2: #2C362D;
|
|
--text: #F2F4EA;
|
|
--text-2: #AAB5A4;
|
|
--border: #3A463A;
|
|
--accent: #D7E47D;
|
|
--accent-text: #D7E47D;
|
|
--success: #B9D987;
|
|
--info: #B9E4EB;
|
|
--warning: #FFD77F;
|
|
--danger: #FF9B8E;
|
|
--on-accent: #171D18;
|
|
--focus-ring: var(--accent);
|
|
--tile-size: 6.75rem;
|
|
--tile-gap: 0.65rem;
|
|
--prompt-gap: 2.25rem;
|
|
|
|
display: flex;
|
|
width: 100%;
|
|
height: 100%;
|
|
box-sizing: border-box;
|
|
flex-direction: column;
|
|
align-items: stretch;
|
|
padding: 1rem 1.25rem 1.5rem;
|
|
gap: var(--prompt-gap);
|
|
overflow: auto;
|
|
background: var(--bg);
|
|
color: var(--text);
|
|
}
|
|
.mme-hub[data-theme="light"] {
|
|
--bg: #F7F8F4;
|
|
--surface: #FFFFFF;
|
|
--surface-2: #EFF2EA;
|
|
--text: #20251C;
|
|
--text-2: #66705E;
|
|
--border: #DFE4D9;
|
|
--accent: #FEC251;
|
|
--accent-text: #79500F;
|
|
--success: #526F32;
|
|
--info: #376777;
|
|
--warning: #A66B13;
|
|
--danger: #B94D42;
|
|
--on-accent: #79500F;
|
|
}
|
|
.mme-topbar {
|
|
display: flex;
|
|
flex-shrink: 0;
|
|
align-items: center;
|
|
gap: 0.65rem;
|
|
width: 100%;
|
|
}
|
|
.mme-topbar-prompt {
|
|
flex: 1 1 auto;
|
|
min-width: 0;
|
|
}
|
|
.mme-topbar-actions {
|
|
display: flex;
|
|
flex-shrink: 0;
|
|
align-items: center;
|
|
gap: 0.5rem;
|
|
}
|
|
.mme-login-banner {
|
|
display: flex;
|
|
flex-shrink: 0;
|
|
flex-direction: column;
|
|
gap: 0.55rem;
|
|
padding: 0.75rem 0.9rem;
|
|
border: 1px solid var(--border);
|
|
border-radius: 0.375rem;
|
|
background: var(--surface);
|
|
color: var(--text);
|
|
}
|
|
.mme-login-banner-label {
|
|
color: var(--text-2);
|
|
font-size: 0.75rem;
|
|
letter-spacing: 0.02em;
|
|
text-transform: uppercase;
|
|
}
|
|
.mme-login-banner-row {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.65rem;
|
|
min-width: 0;
|
|
}
|
|
.mme-login-banner-code {
|
|
flex: 1 1 auto;
|
|
min-width: 0;
|
|
color: var(--accent-text);
|
|
font-family: ui-monospace, "SFMono-Regular", Menlo, Consolas, monospace;
|
|
font-size: 1.25rem;
|
|
font-weight: 600;
|
|
letter-spacing: 0.08em;
|
|
}
|
|
.mme-login-banner-url {
|
|
flex: 1 1 auto;
|
|
min-width: 0;
|
|
color: var(--info);
|
|
font-size: 0.8125rem;
|
|
word-break: break-all;
|
|
}
|
|
.mme-tiles {
|
|
display: grid;
|
|
flex: 1 1 auto;
|
|
align-content: start;
|
|
justify-content: start;
|
|
justify-items: center;
|
|
gap: var(--tile-gap);
|
|
grid-template-columns: repeat(3, var(--tile-size));
|
|
}
|
|
.mme-hub-tile {
|
|
width: var(--tile-size);
|
|
height: var(--tile-size);
|
|
}
|
|
@media (max-width: 720px) {
|
|
.mme-hub {
|
|
--tile-size: 5.75rem;
|
|
--prompt-gap: 1.35rem;
|
|
}
|
|
.mme-topbar {
|
|
flex-wrap: wrap;
|
|
}
|
|
.mme-topbar-actions {
|
|
width: 100%;
|
|
justify-content: flex-end;
|
|
}
|
|
.mme-tiles {
|
|
grid-template-columns: repeat(2, var(--tile-size));
|
|
}
|
|
}
|
|
"#;
|
|
|
|
pub fn launch() {
|
|
dioxus::LaunchBuilder::new()
|
|
.with_cfg(
|
|
dioxus::desktop::Config::new()
|
|
.with_menu(None)
|
|
.with_window(
|
|
dioxus::desktop::WindowBuilder::new().with_title("mme"),
|
|
),
|
|
)
|
|
.launch(App);
|
|
}
|
|
|
|
/// Isolated so hub re-renders never diff `document::Style` props.
|
|
#[component]
|
|
fn AppStyle() -> Element {
|
|
rsx! {
|
|
document::Style { {APP_CSS} }
|
|
}
|
|
}
|
|
|
|
#[component]
|
|
fn App() -> Element {
|
|
rsx! {
|
|
AppStyle {}
|
|
document::Title { "mme" }
|
|
Hub {}
|
|
}
|
|
}
|
|
|
|
#[component]
|
|
fn Hub() -> Element {
|
|
let mut prompt = use_signal(String::new);
|
|
let mut mic_on = use_signal(|| false);
|
|
let mut wifi_on = use_signal(|| true);
|
|
let mut shield_on = use_signal(|| true);
|
|
let mut dark = use_signal(|| true);
|
|
let mut bot_armed = use_signal(|| false);
|
|
|
|
let initial = read_hub_state().unwrap_or_default();
|
|
let mut logged_in = use_signal(|| initial.logged_in);
|
|
let mut logging_in = use_signal(|| initial.logging_in);
|
|
let mut login_url = use_signal(|| initial.login_url.clone());
|
|
let mut login_code = use_signal(|| initial.login_code.clone());
|
|
|
|
use_future(move || async move {
|
|
loop {
|
|
sleep_ms(200).await;
|
|
let Ok(hub) = read_hub_state() else {
|
|
continue;
|
|
};
|
|
// Only write signals when values change — avoids re-rendering the
|
|
// whole hub (and its nested document::Style tags) every tick.
|
|
if logged_in() != hub.logged_in {
|
|
logged_in.set(hub.logged_in);
|
|
}
|
|
if logging_in() != hub.logging_in {
|
|
logging_in.set(hub.logging_in);
|
|
}
|
|
if login_url() != hub.login_url {
|
|
login_url.set(hub.login_url.clone());
|
|
}
|
|
if login_code() != hub.login_code {
|
|
login_code.set(hub.login_code.clone());
|
|
}
|
|
}
|
|
});
|
|
|
|
let theme = if dark() { "dark" } else { "light" };
|
|
let show_banner = logging_in() && (login_url().is_some() || login_code().is_some());
|
|
|
|
rsx! {
|
|
div {
|
|
class: "mme-hub",
|
|
"data-theme": theme,
|
|
|
|
div { class: "mme-topbar",
|
|
div { class: "mme-topbar-prompt",
|
|
Prompt {
|
|
value: prompt(),
|
|
placeholder: "Ask or run a command…".to_string(),
|
|
oninput: move |e: FormEvent| prompt.set(e.value()),
|
|
}
|
|
}
|
|
div { class: "mme-topbar-actions",
|
|
Button {
|
|
variant: ButtonVariant::Outline,
|
|
onclick: move |_| dark.set(!dark()),
|
|
if dark() {
|
|
Moon {}
|
|
} else {
|
|
Sun {}
|
|
}
|
|
if dark() { "Dark" } else { "Light" }
|
|
}
|
|
if logged_in() {
|
|
Button {
|
|
variant: ButtonVariant::Outline,
|
|
onclick: move |_| {},
|
|
Check {}
|
|
"Logged in"
|
|
}
|
|
} else if logging_in() {
|
|
Button {
|
|
variant: ButtonVariant::Outline,
|
|
onclick: move |_| {},
|
|
LogIn {}
|
|
"Waiting…"
|
|
}
|
|
} else {
|
|
Button {
|
|
variant: ButtonVariant::Primary,
|
|
onclick: move |_| start_login(),
|
|
LogIn {}
|
|
"PPC Login"
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if show_banner {
|
|
div { class: "mme-login-banner",
|
|
span { class: "mme-login-banner-label", "PPC device login" }
|
|
if let Some(code) = login_code() {
|
|
div { class: "mme-login-banner-row",
|
|
span { class: "mme-login-banner-code", "{code}" }
|
|
Button {
|
|
variant: ButtonVariant::Outline,
|
|
size: ButtonSize::Sm,
|
|
onclick: move |_| copy_to_clipboard(&code),
|
|
Copy {}
|
|
"Copy code"
|
|
}
|
|
}
|
|
}
|
|
if let Some(url) = login_url() {
|
|
div { class: "mme-login-banner-row",
|
|
span { class: "mme-login-banner-url", "{url}" }
|
|
Button {
|
|
variant: ButtonVariant::Outline,
|
|
size: ButtonSize::Sm,
|
|
onclick: move |_| copy_to_clipboard(&url),
|
|
Copy {}
|
|
"Copy link"
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
div { class: "mme-tiles",
|
|
div { class: "mme-hub-tile",
|
|
Control {
|
|
name: "Mic".to_string(),
|
|
kind: ControlKind::Toggle,
|
|
enabled: mic_on(),
|
|
confirm: Some("Enable microphone access for voice input?".into()),
|
|
on_enabled_change: move |v| mic_on.set(v),
|
|
Mic {}
|
|
}
|
|
}
|
|
div { class: "mme-hub-tile",
|
|
Control {
|
|
name: "Alerts".to_string(),
|
|
kind: ControlKind::Action,
|
|
onclick: move |_| {},
|
|
Bell {}
|
|
}
|
|
}
|
|
div { class: "mme-hub-tile",
|
|
Control {
|
|
name: "Network".to_string(),
|
|
kind: ControlKind::Toggle,
|
|
enabled: wifi_on(),
|
|
on_enabled_change: move |v| wifi_on.set(v),
|
|
Wifi {}
|
|
}
|
|
}
|
|
div { class: "mme-hub-tile",
|
|
Control {
|
|
name: "Agent".to_string(),
|
|
kind: ControlKind::Toggle,
|
|
enabled: bot_armed(),
|
|
confirm: Some("Arm the agent for autonomous actions?".into()),
|
|
on_enabled_change: move |v| bot_armed.set(v),
|
|
Bot {}
|
|
}
|
|
}
|
|
div { class: "mme-hub-tile",
|
|
Control {
|
|
name: "Status".to_string(),
|
|
kind: ControlKind::Display,
|
|
value: Some(if bot_armed() { "armed".into() } else { "idle".into() }),
|
|
Power {}
|
|
}
|
|
}
|
|
div { class: "mme-hub-tile",
|
|
Control {
|
|
name: "Shield".to_string(),
|
|
kind: ControlKind::Toggle,
|
|
enabled: shield_on(),
|
|
confirm: Some("Disable shield? This turns off local protections.".into()),
|
|
on_enabled_change: move |v| shield_on.set(v),
|
|
Shield {}
|
|
}
|
|
}
|
|
div { class: "mme-hub-tile",
|
|
Control {
|
|
name: "Settings".to_string(),
|
|
kind: ControlKind::Action,
|
|
onclick: move |_| {},
|
|
Settings {}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|