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
11 changes: 9 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -193,16 +193,23 @@ lib.rs # re-exports hidrpc + jsonrpc

```
main.rs # WASM entry: panic hook + tracing-wasm + mount Leptos App
├── app.rs # root component, layout, top-level signals/routing
├── app.rs # root component: connection state, handlers, page content inside the shell
├── shell/ # Claude Desktop frame: sidebar.rs (52px rail / 256px overlay), titlebar.rs
├── ui/ # design-system components: icon.rs, button.rs (Button, IconButton), field.rs (Field, Callout)
├── i18n.rs # zh/en copy via `i18n.t("中文", "English")`, zh by default
├── theme.rs # light/dark toggle (boot script in index.html applies it before paint)
├── rpc.rs # JSON-RPC over WebRTC data channel
├── net/ # peer.rs (WebRTC PeerConnection), rest.rs (HTTP), upload.rs (virtual media)
├── hid/ # keymap.rs (re-exports rkvm_proto::keymap) + mod.rs (keyboard/mouse → hidrpc via rkvm-proto)
├── terminal.rs # vt100 terminal view
├── ocr.rs # screen OCR via Tesseract.js (JS interop, CDN-loaded)
├── settings.rs / settings_advanced.rs # settings drawer
├── settings.rs / settings_advanced.rs # settings dialog sections
├── settings_nav.rs # settings dialog pages, search
└── util.rs
```

Styles live in `crates/rkvm-web/style/` (`tokens.css` → `base.css` → `components.css` → `shell.css` → `panels.css`, linked from `index.html` with `data-trunk rel="css"`). The design rules (tokens, layout, components, copy) are in [`docs/design-system.md`](docs/design-system.md); follow it for any UI change.

## Frontend

The SPA is the in-tree **`crates/rkvm-web`** crate (Leptos 0.8, CSR / `wasm32-unknown-unknown`). It is **excluded from the workspace** — it has its own `Cargo.lock`, `rust-toolchain.toml` (`stable` + `wasm32-unknown-unknown`), `Trunk.toml`, and `deny.toml`.
Expand Down
2 changes: 2 additions & 0 deletions crates/rkvm-web/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ web-sys = { version = "0.3", features = [
"HtmlVideoElement",
"HtmlMediaElement",
"MediaStream",
"MediaQueryList",
"RtcPeerConnection",
"RtcConfiguration",
"RtcIceServer",
Expand Down Expand Up @@ -70,6 +71,7 @@ web-sys = { version = "0.3", features = [
"Navigator",
"WebSocket",
"Node",
"Storage",
] }

[profile.release]
Expand Down
242 changes: 22 additions & 220 deletions crates/rkvm-web/index.html

Large diffs are not rendered by default.

594 changes: 382 additions & 212 deletions crates/rkvm-web/src/app.rs

Large diffs are not rendered by default.

53 changes: 44 additions & 9 deletions crates/rkvm-web/src/hid/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ pub fn attach(video: HtmlVideoElement, session: Session, mouse_mode: MouseMode)
let session = session.clone();
let kb = kb.clone();
let cb = Closure::<dyn FnMut(KeyboardEvent)>::new(move |ev: KeyboardEvent| {
if editable_focused() {
if ui_owns_keyboard() {
return;
}
ev.prevent_default();
Expand All @@ -83,18 +83,27 @@ pub fn attach(video: HtmlVideoElement, session: Session, mouse_mode: MouseMode)
let session = session.clone();
let kb = kb.clone();
let cb = Closure::<dyn FnMut(KeyboardEvent)>::new(move |ev: KeyboardEvent| {
if editable_focused() {
return;
}
ev.prevent_default();
// A key pressed for the host is always released to it, even if
// focus moved into the UI while it was held.
let local = ui_owns_keyboard();
let code = ev.code();
let mut st = kb.borrow_mut();
if let Some(bit) = keymap::code_to_modifier(&code) {
let held = if let Some(bit) = keymap::code_to_modifier(&code) {
let held = st.modifiers & bit != 0;
st.modifiers &= !bit;
held
} else if let Some(usage) = keymap::code_to_usage(&code) {
let held = st.keys.contains(&usage);
st.keys.retain(|&k| k != usage);
held
} else {
return;
};
if local && !held {
return;
}
if !local {
ev.prevent_default();
}
if let Some((peer, _)) = session.borrow().as_ref() {
send_keyboard(peer, &st);
Expand Down Expand Up @@ -162,14 +171,22 @@ fn pointer_locked(_video: &HtmlVideoElement) -> bool {
web_sys::window().and_then(|w| w.document()).and_then(|d| d.pointer_lock_element()).is_some()
}

fn editable_focused() -> bool {
/// Whether keys belong to the page instead of the host: a text field, the
/// terminal, an open sheet or dialog (`data-modal`), or chrome
/// (`data-chrome`) reached with the keyboard. A button that was only clicked
/// does not match `:focus-visible`, so typing after a click still goes to the
/// host.
fn ui_owns_keyboard() -> bool {
web_sys::window()
.and_then(|w| w.document())
.and_then(|d| d.active_element())
.map(|el| {
matches!(el.tag_name().as_str(), "INPUT" | "TEXTAREA" | "SELECT")
|| el.has_attribute("contenteditable")
|| el.has_attribute("data-term")
|| el.closest("[data-modal]").ok().flatten().is_some()
|| (el.closest("[data-chrome]").ok().flatten().is_some()
&& el.matches(":focus-visible").unwrap_or(false))
})
.unwrap_or(false)
}
Expand All @@ -183,17 +200,35 @@ fn send_keyboard(peer: &Peer, st: &KbState) {
let _ = peer.hidrpc.send_with_u8_array(&bytes);
}

/// Maps a pointer position on the `<video>` element to the 0..=32767 absolute
/// range. The element uses `object-fit: contain`, so the picture is centered
/// and may have bars on two sides; positions are taken relative to the
/// picture itself and clamped onto its edges when the pointer is on a bar.
fn abs_position(video: &HtmlVideoElement, ev: &MouseEvent) -> Option<(i32, i32, u8)> {
let (cw, ch) = (video.client_width() as f64, video.client_height() as f64);
if cw <= 0.0 || ch <= 0.0 {
return None;
}
let rel_x = (ev.offset_x() as f64 / cw).clamp(0.0, 1.0);
let rel_y = (ev.offset_y() as f64 / ch).clamp(0.0, 1.0);
let (left, top, w, h) =
content_rect(cw, ch, video.video_width() as f64, video.video_height() as f64);
let rel_x = ((ev.offset_x() as f64 - left) / w).clamp(0.0, 1.0);
let rel_y = ((ev.offset_y() as f64 - top) / h).clamp(0.0, 1.0);
let buttons = (ev.buttons() & 0x1f) as u8;
Some(((rel_x * 32767.0).round() as i32, (rel_y * 32767.0).round() as i32, buttons))
}

/// The box the picture occupies inside a `cw` x `ch` element under
/// `object-fit: contain`, as (left, top, width, height). Before the first
/// frame the intrinsic size is 0 and the whole element is used.
fn content_rect(cw: f64, ch: f64, vw: f64, vh: f64) -> (f64, f64, f64, f64) {
if vw <= 0.0 || vh <= 0.0 {
return (0.0, 0.0, cw, ch);
}
let scale = (cw / vw).min(ch / vh);
let (w, h) = (vw * scale, vh * scale);
((cw - w) / 2.0, (ch - h) / 2.0, w, h)
}

/// Sends a press and a release report. Keyboard reports go over the reliable,
/// ordered `hidrpc` channel: a lost or reordered release would leave keys or
/// modifiers stuck on the host. Only pointer motion uses the unreliable one.
Expand Down
113 changes: 113 additions & 0 deletions crates/rkvm-web/src/i18n.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
//! Chinese / English UI copy. Strings live next to their use as
//! `i18n.t("中文", "English")` pairs, so a view reads in both languages and
//! nothing can fall out of a lookup table. Chinese is the default unless the
//! browser asks for another language; the choice is kept in localStorage.

use std::cell::Cell;

use leptos::prelude::*;

const STORAGE_KEY: &str = "rkvm.lang";

thread_local! {
// Mirror of the signal for code without a reactive owner (async results,
// helpers in util.rs). Views use `I18n::t` so they re-render on switch.
static CURRENT: Cell<Lang> = const { Cell::new(Lang::Zh) };
}

/// The current language, read once (not reactive).
pub fn lang_now() -> Lang {
CURRENT.with(Cell::get)
}

/// The string for the current language, read once (not reactive).
pub fn tr(zh: &'static str, en: &'static str) -> &'static str {
match CURRENT.with(Cell::get) {
Lang::Zh => zh,
Lang::En => en,
}
}

#[derive(Clone, Copy, PartialEq, Eq)]
pub enum Lang {
Zh,
En,
}

/// Handle to the current language, provided once by [`provide`] and read with
/// [`use_i18n`]. Copy, so views capture it freely.
#[derive(Clone, Copy)]
pub struct I18n(RwSignal<Lang>);

impl I18n {
/// Pick the string for the current language. Reactive: call it inside a
/// closure in a view and the text follows language changes.
pub fn t(self, zh: &'static str, en: &'static str) -> &'static str {
match self.0.get() {
Lang::Zh => zh,
Lang::En => en,
}
}

/// [`I18n::t`] without tracking, for event handlers and async tasks
/// that build a message once.
pub fn t_now(self, zh: &'static str, en: &'static str) -> &'static str {
tr(zh, en)
}

pub fn lang(self) -> Lang {
self.0.get()
}

pub fn set(self, next: Lang) {
self.0.set(next);
apply(next);
crate::util::store_set(STORAGE_KEY, code(next));
}
}

pub fn provide() -> I18n {
let lang = match crate::util::store_get(STORAGE_KEY).as_deref() {
Some("en") => Lang::En,
Some("zh") => Lang::Zh,
_ => browser_lang(),
};
apply(lang);
let i18n = I18n(RwSignal::new(lang));
provide_context(i18n);
i18n
}

pub fn use_i18n() -> I18n {
use_context::<I18n>().unwrap_or_else(|| I18n(RwSignal::new(Lang::Zh)))
}

fn code(lang: Lang) -> &'static str {
match lang {
Lang::Zh => "zh",
Lang::En => "en",
}
}

fn browser_lang() -> Lang {
let nav = web_sys::window().and_then(|w| w.navigator().language());
match nav {
Some(l) if !l.to_ascii_lowercase().starts_with("zh") => Lang::En,
_ => Lang::Zh,
}
}

fn apply(lang: Lang) {
CURRENT.with(|c| c.set(lang));
if let Some(root) =
web_sys::window().and_then(|w| w.document()).and_then(|d| d.document_element())
{
let tag = match lang {
Lang::Zh => "zh-CN",
Lang::En => "en",
};
// Failing to set the lang attribute only affects hyphenation and
// screen-reader voice, never the UI itself.
let _ = root.set_attribute("lang", tag);
}
}
5 changes: 5 additions & 0 deletions crates/rkvm-web/src/main.rs
Original file line number Diff line number Diff line change
@@ -1,11 +1,16 @@
mod app;
mod hid;
mod i18n;
mod net;
mod ocr;
mod rpc;
mod settings;
mod settings_advanced;
mod settings_nav;
mod shell;
mod terminal;
mod theme;
mod ui;
mod util;

fn main() {
Expand Down
20 changes: 13 additions & 7 deletions crates/rkvm-web/src/net/peer.rs
Original file line number Diff line number Diff line change
Expand Up @@ -40,8 +40,13 @@ impl Peer {
}
}

const LOST_MESSAGE: &str = "Connection lost: another client may have taken over this session, \
or the network dropped.";
fn lost_message() -> String {
crate::i18n::tr(
"连接已断开:可能是其他客户端接管了这个会话,或者网络中断了。",
"Connection lost: another client may have taken over this session, or the network dropped.",
)
.to_string()
}

/// Polls an established connection until it is lost or `cancelled` returns
/// true. Returns the user-facing reason when the connection is lost.
Expand All @@ -60,12 +65,12 @@ pub async fn watch(peer: &Peer, cancelled: impl Fn() -> bool) -> Option<String>
}
match peer.pc.ice_connection_state() {
RtcIceConnectionState::Failed | RtcIceConnectionState::Closed => {
return Some(LOST_MESSAGE.to_string());
return Some(lost_message());
}
RtcIceConnectionState::Disconnected => {
disconnected += 1;
if disconnected >= DISCONNECTED_TICKS {
return Some(LOST_MESSAGE.to_string());
return Some(lost_message());
}
}
_ => disconnected = 0,
Expand All @@ -74,7 +79,7 @@ pub async fn watch(peer: &Peer, cancelled: impl Fn() -> bool) -> Option<String>
peer.rpc.ready_state(),
RtcDataChannelState::Closing | RtcDataChannelState::Closed
) {
return Some(LOST_MESSAGE.to_string());
return Some(lost_message());
}
}
}
Expand Down Expand Up @@ -212,7 +217,7 @@ pub async fn connect(video: &HtmlVideoElement) -> Result<Peer, String> {
pc.close();
out_tx.close_channel();
let _ = raw_ws.close();
return Err("ICE connection failed".to_string());
return Err(crate::i18n::tr("连接失败(ICE)", "ICE connection failed").to_string());
}
_ => {}
}
Expand All @@ -232,7 +237,8 @@ pub async fn connect(video: &HtmlVideoElement) -> Result<Peer, String> {
pc.close();
out_tx.close_channel();
let _ = raw_ws.close();
Err("timed out waiting for the data channel to open".to_string())
Err(crate::i18n::tr("等待数据通道打开超时", "Timed out waiting for the data channel to open")
.to_string())
}

fn signaling_url() -> Result<String, String> {
Expand Down
8 changes: 5 additions & 3 deletions crates/rkvm-web/src/net/rest.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ use gloo_net::http::Request;
use serde_json::json;
use web_sys::RequestCredentials;

use crate::i18n::tr;

pub async fn auth_required() -> bool {
match Request::get("/device").credentials(RequestCredentials::Include).send().await {
Ok(resp) => resp.status() == 401,
Expand Down Expand Up @@ -72,7 +74,7 @@ pub async fn change_password(old: &str, new: &str) -> Result<(), String> {
.map_err(|e| format!("change-password request failed: {e}"))?;
match resp.status() {
s if (200..300).contains(&s) => Ok(()),
401 => Err("Current password is incorrect".to_string()),
401 => Err(tr("当前密码不正确", "Current password is incorrect").to_string()),
s => Err(format!("change-password HTTP {s}")),
}
}
Expand All @@ -88,9 +90,9 @@ pub async fn login_local(password: &str) -> Result<(), String> {
if resp.ok() {
Ok(())
} else if resp.status() == 401 {
Err("Incorrect password".to_string())
Err(tr("密码不正确", "Incorrect password").to_string())
} else if resp.status() == 429 {
Err("Too many attempts — try again later".to_string())
Err(tr("尝试次数过多,请稍后再试", "Too many attempts, try again later").to_string())
} else {
Err(format!("login HTTP {}", resp.status()))
}
Expand Down
2 changes: 1 addition & 1 deletion crates/rkvm-web/src/ocr.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ extern "C" {
pub async fn recognize(video: &HtmlVideoElement) -> Result<String, String> {
let (w, h) = (video.video_width(), video.video_height());
if w == 0 || h == 0 {
return Err("no video frame yet".to_string());
return Err(crate::i18n::tr("还没有视频画面", "No video frame yet").to_string());
}

let document = web_sys::window().and_then(|w| w.document()).ok_or("no document")?;
Expand Down
Loading
Loading