diff --git a/crates/rkvm-web/src/app.rs b/crates/rkvm-web/src/app.rs index c42532c..c36dc31 100644 --- a/crates/rkvm-web/src/app.rs +++ b/crates/rkvm-web/src/app.rs @@ -185,8 +185,28 @@ pub fn App() -> impl IntoView { let toggle_mouse = move |_| { let next = !mouse_relative.get(); - mouse_relative.set(next); - mouse_mode.get_value().set(next); + if !next { + mouse_relative.set(false); + mouse_mode.get_value().set(false); + return; + } + // Relative reports go to a separate USB function; without it the host + // receives nothing, so only switch when the device has it enabled. + let Some(client) = rpc_store.get_value() else { return }; + spawn_local(async move { + match client.call("getUsbDevices", Value::Null).await { + Ok(v) if v.get("relative_mouse").and_then(Value::as_bool).unwrap_or(false) => { + error.set(None); + mouse_relative.set(true); + mouse_mode.get_value().set(true); + } + Ok(_) => error.set(Some( + "Relative mouse is disabled: enable it in ⚙ Settings → USB devices first" + .to_string(), + )), + Err(e) => error.set(Some(e.to_string())), + } + }); }; let do_reboot = move |_| { diff --git a/crates/rkvm-web/src/settings.rs b/crates/rkvm-web/src/settings.rs index 1149ff9..9ffff0e 100644 --- a/crates/rkvm-web/src/settings.rs +++ b/crates/rkvm-web/src/settings.rs @@ -13,14 +13,17 @@ use crate::net::{rest, upload}; use crate::rpc::RpcClient; use crate::util::{Rpc, as_string, fire, json_str}; +/// Values are the codes `getKeyboardLayout` returns and `setKeyboardLayout` +/// accepts on the device. const KEYBOARD_LAYOUTS: &[(&str, &str)] = &[ - ("en_US", "English (US)"), - ("en_UK", "English (UK)"), - ("de_DE", "German"), - ("fr_FR", "French"), - ("es_ES", "Spanish"), - ("it_IT", "Italian"), - ("ja_JP", "Japanese"), + ("us", "English (US)"), + ("uk", "English (UK)"), + ("de", "German"), + ("fr", "French"), + ("es", "Spanish"), + ("it", "Italian"), + ("jp", "Japanese"), + ("kr", "Korean"), ]; #[component] @@ -109,7 +112,11 @@ pub fn Settings( if let Ok(v) = client.call("getLocalVersion", Value::Null).await { let app = json_str(&v, "app_version"); let sys = json_str(&v, "system_version"); - version.set(format!("app {app} · system {sys}")); + version.set(if sys.is_empty() { + format!("app {app}") + } else { + format!("app {app} · system {sys}") + }); } }), Box::pin(async move { @@ -119,13 +126,7 @@ pub fn Settings( }), Box::pin(async move { if let Ok(v) = client.call("getNetworkState", Value::Null).await { - let ip = json_str(&v, "ip"); - let online = v.get("online").and_then(Value::as_bool).unwrap_or(false); - network.set(format!( - "{} ({})", - if ip.is_empty() { "—" } else { &ip }, - if online { "online" } else { "offline" } - )); + network.set(describe_network(&v)); } }), Box::pin(async move { @@ -262,7 +263,7 @@ pub fn Settings( spawn_local(async move { let _ = client.call("renewDHCPLease", Value::Null).await; if let Ok(v) = client.call("getNetworkState", Value::Null).await { - network.set(json_str(&v, "ip")); + network.set(describe_network(&v)); } }); }; @@ -779,6 +780,37 @@ impl UsbDevices { } } +/// One line for the Network section: address, link state, gateway and DNS. +/// The device can have several interfaces, so when this page was loaded from a +/// different address than the one reported, say so instead of looking wrong. +fn describe_network(v: &Value) -> String { + let ip = json_str(v, "ip"); + let online = v.get("online").and_then(Value::as_bool).unwrap_or(false); + let mut line = format!( + "{} ({})", + if ip.is_empty() { "—" } else { &ip }, + if online { "online" } else { "offline" } + ); + let gateway = json_str(v, "gateway"); + if !gateway.is_empty() { + line.push_str(&format!(" · gateway {gateway}")); + } + let dns: Vec<&str> = v + .get("dns") + .and_then(Value::as_array) + .map(|a| a.iter().filter_map(Value::as_str).collect()) + .unwrap_or_default(); + if !dns.is_empty() { + line.push_str(&format!(" · DNS {}", dns.join(", "))); + } + let page_host = + web_sys::window().and_then(|w| w.location().hostname().ok()).unwrap_or_default(); + if !ip.is_empty() && !page_host.is_empty() && page_host != ip { + line.push_str(&format!(" · this page via {page_host}")); + } + line +} + fn describe_mount(v: &Value) -> String { if v.is_null() { return "none".to_string(); diff --git a/crates/rkvm-web/src/settings_advanced.rs b/crates/rkvm-web/src/settings_advanced.rs index 299e1d0..9c5c22a 100644 --- a/crates/rkvm-web/src/settings_advanced.rs +++ b/crates/rkvm-web/src/settings_advanced.rs @@ -7,8 +7,11 @@ use crate::util::{Rpc, as_string, fire, json_str, json_string_list}; #[component] pub fn AdvancedSettings(open: RwSignal, rpc: Rpc) -> impl IntoView { let dc_enabled = RwSignal::new(false); - let dc_supported = RwSignal::new(false); + let dc_restore = RwSignal::new(String::new()); + let dc_reading = RwSignal::new(String::new()); let atx_state = RwSignal::new(String::new()); + // "dc-power", "atx-power" or "" (no power extension mounted). + let power_ext = RwSignal::new(String::new()); let cloud_state = RwSignal::new(String::new()); let cloud_api = RwSignal::new(String::new()); let cloud_app = RwSignal::new(String::new()); @@ -33,22 +36,52 @@ pub fn AdvancedSettings(open: RwSignal, rpc: Rpc) -> impl IntoView { } let Some(c) = rpc.get_value() else { return }; spawn_local(async move { - if let Ok(v) = c.call("getDCPowerState", Value::Null).await { - dc_enabled.set(v.get("enabled").and_then(Value::as_bool).unwrap_or(false)); - dc_supported.set(v.get("supported").and_then(Value::as_bool).unwrap_or(false)); - } - if let Ok(v) = c.call("getATXState", Value::Null).await { - atx_state.set(compact_json(&v)); + // Power controls depend on which extension is mounted. Asking for + // ATX state while it is not mounted only logs an error on the device. + let ext = match c.call("getActiveExtension", Value::Null).await { + Ok(v) => as_string(&v), + Err(_) => String::new(), + }; + match ext.as_str() { + "dc-power" => { + if let Ok(v) = c.call("getDCPowerState", Value::Null).await { + dc_enabled.set(v.get("isOn").and_then(Value::as_bool).unwrap_or(false)); + dc_restore.set( + v.get("restoreState") + .and_then(Value::as_i64) + .map(|s| s.to_string()) + .unwrap_or_default(), + ); + let num = |k: &str| v.get(k).and_then(Value::as_f64).unwrap_or(0.0); + dc_reading.set(format!( + "{:.2} V · {:.2} A · {:.2} W", + num("voltage"), + num("current"), + num("power") + )); + } + } + "atx-power" => match c.call("getATXState", Value::Null).await { + Ok(v) => { + let on = |k: &str| v.get(k).and_then(Value::as_bool).unwrap_or(false); + atx_state.set(format!( + "power LED {} · HDD LED {}", + if on("power") { "on" } else { "off" }, + if on("hdd") { "on" } else { "off" } + )); + } + Err(e) => atx_state.set(e.to_string()), + }, + _ => {} } + active_ext.set(ext.clone()); + power_ext.set(ext); if let Ok(v) = c.call("getCloudState", Value::Null).await { cloud_state.set(compact_json(&v)); } if let Ok(v) = c.call("getTailscaleControlURL", Value::Null).await { ts_url.set(as_string(&v)); } - if let Ok(v) = c.call("getActiveExtension", Value::Null).await { - active_ext.set(as_string(&v)); - } if let Ok(v) = c.call("getDevModeState", Value::Null).await { dev_mode.set(v.get("enabled").and_then(Value::as_bool).unwrap_or(false)); } @@ -173,26 +206,31 @@ pub fn AdvancedSettings(open: RwSignal, rpc: Rpc) -> impl IntoView { view! {

"Power"

- "DC power not supported" } - > + - -
"ATX: "{move || atx_state.get()}
-
- - - -
+ +
"ATX: "{move || atx_state.get()}
+
+ + + +
+
+ +
+ "No power extension active. Choose atx-power or dc-power under Extension to control host power." +
+