-
+
@@ -614,57 +648,76 @@ pub fn Settings(
-
-
"Log level"
"About"
- "Device: "{move || device_id.get()}
- {move || version.get()}
+
+ - "Device ID"
+ - {move || or_dash(device_id.get())}
+ - "App version"
+ - {move || or_dash(app_version.get())}
+
+ - "System version"
+ - {move || sys_version.get()}
+
+
@@ -820,20 +878,25 @@ impl UsbDevices {
}
}
-/// One line for the Network section: address, link state, gateway and DNS.
+const NOTHING_MOUNTED: &str = "Nothing mounted";
+
+fn or_dash(s: String) -> String {
+ if s.is_empty() { "—".to_string() } else { s }
+}
+
+/// Rows 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 {
+fn describe_network(v: &Value) -> Vec<(&'static str, 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 mut rows = vec![
+ ("Address", or_dash(ip.clone())),
+ ("Link", if online { "Online" } else { "Offline" }.to_string()),
+ ];
let gateway = json_str(v, "gateway");
if !gateway.is_empty() {
- line.push_str(&format!(" · gateway {gateway}"));
+ rows.push(("Gateway", gateway));
}
let dns: Vec<&str> = v
.get("dns")
@@ -841,29 +904,29 @@ fn describe_network(v: &Value) -> String {
.map(|a| a.iter().filter_map(Value::as_str).collect())
.unwrap_or_default();
if !dns.is_empty() {
- line.push_str(&format!(" · DNS {}", dns.join(", ")));
+ rows.push(("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}"));
+ rows.push(("This page", format!("opened via {page_host}")));
}
- line
+ rows
}
fn describe_mount(v: &Value) -> String {
- if v.is_null() {
- return "none".to_string();
- }
let name = json_str(v, "filename");
let url = json_str(v, "url");
- let mode = json_str(v, "mode");
let what = if !name.is_empty() {
name
} else if !url.is_empty() {
url
} else {
- return "none".to_string();
+ return NOTHING_MOUNTED.to_string();
};
- if mode.is_empty() { what } else { format!("{what} ({mode})") }
+ match json_str(v, "mode").as_str() {
+ "CDROM" => format!("Mounted as CD-ROM: {what}"),
+ "DISK" => format!("Mounted as USB disk: {what}"),
+ _ => format!("Mounted: {what}"),
+ }
}
diff --git a/crates/rkvm-web/src/settings_advanced.rs b/crates/rkvm-web/src/settings_advanced.rs
index 9d62ba9..8128c56 100644
--- a/crates/rkvm-web/src/settings_advanced.rs
+++ b/crates/rkvm-web/src/settings_advanced.rs
@@ -1,8 +1,15 @@
+use std::rc::Rc;
+
use leptos::prelude::*;
use leptos::task::spawn_local;
use serde_json::{Value, json};
-use crate::util::{Rpc, as_string, fire, json_str, json_string_list};
+use crate::rpc::RpcClient;
+use crate::util::{Rpc, as_string, fire, fire_status, json_str, json_string_list};
+
+/// Extensions the device knows how to mount (`setActiveExtension`).
+const EXTENSIONS: &[(&str, &str)] =
+ &[("", "None"), ("atx-power", "ATX power board"), ("dc-power", "DC power board")];
#[component]
pub fn AdvancedSettings(open: RwSignal
, rpc: Rpc) -> impl IntoView {
@@ -12,24 +19,65 @@ pub fn AdvancedSettings(open: RwSignal, rpc: Rpc) -> impl IntoView {
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 = RwSignal::new(CloudState::default());
let cloud_api = RwSignal::new(String::new());
let cloud_app = RwSignal::new(String::new());
+ let cloud_msg = RwSignal::new(String::new());
let ts_url = RwSignal::new(String::new());
+ let ts_msg = RwSignal::new(String::new());
let active_ext = RwSignal::new(String::new());
+ let ext_msg = RwSignal::new(String::new());
let dev_mode = RwSignal::new(false);
let dev_channel = RwSignal::new(false);
let tls_mode = RwSignal::new(String::new());
let tls_cert = RwSignal::new(String::new());
let tls_key = RwSignal::new(String::new());
- let serial_cmd = RwSignal::new(String::new());
+ let tls_msg = RwSignal::new(String::new());
let serial_history = RwSignal::new(Vec::::new());
- let timezones = RwSignal::new(Vec::::new());
let timezone = RwSignal::new(String::new());
let macros = RwSignal::new(Vec::::new());
let edit_id = RwSignal::new(Option::::None);
let edit_name = RwSignal::new(String::new());
let edit_steps = RwSignal::new(String::new());
+ let macro_msg = RwSignal::new(String::new());
+
+ // 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 load_power = move |c: Rc, ext: String| async move {
+ 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(format!("State unavailable: {e}")),
+ },
+ _ => {}
+ }
+ power_ext.set(ext);
+ };
Effect::new(move |_| {
if !open.get() {
@@ -37,48 +85,17 @@ pub fn AdvancedSettings(open: RwSignal, rpc: Rpc) -> impl IntoView {
}
let Some(c) = rpc.get_value() else { return };
spawn_local(async move {
- // 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);
+ load_power(c.clone(), ext).await;
if let Ok(v) = c.call("getCloudState", Value::Null).await {
- cloud_state.set(compact_json(&v));
+ let state = CloudState::from_value(&v);
+ cloud_api.set(state.api_url.clone());
+ cloud_app.set(state.app_url.clone());
+ cloud.set(state);
}
if let Ok(v) = c.call("getTailscaleControlURL", Value::Null).await {
ts_url.set(as_string(&v));
@@ -95,18 +112,9 @@ pub fn AdvancedSettings(open: RwSignal, rpc: Rpc) -> impl IntoView {
if let Ok(v) = c.call("getSerialCommandHistory", Value::Null).await {
serial_history.set(json_string_list(&v));
}
- let current = match c.call("getTimezone", Value::Null).await {
- Ok(v) => as_string(&v),
- Err(_) => String::new(),
- };
- if let Ok(v) = c.call("getTimezones", Value::Null).await {
- let mut list = json_string_list(&v);
- if !current.is_empty() && !list.contains(¤t) {
- list.insert(0, current.clone());
- }
- timezones.set(list);
+ if let Ok(v) = c.call("getTimezone", Value::Null).await {
+ timezone.set(as_string(&v));
}
- timezone.set(current);
if let Ok(v) = c.call("getKeyboardMacros", Value::Null).await {
macros.set(MacroItem::list_from(&v));
}
@@ -126,18 +134,62 @@ pub fn AdvancedSettings(open: RwSignal, rpc: Rpc) -> impl IntoView {
move |action: &'static str| fire(rpc, "setATXPowerAction", json!({ "action": action }));
let save_cloud_url = move |_| {
- fire(rpc, "setCloudUrl", json!({ "apiUrl": cloud_api.get(), "appUrl": cloud_app.get() }))
+ let Some(c) = rpc.get_value() else { return };
+ let (api, app) = (cloud_api.get().trim().to_string(), cloud_app.get().trim().to_string());
+ cloud_msg.set("Saving…".to_string());
+ spawn_local(async move {
+ match c.call("setCloudUrl", json!({ "apiUrl": api, "appUrl": app })).await {
+ Ok(_) => {
+ cloud_msg.set("Cloud URLs saved".to_string());
+ if let Ok(v) = c.call("getCloudState", Value::Null).await {
+ cloud.set(CloudState::from_value(&v));
+ }
+ }
+ Err(e) => cloud_msg.set(format!("Failed: {e}")),
+ }
+ });
};
let deregister = move |_| {
- if confirm("Deregister this device from the cloud?") {
- fire(rpc, "deregisterDevice", Value::Null);
+ if !confirm("Remove this device from the cloud account?") {
+ return;
}
+ let Some(c) = rpc.get_value() else { return };
+ spawn_local(async move {
+ match c.call("deregisterDevice", Value::Null).await {
+ Ok(_) => {
+ cloud_msg.set("Device removed from the cloud".to_string());
+ if let Ok(v) = c.call("getCloudState", Value::Null).await {
+ cloud.set(CloudState::from_value(&v));
+ }
+ }
+ Err(e) => cloud_msg.set(format!("Failed: {e}")),
+ }
+ });
+ };
+ let save_ts = move |_| {
+ fire_status(
+ rpc,
+ "setTailscaleControlURL",
+ json!({ "controlURL": ts_url.get().trim() }),
+ ts_msg,
+ "Control server saved",
+ )
};
- let save_ts =
- move |_| fire(rpc, "setTailscaleControlURL", json!({ "controlURL": ts_url.get() }));
- let save_ext =
- move |_| fire(rpc, "setActiveExtension", json!({ "extensionId": active_ext.get() }));
+ let save_ext = move |_| {
+ let Some(c) = rpc.get_value() else { return };
+ let ext = active_ext.get();
+ ext_msg.set("Applying…".to_string());
+ spawn_local(async move {
+ match c.call("setActiveExtension", json!({ "extensionId": ext })).await {
+ Ok(_) => {
+ ext_msg.set("Extension applied".to_string());
+ load_power(c, ext).await;
+ }
+ Err(e) => ext_msg.set(format!("Failed: {e}")),
+ }
+ });
+ };
let toggle_dev = move |ev: web_sys::Event| {
let on = event_target_checked(&ev);
dev_mode.set(on);
@@ -155,20 +207,15 @@ pub fn AdvancedSettings(open: RwSignal, rpc: Rpc) -> impl IntoView {
state["certificate"] = json!(tls_cert.get());
state["privateKey"] = json!(tls_key.get());
}
- fire(rpc, "setTLSState", json!({ "state": state }));
+ fire_status(
+ rpc,
+ "setTLSState",
+ json!({ "state": state }),
+ tls_msg,
+ "HTTPS settings applied",
+ );
};
- let send_serial = move |_| {
- let cmd = serial_cmd.get();
- if cmd.is_empty() {
- return;
- }
- fire(rpc, "sendCustomCommand", json!({ "command": cmd }));
- serial_history.update(|h| h.push(cmd.clone()));
- let hist = serial_history.get_untracked();
- fire(rpc, "setSerialCommandHistory", json!({ "commandHistory": hist }));
- serial_cmd.set(String::new());
- };
let clear_serial = move |_| {
serial_history.set(Vec::new());
fire(rpc, "deleteSerialCommandHistory", Value::Null);
@@ -183,20 +230,31 @@ pub fn AdvancedSettings(open: RwSignal, rpc: Rpc) -> impl IntoView {
edit_id.set(Some(m.id.clone()));
edit_name.set(m.name.clone());
edit_steps.set(m.steps_text.clone());
+ macro_msg.set(String::new());
};
- let delete_macro = move |id: String| {
+ let delete_macro = move |id: String, name: String| {
+ if !confirm(&format!("Delete macro \"{name}\"?")) {
+ return;
+ }
let kept: Vec = macros
.get_untracked()
.into_iter()
.filter(|m| m.id != id)
.map(|m| m.to_value())
.collect();
- fire(rpc, "setKeyboardMacros", json!({ "macros": kept }));
+ fire_status(
+ rpc,
+ "setKeyboardMacros",
+ json!({ "macros": kept }),
+ macro_msg,
+ "Macro deleted",
+ );
macros.update(|list| list.retain(|m| m.id != id));
};
let save_macro = move |_| {
- let name = edit_name.get();
+ let name = edit_name.get().trim().to_string();
if name.is_empty() {
+ macro_msg.set("Give the macro a name first".to_string());
return;
}
let id = edit_id.get().unwrap_or_else(|| format!("macro-{}", js_now()));
@@ -208,9 +266,17 @@ pub fn AdvancedSettings(open: RwSignal, rpc: Rpc) -> impl IntoView {
list.push(item.clone());
}
let payload: Vec = list.iter().map(MacroItem::to_value).collect();
- fire(rpc, "setKeyboardMacros", json!({ "macros": payload }));
+ fire_status(
+ rpc,
+ "setKeyboardMacros",
+ json!({ "macros": payload }),
+ macro_msg,
+ "Macro saved",
+ );
macros.set(list);
- new_macro(());
+ edit_id.set(None);
+ edit_name.set(String::new());
+ edit_steps.set(String::new());
};
view! {
@@ -219,71 +285,104 @@ pub fn AdvancedSettings(open: RwSignal, rpc: Rpc) -> impl IntoView {
{move || dc_reading.get()}
-
+
- "ATX: "{move || atx_state.get()}
+ {move || atx_state.get()}
-
+
- "No power extension active. Choose atx-power or dc-power under Extension to control host power."
+ "No power board is active. Pick the ATX or DC power board under Extension to control host power from here."
@@ -298,107 +397,114 @@ pub fn AdvancedSettings(open: RwSignal, rpc: Rpc) -> impl IntoView {
prop:checked=move || dev_channel.get()
on:change=toggle_devchan
/>
- "Pre-release channel"
+ "Pre-release updates"
- "TLS"
-
+ "HTTPS"
+
-
+
+ {move || tls_msg.get()}
"Serial console"
-
-
-
-
-
- {move || {
- let h = serial_history.get();
- if h.is_empty() { "no history".to_string() } else { h.join(" · ") }
- }}
-
-
+ // `sendCustomCommand` only logs on the device until a serial
+ // adapter is supported, so don't offer a Send button that does
+ // nothing. Earlier history stays visible and can be cleared.
+ "Serial console is not supported on this device yet."
+
+
+ {move || {
+ serial_history
+ .get()
+ .into_iter()
+ .map(|cmd| view! { - {cmd}
})
+ .collect_view()
+ }}
+
+
+
- "Timezone"
- // The device has no RPC to change its zone yet, so show the current
- // one without pretending the select does something.
-
- "Current device time zone. Changing it is not supported yet."
+ "Time zone"
+ {move || or_dash(timezone.get())}
+ "Changing the device time zone is not supported yet."
"Keyboard macros"
+
+ "No macros yet."
+
- {m.name.clone()}
+ {m.name.clone()}
-
+
}
}
/>
-