Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions crates/rkvm-web/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,8 @@ web-sys = { version = "0.3", features = [
"HtmlSelectElement",
"HtmlCanvasElement",
"CanvasRenderingContext2d",
"Navigator",
"Node",
] }

[profile.release]
Expand Down
14 changes: 11 additions & 3 deletions crates/rkvm-web/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@
.card button:not(:disabled):hover { background: var(--accent-hover); }
.card button:not(:disabled):active { transform: translateY(1px); }
.card button:disabled { opacity: .65; cursor: progress; }
.card .row { display: flex; gap: 8px; }
.err { color: var(--danger); margin-top: 10px; min-height: 1em; font-size: 12.5px; }

.tb-handle { position: fixed; top: 0; left: 50%; transform: translateX(-50%); z-index: 31;
Expand All @@ -81,12 +82,16 @@
transition: transform .22s cubic-bezier(.2,.8,.2,1), opacity .18s; opacity: 0; z-index: 32;
display: flex; align-items: center; gap: 8px; padding: 7px 10px;
border-radius: 14px; background: rgba(18, 21, 28, 0.82); border: 1px solid var(--border);
font-size: 12px; backdrop-filter: blur(16px) saturate(1.4); box-shadow: var(--shadow); }
font-size: 12px; backdrop-filter: blur(16px) saturate(1.4); box-shadow: var(--shadow);
max-width: calc(100vw - 16px); box-sizing: border-box; flex-wrap: wrap;
justify-content: center; row-gap: 4px; }
.toolbar.open { transform: translateX(-50%) translateY(0); opacity: 1; }
.toolbar button { display: inline-flex; align-items: center; justify-content: center;
min-width: 30px; height: 28px; padding: 0 10px; border: 1px solid transparent;
border-radius: var(--r1); background: transparent; color: var(--text); cursor: pointer;
font: 500 12px var(--font); transition: background .12s, border-color .12s, color .12s, transform .05s; }
font: 500 12px var(--font); white-space: nowrap;
transition: background .12s, border-color .12s, color .12s, transform .05s; }
.toolbar button:disabled { opacity: .4; cursor: default; }
.toolbar button:hover { background: var(--surface-3); }
.toolbar button:active { transform: translateY(1px); }
.toolbar button.active { background: var(--accent); border-color: var(--accent); color: #fff; }
Expand All @@ -103,12 +108,15 @@
.tb-leds span.on { color: #07120c; background: var(--success); border-color: var(--success);
box-shadow: 0 0 10px -1px var(--success); }
.tb-err { color: var(--danger); max-width: 220px; overflow: hidden; text-overflow: ellipsis;
white-space: nowrap; }
white-space: nowrap; cursor: pointer; }
.tb-err:empty { display: none; }

.nopw-banner { position: fixed; top: 8px; left: 8px; z-index: 30; max-width: min(460px, calc(100vw - 16px));
display: flex; align-items: flex-start; gap: 8px; padding: 8px 10px; border-radius: var(--r1);
background: rgba(120, 22, 32, 0.92); border: 1px solid var(--danger); color: #fff;
font: 500 12.5px/1.4 var(--font); box-shadow: var(--shadow); }
/* The open toolbar sits at the top; move the warning out of its way. */
.nopw-banner.low { top: auto; bottom: 8px; }
.nopw-banner button { flex: none; background: none; border: 0; color: #fff; cursor: pointer;
font-size: 12px; padding: 0 2px; opacity: .8; }
.nopw-banner button:hover { opacity: 1; }
Expand Down
79 changes: 68 additions & 11 deletions crates/rkvm-web/src/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -213,7 +213,11 @@ pub fn App() -> impl IntoView {

let adjust_quality = move |delta: f64| {
let Some(client) = rpc_store.get_value() else { return };
let target = (quality.get_untracked() + delta).clamp(0.1, 1.0);
let current = quality.get_untracked();
let target = ((current + delta).clamp(0.1, 1.0) * 10.0).round() / 10.0;
if (target - current).abs() < 0.01 {
return;
}
spawn_local(async move {
match client.call("setStreamQualityFactor", json!({ "factor": target })).await {
Ok(_) => quality.set(target),
Expand Down Expand Up @@ -272,11 +276,11 @@ pub fn App() -> impl IntoView {
};

let go_fullscreen = move |_| {
if let Some(el) =
web_sys::window().and_then(|w| w.document()).and_then(|d| d.document_element())
{
let _ = el.request_fullscreen();
}
spawn_local(async move {
if let Err(e) = crate::util::toggle_fullscreen().await {
error.set(Some(e));
}
});
};

let do_ocr = move |_| {
Expand All @@ -290,6 +294,34 @@ pub fn App() -> impl IntoView {
});
};

let ocr_copy_msg = RwSignal::new(String::new());
let copy_ocr = move |_| {
let text = ocr_text.get_untracked().unwrap_or_default();
spawn_local(async move {
ocr_copy_msg.set(match crate::util::copy_text(&text).await {
Ok(()) => "Copied".to_string(),
Err(e) => e,
});
});
};

// Toolbar errors are transient: clear each one after a few seconds unless a
// newer error replaced it. Errors shown on the Connect card stay put.
let error_timer = StoredValue::new_local(None::<Timeout>);
Effect::new(move |_| {
let Some(msg) = error.get() else { return };
if conn_state.get_untracked() != "connected" {
return;
}
error_timer.set_value(Some(Timeout::new(6000, move || {
if error.get_untracked().as_deref() == Some(msg.as_str())
&& conn_state.get_untracked() == "connected"
{
error.set(None);
}
})));
});

let hide_timer = StoredValue::new_local(None::<Timeout>);
let cancel_hide = move |_| hide_timer.set_value(None);
let schedule_hide = move |_| {
Expand Down Expand Up @@ -344,9 +376,21 @@ pub fn App() -> impl IntoView {
{move || if mouse_relative.get() { "Mouse: Rel" } else { "Mouse: Abs" }}
</button>
<span class="tb-group">
<button on:click=quality_down title="Lower stream quality">"−"</button>
<button
on:click=quality_down
disabled=move || quality.get() <= 0.15
title="Lower stream quality"
>
"−"
</button>
<span class="tb-stat">{move || format!("{:.0}%", quality.get() * 100.0)}</span>
<button on:click=quality_up title="Raise stream quality">"+"</button>
<button
on:click=quality_up
disabled=move || quality.get() >= 0.95
title="Raise stream quality"
>
"+"
</button>
</span>
<button class:active=move || audio_on.get() on:click=toggle_audio title="Toggle device audio">
{move || if audio_on.get() { "Audio: on" } else { "Audio: off" }}
Expand All @@ -363,11 +407,17 @@ pub fn App() -> impl IntoView {
<span class="tb-sep"></span>

<button class="tb-danger" on:click=do_reboot title="Reboot device">"⏻"</button>
<span class="tb-err">{move || error.get().unwrap_or_default()}</span>
<span
class="tb-err"
title=move || error.get().unwrap_or_default()
on:click=move |_| error.set(None)
>
{move || error.get().unwrap_or_default()}
</span>
</div>

<Show when=move || no_password.get() && !nopw_dismissed.get()>
<div class="nopw-banner" role="alert">
<div class="nopw-banner" class:low=move || toolbar_open.get() role="alert">
<span>
"⚠ No password set: anyone on this network can control this machine and open a root shell on the device. Set one in ⚙ Settings → Password."
</span>
Expand All @@ -390,7 +440,14 @@ pub fn App() -> impl IntoView {
<textarea rows="10" readonly=true prop:value=move || {
ocr_text.get().unwrap_or_default()
}></textarea>
<button on:click=move |_| ocr_text.set(None)>"Close"</button>
<div class="row">
<button on:click=copy_ocr>"Copy"</button>
<button on:click=move |_| {
ocr_copy_msg.set(String::new());
ocr_text.set(None);
}>"Close"</button>
</div>
<div class="muted">{move || ocr_copy_msg.get()}</div>
</div>
</div>
</Show>
Expand Down
76 changes: 76 additions & 0 deletions crates/rkvm-web/src/util.rs
Original file line number Diff line number Diff line change
@@ -1,8 +1,12 @@
use std::rc::Rc;

use js_sys::{Array, Function, Promise, Reflect};
use leptos::prelude::*;
use leptos::task::spawn_local;
use serde_json::Value;
use wasm_bindgen::{JsCast, JsValue};
use wasm_bindgen_futures::JsFuture;
use web_sys::HtmlTextAreaElement;

use crate::rpc::RpcClient;

Expand All @@ -29,3 +33,75 @@ pub fn json_string_list(v: &Value) -> Vec<String> {
.map(|a| a.iter().filter_map(Value::as_str).map(str::to_string).collect())
.unwrap_or_default()
}

fn call_method(target: &JsValue, name: &str, args: &Array) -> Result<JsValue, JsValue> {
let f = Reflect::get(target, &JsValue::from_str(name))?.dyn_into::<Function>()?;
f.apply(target, args)
}

fn js_message(e: &JsValue) -> String {
e.dyn_ref::<js_sys::Error>()
.map(|e| String::from(e.message()))
.or_else(|| e.as_string())
.unwrap_or_else(|| format!("{e:?}"))
}

/// Toggles page fullscreen. `requestFullscreen` returns a promise that
/// browsers reject (permissions, iframes, missing user gesture); surface that
/// instead of leaving an unhandled rejection.
pub async fn toggle_fullscreen() -> Result<(), String> {
let doc = web_sys::window().and_then(|w| w.document()).ok_or("no document")?;
let doc_js: &JsValue = doc.as_ref();
let active = Reflect::get(doc_js, &JsValue::from_str("fullscreenElement"))
.map(|v| !v.is_null() && !v.is_undefined())
.unwrap_or(false);
let result = if active {
call_method(doc_js, "exitFullscreen", &Array::new())
} else {
let el = doc.document_element().ok_or("no document element")?;
call_method(el.as_ref(), "requestFullscreen", &Array::new())
};
let value = result.map_err(|e| format!("Fullscreen unavailable: {}", js_message(&e)))?;
if let Ok(promise) = value.dyn_into::<Promise>() {
JsFuture::from(promise)
.await
.map_err(|e| format!("Fullscreen blocked by the browser: {}", js_message(&e)))?;
}
Ok(())
}

/// Copies text to the clipboard. The async Clipboard API only exists in secure
/// contexts, and the device is usually opened over plain http, so fall back to
/// a temporary textarea and `execCommand("copy")`.
pub async fn copy_text(text: &str) -> Result<(), String> {
let window = web_sys::window().ok_or("no window")?;
let clipboard = Reflect::get(window.navigator().as_ref(), &JsValue::from_str("clipboard"))
.unwrap_or(JsValue::UNDEFINED);
if clipboard.is_object()
&& let Ok(p) = call_method(&clipboard, "writeText", &Array::of1(&JsValue::from_str(text)))
&& let Ok(promise) = p.dyn_into::<Promise>()
&& JsFuture::from(promise).await.is_ok()
{
return Ok(());
}
let doc = window.document().ok_or("no document")?;
let body = doc.body().ok_or("no body")?;
let area = doc
.create_element("textarea")
.map_err(|e| js_message(&e))?
.dyn_into::<HtmlTextAreaElement>()
.map_err(|_| "textarea cast failed")?;
area.set_value(text);
area.set_attribute("style", "position:fixed;opacity:0").map_err(|e| js_message(&e))?;
body.append_child(&area).map_err(|e| js_message(&e))?;
area.select();
let copied = call_method(doc.as_ref(), "execCommand", &Array::of1(&JsValue::from_str("copy")))
.map(|v| v.as_bool().unwrap_or(false))
.unwrap_or(false);
area.remove();
if copied {
Ok(())
} else {
Err("Copy failed: select the text and copy it manually".to_string())
}
}
Loading