diff --git a/crates/rkvm-web/index.html b/crates/rkvm-web/index.html index 5f77cfb..5916db7 100644 --- a/crates/rkvm-web/index.html +++ b/crates/rkvm-web/index.html @@ -159,7 +159,19 @@ font: 400 13px var(--font); transition: border-color .12s, box-shadow .12s; } .drawer textarea { font-family: var(--mono); resize: vertical; } .drawer input[type=range] { width: auto; flex: 1; accent-color: var(--accent); margin: 0; } - .muted { color: var(--muted); font-size: 12px; word-break: break-all; } + .muted { color: var(--muted); font-size: 12px; overflow-wrap: anywhere; } + .drawer .field { display: block; margin: 8px 0 0; } + .drawer .field > span { display: block; font-size: 12px; color: var(--muted); } + .drawer .hint { color: var(--faint); font-size: 12px; margin: 2px 0 6px; } + .drawer .kv { display: grid; grid-template-columns: auto 1fr; gap: 4px 12px; margin: 0 0 8px; + font-size: 12px; } + .drawer .kv dt { color: var(--muted); } + .drawer .kv dd { margin: 0; overflow-wrap: anywhere; } + .drawer .dot { display: inline-block; width: 8px; height: 8px; border-radius: 50%; + margin-right: 6px; background: var(--faint); vertical-align: 0; } + .drawer .dot.on { background: var(--success); } + .drawer ul.plain { margin: 4px 0; padding: 0; list-style: none; font: 400 12px var(--mono); } + .drawer ul.plain li { padding: 2px 0; overflow-wrap: anywhere; } .toggle { display: flex; align-items: center; gap: 10px; margin: 8px 0; cursor: pointer; font-size: 13px; } diff --git a/crates/rkvm-web/src/app.rs b/crates/rkvm-web/src/app.rs index 982cc7f..bd1313c 100644 --- a/crates/rkvm-web/src/app.rs +++ b/crates/rkvm-web/src/app.rs @@ -285,11 +285,14 @@ pub fn App() -> impl IntoView { let do_ocr = move |_| { let Some(video) = video_store.get_value() else { return }; - ocr_text.set(Some("scanning…".to_string())); + ocr_text.set(Some("Reading text from the screen…".to_string())); spawn_local(async move { match crate::ocr::recognize(&video).await { + Ok(text) if text.trim().is_empty() => { + ocr_text.set(Some("No text found on the screen.".to_string())) + } Ok(text) => ocr_text.set(Some(text)), - Err(e) => ocr_text.set(Some(e)), + Err(e) => ocr_text.set(Some(format!("Could not read text: {e}"))), } }); }; @@ -360,7 +363,7 @@ pub fn App() -> impl IntoView { if v.width > 0 { format!("{}×{} @ {:.0}fps", v.width, v.height, v.fps) } else { - "no signal".to_string() + "No signal".to_string() } }} @@ -373,7 +376,7 @@ pub fn App() -> impl IntoView { - - +
{move || upload_state.get()}

"Network"

-
{move || network.get()}
+
+ {move || { + network + .get() + .into_iter() + .map(|(k, v)| view! {
{k}
{v}
}) + .collect_view() + }} +
@@ -557,11 +591,11 @@ pub fn Settings(

"Firmware update"

{move || update_state.get()}
- + @@ -614,57 +648,76 @@ pub fn Settings(

"EDID"

- + +
"Leave empty to restore the default display identity."
+
{move || edid_msg.get()}

"Wake-on-LAN"

- + +
{move || wol_msg.get()}
-

"Static network"

+

"IP address"

- - - + + + - + +
{move || net_msg.get()}
@@ -685,16 +738,6 @@ pub fn Settings( /> "USB emulation" -
-
-

"Video sleep"

-
- - "seconds (-1 = off)" -
-
-

"Log level"

+

"SSH access"

+ +
{move || ssh_msg.get()}
@@ -747,47 +781,71 @@ pub fn Settings( prop:checked=move || mqtt_enabled.get() on:change=move |ev| mqtt_enabled.set(event_target_checked(&ev)) /> - "Enabled" + "Publish to an MQTT broker" + + + + + + - - - - - +
{move || mqtt_msg.get()}

"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."

"Cloud"

-
{move || cloud_state.get()}
- - +
+
"Status"
+
+ + {move || if cloud.get().connected { "Connected" } else { "Not connected" }} +
+
"API"
+
{move || or_dash(cloud.get().api_url)}
+
"App"
+
{move || or_dash(cloud.get().app_url)}
+
+ +
- +
+
{move || cloud_msg.get()}

"Tailscale"

- - + +
"Change only if you run your own server, such as Headscale."
+ +
{move || ts_msg.get()}

"Extension"

- - + +
"A power board adds its controls to the Power section."
+ +
{move || ext_msg.get()}
@@ -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()} - +
} } /> - + +
"One step per line: modifiers | keys | delay in ms"
- + + +
+
{move || macro_msg.get()}
@@ -407,17 +513,17 @@ pub fn AdvancedSettings(open: RwSignal, rpc: Rpc) -> impl IntoView {