From 459e4f843219603e9f06f77ebf3ee59fc8261bae Mon Sep 17 00:00:00 2001 From: shihuili1218 Date: Wed, 2 Sep 2026 23:20:23 +0800 Subject: [PATCH 01/12] docs(roadmap): drop shipped mobile GPU toggle item --- roadmap.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/roadmap.md b/roadmap.md index f61f5e35..787715d6 100644 --- a/roadmap.md +++ b/roadmap.md @@ -15,7 +15,7 @@ - 移动端增加自定义键盘 - profile/forward 自定义icon - iOS快捷键盘,随着软键盘升起 -- 手机端增加gpu加速开关 + ![Stars](https://img.shields.io/github/stars/shihuili1218/rssh) ![Forks](https://img.shields.io/github/forks/shihuili1218/rssh) From a431123bf340c380f1d0833e9fcf39bf1562964f Mon Sep 17 00:00:00 2001 From: shihuili1218 Date: Wed, 2 Sep 2026 23:20:31 +0800 Subject: [PATCH 02/12] feat(plugins): sandboxed plugin system with side/strip regions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Third-party plugins are self-contained web packages installed from a zip. The only capability is a one-shot SSH exec channel brokered by the host — the same trust model as installing a shell script, no permission matrix. - Package: { manifest.json, index.html }; the manifest declares area side|strip, api 1. Install validates, guards zip-slip, unpacks under $APPDATA/plugins, registers in DB (v30). A side plugin picked from the strip's install entry (or vice versa) is rejected before extraction. - Host: sandboxed iframes (allow-scripts, opaque origin) plus a postMessage bridge — exec broker, theme tokens on hello, content-size notifications (the host cannot measure sandboxed content). Exec is one-shot on the tab's SSH session; telnet/serial/local tabs are gated off. - Regions: a plugin side column (left/right, per-tab keep-alive, cards sized to reported content height) and a strip bar (top/bottom, segments sized to reported width, scrolls horizontally). - Manager: Settings -> Plugins shows one combined stage — the final window composition. Drag-to-reorder, hover for enable/uninstall, per-region install entries, live previews (tokens travel via URL fragment). - Panel skeleton unified: new SidePanel.svelte chrome + panel-state factory shared by AI/SFTP/plugins. The per-tab width state (whose hand-rolled copies drifted into a frozen-drag bug) is now written exactly once; resize handles punch through iframes during gestures. --- src-tauri/Cargo.lock | 192 +++++- src-tauri/Cargo.toml | 6 +- src-tauri/src/commands/mod.rs | 1 + src-tauri/src/commands/plugin.rs | 819 +++++++++++++++++++++++ src-tauri/src/db/mod.rs | 1 + src-tauri/src/db/plugin.rs | 201 ++++++ src-tauri/src/db/schema.rs | 22 +- src-tauri/src/lib.rs | 8 + src-tauri/src/models.rs | 33 + src-tauri/src/server.rs | 51 ++ src-tauri/src/ssh/client.rs | 68 ++ src-tauri/tauri.conf.json | 6 +- src-tauri/tests/cli_contract.rs | 451 ------------- src/lib/ai/store.svelte.ts | 67 +- src/lib/components/AppShell.svelte | 486 ++++++++------ src/lib/components/SettingsLayout.svelte | 3 + src/lib/components/SidePanel.svelte | 136 ++++ src/lib/components/panel-widths.test.ts | 69 ++ src/lib/components/panel-widths.ts | 42 +- src/lib/i18n/index.svelte.ts | 33 +- src/lib/i18n/locales/en.ts | 29 + src/lib/i18n/locales/zh.ts | 29 + src/lib/plugins/PluginFrame.svelte | 125 ++++ src/lib/plugins/PluginManager.svelte | 565 ++++++++++++++++ src/lib/plugins/PluginSide.svelte | 164 +++++ src/lib/plugins/PluginStrip.svelte | 119 ++++ src/lib/plugins/bridge.test.ts | 134 ++++ src/lib/plugins/bridge.ts | 151 +++++ src/lib/plugins/layout.test.ts | 46 ++ src/lib/plugins/layout.ts | 45 ++ src/lib/plugins/store.svelte.ts | 174 +++++ src/lib/plugins/store.test.ts | 204 ++++++ src/lib/stores/app.svelte.ts | 65 +- src/lib/stores/panel-state.svelte.ts | 115 ++++ src/lib/stores/panel-state.test.ts | 106 +++ src/styles/global.css | 5 + 36 files changed, 3991 insertions(+), 780 deletions(-) create mode 100644 src-tauri/src/commands/plugin.rs create mode 100644 src-tauri/src/db/plugin.rs delete mode 100644 src-tauri/tests/cli_contract.rs create mode 100644 src/lib/components/SidePanel.svelte create mode 100644 src/lib/plugins/PluginFrame.svelte create mode 100644 src/lib/plugins/PluginManager.svelte create mode 100644 src/lib/plugins/PluginSide.svelte create mode 100644 src/lib/plugins/PluginStrip.svelte create mode 100644 src/lib/plugins/bridge.test.ts create mode 100644 src/lib/plugins/bridge.ts create mode 100644 src/lib/plugins/layout.test.ts create mode 100644 src/lib/plugins/layout.ts create mode 100644 src/lib/plugins/store.svelte.ts create mode 100644 src/lib/plugins/store.test.ts create mode 100644 src/lib/stores/panel-state.svelte.ts create mode 100644 src/lib/stores/panel-state.test.ts diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index a1b54990..28d1ffb6 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -168,6 +168,15 @@ version = "1.0.102" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" +[[package]] +name = "arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1" +dependencies = [ + "derive_arbitrary", +] + [[package]] name = "arboard" version = "3.6.1" @@ -442,7 +451,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "144e573728da132683b9488acd528274c790e07fc06ff81ee29f9d8f8b1041e0" dependencies = [ "blowfish", - "pbkdf2", + "pbkdf2 0.13.0", "sha2 0.11.0", ] @@ -617,6 +626,25 @@ dependencies = [ "serde", ] +[[package]] +name = "bzip2" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49ecfb22d906f800d4fe833b6282cf4dc1c298f5057ca0b5445e5c209735ca47" +dependencies = [ + "bzip2-sys", +] + +[[package]] +name = "bzip2-sys" +version = "0.1.13+1.0.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "225bff33b2141874fe80d71e07d6eec4f85c5c216453dd96388240f96e1acc14" +dependencies = [ + "cc", + "pkg-config", +] + [[package]] name = "cairo-rs" version = "0.18.5" @@ -969,6 +997,12 @@ dependencies = [ "tiny-keccak", ] +[[package]] +name = "constant_time_eq" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c74b8349d32d297c9134b8c88677813a227df8f779daa29bfc29c183fe3dca6" + [[package]] name = "convert_case" version = "0.4.0" @@ -1068,6 +1102,21 @@ dependencies = [ "libc", ] +[[package]] +name = "crc" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5eb8a2a1cd12ab0d987a5d5e825195d372001a4094a0376319d5a0ad71c1ba0d" +dependencies = [ + "crc-catalog", +] + +[[package]] +name = "crc-catalog" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "217698eaf96b4a3f0bc4f3662aaa55bdf913cd54d7204591faa790070c6d0853" + [[package]] name = "crc32fast" version = "1.5.0" @@ -1320,6 +1369,12 @@ dependencies = [ "zeroize", ] +[[package]] +name = "deflate64" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac6b926516df9c60bfa16e107b21086399f8285a44ca9711344b9e553c5146e2" + [[package]] name = "delegate" version = "0.13.5" @@ -1352,6 +1407,17 @@ dependencies = [ "serde_core", ] +[[package]] +name = "derive_arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "derive_more" version = "0.99.20" @@ -2664,6 +2730,12 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "http-range" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21dec9db110f5f872ed9699c3ecf50cf16f423502706ba5c72462e28d3157573" + [[package]] name = "httparse" version = "1.10.1" @@ -3422,6 +3494,27 @@ version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" +[[package]] +name = "lzma-rs" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "297e814c836ae64db86b36cf2a557ba54368d03f6afcd7d947c266692f71115e" +dependencies = [ + "byteorder", + "crc", +] + +[[package]] +name = "lzma-sys" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fda04ab3764e6cde78b9974eec4f779acaba7c4e84b36eca3cf77c581b85d27" +dependencies = [ + "cc", + "libc", + "pkg-config", +] + [[package]] name = "mac" version = "0.1.1" @@ -4237,6 +4330,16 @@ version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df94ce210e5bc13cb6651479fa48d14f601d9858cfe0467f43ae157023b938d3" +[[package]] +name = "pbkdf2" +version = "0.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ed6a7761f76e3b9f92dfb0a60a6a6477c61024b775147ff0973a02653abaf2" +dependencies = [ + "digest 0.10.7", + "hmac 0.12.1", +] + [[package]] name = "pbkdf2" version = "0.13.0" @@ -4488,7 +4591,7 @@ dependencies = [ "aes-gcm", "cbc 0.2.0", "der", - "pbkdf2", + "pbkdf2 0.13.0", "rand_core 0.10.1", "scrypt", "sha2 0.11.0", @@ -5291,6 +5394,7 @@ dependencies = [ "url", "uuid", "zeroize", + "zip", ] [[package]] @@ -5361,7 +5465,7 @@ dependencies = [ "p384", "p521", "pageant", - "pbkdf2", + "pbkdf2 0.13.0", "pkcs1", "pkcs5", "pkcs8", @@ -5609,7 +5713,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d87af57419b594aa23fa95f09f0e06d80d84ba01c26148c43844cad6ff4485f0" dependencies = [ "cfg-if", - "pbkdf2", + "pbkdf2 0.13.0", "salsa20", "sha2 0.11.0", ] @@ -6518,6 +6622,7 @@ dependencies = [ "gtk", "heck 0.5.0", "http", + "http-range", "jni 0.21.1", "libc", "log", @@ -8626,6 +8731,15 @@ dependencies = [ "markup5ever 0.12.1", ] +[[package]] +name = "xz2" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "388c44dc09d76f1536602ead6d325eb532f5c122f17782bd57fb47baeeb767e2" +dependencies = [ + "lzma-sys", +] + [[package]] name = "yoke" version = "0.8.2" @@ -8860,12 +8974,82 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "zip" +version = "2.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fabe6324e908f85a1c52063ce7aa26b68dcb7eb6dbc83a2d148403c9bc3eba50" +dependencies = [ + "aes 0.8.4", + "arbitrary", + "bzip2", + "constant_time_eq", + "crc32fast", + "crossbeam-utils", + "deflate64", + "displaydoc", + "flate2", + "getrandom 0.3.4", + "hmac 0.12.1", + "indexmap 2.13.1", + "lzma-rs", + "memchr", + "pbkdf2 0.12.2", + "sha1 0.10.6", + "thiserror 2.0.18", + "time", + "xz2", + "zeroize", + "zopfli", + "zstd", +] + [[package]] name = "zmij" version = "1.0.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" +[[package]] +name = "zopfli" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f05cd8797d63865425ff89b5c4a48804f35ba0ce8d125800027ad6017d2b5249" +dependencies = [ + "bumpalo", + "crc32fast", + "log", + "simd-adler32", +] + +[[package]] +name = "zstd" +version = "0.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e91ee311a569c327171651566e07972200e76fcfe2242a4fa446149a3881c08a" +dependencies = [ + "zstd-safe", +] + +[[package]] +name = "zstd-safe" +version = "7.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f49c4d5f0abb602a93fb8736af2a4f4dd9512e36f7f570d66e65ff867ed3b9d" +dependencies = [ + "zstd-sys", +] + +[[package]] +name = "zstd-sys" +version = "2.0.16+zstd.1.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e19ebc2adc8f83e43039e79776e3fda8ca919132d68a1fed6a5faca2683748" +dependencies = [ + "cc", + "pkg-config", +] + [[package]] name = "zune-core" version = "0.5.1" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 8d24c701..0f7cdc45 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -12,7 +12,7 @@ crate-type = ["staticlib", "cdylib", "lib"] tauri-build = { version = "2", features = [] } [dependencies] -tauri = { version = "2", features = [] } +tauri = { version = "2", features = ["protocol-asset"] } tauri-plugin-opener = "2" tauri-plugin-dialog = "2" tauri-plugin-fs = "2" @@ -46,6 +46,10 @@ include_dir = { version = "0.7", optional = true } # SFTP russh-sftp = "2" +# Plugin packages are user-supplied zips (manifest.json + index.html + assets). +# Default features on: accept any standard compression a packager might emit. +zip = "2" + # HTTP + crypto base64 = "0.22" # 配置备份/同步走 Argon2id(KDF)+ ChaCha20-Poly1305(AEAD), diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index 038d8548..abf0db41 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -6,6 +6,7 @@ pub mod external; pub mod forward; pub mod group; pub mod lifecycle; +pub mod plugin; pub mod profile; #[cfg(desktop)] pub mod pty; diff --git a/src-tauri/src/commands/plugin.rs b/src-tauri/src/commands/plugin.rs new file mode 100644 index 00000000..48326248 --- /dev/null +++ b/src-tauri/src/commands/plugin.rs @@ -0,0 +1,819 @@ +//! Third-party plugin packages: install/list/uninstall/enable + the one-shot +//! exec channel that is a plugin's ONLY way to interact with the host. +//! +//! A package is a zip of `{ manifest.json, index.html, assets/** }`. The UI is +//! hosted in a sandboxed iframe (see frontend PluginFrame); this module only +//! owns registration, safe on-disk extraction, and `plugin_exec`. + +use std::io::Read; +use std::path::{Component, Path, PathBuf}; + +use serde_json::json; +use tauri::State; + +use crate::error::{locked, AppError, AppResult}; +use crate::models::{Plugin, PluginExecResult}; +use crate::ssh::client::{self, SshHandle}; +use crate::state::{AppState, SessionKind, SessionOwner, SessionPhase}; + +// ── Package limits ────────────────────────────────────────────────────────── +// Generous for UI bundles, tight enough to make zip bombs boring. + +/// Compressed payload accepted from the webview (base64-decoded length). +const MAX_ZIP_BYTES: usize = 10 * 1024 * 1024; +/// Entry count cap — a UI bundle is dozens of files, not thousands. +const MAX_ENTRIES: usize = 500; +/// Per-file uncompressed cap; enforced via `take()` on the reader, not the +/// (spoofable) declared size in the zip header. +const MAX_FILE_UNCOMPRESSED: u64 = 32 * 1024 * 1024; +/// Total uncompressed cap across the archive. +const MAX_TOTAL_UNCOMPRESSED: u64 = 64 * 1024 * 1024; + +// ── Exec limits ───────────────────────────────────────────────────────────── + +const MAX_COMMAND_LEN: usize = 4096; +const DEFAULT_TIMEOUT_MS: u64 = 10_000; +const MIN_TIMEOUT_MS: u64 = 1_000; +const MAX_TIMEOUT_MS: u64 = 60_000; + +/// Absolute path of the on-disk plugin store: `/plugins`. +pub fn plugins_dir(state: &AppState) -> PathBuf { + state.data_dir.join("plugins") +} + +// ── Manifest ──────────────────────────────────────────────────────────────── + +/// `manifest.json` inside a plugin zip, before it becomes a DB `Plugin` row. +#[derive(Debug, Clone, serde::Deserialize)] +pub struct PluginManifest { + pub id: String, + pub name: String, + pub version: String, + #[serde(default)] + pub description: String, + #[serde(default)] + pub author: String, + /// Host region: "side" | "strip". + pub area: String, + /// Optional package-relative path to a preview document shown on the + /// manager page (e.g. "preview.html"). + #[serde(default)] + pub preview: String, + /// Bridge protocol version the plugin speaks. + pub api: u32, +} + +/// Plugin id doubles as the install directory name — a strict lowercase slug +/// is the whole path-safety story for `plugins/`. +pub fn valid_plugin_id(id: &str) -> bool { + (2..=64).contains(&id.len()) + && id + .bytes() + .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-') + && !id.starts_with('-') + && !id.ends_with('-') +} + +/// Field-level validation; every rejection names the offending field. +pub fn validate_manifest(m: &PluginManifest) -> AppResult<()> { + let bad = |field: &str, reason: &str| { + AppError::config( + "plugin_manifest_invalid", + json!({ "field": field, "reason": reason }), + ) + }; + if !valid_plugin_id(&m.id) { + return Err(bad( + "id", + "expected 2-64 chars of [a-z0-9-], no leading/trailing '-'", + )); + } + let text_field = |v: &str, field: &'static str, max: usize| -> AppResult<()> { + let t = v.trim(); + if t.is_empty() { + return Err(bad(field, "must not be empty")); + } + if t.chars().count() > max { + return Err(bad(field, &format!("too long (max {max} chars)"))); + } + Ok(()) + }; + text_field(&m.name, "name", 100)?; + text_field(&m.version, "version", 32)?; + if m.description.chars().count() > 2000 { + return Err(bad("description", "too long (max 2000 chars)")); + } + if m.author.chars().count() > 100 { + return Err(bad("author", "too long (max 100 chars)")); + } + if m.area != "side" && m.area != "strip" { + return Err(bad("area", "expected \"side\" or \"strip\"")); + } + if !m.preview.is_empty() && (m.preview.chars().count() > 200 || !safe_entry_name(&m.preview)) { + return Err(bad( + "preview", + "expected a relative path inside the package", + )); + } + if m.api != 1 { + return Err(bad( + "api", + "unsupported protocol version (this host speaks 1)", + )); + } + Ok(()) +} + +// ── Safe extraction ───────────────────────────────────────────────────────── + +/// Entry names must be relative, backslash-free, and free of `..`/`.`/prefix +/// components — anything else is a zip-slip attempt. +fn safe_entry_name(name: &str) -> bool { + if name.is_empty() || name.contains('\\') { + return false; + } + let path = Path::new(name); + path.is_relative() + && !path.components().any(|c| { + matches!( + c, + Component::ParentDir | Component::CurDir | Component::Prefix(_) + ) + }) +} + +/// A symlink inside a plugin dir could point anywhere on disk; the iframe only +/// reads today, but future capabilities may not. zip's own S_IFLNK detection. +fn entry_is_symlink(entry: &zip::read::ZipFile<'_>) -> bool { + entry.is_symlink() +} + +fn read_entry( + archive: &mut zip::ZipArchive>, + name: &str, +) -> AppResult> { + let entry = archive + .by_name(name) + .map_err(|e| zip_err("read_entry", e))?; + let mut buf = Vec::new(); + entry + .take(MAX_FILE_UNCOMPRESSED + 1) + .read_to_end(&mut buf)?; + Ok(buf) +} + +/// macOS Finder zips carry `__MACOSX/` metadata and `.DS_Store` noise; skip. +fn is_junk_entry(name: &str) -> bool { + name.starts_with("__MACOSX/") || name.ends_with("/.DS_Store") || name == ".DS_Store" +} + +fn zip_err(op: &'static str, e: zip::result::ZipError) -> AppError { + AppError::config( + "plugin_zip_invalid", + json!({ "op": op, "err": e.to_string() }), + ) +} + +/// Extract `archive` into `dest` with all caps enforced. Rejecting one bad +/// entry rejects the whole install — a half-trusted bundle is worse than none. +pub fn extract_plugin_zip( + archive: &mut zip::ZipArchive>, + dest: &Path, +) -> AppResult<()> { + if archive.len() > MAX_ENTRIES { + return Err(AppError::config( + "plugin_zip_invalid", + json!({ "op": "entries", "count": archive.len() }), + )); + } + std::fs::create_dir_all(dest)?; + let mut total: u64 = 0; + for i in 0..archive.len() { + let entry = archive.by_index(i).map_err(|e| zip_err("read_entry", e))?; + let name = entry.name().to_owned(); + if is_junk_entry(&name) { + continue; + } + if !safe_entry_name(&name) { + return Err(AppError::config( + "plugin_zip_invalid", + json!({ "op": "entry_name", "name": name }), + )); + } + if entry_is_symlink(&entry) { + return Err(AppError::config( + "plugin_zip_invalid", + json!({ "op": "symlink", "name": name }), + )); + } + let out_path = dest.join(&name); + if entry.is_dir() { + std::fs::create_dir_all(&out_path)?; + continue; + } + if let Some(parent) = out_path.parent() { + std::fs::create_dir_all(parent)?; + } + // `take` caps the actual bytes read; the header's declared size is + // advisory from an attacker's point of view. + let mut buf = Vec::new(); + entry + .take(MAX_FILE_UNCOMPRESSED + 1) + .read_to_end(&mut buf)?; + if buf.len() as u64 > MAX_FILE_UNCOMPRESSED { + return Err(AppError::config( + "plugin_zip_invalid", + json!({ "op": "file_too_large", "name": name }), + )); + } + total += buf.len() as u64; + if total > MAX_TOTAL_UNCOMPRESSED { + return Err(AppError::config( + "plugin_zip_invalid", + json!({ "op": "total_too_large", "name": name }), + )); + } + std::fs::write(&out_path, &buf)?; + } + Ok(()) +} + +/// Install (or upgrade) a plugin from raw zip bytes. `expected_area` is the +/// region the install was triggered from ("side"/"strip"): a plugin whose +/// manifest declares the other area is rejected before anything touches disk. +/// Extraction goes to a staging dir first; only a fully valid package +/// replaces the install dir. +pub fn install_impl( + state: &AppState, + zip_bytes: &[u8], + expected_area: Option<&str>, +) -> AppResult { + if zip_bytes.len() > MAX_ZIP_BYTES { + return Err(AppError::config( + "plugin_zip_invalid", + json!({ "op": "zip_too_large", "size": zip_bytes.len() }), + )); + } + let mut archive = + zip::ZipArchive::new(std::io::Cursor::new(zip_bytes)).map_err(|e| zip_err("open", e))?; + + let manifest_bytes = read_entry(&mut archive, "manifest.json")?; + let manifest: PluginManifest = serde_json::from_slice(&manifest_bytes).map_err(|e| { + AppError::config( + "plugin_manifest_invalid", + json!({ "field": "manifest.json", "reason": e.to_string() }), + ) + })?; + validate_manifest(&manifest)?; + if let Some(expected) = expected_area { + if manifest.area != expected { + return Err(AppError::config( + "plugin_area_mismatch", + json!({ "expected": expected, "actual": manifest.area }), + )); + } + } + if !entry_exists(&mut archive, "index.html") { + return Err(AppError::config( + "plugin_manifest_invalid", + json!({ "field": "index.html", "reason": "missing from package" }), + )); + } + if !manifest.preview.is_empty() && !entry_exists(&mut archive, &manifest.preview) { + return Err(AppError::config( + "plugin_manifest_invalid", + json!({ "field": "preview", "reason": "declared but missing from package" }), + )); + } + + let root = plugins_dir(state); + std::fs::create_dir_all(&root)?; + let staging = root.join(format!(".staging-{}", uuid::Uuid::new_v4())); + if let Err(e) = extract_plugin_zip(&mut archive, &staging) { + let _ = std::fs::remove_dir_all(&staging); + return Err(e); + } + + let final_dir = root.join(&manifest.id); + if final_dir.exists() { + std::fs::remove_dir_all(&final_dir)?; + } + std::fs::rename(&staging, &final_dir)?; + + let plugin = Plugin { + id: manifest.id, + name: manifest.name.trim().to_owned(), + version: manifest.version.trim().to_owned(), + description: manifest.description.trim().to_owned(), + author: manifest.author.trim().to_owned(), + area: manifest.area, + preview: manifest.preview.trim().to_owned(), + enabled: true, + installed_at: chrono::Utc::now().timestamp(), + // New installs append to the end of their area; upgrades keep the + // stored sort_order (upsert does not touch it). + sort_order: crate::db::plugin::next_sort_order(&state.db)?, + }; + crate::db::plugin::upsert(&state.db, &plugin)?; + // Return the DB row, not the local struct: an upgrade preserves the old + // `enabled` (upsert semantics), and the caller must see that truth. + crate::db::plugin::get(&state.db, &plugin.id)?.ok_or_else(|| { + AppError::other( + "session_registry_inconsistent", + json!({ "op": "plugin_upsert_vanished", "id": plugin.id }), + ) + }) +} + +fn entry_exists(archive: &mut zip::ZipArchive>, name: &str) -> bool { + archive.by_name(name).is_ok() +} + +// ── Session lookup for exec ───────────────────────────────────────────────── + +/// Where a plugin command runs: an SSH exec channel on the tab's connection, +/// or a local child process for local-shell tabs. Telnet/serial tabs have +/// neither — the plugin capability contract covers SSH + local only. +enum ExecTransport { + Ssh(SshHandle), + #[cfg(desktop)] + Local, +} + +fn exec_transport( + state: &AppState, + session_id: &str, + requester: &SessionOwner, +) -> AppResult { + let registry = locked(&state.lifecycle_sessions)?; + let record = registry + .get(session_id) + .ok_or_else(|| AppError::not_found("plugin_no_exec", json!({ "id": session_id })))?; + if record.phase != SessionPhase::Ready { + return Err(AppError::not_found( + "plugin_no_exec", + json!({ "id": session_id, "kind": format!("{:?}", record.kind) }), + )); + } + if &record.owner != requester { + return Err(AppError::config( + "session_owner_mismatch", + json!({ "id": session_id }), + )); + } + match record.kind { + SessionKind::Ssh => { + let handle = locked(&state.sessions)? + .get(session_id) + .map(|h| h.ssh_handle().clone()) + .ok_or_else(|| { + AppError::other("session_registry_inconsistent", json!({ "id": session_id })) + })?; + Ok(ExecTransport::Ssh(handle)) + } + // Local shell tab: run on this machine. The PTY handle itself is not + // needed — a fresh child process per call, same one-shot contract. + #[cfg(desktop)] + SessionKind::Pty => Ok(ExecTransport::Local), + kind => Err(AppError::not_found( + "plugin_no_exec", + json!({ "id": session_id, "kind": format!("{kind:?}") }), + )), + } +} + +/// One-shot local command, mirroring `ssh::client::exec_once` semantics +/// (timeout, 256 KB per stream). `kill_on_drop` makes the timeout path kill +/// the child: dropping the collection future drops the Child, which kills +/// the process — no orphaned `cat /dev/zero` burners. +#[cfg(desktop)] +async fn local_exec(command: &str, timeout: std::time::Duration) -> AppResult { + const CAP: u64 = 256 * 1024; + + #[cfg(unix)] + let mut cmd = { + let mut c = tokio::process::Command::new("/bin/sh"); + c.arg("-c"); + c + }; + #[cfg(windows)] + let mut cmd = { + let mut c = tokio::process::Command::new("cmd"); + c.arg("/C"); + c + }; + cmd.stdin(std::process::Stdio::null()) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + .kill_on_drop(true) + .arg(command); + let mut child = cmd.spawn().map_err(|e| { + AppError::other("plugin_exec_spawn_failed", json!({ "err": e.to_string() })) + })?; + let out_pipe = child.stdout.take(); + let err_pipe = child.stderr.take(); + + async fn read_capped(pipe: Option) -> Vec { + use tokio::io::AsyncReadExt as _; + let mut buf = Vec::new(); + if let Some(r) = pipe { + let mut capped = r.take(CAP); + let _ = capped.read_to_end(&mut buf).await; + } + buf + } + + let collect = async { + let (out, err) = tokio::join!(read_capped(out_pipe), read_capped(err_pipe)); + let status = child.wait().await; + (out, err, status) + }; + match tokio::time::timeout(timeout, collect).await { + Ok((out, err, status)) => Ok(PluginExecResult { + stdout: String::from_utf8_lossy(&out).into_owned(), + stderr: String::from_utf8_lossy(&err).into_owned(), + exit_code: status.ok().and_then(|s| s.code()), + }), + Err(_) => Err(AppError::other( + "plugin_exec_timeout", + json!({ "millis": timeout.as_millis() as u64 }), + )), + } +} + +/// Shared exec core (Tauri command + headless server dispatch both call this). +pub async fn plugin_exec_impl( + state: &AppState, + owner: &SessionOwner, + session_id: String, + command: String, + timeout_ms: Option, +) -> AppResult { + if command.len() > MAX_COMMAND_LEN { + return Err(AppError::config( + "plugin_exec_command_too_long", + json!({ "len": command.len() }), + )); + } + let timeout = std::time::Duration::from_millis( + timeout_ms + .unwrap_or(DEFAULT_TIMEOUT_MS) + .clamp(MIN_TIMEOUT_MS, MAX_TIMEOUT_MS), + ); + match exec_transport(state, &session_id, owner)? { + ExecTransport::Ssh(handle) => { + let ssh = handle.clone(); + let cmd = command; + client::run_blocking_ssh( + move || async move { client::exec_once(&ssh, &cmd, timeout).await }, + ) + .await + } + #[cfg(desktop)] + ExecTransport::Local => local_exec(&command, timeout).await, + } +} + +// ── Tauri commands ────────────────────────────────────────────────────────── + +#[tauri::command] +pub fn plugins_root(state: State<'_, AppState>) -> AppResult { + Ok(plugins_dir(&state).to_string_lossy().into_owned()) +} + +#[tauri::command] +pub fn install_plugin( + state: State<'_, AppState>, + base64_zip: String, + area: Option, +) -> AppResult { + use base64::{engine::general_purpose::STANDARD, Engine}; + let bytes = STANDARD.decode(base64_zip.trim()).map_err(|e| { + AppError::config( + "crypto_base64_decode_failed", + json!({ "err": e.to_string() }), + ) + })?; + install_impl(&state, &bytes, area.as_deref()) +} + +#[tauri::command] +pub fn list_plugins(state: State<'_, AppState>) -> AppResult> { + crate::db::plugin::list(&state.db) +} + +#[tauri::command] +pub fn set_plugin_enabled(state: State<'_, AppState>, id: String, enabled: bool) -> AppResult<()> { + crate::db::plugin::set_enabled(&state.db, &id, enabled) +} + +/// Rewrite one area's order from the manager page (full ordered id list). +#[tauri::command] +pub fn set_plugin_order(state: State<'_, AppState>, ids: Vec) -> AppResult<()> { + for id in &ids { + if !valid_plugin_id(id) { + return Err(AppError::config( + "plugin_manifest_invalid", + json!({ "field": "id", "reason": "not a plugin id" }), + )); + } + } + crate::db::plugin::set_order(&state.db, &ids) +} + +#[tauri::command] +pub fn uninstall_plugin(state: State<'_, AppState>, id: String) -> AppResult<()> { + if !valid_plugin_id(&id) { + return Err(AppError::config( + "plugin_manifest_invalid", + json!({ "field": "id", "reason": "not a plugin id" }), + )); + } + let dir = plugins_dir(&state).join(&id); + if dir.exists() { + std::fs::remove_dir_all(&dir)?; + } + crate::db::plugin::delete(&state.db, &id) +} + +#[tauri::command] +pub async fn plugin_exec( + window: tauri::Window, + state: State<'_, AppState>, + session_id: String, + command: String, + timeout_ms: Option, +) -> AppResult { + plugin_exec_impl( + &state, + &SessionOwner::Window(window.label().to_owned()), + session_id, + command, + timeout_ms, + ) + .await +} + +#[cfg(test)] +mod tests { + use super::*; + use std::io::Write; + + fn build_zip(files: &[(&str, &str)]) -> Vec { + let mut w = zip::ZipWriter::new(std::io::Cursor::new(Vec::new())); + for (name, body) in files { + w.start_file((*name).to_owned(), zip::write::SimpleFileOptions::default()) + .unwrap(); + w.write_all(body.as_bytes()).unwrap(); + } + let cursor = w.finish().unwrap(); + cursor.into_inner() + } + + /// zip 2.x's writer masks permissions to 0o777, so a symlink entry can + /// only be produced through the dedicated API — same as a hostile packager. + fn build_zip_with_symlink() -> Vec { + let mut w = zip::ZipWriter::new(std::io::Cursor::new(Vec::new())); + w.add_symlink( + "link", + "/etc/passwd", + zip::write::SimpleFileOptions::default(), + ) + .unwrap(); + let cursor = w.finish().unwrap(); + cursor.into_inner() + } + + fn manifest_json(id: &str, area: &str) -> String { + format!( + r#"{{"id":"{id}","name":"Mon","version":"1.0.0","description":"d","author":"a","area":"{area}","api":1}}"# + ) + } + + fn open(bytes: &[u8]) -> zip::ZipArchive> { + zip::ZipArchive::new(std::io::Cursor::new(bytes)).unwrap() + } + + #[cfg(desktop)] + #[tokio::test] + async fn local_exec_runs_command_and_captures_output() { + let result = local_exec( + "echo hi; echo oops 1>&2; exit 7", + std::time::Duration::from_secs(5), + ) + .await + .unwrap(); + assert_eq!(result.stdout.trim(), "hi"); + assert_eq!(result.stderr.trim(), "oops"); + assert_eq!(result.exit_code, Some(7)); + } + + #[cfg(desktop)] + #[tokio::test] + async fn local_exec_times_out_and_kills() { + let start = std::time::Instant::now(); + let err = local_exec("sleep 30", std::time::Duration::from_secs(1)) + .await + .unwrap_err(); + assert_eq!(err.code(), "plugin_exec_timeout"); + // kill_on_drop fired — we must not have waited for the sleeper. + assert!(start.elapsed() < std::time::Duration::from_secs(5)); + } + + #[test] + fn preview_declared_but_missing_is_rejected() { + let state = test_state(); + let manifest = manifest_json("mon", "side") + .replace("\"api\":1", "\"preview\":\"preview.html\",\"api\":1"); + let bytes = build_zip(&[("manifest.json", &manifest), ("index.html", "x")]); + let err = install_impl(&state, &bytes, None).unwrap_err(); + assert_eq!(err.code(), "plugin_manifest_invalid"); + + // Declared AND present → install succeeds and the row carries it. + let bytes = build_zip(&[ + ("manifest.json", &manifest), + ("index.html", "x"), + ("preview.html", ""), + ]); + let plugin = install_impl(&state, &bytes, None).unwrap(); + assert_eq!(plugin.preview, "preview.html"); + } + + #[test] + fn plugin_id_rules() { + assert!(valid_plugin_id("mon")); + assert!(valid_plugin_id("rssh-plugin-monitor")); + assert!(!valid_plugin_id("a")); + assert!(!valid_plugin_id("-mon")); + assert!(!valid_plugin_id("Mon")); + assert!(!valid_plugin_id("mon_x")); + assert!(!valid_plugin_id("../evil")); + assert!(!valid_plugin_id("")); + } + + #[test] + fn manifest_validation_rejects_bad_area_and_api() { + let parse = |s: &str| serde_json::from_str::(s).unwrap(); + let ok = parse(&manifest_json("mon", "side")); + assert!(validate_manifest(&ok).is_ok()); + + let bad_area = parse(&manifest_json("mon", "corner")); + assert!(validate_manifest(&bad_area).is_err()); + + let bad_api = parse(&manifest_json("mon", "side").replace("\"api\":1", "\"api\":2")); + assert!(validate_manifest(&bad_api).is_err()); + + let no_name = serde_json::from_str::( + r#"{"id":"mon","name":"","version":"1","area":"side","api":1}"#, + ) + .unwrap(); + assert!(validate_manifest(&no_name).is_err()); + } + + #[test] + fn extraction_copies_files() { + let bytes = build_zip(&[ + ("manifest.json", &manifest_json("mon", "side")), + ("index.html", ""), + ("assets/app.js", "console.log(1)"), + ]); + let dir = tempfile::tempdir().unwrap(); + extract_plugin_zip(&mut open(&bytes), dir.path()).unwrap(); + assert!(dir.path().join("index.html").exists()); + assert!(dir.path().join("assets/app.js").exists()); + } + + #[test] + fn extraction_rejects_parent_dir_escape() { + for name in ["../evil.txt", "a/../../evil.txt"] { + let bytes = build_zip(&[(name, "x")]); + let dir = tempfile::tempdir().unwrap(); + let err = extract_plugin_zip(&mut open(&bytes), dir.path()).unwrap_err(); + assert_eq!(err.code(), "plugin_zip_invalid"); + // Nothing may land outside dest — or inside it either. + assert!(!dir.path().parent().unwrap().join("evil.txt").exists()); + } + } + + #[test] + fn extraction_rejects_symlink_entries() { + let bytes = build_zip_with_symlink(); + let dir = tempfile::tempdir().unwrap(); + let err = extract_plugin_zip(&mut open(&bytes), dir.path()).unwrap_err(); + assert_eq!(err.code(), "plugin_zip_invalid"); + } + + #[test] + fn extraction_skips_macos_junk() { + let bytes = build_zip(&[("__MACOSX/meta", "junk"), ("index.html", "ok")]); + let dir = tempfile::tempdir().unwrap(); + extract_plugin_zip(&mut open(&bytes), dir.path()).unwrap(); + assert!(!dir.path().join("__MACOSX").exists()); + assert!(dir.path().join("index.html").exists()); + } + + #[test] + fn install_impl_rejects_zip_without_manifest() { + let state = test_state(); + let bytes = build_zip(&[("index.html", "")]); + let err = install_impl(&state, &bytes, None).unwrap_err(); + assert_eq!(err.code(), "plugin_zip_invalid"); + } + + #[test] + fn install_impl_round_trip_and_uninstall_dir() { + let state = test_state(); + let bytes = build_zip(&[ + ("manifest.json", &manifest_json("demo-mon", "side")), + ("index.html", "ok"), + ]); + let plugin = install_impl(&state, &bytes, None).unwrap(); + assert_eq!(plugin.id, "demo-mon"); + assert_eq!(plugin.area, "side"); + let dir = plugins_dir(&state).join("demo-mon"); + assert!(dir.join("index.html").exists()); + + let listed = crate::db::plugin::list(&state.db).unwrap(); + assert_eq!(listed.len(), 1); + assert_eq!(listed[0].id, "demo-mon"); + + std::fs::remove_dir_all(&dir).unwrap(); + assert!(!dir.exists()); + } + + #[test] + fn install_impl_upgrades_same_id() { + let state = test_state(); + let v1 = build_zip(&[ + ("manifest.json", &manifest_json("demo-mon", "side")), + ("index.html", "v1"), + ]); + install_impl(&state, &v1, None).unwrap(); + crate::db::plugin::set_enabled(&state.db, "demo-mon", false).unwrap(); + + let v2 = build_zip(&[ + ( + "manifest.json", + &manifest_json("demo-mon", "side").replace("1.0.0", "2.0.0"), + ), + ("index.html", "v2"), + ]); + let upgraded = install_impl(&state, &v2, None).unwrap(); + assert_eq!(upgraded.version, "2.0.0"); + // Upgrade keeps the user's disabled state (db upsert semantics). + assert!(!upgraded.enabled); + let dir = plugins_dir(&state).join("demo-mon"); + let html = std::fs::read_to_string(dir.join("index.html")).unwrap(); + assert_eq!(html, "v2"); + } + + #[test] + fn install_impl_rejects_area_mismatch() { + let state = test_state(); + // A side package offered to the strip region's install entry: rejected + // before extraction — nothing lands on disk or in the DB. + let bytes = build_zip(&[ + ("manifest.json", &manifest_json("demo-mon", "side")), + ("index.html", "x"), + ]); + let err = install_impl(&state, &bytes, Some("strip")).unwrap_err(); + assert_eq!(err.code(), "plugin_area_mismatch"); + assert!(crate::db::plugin::list(&state.db).unwrap().is_empty()); + assert!(!plugins_dir(&state).join("demo-mon").exists()); + + // Matching area installs fine. + install_impl(&state, &bytes, Some("side")).unwrap(); + assert_eq!(crate::db::plugin::list(&state.db).unwrap().len(), 1); + } + + /// AppState without a real Tauri app: enough fields for install_impl. + /// Same construction as lifecycle.rs's `empty_state`, but with a real + /// tempdir so extracted files land somewhere inspectable. + fn test_state() -> AppState { + let db = std::sync::Arc::new(crate::db::Db::open_in_memory().unwrap()); + let secret_store: std::sync::Arc = + std::sync::Arc::new(crate::secret::DbStore::new(db.clone())); + AppState { + db, + secret_store, + lifecycle_sessions: Default::default(), + sessions: Default::default(), + #[cfg(desktop)] + pty_sessions: Default::default(), + #[cfg(desktop)] + serial_sessions: Default::default(), + telnet_sessions: Default::default(), + sftp_sessions: Default::default(), + transfer_cancels: Default::default(), + active_forwards: Default::default(), + auth_waiters: Default::default(), + passphrase_waiters: Default::default(), + host_key_waiters: Default::default(), + passphrase_cache: Default::default(), + ai_sessions: Default::default(), + ai_session_owners: Default::default(), + ai_remote_shell_cache: Default::default(), + data_dir: tempfile::tempdir().unwrap().keep(), + } + } +} diff --git a/src-tauri/src/db/mod.rs b/src-tauri/src/db/mod.rs index 89267f56..f676f602 100644 --- a/src-tauri/src/db/mod.rs +++ b/src-tauri/src/db/mod.rs @@ -8,6 +8,7 @@ pub mod credential; pub mod forward; pub mod group; pub mod highlight; +pub mod plugin; pub mod profile; pub mod schema; pub mod secret; diff --git a/src-tauri/src/db/plugin.rs b/src-tauri/src/db/plugin.rs new file mode 100644 index 00000000..f3499a54 --- /dev/null +++ b/src-tauri/src/db/plugin.rs @@ -0,0 +1,201 @@ +use rusqlite::params; + +use super::Db; +use crate::error::AppResult; +use crate::models::Plugin; + +const COLS: &str = + "id, name, version, description, author, area, preview, enabled, installed_at, sort_order"; + +fn row_to_plugin(row: &rusqlite::Row<'_>) -> rusqlite::Result { + Ok(Plugin { + id: row.get(0)?, + name: row.get(1)?, + version: row.get(2)?, + description: row.get(3)?, + author: row.get(4)?, + area: row.get(5)?, + preview: row.get(6)?, + enabled: row.get::<_, i64>(7)? != 0, + installed_at: row.get(8)?, + sort_order: row.get(9)?, + }) +} + +pub fn list(db: &Db) -> AppResult> { + let conn = db.lock()?; + let mut stmt = conn.prepare(&format!( + "SELECT {COLS} FROM plugins ORDER BY area ASC, sort_order ASC, id ASC" + ))?; + let rows = stmt.query_map([], |row| row_to_plugin(row))?; + Ok(rows.collect::, _>>()?) +} + +pub fn get(db: &Db, id: &str) -> AppResult> { + let conn = db.lock()?; + let result = conn.query_row( + &format!("SELECT {COLS} FROM plugins WHERE id = ?1"), + params![id], + |row| row_to_plugin(row), + ); + match result { + Ok(plugin) => Ok(Some(plugin)), + Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None), + Err(e) => Err(e.into()), + } +} + +/// Insert or replace: installing the same id again is an upgrade (dir contents +/// were already swapped on disk by the caller before this runs). `enabled` +/// and `sort_order` are deliberately NOT in the update set — an upgrade keeps +/// the user's toggle state and position. +pub fn upsert(db: &Db, plugin: &Plugin) -> AppResult<()> { + let conn = db.lock()?; + conn.execute( + "INSERT INTO plugins (id, name, version, description, author, area, preview, enabled, installed_at, sort_order) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10) + ON CONFLICT(id) DO UPDATE SET + name = ?2, version = ?3, description = ?4, author = ?5, area = ?6, + preview = ?7, installed_at = ?9", + params![ + plugin.id, + plugin.name, + plugin.version, + plugin.description, + plugin.author, + plugin.area, + plugin.preview, + plugin.enabled as i64, + plugin.installed_at, + plugin.sort_order, + ], + )?; + Ok(()) +} + +/// Remove a row. Preserves nothing — `enabled` state dies with the install; +/// a reinstall comes back enabled (fresh trust decision by the user). +pub fn delete(db: &Db, id: &str) -> AppResult<()> { + let conn = db.lock()?; + conn.execute("DELETE FROM plugins WHERE id = ?1", params![id])?; + Ok(()) +} + +pub fn set_enabled(db: &Db, id: &str, enabled: bool) -> AppResult<()> { + let conn = db.lock()?; + conn.execute( + "UPDATE plugins SET enabled = ?2 WHERE id = ?1", + params![id, enabled as i64], + )?; + Ok(()) +} + +/// One past the current maximum, so each new install appends to its area. +pub fn next_sort_order(db: &Db) -> AppResult { + let conn = db.lock()?; + Ok(conn.query_row( + "SELECT COALESCE(MAX(sort_order), -1) + 1 FROM plugins", + [], + |row| row.get(0), + )?) +} + +/// Rewrite the order within one area from a full ordered id list (the manager +/// page computes the new sequence after a move; unknown ids are ignored). +pub fn set_order(db: &Db, ids: &[String]) -> AppResult<()> { + let conn = db.lock()?; + for (idx, id) in ids.iter().enumerate() { + conn.execute( + "UPDATE plugins SET sort_order = ?2 WHERE id = ?1", + params![id, idx as i64], + )?; + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn sample(id: &str, sort_order: i64) -> Plugin { + Plugin { + id: id.to_owned(), + name: "Monitor".into(), + version: "1.0.0".into(), + description: "desc".into(), + author: "a".into(), + area: "side".into(), + preview: "preview.html".into(), + enabled: true, + installed_at: 42, + sort_order, + } + } + + #[test] + fn empty_db_lists_nothing() { + let db = Db::open_in_memory().unwrap(); + assert!(list(&db).unwrap().is_empty()); + } + + #[test] + fn upsert_then_get_round_trip() { + let db = Db::open_in_memory().unwrap(); + upsert(&db, &sample("mon", 0)).unwrap(); + let p = get(&db, "mon").unwrap().unwrap(); + assert_eq!(p.name, "Monitor"); + assert_eq!(p.area, "side"); + assert_eq!(p.preview, "preview.html"); + assert!(p.enabled); + } + + #[test] + fn reinstall_upgrades_but_keeps_enabled_and_order() { + // ON CONFLICT keeps the old `enabled`/`sort_order` — an upgrade must + // not silently re-enable a plugin the user turned off, or teleport it + // to the end of the list. + let db = Db::open_in_memory().unwrap(); + upsert(&db, &sample("mon", 3)).unwrap(); + set_enabled(&db, "mon", false).unwrap(); + let mut upgraded = sample("mon", 0); + upgraded.version = "2.0.0".into(); + upsert(&db, &upgraded).unwrap(); + let got = get(&db, "mon").unwrap().unwrap(); + assert_eq!(got.version, "2.0.0"); + assert!(!got.enabled); + assert_eq!(got.sort_order, 3); + } + + #[test] + fn set_order_rewrites_sequence() { + let db = Db::open_in_memory().unwrap(); + for (i, id) in ["a", "b", "c"].into_iter().enumerate() { + upsert(&db, &sample(id, i as i64)).unwrap(); + } + set_order(&db, &["c".into(), "a".into(), "b".into()]).unwrap(); + let ids: Vec = list(&db) + .unwrap() + .into_iter() + .filter(|p| p.area == "side") + .map(|p| p.id) + .collect(); + assert_eq!(ids, vec!["c", "a", "b"]); + } + + #[test] + fn next_sort_order_appends() { + let db = Db::open_in_memory().unwrap(); + assert_eq!(next_sort_order(&db).unwrap(), 0); + upsert(&db, &sample("a", 0)).unwrap(); + upsert(&db, &sample("b", 5)).unwrap(); + assert_eq!(next_sort_order(&db).unwrap(), 6); + } + + #[test] + fn delete_removes_row() { + let db = Db::open_in_memory().unwrap(); + upsert(&db, &sample("mon", 0)).unwrap(); + delete(&db, "mon").unwrap(); + assert!(get(&db, "mon").unwrap().is_none()); + } +} diff --git a/src-tauri/src/db/schema.rs b/src-tauri/src/db/schema.rs index f39f9c9a..d0bad7d3 100644 --- a/src-tauri/src/db/schema.rs +++ b/src-tauri/src/db/schema.rs @@ -2,7 +2,7 @@ use rusqlite::{params, Connection}; use crate::error::AppResult; -const SCHEMA_VERSION: u32 = 29; +const SCHEMA_VERSION: u32 = 30; fn column_exists(conn: &Connection, table: &str, col: &str) -> AppResult { let mut stmt = conn.prepare("SELECT 1 FROM pragma_table_info(?1) WHERE name = ?2")?; @@ -647,6 +647,26 @@ pub fn migrate(conn: &Connection) -> AppResult<()> { } } + if version < 30 { + // Installed third-party plugins. The package itself lives on disk at + // `/plugins//`; this table only tracks registration and + // the manifest fields the manager page displays. + conn.execute_batch( + "CREATE TABLE IF NOT EXISTS plugins ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + version TEXT NOT NULL, + description TEXT NOT NULL DEFAULT '', + author TEXT NOT NULL DEFAULT '', + area TEXT NOT NULL, + preview TEXT NOT NULL DEFAULT '', + enabled INTEGER NOT NULL DEFAULT 1, + installed_at INTEGER NOT NULL DEFAULT 0, + sort_order INTEGER NOT NULL DEFAULT 0 + );", + )?; + } + if version < SCHEMA_VERSION { conn.pragma_update(None, "user_version", SCHEMA_VERSION)?; } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 7b84ab7f..efbf91cc 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -188,6 +188,14 @@ pub fn run() { commands::settings::read_recording, commands::settings::secret_backend, commands::settings::list_fonts, + // plugins + commands::plugin::plugins_root, + commands::plugin::install_plugin, + commands::plugin::list_plugins, + commands::plugin::set_plugin_enabled, + commands::plugin::set_plugin_order, + commands::plugin::uninstall_plugin, + commands::plugin::plugin_exec, commands::command_block::command_block_list_redact_rules, commands::command_block::command_block_save_redact_rule, commands::command_block::command_block_delete_redact_rule, diff --git a/src-tauri/src/models.rs b/src-tauri/src/models.rs index 0a6e1635..a5b22e1d 100644 --- a/src-tauri/src/models.rs +++ b/src-tauri/src/models.rs @@ -516,6 +516,39 @@ pub struct Snippet { pub command: String, } +// --- Plugin --- + +/// A third-party plugin package installed on this machine. `id` doubles as the +/// directory name under `/plugins/`, so it must stay a slug. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Plugin { + pub id: String, + pub name: String, + pub version: String, + #[serde(default)] + pub description: String, + #[serde(default)] + pub author: String, + /// Which host region the plugin UI mounts in: "side" | "strip". + pub area: String, + /// Package-relative preview document ("" = none). + #[serde(default)] + pub preview: String, + pub enabled: bool, + pub installed_at: i64, + /// Position within its area; the manager page rewrites this on reorder. + #[serde(default)] + pub sort_order: i64, +} + +/// One-shot remote command result handed to a plugin iframe via the bridge. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PluginExecResult { + pub stdout: String, + pub stderr: String, + pub exit_code: Option, +} + // --- Session Recording --- #[derive(Debug, Clone, Serialize, Deserialize)] diff --git a/src-tauri/src/server.rs b/src-tauri/src/server.rs index 57483ffc..1b280838 100644 --- a/src-tauri/src/server.rs +++ b/src-tauri/src/server.rs @@ -326,6 +326,48 @@ fn dispatch( &arg::(&args, "id")?, )), + // ---- plugins (JCEF hosts the UI but has no asset protocol, so iframe + // loading is unavailable there; registry + exec still work) ---- + "plugins_root" => ok(Ok::<_, AppError>( + crate::commands::plugin::plugins_dir(state) + .to_string_lossy() + .into_owned(), + )), + "install_plugin" => { + use base64::{engine::general_purpose::STANDARD, Engine}; + let b64: String = arg(&args, "base64Zip")?; + let bytes = STANDARD.decode(b64.trim()).map_err(|e| { + err_value(AppError::config( + "crypto_base64_decode_failed", + json!({ "err": e.to_string() }), + )) + })?; + ok(crate::commands::plugin::install_impl(state, &bytes, None)) + } + "list_plugins" => ok(crate::db::plugin::list(&state.db)), + "set_plugin_enabled" => { + let id: String = arg(&args, "id")?; + let enabled: bool = arg(&args, "enabled")?; + ok(crate::db::plugin::set_enabled(&state.db, &id, enabled)) + } + "set_plugin_order" => { + let ids: Vec = arg(&args, "ids")?; + ok(crate::db::plugin::set_order(&state.db, &ids)) + } + "uninstall_plugin" => { + let id: String = arg(&args, "id")?; + let dir = state.data_dir.join("plugins").join(&id); + if crate::commands::plugin::valid_plugin_id(&id) && dir.exists() { + std::fs::remove_dir_all(&dir).map_err(|e| { + err_value(AppError::other( + "plugin_uninstall_failed", + json!({ "err": e.to_string() }), + )) + })?; + } + ok(crate::db::plugin::delete(&state.db, &id)) + } + // ---- settings / snippets / highlights ---- "get_setting" => { let key: String = arg(&args, "key")?; @@ -905,6 +947,15 @@ async fn dispatch_async( // the native pick dialogs that supply that path are host-provided) ---- "sftp_connect" => sftp_connect(state, owner, args).await, "sftp_connect_session" => sftp_connect_session(state, owner, args).await, + "plugin_exec" => { + let session_id: String = arg(&args, "sessionId")?; + let command: String = arg(&args, "command")?; + let timeout_ms = args.get("timeoutMs").and_then(Value::as_u64); + ok(crate::commands::plugin::plugin_exec_impl( + state, owner, session_id, command, timeout_ms, + ) + .await) + } "sftp_home" => ok(sftp_handle(state, &arg::(&args, "sftpId")?)? .home_dir() .await), diff --git a/src-tauri/src/ssh/client.rs b/src-tauri/src/ssh/client.rs index 8cce2b60..8ead6c2e 100644 --- a/src-tauri/src/ssh/client.rs +++ b/src-tauri/src/ssh/client.rs @@ -827,6 +827,74 @@ impl SessionHandle { } } +/// Run one command on an existing SSH connection and collect its output. +/// +/// Opens a fresh session channel per call — one-shot, no state kept between +/// calls, so there is nothing to clean up when a plugin stops polling (the +/// "lazy" in the plugin exec capability). The channel is opened under a short +/// handle lock and then driven without it, mirroring `SftpHandle::from_handle`. +pub async fn exec_once( + ssh_handle: &SshHandle, + command: &str, + timeout: std::time::Duration, +) -> AppResult { + const OUTPUT_CAP: usize = 256 * 1024; + + let mut channel = { + let h = ssh_handle.lock().await; + h.channel_open_session().await.map_err(|e| { + AppError::ssh( + "plugin_exec_channel_failed", + json!({ "err": e.to_string() }), + ) + })? + }; + channel + .exec(true, command) + .await + .map_err(|e| AppError::ssh("plugin_exec_failed", json!({ "err": e.to_string() })))?; + + let mut stdout: Vec = Vec::new(); + let mut stderr: Vec = Vec::new(); + let mut exit_code: Option = None; + + let collect = async { + loop { + match channel.wait().await { + Some(ChannelMsg::Data { data }) => { + if stdout.len() < OUTPUT_CAP { + stdout + .extend_from_slice(&data[..data.len().min(OUTPUT_CAP - stdout.len())]); + } + } + Some(ChannelMsg::ExtendedData { data, .. }) => { + if stderr.len() < OUTPUT_CAP { + stderr + .extend_from_slice(&data[..data.len().min(OUTPUT_CAP - stderr.len())]); + } + } + Some(ChannelMsg::ExitStatus { exit_status }) => { + exit_code = Some(exit_status as i32); + } + Some(ChannelMsg::Eof | ChannelMsg::Close) | None => break, + _ => {} + } + } + }; + tokio::time::timeout(timeout, collect).await.map_err(|_| { + AppError::ssh( + "plugin_exec_timeout", + json!({ "millis": timeout.as_millis() as u64 }), + ) + })?; + + Ok(crate::models::PluginExecResult { + stdout: String::from_utf8_lossy(&stdout).into_owned(), + stderr: String::from_utf8_lossy(&stderr).into_owned(), + exit_code, + }) +} + // --------------------------------------------------------------------------- // connect — 支持可选堡垒机(ProxyJump) // --------------------------------------------------------------------------- diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index e7a22f44..f317e1c2 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -22,7 +22,11 @@ } ], "security": { - "csp": null + "csp": null, + "assetProtocol": { + "enable": true, + "scope": ["$APPDATA/plugins/**", "$HOME/.rssh/plugins/**"] + } } }, "bundle": { diff --git a/src-tauri/tests/cli_contract.rs b/src-tauri/tests/cli_contract.rs deleted file mode 100644 index dad99132..00000000 --- a/src-tauri/tests/cli_contract.rs +++ /dev/null @@ -1,451 +0,0 @@ -#![cfg(feature = "cli")] - -use std::io::Write; -use std::path::Path; -use std::process::{Command, Output, Stdio}; - -use rssh_lib::db::Db; -use rssh_lib::models::{ - Credential, CredentialType, Forward, ForwardType, Group, Profile, SshAlgorithms, -}; - -fn rssh(args: &[&str]) -> Output { - Command::new(env!("CARGO_BIN_EXE_rssh-cli")) - .args(args) - .output() - .expect("run rssh CLI") -} - -fn rssh_in_home(home: &Path, args: &[&str], input: &str) -> Output { - let mut child = Command::new(env!("CARGO_BIN_EXE_rssh-cli")) - .args(args) - .env("HOME", home) - .stdin(Stdio::piped()) - .stdout(Stdio::piped()) - .stderr(Stdio::piped()) - .spawn() - .expect("spawn rssh CLI"); - child - .stdin - .take() - .expect("piped stdin") - .write_all(input.as_bytes()) - .expect("write CLI input"); - child.wait_with_output().expect("wait for rssh CLI") -} - -fn assert_dynamic_completion_registration(shell: &str) { - let home = tempfile::tempdir().expect("temporary HOME"); - let output = rssh_in_home(home.path(), &["completions", shell], ""); - assert!( - output.status.success(), - "{shell} stderr: {}", - String::from_utf8_lossy(&output.stderr) - ); - - let stdout = String::from_utf8(output.stdout).expect("completion script is UTF-8"); - assert!( - stdout.contains("_RSSH_COMPLETE"), - "{shell} completion does not call the runtime completer:\n{stdout}" - ); - assert!( - stdout.contains("rssh") && stdout.contains("--"), - "{shell} completion does not pass command words back to rssh:\n{stdout}" - ); -} - -fn complete_in_home(home: &Path, words: &[&str]) -> Output { - Command::new(env!("CARGO_BIN_EXE_rssh-cli")) - .arg("--") - .args(words) - .env("HOME", home) - .env("_RSSH_COMPLETE", "fish") - .output() - .expect("run dynamic completion") -} - -#[test] -fn root_help_exposes_only_typed_command_families() { - let output = rssh(&["--help"]); - assert!(output.status.success()); - - let stdout = String::from_utf8(output.stdout).expect("help is UTF-8"); - for family in ["profile", "credential", "forward", "group"] { - assert!( - stdout - .lines() - .any(|line| line.starts_with(&format!(" {family}"))), - "missing {family} command in:\n{stdout}" - ); - } - for legacy in ["ls", "open", "add", "edit", "rm", "_names"] { - assert!( - !stdout - .lines() - .any(|line| line.starts_with(&format!(" {legacy}"))), - "legacy {legacy} command still present in:\n{stdout}" - ); - } -} - -#[test] -fn legacy_top_level_commands_are_rejected_by_clap() { - for legacy in ["ls", "open", "add", "edit", "rm", "_names"] { - let output = rssh(&[legacy]); - assert_eq!( - output.status.code(), - Some(2), - "{legacy} stderr: {}", - String::from_utf8_lossy(&output.stderr) - ); - assert!( - String::from_utf8_lossy(&output.stderr).contains("unrecognized subcommand"), - "{legacy} stderr: {}", - String::from_utf8_lossy(&output.stderr) - ); - } -} - -#[test] -fn version_reports_the_independent_cli_version() { - let home_file = tempfile::NamedTempFile::new().expect("temporary HOME file"); - let output = Command::new(env!("CARGO_BIN_EXE_rssh-cli")) - .arg("version") - .env("HOME", home_file.path()) - .output() - .expect("run rssh CLI"); - - assert!(output.status.success()); - assert_eq!(String::from_utf8_lossy(&output.stdout), "1.0.0\n"); -} - -#[test] -fn bash_completions_follow_the_typed_command_tree() { - assert_dynamic_completion_registration("bash"); -} - -#[test] -fn zsh_completions_follow_the_typed_command_tree() { - assert_dynamic_completion_registration("zsh"); -} - -#[test] -fn fish_completions_follow_the_typed_command_tree() { - assert_dynamic_completion_registration("fish"); -} - -#[test] -fn powershell_completions_follow_the_typed_command_tree() { - assert_dynamic_completion_registration("powershell"); -} - -#[test] -fn completions_do_not_require_a_database_home() { - let home_file = tempfile::NamedTempFile::new().expect("temporary HOME file"); - let output = Command::new(env!("CARGO_BIN_EXE_rssh-cli")) - .args(["completions", "bash"]) - .env("HOME", home_file.path()) - .output() - .expect("run rssh CLI"); - - assert!( - output.status.success(), - "stderr: {}", - String::from_utf8_lossy(&output.stderr) - ); - assert!(String::from_utf8_lossy(&output.stdout).contains("_RSSH_COMPLETE")); -} - -#[test] -fn runtime_completion_reads_named_resources() { - let home = tempfile::tempdir().expect("temporary HOME"); - let db = Db::open(&home.path().join(".rssh")).expect("open test database"); - let credential = Credential { - id: "credential-id".into(), - name: "Credential Deploy".into(), - username: "deploy".into(), - credential_type: CredentialType::None, - secret: None, - save_to_remote: false, - }; - rssh_lib::db::credential::insert(&db, &credential).expect("insert credential"); - let group = Group { - id: "group-id".into(), - name: "Group Platform".into(), - color: "#112233".into(), - sort_order: 0, - }; - rssh_lib::db::group::insert(&db, &group).expect("insert group"); - let profile = Profile { - id: "profile-id".into(), - name: "Profile Production".into(), - host: "production.example.com".into(), - port: 22, - credential_id: credential.id, - bastion_profile_id: None, - init_command: None, - group_id: Some(group.id), - algorithms: SshAlgorithms::default(), - }; - rssh_lib::db::profile::insert(&db, &profile).expect("insert profile"); - let forward = Forward { - id: "forward-id".into(), - name: "Forward Database".into(), - profile_id: profile.id, - group_id: None, - rules: vec![rssh_lib::models::ForwardRule { - forward_type: ForwardType::Local, - local_port: 5432, - remote_host: "database.internal".into(), - remote_port: 5432, - }], - }; - rssh_lib::db::forward::insert(&db, &forward).expect("insert forward"); - drop(db); - - for (words, expected) in [ - ( - &["rssh", "profile", "open", "pro"][..], - "Profile Production", - ), - ( - &["rssh", "credential", "edit", "cre"][..], - "Credential Deploy", - ), - (&["rssh", "forward", "open", "for"][..], "Forward Database"), - (&["rssh", "group", "rm", "gro"][..], "Group Platform"), - ] { - let output = complete_in_home(home.path(), words); - assert!( - output.status.success(), - "completion stderr: {}", - String::from_utf8_lossy(&output.stderr) - ); - assert!( - String::from_utf8_lossy(&output.stdout) - .lines() - .any(|candidate| candidate == expected), - "missing {expected:?} in completion output: {}", - String::from_utf8_lossy(&output.stdout) - ); - } -} - -#[test] -fn unsupported_completion_shell_is_rejected_by_clap() { - let output = rssh(&["completions", "tcsh"]); - - assert_eq!(output.status.code(), Some(2)); - assert!( - String::from_utf8_lossy(&output.stderr).contains("invalid value 'tcsh'"), - "stderr: {}", - String::from_utf8_lossy(&output.stderr) - ); -} - -#[test] -fn profile_named_fwd_is_opened_as_a_profile() { - let home = tempfile::tempdir().expect("temporary HOME"); - let output = Command::new(env!("CARGO_BIN_EXE_rssh-cli")) - .args(["profile", "open", "fwd"]) - .env("HOME", home.path()) - .env("RSSH_APP", "1") - .output() - .expect("run rssh CLI"); - - assert!( - output.status.success(), - "stderr: {}", - String::from_utf8_lossy(&output.stderr) - ); - assert_eq!(output.stdout, b"\x1b]7337;open:fwd\x07"); -} - -#[test] -fn forward_open_uses_the_forward_osc_action() { - let home = tempfile::tempdir().expect("temporary HOME"); - let output = Command::new(env!("CARGO_BIN_EXE_rssh-cli")) - .args(["forward", "open", "tunnel"]) - .env("HOME", home.path()) - .env("RSSH_APP", "1") - .output() - .expect("run rssh CLI"); - - assert!( - output.status.success(), - "stderr: {}", - String::from_utf8_lossy(&output.stderr) - ); - assert_eq!(output.stdout, b"\x1b]7337;fwd:tunnel\x07"); -} - -#[test] -fn profile_list_treats_cred_as_a_search_query() { - let home = tempfile::tempdir().expect("temporary HOME"); - let output = Command::new(env!("CARGO_BIN_EXE_rssh-cli")) - .args(["profile", "list", "cred"]) - .env("HOME", home.path()) - .output() - .expect("run rssh CLI"); - - assert!( - output.status.success(), - "stderr: {}", - String::from_utf8_lossy(&output.stderr) - ); - assert_eq!(String::from_utf8_lossy(&output.stdout), "No profiles.\n"); -} - -#[test] -fn bare_rssh_still_lists_profiles_outside_the_linux_gui_shadow() { - let home = tempfile::tempdir().expect("temporary HOME"); - let output = Command::new(env!("CARGO_BIN_EXE_rssh-cli")) - .env("HOME", home.path()) - .env("RSSH_APP", "1") - .output() - .expect("run rssh CLI"); - - assert!( - output.status.success(), - "stderr: {}", - String::from_utf8_lossy(&output.stderr) - ); - assert_eq!(String::from_utf8_lossy(&output.stdout), "No profiles.\n"); -} - -#[test] -fn group_list_reports_an_empty_store() { - let home = tempfile::tempdir().expect("temporary HOME"); - let output = Command::new(env!("CARGO_BIN_EXE_rssh-cli")) - .args(["group", "list"]) - .env("HOME", home.path()) - .output() - .expect("run rssh CLI"); - - assert!( - output.status.success(), - "stderr: {}", - String::from_utf8_lossy(&output.stderr) - ); - assert_eq!(String::from_utf8_lossy(&output.stdout), "No groups.\n"); -} - -#[test] -fn group_add_uses_prompt_defaults_and_is_listed() { - let home = tempfile::tempdir().expect("temporary HOME"); - let add = rssh_in_home(home.path(), &["group", "add"], "ops\n\n\n"); - assert!( - add.status.success(), - "stderr: {}", - String::from_utf8_lossy(&add.stderr) - ); - - let list = rssh_in_home(home.path(), &["group", "list"], ""); - assert!(list.status.success()); - let stdout = String::from_utf8(list.stdout).expect("list is UTF-8"); - assert!(stdout.contains("ops"), "{stdout}"); - assert!(stdout.contains("#4A6CF7"), "{stdout}"); - assert!(stdout.contains("0"), "{stdout}"); -} - -#[test] -fn group_add_rejects_invalid_sort_order() { - let home = tempfile::tempdir().expect("temporary HOME"); - let add = rssh_in_home(home.path(), &["group", "add"], "ops\n\nnot-a-number\n"); - - assert_eq!(add.status.code(), Some(1)); - assert!( - String::from_utf8_lossy(&add.stderr).contains("numeric_arg_invalid"), - "stderr: {}", - String::from_utf8_lossy(&add.stderr) - ); - - let list = rssh_in_home(home.path(), &["group", "list"], ""); - assert_eq!(String::from_utf8_lossy(&list.stdout), "No groups.\n"); -} - -#[test] -fn group_add_rejects_invalid_color_without_echoing_control_bytes() { - let home = tempfile::tempdir().expect("temporary HOME"); - let malicious = "\x1b]52;c;payload\x07"; - let input = format!("ops\n{malicious}\n0\n"); - let add = rssh_in_home(home.path(), &["group", "add"], &input); - - assert_eq!(add.status.code(), Some(1)); - let stderr = String::from_utf8_lossy(&add.stderr); - assert!(stderr.contains("group_color_invalid"), "stderr: {stderr}"); - assert!( - !stderr.contains('\x1b'), - "stderr echoed control bytes: {stderr:?}" - ); - - let list = rssh_in_home(home.path(), &["group", "list"], ""); - assert_eq!(String::from_utf8_lossy(&list.stdout), "No groups.\n"); -} - -#[test] -fn group_edit_updates_name_color_and_order() { - let home = tempfile::tempdir().expect("temporary HOME"); - let add = rssh_in_home(home.path(), &["group", "add"], "ops\n\n\n"); - assert!(add.status.success()); - - let edit = rssh_in_home( - home.path(), - &["group", "edit", "ops"], - "platform\n#112233\n7\n", - ); - assert!( - edit.status.success(), - "stderr: {}", - String::from_utf8_lossy(&edit.stderr) - ); - - let list = rssh_in_home(home.path(), &["group", "list"], ""); - let stdout = String::from_utf8(list.stdout).expect("list is UTF-8"); - assert!(stdout.contains("platform"), "{stdout}"); - assert!(stdout.contains("#112233"), "{stdout}"); - assert!(stdout.contains("7"), "{stdout}"); - assert!(!stdout.contains("ops"), "{stdout}"); -} - -#[test] -fn group_edit_rejects_invalid_sort_order() { - let home = tempfile::tempdir().expect("temporary HOME"); - let add = rssh_in_home(home.path(), &["group", "add"], "ops\n\n\n"); - assert!(add.status.success()); - - let edit = rssh_in_home( - home.path(), - &["group", "edit", "ops"], - "platform\n#112233\nnot-a-number\n", - ); - - assert_eq!(edit.status.code(), Some(1)); - assert!( - String::from_utf8_lossy(&edit.stderr).contains("numeric_arg_invalid"), - "stderr: {}", - String::from_utf8_lossy(&edit.stderr) - ); - - let list = rssh_in_home(home.path(), &["group", "list"], ""); - let stdout = String::from_utf8(list.stdout).expect("list is UTF-8"); - assert!(stdout.contains("ops"), "{stdout}"); - assert!(!stdout.contains("platform"), "{stdout}"); -} - -#[test] -fn group_rm_removes_the_named_group() { - let home = tempfile::tempdir().expect("temporary HOME"); - let add = rssh_in_home(home.path(), &["group", "add"], "ops\n\n\n"); - assert!(add.status.success()); - - let remove = rssh_in_home(home.path(), &["group", "rm", "ops"], "y\n"); - assert!( - remove.status.success(), - "stderr: {}", - String::from_utf8_lossy(&remove.stderr) - ); - - let list = rssh_in_home(home.path(), &["group", "list"], ""); - assert_eq!(String::from_utf8_lossy(&list.stdout), "No groups.\n"); -} diff --git a/src/lib/ai/store.svelte.ts b/src/lib/ai/store.svelte.ts index 0bc3e4ca..09be0d98 100644 --- a/src/lib/ai/store.svelte.ts +++ b/src/lib/ai/store.svelte.ts @@ -9,6 +9,7 @@ import { invoke } from "@tauri-apps/api/core"; import { listen, type Event as TauriEvent, type UnlistenFn } from "@tauri-apps/api/event"; import { saveTextFile, fileStamp } from "../save-file.ts"; +import { createSidePanelState } from "../stores/panel-state.svelte.ts"; import { t, errMsg, locale as currentLocale } from "../i18n/index.svelte.ts"; import { extractOutput, findSentinel } from "./pty-output.ts"; import { truncateCommand } from "./format.ts"; @@ -71,19 +72,15 @@ function loadPos(): AiPosition { return v === "left" || v === "right" ? v : "right"; } -function loadLegacyPanelWidth(): number | null { - const raw = localStorage.getItem(LEGACY_PANEL_WIDTH_KEY); - if (!raw) return null; - const width = Number.parseInt(raw, 10); - return Number.isFinite(width) && width >= MIN_PANEL_WIDTH ? width : null; -} - // ─── Per-tab visibility ─────────────────────────────────────────── -let _openByTab = $state>({}); -let _panelWidthByTab = $state>({}); +/* Per-tab panel state — the shared side-panel skeleton (open flags + widths + + the persisted committed default that seeds new tabs). */ +const panel = createSidePanelState({ + minWidth: MIN_PANEL_WIDTH, + storageKey: LEGACY_PANEL_WIDTH_KEY, +}); let _position = $state(loadPos()); -let _initialPanelWidth = loadLegacyPanelWidth(); let _sessionByTab = $state>({}); let _chatByTab = $state>({}); let _pendingByTab = $state>({}); @@ -223,11 +220,10 @@ export function setPosition(p: AiPosition) { // ─── Open/close ─────────────────────────────────────────────────── -export function isOpen(tab_id: string) { return _openByTab[tab_id] === true; } -export function openPanel(tab_id: string) { _openByTab[tab_id] = true; } -function hidePanel(tab_id: string) { delete _openByTab[tab_id]; } +export function isOpen(tab_id: string) { return panel.isOpen(tab_id); } +export function openPanel(tab_id: string) { panel.openPanel(tab_id); } export function closePanel(tab_id: string): Promise { - hidePanel(tab_id); + panel.closePanel(tab_id); return endConversation(tab_id); } @@ -249,48 +245,29 @@ export function endConversation(tab_id: string): Promise { return Promise.resolve(); } export async function togglePanel(tab_id: string): Promise { - if (isOpen(tab_id)) await closePanel(tab_id); - else openPanel(tab_id); -} -function hasPanelWidthState(tab_id: string): boolean { - return Object.prototype.hasOwnProperty.call(_panelWidthByTab, tab_id); + if (panel.isOpen(tab_id)) await closePanel(tab_id); + else panel.openPanel(tab_id); } export function panelWidth(tab_id: string): number | null { - return hasPanelWidthState(tab_id) ? _panelWidthByTab[tab_id] : null; + return panel.width(tab_id); } export function setPanelWidth(tab_id: string, width: number | null) { - if (!_disposedTabs.has(tab_id)) _panelWidthByTab[tab_id] = width; + // Writes from a disposed tab's late drag continuation must not resurrect + // state — the dispose flow owns cleanup from that point on. + if (!_disposedTabs.has(tab_id)) panel.setWidth(tab_id, width); } /** Commit only at the end of a drag. The active tab keeps its own value; the * committed value seeds tabs created later and preserves the pre-per-tab * localStorage behavior across app restarts. */ export function commitPanelWidth(tab_id: string): boolean { - if (_disposedTabs.has(tab_id) || !hasPanelWidthState(tab_id)) { - if (_disposedTabs.has(tab_id)) delete _panelWidthByTab[tab_id]; + if (_disposedTabs.has(tab_id)) { + panel.clearTab(tab_id); return false; } - const width = _panelWidthByTab[tab_id]; - if (width === null) { - _initialPanelWidth = null; - try { - localStorage.removeItem(LEGACY_PANEL_WIDTH_KEY); - } catch (error) { - console.warn("[ai] clear panel width:", error); - } - return true; - } - if (!Number.isFinite(width) || width < MIN_PANEL_WIDTH) return false; - _initialPanelWidth = width; - try { - localStorage.setItem(LEGACY_PANEL_WIDTH_KEY, String(width)); - } catch (error) { - console.warn("[ai] persist panel width:", error); - } - return true; + return panel.commitWidth(tab_id); } export function discardPanelState(tab_id: string) { - hidePanel(tab_id); - delete _panelWidthByTab[tab_id]; + panel.clearTab(tab_id); clearPrefill(tab_id); } @@ -299,9 +276,7 @@ export function discardPanelState(tab_id: string) { export function activateTab(tab_id: string) { _tabGeneration[tab_id] = tabGeneration(tab_id) + 1; _disposedTabs.delete(tab_id); - if (!hasPanelWidthState(tab_id)) { - _panelWidthByTab[tab_id] = _initialPanelWidth; - } + panel.seedWidth(tab_id); } /** closeTab 的唯一 AI teardown:先同步封死后续异步 continuation,再清 UI/actor。 */ diff --git a/src/lib/components/AppShell.svelte b/src/lib/components/AppShell.svelte index 50cdc8e8..9b70b8b7 100644 --- a/src/lib/components/AppShell.svelte +++ b/src/lib/components/AppShell.svelte @@ -23,6 +23,11 @@ import ChatPanel from "../ai/ChatPanel.svelte"; import * as ai from "../ai/store.svelte.ts"; import type { AiTargetKind } from "../ai/types.ts"; + import PluginSide from "../plugins/PluginSide.svelte"; + import PluginStrip from "../plugins/PluginStrip.svelte"; + import SidePanel from "./SidePanel.svelte"; + import * as plugins from "../plugins/store.svelte.ts"; + import { sideStacks, type PanelKind } from "../plugins/layout.ts"; import {attachShortcuts, attachKeyup, digitTabIndex, type Shortcut} from "../keyboard/registry.ts"; import {matchBinding, TAB_CYCLE} from "../keyboard/keymap.ts"; import * as keymap from "../stores/keymap.svelte.ts"; @@ -33,6 +38,7 @@ import { defaultPanelWidth, fitPanelWidths, + fitPluginSideWidth, resizePanelWidth, type PanelFitPriority, } from "./panel-widths.ts"; @@ -220,6 +226,11 @@ consumeCloneQuery(); consumeAiHandoff(); + // Plugin registry: manifest rows + on-disk root (for iframe entry URLs). + // A failed load (e.g. plain-browser dev without the shim server) only + // means no plugins — never block startup on it. + void plugins.load().catch((e) => console.warn("[plugins] registry load failed:", e)); + const detachKeydown = attachShortcuts(shortcutsTable()); const detachKeyup = attachKeyup((e) => { if (tabCycling && e.key === "Control") { @@ -414,6 +425,41 @@ // The Transfers popover does not hide SFTP — overlay. ); + /* ── Plugin panels:per-tab open 状态镜像 SFTP;两个宿主区域(side aside + + terminal strip)各自渲染 manifest.area 匹配的启用插件。iframe 只给 + 已连接的 ssh tab 挂(exec 是唯一通道,没会话就是错误弹幕)。 */ + let pluginHostOk = $derived(plugins.hostSupported()); + let pluginSidePlugins = $derived(plugins.sidePlugins()); + let pluginStripPlugins = $derived(plugins.stripPlugins()); + let pluginTabs = $derived( + app.tabs() + .filter((tab) => + plugins.isOpen(tab.id) + && (tab.type === "ssh" || tab.type === "local")) + // sessionId 可为空串:断线时 iframe 保活(图表不丢),exec 报 + // plugin_no_exec 由插件显示断连态;重连后 sessionId 恢复。 + .map((tab) => ({tabId: tab.id, sessionId: app.sessionIdForTab(tab.id) ?? ""})), + ); + let pluginPaneActive = $derived.by(() => { + const tab = app.activeTab(); + return ( + !!tab + && (tab.type === "ssh" || tab.type === "local") + && plugins.isOpen(tab.id) + && !!app.sessionIdForTab(tab.id) + ); + }); + let pluginSideVisible = $derived( + pluginPaneActive && pluginHostOk && pluginSidePlugins.length > 0 + && !app.settingsActive() + ); + let pluginStripVisible = $derived( + pluginPaneActive && pluginHostOk && pluginStripPlugins.length > 0 + && !app.settingsActive() + ); + let pluginSidePos = $derived(plugins.sidePosition()); + let pluginStripPos = $derived(plugins.stripPosition()); + /* ── 两侧面板宽度:preferred value 按 tab 保存,rendered value 按当前容器 动态收敛;开第二块面板、切回宽 tab 或缩窗都不能把主区挤穿。 */ const panelMinWidth = 280; @@ -445,11 +491,22 @@ } }); - let fittedPanelWidths = $derived(fitPanelWidths({ + /* 插件侧栏先适配(最低优先级,先被压缩),AI/SFTP 在剩余宽度上照旧协商。 */ + let pluginSideFitted = $derived(fitPluginSideWidth({ containerWidth: contentWidth, mainMinWidth: mainPanelMinWidth, panelMinWidth, defaultWidth: defaultPanelWidth(viewportWidth), + pluginVisible: pluginSideVisible, + pluginWidth: plugins.sideWidth(app.activePaneId()), + aiVisible, + sftpVisible, + })); + let fittedPanelWidths = $derived(fitPanelWidths({ + containerWidth: pluginSideFitted.remainingContainerWidth, + mainMinWidth: mainPanelMinWidth, + panelMinWidth, + defaultWidth: defaultPanelWidth(viewportWidth), aiVisible, sftpVisible, aiWidth: aiPanelWidth, @@ -457,19 +514,13 @@ priority: panelFitPriorityByTab[aiTabId], })); - let aiSideStyle = $derived( - `flex: 0 0 ${fittedPanelWidths.ai}px; max-width: ${fittedPanelWidths.ai}px;` - ); - let sftpSideStyle = $derived( - `flex: 0 0 ${fittedPanelWidths.sftp}px; max-width: ${fittedPanelWidths.sftp}px;` - ); - let activePanelResizeStop: (() => void) | null = null; $effect(() => { // 订阅所有会改变 drag owner/visibility 的坐标;一旦变化,旧手势立即失效。 aiTabId; aiVisible; sftpVisible; + pluginSideVisible; app.settingsActive(); activePanelResizeStop?.(); }); @@ -488,6 +539,10 @@ }) { e.preventDefault(); activePanelResizeStop?.(); + // Plugin iframes are separate documents: they swallow document-level + // mousemove/mouseup while the cursor is over them, freezing the drag + // (body.panel-resizing punches through, see global.css). + document.body.classList.add("panel-resizing"); const startX = e.clientX; const sideEl = (e.currentTarget as HTMLElement).parentElement as HTMLElement | null; // 取实际渲染宽度作为起点,避免首次拖拽时的"跳变"。 @@ -499,6 +554,7 @@ function stop() { if (stopped) return; stopped = true; + document.body.classList.remove("panel-resizing"); document.removeEventListener("mousemove", onMove); document.removeEventListener("mouseup", stop); window.removeEventListener("blur", stop); @@ -530,14 +586,19 @@ activePanelResizeStop = stop; } + /** 统一的拖拽方向公式:面板在左 → 右移变宽(+1);在右 → 左移变宽(-1)。 + 三个面板同一公式,只喂各自的停靠边。 */ + function resizeSign(side: "left" | "right"): number { + return side === "left" ? 1 : -1; + } + function startAiResize(e: MouseEvent) { const tabId = aiTabId; startPanelResize(e, { tabId, currentWidth: ai.panelWidth(tabId), priority: "ai", - // AI 在右:左移变宽;AI 在左:右移变宽。 - sign: aiPos === "left" ? 1 : -1, + sign: resizeSign(aiPos), minWidth: panelMinWidth, minMain: mainPanelMinWidth, otherPanelVisible: sftpVisible, @@ -557,15 +618,16 @@ /* ── SFTP 面板宽度:跟 AI 镜像一份,同样按 tab 管理。 SFTP 永远走 AI 的对侧(aiPos=right → SFTP 左;aiPos=left → SFTP 右), - 靠 .content.ai-left 的 row-reverse 自动翻边,不引入新位置 config。 */ + 靠 sideStacks 的栈顺序翻边,不引入新位置 config。 */ + let sftpSide = $derived<"left" | "right">(aiPos === "left" ? "right" : "left"); + function startSftpResize(e: MouseEvent) { const tabId = app.activePaneId(); startPanelResize(e, { tabId, currentWidth: app.sftpPanelWidthForTab(tabId), priority: "sftp", - // SFTP 在左:右移变宽;SFTP 在右:左移变宽。 - sign: aiPos === "left" ? -1 : 1, + sign: resizeSign(sftpSide), minWidth: panelMinWidth, minMain: mainPanelMinWidth, otherPanelVisible: aiVisible, @@ -582,6 +644,29 @@ app.commitSftpPanelWidth(tabId); } + /* ── Plugin side panel:跟 AI/SFTP 同一套拖拽骨架(per-tab 宽度存 plugin store)。 */ + function startPluginSideResize(e: MouseEvent) { + const tabId = app.activePaneId(); + startPanelResize(e, { + tabId, + currentWidth: plugins.sideWidth(tabId), + priority: "plugin", + sign: resizeSign(pluginSidePos), + minWidth: panelMinWidth, + minMain: mainPanelMinWidth, + otherPanelVisible: aiVisible || sftpVisible, + stillActive: () => pluginSideVisible && app.activePaneId() === tabId, + setWidth: plugins.setSideWidth, + commitWidth: () => {}, + }); + } + + function resetPluginSideWidth() { + const tabId = app.activePaneId(); + panelFitPriorityByTab[tabId] = "plugin"; + plugins.setSideWidth(tabId, null); + } + /* Menu data — sections describe layout (header / scrollable list / footer), flat navItems is what the keyboard shortcut cycles through. */ let navSections = $derived<{ header: NavItem[]; middle: NavItem[]; footer: NavItem[] }>({ @@ -878,6 +963,13 @@ disabled: !isSsh, onClick: () => { app.setActivePane(tab.id); app.openSftp(); }, }); + // Plugins run over exec: SSH channels remotely, a local child + // process on local-shell tabs. Telnet/serial have neither. + items.push({ + label: t("tab.context.plugins"), + disabled: !(isSsh || tab.type === "local"), + onClick: () => { app.setActivePane(tab.id); plugins.togglePanel(tab.id); }, + }); } sections.push(items); } @@ -1069,6 +1161,7 @@ // not also collapse SFTP/drawer underneath it. if (app.downloadsActive()) return; if (app.sftpOpen()) { app.closeSftp(); e.preventDefault(); } + else if (plugins.isOpen(app.activePaneId())) { plugins.closePanel(app.activePaneId()); e.preventDefault(); } else if (drawerOpen) { closeDrawer(); e.preventDefault(); } } } @@ -1194,117 +1287,169 @@ /> {/if} + {#snippet sideAside(kind: PanelKind, side: "left" | "right")} + {#if kind === "sftp"} + + {#if resourcePanesAllowed && sftpTabs.length > 0} + + {/if} + {:else if kind === "plugin"} + {#if resourcePanesAllowed && pluginHostOk && pluginSidePlugins.length > 0 && pluginTabs.length > 0} +