From 824e4026dd5117c0ac30c61cab98d84c3ea2a760 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E7=8E=8B=E6=80=A7=E9=A9=8A?=
Date: Tue, 15 Sep 2026 17:07:16 +0800
Subject: [PATCH] fix bot flow
---
.env.example | 3 +
Cargo.lock | 270 +++++++++-
box/Dockerfile | 4 +-
box/start-desktop.sh | 6 +-
crates/lazyboy-core/Cargo.toml | 8 +
crates/lazyboy-core/src/box_runtime.rs | 2 +-
crates/lazyboy-core/src/lib.rs | 4 +-
crates/lazyboy-core/src/local_tls.rs | 365 +++++++++++++
crates/lazyboy-core/src/novnc_skin.rs | 414 ++++++++++++++
crates/lazyboy-core/src/session.rs | 37 +-
crates/lazyboy-core/src/team/mod.rs | 2 +-
crates/lazyboy-core/src/team/service.rs | 73 ++-
crates/lazyboy-core/src/team/store.rs | 275 +++++++++-
crates/lazyboy-core/src/team/tests.rs | 104 ++++
crates/lazyboy-core/src/team/worker.rs | 56 +-
crates/lazyboy-core/src/web_server.rs | 295 ++++++++--
crates/lazyboy/src/main.rs | 5 +-
tests/agent_profile_ui.mjs | 126 +++++
tests/chat_overlap_ui.mjs | 94 ++++
tests/transcript_ui.mjs | 59 ++
web/src/App.tsx | 686 +++++++++++++++++++-----
web/src/agent-settings.tsx | 379 +++++++++++++
web/src/animated-icons.tsx | 7 +-
web/src/api.ts | 38 +-
web/src/avatar-upload.tsx | 216 ++++++++
web/src/avatar.tsx | 92 ++--
web/src/channels.ts | 26 +
web/src/i18n.tsx | 176 ++++++
web/src/lazyboy/avatar.css | 1 +
web/src/lazyboy/chat.css | 53 +-
web/src/lazyboy/computer.css | 53 +-
web/src/lazyboy/extra.css | 51 +-
web/src/lazyboy/refinements.css | 49 +-
web/src/lazyboy/responsive.css | 3 +-
web/src/lazyboy/settings.css | 6 +
web/src/lazyboy/styles.css | 6 +-
web/src/markdown.tsx | 5 +-
web/src/settings.tsx | 4 +-
web/src/time.ts | 33 ++
web/src/types.ts | 2 +-
web/src/vite-env.d.ts | 9 +
web/vite.config.ts | 52 +-
42 files changed, 3775 insertions(+), 374 deletions(-)
create mode 100644 crates/lazyboy-core/src/local_tls.rs
create mode 100644 crates/lazyboy-core/src/novnc_skin.rs
create mode 100644 tests/agent_profile_ui.mjs
create mode 100644 tests/chat_overlap_ui.mjs
create mode 100644 tests/transcript_ui.mjs
create mode 100644 web/src/agent-settings.tsx
create mode 100644 web/src/avatar-upload.tsx
create mode 100644 web/src/channels.ts
diff --git a/.env.example b/.env.example
index 0549a5b..940108b 100644
--- a/.env.example
+++ b/.env.example
@@ -40,6 +40,9 @@ LAZYBOY_API_KEY=
# 正式 UI:先 `cd web && npm run build`,再指到編譯結果。開發用 Vite 時不必設。
# LAZYBOY_WEB_DIST=web/dist
+# 關於畫面與設定裡顯示的版號。改了要重開 Vite 或重新 `npm run build`。
+LAZYBOY_VERSION=0.1.0
+
# =============================================================================
# 公開搜尋 / 抓頁
# =============================================================================
diff --git a/Cargo.lock b/Cargo.lock
index 0095abc..fd8ce7c 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -59,6 +59,45 @@ version = "1.0.104"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470"
+[[package]]
+name = "asn1-rs"
+version = "0.6.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5493c3bedbacf7fd7382c6346bbd66687d12bbaad3a89a2d2c303ee6cf20b048"
+dependencies = [
+ "asn1-rs-derive",
+ "asn1-rs-impl",
+ "displaydoc",
+ "nom",
+ "num-traits",
+ "rusticata-macros",
+ "thiserror 1.0.69",
+ "time",
+]
+
+[[package]]
+name = "asn1-rs-derive"
+version = "0.5.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "965c2d33e53cb6b267e148a4cb0760bc01f4904c1cd4bb4002a085bb016d1490"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+ "synstructure",
+]
+
+[[package]]
+name = "asn1-rs-impl"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7b18050c2cd6fe86c3a76584ef5e0baf286d038cda203eb6223df2cc413565f7"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+]
+
[[package]]
name = "async-compression"
version = "0.4.47"
@@ -125,7 +164,7 @@ dependencies = [
"sync_wrapper",
"tokio",
"tokio-tungstenite",
- "tower",
+ "tower 0.5.3",
"tower-layer",
"tower-service",
]
@@ -328,6 +367,26 @@ version = "2.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4583a4551df46e2792f82ceeac45e850d2e2d5debba0b91f102385cda5b11f06"
+[[package]]
+name = "der-parser"
+version = "9.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5cd0a5c643689626bec213c4d8bd4d96acc8ffdb4ad4bb6bc16abf27d5f4b553"
+dependencies = [
+ "asn1-rs",
+ "displaydoc",
+ "nom",
+ "num-bigint",
+ "num-traits",
+ "rusticata-macros",
+]
+
+[[package]]
+name = "deranged"
+version = "0.5.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c"
+
[[package]]
name = "digest"
version = "0.10.7"
@@ -817,6 +876,12 @@ dependencies = [
"wasm-bindgen",
]
+[[package]]
+name = "lazy_static"
+version = "1.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe"
+
[[package]]
name = "lazyboy"
version = "0.1.0"
@@ -833,19 +898,27 @@ version = "0.1.0"
dependencies = [
"anyhow",
"axum",
+ "base64",
"chrono",
"encoding_rs",
"futures-util",
"html2text",
+ "hyper",
+ "hyper-util",
"libc",
+ "rcgen",
"regex",
"reqwest",
"rusqlite",
+ "rustls",
+ "rustls-pemfile",
"serde",
"serde_json",
"thiserror 2.0.20",
"tokio",
+ "tokio-rustls",
"tokio-tungstenite",
+ "tower 0.4.13",
"tower-http 0.5.2",
"uuid",
]
@@ -933,6 +1006,12 @@ dependencies = [
"unicase",
]
+[[package]]
+name = "minimal-lexical"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a"
+
[[package]]
name = "miniz_oxide"
version = "0.9.1"
@@ -987,6 +1066,41 @@ version = "1.0.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086"
+[[package]]
+name = "nom"
+version = "7.1.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a"
+dependencies = [
+ "memchr",
+ "minimal-lexical",
+]
+
+[[package]]
+name = "num-bigint"
+version = "0.4.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367"
+dependencies = [
+ "num-integer",
+ "num-traits",
+]
+
+[[package]]
+name = "num-conv"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441"
+
+[[package]]
+name = "num-integer"
+version = "0.1.47"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7ce2d95d4b3734dc35aa2f45e1aa22cd416814592a4f9d9205e11affd5b8e10b"
+dependencies = [
+ "num-traits",
+]
+
[[package]]
name = "num-traits"
version = "0.2.19"
@@ -996,6 +1110,15 @@ dependencies = [
"autocfg",
]
+[[package]]
+name = "oid-registry"
+version = "0.7.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a8d8034d9489cdaf79228eb9f6a3b8d7bb32ba00d6645ebd48eef4077ceb5bd9"
+dependencies = [
+ "asn1-rs",
+]
+
[[package]]
name = "once_cell"
version = "1.21.4"
@@ -1025,6 +1148,16 @@ dependencies = [
"windows-link",
]
+[[package]]
+name = "pem"
+version = "3.0.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be"
+dependencies = [
+ "base64",
+ "serde_core",
+]
+
[[package]]
name = "percent-encoding"
version = "2.3.2"
@@ -1070,6 +1203,26 @@ dependencies = [
"siphasher",
]
+[[package]]
+name = "pin-project"
+version = "1.1.13"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2466b2336ed02bcdca6b294417127b90ec92038d1d5c4fbeac971a922e0e0924"
+dependencies = [
+ "pin-project-internal",
+]
+
+[[package]]
+name = "pin-project-internal"
+version = "1.1.13"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c96395f0a926bc13b1c17622aaddda1ecb55d49c8f1bf9777e4d877800a43f8b"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+]
+
[[package]]
name = "pin-project-lite"
version = "0.2.17"
@@ -1091,6 +1244,12 @@ dependencies = [
"zerovec",
]
+[[package]]
+name = "powerfmt"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391"
+
[[package]]
name = "ppv-lite86"
version = "0.2.21"
@@ -1242,6 +1401,20 @@ dependencies = [
"rand_core 0.10.1",
]
+[[package]]
+name = "rcgen"
+version = "0.13.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "75e669e5202259b5314d1ea5397316ad400819437857b90861765f24c4cf80a2"
+dependencies = [
+ "pem",
+ "ring",
+ "rustls-pki-types",
+ "time",
+ "x509-parser",
+ "yasna",
+]
+
[[package]]
name = "redox_syscall"
version = "0.5.18"
@@ -1310,7 +1483,7 @@ dependencies = [
"tokio",
"tokio-rustls",
"tokio-util",
- "tower",
+ "tower 0.5.3",
"tower-http 0.6.11",
"tower-service",
"url",
@@ -1355,6 +1528,15 @@ version = "2.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d"
+[[package]]
+name = "rusticata-macros"
+version = "4.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "faf0c4a6ece9950b9abdb62b1cfcf2a68b3b67a10ba445b3bb85be2a293d0632"
+dependencies = [
+ "nom",
+]
+
[[package]]
name = "rustls"
version = "0.23.44"
@@ -1369,6 +1551,15 @@ dependencies = [
"zeroize",
]
+[[package]]
+name = "rustls-pemfile"
+version = "2.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "dce314e5fee3f39953d46bb63bb8a46d40c2f8fb7cc5a3b6cab2bde9721d6e50"
+dependencies = [
+ "rustls-pki-types",
+]
+
[[package]]
name = "rustls-pki-types"
version = "1.15.1"
@@ -1668,6 +1859,36 @@ dependencies = [
"syn 3.0.5",
]
+[[package]]
+name = "time"
+version = "0.3.55"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134"
+dependencies = [
+ "deranged",
+ "num-conv",
+ "powerfmt",
+ "serde_core",
+ "time-core",
+ "time-macros",
+]
+
+[[package]]
+name = "time-core"
+version = "0.1.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109"
+
+[[package]]
+name = "time-macros"
+version = "0.2.32"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7e689342a48d2ea927c87ea50cabf8594854bf940e9310208848d680d668ed85"
+dependencies = [
+ "num-conv",
+ "time-core",
+]
+
[[package]]
name = "tinystr"
version = "0.8.4"
@@ -1755,6 +1976,21 @@ dependencies = [
"tokio",
]
+[[package]]
+name = "tower"
+version = "0.4.13"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b8fa9be0de6cf49e536ce1851f987bd21a43b771b09473c3549a6c853db37c1c"
+dependencies = [
+ "futures-core",
+ "futures-util",
+ "pin-project",
+ "pin-project-lite",
+ "tower-layer",
+ "tower-service",
+ "tracing",
+]
+
[[package]]
name = "tower"
version = "0.5.3"
@@ -1812,7 +2048,7 @@ dependencies = [
"pin-project-lite",
"tokio",
"tokio-util",
- "tower",
+ "tower 0.5.3",
"tower-layer",
"tower-service",
"url",
@@ -1836,6 +2072,7 @@ version = "0.1.44"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100"
dependencies = [
+ "log",
"pin-project-lite",
"tracing-core",
]
@@ -2222,6 +2459,33 @@ version = "0.6.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc"
+[[package]]
+name = "x509-parser"
+version = "0.16.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fcbc162f30700d6f3f82a24bf7cc62ffe7caea42c0b2cba8bf7f3ae50cf51f69"
+dependencies = [
+ "asn1-rs",
+ "data-encoding",
+ "der-parser",
+ "lazy_static",
+ "nom",
+ "oid-registry",
+ "ring",
+ "rusticata-macros",
+ "thiserror 1.0.69",
+ "time",
+]
+
+[[package]]
+name = "yasna"
+version = "0.5.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e17bb3549cc1321ae1296b9cdc2698e2b6cb1992adfa19a8c72e5b7a738f44cd"
+dependencies = [
+ "time",
+]
+
[[package]]
name = "yoke"
version = "0.8.3"
diff --git a/box/Dockerfile b/box/Dockerfile
index 392eeb9..4be1608 100644
--- a/box/Dockerfile
+++ b/box/Dockerfile
@@ -1,8 +1,7 @@
FROM debian:bookworm-slim
ENV DEBIAN_FRONTEND=noninteractive
-LABEL lazyboy.box.revision=computer-use-3
RUN apt-get update && apt-get install -y --no-install-recommends \
- xvfb x11-utils x11vnc novnc websockify imagemagick xdotool \
+ xvfb x11-utils x11-xserver-utils x11vnc novnc websockify autocutsel xclip imagemagick xdotool \
dbus-x11 xfce4-session xfce4-panel xfwm4 xfdesktop4 xfce4-settings \
xfce4-terminal thunar \
fonts-noto-core fonts-noto-cjk \
@@ -22,5 +21,6 @@ RUN chmod +x /usr/local/bin/start-desktop.sh /usr/local/bin/box-chrome
ENV DISPLAY=:1
WORKDIR /workspace
EXPOSE 6080
+LABEL lazyboy.box.revision=computer-use-5
ENTRYPOINT ["/usr/bin/tini", "--"]
CMD ["/usr/local/bin/start-desktop.sh"]
diff --git a/box/start-desktop.sh b/box/start-desktop.sh
index c621b8e..eacda0b 100644
--- a/box/start-desktop.sh
+++ b/box/start-desktop.sh
@@ -45,7 +45,11 @@ startxfce4 >/tmp/xfce.log 2>&1 &
# Only websockify (same container) talks to VNC; keep the passwordless port
# off the Docker bridge so sibling containers cannot attach. (libvncserver
# still binds [::]:5900, but the container has no routable IPv6 address.)
-x11vnc -display "$DISPLAY" -forever -shared -nopw -rfbport 5900 -listen localhost >/tmp/x11vnc.log 2>&1 &
+x11vnc -display "$DISPLAY" -forever -shared -nopw -rfbport 5900 -listen localhost -xkb -repeat -noxdamage >/tmp/x11vnc.log 2>&1 &
+if command -v autocutsel >/dev/null 2>&1; then
+ autocutsel -fork -selection CLIPBOARD >/dev/null 2>&1 || true
+ autocutsel -fork -selection PRIMARY >/dev/null 2>&1 || true
+fi
websockify --web /usr/share/novnc 6080 localhost:5900 >/tmp/novnc.log 2>&1 &
echo "[lazyboy-box] desktop up display=$DISPLAY vnc=6080 xfce chrome terminal" >/tmp/start-desktop.log
diff --git a/crates/lazyboy-core/Cargo.toml b/crates/lazyboy-core/Cargo.toml
index d8cede3..79dcbb4 100644
--- a/crates/lazyboy-core/Cargo.toml
+++ b/crates/lazyboy-core/Cargo.toml
@@ -8,18 +8,26 @@ description = "Minimal GrokBot-like agent core (chat + tools + ReAct)"
[dependencies]
anyhow.workspace = true
axum.workspace = true
+base64 = "0.22"
chrono.workspace = true
encoding_rs = "0.8"
futures-util.workspace = true
html2text = "0.17"
+hyper = { version = "1", features = ["http1", "server"] }
+hyper-util = { version = "0.1", features = ["tokio", "http1", "server"] }
+rcgen = { version = "0.13", features = ["x509-parser"] }
regex = "1"
reqwest.workspace = true
rusqlite = { version = "0.32", features = ["bundled"] }
+rustls = { version = "0.23", default-features = false, features = ["std", "tls12", "ring"] }
+rustls-pemfile = "2"
serde.workspace = true
serde_json.workspace = true
thiserror.workspace = true
tokio.workspace = true
+tokio-rustls = { version = "0.26", default-features = false, features = ["ring", "tls12"] }
tokio-tungstenite.workspace = true
+tower = { version = "0.4", features = ["util"] }
tower-http.workspace = true
uuid.workspace = true
diff --git a/crates/lazyboy-core/src/box_runtime.rs b/crates/lazyboy-core/src/box_runtime.rs
index 9902c8f..32b4030 100644
--- a/crates/lazyboy-core/src/box_runtime.rs
+++ b/crates/lazyboy-core/src/box_runtime.rs
@@ -16,7 +16,7 @@ use tokio::sync::Mutex;
const IMAGE: &str = "lazyboy-box:local";
const CONTAINER: &str = "lazyboy-box";
const VIEWER_PORT: u16 = 6080;
-const BOX_REVISION: &str = "computer-use-3";
+const BOX_REVISION: &str = "computer-use-5";
const BROWSER_PROFILE: &str = "/home/box/chrome-profile";
/// Per-stream cap on shell output returned to the model; the middle is elided.
const JOB_OUTPUT_HEAD_CHARS: usize = 16_000;
diff --git a/crates/lazyboy-core/src/lib.rs b/crates/lazyboy-core/src/lib.rs
index 5279328..0220955 100644
--- a/crates/lazyboy-core/src/lib.rs
+++ b/crates/lazyboy-core/src/lib.rs
@@ -19,6 +19,8 @@ mod model;
mod session;
mod tools;
mod web;
+mod local_tls;
+mod novnc_skin;
mod web_server;
pub use agent::{
@@ -42,7 +44,7 @@ pub use model::{
};
pub use session::{
list_sessions, load_or_create, load_session, public_transcript, public_transcript_from_messages,
- save_session, session_preview, session_preview_from_messages, sessions_dir, Session,
+ save_session, session_last_at_from_messages, session_preview, session_preview_from_messages, sessions_dir, Session,
SessionSummary,
};
pub use web_server::{serve_http, serve_web, WebListen};
diff --git a/crates/lazyboy-core/src/local_tls.rs b/crates/lazyboy-core/src/local_tls.rs
new file mode 100644
index 0000000..ab45858
--- /dev/null
+++ b/crates/lazyboy-core/src/local_tls.rs
@@ -0,0 +1,365 @@
+//! Local HTTPS for the web UI. VNC stays on 127.0.0.1; browsers talk TLS to LazyBoy.
+
+use anyhow::{Context, Result};
+use rcgen::{
+ BasicConstraints, CertificateParams, DistinguishedName, DnType, ExtendedKeyUsagePurpose,
+ IsCa, KeyPair, KeyUsagePurpose, SanType,
+};
+use rustls::pki_types::{CertificateDer, PrivateKeyDer};
+use rustls::ServerConfig;
+use std::net::IpAddr;
+use std::path::{Path, PathBuf};
+use std::sync::Arc;
+
+pub struct TlsMaterial {
+ pub ca_pem: String,
+ pub cert_pem: String,
+ pub key_pem: String,
+ pub names: Vec,
+ pub dir: PathBuf,
+}
+
+pub fn tls_enabled() -> bool {
+ match std::env::var("LAZYBOY_TLS") {
+ Ok(value) => {
+ let v = value.trim().to_ascii_lowercase();
+ !matches!(v.as_str(), "0" | "false" | "no" | "off")
+ }
+ Err(_) => true,
+ }
+}
+
+pub fn cert_dir() -> PathBuf {
+ crate::config::home_config_dir()
+ .unwrap_or_else(|| PathBuf::from("."))
+ .join("certs")
+}
+
+pub fn lan_ips() -> Vec {
+ let mut ips = Vec::new();
+ for iface in ["en0", "en1", "eth0", "wlan0"] {
+ if let Ok(out) = std::process::Command::new("ipconfig")
+ .args(["getifaddr", iface])
+ .output()
+ {
+ if out.status.success() {
+ let ip = String::from_utf8_lossy(&out.stdout).trim().to_string();
+ if !ip.is_empty() {
+ ips.push(ip);
+ }
+ }
+ }
+ }
+ ips
+}
+
+fn hostnames() -> Vec {
+ let mut names = vec!["localhost".into()];
+ for cmd in [["hostname", "-s"], ["hostname", ""]] {
+ let mut c = std::process::Command::new(cmd[0]);
+ if !cmd[1].is_empty() {
+ c.arg(cmd[1]);
+ }
+ if let Ok(out) = c.output() {
+ if out.status.success() {
+ let name = String::from_utf8_lossy(&out.stdout).trim().to_string();
+ if !name.is_empty() && !names.iter().any(|n| n == &name) {
+ names.push(name.clone());
+ if !name.ends_with(".local") && !name.contains('.') {
+ names.push(format!("{name}.local"));
+ }
+ }
+ }
+ }
+ }
+ names
+}
+
+pub fn desired_names() -> Vec {
+ let mut names = hostnames();
+ names.push("127.0.0.1".into());
+ names.push("::1".into());
+ for ip in lan_ips() {
+ if !names.iter().any(|n| n == &ip) {
+ names.push(ip);
+ }
+ }
+ names.sort();
+ names.dedup();
+ names
+}
+
+fn san_list(names: &[String]) -> Vec {
+ names
+ .iter()
+ .filter_map(|name| {
+ if let Ok(ip) = name.parse::() {
+ Some(SanType::IpAddress(ip))
+ } else {
+ name.as_str()
+ .try_into()
+ .ok()
+ .map(SanType::DnsName)
+ }
+ })
+ .collect()
+}
+
+pub fn ensure() -> Result {
+ let dir = cert_dir();
+ std::fs::create_dir_all(&dir).with_context(|| format!("create {}", dir.display()))?;
+ let ca_pem_path = dir.join("ca.pem");
+ let ca_key_path = dir.join("ca.key");
+ let cert_path = dir.join("server.pem");
+ let key_path = dir.join("server.key");
+ let names_path = dir.join("sans.txt");
+ let names = desired_names();
+ let names_blob = names.join("\n");
+
+ let (ca_pem, ca_key_pem) = if ca_pem_path.is_file() && ca_key_path.is_file() {
+ (
+ std::fs::read_to_string(&ca_pem_path)?,
+ std::fs::read_to_string(&ca_key_path)?,
+ )
+ } else {
+ let (pem, key) = issue_ca()?;
+ std::fs::write(&ca_pem_path, &pem)?;
+ std::fs::write(&ca_key_path, &key)?;
+ #[cfg(unix)]
+ {
+ use std::os::unix::fs::PermissionsExt;
+ let _ = std::fs::set_permissions(&ca_key_path, std::fs::Permissions::from_mode(0o600));
+ }
+ (pem, key)
+ };
+
+ let reuse = cert_path.is_file()
+ && key_path.is_file()
+ && names_path
+ .is_file()
+ .then(|| std::fs::read_to_string(&names_path).ok())
+ .flatten()
+ .is_some_and(|old| old.trim() == names_blob.trim());
+
+ let (cert_pem, key_pem) = if reuse {
+ (
+ std::fs::read_to_string(&cert_path)?,
+ std::fs::read_to_string(&key_path)?,
+ )
+ } else {
+ let (pem, key) = issue_server(&ca_pem, &ca_key_pem, &names)?;
+ std::fs::write(&cert_path, &pem)?;
+ std::fs::write(&key_path, &key)?;
+ std::fs::write(&names_path, &names_blob)?;
+ #[cfg(unix)]
+ {
+ use std::os::unix::fs::PermissionsExt;
+ let _ = std::fs::set_permissions(&key_path, std::fs::Permissions::from_mode(0o600));
+ }
+ (pem, key)
+ };
+
+ Ok(TlsMaterial {
+ ca_pem,
+ cert_pem,
+ key_pem,
+ names,
+ dir,
+ })
+}
+
+fn issue_ca() -> Result<(String, String)> {
+ let mut params = CertificateParams::default();
+ params.is_ca = IsCa::Ca(BasicConstraints::Unconstrained);
+ params.key_usages = vec![KeyUsagePurpose::KeyCertSign, KeyUsagePurpose::CrlSign];
+ let mut dn = DistinguishedName::new();
+ dn.push(DnType::CommonName, "LazyBoy Local CA");
+ params.distinguished_name = dn;
+ let key = KeyPair::generate().context("generate CA key")?;
+ let cert = params.self_signed(&key).context("self-sign CA")?;
+ Ok((cert.pem(), key.serialize_pem()))
+}
+
+fn issue_server(ca_pem: &str, ca_key_pem: &str, names: &[String]) -> Result<(String, String)> {
+ let ca_key = KeyPair::from_pem(ca_key_pem).context("load CA key")?;
+ let issuer_params =
+ CertificateParams::from_ca_cert_pem(ca_pem).context("parse CA certificate")?;
+ let issuer = issuer_params
+ .self_signed(&ca_key)
+ .context("rebuild CA issuer")?;
+ let dns: Vec = names
+ .iter()
+ .filter(|n| n.parse::().is_err())
+ .cloned()
+ .collect();
+ let mut params = CertificateParams::new(dns).context("server certificate names")?;
+ params.subject_alt_names = san_list(names);
+ params.key_usages = vec![
+ KeyUsagePurpose::DigitalSignature,
+ KeyUsagePurpose::KeyEncipherment,
+ ];
+ params.extended_key_usages = vec![ExtendedKeyUsagePurpose::ServerAuth];
+ let mut dn = DistinguishedName::new();
+ dn.push(DnType::CommonName, "LazyBoy");
+ params.distinguished_name = dn;
+ let key = KeyPair::generate().context("generate server key")?;
+ let cert = params
+ .signed_by(&key, &issuer, &ca_key)
+ .context("sign server certificate")?;
+ Ok((cert.pem(), key.serialize_pem()))
+}
+
+pub fn server_config(material: &TlsMaterial) -> Result> {
+ let _ = rustls::crypto::ring::default_provider().install_default();
+ let mut certs = Vec::new();
+ for item in rustls_pemfile::certs(&mut material.cert_pem.as_bytes()) {
+ certs.push(CertificateDer::from(item.context("parse server cert")?));
+ }
+ let mut ca_bytes = material.ca_pem.as_bytes();
+ if let Ok(ca) = rustls_pemfile::certs(&mut ca_bytes).collect::, _>>() {
+ for item in ca {
+ certs.push(CertificateDer::from(item));
+ }
+ }
+ let mut key_bytes = material.key_pem.as_bytes();
+ let mut keys = rustls_pemfile::pkcs8_private_keys(&mut key_bytes);
+ let key = keys
+ .next()
+ .ok_or_else(|| anyhow::anyhow!("server key missing"))?
+ .context("parse server key")?;
+ let mut config = ServerConfig::builder()
+ .with_no_client_auth()
+ .with_single_cert(certs, PrivateKeyDer::Pkcs8(key))
+ .context("rustls server config")?;
+ config.alpn_protocols = vec![b"http/1.1".to_vec()];
+ Ok(Arc::new(config))
+}
+
+/// Best-effort: install the CA in the user login keychain so Chrome shows a lock.
+pub fn trust_ca_best_effort(ca_pem: &Path) {
+ if !cfg!(target_os = "macos") {
+ return;
+ }
+ let keychain = dirs_login_keychain();
+ let already = std::process::Command::new("security")
+ .args(["find-certificate", "-c", "LazyBoy Local CA"])
+ .output()
+ .is_ok_and(|o| o.status.success());
+ if already {
+ return;
+ }
+ let Some(keychain) = keychain else {
+ return;
+ };
+ let _ = std::process::Command::new("security")
+ .args([
+ "add-trusted-cert",
+ "-d",
+ "-r",
+ "trustRoot",
+ "-k",
+ &keychain,
+ &ca_pem.to_string_lossy(),
+ ])
+ .status();
+}
+
+fn dirs_login_keychain() -> Option {
+ let home = std::env::var("HOME").ok()?;
+ let path = format!("{home}/Library/Keychains/login.keychain-db");
+ if Path::new(&path).exists() {
+ Some(path)
+ } else {
+ Some(format!("{home}/Library/Keychains/login.keychain"))
+ }
+}
+
+pub fn https_location(host: &str, path: &str, port: u16) -> String {
+ let host = host.trim();
+ let path = if path.is_empty() { "/" } else { path };
+ let with_port = if host.starts_with('[') {
+ if host.rfind("]:").is_some() {
+ host.to_string()
+ } else {
+ format!("{host}:{port}")
+ }
+ } else if host.matches(':').count() == 1 {
+ host.to_string()
+ } else {
+ format!("{host}:{port}")
+ };
+ format!("https://{with_port}{path}")
+}
+
+pub async fn redirect_plaintext(stream: tokio::net::TcpStream, port: u16) {
+ use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
+ let mut reader = BufReader::new(stream);
+ let mut first = String::new();
+ if reader.read_line(&mut first).await.unwrap_or(0) == 0 {
+ return;
+ }
+ let path = first
+ .split_whitespace()
+ .nth(1)
+ .unwrap_or("/")
+ .to_string();
+ let mut host = String::new();
+ loop {
+ let mut line = String::new();
+ let n = reader.read_line(&mut line).await.unwrap_or(0);
+ if n == 0 || line == "\r\n" || line == "\n" {
+ break;
+ }
+ if let Some(rest) = line.strip_prefix("Host:") {
+ host = rest.trim().to_string();
+ }
+ }
+ if host.is_empty() {
+ host = format!("127.0.0.1:{port}");
+ }
+ let location = https_location(&host, &path, port);
+ let response = format!(
+ "HTTP/1.1 308 Permanent Redirect\r\nLocation: {location}\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"
+ );
+ let mut stream = reader.into_inner();
+ let _ = stream.write_all(response.as_bytes()).await;
+ let _ = stream.shutdown().await;
+}
+
+pub fn is_tls_client_hello(first: u8) -> bool {
+ first == 0x16
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn https_location_keeps_port() {
+ assert_eq!(
+ https_location("10.0.0.8:8787", "/novnc/vnc.html", 8787),
+ "https://10.0.0.8:8787/novnc/vnc.html"
+ );
+ assert_eq!(
+ https_location("127.0.0.1", "/", 8787),
+ "https://127.0.0.1:8787/"
+ );
+ }
+
+ #[test]
+ fn issues_ca_and_server() {
+ let (ca, ca_key) = issue_ca().unwrap();
+ assert!(ca.contains("BEGIN CERTIFICATE"));
+ let (cert, key) = issue_server(&ca, &ca_key, &["localhost".into(), "127.0.0.1".into()]).unwrap();
+ assert!(cert.contains("BEGIN CERTIFICATE"));
+ assert!(key.contains("BEGIN PRIVATE KEY"));
+ let material = TlsMaterial {
+ ca_pem: ca,
+ cert_pem: cert,
+ key_pem: key,
+ names: vec!["localhost".into()],
+ dir: PathBuf::from("/tmp"),
+ };
+ server_config(&material).unwrap();
+ }
+}
diff --git a/crates/lazyboy-core/src/novnc_skin.rs b/crates/lazyboy-core/src/novnc_skin.rs
new file mode 100644
index 0000000..949a425
--- /dev/null
+++ b/crates/lazyboy-core/src/novnc_skin.rs
@@ -0,0 +1,414 @@
+//! Hide stock noVNC chrome, fill the iframe, and bridge clipboard.
+
+pub const NOVNC_SKIN: &str = r###"
+"###;
+
+pub fn inject(html: &[u8]) -> Vec {
+ let Ok(text) = std::str::from_utf8(html) else {
+ return html.to_vec();
+ };
+ if text.contains("lazyboy-novnc-skin") {
+ return html.to_vec();
+ }
+ if let Some(at) = text.rfind("