From 09077fa03eac3925965ff9adc6fbe5bbb6ad15fe Mon Sep 17 00:00:00 2001 From: shihuili1218 Date: Sat, 10 Oct 2026 16:21:08 +0800 Subject: [PATCH] refactor(platform): separate layout, host capabilities and native adapters Keep the shared platform improvements independently reviewable for the existing Linux, macOS, Windows, Android and iOS targets. Separate viewport layout from input handling and runtime capabilities; centralize native file, clipboard, external-link and window operations. Preserve the shared resource cleanup, SFTP, terminal and headless fixes. Exclude HarmonyOS implementations, projects and framework patches. --- AGENTS.md | 18 +- .../main/kotlin/sh/rssh/idea/RsshBridge.kt | 1 + package-lock.json | 20 - package.json | 2 - roadmap.md | 3 + src-tauri/Cargo.toml | 10 +- src-tauri/build.rs | 24 + src-tauri/capabilities/default.json | 9 +- src-tauri/src/ai/commands.rs | 34 +- src-tauri/src/ai/session.rs | 98 +- src-tauri/src/ai/skills.rs | 77 +- src-tauri/src/bin/rssh/main.rs | 4 +- src-tauri/src/commands/cli.rs | 16 +- src-tauri/src/commands/clipboard.rs | 12 + src-tauri/src/commands/discovery.rs | 6 +- src-tauri/src/commands/external.rs | 27 +- src-tauri/src/commands/files.rs | 25 + src-tauri/src/commands/forward.rs | 2 +- src-tauri/src/commands/lifecycle.rs | 894 +++++++++++++++--- src-tauri/src/commands/mod.rs | 11 +- src-tauri/src/commands/plugin.rs | 22 +- src-tauri/src/commands/runtime.rs | 7 + src-tauri/src/commands/serial.rs | 54 +- src-tauri/src/commands/sftp.rs | 402 ++------ src-tauri/src/commands/window.rs | 76 +- src-tauri/src/emitter.rs | 55 +- src-tauri/src/error.rs | 2 +- src-tauri/src/files/atomic.rs | 79 ++ src-tauri/src/files/mod.rs | 232 +++++ src-tauri/src/files/native.rs | 506 ++++++++++ src-tauri/src/lib.rs | 116 +-- src-tauri/src/platform/clipboard.rs | 50 + src-tauri/src/platform/external.rs | 44 + src-tauri/src/platform/mod.rs | 7 + src-tauri/src/platform/runtime.rs | 157 +++ src-tauri/src/platform/tauri/clipboard.rs | 63 ++ src-tauri/src/platform/tauri/external.rs | 11 + src-tauri/src/platform/tauri/window.rs | 10 + src-tauri/src/platform/window.rs | 35 + src-tauri/src/resource.rs | 482 ++++++++++ src-tauri/src/secret/keyring_store.rs | 12 +- src-tauri/src/secret/mod.rs | 36 +- src-tauri/src/server.rs | 232 +++-- src-tauri/src/server_main.rs | 4 +- src-tauri/src/ssh/known_hosts.rs | 10 +- src-tauri/src/ssh/sftp.rs | 343 +++++-- src-tauri/src/state.rs | 34 +- src-tauri/src/terminal/mod.rs | 4 +- src-tauri/src/terminal/pty.rs | 59 +- src-tauri/src/terminal/serial.rs | 431 +++------ src-tauri/src/terminal/serial/desktop.rs | 371 ++++++++ .../src/terminal/serial/desktop_tests.rs | 193 ++++ src-tauri/tauri.conf.json | 2 +- src/App.svelte | 57 +- src/global.d.ts | 4 + src/lib/ai/ChatPanel.svelte | 11 +- src/lib/ai/store.svelte.test.ts | 8 +- src/lib/clipboard.test.ts | 82 +- src/lib/clipboard.ts | 16 +- src/lib/components/AboutScreen.svelte | 4 +- src/lib/components/AppShell.svelte | 164 +++- src/lib/components/AppearanceSettings.svelte | 6 +- src/lib/components/ConnectionEditor.svelte | 2 +- src/lib/components/ConnectionList.svelte | 2 +- src/lib/components/CredentialEditor.svelte | 6 +- src/lib/components/DownloadsScreen.svelte | 4 +- src/lib/components/HomeScreen.svelte | 8 +- src/lib/components/PlaybackScreen.svelte | 12 +- src/lib/components/SerialProfileEditor.svelte | 91 +- src/lib/components/SettingsLayout.svelte | 32 +- src/lib/components/SftpBrowser.svelte | 186 ++-- src/lib/components/ShellSettings.svelte | 50 +- src/lib/components/ShortcutsScreen.svelte | 4 +- ...ileKeybar.svelte => TerminalKeybar.svelte} | 34 +- src/lib/components/TerminalPane.svelte | 346 ++----- src/lib/components/ToastStack.svelte | 20 +- src/lib/components/WelcomeScreen.svelte | 17 +- src/lib/components/connection-editor.test.ts | 6 +- src/lib/components/connection-editor.ts | 4 +- .../components/primary-session-window.test.ts | 21 +- src/lib/components/primary-session-window.ts | 7 +- src/lib/components/sftp-download.test.ts | 104 +- .../components/terminal-connect-error.test.ts | 46 + src/lib/components/toast-stack.test.ts | 36 + src/lib/components/welcome/SceneSync.svelte | 10 +- src/lib/file-access.test.ts | 87 ++ src/lib/file-access.ts | 42 + src/lib/i18n/locales/en.ts | 33 +- src/lib/i18n/locales/zh.ts | 33 +- src/lib/input.test.ts | 69 ++ src/lib/input.ts | 26 + src/lib/ipc-shim.test.ts | 123 ++- src/lib/ipc-shim.ts | 47 +- src/lib/platform.test.ts | 46 +- src/lib/platform.ts | 34 +- src/lib/plugins/PluginSide.svelte | 32 + src/lib/plugins/PluginStrip.svelte | 22 + src/lib/plugins/store.svelte.ts | 9 +- src/lib/plugins/store.test.ts | 10 +- src/lib/save-file.test.ts | 84 +- src/lib/save-file.ts | 29 +- src/lib/sftp-name.test.ts | 37 - src/lib/sftp-name.ts | 20 - src/lib/soft-keyboard-inset.ts | 4 +- src/lib/stores/app.svelte.test.ts | 122 ++- src/lib/stores/app.svelte.ts | 69 +- src/lib/stores/layout.svelte.test.ts | 49 + src/lib/stores/layout.svelte.ts | 19 + src/lib/stores/runtime.svelte.test.ts | 159 ++++ src/lib/stores/runtime.svelte.ts | 93 ++ src/lib/stores/serial.svelte.ts | 35 + src/lib/stores/session-cleanup.svelte.ts | 21 + src/lib/stores/session-cleanup.test.ts | 23 + src/lib/terminal/limits.ts | 3 - src/lib/terminal/output-feeder.test.ts | 34 + .../terminal/reserved-session-attempt.test.ts | 312 +++++- src/lib/terminal/reserved-session-attempt.ts | 30 +- src/lib/terminal/session-cleanup.test.ts | 56 ++ src/lib/terminal/session-cleanup.ts | 82 ++ src/lib/terminal/soft-keyboard.test.ts | 136 +++ src/lib/terminal/soft-keyboard.ts | 175 ++++ src/lib/terminal/touch-scroll.test.ts | 28 +- src/lib/terminal/touch-scroll.ts | 7 +- src/lib/themes/store.svelte.test.ts | 61 +- src/lib/themes/store.svelte.ts | 30 +- src/main.ts | 11 +- src/update-capability.test.ts | 79 ++ 127 files changed, 7067 insertions(+), 2188 deletions(-) create mode 100644 src-tauri/src/commands/clipboard.rs create mode 100644 src-tauri/src/commands/files.rs create mode 100644 src-tauri/src/commands/runtime.rs create mode 100644 src-tauri/src/files/atomic.rs create mode 100644 src-tauri/src/files/mod.rs create mode 100644 src-tauri/src/files/native.rs create mode 100644 src-tauri/src/platform/clipboard.rs create mode 100644 src-tauri/src/platform/external.rs create mode 100644 src-tauri/src/platform/mod.rs create mode 100644 src-tauri/src/platform/runtime.rs create mode 100644 src-tauri/src/platform/tauri/clipboard.rs create mode 100644 src-tauri/src/platform/tauri/external.rs create mode 100644 src-tauri/src/platform/tauri/window.rs create mode 100644 src-tauri/src/platform/window.rs create mode 100644 src-tauri/src/resource.rs create mode 100644 src-tauri/src/terminal/serial/desktop.rs create mode 100644 src-tauri/src/terminal/serial/desktop_tests.rs rename src/lib/components/{MobileKeybar.svelte => TerminalKeybar.svelte} (89%) create mode 100644 src/lib/components/terminal-connect-error.test.ts create mode 100644 src/lib/components/toast-stack.test.ts create mode 100644 src/lib/file-access.test.ts create mode 100644 src/lib/file-access.ts create mode 100644 src/lib/input.test.ts create mode 100644 src/lib/input.ts delete mode 100644 src/lib/sftp-name.test.ts delete mode 100644 src/lib/sftp-name.ts create mode 100644 src/lib/stores/layout.svelte.test.ts create mode 100644 src/lib/stores/layout.svelte.ts create mode 100644 src/lib/stores/runtime.svelte.test.ts create mode 100644 src/lib/stores/runtime.svelte.ts create mode 100644 src/lib/stores/serial.svelte.ts create mode 100644 src/lib/stores/session-cleanup.svelte.ts create mode 100644 src/lib/stores/session-cleanup.test.ts create mode 100644 src/lib/terminal/session-cleanup.test.ts create mode 100644 src/lib/terminal/session-cleanup.ts create mode 100644 src/lib/terminal/soft-keyboard.test.ts create mode 100644 src/lib/terminal/soft-keyboard.ts create mode 100644 src/update-capability.test.ts diff --git a/AGENTS.md b/AGENTS.md index 1c919827..1636e542 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -70,12 +70,16 @@ Tab、导航和连接会话协调在 `app.svelte.ts`;AI、主题、快捷键 rg '^let _.*\$state|^export function' src/lib/stores src/lib/ai src/lib/themes ``` -### R9. 平台条件统一走 `cfg` / `app.isMobile` +### R9. 布局、输入方式与宿主能力分别判断 -OS / 设备形态分支:Rust 端用 `#[cfg(...)]`,前端用 `app.isMobile`(UA 嗅探,顶层 const),不要在各组件重复造一套判断。Docker CLI、kubectl、keychain 等外部能力是否可用,仍应在运行时真实探测。 +页面排版只看窗口或容器宽度:CSS 用 media/container query,需要切换 DOM 时用 `layout.compact()`;不能用 UA、OS 或触摸能力选择手机/电脑布局。触摸、鼠标、键盘按实际输入事件处理,窄窗口不代表没有硬件键盘,宽窗口也可能需要软键盘。 + +原生功能必须走 `app.capabilities()`(后端 `get_runtime_capabilities`),不能从宽度推断 PTY、串口、多窗口、目录授权等能力。Rust 只在平台 API、库或实现不同处使用 `#[cfg(...)]`。Rust 源码统一使用 `linux` / `macos` / `windows` / `android` / `ios` 五个互斥条件(`windows` 为 Rust 内建,其余在 build.rs 定义);不使用 Tauri 的 `mobile` / `desktop` 分类。多系统实现显式列举,例如 `any(android, ios)`。Cargo.toml 依赖条件仍使用原生 target 表达式(build.rs 的 cfg 不参与依赖解析)。App 成功加载能力后才挂载资源页面。Docker CLI、kubectl、keychain 等外部能力仍应在运行时真实探测。 ```bash -rg 'cfg\(target_os|isMobile' src src-tauri/src +rg 'layout\.compact|@media|@container|capabilities\(' src +cat src-tauri/build.rs # 五个系统条件的唯一定义 +rg 'cfg\(|cfg!\(' src-tauri/src ``` ### R10. 新增功能必须显式考虑四条入口 @@ -83,14 +87,14 @@ rg 'cfg\(target_os|isMobile' src src-tauri/src 每个新 feature / UI 改动,PR 描述里至少写清以下入口各自怎么处理: - **桌面 GUI**:默认目标,必须可用 -- **移动 GUI**:`app.isMobile` 路径。没右键、没快捷键、没多窗口、屏幕窄。要么适配(`MobileKeybar` 加按钮、长按代替右键),要么显式声明"移动端不提供" +- **移动 GUI**:窄窗口与触摸输入都要可操作,同时保留外接键盘/鼠标的能力;宽屏平板使用宽布局。操作需有可点击入口;确实缺少原生 API 的功能由能力查询关闭,不能按设备名称一概隐藏。 - **CLI**:`src-tauri/src/bin/rssh/`。CRUD 类操作大概率要补;纯 UI/可视化类可声明 N/A - **Headless / JetBrains**:`src-tauri/src/server.rs` + `src/lib/ipc-shim.ts`。复用同一前端,但 command / event 需要 server adapter 支持 允许的结论是“全部支持”或“只在 X 入口,因为 Y”。**不允许**的是没想过——上线后才发现移动端按钮够不着、CLI 改了 schema 但读不出新字段、JetBrains 页面只能报 unknown command。 ```bash -rg 'isMobile' src/lib/components # 看现有移动端分支怎么写 +rg 'layout\.compact|@media|@container' src/lib/components # 看宽度响应式布局 rg '#\[cfg\(' src-tauri/src/commands # 看 command 层平台分支 rg '"[a-z_]+" =>' src-tauri/src/server.rs # 看 headless dispatcher ``` @@ -244,9 +248,9 @@ rg 'db::|secret_store|sync::config' src-tauri/src/bin/rssh 本地 export 是全量备份;远程 `rssh config github push` / `rssh config webdav push` 与 GUI push 才按同步类别、分组及 secret opt-in 过滤。Credential 用 `save_to_remote`,Telnet 登录脚本用 `save_script_to_remote`。改同步逻辑必须统一走 `sync::config::build_payload`,不能让 GUI / CLI / GitHub / WebDAV 四条路径各写一份规则。 -### P7. `isMobile` 是 const,不响应窗口缩放 +### P7. 布局断点不能决定输入或原生能力 -UA 嗅探,启动一次。需要响应式断点 → 自己加 `$state` + `resize` 监听,别误以为现成。 +`stores/layout.svelte.ts` 统一观察窗口宽度,App 根组件管理监听生命周期。侧栏保留窄/宽两份偏好;resize 只切换读取,不改持久化设置。终端辅助键盘(包括唤起系统键盘的按钮)只在窄屏显示;宽屏不显示辅助键盘或独立浮钮,不按 OS、触摸能力或系统键盘是否打开增加例外。硬件键盘与系统输入法的输入处理独立于断点。 ### P8. Command 名是跨适配器 wire contract diff --git a/idea-plugin/src/main/kotlin/sh/rssh/idea/RsshBridge.kt b/idea-plugin/src/main/kotlin/sh/rssh/idea/RsshBridge.kt index 44be2128..75c24d87 100644 --- a/idea-plugin/src/main/kotlin/sh/rssh/idea/RsshBridge.kt +++ b/idea-plugin/src/main/kotlin/sh/rssh/idea/RsshBridge.kt @@ -64,6 +64,7 @@ object RsshBridge { )} }); }; + window.dispatchEvent(new Event('rssh:host-ready')); """.trimIndent() browser.jbCefClient.addLoadHandler( diff --git a/package-lock.json b/package-lock.json index 70af1636..fa101fca 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,8 +11,6 @@ "@codemirror/legacy-modes": "^6.5.2", "@codemirror/theme-one-dark": "^6.1.3", "@tauri-apps/api": "^2", - "@tauri-apps/plugin-dialog": "^2", - "@tauri-apps/plugin-fs": "^2", "@types/dompurify": "^3.0.5", "@xterm/addon-fit": "^0.11.0", "@xterm/addon-image": "^0.9.0", @@ -1292,24 +1290,6 @@ "node": ">= 10" } }, - "node_modules/@tauri-apps/plugin-dialog": { - "version": "2.7.1", - "resolved": "https://registry.npmjs.org/@tauri-apps/plugin-dialog/-/plugin-dialog-2.7.1.tgz", - "integrity": "sha512-OK1UBXYt+ojcmxMktzzuyonYIFta8CmAASpX+CA+DTGK24KlHjhYI6x2iOJ/TjZF4N7/ACK1oFmEOjIY9IhzOQ==", - "license": "MIT OR Apache-2.0", - "dependencies": { - "@tauri-apps/api": "^2.11.0" - } - }, - "node_modules/@tauri-apps/plugin-fs": { - "version": "2.5.1", - "resolved": "https://registry.npmjs.org/@tauri-apps/plugin-fs/-/plugin-fs-2.5.1.tgz", - "integrity": "sha512-9Lz+Jopp6QyeEWhlpkMx4R/+P9HgR+AVAI4vOZhlT8Xaymtz8iVI/Ov984/XTqgJz/5gz5NretqPB/XEMS3NhQ==", - "license": "MIT OR Apache-2.0", - "dependencies": { - "@tauri-apps/api": "^2.11.0" - } - }, "node_modules/@types/chai": { "version": "5.2.3", "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", diff --git a/package.json b/package.json index 2d3b699d..ce1aeefb 100644 --- a/package.json +++ b/package.json @@ -15,8 +15,6 @@ "@codemirror/legacy-modes": "^6.5.2", "@codemirror/theme-one-dark": "^6.1.3", "@tauri-apps/api": "^2", - "@tauri-apps/plugin-dialog": "^2", - "@tauri-apps/plugin-fs": "^2", "@types/dompurify": "^3.0.5", "@xterm/addon-fit": "^0.11.0", "@xterm/addon-image": "^0.9.0", diff --git a/roadmap.md b/roadmap.md index c56215c3..4e7ce78b 100644 --- a/roadmap.md +++ b/roadmap.md @@ -15,6 +15,9 @@ - 移动端增加自定义键盘 - profile/forward 自定义icon - iOS快捷键盘,随着软键盘升起 +- 滚动到上一屏,按cmd会回到光标处,不好复制 +- 折叠的块,也要支持复制文本 +- 快捷命令块,增加占位符 ![Stars](https://img.shields.io/github/stars/rssh-org/rssh) diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 0f7cdc45..3a34f36f 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -77,6 +77,8 @@ tree-sitter-bash = "0.25.1" fontdb = "0.23.0" url = "2.5.8" +# Mobile reqwest carries webpki-roots so TLS works without a probeable +# system trust store. [target.'cfg(any(target_os = "android", target_os = "ios"))'.dependencies] reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls-native-roots", "rustls-tls-webpki-roots", "stream", "system-proxy"] } @@ -88,16 +90,16 @@ reqwest = { version = "0.12", default-features = false, features = ["json", "rus [target.'cfg(any(target_os = "macos", target_os = "linux"))'.dependencies] libc = "0.2" -# PTY, serial and clipboard — the three desktop targets RSSH actually ships. -# Cargo target tables cannot use Tauri's `desktop` cfg alias, so keep this as a -# positive OS list instead of treating every non-mobile target as a desktop. +# Native desktop PTY, serial and clipboard retain their existing backends. +# Cargo target tables cannot use the application's build.rs OS aliases, so keep +# the native target conditions here. [target.'cfg(any(target_os = "macos", target_os = "windows", target_os = "linux"))'.dependencies] -portable-pty = "0.8" # Serial console transport — a 3rd byte-stream alongside SSH/PTY. macOS uses # IOKit and Windows uses the Win32 API (no extra system dep); Linux links # libudev for port enumeration, so Linux CI/packaging needs `libudev-dev` at # build time (see build-linux.sh). serialport = "4.9.0" +portable-pty = "0.8" arboard = "3" # Keychain — Apple platforms share the native Security.framework backend. diff --git a/src-tauri/build.rs b/src-tauri/build.rs index d860e1e6..8521e65b 100644 --- a/src-tauri/build.rs +++ b/src-tauri/build.rs @@ -1,3 +1,27 @@ fn main() { + // Application OS names are mutually exclusive. Reject unsupported runtimes + // before selecting a native backend from their shared target OS. + // Read the compilation target, not the host running this build script. + // `windows` already exists as a built-in Rust cfg. + for os in ["linux", "macos", "android", "ios"] { + println!("cargo:rustc-check-cfg=cfg({os})"); + } + let target_os = std::env::var("CARGO_CFG_TARGET_OS").expect("Cargo target OS"); + let target_env = std::env::var("CARGO_CFG_TARGET_ENV").expect("Cargo target environment"); + let os = match (target_os.as_str(), target_env.as_str()) { + ("linux", "ohos") => { + panic!("RSSH does not support target OS {target_os} with environment {target_env}") + } + ("linux", _) => "linux", + ("macos", _) => "macos", + ("windows", _) => "windows", + ("android", _) => "android", + ("ios", _) => "ios", + _ => panic!("RSSH does not support target OS {target_os} with environment {target_env}"), + }; + if os != "windows" { + println!("cargo:rustc-cfg={os}"); + } + tauri_build::build() } diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json index 54304cba..6e36588b 100644 --- a/src-tauri/capabilities/default.json +++ b/src-tauri/capabilities/default.json @@ -1,14 +1,11 @@ { "identifier": "default", - "description": "Default capabilities for RSSH", + "description": "Core capabilities for RSSH", "windows": ["main", "rssh-*"], "permissions": [ "core:default", "core:window:allow-set-title", - "core:window:allow-set-always-on-top", - "opener:default", - "dialog:default", - "fs:default", - "fs:allow-write-text-file" + "core:window:allow-set-decorations", + "core:window:allow-set-always-on-top" ] } diff --git a/src-tauri/src/ai/commands.rs b/src-tauri/src/ai/commands.rs index 0f7c608d..fcf4cdcf 100644 --- a/src-tauri/src/ai/commands.rs +++ b/src-tauri/src/ai/commands.rs @@ -476,7 +476,7 @@ pub async fn ai_session_start_impl( } Some(handle) } - #[cfg(desktop)] + #[cfg(any(windows, macos, linux))] AiTarget::Local(target_id) => { let shell_path = match crate::commands::lifecycle::owned_ready_ai_target( state, @@ -490,9 +490,9 @@ pub async fn ai_session_start_impl( initial_shell = super::shell::ShellKind::from_local_path(&shell_path); None } - #[cfg(mobile)] + #[cfg(any(android, ios))] AiTarget::Local(_) => return Err(AppError::not_found("local_pty_not_found", json!({}))), - #[cfg(desktop)] + #[cfg(any(windows, macos, linux))] AiTarget::Serial(target_id) => { // No shell to probe — a serial port is raw bytes. Validate it exists, // then run with ShellKind::Serial (no sentinel, no exit code) and no @@ -506,7 +506,7 @@ pub async fn ai_session_start_impl( initial_shell = super::shell::ShellKind::Serial; None } - #[cfg(mobile)] + #[cfg(any(android, ios))] AiTarget::Serial(_) => { return Err(AppError::not_found("serial_session_not_found", json!({}))) } @@ -535,11 +535,15 @@ pub async fn ai_session_start_impl( // 用户写多个 skill 也不会让启动 prompt 爆炸。 let _ = skill; // 前端不再选;保留参数兼容 let locale_lbl = locale_label(locale.as_deref().unwrap_or("en")); - // 移动端注入能力声明,引导 LLM 切桌面端、别徒劳调工具:analyze_locally 真·阻断 - // (Tauri 2 mobile 不能 spawn 分析窗口);download_file 技术上能跑(写 app 数据 - // 目录),但 analyze_locally 用不了、下下来的文件也取不出私有目录,故一并劝退。 - let is_mobile = cfg!(mobile); - let system_prompt = skills::build_catalog_prompt(&state.db, locale_lbl, is_mobile)?; + // Use the same host check as analyze_locally so the prompt reflects + // whether this runtime can open a local analysis window. + let local_analysis_unavailable = host.ensure_local_analysis_available().await.err(); + let system_prompt = skills::build_catalog_prompt( + &state.db, + locale_lbl, + local_analysis_unavailable.as_deref(), + host.allows_diagnosis_downloads(), + )?; let user_skills_cache = skills::list_user(&state.db)?; // 3. Conversation identity. Resume revives a persisted history under its @@ -1156,7 +1160,7 @@ pub(crate) fn ai_session_rebind_target_impl( crate::commands::lifecycle::OwnedAiTarget::Ssh { handle, .. } => Some(handle), _ => unreachable!("SSH lifecycle kind returned a non-SSH target"), }, - #[cfg(desktop)] + #[cfg(any(windows, macos, linux))] AiTarget::Local(target_id) => { let _ = crate::commands::lifecycle::owned_ready_ai_target( state, @@ -1166,9 +1170,9 @@ pub(crate) fn ai_session_rebind_target_impl( )?; None } - #[cfg(mobile)] + #[cfg(any(android, ios))] AiTarget::Local(_) => return Err(AppError::not_found("local_pty_not_found", json!({}))), - #[cfg(desktop)] + #[cfg(any(windows, macos, linux))] AiTarget::Serial(target_id) => { let _ = crate::commands::lifecycle::owned_ready_ai_target( state, @@ -1178,7 +1182,7 @@ pub(crate) fn ai_session_rebind_target_impl( )?; None } - #[cfg(mobile)] + #[cfg(any(android, ios))] AiTarget::Serial(_) => { return Err(AppError::not_found("serial_session_not_found", json!({}))) } @@ -1344,7 +1348,7 @@ pub(crate) fn conversation_target_key(state: &AppState, target: &AiTarget) -> Ap crate::db::ai_conversation::ssh_target_key(h.profile_id()) } AiTarget::Local(_) => "local".to_string(), - #[cfg(desktop)] + #[cfg(any(windows, macos, linux))] AiTarget::Serial(id) => { let g = locked(&state.serial_sessions)?; let h = g @@ -1352,7 +1356,7 @@ pub(crate) fn conversation_target_key(state: &AppState, target: &AiTarget) -> Ap .ok_or_else(|| AppError::not_found("serial_session_not_found", json!({})))?; format!("serial:{}", h.port_name()) } - #[cfg(mobile)] + #[cfg(any(android, ios))] AiTarget::Serial(_) => { return Err(AppError::not_found("serial_session_not_found", json!({}))) } diff --git a/src-tauri/src/ai/session.rs b/src-tauri/src/ai/session.rs index 1179e0ae..8a480af7 100644 --- a/src-tauri/src/ai/session.rs +++ b/src-tauri/src/ai/session.rs @@ -1560,6 +1560,15 @@ impl Actor { } }; + if let Err(error) = self.app.ensure_local_analysis_available().await { + return Ok(self.make_tool_error( + &tc.id, + &format!( + "analyze_locally is unavailable: {error}. Continue diagnosis in the current session." + ), + )); + } + // File must exist — LLM should have called download_file first. if !std::path::Path::new(&input.local_path).exists() { return Ok(self.make_tool_error( @@ -1615,25 +1624,14 @@ impl Actor { "task": input.task, }) .to_string(); - let json_literal = match serde_json::to_string(&handoff) { - Ok(s) => s, - Err(e) => { - return Ok( - self.make_tool_error(&tc.id, &format!("Failed to encode handoff payload: {e}")) - ); - } - }; // 直接把 JSON 字符串赋值为 JS string;前端走 JSON.parse(data) 还原。 // 不要在这里 JSON.parse —— 否则 window.__rssh_ai_handoff 已经是 object, // 前端再 JSON.parse 会撞 "[object Object]" 解析失败。 - let init_script = format!("window.__rssh_ai_handoff = {};", json_literal); + let init_script = format!("window.__rssh_ai_handoff = {};", json!(handoff)); let label = format!("rssh-ai-{}", uuid::Uuid::new_v4().simple()); - // Tauri 2 把 .title()/.inner_size() 等窗口方法限定在 #[cfg(desktop)], - // 移动端不存在。analyze_locally 的本质就是开新窗口,移动端语义上不存在, - // 直接告知 LLM 工具不可用。 - // 简单的卡片关闭辅助:开窗成功 / 失败 / 移动端都得 emit command_completed, - // 否则 UI 上审批卡片一直停在 "executing"(前端已 ack 但没拿到结果事件)。 + // Every accepted proposal must complete, including a capability change + // or native window failure after approval. let emit_done = |this: &Self, ok: bool, summary: String| { this.emit( "analyze_completed", @@ -1646,54 +1644,42 @@ impl Actor { ); }; - #[cfg(desktop)] + if let Err(e) = self + .app + .open_app_window(&label, "RSSH — Local Analysis", &init_script) + .await { - if let Err(e) = self - .app - .open_app_window(&label, "RSSH — Local Analysis", &init_script) - { - emit_done(self, false, format!("Failed to open analysis window: {e}")); - return Ok(self.make_tool_error( - &tc.id, - &format!( - "Failed to open analysis window: {e}. Continue diagnosis in the current session." - ), - )); - } - - self.audit_push(AuditKind::Note { - message: format!( - "analyze_locally: spawned new window for {} (task: {})", - input.local_path, input.task - ), - }); - - emit_done( - self, - true, - format!("Opened analysis window for {}", input.local_path), - ); - return Ok(Self::make_tool_result( + emit_done(self, false, format!("Failed to open analysis window: {e}")); + return Ok(self.make_tool_error( &tc.id, - format!( - "Opened a new window with a separate AI session to analyze {} (task: {}). \ - This session will NOT receive the analysis result — continue with the current remote diagnosis. \ - Once the user has the result in the new window, they'll decide how to bring the conclusion back here.", - input.local_path, input.task + &format!( + "Failed to open analysis window: {e}. Continue diagnosis in the current session." ), - true, )); } - #[cfg(mobile)] - { - let _ = (init_script, label); - emit_done(self, false, "Desktop-only feature".into()); - Ok(self.make_tool_error( - &tc.id, - "analyze_locally is desktop-only: this build cannot spawn additional windows. Continue diagnosis in the current session.", - )) - } + self.audit_push(AuditKind::Note { + message: format!( + "analyze_locally: spawned new window for {} (task: {})", + input.local_path, input.task + ), + }); + + emit_done( + self, + true, + format!("Opened analysis window for {}", input.local_path), + ); + Ok(Self::make_tool_result( + &tc.id, + format!( + "Opened a new window with a separate AI session to analyze {} (task: {}). \ + This session will NOT receive the analysis result — continue with the current remote diagnosis. \ + Once the user has the result in the new window, they'll decide how to bring the conclusion back here.", + input.local_path, input.task + ), + true, + )) } async fn handle_run_command(&mut self, tc: ToolCall) -> AppResult { diff --git a/src-tauri/src/ai/skills.rs b/src-tauri/src/ai/skills.rs index 7af5375f..ea3fcf72 100644 --- a/src-tauri/src/ai/skills.rs +++ b/src-tauri/src/ai/skills.rs @@ -101,13 +101,14 @@ pub fn delete_user(db: &Db, id: &str) -> AppResult<()> { /// `user_locale_label` 是给 LLM 的回复语言提示(如 "English"、"Chinese (Simplified)"), /// 由 commands 层根据前端 UI locale 解析后传入。 /// -/// `is_mobile` = true 时追加一段移动端能力声明:`analyze_locally` 真·阻断(Tauri 2 -/// mobile 不能 spawn 分析窗口),`download_file` 则是劝退(技术上能跑,但没了 -/// analyze_locally,下到私有目录的文件也用不上),让 LLM 引导用户改用桌面端。 +/// Local analysis availability comes from the same host check used by the tool, +/// not the build's mobile/desktop classification. Download guidance is a separate +/// host policy: some sandboxed clients cannot use the resulting private artifact. pub fn build_catalog_prompt( db: &Db, user_locale_label: &str, - is_mobile: bool, + local_analysis_unavailable: Option<&str>, + allow_downloads: bool, ) -> AppResult { let mut s = String::new(); s.push_str(super::prompts::GENERAL); @@ -136,15 +137,21 @@ pub fn build_catalog_prompt( "\n---\n\n# Response language\n\nRespond to the user in {user_locale_label}. Keep tool-call arguments (cmd, explain, side_effect, etc.) consistent with the user's language too — those are also user-facing.\n" )); - if is_mobile { - s.push_str( - "\n---\n\n# Runtime: mobile device\n\n\ - The user is running rssh on a **mobile build** (Android / iOS). On this build the following tools are **unavailable** and MUST NOT be invoked:\n\ - - `analyze_locally` — the mobile app cannot spawn additional windows.\n\ - - `download_file` — the mobile app has no native file-save dialog.\n\n\ - If the diagnosis would normally require either tool (e.g. dump heap to local for MAT, save flamegraph SVG, run pprof locally), **do not attempt them on the remote host as a workaround** — heap dumps and similar long-running probes can cause STW pauses or fill remote disk. \ - Instead, tell the user plainly that this step needs the desktop build of rssh, and continue the diagnosis with whatever in-session tooling remains useful.\n", - ); + if let Some(reason) = local_analysis_unavailable { + s.push_str(&format!( + "\n---\n\n# Runtime capabilities\n\n\ + `analyze_locally` is unavailable on the current host: {reason}. Do not invoke it.\n\ + If the diagnosis requires local analysis, explain the missing capability and continue with useful in-session tooling. \ + Do not move heavy analysis or long-running probes to the remote host as a workaround — they can exhaust its resources or interrupt the diagnosed service.\n" + )); + } + + if allow_downloads { + if local_analysis_unavailable.is_some() { + s.push_str("\n`download_file` remains available for an SSH target and saves into the app's diagnosis directory. Do not download an artifact solely for unavailable local analysis, or assume its returned path is accessible from another app.\n"); + } + } else { + s.push_str("\n---\n\n# Artifact download policy\n\n`download_file` is unavailable for this host's diagnosis workflow. Do not invoke it or generate heap dumps and similar artifacts on the remote host as a workaround. Explain that this step needs a host with an accessible artifact workflow, and continue with useful in-session tooling.\n"); } Ok(s) @@ -187,7 +194,7 @@ mod tests { fn catalog_omitted_when_no_user_skills() { let db = Db::open_in_memory().unwrap(); - let prompt = build_catalog_prompt(&db, "English", false).unwrap(); + let prompt = build_catalog_prompt(&db, "English", None, true).unwrap(); // The catalog section appears only when user skills exist. The // `load_skill` tool itself is always listed in general.md regardless. @@ -208,10 +215,50 @@ mod tests { ) .unwrap(); - let prompt = build_catalog_prompt(&db, "English", false).unwrap(); + let prompt = build_catalog_prompt(&db, "English", None, true).unwrap(); assert!(prompt.contains("`user-mine`")); assert!(prompt.contains("My workflow")); assert!(prompt.contains("load_skill")); } + + #[test] + fn local_analysis_capable_hosts_keep_the_analysis_workflow() { + let db = Db::open_in_memory().unwrap(); + + let prompt = build_catalog_prompt(&db, "English", None, true).unwrap(); + + assert!(prompt.contains("analyze_locally")); + assert!(!prompt.contains("# Runtime capabilities")); + assert!(!prompt.contains("mobile build")); + } + + #[test] + fn unavailable_local_analysis_does_not_disable_sftp_downloads() { + let db = Db::open_in_memory().unwrap(); + for reason in [ + "Additional windows are unavailable on this device", + "Local analysis requires a working local terminal", + "Local analysis windows are unavailable in headless mode", + ] { + let prompt = build_catalog_prompt(&db, "English", Some(reason), true).unwrap(); + + assert!(prompt.contains(&format!( + "`analyze_locally` is unavailable on the current host: {reason}" + ))); + assert!(prompt.contains("`download_file` remains available for an SSH target")); + assert!(prompt.contains("app's diagnosis directory")); + assert!(!prompt.contains("mobile build")); + assert!(!prompt.contains("no native file-save dialog")); + } + } + #[test] + fn sandbox_artifact_policy_does_not_reenable_existing_download_workflows() { + let db = Db::open_in_memory().unwrap(); + let prompt = + build_catalog_prompt(&db, "English", Some("No analysis window"), false).unwrap(); + assert!(prompt.contains("`download_file` is unavailable")); + assert!(prompt.contains("Do not invoke it")); + assert!(!prompt.contains("`download_file` remains available")); + } } diff --git a/src-tauri/src/bin/rssh/main.rs b/src-tauri/src/bin/rssh/main.rs index 05e217d7..43ca0b73 100644 --- a/src-tauri/src/bin/rssh/main.rs +++ b/src-tauri/src/bin/rssh/main.rs @@ -163,7 +163,7 @@ enum GroupCmd { /// GUI binary at `/usr/bin/rssh`. When invoked without a subcommand, detect /// the GUI binary and launch it instead — so `rssh` opens the app, while /// `rssh profile list`, `rssh profile open …` etc. still use the CLI path. -#[cfg(target_os = "linux")] +#[cfg(linux)] fn try_launch_gui() -> bool { use std::os::unix::process::CommandExt; use std::process::Command; @@ -237,7 +237,7 @@ fn main() { let cli = Cli::parse(); // No subcommand → try launching GUI on Linux. - #[cfg(target_os = "linux")] + #[cfg(linux)] if cli.command.is_none() && try_launch_gui() { return; } diff --git a/src-tauri/src/commands/cli.rs b/src-tauri/src/commands/cli.rs index b272c2fc..d4f576b7 100644 --- a/src-tauri/src/commands/cli.rs +++ b/src-tauri/src/commands/cli.rs @@ -19,7 +19,7 @@ pub struct CliStatus { } fn install_dir() -> PathBuf { - if cfg!(target_os = "windows") { + if cfg!(windows) { dirs::data_local_dir() .unwrap_or_default() .join("Programs") @@ -30,7 +30,7 @@ fn install_dir() -> PathBuf { } fn cli_name() -> &'static str { - if cfg!(target_os = "windows") { + if cfg!(windows) { "rssh.exe" } else { "rssh" @@ -40,7 +40,7 @@ fn cli_name() -> &'static str { fn find_installed() -> Option { let name = cli_name(); // Check common paths - let candidates = if cfg!(target_os = "windows") { + let candidates = if cfg!(windows) { vec![ install_dir().join(name), dirs::home_dir() @@ -68,7 +68,7 @@ fn find_installed() -> Option { } fn find_bundled(app: &AppHandle) -> Option { - let name = if cfg!(target_os = "windows") { + let name = if cfg!(windows) { "rssh-cli.exe" } else { "rssh-cli" @@ -183,7 +183,7 @@ pub fn cli_install(app: AppHandle) -> AppResult { let dest_dir = install_dir(); let dest = dest_dir.join(cli_name()); - #[cfg(target_os = "macos")] + #[cfg(macos)] { let script = format!( r#"do shell script "mkdir -p '{}' && cp '{}' '{}' && chmod 755 '{}'" with administrator privileges"#, @@ -210,7 +210,7 @@ pub fn cli_install(app: AppHandle) -> AppResult { } } - #[cfg(target_os = "linux")] + #[cfg(linux)] { let status = Command::new("pkexec") .arg("sh") @@ -237,7 +237,7 @@ pub fn cli_install(app: AppHandle) -> AppResult { } } - #[cfg(target_os = "windows")] + #[cfg(windows)] { std::fs::create_dir_all(&dest_dir)?; std::fs::copy(&src, &dest)?; @@ -288,7 +288,7 @@ fn setup_completions(cli: &PathBuf) { } } - #[cfg(target_os = "windows")] + #[cfg(windows)] { // PowerShell completion — append to profile if not already present if let Ok(out) = Command::new(cli) diff --git a/src-tauri/src/commands/clipboard.rs b/src-tauri/src/commands/clipboard.rs new file mode 100644 index 00000000..cdca7bdb --- /dev/null +++ b/src-tauri/src/commands/clipboard.rs @@ -0,0 +1,12 @@ +use crate::error::AppResult; +use crate::platform::clipboard; + +#[tauri::command] +pub async fn clipboard_read() -> AppResult { + clipboard::read_text().await +} + +#[tauri::command] +pub async fn clipboard_write(text: String) -> AppResult<()> { + clipboard::write_text(text).await +} diff --git a/src-tauri/src/commands/discovery.rs b/src-tauri/src/commands/discovery.rs index a547d5be..95df2998 100644 --- a/src-tauri/src/commands/discovery.rs +++ b/src-tauri/src/commands/discovery.rs @@ -101,7 +101,7 @@ fn discovery_cli_fallback_dirs() -> Vec { if let Some(home) = dirs::home_dir() { fallback_dirs.push(home.join(".docker").join("bin")); fallback_dirs.push(home.join(".local").join("bin")); - #[cfg(target_os = "macos")] + #[cfg(macos)] fallback_dirs.push( home.join("Applications") .join("Docker.app") @@ -114,9 +114,9 @@ fn discovery_cli_fallback_dirs() -> Vec { fallback_dirs.push(PathBuf::from("/usr/local/bin")); fallback_dirs.push(PathBuf::from("/usr/bin")); fallback_dirs.push(PathBuf::from("/bin")); - #[cfg(target_os = "linux")] + #[cfg(linux)] fallback_dirs.push(PathBuf::from("/snap/bin")); - #[cfg(target_os = "macos")] + #[cfg(macos)] fallback_dirs.push(PathBuf::from( "/Applications/Docker.app/Contents/Resources/bin", )); diff --git a/src-tauri/src/commands/external.rs b/src-tauri/src/commands/external.rs index 003068dc..6cd12161 100644 --- a/src-tauri/src/commands/external.rs +++ b/src-tauri/src/commands/external.rs @@ -1,27 +1,6 @@ -use tauri::AppHandle; -use tauri_plugin_opener::OpenerExt; +use crate::error::AppResult; -use crate::error::{AppError, AppResult}; - -/// Open an external http(s) URL in the user's default browser/app. -/// -/// Goes through `tauri-plugin-opener` so the same call works on desktop, -/// Android and iOS — on mobile the old `Command::new("open" | "xdg-open" | "cmd")` -/// route had no implementation and the invoke silently failed for users. -/// -/// Refuses non-http(s) schemes to prevent abuse (file://, javascript:, …). #[tauri::command] -pub fn open_external_url(app: AppHandle, url: String) -> AppResult<()> { - if !url.starts_with("http://") && !url.starts_with("https://") { - return Err(AppError::config( - "window_non_https_url", - serde_json::json!({ "url": url }), - )); - } - app.opener().open_url(&url, None::<&str>).map_err(|e| { - AppError::other( - "window_open_url_failed", - serde_json::json!({ "err": e.to_string() }), - ) - }) +pub async fn open_external_url(app: tauri::AppHandle, url: String) -> AppResult<()> { + crate::platform::external::open_url(&app, url).await } diff --git a/src-tauri/src/commands/files.rs b/src-tauri/src/commands/files.rs new file mode 100644 index 00000000..7b636ecf --- /dev/null +++ b/src-tauri/src/commands/files.rs @@ -0,0 +1,25 @@ +//! Native GUI file command entry points. Platform mechanics live in crate::files. + +use crate::error::AppResult; +use crate::files::{self, FileFilter}; + +#[tauri::command] +pub async fn resolve_local_paths( + local_root: String, + relative_paths: Vec, + write: bool, +) -> AppResult> { + files::resolve_paths(local_root, relative_paths, write).await +} + +#[tauri::command] +// Browser exports use Blob downloads in save-file.ts; JCEF explicitly rejects +// them. This command belongs to native GUI hosts, never the headless server. +pub async fn save_text_file( + app: tauri::AppHandle, + default_name: String, + contents: String, + filters: Vec, +) -> AppResult> { + files::save_text(&app, default_name, contents, filters).await +} diff --git a/src-tauri/src/commands/forward.rs b/src-tauri/src/commands/forward.rs index eaece61d..42968dd5 100644 --- a/src-tauri/src/commands/forward.rs +++ b/src-tauri/src/commands/forward.rs @@ -139,7 +139,7 @@ fn with_ready_forward( let record = sessions .get(active_id) .ok_or_else(|| AppError::not_found("fwd_not_found", json!({ "id": active_id })))?; - if record.kind != SessionKind::Forward || record.phase != SessionPhase::Ready { + if record.kind != SessionKind::Forward || record.phase() != SessionPhase::Ready { return Err(AppError::not_found( "fwd_not_found", json!({ "id": active_id }), diff --git a/src-tauri/src/commands/lifecycle.rs b/src-tauri/src/commands/lifecycle.rs index c7a022df..488946b5 100644 --- a/src-tauri/src/commands/lifecycle.rs +++ b/src-tauri/src/commands/lifecycle.rs @@ -1,12 +1,14 @@ //! Session 生命周期管理:前端 reconcile + 窗口销毁清理。 use std::collections::{HashMap, HashSet}; -use std::sync::Mutex; +use std::sync::{Arc, Mutex}; use tauri::State; use crate::error::{locked, AppError, AppResult}; +use crate::resource::{CleanupHandle, PendingOperation, ResourceCleanup}; use crate::state::AppState; +use crate::state::ResourceState; use crate::state::{AiSessionRecord, SessionKind, SessionOwner, SessionPhase, SessionRecord}; /// 前端启动 / 重连后调用:把不在 `active_ids` 列表里的所有 session 全部清掉。 @@ -14,7 +16,7 @@ use crate::state::{AiSessionRecord, SessionKind, SessionOwner, SessionPhase, Ses /// `active_ids` 是前端当前持有的所有 ID(不区分 ssh / sftp / forward — /// UUID 不会撞)。返回被清理的总数。 #[tauri::command] -pub fn reconcile_sessions( +pub async fn reconcile_sessions( window: tauri::Window, state: State<'_, AppState>, active_ids: Vec, @@ -24,6 +26,7 @@ pub fn reconcile_sessions( &SessionOwner::Window(window.label().to_owned()), active_ids, ) + .await } /// Resolve a frontend-reserved session identity. Pre-reservation lets the UI @@ -50,6 +53,40 @@ pub struct ResourceReservation<'a> { armed: bool, } +#[cfg(test)] +#[derive(Debug, Default)] +pub(crate) struct AtomicCleanupProbe { + failures: std::sync::atomic::AtomicUsize, + calls: std::sync::atomic::AtomicUsize, +} + +#[cfg(test)] +impl AtomicCleanupProbe { + fn close(&self) -> AppResult<()> { + use std::sync::atomic::Ordering; + self.calls.fetch_add(1, Ordering::SeqCst); + if self + .failures + .fetch_update(Ordering::SeqCst, Ordering::SeqCst, |n| n.checked_sub(1)) + .is_ok() + { + Err(AppError::pty( + "serial_op_failed", + serde_json::json!({"err": "native close refused"}), + )) + } else { + Ok(()) + } + } +} + +#[cfg(test)] +impl crate::resource::Cleanup for AtomicCleanupProbe { + fn close(&self) -> crate::resource::CleanupFuture<'_> { + Box::pin(async move { AtomicCleanupProbe::close(self) }) + } +} + pub struct AiOwnerReservation<'a> { state: &'a AppState, tab_id: String, @@ -378,7 +415,7 @@ fn close_owned_ai( } // An explicit close already owns the cleanup task. Leave its // tombstone alone; owner shutdown must not reopen the ABA window. - SessionPhase::Closed => {} + SessionPhase::Closing | SessionPhase::Closed => {} } } drop(sessions); @@ -462,7 +499,7 @@ pub(crate) fn register_prompt_waiter( let pending = registry.get(resource_id).is_some_and(|record| { record.kind == SessionKind::Ssh && &record.owner == owner - && record.phase == SessionPhase::Pending + && record.phase() == SessionPhase::Pending }); if !pending { return Err(AppError::not_found( @@ -493,9 +530,9 @@ pub(crate) fn register_prompt_waiter( pub enum ReadySession { Ssh(crate::ssh::client::SessionHandle), - #[cfg(desktop)] + #[cfg(any(windows, macos, linux))] Pty(crate::terminal::pty::PtyHandle), - #[cfg(desktop)] + #[cfg(any(windows, macos, linux))] Serial(crate::terminal::serial::SerialHandle), Telnet(crate::terminal::telnet::TelnetHandle), Sftp(std::sync::Arc), @@ -511,9 +548,9 @@ impl ReadySession { fn kind(&self) -> SessionKind { match self { Self::Ssh(_) => SessionKind::Ssh, - #[cfg(desktop)] + #[cfg(any(windows, macos, linux))] Self::Pty(_) => SessionKind::Pty, - #[cfg(desktop)] + #[cfg(any(windows, macos, linux))] Self::Serial(_) => SessionKind::Serial, Self::Telnet(_) => SessionKind::Telnet, Self::Sftp(_) => SessionKind::Sftp, @@ -523,6 +560,14 @@ impl ReadySession { } } + fn cleanup(&self) -> Option { + match self { + #[cfg(any(windows, macos, linux))] + Self::Serial(handle) => crate::terminal::serial::cleanup(handle), + _ => None, + } + } + fn close(self) { match self { Self::Ssh(handle) => handle.force_disconnect(), @@ -531,8 +576,10 @@ impl ReadySession { Self::CleanupProbe { cleaned, .. } => { cleaned.store(true, std::sync::atomic::Ordering::SeqCst); } - #[cfg(desktop)] - Self::Pty(_) | Self::Serial(_) => {} + #[cfg(any(windows, macos, linux))] + Self::Pty(_) => {} + #[cfg(any(windows, macos, linux))] + Self::Serial(_) => {} Self::Telnet(_) | Self::Sftp(_) => {} } } @@ -543,6 +590,36 @@ impl ResourceReservation<'_> { &self.session_id } + /// Register cancellation before beginning an asynchronous native open. The + /// same registry lock orders registration against close and activation. + pub fn pending_operation(&self) -> AppResult { + let mut registry = locked(&self.state.lifecycle_sessions)?; + let record = registry.get_mut(&self.session_id).ok_or_else(|| { + AppError::not_found( + "session_reservation_lost", + serde_json::json!({ "id": self.session_id }), + ) + })?; + if record.nonce != self.nonce + || record.kind != self.kind + || record.phase() != SessionPhase::Pending + { + return Err(AppError::not_found( + "session_reservation_lost", + serde_json::json!({ "id": self.session_id }), + )); + } + if matches!(record.state, ResourceState::Pending(Some(_))) { + return Err(AppError::other( + "session_registry_inconsistent", + serde_json::json!({ "id": self.session_id }), + )); + } + let (operation, pending) = PendingOperation::new(); + record.state = ResourceState::Pending(Some(pending)); + Ok(operation) + } + #[cfg(test)] pub fn ensure_pending(&self) -> AppResult<()> { let registry = locked(&self.state.lifecycle_sessions)?; @@ -550,7 +627,7 @@ impl ResourceReservation<'_> { Some(record) if record.nonce == self.nonce && record.kind == self.kind - && record.phase == SessionPhase::Pending => + && record.phase() == SessionPhase::Pending => { Ok(()) } @@ -586,7 +663,7 @@ impl ResourceReservation<'_> { }; if record.nonce != self.nonce || record.kind != self.kind - || record.phase != SessionPhase::Pending + || record.phase() != SessionPhase::Pending { drop(registry); handle.close(); @@ -596,7 +673,7 @@ impl ResourceReservation<'_> { )); } insert_ready_handle(self.state, &self.session_id, handle)?; - record.phase = SessionPhase::Ready; + record.state = ResourceState::Ready; self.armed = false; Ok(()) } @@ -652,9 +729,9 @@ fn insert_ready_handle(state: &AppState, session_id: &str, handle: ReadySession) sessions.insert(session_id.to_owned(), handle); Ok(()) } - #[cfg(desktop)] + #[cfg(any(windows, macos, linux))] ReadySession::Pty(handle) => insert_unique(&state.pty_sessions, session_id, handle), - #[cfg(desktop)] + #[cfg(any(windows, macos, linux))] ReadySession::Serial(handle) => insert_unique(&state.serial_sessions, session_id, handle), ReadySession::Telnet(handle) => insert_unique(&state.telnet_sessions, session_id, handle), ReadySession::Sftp(handle) => insert_unique(&state.sftp_sessions, session_id, handle), @@ -679,8 +756,12 @@ impl Drop for ResourceReservation<'_> { return; }; if let Some(record) = registry.get_mut(&self.session_id) { - if record.nonce == self.nonce && record.phase == SessionPhase::Pending { - record.phase = SessionPhase::Closed; + if record.nonce == self.nonce && record.phase() == SessionPhase::Pending { + if let Ok((_, Some(cleanup))) = + begin_resource_close(self.state, &self.session_id, record) + { + cleanup.start(); + } } } } @@ -706,7 +787,7 @@ pub fn reserve_resource<'a>( nonce, kind, owner, - phase: SessionPhase::Pending, + state: ResourceState::Pending(None), parent: None, }, ); @@ -744,7 +825,7 @@ pub fn reserve_sftp_child<'a>( let parent = registry .get(parent_id) .ok_or_else(|| AppError::not_found("ssh_session_not_found_msg", serde_json::json!({})))?; - if parent.kind != SessionKind::Ssh || parent.phase != SessionPhase::Ready { + if parent.kind != SessionKind::Ssh || parent.phase() != SessionPhase::Ready { return Err(AppError::not_found( "ssh_session_not_found_msg", serde_json::json!({}), @@ -780,7 +861,7 @@ pub fn reserve_sftp_child<'a>( nonce, kind: SessionKind::Sftp, owner, - phase: SessionPhase::Pending, + state: ResourceState::Pending(None), parent: Some(parent_id.to_owned()), }, ); @@ -805,9 +886,9 @@ pub enum OwnedAiTarget { handle: crate::ssh::client::SshHandle, profile_id: String, }, - #[cfg(desktop)] + #[cfg(any(windows, macos, linux))] PtyShellPath(String), - #[cfg(desktop)] + #[cfg(any(windows, macos, linux))] Serial, Telnet, } @@ -822,7 +903,7 @@ pub fn owned_ready_ai_target( let record = registry .get(id) .ok_or_else(|| AppError::not_found("session_not_found", serde_json::json!({ "id": id })))?; - if record.kind != kind || record.phase != SessionPhase::Ready { + if record.kind != kind || record.phase() != SessionPhase::Ready { return Err(AppError::not_found( "session_not_found", serde_json::json!({ "id": id }), @@ -851,7 +932,7 @@ pub fn owned_ready_ai_target( serde_json::json!({ "id": id }), ) }), - #[cfg(desktop)] + #[cfg(any(windows, macos, linux))] SessionKind::Pty => locked(&state.pty_sessions)? .get(id) .map(|session| OwnedAiTarget::PtyShellPath(session.shell_path().to_owned())) @@ -861,7 +942,7 @@ pub fn owned_ready_ai_target( serde_json::json!({ "id": id }), ) }), - #[cfg(desktop)] + #[cfg(any(windows, macos, linux))] SessionKind::Serial => locked(&state.serial_sessions)? .contains_key(id) .then_some(OwnedAiTarget::Serial) @@ -896,11 +977,11 @@ fn take_ready_handle( SessionKind::Ssh => locked(&state.sessions)? .remove(session_id) .map(ReadySession::Ssh), - #[cfg(desktop)] + #[cfg(any(windows, macos, linux))] SessionKind::Pty => locked(&state.pty_sessions)? .remove(session_id) .map(ReadySession::Pty), - #[cfg(desktop)] + #[cfg(any(windows, macos, linux))] SessionKind::Serial => locked(&state.serial_sessions)? .remove(session_id) .map(ReadySession::Serial), @@ -927,6 +1008,8 @@ fn close_removed(mut removed: Vec) { } } +/// Ensure a transport is closed. Missing or already released resources are +/// successful no-ops; retained records still enforce their owner and kind. pub fn close_resource( state: &AppState, session_id: &str, @@ -936,10 +1019,54 @@ pub fn close_resource( if expected_kind == SessionKind::Ssh { return close_ssh_tree(state, session_id, expected_owner); } + let (removed, cleanup) = + take_closed_resource(state, session_id, expected_kind, expected_owner)?; + if let Some(handle) = removed { + handle.close(); + } + if let Some(cleanup) = cleanup { + cleanup.start(); + } + close_waiters_for_resource(state, session_id, expected_owner)?; + Ok(()) +} + +/// Close an exclusive transport and wait until its native port is released. +/// Pending opens acknowledge completion only after their own cancellation +/// cleanup; Ready handles are taken under the same lock that validates owner. +pub async fn close_resource_and_wait( + state: &AppState, + session_id: &str, + expected_kind: SessionKind, + expected_owner: &SessionOwner, +) -> AppResult<()> { + if expected_kind == SessionKind::Ssh { + return close_ssh_tree(state, session_id, expected_owner); + } + let (removed, cleanup) = + take_closed_resource(state, session_id, expected_kind, expected_owner)?; + let waiter_result = close_waiters_for_resource(state, session_id, expected_owner); + if let Some(handle) = removed { + handle.close(); + } + if let Some(cleanup) = cleanup { + cleanup.finish().await?; + } + + waiter_result +} + +fn take_closed_resource( + state: &AppState, + session_id: &str, + expected_kind: SessionKind, + expected_owner: &SessionOwner, +) -> AppResult<(Option, Option>)> { let mut registry = locked(&state.lifecycle_sessions)?; - let record = registry.get_mut(session_id).ok_or_else(|| { - AppError::not_found("session_not_found", serde_json::json!({ "id": session_id })) - })?; + let Some(record) = registry.get_mut(session_id) else { + // A frontend may cancel before its opening command was dispatched. + return Ok((None, None)); + }; if record.kind != expected_kind { return Err(AppError::config( "session_kind_mismatch", @@ -952,31 +1079,39 @@ pub fn close_resource( serde_json::json!({ "id": session_id }), )); } - if record.phase == SessionPhase::Closed { - return Err(AppError::not_found( - "session_not_found", - serde_json::json!({ "id": session_id }), - )); - } - let removed = if record.phase == SessionPhase::Ready { - Some( - take_ready_handle(state, session_id, record.kind)?.ok_or_else(|| { + begin_resource_close(state, session_id, record) +} + +fn begin_resource_close( + state: &AppState, + session_id: &str, + record: &mut SessionRecord, +) -> AppResult<(Option, Option>)> { + let cleanup = match &mut record.state { + ResourceState::Closing(cleanup) => return Ok((None, Some(cleanup.clone()))), + ResourceState::Pending(operation) => operation.take().map(ResourceCleanup::pending), + ResourceState::Ready => { + let handle = take_ready_handle(state, session_id, record.kind)?.ok_or_else(|| { AppError::other( "session_registry_inconsistent", - serde_json::json!({ "id": session_id }), + serde_json::json!({"id": session_id}), ) - })?, - ) - } else { - None + })?; + match handle.cleanup() { + Some(cleanup) => Some(ResourceCleanup::ready(cleanup)), + None => { + record.state = ResourceState::Closed; + return Ok((Some(handle), None)); + } + } + } + ResourceState::Closed => None, }; - record.phase = SessionPhase::Closed; - drop(registry); - if let Some(handle) = removed { - handle.close(); - } - close_waiters_for_resource(state, session_id, expected_owner)?; - Ok(()) + record.state = match &cleanup { + Some(cleanup) => ResourceState::Closing(cleanup.clone()), + None => ResourceState::Closed, + }; + Ok((None, cleanup)) } pub fn close_ssh_tree( @@ -985,9 +1120,9 @@ pub fn close_ssh_tree( expected_owner: &SessionOwner, ) -> AppResult<()> { let mut registry = locked(&state.lifecycle_sessions)?; - let parent = registry.get(session_id).ok_or_else(|| { - AppError::not_found("session_not_found", serde_json::json!({ "id": session_id })) - })?; + let Some(parent) = registry.get(session_id) else { + return Ok(()); + }; if parent.kind != SessionKind::Ssh { return Err(AppError::config( "session_kind_mismatch", @@ -1000,58 +1135,36 @@ pub fn close_ssh_tree( serde_json::json!({ "id": session_id }), )); } - if parent.phase == SessionPhase::Closed { - return Err(AppError::not_found( - "session_not_found", - serde_json::json!({ "id": session_id }), - )); - } - let child_ids: Vec = registry .iter() .filter(|(_, record)| { - record.parent.as_deref() == Some(session_id) && record.phase != SessionPhase::Closed + record.parent.as_deref() == Some(session_id) && record.phase() != SessionPhase::Closed }) .map(|(id, _)| id.clone()) .collect(); let mut removed = Vec::new(); - for child_id in child_ids { - let child = registry - .get_mut(&child_id) - .expect("child came from registry"); - if child.kind != SessionKind::Sftp { + let mut cleanups = Vec::new(); + for id in child_ids + .iter() + .map(String::as_str) + .chain(std::iter::once(session_id)) + { + let record = registry.get_mut(id).expect("resource was validated"); + if id != session_id && record.kind != SessionKind::Sftp { return Err(AppError::other( "session_registry_inconsistent", - serde_json::json!({ "id": child_id }), + serde_json::json!({"id": id}), )); } - if child.phase == SessionPhase::Ready { - removed.push( - take_ready_handle(state, &child_id, SessionKind::Sftp)?.ok_or_else(|| { - AppError::other( - "session_registry_inconsistent", - serde_json::json!({ "id": child_id }), - ) - })?, - ); - } - child.phase = SessionPhase::Closed; - } - - let parent = registry.get_mut(session_id).expect("parent was validated"); - if parent.phase == SessionPhase::Ready { - removed.push( - take_ready_handle(state, session_id, SessionKind::Ssh)?.ok_or_else(|| { - AppError::other( - "session_registry_inconsistent", - serde_json::json!({ "id": session_id }), - ) - })?, - ); + let (handle, cleanup) = begin_resource_close(state, id, record)?; + removed.extend(handle); + cleanups.extend(cleanup); } - parent.phase = SessionPhase::Closed; drop(registry); close_removed(removed); + for cleanup in cleanups { + cleanup.start(); + } close_waiters_for_resource(state, session_id, expected_owner)?; Ok(()) } @@ -1060,13 +1173,13 @@ fn remove_owned_resources( state: &AppState, owner: &SessionOwner, active_ids: Option<&HashSet>, -) -> AppResult<(usize, Vec)> { +) -> AppResult<(usize, Vec, Vec>)> { let mut registry = locked(&state.lifecycle_sessions)?; let mut ids: Vec = registry .iter() .filter(|(id, record)| { record.owner == *owner - && record.phase != SessionPhase::Closed + && record.phase() != SessionPhase::Closed && active_ids.is_none_or(|active| !active.contains(*id)) }) .map(|(id, _)| id.clone()) @@ -1084,7 +1197,7 @@ fn remove_owned_resources( .iter() .filter(|(id, record)| { !ids.contains(id) - && record.phase != SessionPhase::Closed + && record.phase() != SessionPhase::Closed && record .parent .as_ref() @@ -1094,37 +1207,43 @@ fn remove_owned_resources( .collect(); ids.extend(child_ids); let mut removed = Vec::new(); + let mut cleanups = Vec::new(); for id in &ids { let record = registry.get_mut(id).expect("id came from registry"); - if record.phase == SessionPhase::Ready { - removed.push(take_ready_handle(state, id, record.kind)?.ok_or_else(|| { - AppError::other( - "session_registry_inconsistent", - serde_json::json!({ "id": id }), - ) - })?); - } - record.phase = SessionPhase::Closed; + let (handle, cleanup) = begin_resource_close(state, id, record)?; + removed.extend(handle); + cleanups.extend(cleanup); } - Ok((ids.len(), removed)) + Ok((ids.len(), removed, cleanups)) } -pub fn reconcile_owner( +pub async fn reconcile_owner( state: &AppState, owner: &SessionOwner, active_ids: Vec, ) -> AppResult { let active: HashSet = active_ids.into_iter().collect(); - let (closed, removed) = remove_owned_resources(state, owner, Some(&active))?; + let (closed, removed, cleanups) = remove_owned_resources(state, owner, Some(&active))?; close_removed(removed); - Ok(closed - + close_owned_ai(state, owner, Some(&active))? - + close_owned_waiters(state, owner, Some(&active))?) + // The application owns recovery even if the requesting WebView disappears. + // The caller still waits for release before opening replacement resources. + for cleanup in &cleanups { + cleanup.start(); + } + let closed_ai = close_owned_ai(state, owner, Some(&active)); + let closed_waiters = close_owned_waiters(state, owner, Some(&active)); + futures_util::future::try_join_all(cleanups.iter().map(|cleanup| cleanup.finish())).await?; + Ok(closed + closed_ai? + closed_waiters?) } pub fn close_owner(state: &AppState, owner: &SessionOwner) { match remove_owned_resources(state, owner, None) { - Ok((_, removed)) => close_removed(removed), + Ok((_, removed, cleanups)) => { + close_removed(removed); + for cleanup in cleanups { + cleanup.start(); + } + } Err(error) => log::warn!("close owner sessions failed: {error}"), } if let Err(error) = close_owned_ai(state, owner, None) { @@ -1135,12 +1254,12 @@ pub fn close_owner(state: &AppState, owner: &SessionOwner) { } } -pub fn reconcile_sessions_impl( +pub async fn reconcile_sessions_impl( state: &AppState, owner: &SessionOwner, active_ids: Vec, ) -> AppResult { - reconcile_owner(state, owner, active_ids) + reconcile_owner(state, owner, active_ids).await } pub fn close_window_sessions(state: &AppState, window_label: &str) { @@ -1265,9 +1384,9 @@ mod tests { secret_store, lifecycle_sessions: Mutex::new(HashMap::new()), sessions: Mutex::new(HashMap::new()), - #[cfg(desktop)] + #[cfg(any(windows, macos, linux))] pty_sessions: Mutex::new(HashMap::new()), - #[cfg(desktop)] + #[cfg(any(windows, macos, linux))] serial_sessions: Mutex::new(HashMap::new()), telnet_sessions: Mutex::new(HashMap::new()), sftp_sessions: Mutex::new(HashMap::new()), @@ -1513,8 +1632,542 @@ mod tests { ); } + #[tokio::test] + async fn session_close_is_idempotent_before_dispatch_and_after_failed_open() { + for kind in [SessionKind::Ssh, SessionKind::Telnet] { + let state = empty_state(); + let owner = SessionOwner::Window("rssh-clone".into()); + let id = "550e8400-e29b-41d4-a716-446655440130"; + + // The pane can disappear while its event listeners are being wired, + // before the opening command has reserved anything in the backend. + close_resource(&state, id, kind, &owner).unwrap(); + close_resource_and_wait(&state, id, kind, &owner) + .await + .unwrap(); + assert!(state.lifecycle_sessions.lock().unwrap().is_empty()); + + let reservation = reserve_resource(&state, id, kind, owner.clone()).unwrap(); + // A rejected open (for example SSH TCP timeout) drops its reservation + // before the frontend requests its defensive cleanup. + drop(reservation); + close_resource(&state, id, kind, &owner).unwrap(); + close_resource(&state, id, kind, &owner).unwrap(); + close_resource_and_wait(&state, id, kind, &owner) + .await + .unwrap(); + assert_eq!( + state.lifecycle_sessions.lock().unwrap()[id].phase(), + SessionPhase::Closed + ); + assert!( + reserve_resource(&state, id, kind, owner).is_err(), + "closed identities must stay retired" + ); + } + } + + #[tokio::test] + async fn idempotent_close_keeps_owner_and_kind_checks_for_closed_records() { + for kind in [SessionKind::Ssh, SessionKind::Telnet] { + let state = empty_state(); + let owner = SessionOwner::Window("main".into()); + let other = SessionOwner::Window("rssh-clone".into()); + let id = "550e8400-e29b-41d4-a716-446655440131"; + drop(reserve_resource(&state, id, kind, owner.clone()).unwrap()); + assert_eq!( + close_resource(&state, id, kind, &other).unwrap_err().code(), + "session_owner_mismatch" + ); + assert_eq!( + close_resource_and_wait(&state, id, kind, &other) + .await + .unwrap_err() + .code(), + "session_owner_mismatch" + ); + let other_kind = if kind == SessionKind::Ssh { + SessionKind::Telnet + } else { + SessionKind::Ssh + }; + assert_eq!( + close_resource(&state, id, other_kind, &owner) + .unwrap_err() + .code(), + "session_kind_mismatch" + ); + assert_eq!( + close_resource_and_wait(&state, id, other_kind, &owner) + .await + .unwrap_err() + .code(), + "session_kind_mismatch" + ); + } + } + + #[test] + fn failed_clone_cleanup_does_not_cancel_the_source_window_connection() { + let state = empty_state(); + let source_owner = SessionOwner::Window("main".into()); + let clone_owner = SessionOwner::Window("rssh-clone".into()); + let source = reserve_resource( + &state, + "550e8400-e29b-41d4-a716-446655440132", + SessionKind::Ssh, + source_owner, + ) + .unwrap(); + let failed_id = "550e8400-e29b-41d4-a716-446655440133"; + drop(reserve_resource(&state, failed_id, SessionKind::Ssh, clone_owner.clone()).unwrap()); + close_resource(&state, failed_id, SessionKind::Ssh, &clone_owner).unwrap(); + let retry = reserve_resource( + &state, + "550e8400-e29b-41d4-a716-446655440134", + SessionKind::Ssh, + clone_owner, + ) + .unwrap(); + source.ensure_pending().unwrap(); + retry.ensure_pending().unwrap(); + } + + #[tokio::test] + async fn failed_cleanup_retains_the_resource_for_close_and_owner_retry() { + use std::sync::atomic::Ordering; + + let state = empty_state(); + let owner = SessionOwner::Window("main".into()); + let id = "550e8400-e29b-41d4-a716-446655440110"; + let reservation = reserve_resource(&state, id, SessionKind::Telnet, owner.clone()).unwrap(); + let operation = reservation.pending_operation().unwrap(); + let probe = Arc::new(AtomicCleanupProbe::default()); + probe.failures.store(2, Ordering::SeqCst); + operation + .retain_cleanup(CleanupHandle::new(probe.clone())) + .unwrap(); + operation.complete(probe.close()); // Native open failed and its first cleanup failed too. + + let error = close_resource_and_wait(&state, id, SessionKind::Telnet, &owner) + .await + .unwrap_err(); + assert_eq!(error.code(), "serial_op_failed"); + assert_eq!( + state.lifecycle_sessions.lock().unwrap()[id].phase(), + SessionPhase::Closing + ); + assert_eq!(probe.calls.load(Ordering::SeqCst), 1); + assert!(!state.telnet_sessions.lock().unwrap().contains_key(id)); + assert_eq!( + close_resource_and_wait( + &state, + id, + SessionKind::Telnet, + &SessionOwner::Window("other".into()) + ) + .await + .unwrap_err() + .code(), + "session_owner_mismatch" + ); + + let error = close_resource_and_wait(&state, id, SessionKind::Telnet, &owner) + .await + .unwrap_err(); + assert_eq!(error.code(), "serial_op_failed"); + assert_eq!(probe.calls.load(Ordering::SeqCst), 2); + close_owner(&state, &owner); + tokio::time::timeout(std::time::Duration::from_secs(2), async { + while state.lifecycle_sessions.lock().unwrap()[id].phase() != SessionPhase::Closed { + tokio::task::yield_now().await; + } + }) + .await + .unwrap(); + assert_eq!(probe.calls.load(Ordering::SeqCst), 3); + drop(reservation); + } + + #[tokio::test] + async fn aborted_open_is_cleaned_by_reservation_drop_without_another_close() { + use std::sync::atomic::Ordering; + + let state = empty_state(); + let owner = SessionOwner::Window("main".into()); + let id = "550e8400-e29b-41d4-a716-446655440111"; + let reservation = reserve_resource(&state, id, SessionKind::Telnet, owner.clone()).unwrap(); + let operation = reservation.pending_operation().unwrap(); + let probe = Arc::new(AtomicCleanupProbe::default()); + operation + .retain_cleanup(CleanupHandle::new(probe.clone())) + .unwrap(); + drop(operation); + drop(reservation); + tokio::time::timeout(std::time::Duration::from_secs(2), async { + while state.lifecycle_sessions.lock().unwrap()[id].phase() != SessionPhase::Closed { + tokio::task::yield_now().await; + } + }) + .await + .unwrap(); + assert_eq!(probe.calls.load(Ordering::SeqCst), 1); + } + + #[tokio::test] + async fn concurrent_pending_closes_join_the_same_cleanup() { + let state = empty_state(); + let owner = SessionOwner::Window("main".into()); + let id = "550e8400-e29b-41d4-a716-446655440109"; + let reservation = reserve_resource(&state, id, SessionKind::Telnet, owner.clone()).unwrap(); + let operation = reservation.pending_operation().unwrap(); + let first = close_resource_and_wait(&state, id, SessionKind::Telnet, &owner); + tokio::pin!(first); + tokio::select! { + biased; + result = &mut first => panic!("first close skipped cleanup: {result:?}"), + _ = operation.cancelled() => {}, + } + let second = close_resource_and_wait(&state, id, SessionKind::Telnet, &owner); + tokio::pin!(second); + tokio::select! { + biased; + result = &mut second => panic!("second close skipped cleanup: {result:?}"), + _ = std::future::ready(()) => {}, + } + operation.complete(Ok(())); + first.await.unwrap(); + second.await.unwrap(); + } + + #[tokio::test] + async fn pending_close_waits_for_cleanup_after_notifying_cancellation() { + let state = empty_state(); + let owner = SessionOwner::Window("main".into()); + let id = "550e8400-e29b-41d4-a716-446655440101"; + let reservation = reserve_resource(&state, id, SessionKind::Telnet, owner.clone()).unwrap(); + let operation = reservation.pending_operation().unwrap(); + let close = close_resource_and_wait(&state, id, SessionKind::Telnet, &owner); + tokio::pin!(close); + + tokio::select! { + biased; + result = &mut close => panic!("close completed before native cleanup: {result:?}"), + _ = operation.cancelled() => {}, + } + assert_eq!( + reservation.ensure_pending().unwrap_err().code(), + "session_reservation_lost" + ); + // The resource is Closing until cleanup acknowledges completion. + // A second poll must still wait for the operation guard. + tokio::select! { + biased; + result = &mut close => panic!("close skipped its cleanup barrier: {result:?}"), + _ = std::future::ready(()) => {}, + } + operation.complete(Ok(())); + close.await.unwrap(); + } + + #[tokio::test] + async fn pending_close_preserves_the_native_cleanup_error_code_and_parameters() { + let state = empty_state(); + let owner = SessionOwner::Window("main".into()); + let id = "550e8400-e29b-41d4-a716-446655440107"; + let reservation = reserve_resource(&state, id, SessionKind::Telnet, owner.clone()).unwrap(); + let operation = reservation.pending_operation().unwrap(); + let close = close_resource_and_wait(&state, id, SessionKind::Telnet, &owner); + tokio::pin!(close); + tokio::select! { + biased; + result = &mut close => panic!("close completed before native cleanup: {result:?}"), + _ = operation.cancelled() => {}, + } + let failure = AppError::pty( + "serial_op_failed", + serde_json::json!({ "err": "native close refused", "port": "uart-7" }), + ); + let expected = serde_json::to_value(&failure).unwrap(); + operation.complete(Err(failure)); + let error = close.await.unwrap_err(); + assert!(matches!(&error, AppError::Pty(_))); + assert_eq!(error.code(), "serial_op_failed"); + assert_eq!(serde_json::to_value(&error).unwrap(), expected); + } + + #[tokio::test] + async fn aborted_open_before_resource_registration_needs_no_native_cleanup() { + let state = empty_state(); + let owner = SessionOwner::Window("main".into()); + let id = "550e8400-e29b-41d4-a716-446655440108"; + let reservation = reserve_resource(&state, id, SessionKind::Telnet, owner.clone()).unwrap(); + let operation = reservation.pending_operation().unwrap(); + let close = close_resource_and_wait(&state, id, SessionKind::Telnet, &owner); + tokio::pin!(close); + tokio::select! { + biased; + result = &mut close => panic!("close completed before native cleanup: {result:?}"), + _ = operation.cancelled() => {}, + } + // The contract requires registration before external effects. No + // registered resource means this task stopped before native dispatch. + drop(operation); + close.await.unwrap(); + assert_eq!( + state.lifecycle_sessions.lock().unwrap()[id].phase(), + SessionPhase::Closed + ); + } + + #[tokio::test] + async fn pending_close_validates_owner_and_kind_before_cancelling() { + let state = empty_state(); + let owner = SessionOwner::Window("main".into()); + let other = SessionOwner::Window("other".into()); + let id = "550e8400-e29b-41d4-a716-446655440102"; + let reservation = reserve_resource(&state, id, SessionKind::Telnet, owner.clone()).unwrap(); + let operation = reservation.pending_operation().unwrap(); + assert_eq!( + close_resource_and_wait(&state, id, SessionKind::Telnet, &other) + .await + .unwrap_err() + .code(), + "session_owner_mismatch" + ); + assert_eq!( + close_resource_and_wait(&state, id, SessionKind::Sftp, &owner) + .await + .unwrap_err() + .code(), + "session_kind_mismatch" + ); + tokio::select! { + biased; + _ = operation.cancelled() => panic!("foreign owner or kind cancelled the operation"), + _ = std::future::ready(()) => {}, + } + reservation.ensure_pending().unwrap(); + operation.complete(Ok(())); + } + + #[tokio::test] + async fn reconcile_waits_for_a_cancelled_open_to_release_its_resource() { + let state = empty_state(); + let owner = SessionOwner::Window("main".into()); + let id = "550e8400-e29b-41d4-a716-446655440120"; + let reservation = reserve_resource(&state, id, SessionKind::Telnet, owner.clone()).unwrap(); + let operation = reservation.pending_operation().unwrap(); + let reconcile = reconcile_owner(&state, &owner, Vec::new()); + tokio::pin!(reconcile); + tokio::select! { + biased; + result = &mut reconcile => panic!("reconcile passed the pending open: {result:?}"), + _ = operation.cancelled() => {}, + } + assert_eq!( + state.lifecycle_sessions.lock().unwrap()[id].phase(), + SessionPhase::Closing + ); + operation.complete(Ok(())); + assert_eq!(reconcile.await.unwrap(), 1); + assert_eq!( + state.lifecycle_sessions.lock().unwrap()[id].phase(), + SessionPhase::Closed + ); + } + + #[tokio::test] + async fn failed_reconcile_reports_the_error_and_keeps_recovering_after_owner_disappears() { + use std::sync::atomic::Ordering; + + let state = empty_state(); + let owner = SessionOwner::Window("main".into()); + let id = "550e8400-e29b-41d4-a716-446655440121"; + let reservation = reserve_resource(&state, id, SessionKind::Telnet, owner.clone()).unwrap(); + let operation = reservation.pending_operation().unwrap(); + let probe = Arc::new(AtomicCleanupProbe::default()); + probe.failures.store(3, Ordering::SeqCst); + operation + .retain_cleanup(CleanupHandle::new(probe.clone())) + .unwrap(); + drop(operation); + assert_eq!( + reconcile_owner(&state, &owner, Vec::new()) + .await + .unwrap_err() + .code(), + "serial_op_failed" + ); + close_owner(&state, &owner); + drop(reservation); + tokio::time::timeout(std::time::Duration::from_secs(3), async { + while state.lifecycle_sessions.lock().unwrap()[id].phase() != SessionPhase::Closed { + tokio::task::yield_now().await; + } + }) + .await + .unwrap(); + assert_eq!(probe.calls.load(Ordering::SeqCst), 4); + } + + #[tokio::test] + async fn reconcile_reports_a_failed_close_while_another_open_still_awaits_cancellation() { + use std::sync::atomic::Ordering; + + let state = empty_state(); + let owner = SessionOwner::Window("main".into()); + let failing = reserve_resource( + &state, + "550e8400-e29b-41d4-a716-446655440122", + SessionKind::Telnet, + owner.clone(), + ) + .unwrap(); + let opening = reserve_resource( + &state, + "550e8400-e29b-41d4-a716-446655440123", + SessionKind::Telnet, + owner.clone(), + ) + .unwrap(); + let failure = failing.pending_operation().unwrap(); + let pending = opening.pending_operation().unwrap(); + let probe = Arc::new(AtomicCleanupProbe::default()); + probe.failures.store(usize::MAX, Ordering::SeqCst); + failure + .retain_cleanup(CleanupHandle::new(probe.clone())) + .unwrap(); + drop(failure); + let result = tokio::time::timeout( + std::time::Duration::from_secs(2), + reconcile_owner(&state, &owner, Vec::new()), + ) + .await; + // Settle both workers even when this assertion catches a regression. + probe.failures.store(0, Ordering::SeqCst); + pending.complete(Ok(())); + reconcile_owner(&state, &owner, Vec::new()).await.unwrap(); + assert_eq!( + result + .expect("a pending permission must not hide another close failure") + .unwrap_err() + .code(), + "serial_op_failed" + ); + } + + #[tokio::test] + async fn every_pending_close_path_notifies_the_registered_operation() { + for action in ["close", "owner", "drop"] { + let state = empty_state(); + let owner = SessionOwner::Window("main".into()); + let id = "550e8400-e29b-41d4-a716-446655440103"; + let reservation = + reserve_resource(&state, id, SessionKind::Telnet, owner.clone()).unwrap(); + let operation = reservation.pending_operation().unwrap(); + let mut reservation = Some(reservation); + match action { + "close" => close_resource(&state, id, SessionKind::Telnet, &owner).unwrap(), + "owner" => close_owner(&state, &owner), + "drop" => drop(reservation.take()), + _ => unreachable!(), + } + tokio::select! { + biased; + _ = operation.cancelled() => {}, + _ = std::future::ready(()) => panic!("{action} did not cancel its pending operation"), + } + operation.complete(Ok(())); + } + } + #[test] - fn reconcile_is_scoped_to_one_owner() { + fn cancelled_reservation_cannot_register_a_new_pending_operation() { + let state = empty_state(); + let owner = SessionOwner::Window("main".into()); + let id = "550e8400-e29b-41d4-a716-446655440104"; + let reservation = reserve_resource(&state, id, SessionKind::Telnet, owner.clone()).unwrap(); + close_resource(&state, id, SessionKind::Telnet, &owner).unwrap(); + let error = match reservation.pending_operation() { + Ok(_) => panic!("registered an operation after cancellation"), + Err(error) => error, + }; + assert_eq!(error.code(), "session_reservation_lost"); + } + + #[tokio::test] + async fn late_activation_cannot_release_the_pending_close_barrier_before_cleanup() { + use std::sync::atomic::{AtomicBool, Ordering}; + + let state = empty_state(); + let owner = SessionOwner::Window("main".into()); + let id = "550e8400-e29b-41d4-a716-446655440105"; + let reservation = reserve_resource(&state, id, SessionKind::Telnet, owner.clone()).unwrap(); + let operation = reservation.pending_operation().unwrap(); + let close = close_resource_and_wait(&state, id, SessionKind::Telnet, &owner); + tokio::pin!(close); + tokio::select! { + biased; + result = &mut close => panic!("close completed before pending open: {result:?}"), + _ = operation.cancelled() => {}, + } + let cleaned = Arc::new(AtomicBool::new(false)); + let error = reservation + .activate(ReadySession::CleanupProbe { + kind: SessionKind::Telnet, + cleaned: cleaned.clone(), + }) + .unwrap_err(); + assert_eq!(error.code(), "session_reservation_lost"); + assert!(cleaned.load(Ordering::SeqCst)); + operation.complete(Ok(())); + close.await.unwrap(); + } + + #[tokio::test] + async fn ready_close_takes_the_activated_handle_instead_of_waiting_on_old_open() { + let state = empty_state(); + let owner = SessionOwner::Window("main".into()); + let id = "550e8400-e29b-41d4-a716-446655440106"; + let reservation = reserve_resource(&state, id, SessionKind::Telnet, owner.clone()).unwrap(); + let operation = reservation.pending_operation().unwrap(); + let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let (_, handle) = crate::terminal::telnet::open( + id.into(), + "127.0.0.1", + listener.local_addr().unwrap().port(), + 80, + 24, + "crlf", + Arc::new(|_, _| {}), + ) + .unwrap(); + let (_peer, _) = listener.accept().unwrap(); + reservation.activate(ReadySession::Telnet(handle)).unwrap(); + assert!(state.telnet_sessions.lock().unwrap().contains_key(id)); + + // Keep the old opening guard alive: Ready owns a handle now, so close + // must release that handle rather than waiting on the obsolete guard. + close_resource_and_wait(&state, id, SessionKind::Telnet, &owner) + .await + .unwrap(); + assert!(!state.telnet_sessions.lock().unwrap().contains_key(id)); + assert_eq!( + state + .lifecycle_sessions + .lock() + .unwrap() + .get(id) + .unwrap() + .phase(), + SessionPhase::Closed + ); + operation.complete(Ok(())); + } + + #[tokio::test] + async fn reconcile_is_scoped_to_one_owner() { let state = empty_state(); let owner_a = SessionOwner::Window("a".into()); let owner_b = SessionOwner::Window("b".into()); @@ -1533,7 +2186,10 @@ mod tests { ) .unwrap(); - assert_eq!(reconcile_owner(&state, &owner_a, Vec::new()).unwrap(), 1); + assert_eq!( + reconcile_owner(&state, &owner_a, Vec::new()).await.unwrap(), + 1 + ); assert_eq!( a.ensure_pending().unwrap_err().code(), @@ -1630,7 +2286,7 @@ mod tests { .unwrap() .get("550e8400-e29b-41d4-a716-446655440021") .unwrap() - .phase, + .phase(), SessionPhase::Closed ); } @@ -2166,7 +2822,7 @@ mod tests { nonce: uuid::Uuid::new_v4(), kind: SessionKind::Telnet, owner: other.clone(), - phase: SessionPhase::Ready, + state: ResourceState::Ready, parent: None, }, ); diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index abf0db41..ee08c732 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -1,16 +1,19 @@ -#[cfg(desktop)] +#[cfg(any(windows, macos, linux))] pub mod cli; +pub mod clipboard; pub mod command_block; pub mod discovery; pub mod external; +pub mod files; pub mod forward; pub mod group; pub mod lifecycle; pub mod plugin; pub mod profile; -#[cfg(desktop)] +#[cfg(any(windows, macos, linux))] pub mod pty; -#[cfg(desktop)] +pub mod runtime; +#[cfg(any(windows, macos, linux))] pub mod serial; pub mod session; pub mod settings; @@ -18,5 +21,5 @@ pub mod sftp; pub mod sync; pub mod telnet; pub mod update; -#[cfg(desktop)] +#[cfg(any(windows, macos, linux))] pub mod window; diff --git a/src-tauri/src/commands/plugin.rs b/src-tauri/src/commands/plugin.rs index 4961b51c..06beb4d1 100644 --- a/src-tauri/src/commands/plugin.rs +++ b/src-tauri/src/commands/plugin.rs @@ -348,7 +348,7 @@ fn entry_exists(archive: &mut zip::ZipArchive>, name: &st /// neither — the plugin capability contract covers SSH + local only. enum ExecTransport { Ssh(SshHandle), - #[cfg(desktop)] + #[cfg(any(windows, macos, linux))] Local, } @@ -361,7 +361,7 @@ fn exec_transport( let record = registry .get(session_id) .ok_or_else(|| AppError::not_found("plugin_no_exec", json!({ "id": session_id })))?; - if record.phase != SessionPhase::Ready { + if record.phase() != SessionPhase::Ready { return Err(AppError::not_found( "plugin_no_exec", json!({ "id": session_id, "kind": format!("{:?}", record.kind) }), @@ -385,7 +385,7 @@ fn exec_transport( } // 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)] + #[cfg(any(windows, macos, linux))] SessionKind::Pty => Ok(ExecTransport::Local), kind => Err(AppError::not_found( "plugin_no_exec", @@ -398,7 +398,7 @@ fn exec_transport( /// (timeout, 256 KB per stream). The timeout path kills the WHOLE process /// group: `kill_on_drop` takes out the shell and a group sweep takes out its /// children — no orphaned `sh -c "cat /dev/zero & wait"` burners. -#[cfg(desktop)] +#[cfg(any(windows, macos, linux))] async fn local_exec(command: &str, timeout: std::time::Duration) -> AppResult { const CAP: u64 = 256 * 1024; @@ -515,7 +515,7 @@ pub async fn plugin_exec_impl( ) .await } - #[cfg(desktop)] + #[cfg(any(windows, macos, linux))] ExecTransport::Local => local_exec(&command, timeout).await, } } @@ -705,7 +705,7 @@ mod tests { assert!(ensure_zip_b64_within_cap(&encoded).is_err()); } - #[cfg(desktop)] + #[cfg(any(windows, macos, linux))] #[tokio::test] async fn local_exec_runs_command_and_captures_output() { // Both shells must chain stdout, stderr and a nonzero exit code. @@ -722,7 +722,7 @@ mod tests { assert_eq!(result.exit_code, Some(7)); } - #[cfg(desktop)] + #[cfg(any(windows, macos, linux))] #[tokio::test] async fn local_exec_times_out_and_kills() { // `sleep` doesn't exist on Windows; ping-to-localhost is the standard @@ -741,7 +741,7 @@ mod tests { assert!(start.elapsed() < std::time::Duration::from_secs(5)); } - #[cfg(all(desktop, unix))] + #[cfg(all(any(windows, macos, linux), unix))] #[tokio::test] async fn local_exec_timeout_kills_the_process_group() { // `sh -c "sleep N & wait"` parks the shell behind a background child; @@ -762,7 +762,7 @@ mod tests { assert!(!text.contains("sleep 9876"), "orphan survived:\n{text}"); } - #[cfg(desktop)] + #[cfg(any(windows, macos, linux))] #[tokio::test] async fn local_exec_caps_output_without_breaking_the_pipe() { // >256 KB of stdout: the retained buffer is capped, but the pipe keeps @@ -958,9 +958,9 @@ mod tests { secret_store, lifecycle_sessions: Default::default(), sessions: Default::default(), - #[cfg(desktop)] + #[cfg(any(windows, macos, linux))] pty_sessions: Default::default(), - #[cfg(desktop)] + #[cfg(any(windows, macos, linux))] serial_sessions: Default::default(), telnet_sessions: Default::default(), sftp_sessions: Default::default(), diff --git a/src-tauri/src/commands/runtime.rs b/src-tauri/src/commands/runtime.rs new file mode 100644 index 00000000..c096a228 --- /dev/null +++ b/src-tauri/src/commands/runtime.rs @@ -0,0 +1,7 @@ +use crate::error::AppResult; +use crate::platform::runtime::{self, RuntimeCapabilities}; + +#[tauri::command] +pub async fn get_runtime_capabilities(window: tauri::Window) -> AppResult { + runtime::capabilities(&window).await +} diff --git a/src-tauri/src/commands/serial.rs b/src-tauri/src/commands/serial.rs index 8bf34653..343e34ee 100644 --- a/src-tauri/src/commands/serial.rs +++ b/src-tauri/src/commands/serial.rs @@ -6,12 +6,17 @@ use crate::state::{AppState, SessionKind, SessionOwner}; use crate::terminal::serial; #[tauri::command] -pub fn serial_list_ports() -> AppResult> { - Ok(serial::available_ports()) +pub async fn serial_get_capabilities() -> AppResult { + serial::capabilities().await } #[tauri::command] -pub fn serial_open( +pub async fn serial_list_ports() -> AppResult> { + serial::available_ports().await +} + +#[tauri::command] +pub async fn serial_open( app: AppHandle, window: tauri::Window, state: State<'_, AppState>, @@ -26,8 +31,9 @@ pub fn serial_open( SessionKind::Serial, SessionOwner::Window(window.label().to_owned()), )?; + let operation = reservation.pending_operation()?; // Turn transport-agnostic serial output into Tauri events. The headless ws - // server builds a different sink over the same `serial::open`. + // server builds a different sink over the same `serial::open_resource`. let sink: serial::SerialSink = std::sync::Arc::new(move |id: &str, out: serial::SerialOut| match out { serial::SerialOut::Data(b) => { @@ -37,12 +43,13 @@ pub fn serial_open( let _ = app.emit(&format!("serial:close:{id}"), ()); } }); - let (id, handle) = serial::open(session_id, &port, config, sink)?; - reservation.activate_returned( - &id, - crate::commands::lifecycle::ReadySession::Serial(handle), - )?; - Ok(id) + let opened = serial::open_resource(session_id, &port, config, sink, operation).await?; + opened + .activate(|id, handle| { + reservation + .activate_returned(id, crate::commands::lifecycle::ReadySession::Serial(handle)) + }) + .await } /// Look up an open serial session's handle (cloned — `SerialHandle` is Arc-backed). @@ -54,57 +61,62 @@ fn serial_handle(state: &State<'_, AppState>, session_id: &str) -> AppResult, session_id: String, data: Vec, ) -> AppResult<()> { - serial_handle(&state, &session_id)?.write(&data) + let handle = serial_handle(&state, &session_id)?; + handle.write(&data).await } /// Drive the DTR control line (`true` = asserted). Manual line control for /// MCU reset / bootloader entry / modem signalling. #[tauri::command] -pub fn serial_set_dtr( +pub async fn serial_set_dtr( state: State<'_, AppState>, session_id: String, level: bool, ) -> AppResult<()> { - serial_handle(&state, &session_id)?.set_dtr(level) + let handle = serial_handle(&state, &session_id)?; + handle.set_dtr(level).await } /// Drive the RTS control line (`true` = asserted). #[tauri::command] -pub fn serial_set_rts( +pub async fn serial_set_rts( state: State<'_, AppState>, session_id: String, level: bool, ) -> AppResult<()> { - serial_handle(&state, &session_id)?.set_rts(level) + let handle = serial_handle(&state, &session_id)?; + handle.set_rts(level).await } -/// Send a serial BREAK pulse (~250ms) — attention/interrupt signal for U-Boot, +/// Send a ~250ms serial BREAK pulse — attention/interrupt signal for U-Boot, /// kernel SysRq-over-serial, telco gear. #[tauri::command] -pub fn serial_send_break(state: State<'_, AppState>, session_id: String) -> AppResult<()> { - serial_handle(&state, &session_id)?.send_break() +pub async fn serial_send_break(state: State<'_, AppState>, session_id: String) -> AppResult<()> { + let handle = serial_handle(&state, &session_id)?; + handle.send_break().await } // No serial_resize: a serial line has no rows/cols. The frontend's transport // table maps serial's resize entry to null, so it simply never calls it. #[tauri::command] -pub fn serial_close( +pub async fn serial_close( window: tauri::Window, state: State<'_, AppState>, session_id: String, ) -> AppResult<()> { - crate::commands::lifecycle::close_resource( + crate::commands::lifecycle::close_resource_and_wait( &state, &session_id, SessionKind::Serial, &SessionOwner::Window(window.label().to_owned()), ) + .await } // ── Saved serial profiles (peer of profile/forward; SQLite-persisted CRUD) ── diff --git a/src-tauri/src/commands/sftp.rs b/src-tauri/src/commands/sftp.rs index 91491512..20e21bb4 100644 --- a/src-tauri/src/commands/sftp.rs +++ b/src-tauri/src/commands/sftp.rs @@ -1,5 +1,3 @@ -use std::collections::VecDeque; -use std::path::{Path, PathBuf}; use std::sync::atomic::AtomicBool; use std::sync::Arc; @@ -12,12 +10,6 @@ use crate::ssh::sftp::{FileStat, RemoteEntry, SftpHandle, WalkEntry}; use crate::state::AppState; use crate::state::{SessionKind, SessionOwner}; -#[cfg(desktop)] -use tauri_plugin_dialog::{DialogExt, FilePath}; - -/// Maximum recursion depth for the local walker. Mirrors the remote-side cap. -const LOCAL_WALK_DEPTH_CAP: u32 = 32; - /// RAII:注册 cancel flag 并在 drop 时自动 unregister,无论 streaming 正常返回、 /// 早 `?`、还是 panic。替代旧的手写 register/unregister 配对。 pub struct CancelGuard<'a> { @@ -152,69 +144,10 @@ pub async fn sftp_walk_remote_dir( h.walk_files(&remote_root).await } -/// Recursively list every file under a local directory; the local-side -/// counterpart of `sftp_walk_remote_dir`. `rel_path` always uses '/'; the -/// frontend swaps the separator when rebuilding the local physical path. +/// The file adapter owns local filesystem and authorized URI traversal. #[tauri::command] -pub async fn walk_local_dir(local_root: String) -> AppResult> { - let root = PathBuf::from(&local_root); - let mut queue: VecDeque<(PathBuf, u32)> = VecDeque::new(); - queue.push_back((root.clone(), 0)); - let mut result: Vec = Vec::new(); - - while let Some((dir, depth)) = queue.pop_front() { - if depth >= LOCAL_WALK_DEPTH_CAP { - return Err(AppError::other( - "local_tree_too_deep", - json!({ - "path": dir.display().to_string(), - "depth": depth, - "limit": LOCAL_WALK_DEPTH_CAP, - }), - )); - } - let mut rd = tokio::fs::read_dir(&dir).await?; - while let Some(entry) = rd.next_entry().await? { - // `entry.metadata()` does not traverse symlinks — single syscall - // covers both type discrimination and size for regular files, - // replacing the previous file_type() + metadata() double-stat. - let path = entry.path(); - let meta = entry.metadata().await?; - if meta.is_dir() { - queue.push_back((path, depth + 1)); - } else if meta.is_file() { - result.push(WalkEntry { - rel_path: rel_unix(&path, &root), - size: meta.len(), - }); - } else if meta.is_symlink() { - // Follow once to learn what the target is. Skip symlink-to-dir - // to avoid cycles, and silently skip broken symlinks. - if let Ok(target_meta) = tokio::fs::metadata(&path).await { - if target_meta.is_file() { - result.push(WalkEntry { - rel_path: rel_unix(&path, &root), - size: target_meta.len(), - }); - } - } - } - // Anything else (block/char/fifo): skip. - } - } - Ok(result) -} - -/// Convert the portion of `full` relative to `root` into a '/'-separated string. -/// On Windows std::path::Component uses '\'; we normalise here and the frontend -/// converts back to the platform separator when joining. -fn rel_unix(full: &Path, root: &Path) -> String { - let stripped = full.strip_prefix(root).unwrap_or(full); - stripped - .components() - .map(|c| c.as_os_str().to_string_lossy().into_owned()) - .collect::>() - .join("/") +pub async fn walk_local_dir(local_root: String) -> AppResult> { + crate::files::walk_directory(local_root).await } #[tauri::command] @@ -262,241 +195,51 @@ pub async fn sftp_close( ) } -/// dialog plugin 的 FilePath → 本地 PathBuf。这里的命令仅在桌面注册, -/// 所以 dialog 总返回真实路径,移动端 URI 不会出现在这里。 -#[cfg(desktop)] -fn dialog_to_path(fp: FilePath) -> AppResult { - fp.into_path() - .map_err(|e| AppError::other("file_path_invalid", json!({ "err": e.to_string() }))) -} - -/// `spawn_blocking` 的 JoinError → AppError。 -#[cfg(desktop)] -fn dialog_join_err(e: tokio::task::JoinError) -> AppError { - AppError::other("dialog_task_failed", json!({ "err": e.to_string() })) -} - -/// Download a remote file via native Save As dialog with streaming + progress. -#[cfg(desktop)] +/// Pick a destination or source using the native host; keep authorization URIs intact. #[tauri::command] -pub async fn sftp_save_file( +pub async fn sftp_pick_save_path( app: tauri::AppHandle, - state: State<'_, AppState>, - sftp_id: String, - remote_path: String, default_name: String, ) -> AppResult> { - let dialog_app = app.clone(); - let picked = tokio::task::spawn_blocking(move || { - dialog_app - .dialog() - .file() - .set_file_name(&default_name) - .blocking_save_file() - }) - .await - .map_err(dialog_join_err)?; - let Some(fp) = picked else { return Ok(None) }; - let local = dialog_to_path(fp)?; - - let sftp = get_sftp(&state, &sftp_id)?; - let transfer_id = uuid::Uuid::new_v4().to_string(); - let (_guard, cancel) = CancelGuard::register(&state, transfer_id.clone())?; - let host = crate::emitter::Host::Tauri(app); - sftp.download_streaming(&remote_path, &local, &host, &transfer_id, cancel) - .await?; - Ok(Some(local.display().to_string())) + crate::files::pick_save(&app, default_name, Vec::new()).await } -/// Pick a local file via native Open dialog and upload with streaming + progress. -#[cfg(desktop)] #[tauri::command] -pub async fn sftp_pick_and_upload( +pub async fn sftp_pick_open_path( app: tauri::AppHandle, - state: State<'_, AppState>, - sftp_id: String, - remote_dir: String, -) -> AppResult> { - let dialog_app = app.clone(); - let picked = - tokio::task::spawn_blocking(move || dialog_app.dialog().file().blocking_pick_file()) - .await - .map_err(dialog_join_err)?; - let Some(fp) = picked else { return Ok(None) }; - let local = dialog_to_path(fp)?; - - let name = local - .file_name() - .ok_or_else(|| AppError::other("sftp_invalid_filename", json!({})))? - .to_string_lossy() - .into_owned(); - let remote_path = if remote_dir == "/" { - format!("/{}", name) - } else { - format!("{}/{}", remote_dir.trim_end_matches('/'), name) - }; - - let sftp = get_sftp(&state, &sftp_id)?; - let transfer_id = uuid::Uuid::new_v4().to_string(); - let (_guard, cancel) = CancelGuard::register(&state, transfer_id.clone())?; - let host = crate::emitter::Host::Tauri(app); - let mut reader = tokio::fs::File::open(&local).await?; - let total = reader.metadata().await?.len(); - sftp.upload_streaming( - &mut reader, - total, - &remote_path, - &host, - &transfer_id, - cancel, - ) - .await?; - Ok(Some(name)) +) -> AppResult> { + crate::files::pick_open(&app).await } -/// Open native Save-As dialog and return the chosen path. No transfer happens here. -#[cfg(desktop)] +/// Select a source or destination directory and retain its platform grant. +#[cfg(any(windows, macos, linux))] #[tauri::command] -pub async fn sftp_pick_save_path( +pub async fn sftp_pick_folder( app: tauri::AppHandle, - default_name: String, -) -> AppResult> { - let picked = tokio::task::spawn_blocking(move || { - app.dialog() - .file() - .set_file_name(&default_name) - .blocking_save_file() - }) - .await - .map_err(dialog_join_err)?; - match picked { - Some(fp) => Ok(Some(dialog_to_path(fp)?.display().to_string())), - None => Ok(None), - } + write: bool, +) -> AppResult> { + crate::files::pick_folder(&app, write).await } -/// Open native Open dialog and return the chosen path. No transfer happens here. -#[cfg(desktop)] #[tauri::command] -pub async fn sftp_pick_open_path(app: tauri::AppHandle) -> AppResult> { - let picked = tokio::task::spawn_blocking(move || app.dialog().file().blocking_pick_file()) - .await - .map_err(dialog_join_err)?; - match picked { - Some(fp) => Ok(Some(dialog_to_path(fp)?.display().to_string())), - None => Ok(None), - } -} - -/// Pick a folder via the native dialog. Used both as the destination root -/// (multi-select download) and the source root (recursive upload) — both -/// flows want the same `blocking_pick_folder()` call, so a single command suffices. -#[cfg(desktop)] -#[tauri::command] -pub async fn sftp_pick_folder(app: tauri::AppHandle) -> AppResult> { - let picked = tokio::task::spawn_blocking(move || app.dialog().file().blocking_pick_folder()) - .await - .map_err(dialog_join_err)?; - match picked { - Some(fp) => Ok(Some(dialog_to_path(fp)?.display().to_string())), - None => Ok(None), - } -} - -/// Pick multiple source files for upload. `blocking_pick_files` supports -/// multi-selection on every desktop platform we ship to. -#[cfg(desktop)] -#[tauri::command] -pub async fn sftp_pick_open_files(app: tauri::AppHandle) -> AppResult>> { - let picked = tokio::task::spawn_blocking(move || app.dialog().file().blocking_pick_files()) - .await - .map_err(dialog_join_err)?; - let Some(fps) = picked else { return Ok(None) }; - let paths = fps - .into_iter() - .map(|fp| dialog_to_path(fp).map(|p| p.display().to_string())) - .collect::>>()?; - Ok(Some(paths)) -} - -/// `sftp_io_failed` for a local open failure — same code/i18n as other SFTP IO. -fn open_err(e: std::io::Error) -> AppError { - AppError::sftp( - "sftp_io_failed", - json!({ "op": "open", "err": e.to_string() }), - ) -} - -/// Keeps an iOS security-scoped file URL active for exactly as long as the -/// transfer owns its file handle. Other platforms need no matching release. -struct FileAccessGuard { - #[cfg(target_os = "ios")] +pub async fn sftp_pick_open_files( app: tauri::AppHandle, - #[cfg(target_os = "ios")] - path: Option, -} - -impl FileAccessGuard { - fn new(app: &tauri::AppHandle, path: &tauri_plugin_fs::FilePath) -> Self { - #[cfg(target_os = "ios")] - { - let path = match path { - tauri_plugin_fs::FilePath::Url(url) if url.scheme() == "file" => Some(path.clone()), - _ => None, - }; - Self { - app: app.clone(), - path, - } - } - #[cfg(not(target_os = "ios"))] - { - let _ = (app, path); - Self {} - } +) -> AppResult>> { + crate::files::pick_open_files(&app).await +} + +// File access keeps its native errors for non-SFTP callers. Preserve SFTP's +// existing open-error contract without nesting a coded error inside params. +fn local_open_error(error: AppError) -> AppError { + match error { + AppError::Io(cause) => AppError::sftp( + "sftp_io_failed", + json!({ "op": "open", "err": cause.params["err"] }), + ), + other => other, } } -impl Drop for FileAccessGuard { - fn drop(&mut self) { - #[cfg(target_os = "ios")] - if let Some(path) = self.path.take() { - use tauri_plugin_fs::FsExt; - if let Err(e) = self.app.fs().stop_accessing_security_scoped_resource(path) { - log::warn!("failed to release iOS security-scoped file: {e}"); - } - } - } -} - -/// Resolve a desktop path, Android content URI, or iOS security-scoped file URL -/// to a real file. The guard must live until the file handle is dropped. -fn fs_open_read( - app: &tauri::AppHandle, - fp: tauri_plugin_fs::FilePath, -) -> AppResult<(std::fs::File, FileAccessGuard)> { - use tauri_plugin_fs::{FsExt, OpenOptions}; - let mut opts = OpenOptions::new(); - opts.read(true); - let access = FileAccessGuard::new(app, &fp); - let file = app.fs().open(fp, opts).map_err(open_err)?; - Ok((file, access)) -} - -/// Same as [`fs_open_read`] but opens (create + truncate) for writing — the -/// mobile download target. -fn fs_open_write( - app: &tauri::AppHandle, - fp: tauri_plugin_fs::FilePath, -) -> AppResult<(std::fs::File, FileAccessGuard)> { - use tauri_plugin_fs::{FsExt, OpenOptions}; - let mut opts = OpenOptions::new(); - opts.write(true).create(true).truncate(true); - let access = FileAccessGuard::new(app, &fp); - let file = app.fs().open(fp, opts).map_err(open_err)?; - Ok((file, access)) -} - /// Stream-download to a caller-supplied local target. transfer_id is used as the /// `sftp:progress:{transfer_id}` event suffix (R1) so the frontend listens /// per-transfer instead of multiplexing one global stream. @@ -512,25 +255,28 @@ pub async fn sftp_download_to( let sftp = get_sftp(&state, &sftp_id)?; let (_guard, cancel) = CancelGuard::register(&state, transfer_id.clone())?; let host = crate::emitter::Host::Tauri(app.clone()); - // Desktop sends a filesystem path → keep the atomic `.part` + rename. - // Mobile sends a SAF `content://` URI → open it through plugin-fs (which - // resolves the URI to a real fd) and stream straight in. There's no path to - // rename through on mobile, so that download has no local atomicity (agreed). - match local_path - .parse::() - .expect("FilePath::from_str is infallible") + match crate::files::download_target(&app, local_path) + .await + .map_err(local_open_error)? { - tauri_plugin_fs::FilePath::Path(p) => sftp - .download_streaming(&remote_path, &p, &host, &transfer_id, cancel) + crate::files::DownloadTarget::AtomicPath(path) => sftp + .download_streaming(&remote_path, &path, &host, &transfer_id, cancel) + .await + .map(|_| ()), + crate::files::DownloadTarget::Provider(location) => sftp + .download_streaming_to_writer( + &remote_path, + || async { + crate::files::open_file(&app, location, true) + .await + .map_err(local_open_error) + }, + &host, + &transfer_id, + cancel, + ) .await .map(|_| ()), - fp @ tauri_plugin_fs::FilePath::Url(_) => { - let (dst, _access) = fs_open_write(&app, fp)?; - let mut dst = tokio::fs::File::from_std(dst); - sftp.download_streaming_to_writer(&remote_path, &mut dst, &host, &transfer_id, cancel) - .await - .map(|_| ()) - } } } @@ -547,25 +293,15 @@ pub async fn sftp_upload_from( let sftp = get_sftp(&state, &sftp_id)?; let (_guard, cancel) = CancelGuard::register(&state, transfer_id.clone())?; let host = crate::emitter::Host::Tauri(app.clone()); - // Local end is just a reader, so desktop (path) and mobile (content:// URI) - // share one path — plugin-fs resolves either to a real fd. - let fp = local_path - .parse::() - .expect("FilePath::from_str is infallible"); - let (reader, _access) = fs_open_read(&app, fp)?; - let mut reader = tokio::fs::File::from_std(reader); + let mut file = crate::files::open_file(&app, local_path, false) + .await + .map_err(local_open_error)?; // content:// fds may not support fstat; fall back to 0 (indeterminate bar). - let total = reader.metadata().await.map(|m| m.len()).unwrap_or(0); - sftp.upload_streaming( - &mut reader, - total, - &remote_path, - &host, - &transfer_id, - cancel, - ) - .await - .map(|_| ()) + let total = file.metadata().await.map(|m| m.len()).unwrap_or(0); + let reader = file.stream(); + sftp.upload_streaming(reader, total, &remote_path, &host, &transfer_id, cancel) + .await + .map(|_| ()) } #[tauri::command] @@ -609,3 +345,29 @@ pub fn sftp_cancel_transfer(state: State<'_, AppState>, transfer_id: String) -> } Ok(()) } + +#[cfg(test)] +mod file_error_tests { + use super::*; + + #[test] + fn local_open_failure_preserves_sftp_wire_error_without_nested_encoding() { + let io = std::io::Error::new(std::io::ErrorKind::PermissionDenied, "access denied"); + let error = local_open_error(io.into()); + let AppError::Sftp(cause) = error else { + panic!("expected the existing SFTP open error"); + }; + assert_eq!(cause.code, "sftp_io_failed"); + assert_eq!( + cause.params, + json!({ "op": "open", "err": "access denied" }) + ); + } + + #[test] + fn provider_authorization_errors_keep_their_original_contract() { + let error = AppError::other("file_access_denied", json!({"err": "not selected"})); + let expected = error.to_string(); + assert_eq!(local_open_error(error).to_string(), expected); + } +} diff --git a/src-tauri/src/commands/window.rs b/src-tauri/src/commands/window.rs index 8a38c054..e59451f0 100644 --- a/src-tauri/src/commands/window.rs +++ b/src-tauri/src/commands/window.rs @@ -1,7 +1,8 @@ -use tauri::{AppHandle, WebviewUrl, WebviewWindowBuilder}; +use tauri::AppHandle; use uuid::Uuid; use crate::error::{AppError, AppResult}; +use crate::platform::window::{open_app_window, AppWindowPurpose}; /// Encode the frontend clone payload as a JavaScript string literal. /// @@ -32,11 +33,8 @@ fn clone_init_script(clone: &str) -> AppResult { pub async fn open_tab_in_new_window(app: AppHandle, clone: String) -> AppResult<()> { let init_script = clone_init_script(&clone)?; let label = format!("rssh-{}", Uuid::new_v4().simple()); - WebviewWindowBuilder::new(&app, &label, WebviewUrl::App("index.html".into())) - .title("RSSH") - .inner_size(1200.0, 800.0) - .initialization_script(&init_script) - .build() + open_app_window(&app, &label, "RSSH", &init_script, AppWindowPurpose::Tab) + .await .map_err(|e| { AppError::other( "window_open_failed", @@ -46,7 +44,7 @@ pub async fn open_tab_in_new_window(app: AppHandle, clone: String) -> AppResult< Ok(()) } -#[cfg(all(test, desktop))] +#[cfg(all(test, any(windows, macos, linux)))] mod tests { use super::*; @@ -71,67 +69,3 @@ mod tests { assert_eq!(serde_json::from_str::(literal).unwrap(), clone); } } - -/// One `arboard::Clipboard` for the whole process, created lazily. -/// -/// On X11 the clipboard is a *selection ownership* protocol, not a store: the -/// process that wrote the text must stay alive to serve other apps' (and our -/// own paste's) `SelectionRequest`s. arboard owns the CLIPBOARD selection only -/// while at least one `Clipboard` instance is alive; the last one to drop tears -/// down its X11 window and hands the data off to a clipboard manager on a -/// best-effort basis — a race it usually loses ("Clipboard was dropped very -/// quickly after writing"). Creating a fresh `Clipboard` per call therefore -/// relinquished the selection the instant the call returned, so the next paste -/// read an empty clipboard. -/// -/// Keeping one instance alive for the process lifetime means we stay the -/// selection owner: reads short-circuit to local data and external pastes are -/// served, with no per-call teardown/handoff race. `Clipboard` is `Send + Sync` -/// on every desktop platform, so a `static` behind a `Mutex` is sound. -static CLIPBOARD: std::sync::OnceLock>> = - std::sync::OnceLock::new(); - -/// Run `op` against the process-wide clipboard, creating it on first use. -fn with_clipboard( - op: &'static str, - f: impl FnOnce(&mut arboard::Clipboard) -> Result, -) -> AppResult { - let cell = CLIPBOARD.get_or_init(|| std::sync::Mutex::new(None)); - // A panic while holding the lock can't leave the clipboard in an unsafe - // state, so recover from poisoning rather than failing the operation. - let mut guard = cell.lock().unwrap_or_else(|e| e.into_inner()); - if guard.is_none() { - *guard = Some(arboard::Clipboard::new().map_err(|e| { - AppError::other( - "window_clipboard_failed", - serde_json::json!({ "op": "init", "err": e.to_string() }), - ) - })?); - } - let cb = guard.as_mut().expect("clipboard initialized above"); - f(cb).map_err(|e| { - AppError::other( - "window_clipboard_failed", - serde_json::json!({ "op": op, "err": e.to_string() }), - ) - }) -} - -/// Read the system clipboard as text. -/// Goes through Rust (arboard) to bypass WebKit's permission prompt on -/// externally-sourced clipboard content — `navigator.clipboard.readText()` -/// pops a dialog every time on macOS unless the content was written by the -/// same page in this session. -#[tauri::command] -pub fn clipboard_read() -> AppResult { - with_clipboard("read", |cb| cb.get_text()) -} - -/// Write text to the system clipboard. -/// Mirrors `clipboard_read`: goes through Rust (arboard) because in the -/// WKWebView `navigator.clipboard.writeText` is unreliable from a right-click -/// (contextmenu) / unfocused context — it silently rejects. -#[tauri::command] -pub fn clipboard_write(text: String) -> AppResult<()> { - with_clipboard("write", |cb| cb.set_text(text)) -} diff --git a/src-tauri/src/emitter.rs b/src-tauri/src/emitter.rs index d0a549df..694934c0 100644 --- a/src-tauri/src/emitter.rs +++ b/src-tauri/src/emitter.rs @@ -82,26 +82,57 @@ impl Host { } } + /// Preserve the host's artifact workflow policy independently of window support. + pub fn allows_diagnosis_downloads(&self) -> bool { + match self { + Self::Tauri(_) => crate::platform::runtime::allows_diagnosis_downloads(), + Self::Headless { .. } => true, + } + } + + /// Check before proposing approval; unsupported hosts must not leave a + /// useless approval card waiting for user input. + pub async fn ensure_local_analysis_available(&self) -> Result<(), String> { + match self { + #[cfg(any(windows, macos, linux))] + Host::Tauri(_) => { + crate::platform::window::ensure_app_window_available( + crate::platform::window::AppWindowPurpose::LocalAnalysis, + ) + .await + } + #[cfg(not(any(windows, macos, linux)))] + Host::Tauri(_) => Err("Additional windows are unavailable on this device".into()), + Host::Headless { .. } => { + Err("Local analysis windows are unavailable in headless mode".into()) + } + } + } + /// Spawn a standalone analysis window (the `analyze_locally` AI tool). - /// Desktop-Tauri only; headless has no native windows, so it reports an - /// error the tool surfaces to the model (same as the mobile path). - #[cfg(desktop)] - pub fn open_app_window( + /// The native boundary rechecks window support after approval. + pub async fn open_app_window( &self, label: &str, title: &str, init_script: &str, ) -> Result<(), String> { match self { + #[cfg(any(windows, macos, linux))] Host::Tauri(app) => { - use tauri::{WebviewUrl, WebviewWindowBuilder}; - WebviewWindowBuilder::new(app, label, WebviewUrl::App("index.html".into())) - .title(title) - .inner_size(1200.0, 800.0) - .initialization_script(init_script) - .build() - .map(|_| ()) - .map_err(|e| e.to_string()) + crate::platform::window::open_app_window( + app, + label, + title, + init_script, + crate::platform::window::AppWindowPurpose::LocalAnalysis, + ) + .await + } + #[cfg(not(any(windows, macos, linux)))] + Host::Tauri(_) => { + let _ = (label, title, init_script); + Err("Additional windows are unavailable on this device".into()) } Host::Headless { .. } => Err("multi-window unavailable in headless mode".to_string()), } diff --git a/src-tauri/src/error.rs b/src-tauri/src/error.rs index 8bec90b2..17fb3b63 100644 --- a/src-tauri/src/error.rs +++ b/src-tauri/src/error.rs @@ -28,7 +28,7 @@ impl std::fmt::Display for CodedMsg { impl std::error::Error for CodedMsg {} -#[derive(Debug, thiserror::Error)] +#[derive(Debug, Clone, thiserror::Error)] pub enum AppError { /// SQLite 错误 — `From` 自动包装为 CodedMsg。 #[error(transparent)] diff --git a/src-tauri/src/files/atomic.rs b/src-tauri/src/files/atomic.rs new file mode 100644 index 00000000..5688ecb0 --- /dev/null +++ b/src-tauri/src/files/atomic.rs @@ -0,0 +1,79 @@ +//! A download owns its temporary file until it commits or aborts. + +use std::path::{Path, PathBuf}; +use tokio::io::AsyncWriteExt; + +pub(crate) struct AtomicDownload { + file: Option, + temporary: PathBuf, + destination: PathBuf, + committed: bool, +} + +impl AtomicDownload { + pub fn create(destination: &Path) -> std::io::Result { + let parent = destination.parent().ok_or_else(|| { + std::io::Error::new(std::io::ErrorKind::InvalidInput, "Missing download parent") + })?; + std::fs::create_dir_all(parent)?; + // Never share `.part`: concurrent downloads and pre-existing user + // files must not be truncated or removed by another transfer. + let temporary = parent.join(format!(".rssh-download-{}.part", uuid::Uuid::new_v4())); + let file = std::fs::OpenOptions::new() + .write(true) + .create_new(true) + .open(&temporary)?; + Ok(Self { + file: Some(tokio::fs::File::from_std(file)), + temporary, + destination: destination.to_owned(), + committed: false, + }) + } + + pub fn stream(&mut self) -> &mut tokio::fs::File { + self.file.as_mut().expect("download is open") + } + + async fn close(&mut self) -> std::io::Result<()> { + // Keep ownership in self while awaiting, so dropping this future also + // waits for any Tokio worker before removing its temporary file. + self.stream().shutdown().await?; + drop(self.file.take()); + Ok(()) + } + + pub async fn commit(mut self) -> std::io::Result<()> { + self.close().await?; + // std::fs::rename replaces existing files on Unix and Windows. Do not + // unlink the original first: rename failure must leave it intact. + std::fs::rename(&self.temporary, &self.destination)?; + self.committed = true; + Ok(()) + } + + pub async fn abort(mut self) { + if self.close().await.is_ok() { + let _ = std::fs::remove_file(&self.temporary); + } + } +} + +impl Drop for AtomicDownload { + fn drop(&mut self) { + if self.committed { + return; + } + if let Some(file) = self.file.take() { + let temporary = self.temporary.clone(); + // File::drop can leave a blocking write running. Close its actual + // descriptor before cleanup, including when a transfer is dropped. + tauri::async_runtime::spawn(async move { + drop(file.into_std().await); + let _ = std::fs::remove_file(temporary); + }); + } else { + let _ = std::fs::remove_file(&self.temporary); + } + } +} diff --git a/src-tauri/src/files/mod.rs b/src-tauri/src/files/mod.rs new file mode 100644 index 00000000..76556495 --- /dev/null +++ b/src-tauri/src/files/mod.rs @@ -0,0 +1,232 @@ +//! File access contract shared by commands and native backends. +//! Locations stay opaque; the selected backend owns picker, authorization and I/O mechanics. + +use crate::error::AppResult; + +pub(crate) mod atomic; + +#[path = "native.rs"] +mod backend; + +pub use backend::{download_target, open_file, pick_save, walk_directory, OpenedFile}; + +#[derive(serde::Serialize)] +pub struct PickedLocation { + pub location: String, + pub name: Option, +} + +/// A source location and its relative destination name are separate values. +#[derive(serde::Serialize)] +pub struct LocalWalkEntry { + pub rel_path: String, + pub size: u64, + pub local_path: String, +} + +#[derive(serde::Deserialize)] +pub struct FileFilter { + name: String, + extensions: Vec, +} + +/// Backends classify locations without opening or truncating the destination. +/// Directory authorization permits a sibling temporary file and atomic replacement; +/// a selected provider document grants access only to that document. +pub enum DownloadTarget { + AtomicPath(std::path::PathBuf), + Provider(String), +} + +async fn describe_location(app: &tauri::AppHandle, location: String) -> AppResult { + let name = backend::file_name(app, &location).await?; + Ok(PickedLocation { + location, + name: name.filter(|name| valid_display_name(name)), + }) +} + +// Metadata is a single suggested remote name, never authority to select a path. +// Backslashes remain literal: the remote SFTP namespace uses POSIX separators. +fn valid_display_name(name: &str) -> bool { + !name.is_empty() && name != "." && name != ".." && !name.contains(['/', '\0']) +} + +pub async fn pick_open(app: &tauri::AppHandle) -> AppResult> { + match backend::select_open(app).await? { + Some(location) => describe_location(app, location).await.map(Some), + None => Ok(None), + } +} + +pub async fn pick_open_files(app: &tauri::AppHandle) -> AppResult>> { + let Some(locations) = backend::select_open_files(app).await? else { + return Ok(None); + }; + let mut files = Vec::with_capacity(locations.len()); + for location in locations { + files.push(describe_location(app, location).await?); + } + Ok(Some(files)) +} + +#[cfg(any(windows, macos, linux))] +pub async fn pick_folder(app: &tauri::AppHandle, write: bool) -> AppResult> { + match backend::select_folder(app, write).await? { + Some(location) => describe_location(app, location).await.map(Some), + None => Ok(None), + } +} + +/// Every selected name has an independent outcome; one bad target does not +/// invalidate unrelated downloads in the same selection. +#[derive(Debug, serde::Serialize)] +#[serde(tag = "status", rename_all = "snake_case")] +pub enum ResolvedPath { + Ready { + relative_path: String, + location: String, + }, + Failed { + relative_path: String, + error: String, + }, +} + +impl ResolvedPath { + pub fn new(relative_path: String, result: AppResult) -> Self { + match result { + Ok(location) => Self::Ready { + relative_path, + location, + }, + Err(error) => Self::Failed { + relative_path, + error: error.to_string(), + }, + } + } +} + +pub async fn resolve_paths( + local_root: String, + relative_paths: Vec, + write: bool, +) -> AppResult> { + backend::resolve_paths(local_root, relative_paths, write).await +} + +/// Remote names are relative names, never authority to escape a selected root. +fn validate_relative_path(path: &str) -> AppResult<()> { + let valid = !path.is_empty() + && path.split('/').all(|part| { + let mut components = std::path::Path::new(part).components(); + !part.is_empty() + && part != "." + && part != ".." + && !part.contains('\0') + && matches!(components.next(), Some(std::path::Component::Normal(_))) + && components.next().is_none() + }); + if !valid { + return Err(crate::error::AppError::other( + "file_path_invalid", + serde_json::json!({"err": "Expected a relative file name below the selected directory"}), + )); + } + Ok(()) +} + +// Owning the writer also owns its platform access grant. In particular iOS +// must keep the grant until Tokio's outstanding file work has finished. +impl tokio::io::AsyncWrite for OpenedFile { + fn poll_write( + mut self: std::pin::Pin<&mut Self>, + cx: &mut std::task::Context<'_>, + buf: &[u8], + ) -> std::task::Poll> { + std::pin::Pin::new(self.stream()).poll_write(cx, buf) + } + + fn poll_flush( + mut self: std::pin::Pin<&mut Self>, + cx: &mut std::task::Context<'_>, + ) -> std::task::Poll> { + std::pin::Pin::new(self.stream()).poll_flush(cx) + } + + fn poll_shutdown( + mut self: std::pin::Pin<&mut Self>, + cx: &mut std::task::Context<'_>, + ) -> std::task::Poll> { + std::pin::Pin::new(self.stream()).poll_shutdown(cx) + } +} + +pub async fn save_text( + app: &tauri::AppHandle, + default_name: String, + contents: String, + filters: Vec, +) -> AppResult> { + use tokio::io::AsyncWriteExt; + let Some(path) = pick_save(app, default_name, filters).await? else { + return Ok(None); + }; + let mut file = open_file(app, path.clone(), true).await?; + file.stream().write_all(contents.as_bytes()).await?; + file.stream().flush().await?; + Ok(Some(path)) +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn provider_names_are_single_names_not_remote_paths() { + for name in ["", ".", "..", "../key", "a/b", "a\0b"] { + assert!(!valid_display_name(name), "accepted {name:?}"); + } + for name in ["报告: final %.txt", ".env", "a\\b.txt"] { + assert!(valid_display_name(name), "rejected {name:?}"); + } + } + + #[test] + fn directory_targets_reject_traversal_and_keep_literal_uri_characters() { + for invalid in [ + "", + "/root", + "../secret", + "x/../secret", + "./x", + "x//y", + "x/", + "x\0y", + ] { + assert!( + validate_relative_path(invalid).is_err(), + "accepted {invalid:?}" + ); + } + for valid in ["备份/报告 #%?.txt", ".config/config.json"] { + assert!(validate_relative_path(valid).is_ok()); + } + } + + #[cfg(unix)] + #[test] + fn preserves_posix_file_names_that_are_not_path_separators() { + for valid in ["dir/a\\b.txt", "dir/line\nbreak"] { + assert!(validate_relative_path(valid).is_ok()); + } + } + + #[cfg(windows)] + #[test] + fn rejects_windows_path_components_in_remote_file_names() { + for invalid in ["dir/a\\b.txt", "dir/C:relative", "dir/\\root"] { + assert!(validate_relative_path(invalid).is_err()); + } + } +} diff --git a/src-tauri/src/files/native.rs b/src-tauri/src/files/native.rs new file mode 100644 index 00000000..1ae9909e --- /dev/null +++ b/src-tauri/src/files/native.rs @@ -0,0 +1,506 @@ +//! Standard Tauri file backend: desktop paths, Android provider URIs and iOS scopes. + +use super::{DownloadTarget, FileFilter, LocalWalkEntry}; +use crate::error::{AppError, AppResult}; +use serde_json::json; +use std::{ + collections::VecDeque, + path::{Path, PathBuf}, +}; +use tauri_plugin_dialog::DialogExt; + +pub(super) async fn file_name(app: &tauri::AppHandle, location: &str) -> AppResult> { + #[cfg(android)] + { + use tauri::Manager; + let app = app.clone(); + let source = location.to_owned(); + tokio::task::spawn_blocking(move || app.path().file_name(&source)) + .await + .map_err(dialog_error) + } + #[cfg(not(android))] + { + let _ = app; + Ok(filesystem_path(location.to_owned())? + .file_name() + .map(|name| name.to_string_lossy().into_owned())) + } +} + +pub async fn pick_save( + app: &tauri::AppHandle, + default_name: String, + filters: Vec, +) -> AppResult> { + let app = app.clone(); + tokio::task::spawn_blocking(move || { + let mut dialog = app.dialog().file().set_file_name(default_name); + for filter in filters { + let extensions: Vec<_> = filter.extensions.iter().map(String::as_str).collect(); + dialog = dialog.add_filter(filter.name, &extensions); + } + dialog.blocking_save_file().map(|path| path.to_string()) + }) + .await + .map_err(dialog_error) +} + +pub(super) async fn select_open(app: &tauri::AppHandle) -> AppResult> { + let app = app.clone(); + tokio::task::spawn_blocking(move || { + app.dialog() + .file() + .blocking_pick_file() + .map(|path| path.to_string()) + }) + .await + .map_err(dialog_error) +} + +pub(super) async fn select_open_files(app: &tauri::AppHandle) -> AppResult>> { + let app = app.clone(); + tokio::task::spawn_blocking(move || { + app.dialog() + .file() + .blocking_pick_files() + .map(|files| files.into_iter().map(|file| file.to_string()).collect()) + }) + .await + .map_err(dialog_error) +} + +#[cfg(any(windows, macos, linux))] +pub(super) async fn select_folder( + app: &tauri::AppHandle, + write: bool, +) -> AppResult> { + let _ = write; + let app = app.clone(); + tokio::task::spawn_blocking(move || { + app.dialog() + .file() + .blocking_pick_folder() + .map(|file| file.to_string()) + }) + .await + .map_err(dialog_error) +} + +fn filesystem_path(location: String) -> AppResult { + location + .parse::() + .expect("FilePath::from_str is infallible") + .into_path() + .map_err(|error| AppError::other("file_path_invalid", json!({"err": error.to_string()}))) +} + +pub(super) async fn resolve_paths( + local_root: String, + relative_paths: Vec, + write: bool, +) -> AppResult> { + let _ = write; + let root = filesystem_path(local_root)?; + Ok(relative_paths + .into_iter() + .map(|relative| { + let result = super::validate_relative_path(&relative).map(|()| { + relative + .split('/') + .fold(root.clone(), |path, part| path.join(part)) + .to_string_lossy() + .into_owned() + }); + super::ResolvedPath::new(relative, result) + }) + .collect()) +} + +fn dialog_error(error: tokio::task::JoinError) -> AppError { + AppError::other("dialog_task_failed", json!({"err": error.to_string()})) +} + +/// The iOS authorization must outlive the file it grants access to. +struct FileAccessGuard { + #[cfg(ios)] + app: tauri::AppHandle, + #[cfg(ios)] + path: Option, +} + +impl Drop for FileAccessGuard { + fn drop(&mut self) { + #[cfg(ios)] + if let Some(path) = self.path.take() { + use tauri_plugin_fs::FsExt; + if let Err(error) = self.app.fs().stop_accessing_security_scoped_resource(path) { + log::warn!("failed to release iOS security-scoped file: {error}"); + } + } + } +} + +struct FileResource { + file: tokio::fs::File, + _access: G, +} + +#[cfg(any(ios, test))] +impl FileResource { + async fn close(self) { + // Tokio workers can keep the descriptor alive after File::drop. + // into_std waits for those workers, including after an I/O future + // was cancelled, before we close the descriptor and release access. + let Self { file, _access } = self; + drop(file.into_std().await); + drop(_access); + } +} + +/// Owns the stream and its authorization together for the entire operation. +pub struct OpenedFile { + resource: Option>, +} + +impl OpenedFile { + pub fn stream( + &mut self, + ) -> &mut (impl tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin + Send) { + &mut self.resource.as_mut().expect("file is open").file + } + + pub async fn metadata(&self) -> std::io::Result { + self.resource + .as_ref() + .expect("file is open") + .file + .metadata() + .await + } +} + +impl Drop for OpenedFile { + fn drop(&mut self) { + #[cfg(ios)] + if let Some(resource) = self.resource.take() { + // This task owns the grant even if the caller is cancelled. The + // application runtime outlives individual transfer/window tasks. + tauri::async_runtime::spawn(resource.close()); + } + } +} + +pub async fn download_target( + app: &tauri::AppHandle, + location: String, +) -> AppResult { + // Ordinary paths retain sibling .part + rename. Provider URIs retain the + // selected-file authorization and provider open operation. + if let tauri_plugin_fs::FilePath::Path(path) = location + .parse::() + .expect("FilePath::from_str is infallible") + { + return Ok(DownloadTarget::AtomicPath(path)); + } + let _ = app; + Ok(DownloadTarget::Provider(location)) +} + +pub async fn open_file(app: &tauri::AppHandle, path: String, write: bool) -> AppResult { + use tauri_plugin_fs::{FilePath, FsExt, OpenOptions}; + let path = path + .parse::() + .expect("FilePath::from_str is infallible"); + let access = FileAccessGuard { + #[cfg(ios)] + app: app.clone(), + #[cfg(ios)] + path: match &path { + FilePath::Url(url) if url.scheme() == "file" => Some(path.clone()), + _ => None, + }, + }; + let mut options = OpenOptions::new(); + options + .read(!write) + .write(write) + .create(write) + .truncate(write); + let file = app.fs().open(path, options)?; + Ok(OpenedFile { + resource: Some(FileResource { + file: tokio::fs::File::from_std(file), + _access: access, + }), + }) +} + +#[cfg(test)] +mod tests { + use super::*; + struct AccessProbe { + released: std::sync::mpsc::Sender>, + path: std::path::PathBuf, + } + + impl Drop for AccessProbe { + fn drop(&mut self) { + let _ = self.released.send(std::fs::read(&self.path).unwrap()); + } + } + + // Hold the only blocking worker so Tokio's queued file operation cannot + // finish before the close future is polled. Channel ownership also releases + // the worker if a test assertion fails; there are no timing-based sleeps. + fn hold_file_worker(runtime: &tokio::runtime::Runtime) -> std::sync::mpsc::Sender<()> { + let (started, ready) = std::sync::mpsc::channel(); + let (release, wait) = std::sync::mpsc::channel(); + runtime.spawn_blocking(move || { + started.send(()).unwrap(); + let _ = wait.recv(); + }); + ready.recv().unwrap(); + release + } + + fn check_pending_file_close(read: bool) { + use std::{ + future::Future, + pin::pin, + task::{Context, Waker}, + }; + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + + let source = tempfile::NamedTempFile::new().unwrap(); + std::fs::write(source.path(), b"before").unwrap(); + let runtime = tokio::runtime::Builder::new_current_thread() + .max_blocking_threads(1) + .build() + .unwrap(); + let release = hold_file_worker(&runtime); + let (released, access) = std::sync::mpsc::channel(); + let mut resource = FileResource { + file: tokio::fs::File::from_std(source.reopen().unwrap()), + _access: AccessProbe { + released, + path: source.path().to_owned(), + }, + }; + runtime.block_on(async { + if read { + let mut buf = [0u8; 6]; + let mut reading = pin!(resource.file.read(&mut buf)); + assert!(reading + .as_mut() + .poll(&mut Context::from_waker(Waker::noop())) + .is_pending()); + // Dropping a pending read future leaves the worker owning the FD. + } else { + resource.file.write_all(b"after!").await.unwrap(); + // A cancellation or remote read error now skips normal flush. + } + }); + let mut closing = pin!(resource.close()); + assert!(closing + .as_mut() + .poll(&mut Context::from_waker(Waker::noop())) + .is_pending()); + assert!(matches!( + access.try_recv(), + Err(std::sync::mpsc::TryRecvError::Empty) + )); + release.send(()).unwrap(); + runtime.block_on(closing); + assert_eq!( + access.recv().unwrap(), + if read { b"before" } else { b"after!" } + ); + } + + #[test] + fn authorization_outlives_a_queued_write_after_cancellation() { + check_pending_file_close(false); + } + + #[test] + fn authorization_outlives_a_cancelled_read_future() { + check_pending_file_close(true); + } + + #[test] + fn completed_file_releases_authorization_without_pending_io() { + use std::{ + future::Future, + pin::pin, + task::{Context, Waker}, + }; + + let source = tempfile::NamedTempFile::new().unwrap(); + std::fs::write(source.path(), b"complete").unwrap(); + let (released, access) = std::sync::mpsc::channel(); + let resource = FileResource { + file: tokio::fs::File::from_std(source.reopen().unwrap()), + _access: AccessProbe { + released, + path: source.path().to_owned(), + }, + }; + let mut closing = pin!(resource.close()); + assert!(closing + .as_mut() + .poll(&mut Context::from_waker(Waker::noop())) + .is_ready()); + assert_eq!(access.recv().unwrap(), b"complete"); + } + + #[cfg(not(android))] + #[test] + fn selected_names_decode_file_urls_but_preserve_literal_path_percent_signs() { + let root = std::env::temp_dir(); + for name in ["报告 #%.txt", "literal%20.txt"] { + let path = root.join(name); + let uri = tauri::Url::from_file_path(&path).unwrap().to_string(); + assert_eq!(filesystem_path(uri).unwrap().file_name().unwrap(), name); + assert_eq!( + filesystem_path(path.to_string_lossy().into_owned()) + .unwrap() + .file_name() + .unwrap(), + name + ); + } + } + + #[tokio::test] + async fn desktop_resolution_preserves_root_and_nested_names() { + let root = std::env::temp_dir().join("rssh path test"); + assert_eq!( + filesystem_path(tauri::Url::from_file_path(&root).unwrap().to_string()).unwrap(), + root + ); + let resolved = resolve_paths( + root.to_string_lossy().into_owned(), + vec![ + "目录/a #%.txt".into(), + "../invalid".into(), + "second.txt".into(), + ], + true, + ) + .await + .unwrap(); + assert!( + matches!(&resolved[0], super::super::ResolvedPath::Ready { relative_path, location } + if relative_path == "目录/a #%.txt" && location == &root.join("目录/a #%.txt").to_string_lossy()) + ); + assert!( + matches!(&resolved[1], super::super::ResolvedPath::Failed { relative_path, .. } + if relative_path == "../invalid") + ); + assert!( + matches!(&resolved[2], super::super::ResolvedPath::Ready { relative_path, .. } + if relative_path == "second.txt") + ); + } +} + +/// Maximum recursion depth, matching the remote and native walkers. +const LOCAL_WALK_DEPTH_CAP: u32 = 32; + +/// Walk ordinary filesystem paths without changing the existing symlink policy. +pub async fn walk_directory(local_root: String) -> AppResult> { + let root = filesystem_path(local_root)?; + let mut queue: VecDeque<(PathBuf, u32)> = VecDeque::new(); + queue.push_back((root.clone(), 0)); + let mut result: Vec = Vec::new(); + + while let Some((dir, depth)) = queue.pop_front() { + if depth >= LOCAL_WALK_DEPTH_CAP { + return Err(AppError::other( + "local_tree_too_deep", + json!({ + "path": dir.display().to_string(), + "depth": depth, + "limit": LOCAL_WALK_DEPTH_CAP, + }), + )); + } + let mut rd = tokio::fs::read_dir(&dir).await?; + while let Some(entry) = rd.next_entry().await? { + // `entry.metadata()` does not traverse symlinks — single syscall + // covers both type discrimination and size for regular files, + // replacing the previous file_type() + metadata() double-stat. + let path = entry.path(); + let meta = entry.metadata().await?; + if meta.is_dir() { + queue.push_back((path, depth + 1)); + } else if meta.is_file() { + result.push(LocalWalkEntry { + local_path: path.to_string_lossy().into_owned(), + rel_path: rel_unix(&path, &root), + size: meta.len(), + }); + } else if meta.is_symlink() { + // Follow once to learn what the target is. Skip symlink-to-dir + // to avoid cycles, and silently skip broken symlinks. + if let Ok(target_meta) = tokio::fs::metadata(&path).await { + if target_meta.is_file() { + result.push(LocalWalkEntry { + local_path: path.to_string_lossy().into_owned(), + rel_path: rel_unix(&path, &root), + size: target_meta.len(), + }); + } + } + } + // Anything else (block/char/fifo): skip. + } + } + Ok(result) +} + +/// Convert the portion of `full` relative to `root` into a '/'-separated string. +/// The remote destination always uses '/', independently of the local platform. +fn rel_unix(full: &Path, root: &Path) -> String { + let stripped = full.strip_prefix(root).unwrap_or(full); + stripped + .components() + .map(|c| c.as_os_str().to_string_lossy().into_owned()) + .collect::>() + .join("/") +} + +#[cfg(test)] +mod local_walk_tests { + use super::*; + + #[tokio::test] + async fn file_uri_walk_returns_openable_locations_and_preserves_symlink_policy() { + let root = tempfile::tempdir().unwrap(); + let source = root.path().join("目录 #%"); + std::fs::create_dir_all(source.join("nested")).unwrap(); + std::fs::write(source.join("nested/file #%.txt"), b"content").unwrap(); + #[cfg(unix)] + { + std::os::unix::fs::symlink(&source, source.join("cycle")).unwrap(); + std::os::unix::fs::symlink( + source.join("nested/file #%.txt"), + source.join("file-alias"), + ) + .unwrap(); + } + let entries = walk_directory(tauri::Url::from_file_path(&source).unwrap().to_string()) + .await + .unwrap(); + assert_eq!(entries.len(), if cfg!(unix) { 2 } else { 1 }); + assert!(entries + .iter() + .any(|entry| entry.rel_path == "nested/file #%.txt")); + for entry in entries { + assert_eq!(entry.size, 7); + assert_eq!(std::fs::read(&entry.local_path).unwrap(), b"content"); + assert_eq!(PathBuf::from(entry.local_path), source.join(entry.rel_path)); + } + } +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index efbf91cc..5998d07d 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1,18 +1,21 @@ mod ai; mod commands; -#[cfg(desktop)] +#[cfg(any(windows, macos, linux))] pub use commands::cli::CLI_VERSION; pub mod crypto; pub mod db; pub mod emitter; pub mod error; +mod files; pub mod migration; pub mod models; +mod platform; mod redaction; +mod resource; pub mod secret; mod ssh; pub use ssh::bastion; -#[cfg(all(feature = "server", desktop))] +#[cfg(all(feature = "server", any(windows, macos, linux)))] pub mod server; mod state; pub mod sync; @@ -26,7 +29,7 @@ use tauri::Manager; use state::AppState; -#[cfg(target_os = "linux")] +#[cfg(linux)] fn apply_linux_wayland_compat() { if std::env::var_os("RSSH_DISABLE_WAYLAND_COMPAT").is_some() { return; @@ -56,10 +59,10 @@ fn apply_linux_wayland_compat() { } } -#[cfg(not(target_os = "linux"))] +#[cfg(not(linux))] fn apply_linux_wayland_compat() {} -#[cfg_attr(mobile, tauri::mobile_entry_point)] +#[cfg_attr(any(android, ios), tauri::mobile_entry_point)] pub fn run() { apply_linux_wayland_compat(); @@ -67,37 +70,43 @@ pub fn run() { let _ = env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info")) .try_init(); - tauri::Builder::default() + let builder = tauri::Builder::default() .plugin(tauri_plugin_opener::init()) .plugin(tauri_plugin_dialog::init()) - .plugin(tauri_plugin_fs::init()) - .on_window_event(|window, event| { + .plugin(tauri_plugin_fs::init()); + builder + // Android/iOS Activity recreation is not a logical window close. + .on_window_event(|_window, event| { match event { // Mobile (Android/iOS): Activity lifecycle changes can fire // WindowEvent::Destroyed when the app merely goes to background // (e.g. a fullscreen file picker opens). Closing sessions there - // would silently disconnect every SSH tab. Desktop only. - #[cfg(desktop)] + // would silently disconnect every SSH tab. + #[cfg(any(windows, macos, linux))] tauri::WindowEvent::Destroyed => { - let state = window.state::(); + let state = _window.state::(); // Close only sessions belonging to this window. - commands::lifecycle::close_window_sessions(&state, window.label()); + commands::lifecycle::close_window_sessions(&state, _window.label()); } _ => {} } }) .setup(|app| { - #[cfg(mobile)] + // Android/iOS use Tauri's sandbox path. + #[cfg(any(android, ios))] let data_dir = app.path().app_data_dir()?; - #[cfg(desktop)] + #[cfg(any(windows, macos, linux))] let data_dir = db::data_dir()?; // 启动时扫一次本机可用 shell,结果缓存到进程退出。 // 用户在 Shell 设置页打开时直接读缓存,没冷启动开销。 - // PTY 模块本身就是桌面端独占(移动端没有 portable_pty)。 - #[cfg(desktop)] + #[cfg(any(windows, macos, linux))] terminal::pty::init_available_shells(); let db = Arc::new(db::Db::open(&data_dir)?); + // The plugin store follows the actual host data directory (including + // application sandboxes), rather than a desktop-only path template. + app.asset_protocol_scope() + .allow_directory(data_dir.join("plugins"), true)?; // secret::open 可能失败:sticky backend 标记 keyring 但 keychain 现在 // 拿不到(系统 keychain 损坏 / D-Bus 挂等)→ 硬 fail 启动。silently // fallback file 会用新主密钥让旧密文全部解不开,比启动失败更危险。 @@ -118,9 +127,9 @@ pub fn run() { secret_store: secret_system.store, lifecycle_sessions: Mutex::new(HashMap::new()), sessions: Mutex::new(HashMap::new()), - #[cfg(desktop)] + #[cfg(any(windows, macos, linux))] pty_sessions: Mutex::new(HashMap::new()), - #[cfg(desktop)] + #[cfg(any(windows, macos, linux))] serial_sessions: Mutex::new(HashMap::new()), telnet_sessions: Mutex::new(HashMap::new()), sftp_sessions: Mutex::new(HashMap::new()), @@ -138,6 +147,8 @@ pub fn run() { Ok(()) }) .invoke_handler(tauri::generate_handler![ + commands::runtime::get_runtime_capabilities, + commands::files::resolve_local_paths, // profile & credential commands::profile::list_profiles, commands::profile::get_profile, @@ -212,45 +223,47 @@ pub fn run() { commands::session::ssh_host_key_cancel, // session lifecycle commands::lifecycle::reconcile_sessions, - // PTY (desktop only) - #[cfg(desktop)] + // PTY (desktop hosts) + #[cfg(any(windows, macos, linux))] commands::pty::list_shells, - #[cfg(desktop)] + #[cfg(any(windows, macos, linux))] commands::pty::refresh_shells, - #[cfg(desktop)] + #[cfg(any(windows, macos, linux))] commands::pty::pty_spawn, - #[cfg(desktop)] + #[cfg(any(windows, macos, linux))] commands::pty::pty_spawn_connector, - #[cfg(desktop)] + #[cfg(any(windows, macos, linux))] commands::pty::pty_write, - #[cfg(desktop)] + #[cfg(any(windows, macos, linux))] commands::pty::pty_resize, - #[cfg(desktop)] + #[cfg(any(windows, macos, linux))] commands::pty::pty_close, - // Serial (desktop only) - #[cfg(desktop)] + // Serial (desktop hosts) + #[cfg(any(windows, macos, linux))] + commands::serial::serial_get_capabilities, + #[cfg(any(windows, macos, linux))] commands::serial::serial_list_ports, - #[cfg(desktop)] + #[cfg(any(windows, macos, linux))] commands::serial::serial_open, - #[cfg(desktop)] + #[cfg(any(windows, macos, linux))] commands::serial::serial_write, - #[cfg(desktop)] + #[cfg(any(windows, macos, linux))] commands::serial::serial_close, - #[cfg(desktop)] + #[cfg(any(windows, macos, linux))] commands::serial::serial_set_dtr, - #[cfg(desktop)] + #[cfg(any(windows, macos, linux))] commands::serial::serial_set_rts, - #[cfg(desktop)] + #[cfg(any(windows, macos, linux))] commands::serial::serial_send_break, - #[cfg(desktop)] + #[cfg(any(windows, macos, linux))] commands::serial::list_serial_profiles, - #[cfg(desktop)] + #[cfg(any(windows, macos, linux))] commands::serial::get_serial_profile, - #[cfg(desktop)] + #[cfg(any(windows, macos, linux))] commands::serial::create_serial_profile, - #[cfg(desktop)] + #[cfg(any(windows, macos, linux))] commands::serial::update_serial_profile, - #[cfg(desktop)] + #[cfg(any(windows, macos, linux))] commands::serial::delete_serial_profile, // Telnet (all platforms — plain TCP) commands::telnet::telnet_open, @@ -277,35 +290,26 @@ pub fn run() { // Stream transfer to/from a path (desktop) or content:// URI (mobile). commands::sftp::sftp_download_to, commands::sftp::sftp_upload_from, - // SFTP native file transfer (desktop only) - #[cfg(desktop)] - commands::sftp::sftp_save_file, - #[cfg(desktop)] - commands::sftp::sftp_pick_and_upload, - #[cfg(desktop)] commands::sftp::sftp_pick_save_path, - #[cfg(desktop)] commands::sftp::sftp_pick_open_path, - #[cfg(desktop)] + #[cfg(any(windows, macos, linux))] commands::sftp::sftp_pick_folder, - #[cfg(desktop)] commands::sftp::sftp_pick_open_files, commands::sftp::sftp_cancel_transfer, + commands::files::save_text_file, commands::sftp::sftp_remove, commands::sftp::sftp_rename, commands::sftp::sftp_stat, // CLI install - #[cfg(desktop)] + #[cfg(any(windows, macos, linux))] commands::cli::cli_status, - #[cfg(desktop)] + #[cfg(any(windows, macos, linux))] commands::cli::cli_install, - // multi-window (desktop only) - #[cfg(desktop)] + // Native multi-window hosts + #[cfg(any(windows, macos, linux))] commands::window::open_tab_in_new_window, - #[cfg(desktop)] - commands::window::clipboard_read, - #[cfg(desktop)] - commands::window::clipboard_write, + commands::clipboard::clipboard_read, + commands::clipboard::clipboard_write, // external URL opener — cross-platform via tauri-plugin-opener commands::external::open_external_url, // update check (cross-platform — separate mod from window) diff --git a/src-tauri/src/platform/clipboard.rs b/src-tauri/src/platform/clipboard.rs new file mode 100644 index 00000000..cab77d60 --- /dev/null +++ b/src-tauri/src/platform/clipboard.rs @@ -0,0 +1,50 @@ +//! Clipboard access with native ownership kept inside the selected backend. + +use crate::error::AppResult; +#[cfg(any(windows, macos, linux))] +#[path = "tauri/clipboard.rs"] +mod backend; + +pub async fn read_text() -> AppResult { + #[cfg(any(windows, macos, linux))] + { + tokio::task::spawn_blocking(backend::read_text) + .await + .map_err(join_error)? + } + #[cfg(any(android, ios))] + { + Err(unavailable("read")) + } +} + +pub async fn write_text(text: String) -> AppResult<()> { + #[cfg(any(windows, macos, linux))] + { + tokio::task::spawn_blocking(move || backend::write_text(text)) + .await + .map_err(join_error)? + } + #[cfg(any(android, ios))] + { + let _ = text; + Err(unavailable("write")) + } +} + +#[cfg(any(windows, macos, linux))] +fn join_error(error: tokio::task::JoinError) -> crate::error::AppError { + crate::error::AppError::other( + "window_clipboard_failed", + serde_json::json!({"op": "dispatch", "err": error.to_string()}), + ) +} + +#[cfg(any(android, ios))] +fn unavailable(op: &str) -> crate::error::AppError { + // These hosts retain their WebView clipboard adapter, selected by capabilities. + crate::error::AppError::other( + "window_clipboard_failed", + serde_json::json!({"op": op, "err": "Clipboard access is provided by the WebView"}), + ) +} diff --git a/src-tauri/src/platform/external.rs b/src-tauri/src/platform/external.rs new file mode 100644 index 00000000..63933193 --- /dev/null +++ b/src-tauri/src/platform/external.rs @@ -0,0 +1,44 @@ +//! External-link policy is shared; opening mechanics belong to the host. +use crate::error::{AppError, AppResult}; + +#[path = "tauri/external.rs"] +mod backend; + +pub async fn open_url(app: &tauri::AppHandle, url: String) -> AppResult<()> { + validate_url(&url)?; + backend::open_url(app, url).await +} + +fn validate_url(url: &str) -> AppResult<()> { + // Preserve the existing http(s)-only command contract on every host. + if !url.starts_with("http://") && !url.starts_with("https://") { + return Err(AppError::config( + "window_non_https_url", + serde_json::json!({"url": url}), + )); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn only_web_links_reach_a_platform_opener() { + for url in ["https://example.com", "http://localhost:8000/path"] { + assert!(validate_url(url).is_ok()); + } + for url in [ + "file:///etc/passwd", + "javascript:alert(1)", + "mailto:a@example.com", + "", + ] { + assert_eq!( + validate_url(url).unwrap_err().code(), + "window_non_https_url" + ); + } + } +} diff --git a/src-tauri/src/platform/mod.rs b/src-tauri/src/platform/mod.rs new file mode 100644 index 00000000..d307cc4b --- /dev/null +++ b/src-tauri/src/platform/mod.rs @@ -0,0 +1,7 @@ +//! Native host contracts. Business commands select operations, not platform protocols. + +pub mod clipboard; +pub mod external; +pub mod runtime; +#[cfg(any(windows, macos, linux))] +pub mod window; diff --git a/src-tauri/src/platform/runtime.rs b/src-tauri/src/platform/runtime.rs new file mode 100644 index 00000000..09bdcb17 --- /dev/null +++ b/src-tauri/src/platform/runtime.rs @@ -0,0 +1,157 @@ +use crate::error::AppResult; + +/// What this host implements. Device shape is a separate frontend concern. +#[derive(Clone, Debug, serde::Serialize)] +#[serde(rename_all = "camelCase")] +pub struct RuntimeCapabilities { + pub local_pty: bool, + pub serial: bool, + pub local_discovery: bool, + pub ssh_agent: bool, + pub default_key_files: bool, + pub cli_install: bool, + pub multi_window: bool, + pub window_controls: bool, + pub window_pin: bool, + pub file_multi_select: bool, + pub directory_transfer: bool, + pub plugins: bool, + pub native_clipboard: bool, + pub release_update_check: bool, + pub terminal_policy: TerminalRuntimePolicy, +} + +impl RuntimeCapabilities { + pub(crate) fn native() -> Self { + Self { + local_pty: cfg!(any(windows, macos, linux)), + serial: cfg!(any(windows, macos, linux)), + local_discovery: cfg!(any(windows, macos, linux)), + ssh_agent: cfg!(any(windows, macos, linux)), + default_key_files: cfg!(any(windows, macos, linux)), + cli_install: cfg!(any(windows, macos, linux)), + multi_window: cfg!(any(windows, macos, linux)), + window_controls: cfg!(any(windows, macos, linux)), + window_pin: cfg!(any(windows, macos, linux)), + // Native file pickers support multiple sources on desktop and mobile. + // Directory authorization remains a separate host capability. + file_multi_select: true, + directory_transfer: cfg!(any(windows, macos, linux)), + plugins: true, + native_clipboard: cfg!(any(windows, macos, linux)), + // App Store/TestFlight manages native iOS releases. Browser clients + // follow their RSSH server's policy, not the browser's operating system. + release_update_check: !cfg!(ios), + terminal_policy: if cfg!(any(android, ios)) { + TerminalRuntimePolicy::constrained() + } else { + TerminalRuntimePolicy::desktop() + }, + } + } + + #[cfg(all(feature = "server", any(windows, macos, linux)))] + pub(crate) fn headless() -> Self { + Self { + cli_install: false, + window_controls: false, + window_pin: false, + plugins: false, + native_clipboard: false, + ..Self::native() + } + } +} + +/// Resource and renderer defaults are host policy, never layout or input detection. +#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize)] +#[serde(rename_all = "camelCase")] +pub struct TerminalRuntimePolicy { + pub image_storage_limit_mb: u32, + pub image_pixel_limit: u32, + pub output_backlog_limit_bytes: u32, + pub gpu_render_default: bool, +} + +impl TerminalRuntimePolicy { + pub(crate) fn desktop() -> Self { + Self { + image_storage_limit_mb: 128, + image_pixel_limit: 16_000_000, + output_backlog_limit_bytes: 128 * 1024 * 1024, + gpu_render_default: true, + } + } + + pub(crate) fn constrained() -> Self { + Self { + image_storage_limit_mb: 32, + image_pixel_limit: 4_000_000, + output_backlog_limit_bytes: 32 * 1024 * 1024, + gpu_render_default: false, + } + } +} + +pub async fn capabilities(window: &tauri::Window) -> AppResult { + let _ = window; + Ok(RuntimeCapabilities::native()) +} + +/// Android/iOS retain their existing diagnosis policy for private artifacts. +pub fn allows_diagnosis_downloads() -> bool { + !cfg!(any(android, ios)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn native_capabilities_have_the_shared_wire_contract() { + let value = serde_json::to_value(RuntimeCapabilities::native()).unwrap(); + for key in [ + "localPty", + "serial", + "localDiscovery", + "sshAgent", + "defaultKeyFiles", + "cliInstall", + "multiWindow", + "windowControls", + "windowPin", + "fileMultiSelect", + "directoryTransfer", + "plugins", + "nativeClipboard", + "releaseUpdateCheck", + ] { + assert!(value[key].is_boolean(), "missing boolean capability {key}"); + } + assert_eq!(value.as_object().unwrap().len(), 15); + assert_eq!(value["fileMultiSelect"], true); + assert_eq!(value["releaseUpdateCheck"], !cfg!(ios)); + let expected = if cfg!(any(android, ios)) { + TerminalRuntimePolicy::constrained() + } else { + TerminalRuntimePolicy::desktop() + }; + assert_eq!( + value["terminalPolicy"], + serde_json::to_value(expected).unwrap() + ); + } + + #[test] + #[cfg(all(feature = "server", any(windows, macos, linux)))] + fn headless_uses_browser_windows_and_host_managed_cli() { + let capabilities = RuntimeCapabilities::headless(); + assert!(capabilities.multi_window); + assert!(!capabilities.window_controls); + assert!(!capabilities.window_pin); + assert!(!capabilities.cli_install); + assert!(!capabilities.plugins); + assert!(capabilities.local_pty); + assert!(capabilities.release_update_check); + } +} diff --git a/src-tauri/src/platform/tauri/clipboard.rs b/src-tauri/src/platform/tauri/clipboard.rs new file mode 100644 index 00000000..e1d59b24 --- /dev/null +++ b/src-tauri/src/platform/tauri/clipboard.rs @@ -0,0 +1,63 @@ +use crate::error::{AppError, AppResult}; + +/// One `arboard::Clipboard` for the whole process, created lazily. +/// +/// On X11 the clipboard is a *selection ownership* protocol, not a store: the +/// process that wrote the text must stay alive to serve other apps' (and our +/// own paste's) `SelectionRequest`s. arboard owns the CLIPBOARD selection only +/// while at least one `Clipboard` instance is alive; the last one to drop tears +/// down its X11 window and hands the data off to a clipboard manager on a +/// best-effort basis — a race it usually loses ("Clipboard was dropped very +/// quickly after writing"). Creating a fresh `Clipboard` per call therefore +/// relinquished the selection the instant the call returned, so the next paste +/// read an empty clipboard. +/// +/// Keeping one instance alive for the process lifetime means we stay the +/// selection owner: reads short-circuit to local data and external pastes are +/// served, with no per-call teardown/handoff race. `Clipboard` is `Send + Sync` +/// on every desktop platform, so a `static` behind a `Mutex` is sound. +static CLIPBOARD: std::sync::OnceLock>> = + std::sync::OnceLock::new(); + +/// Run `op` against the process-wide clipboard, creating it on first use. +fn with_clipboard( + op: &'static str, + f: impl FnOnce(&mut arboard::Clipboard) -> Result, +) -> AppResult { + let cell = CLIPBOARD.get_or_init(|| std::sync::Mutex::new(None)); + // A panic while holding the lock can't leave the clipboard in an unsafe + // state, so recover from poisoning rather than failing the operation. + let mut guard = cell.lock().unwrap_or_else(|e| e.into_inner()); + if guard.is_none() { + *guard = Some(arboard::Clipboard::new().map_err(|e| { + AppError::other( + "window_clipboard_failed", + serde_json::json!({ "op": "init", "err": e.to_string() }), + ) + })?); + } + let cb = guard.as_mut().expect("clipboard initialized above"); + f(cb).map_err(|e| { + AppError::other( + "window_clipboard_failed", + serde_json::json!({ "op": op, "err": e.to_string() }), + ) + }) +} + +/// Read the system clipboard as text. +/// Goes through Rust (arboard) to bypass WebKit's permission prompt on +/// externally-sourced clipboard content — `navigator.clipboard.readText()` +/// pops a dialog every time on macOS unless the content was written by the +/// same page in this session. +pub fn read_text() -> AppResult { + with_clipboard("read", |cb| cb.get_text()) +} + +/// Write text to the system clipboard. +/// Mirrors `clipboard_read`: goes through Rust (arboard) because in the +/// WKWebView `navigator.clipboard.writeText` is unreliable from a right-click +/// (contextmenu) / unfocused context — it silently rejects. +pub fn write_text(text: String) -> AppResult<()> { + with_clipboard("write", |cb| cb.set_text(text)) +} diff --git a/src-tauri/src/platform/tauri/external.rs b/src-tauri/src/platform/tauri/external.rs new file mode 100644 index 00000000..42e1030b --- /dev/null +++ b/src-tauri/src/platform/tauri/external.rs @@ -0,0 +1,11 @@ +use crate::error::{AppError, AppResult}; + +pub async fn open_url(app: &tauri::AppHandle, url: String) -> AppResult<()> { + use tauri_plugin_opener::OpenerExt; + app.opener().open_url(&url, None::<&str>).map_err(|error| { + AppError::other( + "window_open_url_failed", + serde_json::json!({"err": error.to_string()}), + ) + }) +} diff --git a/src-tauri/src/platform/tauri/window.rs b/src-tauri/src/platform/tauri/window.rs new file mode 100644 index 00000000..ecb73243 --- /dev/null +++ b/src-tauri/src/platform/tauri/window.rs @@ -0,0 +1,10 @@ +use super::AppWindowPurpose; + +pub(super) async fn ensure_available(_purpose: AppWindowPurpose) -> Result<(), String> { + Ok(()) +} + +pub(super) async fn confirm_creation(_window: tauri::WebviewWindow) -> Result<(), String> { + // These backends finish native creation before build() returns. + Ok(()) +} diff --git a/src-tauri/src/platform/window.rs b/src-tauri/src/platform/window.rs new file mode 100644 index 00000000..83be673b --- /dev/null +++ b/src-tauri/src/platform/window.rs @@ -0,0 +1,35 @@ +//! Shared window operations; native creation protocols belong to the backend. + +use tauri::{AppHandle, WebviewUrl, WebviewWindowBuilder}; + +#[path = "tauri/window.rs"] +mod backend; + +#[derive(Clone, Copy)] +pub(crate) enum AppWindowPurpose { + Tab, + LocalAnalysis, +} + +pub(crate) async fn ensure_app_window_available(purpose: AppWindowPurpose) -> Result<(), String> { + backend::ensure_available(purpose).await +} + +/// Both IPC tab cloning and AI handoff use this boundary. The window's unique +/// label becomes its session owner when the frontend creates its resources. +pub(crate) async fn open_app_window( + app: &AppHandle, + label: &str, + title: &str, + init_script: &str, + purpose: AppWindowPurpose, +) -> Result<(), String> { + ensure_app_window_available(purpose).await?; + let window = WebviewWindowBuilder::new(app, label, WebviewUrl::App("index.html".into())) + .title(title) + .inner_size(1200.0, 800.0) + .initialization_script(init_script) + .build() + .map_err(|error| error.to_string())?; + backend::confirm_creation(window).await +} diff --git a/src-tauri/src/resource.rs b/src-tauri/src/resource.rs new file mode 100644 index 00000000..6b3d9c5a --- /dev/null +++ b/src-tauri/src/resource.rs @@ -0,0 +1,482 @@ +//! Cancellation and cleanup contracts for resources whose release can fail. +//! +//! Identity, ownership and activation belong to the lifecycle registry. Platform +//! adapters register cleanup before starting an operation with external effects. + +use std::future::Future; +use std::pin::Pin; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{Arc, Mutex}; +use std::time::Duration; + +use crate::error::{locked, AppError, AppResult}; + +pub type CleanupFuture<'a> = Pin> + Send + 'a>>; + +/// Implementations must allow close to be retried after failure and repeated +/// after success. A successful return means the resource has been released. +pub trait Cleanup: Send + Sync { + fn close(&self) -> CleanupFuture<'_>; +} + +#[derive(Clone)] +pub struct CleanupHandle(Arc); + +impl CleanupHandle { + pub fn new(cleanup: Arc) -> Self { + Self(cleanup) + } + + async fn close(&self) -> AppResult<()> { + self.0.close().await + } +} + +impl std::fmt::Debug for CleanupHandle { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str("CleanupHandle") + } +} + +#[derive(Clone, Debug)] +enum OperationCompletion { + Complete(AppResult<()>), + Interrupted, +} + +#[derive(Clone, Debug)] +pub(crate) struct PendingOperationState { + cancel: tokio::sync::watch::Sender, + finished: tokio::sync::watch::Receiver>, + cleanup: Arc>>, +} + +/// The opening task owns this guard until activation or failed-open cleanup. +pub struct PendingOperation { + cancelled: tokio::sync::watch::Receiver, + finished: tokio::sync::watch::Sender>, + cleanup: Arc>>, +} + +impl PendingOperation { + pub(crate) fn new() -> (Self, PendingOperationState) { + let (cancel, cancelled) = tokio::sync::watch::channel(false); + let (finished, completion) = tokio::sync::watch::channel(None); + let cleanup = Arc::new(Mutex::new(None)); + ( + Self { + cancelled, + finished, + cleanup: cleanup.clone(), + }, + PendingOperationState { + cancel, + finished: completion, + cleanup, + }, + ) + } + + pub async fn cancelled(&self) { + let mut cancelled = self.cancelled.clone(); + while !*cancelled.borrow_and_update() { + if cancelled.changed().await.is_err() { + return; + } + } + } + + /// Register before dispatching the native operation, so aborting its future + /// cannot lose a partially acquired resource. + pub fn retain_cleanup(&self, handle: CleanupHandle) -> AppResult<()> { + let mut cleanup = locked(&self.cleanup)?; + if cleanup.is_some() { + return Err(AppError::other( + "session_registry_inconsistent", + serde_json::json!({}), + )); + } + *cleanup = Some(handle); + Ok(()) + } + + /// Completion means the task ended; only Ok means cleanup or activation + /// transferred ownership successfully. + pub fn complete(self, mut result: AppResult<()>) { + if result.is_ok() { + match locked(&self.cleanup) { + Ok(mut cleanup) => drop(cleanup.take()), + Err(error) => result = Err(error), + } + } + self.finished + .send_replace(Some(OperationCompletion::Complete(result))); + } + + async fn fail(self, error: AppError) -> AppError { + let cleanup = locked(&self.cleanup).map(|cleanup| cleanup.clone()); + let result = match cleanup { + Ok(Some(cleanup)) => cleanup.close().await, + Ok(None) => Ok(()), + Err(error) => Err(error), + }; + self.complete(result); + error + } + + pub async fn finish_open( + self, + result: AppResult<(String, T)>, + ) -> AppResult> { + match result { + Ok((id, handle)) => Ok(OpenedResource { + id, + handle, + operation: self, + }), + Err(error) => Err(self.fail(error).await), + } + } +} + +impl Drop for PendingOperation { + fn drop(&mut self) { + let completed = self.finished.borrow().is_some(); + if !completed { + self.finished + .send_replace(Some(OperationCompletion::Interrupted)); + } + } +} + +pub struct OpenedResource { + id: String, + handle: T, + operation: PendingOperation, +} + +impl OpenedResource { + pub async fn activate( + self, + activate: impl FnOnce(&str, T) -> AppResult<()>, + ) -> AppResult { + let Self { + id, + handle, + operation, + } = self; + match activate(&id, handle) { + Ok(()) => { + operation.complete(Ok(())); + Ok(id) + } + Err(error) => Err(operation.fail(error).await), + } + } +} + +#[derive(Clone, Debug)] +enum CleanupState { + Pending(PendingOperationState), + Resource(CleanupHandle), + Failed(AppError), + Finished, +} + +/// The registry retains this owner until cleanup succeeds. Concurrent callers +/// join its operation; failures leave the resource available for another close. +#[derive(Debug)] +pub(crate) struct ResourceCleanup { + operation: tokio::sync::Mutex<()>, + state: Mutex, + background_started: AtomicBool, +} + +impl ResourceCleanup { + fn new(state: CleanupState) -> Arc { + Arc::new(Self { + operation: tokio::sync::Mutex::new(()), + state: Mutex::new(state), + background_started: AtomicBool::new(false), + }) + } + + pub(crate) fn pending(operation: PendingOperationState) -> Arc { + operation.cancel.send_replace(true); + Self::new(CleanupState::Pending(operation)) + } + + pub(crate) fn ready(cleanup: CleanupHandle) -> Arc { + Self::new(CleanupState::Resource(cleanup)) + } + + pub(crate) fn is_finished(&self) -> bool { + self.state + .lock() + .is_ok_and(|state| matches!(*state, CleanupState::Finished)) + } + + pub(crate) async fn finish(&self) -> AppResult<()> { + let _operation = self.operation.lock().await; + // The stored state retains ownership throughout the await. Phase reads + // do not depend on whether another closer currently holds the gate. + let attempt = locked(&self.state)?.clone(); + match attempt { + CleanupState::Pending(mut operation) => { + let completion = loop { + if let Some(result) = operation.finished.borrow_and_update().clone() { + break result; + } + if operation.finished.changed().await.is_err() { + break OperationCompletion::Interrupted; + } + }; + let retained = locked(&operation.cleanup)?.take(); + // An interrupted opener never acknowledged a cleanup attempt. + // Adopt its resource before awaiting close, so cancellation of + // this closer still leaves a retriable owner in the registry. + let result = match completion { + OperationCompletion::Complete(result) => result, + OperationCompletion::Interrupted => { + if let Some(resource) = retained { + *locked(&self.state)? = CleanupState::Resource(resource.clone()); + resource.close().await?; + } + // No registration means no external operation was dispatched. + *locked(&self.state)? = CleanupState::Finished; + return Ok(()); + } + }; + *locked(&self.state)? = match (&result, retained) { + (Ok(()), _) => CleanupState::Finished, + (Err(_), Some(resource)) => CleanupState::Resource(resource), + (Err(error), None) => CleanupState::Failed(error.clone()), + }; + result + } + CleanupState::Resource(resource) => { + resource.close().await?; + *locked(&self.state)? = CleanupState::Finished; + Ok(()) + } + CleanupState::Failed(error) => Err(error), + CleanupState::Finished => Ok(()), + } + } + + pub(crate) fn start(self: &Arc) { + if self.background_started.swap(true, Ordering::AcqRel) { + return; + } + let cleanup = self.clone(); + tauri::async_runtime::spawn(async move { + let mut delay = Duration::from_millis(100); + while let Err(error) = cleanup.finish().await { + log::warn!("session cleanup failed; retained for retry: {error}"); + // Only an actual retained resource can be retried. A failed + // operation with no handle has nothing for a worker to release. + if !cleanup.state.lock().is_ok_and(|state| { + matches!(*state, CleanupState::Pending(_) | CleanupState::Resource(_)) + }) { + break; + } + tokio::time::sleep(delay).await; + delay = (delay * 2).min(Duration::from_secs(30)); + } + }); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; + + #[derive(Default)] + struct Probe { + calls: AtomicUsize, + fail: AtomicBool, + started: tokio::sync::Notify, + release: Option, + } + + impl Cleanup for Probe { + fn close(&self) -> CleanupFuture<'_> { + Box::pin(async move { + self.calls.fetch_add(1, Ordering::SeqCst); + self.started.notify_one(); + if self.fail.swap(false, Ordering::SeqCst) { + return Err(error("cleanup_failed")); + } + if let Some(release) = &self.release { + release.notified().await; + } + Ok(()) + }) + } + } + + fn error(code: &'static str) -> AppError { + AppError::other(code, serde_json::json!({})) + } + + fn opening(probe: &Arc) -> (PendingOperation, PendingOperationState) { + let (operation, state) = PendingOperation::new(); + operation + .retain_cleanup(CleanupHandle::new(probe.clone())) + .unwrap(); + (operation, state) + } + + #[tokio::test] + async fn background_cleanup_retries_without_a_live_frontend() { + let probe = Arc::new(Probe::default()); + probe.fail.store(true, Ordering::SeqCst); + let cleanup = ResourceCleanup::ready(CleanupHandle::new(probe.clone())); + cleanup.start(); + tokio::time::timeout(std::time::Duration::from_secs(2), async { + while !cleanup.is_finished() { + tokio::task::yield_now().await; + } + }) + .await + .expect("destroyed windows have nobody left to request another close"); + assert_eq!(probe.calls.load(Ordering::SeqCst), 2); + } + + #[tokio::test] + async fn repeated_background_starts_share_one_retry_worker() { + let probe = Arc::new(Probe { + release: Some(tokio::sync::Notify::new()), + ..Default::default() + }); + let cleanup = ResourceCleanup::ready(CleanupHandle::new(probe.clone())); + cleanup.start(); + cleanup.start(); + probe.started.notified().await; + assert_eq!(probe.calls.load(Ordering::SeqCst), 1); + probe.release.as_ref().unwrap().notify_one(); + tokio::time::timeout(Duration::from_secs(2), async { + while !cleanup.is_finished() { + tokio::task::yield_now().await; + } + }) + .await + .unwrap(); + cleanup.finish().await.unwrap(); + assert_eq!(probe.calls.load(Ordering::SeqCst), 1); + } + + #[tokio::test] + async fn failed_open_waits_for_release_and_keeps_its_original_error() { + let probe = Arc::new(Probe { + release: Some(tokio::sync::Notify::new()), + ..Default::default() + }); + let (operation, state) = opening(&probe); + let opening = operation.finish_open::<()>(Err(error("open_failed"))); + tokio::pin!(opening); + tokio::select! { + biased; + _ = &mut opening => panic!("open returned before cleanup"), + _ = probe.started.notified() => {}, + } + let cleanup = ResourceCleanup::pending(state); + assert!(!cleanup.is_finished()); + probe.release.as_ref().unwrap().notify_one(); + assert_eq!(opening.await.err().unwrap().code(), "open_failed"); + cleanup.finish().await.unwrap(); + assert!(cleanup.is_finished()); + assert_eq!(probe.calls.load(Ordering::SeqCst), 1); + } + + #[tokio::test] + async fn failed_open_and_failed_cleanup_keep_independent_errors_and_retry_ownership() { + let probe = Arc::new(Probe::default()); + probe.fail.store(true, Ordering::SeqCst); + let (operation, state) = opening(&probe); + assert_eq!( + operation + .finish_open::<()>(Err(error("open_failed"))) + .await + .err() + .unwrap() + .code(), + "open_failed" + ); + let cleanup = ResourceCleanup::pending(state); + assert_eq!(cleanup.finish().await.unwrap_err().code(), "cleanup_failed"); + assert_eq!(probe.calls.load(Ordering::SeqCst), 1); + cleanup.finish().await.unwrap(); + assert!(cleanup.is_finished()); + assert_eq!(probe.calls.load(Ordering::SeqCst), 2); + } + + #[tokio::test] + async fn activation_failure_releases_registered_resource_but_success_transfers_it() { + for succeeds in [false, true] { + let probe = Arc::new(Probe::default()); + let (operation, state) = opening(&probe); + let opened = operation + .finish_open(Ok(("attempt".into(), 17))) + .await + .unwrap(); + let result = opened + .activate(|id, handle| { + assert_eq!(id, "attempt"); + assert_eq!(handle, 17); + if succeeds { + Ok(()) + } else { + Err(error("reservation_lost")) + } + }) + .await; + if succeeds { + assert_eq!(result.unwrap(), "attempt"); + } else { + assert_eq!(result.unwrap_err().code(), "reservation_lost"); + } + let cleanup = ResourceCleanup::pending(state); + cleanup.finish().await.unwrap(); + assert!(cleanup.is_finished()); + assert_eq!(probe.calls.load(Ordering::SeqCst), usize::from(!succeeds)); + } + } + + #[tokio::test] + async fn abandoned_opened_resource_is_released_by_its_owner() { + let probe = Arc::new(Probe::default()); + let (operation, state) = opening(&probe); + let opened = operation + .finish_open(Ok(("attempt".into(), ()))) + .await + .unwrap(); + drop(opened); + let cleanup = ResourceCleanup::pending(state); + cleanup.finish().await.unwrap(); + assert!(cleanup.is_finished()); + assert_eq!(probe.calls.load(Ordering::SeqCst), 1); + } + + #[tokio::test] + async fn cancelled_closer_leaves_the_resource_owned_for_retry() { + let probe = Arc::new(Probe { + release: Some(tokio::sync::Notify::new()), + ..Default::default() + }); + let (operation, state) = opening(&probe); + drop(operation); + let cleanup = ResourceCleanup::pending(state); + let task_cleanup = cleanup.clone(); + let close = tokio::spawn(async move { task_cleanup.finish().await }); + probe.started.notified().await; + close.abort(); + assert!(close.await.unwrap_err().is_cancelled()); + assert!(!cleanup.is_finished()); + probe.release.as_ref().unwrap().notify_one(); + cleanup.finish().await.unwrap(); + assert!(cleanup.is_finished()); + assert_eq!(probe.calls.load(Ordering::SeqCst), 2); + } +} diff --git a/src-tauri/src/secret/keyring_store.rs b/src-tauri/src/secret/keyring_store.rs index cd63ddf1..efd29da0 100644 --- a/src-tauri/src/secret/keyring_store.rs +++ b/src-tauri/src/secret/keyring_store.rs @@ -68,11 +68,11 @@ impl SecretStore for KeyringStore { } fn backend_name(&self) -> &'static str { - if cfg!(target_os = "macos") { + if cfg!(macos) { "macos-keychain" - } else if cfg!(target_os = "ios") { + } else if cfg!(ios) { "ios-keychain" - } else if cfg!(target_os = "windows") { + } else if cfg!(windows) { "windows-credential-manager" } else { "linux-secret-service" @@ -86,11 +86,11 @@ mod tests { #[test] fn backend_name_matches_the_native_store() { - let expected = if cfg!(target_os = "macos") { + let expected = if cfg!(macos) { "macos-keychain" - } else if cfg!(target_os = "ios") { + } else if cfg!(ios) { "ios-keychain" - } else if cfg!(target_os = "windows") { + } else if cfg!(windows) { "windows-credential-manager" } else { "linux-secret-service" diff --git a/src-tauri/src/secret/mod.rs b/src-tauri/src/secret/mod.rs index a6d46938..f2624e11 100644 --- a/src-tauri/src/secret/mod.rs +++ b/src-tauri/src/secret/mod.rs @@ -8,7 +8,7 @@ //! ▼ //! HybridStore ← ChaCha20-Poly1305 加/解密 //! ├── master_key (32B) -//! │ ├── KeyringMasterKey ← keychain(macOS/iOS/Windows/Linux desktop) +//! │ ├── KeyringMasterKey ← keychain(macOS/iOS/Windows/Linux) //! │ └── FileMasterKey ← /master.key(headless/Android) //! │ //! └── DbStore ← rssh.db 的 `secrets` 表(密文 base64) @@ -49,23 +49,13 @@ use crate::error::{AppError, AppResult}; pub mod crypto; mod db_store; mod hybrid_store; -#[cfg(any( - target_os = "macos", - target_os = "ios", - target_os = "windows", - target_os = "linux" -))] +#[cfg(any(macos, ios, windows, linux))] mod keyring_store; mod master_key; pub use db_store::DbStore; pub use hybrid_store::HybridStore; -#[cfg(any( - target_os = "macos", - target_os = "ios", - target_os = "windows", - target_os = "linux" -))] +#[cfg(any(macos, ios, windows, linux))] pub use keyring_store::KeyringStore; pub use master_key::{FileMasterKey, KeyringMasterKey, MasterKeyBackend}; @@ -114,7 +104,7 @@ pub fn open(db: Arc, data_dir: &Path) -> AppResult { let db_store = Arc::new(DbStore::new(db.clone())); let recorded = db::settings::get(&db, BACKEND_MARKER)?; - // 运行期探测:编译进 keyring crate 的平台 + 真能 probe 写读 + // 运行期探测:原生 keychain 后端 + 真能 probe 写读 let probed: Option> = probe_keyring(); match (recorded.as_deref(), probed) { @@ -171,27 +161,17 @@ pub fn open(db: Arc, data_dir: &Path) -> AppResult { } } -/// 运行期探测系统 keychain。`#[cfg(...)]` 排除编译不进 keyring crate 的平台 -/// (例如 Android),其他平台靠 `try_open()` 真探测(写 probe key + 读回 + 删)。 +/// 运行期探测系统 keychain。没有原生后端的平台(例如 Android) +/// 不参与;其他平台靠 `try_open()` 真探测(写 probe key + 读回 + 删)。 fn probe_keyring() -> Option> { - #[cfg(any( - target_os = "macos", - target_os = "ios", - target_os = "windows", - target_os = "linux" - ))] + #[cfg(any(macos, ios, windows, linux))] { keyring_store::try_open().map(|kr| { let arc: Arc = Arc::new(kr); arc as Arc }) } - #[cfg(not(any( - target_os = "macos", - target_os = "ios", - target_os = "windows", - target_os = "linux" - )))] + #[cfg(not(any(macos, ios, windows, linux)))] { None } diff --git a/src-tauri/src/server.rs b/src-tauri/src/server.rs index 3541fbf2..841b7497 100644 --- a/src-tauri/src/server.rs +++ b/src-tauri/src/server.rs @@ -510,96 +510,6 @@ fn dispatch( Ok(Value::Null) } - // ---- serial console ---- - "serial_list_ports" => Ok(json!(serial::available_ports())), - "serial_open" => { - let port: String = arg(&args, "port")?; - let config: serial::SerialConfig = arg(&args, "config")?; - let requested_id = optional_string_arg(&args, "sessionId")?; - let session_id = - crate::commands::lifecycle::resolve_session_id(requested_id).map_err(err_value)?; - let reservation = crate::commands::lifecycle::reserve_resource( - state, - &session_id, - SessionKind::Serial, - owner.clone(), - ) - .map_err(err_value)?; - let tx = tx.clone(); - let sink: SerialSink = Arc::new(move |id: &str, out: SerialOut| { - let msg = match out { - SerialOut::Data(b) => { - json!({ "type": "event", "event": format!("serial:data:{id}"), "payload": b }) - } - SerialOut::Close => { - json!({ "type": "event", "event": format!("serial:close:{id}"), "payload": Value::Null }) - } - }; - let _ = tx.send(Message::Text(msg.to_string())); - }); - let (id, handle) = serial::open(session_id, &port, config, sink).map_err(err_value)?; - reservation - .activate_returned( - &id, - crate::commands::lifecycle::ReadySession::Serial(handle), - ) - .map_err(err_value)?; - Ok(json!(id)) - } - "serial_write" => { - let sid: String = arg(&args, "sessionId")?; - let data: Vec = arg(&args, "data")?; - let handle = locked(&state.serial_sessions) - .map_err(err_value)? - .get(&sid) - .cloned(); - match handle { - Some(h) => h.write(&data).map(|_| Value::Null).map_err(err_value), - None => Err(json!("serial_not_found")), - } - } - "serial_close" => { - let sid: String = arg(&args, "sessionId")?; - crate::commands::lifecycle::close_resource(state, &sid, SessionKind::Serial, owner) - .map_err(err_value)?; - Ok(Value::Null) - } - "serial_set_dtr" => { - let sid: String = arg(&args, "sessionId")?; - let level: bool = arg(&args, "level")?; - let handle = locked(&state.serial_sessions) - .map_err(err_value)? - .get(&sid) - .cloned(); - match handle { - Some(h) => h.set_dtr(level).map(|_| Value::Null).map_err(err_value), - None => Err(json!("serial_not_found")), - } - } - "serial_set_rts" => { - let sid: String = arg(&args, "sessionId")?; - let level: bool = arg(&args, "level")?; - let handle = locked(&state.serial_sessions) - .map_err(err_value)? - .get(&sid) - .cloned(); - match handle { - Some(h) => h.set_rts(level).map(|_| Value::Null).map_err(err_value), - None => Err(json!("serial_not_found")), - } - } - "serial_send_break" => { - let sid: String = arg(&args, "sessionId")?; - let handle = locked(&state.serial_sessions) - .map_err(err_value)? - .get(&sid) - .cloned(); - match handle { - Some(h) => h.send_break().map(|_| Value::Null).map_err(err_value), - None => Err(json!("serial_not_found")), - } - } - // ---- telnet (open lives in dispatch_async — blocking DNS+connect) ---- "telnet_write" => { let sid: String = arg(&args, "sessionId")?; @@ -739,6 +649,9 @@ fn dispatch( } // ---- CLI: PATH-based status; install is host-managed in embedded mode ---- + "get_runtime_capabilities" => ok(Ok::<_, AppError>( + crate::platform::runtime::RuntimeCapabilities::headless(), + )), "cli_status" => ok(Ok::<_, AppError>( crate::commands::cli::cli_status_headless(), )), @@ -775,18 +688,6 @@ fn dispatch( Ok(Value::Null) } - // ---- orphan-session reap on (re)mount: the server outlives a page reload, - // so stale ssh/sftp/forward/pty from before the reload get cleaned ---- - "reconcile_sessions" => { - let active_ids: Vec = args - .get("activeIds") - .and_then(|v| serde_json::from_value(v.clone()).ok()) - .unwrap_or_default(); - ok(crate::commands::lifecycle::reconcile_sessions_impl( - state, owner, active_ids, - )) - } - // `getVersion()` (@tauri-apps/api/app) → plugin:app|version. Desktop has // the Tauri app plugin; headless answers with the crate version (synced // across manifests at release) so About + update checks work embedded. @@ -806,6 +707,127 @@ async fn dispatch_async( tx: &mpsc::UnboundedSender, ) -> Result { match cmd { + // ---- orphan-session reap on (re)mount: the server outlives a page reload, + // so stale ssh/sftp/forward/pty from before the reload get cleaned ---- + "reconcile_sessions" => { + let active_ids: Vec = args + .get("activeIds") + .and_then(|v| serde_json::from_value(v.clone()).ok()) + .unwrap_or_default(); + ok(crate::commands::lifecycle::reconcile_sessions_impl(state, owner, active_ids).await) + } + + // ---- serial console ---- + "serial_get_capabilities" => Ok(json!(serial::capabilities().await.map_err(err_value)?)), + "serial_list_ports" => Ok(json!(serial::available_ports().await.map_err(err_value)?)), + "serial_open" => { + let port: String = arg(&args, "port")?; + let config: serial::SerialConfig = arg(&args, "config")?; + let requested_id = optional_string_arg(&args, "sessionId")?; + let session_id = + crate::commands::lifecycle::resolve_session_id(requested_id).map_err(err_value)?; + let reservation = crate::commands::lifecycle::reserve_resource( + state, + &session_id, + SessionKind::Serial, + owner.clone(), + ) + .map_err(err_value)?; + let tx = tx.clone(); + let sink: SerialSink = Arc::new(move |id: &str, out: SerialOut| { + let msg = match out { + SerialOut::Data(b) => { + json!({ "type": "event", "event": format!("serial:data:{id}"), "payload": b }) + } + SerialOut::Close => { + json!({ "type": "event", "event": format!("serial:close:{id}"), "payload": Value::Null }) + } + }; + let _ = tx.send(Message::Text(msg.to_string())); + }); + let operation = reservation.pending_operation().map_err(err_value)?; + let opened = serial::open_resource(session_id, &port, config, sink, operation) + .await + .map_err(err_value)?; + let id = opened + .activate(|id, handle| { + reservation.activate_returned( + id, + crate::commands::lifecycle::ReadySession::Serial(handle), + ) + }) + .await + .map_err(err_value)?; + Ok(json!(id)) + } + "serial_write" => { + let sid: String = arg(&args, "sessionId")?; + let data: Vec = arg(&args, "data")?; + let handle = locked(&state.serial_sessions) + .map_err(err_value)? + .get(&sid) + .cloned(); + match handle { + Some(h) => h.write(&data).await.map(|_| Value::Null).map_err(err_value), + None => Err(json!("serial_not_found")), + } + } + "serial_close" => { + let sid: String = arg(&args, "sessionId")?; + crate::commands::lifecycle::close_resource_and_wait( + state, + &sid, + SessionKind::Serial, + owner, + ) + .await + .map_err(err_value)?; + Ok(Value::Null) + } + "serial_set_dtr" => { + let sid: String = arg(&args, "sessionId")?; + let level: bool = arg(&args, "level")?; + let handle = locked(&state.serial_sessions) + .map_err(err_value)? + .get(&sid) + .cloned(); + match handle { + Some(h) => h + .set_dtr(level) + .await + .map(|_| Value::Null) + .map_err(err_value), + None => Err(json!("serial_not_found")), + } + } + "serial_set_rts" => { + let sid: String = arg(&args, "sessionId")?; + let level: bool = arg(&args, "level")?; + let handle = locked(&state.serial_sessions) + .map_err(err_value)? + .get(&sid) + .cloned(); + match handle { + Some(h) => h + .set_rts(level) + .await + .map(|_| Value::Null) + .map_err(err_value), + None => Err(json!("serial_not_found")), + } + } + "serial_send_break" => { + let sid: String = arg(&args, "sessionId")?; + let handle = locked(&state.serial_sessions) + .map_err(err_value)? + .get(&sid) + .cloned(); + match handle { + Some(h) => h.send_break().await.map(|_| Value::Null).map_err(err_value), + None => Err(json!("serial_not_found")), + } + } + "ssh_connect" => ssh_connect(state, owner, args, tx).await, // Async like the Tauri command, for the same reason: DNS + TCP connect // can block up to 10s per address and must not stall the ws event loop. @@ -966,6 +988,12 @@ async fn dispatch_async( let h = sftp_handle(state, &arg::(&args, "sftpId")?)?; ok(h.walk_files(&arg::(&args, "remoteRoot")?).await) } + "resolve_local_paths" => ok(crate::commands::files::resolve_local_paths( + arg(&args, "localRoot")?, + arg(&args, "relativePaths")?, + arg(&args, "write")?, + ) + .await), "walk_local_dir" => { ok(crate::commands::sftp::walk_local_dir(arg::(&args, "localRoot")?).await) } diff --git a/src-tauri/src/server_main.rs b/src-tauri/src/server_main.rs index 8671c057..e75ec593 100644 --- a/src-tauri/src/server_main.rs +++ b/src-tauri/src/server_main.rs @@ -2,7 +2,7 @@ //! `rssh_lib::server`; the IDEA plugin (or a dev script) spawns this and reads //! the `{"port":..,"token":..}` line it prints on stdout to point the frontend. -#[cfg(desktop)] +#[cfg(any(windows, macos, linux))] fn main() { let _ = env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info")) .try_init(); @@ -18,7 +18,7 @@ fn main() { } } -#[cfg(mobile)] +#[cfg(any(android, ios))] fn main() { eprintln!("rssh-server is only supported on desktop platforms"); std::process::exit(1); diff --git a/src-tauri/src/ssh/known_hosts.rs b/src-tauri/src/ssh/known_hosts.rs index 192fefb2..d0c607b4 100644 --- a/src-tauri/src/ssh/known_hosts.rs +++ b/src-tauri/src/ssh/known_hosts.rs @@ -1,18 +1,18 @@ //! known_hosts 路径策略:复用系统标准位置 `~/.ssh/known_hosts`, //! 让用户在 OpenSSH / rssh / 其他 SSH 客户端之间共享同一份信任链。 //! -//! 移动端没有可依赖的用户 home,退到 app_data_dir/.ssh/known_hosts。 +//! Android / iOS 使用应用沙箱中的 .ssh/known_hosts。 use std::collections::HashSet; use std::path::{Path, PathBuf}; -/// 解析 known_hosts 文件路径。`fallback_dir` 仅移动端使用。 +/// 解析 known_hosts 文件路径。沙箱平台始终使用 `fallback_dir`。 pub fn path_for(fallback_dir: &Path) -> PathBuf { - #[cfg(mobile)] + #[cfg(any(android, ios))] { return fallback_dir.join(".ssh").join("known_hosts"); } - #[cfg(desktop)] + #[cfg(any(windows, macos, linux))] { let _ = fallback_dir; if let Some(home) = dirs::home_dir() { @@ -165,7 +165,7 @@ mod tests { #[test] fn path_for_returns_some_path_under_home_or_fallback() { - // path_for 在 desktop 下走 home_dir() 或 fallback_dir。 + // Windows/macOS/Linux 的 path_for 走 home_dir() 或 fallback_dir。 // 不验证具体路径(CI 环境 HOME 不一定存在),只保证返回值以 // `known_hosts` 结尾且非空。 let dir = tempfile::tempdir().unwrap(); diff --git a/src-tauri/src/ssh/sftp.rs b/src-tauri/src/ssh/sftp.rs index 66fd411a..e3efd2d1 100644 --- a/src-tauri/src/ssh/sftp.rs +++ b/src-tauri/src/ssh/sftp.rs @@ -75,7 +75,7 @@ impl PartialDownloadGuard { } fn commit(mut self, local_path: &Path) -> std::io::Result<()> { - replace_local_file(&self.path, local_path)?; + std::fs::rename(&self.path, local_path)?; self.committed = true; Ok(()) } @@ -89,21 +89,6 @@ impl Drop for PartialDownloadGuard { } } -/// Replace the completed download without yielding. On Windows, `rename` -/// cannot overwrite an existing file, so removal and rename stay in one -/// synchronous section where task cancellation cannot split them. -fn replace_local_file(tmp_path: &Path, local_path: &Path) -> std::io::Result<()> { - #[cfg(windows)] - { - match std::fs::remove_file(local_path) { - Ok(()) => {} - Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} - Err(error) => return Err(error), - } - } - std::fs::rename(tmp_path, local_path) -} - /// Join a remote path segment. Special-cases dir == "/" so the result never /// contains "//foo"; matches the convention used by the existing callers. fn join_remote(dir: &str, name: &str) -> String { @@ -565,79 +550,66 @@ impl SftpHandle { ) })?; - // For multi-select downloads, local_path may live inside a subdirectory - // we haven't created yet (e.g. ///file.txt). - // For a single-file download the parent already exists, so - // create_dir_all is a no-op. - if let Some(parent) = local_path.parent() { - tokio::fs::create_dir_all(parent).await?; - } - - // Atomicity: write to `.part` first; rename to final name - // only on full success. Cancel / read-error / write-error all leave - // only a partial `.part` (cleaned up below) — never a truncated file - // sitting where users might pick it up. Mirrors `download_to_path`. - let file_name = local_path - .file_name() - .ok_or_else(|| AppError::sftp("sftp_invalid_filename", json!({})))? - .to_string_lossy() - .into_owned(); - let tmp_path = local_path.with_file_name(format!("{file_name}.part")); - let event = format!("sftp:progress:{transfer_id}"); - let result = async { - let mut local_file = tokio::fs::File::create(&tmp_path).await?; - stream_download( - &mut remote_file, - &mut local_file, - |t| { - let _ = host.emit( - &event, - serde_json::json!({ "transferred": t, "total": total }), - ); - }, - &cancel, - ) - .await - } - .await; - match result { - Ok(transferred) => { - // Best-effort: remove any pre-existing destination so rename - // doesn't fail on Windows (Unix rename overwrites silently). - let _ = tokio::fs::remove_file(local_path).await; - tokio::fs::rename(&tmp_path, local_path).await?; - let _ = remote_file.shutdown().await.map_err(|e| { - AppError::sftp( - "sftp_io_failed", - json!({ "op": "close", "err": e.to_string() }), - ) - }); - Ok(transferred) - } - Err(e) => { - // Best-effort cleanup; even if unlink fails we just leak `.part`. - let _ = tokio::fs::remove_file(&tmp_path).await; - Err(e) - } - } + let transferred = copy_atomic_download( + &mut remote_file, + local_path, + |transferred| { + let _ = host.emit( + &event, + json!({ "transferred": transferred, "total": total }), + ); + }, + &cancel, + ) + .await?; + let _ = remote_file.shutdown().await; + Ok(transferred) } - /// Stream-download a remote file into a caller-supplied writer, with no - /// local `.part`/rename step. Used on mobile, where the destination is a SAF - /// `content://` handle that has no filesystem path to rename through — so we - /// write the target directly (failure leaves a truncated file, as agreed). - /// Desktop keeps the atomic `download_streaming` path above. - pub async fn download_streaming_to_writer( + /// A document provider grants only a single writer, not sibling creation or + /// rename. Open it lazily, after the remote file has opened successfully. + /// Mid-write provider failures can still leave a partial document; directory + /// destinations always use the transactional path above instead. + pub async fn download_streaming_to_writer( &self, remote_path: &str, - dst: &mut W, + open_destination: F, host: &crate::emitter::Host, transfer_id: &str, cancel: Arc, ) -> AppResult where W: AsyncWrite + Unpin, + F: FnOnce() -> Fut, + Fut: std::future::Future>, + { + let event = format!("sftp:progress:{transfer_id}"); + self.copy_to_writer( + remote_path, + open_destination, + |transferred, total| { + let _ = host.emit( + &event, + json!({ "transferred": transferred, "total": total }), + ); + }, + &cancel, + ) + .await + } + + async fn copy_to_writer( + &self, + remote_path: &str, + open_destination: F, + mut on_progress: impl FnMut(u64, u64), + cancel: &AtomicBool, + ) -> AppResult + where + W: AsyncWrite + Unpin, + F: FnOnce() -> Fut, + Fut: std::future::Future>, { let meta = self.sftp.metadata(remote_path).await.map_err(|e| { AppError::sftp( @@ -646,25 +618,21 @@ impl SftpHandle { ) })?; let total = meta.size.unwrap_or(0); - let mut remote_file = self.sftp.open(remote_path).await.map_err(|e| { AppError::sftp( "sftp_io_failed", json!({ "op": "open", "err": e.to_string() }), ) })?; - - let event = format!("sftp:progress:{transfer_id}"); + if cancel.load(Ordering::Relaxed) { + return Err(AppError::sftp(CANCELLED_CODE, json!({}))); + } + let mut destination = open_destination().await?; let transferred = stream_download( &mut remote_file, - dst, - |t| { - let _ = host.emit( - &event, - serde_json::json!({ "transferred": t, "total": total }), - ); - }, - &cancel, + &mut destination, + |transferred| on_progress(transferred, total), + cancel, ) .await?; let _ = remote_file.shutdown().await; @@ -807,12 +775,36 @@ impl SftpHandle { } } +async fn copy_atomic_download( + source: &mut R, + destination: &Path, + on_progress: impl FnMut(u64), + cancel: &AtomicBool, +) -> AppResult { + let mut target = crate::files::atomic::AtomicDownload::create(destination)?; + match stream_download(source, target.stream(), on_progress, cancel).await { + Ok(transferred) if !cancel.load(Ordering::Relaxed) => { + target.commit().await?; + Ok(transferred) + } + result => { + target.abort().await; + match result { + Err(error) => Err(error), + Ok(_) => Err(AppError::sftp(CANCELLED_CODE, json!({}))), + } + } + } +} + /// Pure download copy loop: read from `src` (remote), write to `dst` (local), /// 32 KiB at a time. Reports cumulative bytes via `on_progress` and checks /// `cancel` between chunks. No Tauri/Host dependency — progress is injected — so /// it's unit-testable over in-memory `Cursor`/`Vec`. `read` errors are the /// remote side (`sftp_io_failed` op:read); `write` errors are the local side -/// (bare IO error, e.g. disk full or a SAF `content://` write fault). +/// (bare IO error, e.g. disk full or a SAF `content://` write fault). Success +/// includes flushing the destination, so buffered writes and their errors +/// cannot outlive the transfer's completion or the atomic rename. async fn stream_download( src: &mut R, dst: &mut W, @@ -842,6 +834,7 @@ where transferred += n as u64; on_progress(transferred); } + dst.flush().await?; Ok(transferred) } @@ -915,6 +908,146 @@ mod tests { assert!(!tmp_path.exists()); } + // Exercise the real SFTP metadata/open protocol without an SSH server, + // network port or application state. A successful stat followed by a denied + // open is the case that previously destroyed the user's local file. + struct DeniedDownload; + impl russh_sftp::server::Handler for DeniedDownload { + type Error = russh_sftp::protocol::StatusCode; + fn unimplemented(&self) -> Self::Error { + russh_sftp::protocol::StatusCode::PermissionDenied + } + async fn stat( + &mut self, + id: u32, + _: String, + ) -> Result { + Ok(russh_sftp::protocol::Attrs { + id, + attrs: Default::default(), + }) + } + } + + #[tokio::test] + async fn remote_open_failure_never_opens_or_truncates_the_provider() { + let (client, server) = tokio::io::duplex(4096); + russh_sftp::server::run(server, DeniedDownload).await; + let handle = SftpHandle { + sftp: russh_sftp::client::SftpSession::new(client).await.unwrap(), + parent_ssh_id: None, + }; + let directory = tempfile::tempdir().unwrap(); + let target = directory.path().join("existing"); + std::fs::write(&target, b"existing contents").unwrap(); + let mut opened = false; + let result = handle + .copy_to_writer( + "/denied", + || { + opened = true; + async { + tokio::fs::File::create(&target) + .await + .map_err(AppError::from) + } + }, + |_, _| {}, + &AtomicBool::new(false), + ) + .await; + assert!(result.is_err()); + assert!(!opened); + assert_eq!(std::fs::read(&target).unwrap(), b"existing contents"); + assert_eq!(std::fs::read_dir(directory.path()).unwrap().count(), 1); + } + + #[tokio::test] + async fn atomic_download_cancellation_keeps_existing_target_and_cleans_temporary_file() { + let directory = tempfile::tempdir().unwrap(); + let target = directory.path().join("中文 %2F.txt"); + std::fs::write(&target, b"keep old bytes").unwrap(); + // A user's unrelated .part file must not be borrowed as our temporary. + std::fs::write(directory.path().join("中文 %2F.txt.part"), b"unrelated").unwrap(); + let cancel = AtomicBool::new(false); + let mut source = Cursor::new(vec![1_u8; 100_000]); + let error = copy_atomic_download( + &mut source, + &target, + |_| { + cancel.store(true, Ordering::Relaxed); + }, + &cancel, + ) + .await + .unwrap_err(); + assert_eq!(error.code(), CANCELLED_CODE); + assert_eq!(std::fs::read(&target).unwrap(), b"keep old bytes"); + assert_eq!( + std::fs::read(directory.path().join("中文 %2F.txt.part")).unwrap(), + b"unrelated" + ); + assert_eq!(std::fs::read_dir(directory.path()).unwrap().count(), 2); + } + + #[tokio::test] + async fn atomic_download_read_failure_keeps_old_file_after_partial_write() { + let directory = tempfile::tempdir().unwrap(); + let target = directory.path().join("existing"); + std::fs::write(&target, b"old bytes").unwrap(); + let mut source = Cursor::new(b"a partial new download".to_vec()).chain(FailingReader); + assert!( + copy_atomic_download(&mut source, &target, |_| {}, &AtomicBool::new(false)) + .await + .is_err() + ); + assert_eq!(std::fs::read(&target).unwrap(), b"old bytes"); + assert_eq!(std::fs::read_dir(directory.path()).unwrap().count(), 1); + } + + struct FailingReader; + impl AsyncRead for FailingReader { + fn poll_read( + self: std::pin::Pin<&mut Self>, + _: &mut std::task::Context<'_>, + _: &mut tokio::io::ReadBuf<'_>, + ) -> std::task::Poll> { + std::task::Poll::Ready(Err(std::io::Error::other("remote read failed"))) + } + } + + #[tokio::test] + async fn atomic_download_success_replaces_existing_file_with_exact_bytes() { + let directory = tempfile::tempdir().unwrap(); + let target = directory.path().join("literal%25 中文.txt"); + std::fs::write(&target, b"old bytes").unwrap(); + let expected = vec![42_u8; 100_000]; + let mut source = Cursor::new(expected.clone()); + let transferred = + copy_atomic_download(&mut source, &target, |_| {}, &AtomicBool::new(false)) + .await + .unwrap(); + assert_eq!(transferred, expected.len() as u64); + assert_eq!(std::fs::read(&target).unwrap(), expected); + assert_eq!(std::fs::read_dir(directory.path()).unwrap().count(), 1); + } + + #[tokio::test] + async fn atomic_download_failed_replace_keeps_existing_directory_and_cleans_temporary() { + let directory = tempfile::tempdir().unwrap(); + let target = directory.path().join("conflict"); + std::fs::create_dir(&target).unwrap(); + std::fs::write(target.join("important"), b"keep me").unwrap(); + let mut source = Cursor::new(b"new bytes"); + assert!( + copy_atomic_download(&mut source, &target, |_| {}, &AtomicBool::new(false)) + .await + .is_err() + ); + assert_eq!(std::fs::read(target.join("important")).unwrap(), b"keep me"); + assert_eq!(std::fs::read_dir(directory.path()).unwrap().count(), 1); + } + /// Both copy loops must move every byte intact and report monotonic progress /// ending at the total. Driven over in-memory buffers — no SSH, no Tauri. #[tokio::test] @@ -933,6 +1066,36 @@ mod tests { assert!(ticks.windows(2).all(|w| w[0] < w[1])); } + #[tokio::test] + async fn stream_download_finishes_buffered_writes_before_success() { + let data = b"the last chunk must reach the destination"; + let mut src = Cursor::new(data); + let mut dst = tokio::io::BufWriter::with_capacity(128, Vec::new()); + let cancel = AtomicBool::new(false); + + let transferred = stream_download(&mut src, &mut dst, |_| {}, &cancel) + .await + .unwrap(); + + assert_eq!(transferred, data.len() as u64); + assert_eq!(dst.get_ref().as_slice(), data); + } + + #[tokio::test] + async fn stream_download_propagates_errors_from_the_final_buffered_write() { + let (output, input) = tokio::io::duplex(64); + drop(input); + let mut src = Cursor::new(b"buffered until flush"); + let mut dst = tokio::io::BufWriter::with_capacity(128, output); + let cancel = AtomicBool::new(false); + + let error = stream_download(&mut src, &mut dst, |_| {}, &cancel) + .await + .unwrap_err(); + + assert_eq!(error.code(), "io_error"); + } + #[tokio::test] async fn stream_upload_copies_all_bytes_with_progress() { let data: Vec = (0..100_000u32).map(|i| (i * 7) as u8).collect(); diff --git a/src-tauri/src/state.rs b/src-tauri/src/state.rs index 99c46630..fd31b793 100644 --- a/src-tauri/src/state.rs +++ b/src-tauri/src/state.rs @@ -9,18 +9,18 @@ use crate::secret::SecretStore; use crate::ssh::client::SessionHandle; use crate::ssh::forward::ForwardHandle; use crate::ssh::sftp::SftpHandle; -#[cfg(desktop)] +#[cfg(any(windows, macos, linux))] use crate::terminal::pty::PtyHandle; -#[cfg(desktop)] +#[cfg(any(windows, macos, linux))] use crate::terminal::serial::SerialHandle; use crate::terminal::telnet::TelnetHandle; #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum SessionKind { Ssh, - #[cfg(desktop)] + #[cfg(any(windows, macos, linux))] Pty, - #[cfg(desktop)] + #[cfg(any(windows, macos, linux))] Serial, Telnet, Sftp, @@ -37,6 +37,15 @@ pub enum SessionOwner { pub enum SessionPhase { Pending, Ready, + Closing, + Closed, +} + +#[derive(Clone, Debug)] +pub(crate) enum ResourceState { + Pending(Option), + Ready, + Closing(Arc), Closed, } @@ -45,10 +54,21 @@ pub struct SessionRecord { pub nonce: uuid::Uuid, pub kind: SessionKind, pub owner: SessionOwner, - pub phase: SessionPhase, + pub(crate) state: ResourceState, pub parent: Option, } +impl SessionRecord { + pub fn phase(&self) -> SessionPhase { + match &self.state { + ResourceState::Pending(_) => SessionPhase::Pending, + ResourceState::Ready => SessionPhase::Ready, + ResourceState::Closing(cleanup) if !cleanup.is_finished() => SessionPhase::Closing, + ResourceState::Closing(_) | ResourceState::Closed => SessionPhase::Closed, + } + } +} + #[derive(Clone, Debug)] pub struct AiSessionRecord { pub nonce: uuid::Uuid, @@ -73,9 +93,9 @@ pub struct AppState { /// Typed handle maps below contain Ready handles only. pub lifecycle_sessions: Mutex>, pub sessions: Mutex>, - #[cfg(desktop)] + #[cfg(any(windows, macos, linux))] pub pty_sessions: Mutex>, - #[cfg(desktop)] + #[cfg(any(windows, macos, linux))] pub serial_sessions: Mutex>, /// Telnet is plain TCP — available on every platform, no mobile gate. pub telnet_sessions: Mutex>, diff --git a/src-tauri/src/terminal/mod.rs b/src-tauri/src/terminal/mod.rs index 07703e6e..7fe8b751 100644 --- a/src-tauri/src/terminal/mod.rs +++ b/src-tauri/src/terminal/mod.rs @@ -1,7 +1,7 @@ -#[cfg(desktop)] +#[cfg(any(windows, macos, linux))] pub mod pty; pub mod recorder; -#[cfg(desktop)] +#[cfg(any(windows, macos, linux))] pub mod serial; // No mobile gate: telnet is plain TCP, so it works on every platform. pub mod telnet; diff --git a/src-tauri/src/terminal/pty.rs b/src-tauri/src/terminal/pty.rs index cc3b89c0..aeb724b6 100644 --- a/src-tauri/src/terminal/pty.rs +++ b/src-tauri/src/terminal/pty.rs @@ -290,7 +290,7 @@ fn default_shell() -> String { // Windows 走 available_shells() 的扫描结果(System32 / Program Files / PATH)。 // 即便在 Unix,也得校验 SHELL 真有效(trim + is_shell_candidate)—— 空串、 // 卸载残留的旧路径、user 手改坏的值都得过滤,避免拿垃圾路径去 spawn。 - #[cfg(not(target_os = "windows"))] + #[cfg(not(windows))] { if let Ok(s) = std::env::var("SHELL") { let trimmed = s.trim(); @@ -303,7 +303,7 @@ fn default_shell() -> String { .next() .unwrap_or_else(|| "/bin/sh".to_string()) } - #[cfg(target_os = "windows")] + #[cfg(windows)] { // Windows 没有 $SHELL 等价物 —— available_shells() 是字典序排好的, // 直接拿 first 会让 `C:\Program Files\Git\bin\bash.exe` 这种偏门项目 @@ -337,7 +337,7 @@ pub fn spawn( cmd.env("TERM", "xterm-256color"); cmd.env("COLORTERM", "truecolor"); cmd.env("RSSH_APP", "1"); - if !cfg!(target_os = "windows") { + if !cfg!(windows) { cmd.arg("-l"); } spawn_builder(session_id, cols, rows, sink, cmd, shell) @@ -426,3 +426,56 @@ fn spawn_builder( Ok((session_id, handle)) } + +#[cfg(all(test, any(windows, macos, linux), unix))] +mod tests { + use super::*; + use std::sync::mpsc; + use std::time::{Duration, Instant}; + + #[test] + fn local_pty_streams_input_reports_resize_and_closes() { + let (sender, receiver) = mpsc::channel(); + let sink: PtySink = Arc::new(move |_, output| { + let _ = sender.send(output); + }); + let (_, handle) = spawn_command( + "pty-test".into(), + 80, + 24, + sink, + PathBuf::from("/bin/sh"), + OsString::from("/usr/bin:/bin"), + vec![ + "-c".into(), + "stty -echo; printf 'PTY_READY\\n'; IFS= read -r line; printf 'PTY_INPUT:%s\\n' \"$line\"; stty size" + .into(), + ], + ) + .expect("spawn local PTY"); + let deadline = Instant::now() + Duration::from_secs(5); + let mut output = Vec::new(); + while !String::from_utf8_lossy(&output).contains("PTY_READY") { + match receiver.recv_timeout(deadline.saturating_duration_since(Instant::now())) { + Ok(PtyOut::Data(data)) => output.extend(data), + _ => panic!( + "PTY did not become ready: {}", + String::from_utf8_lossy(&output) + ), + } + } + handle.resize(103, 41).expect("resize local PTY"); + handle.write(b"hello-pty\n").expect("write local PTY"); + loop { + match receiver.recv_timeout(deadline.saturating_duration_since(Instant::now())) { + Ok(PtyOut::Data(data)) => output.extend(data), + Ok(PtyOut::Close) => break, + Err(error) => panic!("PTY did not close: {error}"), + } + } + let output = String::from_utf8_lossy(&output); + assert!(output.contains("PTY_INPUT:hello-pty"), "{output}"); + assert!(output.contains("41 103"), "{output}"); + assert_eq!(handle.shell_path(), "/bin/sh"); + } +} diff --git a/src-tauri/src/terminal/serial.rs b/src-tauri/src/terminal/serial.rs index 94ee656b..375eb764 100644 --- a/src-tauri/src/terminal/serial.rs +++ b/src-tauri/src/terminal/serial.rs @@ -1,11 +1,53 @@ -use std::io::{Read, Write}; -use std::sync::atomic::{AtomicBool, Ordering}; -use std::sync::{Arc, Mutex}; -use std::time::Duration; +use std::sync::Arc; -use serialport::{DataBits, FlowControl, Parity, SerialPort, StopBits}; +use crate::error::{AppError, AppResult}; -use crate::error::{locked, AppError, AppResult}; +/// Options implemented by this host's serial service. An empty rate list means +/// the native driver accepts a custom baud rate and validates it when opening. +#[derive(Clone, Debug, serde::Serialize)] +#[serde(rename_all = "camelCase")] +pub struct SerialCapabilities { + pub flow_control: bool, + pub xany: bool, + pub signals: bool, + pub baud_rates: Vec, +} + +impl SerialCapabilities { + pub fn validate(&self, config: &SerialConfig) -> AppResult<()> { + if !self.flow_control && matches!(config.flow_control.as_str(), "hardware" | "software") { + return Err(AppError::config( + "serial_flow_unsupported", + serde_json::json!({}), + )); + } + if !self.xany && config.xany { + return Err(AppError::config( + "serial_xany_unsupported", + serde_json::json!({}), + )); + } + if !self.baud_rates.is_empty() && !self.baud_rates.contains(&config.baud_rate) { + return Err(AppError::config( + "serial_baud_unsupported", + serde_json::json!({ "baud": config.baud_rate }), + )); + } + Ok(()) + } + + #[cfg(test)] + pub fn require_signals(&self) -> AppResult<()> { + if self.signals { + Ok(()) + } else { + Err(AppError::config( + "serial_signals_unsupported", + serde_json::json!({}), + )) + } + } +} /// Serial output destined for the host. Mirrors `pty::PtyOut`, but kept as its /// own type so the serial module carries no dependency on the pty module — two @@ -20,9 +62,9 @@ pub enum SerialOut { /// so one sink can serve any number of serial sessions (same shape as PtySink). pub type SerialSink = Arc; -/// Wire config from the frontend. Strings (not enums) for parity/flow because it -/// arrives as JSON; the string→enum mapping with fail-soft defaults lives here -/// (pure, unit-tested below). snake_case keys match the SerialProfile model and +/// Wire config from the frontend. Strings for parity/flow match saved profiles; +/// each host validates unsupported features before mapping native settings. +/// snake_case keys match the SerialProfile model and /// the tab meta, so a saved profile's fields feed `serial_open` with no remap. #[derive(Clone, serde::Deserialize)] pub struct SerialConfig { @@ -35,7 +77,7 @@ pub struct SerialConfig { pub stop_bits: u8, #[serde(default)] pub flow_control: String, - /// IXANY termios flag (unix only; Windows no-op). The rest of the Tabby-style + /// IXANY flow control where the host advertises support. The other /// settings live on SerialProfile and are applied in the frontend terminal. #[serde(default)] pub xany: bool, @@ -48,306 +90,119 @@ fn default_stop_bits() -> u8 { 1 } -/// 8 data bits is the universal default; anything unrecognized falls back to it -/// rather than erroring — the line should open on 8N1 even if the UI sends junk. -fn map_data_bits(n: u8) -> DataBits { - match n { - 5 => DataBits::Five, - 6 => DataBits::Six, - 7 => DataBits::Seven, - _ => DataBits::Eight, - } -} - -fn map_parity(s: &str) -> Parity { - match s { - "odd" => Parity::Odd, - "even" => Parity::Even, - _ => Parity::None, - } -} - -fn map_stop_bits(n: u8) -> StopBits { - match n { - 2 => StopBits::Two, - _ => StopBits::One, - } -} - -fn map_flow_control(s: &str) -> FlowControl { - match s { - "software" => FlowControl::Software, - "hardware" => FlowControl::Hardware, - _ => FlowControl::None, - } -} - -/// Trips the shared close flag when the last `SerialHandle` clone is dropped -/// (tab closed / window closed → handle removed from the session map). Unlike a -/// PTY there is no child process whose death yields EOF, and dropping the writer -/// fd does NOT close the reader's `try_clone`d fd — so the reader thread must be -/// told to stop explicitly. It checks the flag once per read cycle (≤ timeout). -struct CloseGuard { - closed: Arc, -} - -impl Drop for CloseGuard { - fn drop(&mut self) { - self.closed.store(true, Ordering::Relaxed); - } -} - -/// Serial session handle. Clone + Send + Sync, like `PtyHandle`. No `resize`: -/// a serial line has no rows/cols, so the frontend simply never calls it. -#[derive(Clone)] -pub struct SerialHandle { - writer: Arc>>, - /// Last clone dropped → guard drops → close flag set → reader thread exits. - _guard: Arc, - port_name: Arc, -} - -/// Map any serial I/O failure (write / control-line / break) to a uniform error. -fn serial_op_err(e: impl std::fmt::Display) -> AppError { - AppError::pty( - "serial_op_failed", - serde_json::json!({ "err": e.to_string() }), - ) -} - -impl SerialHandle { - pub fn write(&self, data: &[u8]) -> AppResult<()> { - locked(&self.writer)?.write_all(data).map_err(serial_op_err) - } - - /// Drive the DTR control line. Manual DTR/RTS toggling resets MCUs (Arduino - /// auto-reset), enters bootloaders (the ESP32 DTR/RTS dance), and signals - /// modems. `level` is the logical assert state (`true` = asserted). - pub fn set_dtr(&self, level: bool) -> AppResult<()> { - locked(&self.writer)? - .write_data_terminal_ready(level) - .map_err(serial_op_err) - } - - /// Drive the RTS control line (see `set_dtr`). - pub fn set_rts(&self, level: bool) -> AppResult<()> { - locked(&self.writer)? - .write_request_to_send(level) - .map_err(serial_op_err) - } - - /// Send a serial BREAK: hold the line in the break condition ~250ms, then - /// release. Devices that watch for it (U-Boot, kernel SysRq-over-serial, - /// telco gear) treat it as an attention/interrupt. The writer lock is held - /// across the pulse so no bytes interleave — a break mid-frame is meaningless. - pub fn send_break(&self) -> AppResult<()> { - let port = locked(&self.writer)?; - port.set_break().map_err(serial_op_err)?; - std::thread::sleep(Duration::from_millis(250)); - port.clear_break().map_err(serial_op_err) - } - - /// Port path actually opened (e.g. `/dev/cu.usbserial-1420`, `COM3`). - pub fn port_name(&self) -> &str { - &self.port_name - } -} - -/// Serial ports available on this machine (`/dev/cu.usbserial-*`, `COM3`, …). -/// Enumeration failure (no permission / platform quirk) degrades to an empty -/// list rather than erroring — the UI shows "no ports" and the user can retry. -pub fn available_ports() -> Vec { - let ports = serialport::available_ports() - .map(|ports| ports.into_iter().map(|p| p.port_name).collect::>()) - .unwrap_or_default(); - prefer_callout_ports(ports) -} +#[path = "serial/desktop.rs"] +mod desktop; +pub use desktop::{available_ports, capabilities, SerialHandle}; -/// macOS exposes BOTH device nodes for every serial port: the call-out -/// `/dev/cu.*` and the dial-in `/dev/tty.*`. For an interactive terminal you -/// always want `cu.*` — opening `tty.*` blocks waiting for carrier-detect (DCD). -/// Drop the `tty.*` twins so the picker shows one usable entry per device -/// instead of a doubled list with a hang-trap in it. -#[cfg(target_os = "macos")] -fn prefer_callout_ports(mut ports: Vec) -> Vec { - // Drop a dial-in node only when its call-out twin was also enumerated: - // `/dev/tty.foo` is dropped iff `/dev/cu.foo` exists. A device that exposes - // only `tty.*` (no cu twin) is still shown rather than silently hidden. - let callouts: std::collections::HashSet = ports - .iter() - .filter_map(|p| p.strip_prefix("/dev/cu.").map(str::to_owned)) - .collect(); - ports.retain(|p| match p.strip_prefix("/dev/tty.") { - Some(name) => !callouts.contains(name), - None => true, - }); - ports -} - -/// Non-macOS: device nodes aren't duplicated this way, so pass through. -#[cfg(not(target_os = "macos"))] -fn prefer_callout_ports(ports: Vec) -> Vec { - ports -} - -/// Open a serial port and spawn a reader thread that pushes bytes to `sink`. -/// Returns `(session_id, handle)`. -fn open_err(port: &str, e: serialport::Error) -> AppError { - AppError::pty( - "serial_open_failed", - serde_json::json!({ "port": port, "err": e.to_string() }), - ) -} - -/// Open the port and, on unix, set the IXANY termios flag when `xany` is on — the -/// serialport crate doesn't expose it, so we reach the raw fd via libc. -#[cfg(unix)] -fn open_with_xany( - builder: serialport::SerialPortBuilder, - xany: bool, - port: &str, -) -> AppResult> { - use std::os::unix::io::AsRawFd; - let tty = builder.open_native().map_err(|e| open_err(port, e))?; - if xany { - // SAFETY: fd is valid for this call; tcgetattr/tcsetattr touch only `t`. - unsafe { - let fd = tty.as_raw_fd(); - let mut t: libc::termios = std::mem::zeroed(); - if libc::tcgetattr(fd, &mut t) == 0 { - t.c_iflag |= libc::IXANY; - let _ = libc::tcsetattr(fd, libc::TCSANOW, &t); - } - } - } - Ok(Box::new(tty)) -} - -/// Windows: IXANY has no DCB equivalent exposed by the serialport crate, so the -/// `xany` flag is a no-op. Documented limitation. -#[cfg(not(unix))] -fn open_with_xany( - builder: serialport::SerialPortBuilder, - _xany: bool, - port: &str, -) -> AppResult> { - builder.open().map_err(|e| open_err(port, e)) -} - -pub fn open( +/// The platform adapter only opens the resource. This shared coordinator owns +/// failed-open cleanup and carries that ownership through activation. +pub async fn open_resource( session_id: String, port: &str, - cfg: SerialConfig, + config: SerialConfig, sink: SerialSink, -) -> AppResult<(String, SerialHandle)> { - let builder = serialport::new(port, cfg.baud_rate) - .data_bits(map_data_bits(cfg.data_bits)) - .parity(map_parity(&cfg.parity)) - .stop_bits(map_stop_bits(cfg.stop_bits)) - .flow_control(map_flow_control(&cfg.flow_control)) - // Read timeout paces the reader loop and lets it notice unplug + honor the - // close flag. NOT a data deadline — on TimedOut we just loop again. - .timeout(Duration::from_millis(100)); - let opened = open_with_xany(builder, cfg.xany, port)?; - - // Separate read handle so the blocking read (up to the timeout) doesn't hold - // the writer's lock. try_clone dups the underlying fd / handle. - let reader = opened.try_clone().map_err(|e| { - AppError::pty( - "serial_op_failed", - serde_json::json!({ "err": e.to_string() }), - ) - })?; - - let closed = Arc::new(AtomicBool::new(false)); - let reader_closed = closed.clone(); - - let id = session_id; - let handle = SerialHandle { - writer: Arc::new(Mutex::new(opened)), - _guard: Arc::new(CloseGuard { closed }), - port_name: Arc::from(port), + operation: crate::resource::PendingOperation, +) -> AppResult> { + let (result, operation) = { + let port = port.to_owned(); + // Keep the reservation alive until the native open actually finishes, + // including when the caller drops its future while the driver blocks. + tokio::task::spawn_blocking(move || { + let result = desktop::open(session_id, &port, config, sink); + (result, operation) + }) + .await + .map_err(|error| { + AppError::pty( + "serial_op_failed", + serde_json::json!({ "err": error.to_string() }), + ) + })? }; + operation.finish_open(result).await +} - // Reader thread: serial RX → sink. Exits on close flag, on unplug (read - // error), and emits Close on the way out so the frontend can mark the tab. - let sid = id.clone(); - std::thread::spawn(move || { - let mut reader = reader; - let mut buf = [0u8; 4096]; - loop { - if reader_closed.load(Ordering::Relaxed) { - break; - } - match reader.read(&mut buf) { - Ok(0) => continue, // no bytes this interval - Ok(n) => sink(&sid, SerialOut::Data(buf[..n].to_vec())), - Err(ref e) if e.kind() == std::io::ErrorKind::TimedOut => continue, - Err(_) => break, // port gone (unplugged) / fatal IO error - } - } - sink(&sid, SerialOut::Close); - }); - - Ok((id, handle)) +/// Desktop ports retain their existing last-handle-drop cleanup behavior. +/// Pending opens remain owned by the shared lifecycle coordinator. +pub(crate) fn cleanup(_handle: &SerialHandle) -> Option { + None } #[cfg(test)] mod tests { use super::*; - #[test] - fn data_bits_maps_known_and_defaults_junk_to_eight() { - assert!(matches!(map_data_bits(5), DataBits::Five)); - assert!(matches!(map_data_bits(6), DataBits::Six)); - assert!(matches!(map_data_bits(7), DataBits::Seven)); - assert!(matches!(map_data_bits(8), DataBits::Eight)); - assert!(matches!(map_data_bits(0), DataBits::Eight)); - assert!(matches!(map_data_bits(99), DataBits::Eight)); + fn usb_capabilities() -> SerialCapabilities { + SerialCapabilities { + flow_control: false, + xany: false, + signals: false, + baud_rates: vec![9600, 115200], + } } - #[test] - fn parity_maps_known_and_defaults_unknown_to_none() { - assert!(matches!(map_parity("odd"), Parity::Odd)); - assert!(matches!(map_parity("even"), Parity::Even)); - assert!(matches!(map_parity("none"), Parity::None)); - assert!(matches!(map_parity(""), Parity::None)); - assert!(matches!(map_parity("garbage"), Parity::None)); + fn config() -> SerialConfig { + serde_json::from_value(serde_json::json!({ "baud_rate": 115200 })).unwrap() } #[test] - fn stop_bits_maps_two_else_one() { - assert!(matches!(map_stop_bits(2), StopBits::Two)); - assert!(matches!(map_stop_bits(1), StopBits::One)); - assert!(matches!(map_stop_bits(0), StopBits::One)); + fn basic_serial_accepts_defaults_and_rejects_unsupported_saved_settings() { + let capabilities = usb_capabilities(); + assert!(capabilities.validate(&config()).is_ok()); + for flow in ["hardware", "software"] { + let mut config = config(); + config.flow_control = flow.into(); + assert!(capabilities + .validate(&config) + .unwrap_err() + .to_string() + .contains("serial_flow_unsupported")); + } + let mut config = config(); + config.xany = true; + assert!(capabilities + .validate(&config) + .unwrap_err() + .to_string() + .contains("serial_xany_unsupported")); + config.xany = false; + config.baud_rate = 12345; + assert!(capabilities + .validate(&config) + .unwrap_err() + .to_string() + .contains("serial_baud_unsupported")); + assert!(capabilities + .require_signals() + .unwrap_err() + .to_string() + .contains("serial_signals_unsupported")); } #[test] - fn flow_control_maps_known_and_defaults_unknown_to_none() { - assert!(matches!( - map_flow_control("software"), - FlowControl::Software - )); - assert!(matches!( - map_flow_control("hardware"), - FlowControl::Hardware - )); - assert!(matches!(map_flow_control("none"), FlowControl::None)); - assert!(matches!(map_flow_control("xyz"), FlowControl::None)); + fn extended_serial_accepts_custom_rates_and_signal_controls() { + let capabilities = SerialCapabilities { + flow_control: true, + xany: true, + signals: true, + baud_rates: Vec::new(), + }; + let mut config = config(); + config.flow_control = "software".into(); + config.xany = true; + config.baud_rate = 12345; + assert!(capabilities.validate(&config).is_ok()); + assert!(capabilities.require_signals().is_ok()); } - #[cfg(target_os = "macos")] #[test] - fn macos_drops_tty_only_when_cu_twin_exists() { - let got = prefer_callout_ports(vec![ - "/dev/cu.usbserial-1".into(), - "/dev/tty.usbserial-1".into(), // twin of a listed cu → dropped - "/dev/tty.orphan".into(), // no cu twin → kept (not hidden) - "/dev/cu.standalone".into(), - ]); - assert!(got.contains(&"/dev/cu.usbserial-1".to_string())); - assert!(!got.contains(&"/dev/tty.usbserial-1".to_string())); - assert!(got.contains(&"/dev/tty.orphan".to_string())); - assert!(got.contains(&"/dev/cu.standalone".to_string())); + fn serial_capabilities_use_the_frontend_wire_names() { + assert_eq!( + serde_json::to_value(usb_capabilities()).unwrap(), + serde_json::json!({ + "flowControl": false, "xany": false, "signals": false, + "baudRates": [9600, 115200], + }), + ); } } diff --git a/src-tauri/src/terminal/serial/desktop.rs b/src-tauri/src/terminal/serial/desktop.rs new file mode 100644 index 00000000..e372313a --- /dev/null +++ b/src-tauri/src/terminal/serial/desktop.rs @@ -0,0 +1,371 @@ +use std::io::{Read, Write}; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::Arc; +use std::time::Duration; +use tokio::sync::Mutex; + +use serialport::{DataBits, FlowControl, Parity, SerialPort, StopBits}; + +use crate::error::{AppError, AppResult}; + +use super::{SerialCapabilities, SerialConfig, SerialOut, SerialSink}; + +/// 8 data bits is the universal default; anything unrecognized falls back to it +/// rather than erroring — the line should open on 8N1 even if the UI sends junk. +fn map_data_bits(n: u8) -> DataBits { + match n { + 5 => DataBits::Five, + 6 => DataBits::Six, + 7 => DataBits::Seven, + _ => DataBits::Eight, + } +} + +fn map_parity(s: &str) -> Parity { + match s { + "odd" => Parity::Odd, + "even" => Parity::Even, + _ => Parity::None, + } +} + +fn map_stop_bits(n: u8) -> StopBits { + match n { + 2 => StopBits::Two, + _ => StopBits::One, + } +} + +fn map_flow_control(s: &str) -> FlowControl { + match s { + "software" => FlowControl::Software, + "hardware" => FlowControl::Hardware, + _ => FlowControl::None, + } +} + +/// Trips the shared close flag when the last `SerialHandle` clone is dropped +/// (tab closed / window closed → handle removed from the session map). Unlike a +/// PTY there is no child process whose death yields EOF, and dropping the writer +/// fd does NOT close the reader's `try_clone`d fd — so the reader thread must be +/// told to stop explicitly. It checks the flag once per read cycle (≤ timeout). +struct CloseGuard { + closed: Arc, +} + +impl Drop for CloseGuard { + fn drop(&mut self) { + self.closed.store(true, Ordering::Relaxed); + } +} + +/// Serial session handle. Clone + Send + Sync, like `PtyHandle`. No `resize`: +/// a serial line has no rows/cols, so the frontend simply never calls it. +#[derive(Clone)] +pub struct SerialHandle { + writer: Arc>>, + /// Last clone dropped → guard drops → close flag set → reader thread exits. + _guard: Arc, + port_name: Arc, +} + +/// Map any serial I/O failure (write / control-line / break) to a uniform error. +fn serial_op_err(e: impl std::fmt::Display) -> AppError { + AppError::pty( + "serial_op_failed", + serde_json::json!({ "err": e.to_string() }), + ) +} + +impl SerialHandle { + /// Queue before entering the blocking pool: its worker scheduling must not + /// reorder writes or let control-line changes interrupt another operation. + /// The owned guard stays with the native call even if its caller is cancelled. + async fn with_port( + &self, + operation: impl FnOnce(&mut dyn SerialPort) -> AppResult<()> + Send + 'static, + ) -> AppResult<()> { + let mut writer = self.writer.clone().lock_owned().await; + let close_guard = self._guard.clone(); + tokio::task::spawn_blocking(move || { + let _close_guard = close_guard; + operation(writer.as_mut()) + }) + .await + .map_err(serial_op_err)? + } + + pub async fn write(&self, data: &[u8]) -> AppResult<()> { + let data = data.to_vec(); + self.with_port(move |port| port.write_all(&data).map_err(serial_op_err)) + .await + } + + /// Drive the DTR control line. Manual DTR/RTS toggling resets MCUs (Arduino + /// auto-reset), enters bootloaders (the ESP32 DTR/RTS dance), and signals + /// modems. `level` is the logical assert state (`true` = asserted). + pub async fn set_dtr(&self, level: bool) -> AppResult<()> { + self.with_port(move |port| port.write_data_terminal_ready(level).map_err(serial_op_err)) + .await + } + + /// Drive the RTS control line (see `set_dtr`). + pub async fn set_rts(&self, level: bool) -> AppResult<()> { + self.with_port(move |port| port.write_request_to_send(level).map_err(serial_op_err)) + .await + } + + /// Hold BREAK for ~250ms without blocking an async worker. Keep the writer + /// locked across the pulse so no bytes interleave or controls interrupt it. + pub async fn send_break(&self) -> AppResult<()> { + self.with_port(|port| { + port.set_break().map_err(serial_op_err)?; + std::thread::sleep(Duration::from_millis(250)); + port.clear_break().map_err(serial_op_err) + }) + .await + } + + /// Port path actually opened (e.g. `/dev/cu.usbserial-1420`, `COM3`). + pub fn port_name(&self) -> &str { + &self.port_name + } +} + +/// Serial ports available on this machine (`/dev/cu.usbserial-*`, `COM3`, …). +/// Enumeration failure (no permission / platform quirk) degrades to an empty +/// list rather than erroring — the UI shows "no ports" and the user can retry. +pub async fn available_ports() -> AppResult> { + tokio::task::spawn_blocking(|| { + let ports = serialport::available_ports() + .map(|ports| ports.into_iter().map(|p| p.port_name).collect::>()) + .unwrap_or_default(); + prefer_callout_ports(ports) + }) + .await + .map_err(serial_op_err) +} + +pub async fn capabilities() -> AppResult { + Ok(SerialCapabilities { + flow_control: true, + xany: cfg!(unix), + signals: true, + baud_rates: Vec::new(), + }) +} + +/// macOS exposes BOTH device nodes for every serial port: the call-out +/// `/dev/cu.*` and the dial-in `/dev/tty.*`. For an interactive terminal you +/// always want `cu.*` — opening `tty.*` blocks waiting for carrier-detect (DCD). +/// Drop the `tty.*` twins so the picker shows one usable entry per device +/// instead of a doubled list with a hang-trap in it. +#[cfg(macos)] +fn prefer_callout_ports(mut ports: Vec) -> Vec { + // Drop a dial-in node only when its call-out twin was also enumerated: + // `/dev/tty.foo` is dropped iff `/dev/cu.foo` exists. A device that exposes + // only `tty.*` (no cu twin) is still shown rather than silently hidden. + let callouts: std::collections::HashSet = ports + .iter() + .filter_map(|p| p.strip_prefix("/dev/cu.").map(str::to_owned)) + .collect(); + ports.retain(|p| match p.strip_prefix("/dev/tty.") { + Some(name) => !callouts.contains(name), + None => true, + }); + ports +} + +/// Non-macOS: device nodes aren't duplicated this way, so pass through. +#[cfg(not(macos))] +fn prefer_callout_ports(ports: Vec) -> Vec { + ports +} + +/// Open a serial port and spawn a reader thread that pushes bytes to `sink`. +/// Returns `(session_id, handle)`. +fn open_err(port: &str, e: serialport::Error) -> AppError { + AppError::pty( + "serial_open_failed", + serde_json::json!({ "port": port, "err": e.to_string() }), + ) +} + +/// Open the port and, on unix, set the IXANY termios flag when `xany` is on — the +/// serialport crate doesn't expose it, so we reach the raw fd via libc. +#[cfg(unix)] +fn open_with_xany( + builder: serialport::SerialPortBuilder, + xany: bool, + port: &str, +) -> AppResult> { + use std::os::unix::io::AsRawFd; + let tty = builder.open_native().map_err(|e| open_err(port, e))?; + if xany { + // SAFETY: fd is valid for this call; tcgetattr/tcsetattr touch only `t`. + unsafe { + let fd = tty.as_raw_fd(); + let mut t: libc::termios = std::mem::zeroed(); + if libc::tcgetattr(fd, &mut t) == 0 { + t.c_iflag |= libc::IXANY; + let _ = libc::tcsetattr(fd, libc::TCSANOW, &t); + } + } + } + Ok(Box::new(tty)) +} + +/// Windows has no IXANY equivalent. Preserve the historical no-op for saved +/// configurations imported from a Unix host. +#[cfg(not(unix))] +fn open_with_xany( + builder: serialport::SerialPortBuilder, + _xany: bool, + port: &str, +) -> AppResult> { + builder.open().map_err(|e| open_err(port, e)) +} + +pub fn open( + session_id: String, + port: &str, + cfg: SerialConfig, + sink: SerialSink, +) -> AppResult<(String, SerialHandle)> { + let builder = serialport::new(port, cfg.baud_rate) + .data_bits(map_data_bits(cfg.data_bits)) + .parity(map_parity(&cfg.parity)) + .stop_bits(map_stop_bits(cfg.stop_bits)) + .flow_control(map_flow_control(&cfg.flow_control)) + // Read timeout paces the reader loop and lets it notice unplug + honor the + // close flag. NOT a data deadline — on TimedOut we just loop again. + .timeout(Duration::from_millis(100)); + let opened = open_with_xany(builder, cfg.xany, port)?; + + // Separate read handle so the blocking read (up to the timeout) doesn't hold + // the writer's lock. try_clone dups the underlying fd / handle. + let reader = opened.try_clone().map_err(|e| { + AppError::pty( + "serial_op_failed", + serde_json::json!({ "err": e.to_string() }), + ) + })?; + + let closed = Arc::new(AtomicBool::new(false)); + let reader_closed = closed.clone(); + + let id = session_id; + let handle = SerialHandle { + writer: Arc::new(Mutex::new(opened)), + _guard: Arc::new(CloseGuard { closed }), + port_name: Arc::from(port), + }; + + // Reader thread: serial RX → sink. Exits on close flag, on unplug (read + // error), and emits Close on the way out so the frontend can mark the tab. + let sid = id.clone(); + std::thread::spawn(move || { + let mut reader = reader; + let mut buf = [0u8; 4096]; + loop { + if reader_closed.load(Ordering::Relaxed) { + break; + } + match reader.read(&mut buf) { + Ok(0) => continue, // no bytes this interval + Ok(n) => sink(&sid, SerialOut::Data(buf[..n].to_vec())), + Err(ref e) if e.kind() == std::io::ErrorKind::TimedOut => continue, + Err(_) => break, // port gone (unplugged) / fatal IO error + } + } + sink(&sid, SerialOut::Close); + }); + + Ok((id, handle)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn saved_xany_setting_does_not_preempt_the_native_port_open() { + let config = SerialConfig { + baud_rate: 9600, + data_bits: 8, + parity: String::new(), + stop_bits: 1, + flow_control: String::new(), + xany: true, + }; + let result = open( + "compatibility-test".into(), + "rssh-nonexistent-port-397ea899-0a0d-4a63-a1a5-2251102ebc26", + config, + Arc::new(|_, _| {}), + ); + // Windows historically ignores IXANY. A saved Unix setting must still + // reach the driver, whose missing-port error proves validation did not + // reject the configuration first. + assert_eq!(result.err().unwrap().code(), "serial_open_failed"); + } + + #[test] + fn data_bits_maps_known_and_defaults_junk_to_eight() { + assert!(matches!(map_data_bits(5), DataBits::Five)); + assert!(matches!(map_data_bits(6), DataBits::Six)); + assert!(matches!(map_data_bits(7), DataBits::Seven)); + assert!(matches!(map_data_bits(8), DataBits::Eight)); + assert!(matches!(map_data_bits(0), DataBits::Eight)); + assert!(matches!(map_data_bits(99), DataBits::Eight)); + } + + #[test] + fn parity_maps_known_and_defaults_unknown_to_none() { + assert!(matches!(map_parity("odd"), Parity::Odd)); + assert!(matches!(map_parity("even"), Parity::Even)); + assert!(matches!(map_parity("none"), Parity::None)); + assert!(matches!(map_parity(""), Parity::None)); + assert!(matches!(map_parity("garbage"), Parity::None)); + } + + #[test] + fn stop_bits_maps_two_else_one() { + assert!(matches!(map_stop_bits(2), StopBits::Two)); + assert!(matches!(map_stop_bits(1), StopBits::One)); + assert!(matches!(map_stop_bits(0), StopBits::One)); + } + + #[test] + fn flow_control_maps_known_and_defaults_unknown_to_none() { + assert!(matches!( + map_flow_control("software"), + FlowControl::Software + )); + assert!(matches!( + map_flow_control("hardware"), + FlowControl::Hardware + )); + assert!(matches!(map_flow_control("none"), FlowControl::None)); + assert!(matches!(map_flow_control("xyz"), FlowControl::None)); + } + + #[cfg(macos)] + #[test] + fn macos_drops_tty_only_when_cu_twin_exists() { + let got = prefer_callout_ports(vec![ + "/dev/cu.usbserial-1".into(), + "/dev/tty.usbserial-1".into(), // twin of a listed cu → dropped + "/dev/tty.orphan".into(), // no cu twin → kept (not hidden) + "/dev/cu.standalone".into(), + ]); + assert!(got.contains(&"/dev/cu.usbserial-1".to_string())); + assert!(!got.contains(&"/dev/tty.usbserial-1".to_string())); + assert!(got.contains(&"/dev/tty.orphan".to_string())); + assert!(got.contains(&"/dev/cu.standalone".to_string())); + } +} + +#[cfg(test)] +#[path = "desktop_tests.rs"] +mod io_tests; diff --git a/src-tauri/src/terminal/serial/desktop_tests.rs b/src-tauri/src/terminal/serial/desktop_tests.rs new file mode 100644 index 00000000..d987d817 --- /dev/null +++ b/src-tauri/src/terminal/serial/desktop_tests.rs @@ -0,0 +1,193 @@ +use super::*; +use std::sync::Mutex as TestMutex; +use tokio::sync::Notify; + +struct MockPort { + events: Arc>>, + entered: Arc, + release_write: Option>, + release_break: Option>, +} + +impl Read for MockPort { + fn read(&mut self, _: &mut [u8]) -> std::io::Result { + unreachable!("the writer adapter must not read") + } +} + +impl Write for MockPort { + fn write(&mut self, data: &[u8]) -> std::io::Result { + self.events + .lock() + .unwrap() + .push(String::from_utf8_lossy(data).into()); + self.entered.notify_one(); + if let Some(release) = self.release_write.take() { + release + .recv_timeout(Duration::from_secs(2)) + .expect("async worker must remain able to release the write"); + } + Ok(data.len()) + } + fn flush(&mut self) -> std::io::Result<()> { + Ok(()) + } +} + +impl SerialPort for MockPort { + fn name(&self) -> Option { + Some("mock".into()) + } + fn baud_rate(&self) -> serialport::Result { + Ok(9600) + } + fn data_bits(&self) -> serialport::Result { + Ok(DataBits::Eight) + } + fn flow_control(&self) -> serialport::Result { + Ok(FlowControl::None) + } + fn parity(&self) -> serialport::Result { + Ok(Parity::None) + } + fn stop_bits(&self) -> serialport::Result { + Ok(StopBits::One) + } + fn timeout(&self) -> Duration { + Duration::from_millis(100) + } + fn set_baud_rate(&mut self, _: u32) -> serialport::Result<()> { + Ok(()) + } + fn set_data_bits(&mut self, _: DataBits) -> serialport::Result<()> { + Ok(()) + } + fn set_flow_control(&mut self, _: FlowControl) -> serialport::Result<()> { + Ok(()) + } + fn set_parity(&mut self, _: Parity) -> serialport::Result<()> { + Ok(()) + } + fn set_stop_bits(&mut self, _: StopBits) -> serialport::Result<()> { + Ok(()) + } + fn set_timeout(&mut self, _: Duration) -> serialport::Result<()> { + Ok(()) + } + fn write_request_to_send(&mut self, level: bool) -> serialport::Result<()> { + self.events.lock().unwrap().push(format!("rts:{level}")); + Ok(()) + } + fn write_data_terminal_ready(&mut self, level: bool) -> serialport::Result<()> { + self.events.lock().unwrap().push(format!("dtr:{level}")); + Ok(()) + } + fn read_clear_to_send(&mut self) -> serialport::Result { + Ok(false) + } + fn read_data_set_ready(&mut self) -> serialport::Result { + Ok(false) + } + fn read_ring_indicator(&mut self) -> serialport::Result { + Ok(false) + } + fn read_carrier_detect(&mut self) -> serialport::Result { + Ok(false) + } + fn bytes_to_read(&self) -> serialport::Result { + Ok(0) + } + fn bytes_to_write(&self) -> serialport::Result { + Ok(0) + } + fn clear(&self, _: serialport::ClearBuffer) -> serialport::Result<()> { + Ok(()) + } + fn try_clone(&self) -> serialport::Result> { + unreachable!("the writer adapter must not clone the native port") + } + fn set_break(&self) -> serialport::Result<()> { + self.events.lock().unwrap().push("break".into()); + self.entered.notify_one(); + if let Some(release) = &self.release_break { + release + .recv_timeout(Duration::from_secs(2)) + .expect("async worker must remain able to release BREAK"); + } + Ok(()) + } + fn clear_break(&self) -> serialport::Result<()> { + self.events.lock().unwrap().push("clear".into()); + Ok(()) + } +} + +fn mock_handle( + release_write: Option>, + release_break: Option>, +) -> (SerialHandle, Arc>>, Arc) { + let events = Arc::new(TestMutex::new(Vec::new())); + let entered = Arc::new(Notify::new()); + let handle = SerialHandle { + writer: Arc::new(Mutex::new(Box::new(MockPort { + events: events.clone(), + entered: entered.clone(), + release_write, + release_break, + }))), + _guard: Arc::new(CloseGuard { + closed: Arc::new(AtomicBool::new(false)), + }), + port_name: Arc::from("mock"), + }; + (handle, events, entered) +} + +#[tokio::test(flavor = "current_thread")] +async fn break_pulse_leaves_async_worker_available() { + let (release, receiver) = std::sync::mpsc::channel(); + let (handle, events, entered) = mock_handle(None, Some(receiver)); + let pulse = handle.send_break(); + let observer = async { + entered.notified().await; + assert_eq!( + *events.lock().unwrap(), + ["break"], + "another async task must run before BREAK is released" + ); + release.send(()).unwrap(); + }; + let (result, ()) = tokio::join!(pulse, observer); + result.unwrap(); + assert_eq!(*events.lock().unwrap(), ["break", "clear"]); +} + +#[tokio::test(flavor = "current_thread")] +async fn blocked_write_keeps_async_worker_free_and_cancelled_call_keeps_its_lock() { + let (release, receiver) = std::sync::mpsc::channel(); + let (handle, events, entered) = mock_handle(Some(receiver), None); + let first_handle = handle.clone(); + let first = tokio::spawn(async move { first_handle.write(b"first").await }); + entered.notified().await; + first.abort(); + assert!(first.await.unwrap_err().is_cancelled()); + + let second = handle.write(b"second"); + let dtr = handle.set_dtr(true); + let rts = handle.set_rts(false); + tokio::pin!(second, dtr, rts); + assert!(futures_util::poll!(&mut second).is_pending()); + assert!(futures_util::poll!(&mut dtr).is_pending()); + assert!(futures_util::poll!(&mut rts).is_pending()); + assert_eq!(*events.lock().unwrap(), ["first"]); + release.send(()).unwrap(); + + let (second, dtr, rts) = tokio::join!(second, dtr, rts); + second.unwrap(); + dtr.unwrap(); + rts.unwrap(); + assert_eq!( + *events.lock().unwrap(), + ["first", "second", "dtr:true", "rts:false"] + ); +} diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index f317e1c2..54993911 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -25,7 +25,7 @@ "csp": null, "assetProtocol": { "enable": true, - "scope": ["$APPDATA/plugins/**", "$HOME/.rssh/plugins/**"] + "scope": [] } } }, diff --git a/src/App.svelte b/src/App.svelte index 11b1797b..0154f642 100644 --- a/src/App.svelte +++ b/src/App.svelte @@ -3,11 +3,14 @@ import AppShell from "./lib/components/AppShell.svelte"; import ToastStack from "./lib/components/ToastStack.svelte"; import WelcomeScreen from "./lib/components/WelcomeScreen.svelte"; - import { isIOS, loadProfiles, loadForwards } from "./lib/stores/app.svelte.ts"; + import { loadProfiles, loadForwards } from "./lib/stores/app.svelte.ts"; import * as updates from "./lib/stores/updates.svelte.ts"; import * as sync from "./lib/stores/sync.svelte.ts"; import * as cli from "./lib/stores/cli.svelte.ts"; import * as ai from "./lib/ai/store.svelte.ts"; + import * as runtime from "./lib/stores/runtime.svelte.ts"; + import * as layout from "./lib/stores/layout.svelte.ts"; + import { errMsg, t } from "./lib/i18n/index.svelte.ts"; // First-launch auto-show: when there are no profiles and no forwards, // surface the cinematic welcome once. After the user dismisses it @@ -16,8 +19,12 @@ // boot. Settings → About → "Preview welcome screen" is the manual // replay path; it deliberately bypasses this flag. const DISMISSED_KEY = "rssh.welcome.dismissed"; + // Capture before the capability await: AppShell consumes these handoffs + // when it mounts, before startup's remaining asynchronous work settles. + const auxiliaryWindow = !!window.__rssh_clone || !!window.__rssh_ai_handoff; let showWelcome = $state(false); + let startupError = $state(""); $effect(() => { const revision = sync.configurationRevision(); @@ -27,7 +34,20 @@ ); }); - onMount(async () => { + onMount(() => { + const stopObservingViewport = layout.observeViewport(); + void initialize(); + return stopObservingViewport; + }); + + async function initialize() { + startupError = ""; + try { + await runtime.load(); + } catch (error) { + startupError = errMsg(error); + return; + } // 预热 AI 设置:色条"发送到 AI"等入口靠 ai.settings()?.has_api_key 同步判断是否 // 可用,过去只有打开 AI 面板才加载它。在 app 启动时拉一次,保证任何菜单打开前 // _settings 已就位(fire-and-forget;失败不阻塞 UI,开面板时还会重试)。 @@ -42,14 +62,15 @@ // Skip background update polling on clone / AI-handoff windows — // they're transient and the main window already owns the timer. - if (!window.__rssh_clone && !window.__rssh_ai_handoff) { - // App Store/TestFlight owns updates on iOS; never direct those users to a - // GitHub desktop/Android artifact. - if (!isIOS) updates.startBackgroundChecks(); + if (!auxiliaryWindow) { + if (runtime.capabilities().releaseUpdateCheck) updates.startBackgroundChecks(); sync.startBackgroundChecks(); - cli.startBackgroundChecks(); + if (runtime.capabilities().cliInstall) cli.startBackgroundChecks(); } + // A clone or analysis handoff should open directly into its requested task. + if (auxiliaryWindow) return; + // localStorage / Tauri may not be available in non-app hosts // (e.g. vitest, browser preview) — defensively swallow errors so a // missing API never blocks the regular AppShell from rendering. @@ -70,7 +91,7 @@ } catch (e) { console.debug("welcome auto-check skipped:", e); } - }); + } function dismissWelcome() { showWelcome = false; @@ -80,9 +101,27 @@ } - +{#if runtime.loaded()} + +{:else} +
+ {#if startupError} +

{t("startup.failed")}

+

{startupError}

+ + {:else} +

{t("common.loading")}

+ {/if} +
+{/if} {#if showWelcome} {/if} + + diff --git a/src/global.d.ts b/src/global.d.ts index c7db89a9..793d7215 100644 --- a/src/global.d.ts +++ b/src/global.d.ts @@ -1,5 +1,9 @@ +/// + declare global { interface Window { + /** Installed only by the browser / JetBrains host adapter. */ + __RSSH_IPC_SHIM__?: boolean; /** Set by `open_tab_in_new_window` init script. Consumed once on mount, then deleted. */ __rssh_clone?: string; /** Set by `analyze_locally` tool: opens local shell + auto-starts AI session and sends task. */ diff --git a/src/lib/ai/ChatPanel.svelte b/src/lib/ai/ChatPanel.svelte index 1ed422cf..225742f8 100644 --- a/src/lib/ai/ChatPanel.svelte +++ b/src/lib/ai/ChatPanel.svelte @@ -771,12 +771,6 @@ } .message-actions { display: flex; gap: 1px; - opacity: 0; pointer-events: none; - transition: opacity 120ms ease; - } - .item-user:hover .message-actions, - .item-user:focus-within .message-actions { - opacity: 1; pointer-events: auto; } .message-action { width: 24px; height: 24px; padding: 0; @@ -791,14 +785,13 @@ } .message-action.rollback:hover { color: var(--error); } .message-action:disabled { opacity: 0.4; cursor: default; } - @media (hover: none), (any-pointer: coarse) { + @media (max-width: 639px) { .user-message { flex-direction: column; align-items: flex-end; } - .message-actions { opacity: 1; pointer-events: auto; } - .message-action { width: 44px; height: 44px; } .message-actions { order: 2; } + .message-action { width: 44px; height: 44px; } .bubble.user { order: 1; } } .ts { diff --git a/src/lib/ai/store.svelte.test.ts b/src/lib/ai/store.svelte.test.ts index 3a22107c..69f4e7dc 100644 --- a/src/lib/ai/store.svelte.test.ts +++ b/src/lib/ai/store.svelte.test.ts @@ -14,6 +14,9 @@ vi.mock("@tauri-apps/api/event", () => ({ })); beforeEach(() => { + // resetModules discards the store, but its real autosave timers would still + // call the shared invoke mock during later tests. Own every test's clock. + vi.useFakeTimers(); invokeMock.mockReset(); invokeMock.mockResolvedValue(null); unlistenMock.mockReset(); @@ -27,6 +30,7 @@ beforeEach(() => { }); afterEach(() => { + vi.clearAllTimers(); vi.useRealTimers(); vi.unstubAllGlobals(); }); @@ -294,9 +298,9 @@ describe("tab lifecycle", () => { "ai_session_prepare_stop", { tabId: "tab-a", instanceId: "instance-a" }, )); - expect(invokeMock.mock.calls.some( + expect(invokeMock.mock.calls.filter( ([command]) => command === "ai_conversation_save_timeline", - )).toBe(false); + )).toEqual([]); resolveClear(); await Promise.all([clearing, closing]); diff --git a/src/lib/clipboard.test.ts b/src/lib/clipboard.test.ts index be6758bd..db4af662 100644 --- a/src/lib/clipboard.test.ts +++ b/src/lib/clipboard.test.ts @@ -1,59 +1,57 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -const invokeMock = vi.fn(); - +const { invokeMock, host } = vi.hoisted(() => ({ + invokeMock: vi.fn(), + host: { nativeClipboard: true }, +})); vi.mock("@tauri-apps/api/core", () => ({ invoke: invokeMock })); +vi.mock("./stores/runtime.svelte.ts", () => ({ capabilities: () => host })); -beforeEach(() => { - invokeMock.mockReset(); - vi.unstubAllGlobals(); -}); - +beforeEach(() => { host.nativeClipboard = true; }); afterEach(() => { + invokeMock.mockReset(); vi.unstubAllGlobals(); }); describe("text clipboard", () => { - it("uses the desktop commands and preserves failures", async () => { - vi.stubGlobal("navigator", { userAgent: "Macintosh" }); - invokeMock.mockRejectedValue(new Error("clipboard unavailable")); - vi.resetModules(); - const clipboard = await import("./clipboard.ts"); - - await expect(clipboard.readText()).rejects.toThrow("clipboard unavailable"); - await expect(clipboard.writeText("hello")).rejects.toThrow("clipboard unavailable"); - expect(invokeMock).toHaveBeenCalledWith("clipboard_read"); - expect(invokeMock).toHaveBeenCalledWith("clipboard_write", { text: "hello" }); - }); - - it("uses the browser clipboard on mobile", async () => { - const readText = vi.fn(async () => "mobile text"); - const writeText = vi.fn(async () => undefined); - vi.stubGlobal("navigator", { - userAgent: "Mozilla/5.0 (Linux; Android 15)", - clipboard: { readText, writeText }, - }); - vi.resetModules(); + it.each(["Macintosh", "Android 15", "iPhone", "Windows NT 10.0"])( + "uses the native adapter when the host provides it, regardless of %s", async (userAgent) => { + const browserRead = vi.fn(); + const browserWrite = vi.fn(); + vi.stubGlobal("navigator", { userAgent, clipboard: { readText: browserRead, writeText: browserWrite } }); + invokeMock.mockImplementation(async (command) => command === "clipboard_read" ? "clipboard text" : undefined); + const clipboard = await import("./clipboard.ts"); + await expect(clipboard.readText()).resolves.toBe("clipboard text"); + await clipboard.writeText("hello"); + expect(invokeMock).toHaveBeenCalledWith("clipboard_read"); + expect(invokeMock).toHaveBeenCalledWith("clipboard_write", { text: "hello" }); + expect(browserRead).not.toHaveBeenCalled(); + expect(browserWrite).not.toHaveBeenCalled(); + }, + ); + + it("preserves the WebView/browser clipboard path on hosts without a native adapter", async () => { + host.nativeClipboard = false; + const readText = vi.fn().mockResolvedValue("browser text"); + const writeText = vi.fn().mockResolvedValue(undefined); + vi.stubGlobal("navigator", { userAgent: "Macintosh", clipboard: { readText, writeText } }); const clipboard = await import("./clipboard.ts"); - - await expect(clipboard.readText()).resolves.toBe("mobile text"); - await expect(clipboard.writeText("hello")).resolves.toBeUndefined(); + await expect(clipboard.readText()).resolves.toBe("browser text"); + await clipboard.writeText("hello"); expect(writeText).toHaveBeenCalledWith("hello"); expect(invokeMock).not.toHaveBeenCalled(); }); - it("turns synchronous browser clipboard failures into rejected promises", async () => { - vi.stubGlobal("navigator", { - userAgent: "Mozilla/5.0 (iPhone)", - clipboard: { - readText: () => { throw new Error("read denied"); }, - writeText: () => { throw new Error("write denied"); }, - }, - }); - vi.resetModules(); + it.each([true, false])("preserves failures for nativeClipboard=%s", async (nativeClipboard) => { + host.nativeClipboard = nativeClipboard; + const failure = new Error("clipboard unavailable"); + invokeMock.mockRejectedValue(failure); + vi.stubGlobal("navigator", { clipboard: { + readText: vi.fn().mockRejectedValue(failure), + writeText: vi.fn().mockRejectedValue(failure), + } }); const clipboard = await import("./clipboard.ts"); - - await expect(clipboard.readText()).rejects.toThrow("read denied"); - await expect(clipboard.writeText("hello")).rejects.toThrow("write denied"); + await expect(clipboard.readText()).rejects.toThrow("clipboard unavailable"); + await expect(clipboard.writeText("hello")).rejects.toThrow("clipboard unavailable"); }); }); diff --git a/src/lib/clipboard.ts b/src/lib/clipboard.ts index 83925f6c..1c9eab2f 100644 --- a/src/lib/clipboard.ts +++ b/src/lib/clipboard.ts @@ -1,20 +1,18 @@ import { invoke } from "@tauri-apps/api/core"; - -const isMobile = typeof navigator !== "undefined" - && /Android|iPhone|iPad/i.test(navigator.userAgent); +import { capabilities } from "./stores/runtime.svelte.ts"; /** Read text from the system clipboard. Errors are intentionally preserved. */ export async function readText(): Promise { - return isMobile - ? await navigator.clipboard.readText() - : await invoke("clipboard_read"); + return capabilities().nativeClipboard + ? invoke("clipboard_read") + : navigator.clipboard.readText(); } /** Write text to the system clipboard. Errors are intentionally preserved. */ export async function writeText(text: string): Promise { - if (isMobile) { + if (capabilities().nativeClipboard) { + await invoke("clipboard_write", { text }); + } else { await navigator.clipboard.writeText(text); - return; } - await invoke("clipboard_write", { text }); } diff --git a/src/lib/components/AboutScreen.svelte b/src/lib/components/AboutScreen.svelte index 752977c4..256fb910 100644 --- a/src/lib/components/AboutScreen.svelte +++ b/src/lib/components/AboutScreen.svelte @@ -6,7 +6,7 @@ import * as updates from "../stores/updates.svelte.ts"; import { writeText as writeClipboard } from "../clipboard.ts"; import { toast } from "../stores/toast.svelte.ts"; - import { isIOS } from "../stores/app.svelte.ts"; + import { capabilities } from "../stores/runtime.svelte.ts"; import WelcomeScreen from "./WelcomeScreen.svelte"; const REPO = "rssh-org/rssh"; @@ -56,7 +56,7 @@
v{version}
- {#if !isIOS} + {#if capabilities().releaseUpdateCheck}
{#if update.kind === "outdated"} +
+ {/if} {#if resourcePanesAllowed && pluginHostOk && pluginStripPlugins.length > 0 && pluginStripTabs.length > 0 && pluginStripPos === "top"} plugins.closeStrip(app.activePaneId())} /> {/if}
@@ -1479,6 +1529,7 @@ plugins={pluginStripPlugins} tabs={pluginStripTabs} activeTabId={app.activePaneId()} + onClose={() => plugins.closeStrip(app.activePaneId())} /> {/if}
@@ -1644,6 +1695,17 @@ /* 终端区——所有 .pane 挂在 .terminal-region(绝对定位由它提供 position: relative),插件横条区作为兄弟节点贴其上/下边缘。min-width: 0 让 flex 能把它压到 0(窄屏 AI 接管时)。 */ + .startup-error { + display: flex; + align-items: center; + gap: 12px; + padding: 12px; + flex-wrap: wrap; + overflow-wrap: anywhere; + color: var(--text); + background: var(--bg); + border-bottom: 1px solid var(--divider); + } .main-area { display: flex; flex-direction: column; diff --git a/src/lib/components/AppearanceSettings.svelte b/src/lib/components/AppearanceSettings.svelte index e3e1942c..1a096404 100644 --- a/src/lib/components/AppearanceSettings.svelte +++ b/src/lib/components/AppearanceSettings.svelte @@ -2,6 +2,7 @@ import { onMount } from "svelte"; import { invoke } from "@tauri-apps/api/core"; import * as app from "../stores/app.svelte.ts"; + import * as layout from "../stores/layout.svelte.ts"; import * as ai from "../ai/store.svelte.ts"; import * as theme from "../themes/store.svelte.ts"; import type { PaletteId } from "../themes/palettes.ts"; @@ -25,10 +26,9 @@ { value: "bottom", labelKey: "settings.appearance.pos.bottom" }, ] as const; - // "bottom" collides with MobileKeybar on mobile — block the choice there, - // don't leave the user to discover the clash after picking. + // The compact terminal toolbar already occupies the bottom edge. function disabled(value: app.SidebarPosition): boolean { - return app.isMobile && value === "bottom"; + return layout.compact() && value === "bottom"; } function pick(value: app.SidebarPosition) { diff --git a/src/lib/components/ConnectionEditor.svelte b/src/lib/components/ConnectionEditor.svelte index ecd6f54e..3b3524a3 100644 --- a/src/lib/components/ConnectionEditor.svelte +++ b/src/lib/components/ConnectionEditor.svelte @@ -16,7 +16,7 @@ const copyFromId = intent.mode === "copy" ? intent.sourceId : null; let selectedKind = $state(intent.kind); - let typeOptions = $derived(availableConnectionKinds(app.isMobile).map((kind) => ({ + let typeOptions = $derived(availableConnectionKinds(app.capabilities().serial).map((kind) => ({ kind, label: kindLabel(kind), description: kindDescription(kind), diff --git a/src/lib/components/ConnectionList.svelte b/src/lib/components/ConnectionList.svelte index 164ee122..2e79d44b 100644 --- a/src/lib/components/ConnectionList.svelte +++ b/src/lib/components/ConnectionList.svelte @@ -47,7 +47,7 @@ [profiles, forwards, serialProfiles, telnetProfiles, groups] = await Promise.all([ loadOrEmpty(app.loadProfiles), loadOrEmpty(app.loadForwards), - app.isMobile ? Promise.resolve([]) : loadOrEmpty(app.loadSerialProfiles), + app.capabilities().serial ? loadOrEmpty(app.loadSerialProfiles) : Promise.resolve([]), loadOrEmpty(app.loadTelnetProfiles), loadOrEmpty(app.loadGroups), ]); diff --git a/src/lib/components/CredentialEditor.svelte b/src/lib/components/CredentialEditor.svelte index 1ca7f183..f7942668 100644 --- a/src/lib/components/CredentialEditor.svelte +++ b/src/lib/components/CredentialEditor.svelte @@ -19,7 +19,7 @@ let credentialTypeOptions = $derived([ { value: "password", label: t("credential.type.password") }, { value: "key", label: t("credential.type.key") }, - ...(!app.isMobile || credentialType === "agent" + ...(app.capabilities().sshAgent || credentialType === "agent" ? [{ value: "agent", label: t("credential.type.agent") }] : []), { value: "none", label: t("credential.type.none") }, @@ -48,7 +48,7 @@ /** The usual default private keys. One click reads ~/.ssh/ on the host * (where the keys live) and drops it into the textarea — saves hunting for - * the file in a picker. Hidden on mobile: there is no ~/.ssh there. */ + * the file in a picker. Only hosts with home-directory access offer it. */ const DEFAULT_KEY_NAMES = ["id_rsa", "id_ed25519"]; async function fillDefaultKey(keyName: string) { picking = true; @@ -97,7 +97,7 @@ - {#if !app.isMobile} + {#if app.capabilities().defaultKeyFiles} {#each DEFAULT_KEY_NAMES as keyName} {/each} diff --git a/src/lib/components/DownloadsScreen.svelte b/src/lib/components/DownloadsScreen.svelte index 79a25c26..8d9fd9bc 100644 --- a/src/lib/components/DownloadsScreen.svelte +++ b/src/lib/components/DownloadsScreen.svelte @@ -31,7 +31,7 @@ } function basename(p: string): string { - return p.split(/[\\/]/).pop() || p; + return p.split("/").pop() || p; } /** Trigger elements (sidebar entry, SFTP toolbar icon, etc.) are marked @@ -91,7 +91,7 @@ {item.kind === "download" ? "↓" : "↑"}
- {basename(item.kind === "download" ? item.remotePath : item.localPath)} + {basename(item.remotePath)}
→ {item.kind === "download" ? item.localPath : item.remotePath} diff --git a/src/lib/components/HomeScreen.svelte b/src/lib/components/HomeScreen.svelte index 082c41f5..09f5f1ac 100644 --- a/src/lib/components/HomeScreen.svelte +++ b/src/lib/components/HomeScreen.svelte @@ -67,7 +67,7 @@ app.loadCredentials(), app.loadForwards(), app.loadGroups(), - app.isMobile ? Promise.resolve([]) : app.loadSerialProfiles(), + app.capabilities().serial ? app.loadSerialProfiles() : Promise.resolve([]), app.loadTelnetProfiles(), ]); return { @@ -79,9 +79,9 @@ loadedTelnet, }; }, - loadDynamic: () => app.isMobile - ? Promise.resolve({ targets: [], errors: [] }) - : app.discoverDynamicTargets(), + loadDynamic: () => app.capabilities().localDiscovery + ? app.discoverDynamicTargets() + : Promise.resolve({ targets: [], errors: [] }), applyStatic: (loaded) => { profiles = loaded.loadedProfiles; credentials = loaded.loadedCredentials; diff --git a/src/lib/components/PlaybackScreen.svelte b/src/lib/components/PlaybackScreen.svelte index f75dc843..99899d2f 100644 --- a/src/lib/components/PlaybackScreen.svelte +++ b/src/lib/components/PlaybackScreen.svelte @@ -51,9 +51,8 @@ fitAddon?.fit(); }); - // Mobile: one-finger drag scrolls the playback scrollback (xterm wires no - // touch scroll itself). Desktop uses the wheel, so gate on isMobile. - if (app.isMobile) touchScrollCleanup = setupTouchScroll(containerEl, terminal); + // Handle actual touch input on every host; mouse/wheel remain owned by xterm. + touchScrollCleanup = setupTouchScroll(containerEl, terminal); if (fileName) await loadCast(fileName); window.addEventListener("resize", handleResize); @@ -169,7 +168,7 @@
-
+
diff --git a/src/lib/components/SerialProfileEditor.svelte b/src/lib/components/SerialProfileEditor.svelte index 5972ba70..057c5ae1 100644 --- a/src/lib/components/SerialProfileEditor.svelte +++ b/src/lib/components/SerialProfileEditor.svelte @@ -2,6 +2,7 @@ import { onMount } from "svelte"; import { invoke } from "@tauri-apps/api/core"; import * as app from "../stores/app.svelte.ts"; + import * as serial from "../stores/serial.svelte.ts"; import type { Group } from "../stores/app.svelte.ts"; import { toast } from "../stores/toast.svelte.ts"; import { t, errMsg } from "../i18n/index.svelte.ts"; @@ -33,10 +34,27 @@ let saving = $state(false); // Detected ports populate a for the free-form port input. let ports = $state([]); + let portsLoading = $state(false); + let portsError = $state(null); let groups = $state([]); let groupId = $state(null); let loading = $state(true); let loadError = $state(null); + let capabilities = $derived(serial.capabilities()); + let capabilityState = $derived(serial.state()); + let unsupportedBaud = $derived( + !!capabilities?.baudRates.length && !capabilities.baudRates.includes(Number(baudRate)), + ); + let unsupportedFlow = $derived(!!capabilities && !capabilities.flowControl && flowControl !== "none"); + let unsupportedXany = $derived(!!capabilities && !capabilities.xany && xany); + let invalidBaud = $derived(!Number.isInteger(baudRate) || baudRate < 1 || baudRate > 4294967295); + let invalidSettings = $derived( + !capabilities || unsupportedBaud || unsupportedFlow || unsupportedXany || invalidBaud, + ); + let baudOptions = $derived([ + ...(unsupportedBaud ? [{ value: baudRate, label: String(baudRate), disabled: true }] : []), + ...(capabilities?.baudRates ?? []).map((value) => ({ value, label: String(value) })), + ]); let groupOptions = $derived([ { value: null, label: t("profile.none") }, ...groups.map((g) => ({ value: g.id, label: g.name })), @@ -59,8 +77,8 @@ ]); let flowOptions = $derived([ { value: "none", label: t("serial.flow.none") }, - { value: "software", label: t("serial.flow.software") }, - { value: "hardware", label: t("serial.flow.hardware") }, + { value: "software", label: t("serial.flow.software"), disabled: !capabilities?.flowControl }, + { value: "hardware", label: t("serial.flow.hardware"), disabled: !capabilities?.flowControl }, ]); let newlineOutOptions = $derived([ { value: "raw", label: t("serial.nl.raw") }, @@ -83,8 +101,23 @@ { value: "hex", label: t("serial.mode.hex") }, ]); + async function refreshPorts() { + if (portsLoading) return; + portsLoading = true; + portsError = null; + try { + ports = await invoke("serial_list_ports"); + } catch (error) { + ports = []; + portsError = errMsg(error); + } finally { + portsLoading = false; + } + } + onMount(async () => { - invoke("serial_list_ports").then((p) => (ports = p)).catch(() => {}); + void serial.load(); + void refreshPorts(); try { groups = await app.loadGroups(); } catch (error) { @@ -110,7 +143,7 @@ }); async function save() { - if (loading || loadError || saving) return; + if (loading || loadError || saving || invalidSettings) return; saving = true; try { const profile = { @@ -141,20 +174,53 @@ +
+ + +
{#each ports as p}{/each} -

{t("serial.port_device_hint")}

+

{t("serial.port_device_hint")}

+ {#if portsError} + + {:else if !portsLoading && ports.length === 0} +

{t("serial.ports_empty")}

+ {/if} - + {#if capabilities?.baudRates.length} + + {/if} + {#if unsupportedBaud} + + {:else if invalidBaud} + + {/if}
- + {t("serial.xany")} + {#if capabilities && !capabilities.xany} +

{t("serial.xany_unsupported")}

+ {/if}
@@ -175,7 +241,7 @@
- @@ -190,7 +256,10 @@ .check { display: flex; align-items: center; gap: 8px; } textarea { font-family: var(--term-font); resize: vertical; } .form :global(.section-label) { margin-top: 10px; } - .port-hint { margin: -4px 0 2px; font-size: 11px; color: var(--text-dim); line-height: 1.4; } + .port-picker { display: flex; align-items: center; gap: 8px; } + .port-picker input { flex: 1; min-width: 0; } + .port-picker button { flex-shrink: 0; } + .hint { margin: -4px 0 2px; font-size: 11px; color: var(--text-dim); line-height: 1.4; } .form-error { padding: 6px 10px; border-radius: 4px; diff --git a/src/lib/components/SettingsLayout.svelte b/src/lib/components/SettingsLayout.svelte index 585a1ee9..51571654 100644 --- a/src/lib/components/SettingsLayout.svelte +++ b/src/lib/components/SettingsLayout.svelte @@ -1,6 +1,7 @@
- {#if !app.isMobile} + {#if app.capabilities().localPty}
@@ -241,20 +241,18 @@
- {#if !app.isMobile} -
-
-
{t("settings.shell.copy_on_select")}
-
{t("settings.shell.copy_on_select_desc")}
-
- +
+
+
{t("settings.shell.copy_on_select")}
+
{t("settings.shell.copy_on_select_desc")}
+ +
-
- {/if} +
@@ -267,21 +265,19 @@
- {#if !app.isMobile} -
+
-
-
- -
{t("settings.shell.right_click_desc")}
-
-
- saveRightClickAction(v as app.RightClickAction)} /> +
+
{ extOpen = false; }}>
@@ -141,7 +131,7 @@ {#if app.activeTab()?.type === "ssh"} - {/if} diff --git a/src/lib/components/TerminalPane.svelte b/src/lib/components/TerminalPane.svelte index d74c29ee..f2b70273 100644 --- a/src/lib/components/TerminalPane.svelte +++ b/src/lib/components/TerminalPane.svelte @@ -11,10 +11,14 @@ import {listen, type UnlistenFn} from "@tauri-apps/api/event"; import type {ConnectorSpec, HighlightRule, TelnetProfile} from "../stores/app.svelte.ts"; import * as app from "../stores/app.svelte.ts"; + import * as layout from "../stores/layout.svelte.ts"; + import { terminalPolicy } from "../stores/runtime.svelte.ts"; + import { isIOS } from "../platform.ts"; + import { supportsTouch, isTouchContextMenu } from "../input.ts"; import * as syncStatus from "../stores/sync.svelte.ts"; import * as ai from "../ai/store.svelte.ts"; import * as theme from "../themes/store.svelte.ts"; - import MobileKeybar from "./MobileKeybar.svelte"; + import TerminalKeybar from "./TerminalKeybar.svelte"; import {registerRsshOscHandlers} from "../osc/handler.ts"; import {registerClipboardOscHandler} from "../osc/clipboard.ts"; import {createCommandBlockTracker, type CommandBlock, type CommandBlockTracker} from "../terminal/command-blocks.ts"; @@ -25,8 +29,6 @@ commandBlockFoldCacheLines, BACKLOG_DROP_TRIGGER_BYTES, BACKLOG_INDICATOR_BYTES, - BACKLOG_MAX_PENDING_BYTES, - BACKLOG_MAX_PENDING_BYTES_MOBILE, BACKLOG_QUIESCENCE_MS, } from "../terminal/limits.ts"; import {createPaintScheduler, type PaintScheduler} from "../terminal/paint-scheduler.ts"; @@ -36,10 +38,11 @@ import {extractBlockTexts, extractBlocksText} from "../terminal/block-content.ts"; import {redactCommandBlockTexts} from "../terminal/command-block-redaction.ts"; import {setupTouchScroll} from "../terminal/touch-scroll.ts"; - import {setupSoftKeyboardInset} from "../soft-keyboard-inset.ts"; + import {setupTerminalSoftKeyboard} from "../terminal/soft-keyboard.ts"; import {registerBracketedPasteProvider, unregisterBracketedPasteProvider} from "../terminal/bracketed-paste.ts"; import {setupXtermIme229Workaround} from "../terminal/xterm-ime-229-workaround.ts"; import {createReservedSessionAttempt} from "../terminal/reserved-session-attempt.ts"; + import {sessionCleanupScope} from "../stores/session-cleanup.svelte.ts"; import {renderBlocksToBlob} from "../terminal/block-to-image.ts"; import {inputNewline, normalizeIncoming, bytesToHex, parseHexInput, parseLoginScript, remapEditingKeys, normalizeOutgoing, type LoginStep} from "../terminal/serial-transforms.ts"; import {compileHighlightRules, type CompiledHighlightRule} from "../terminal/highlight.ts"; @@ -617,11 +620,9 @@ let reconnectDisposable: IDisposable | undefined; let resizeObs: ResizeObserver; let ime229WorkaroundCleanup: (() => void) | undefined; - let mobileKeyboardCleanup: (() => void) | undefined; - // The active pane's keyboard toggle, built by setupMobileSoftKeyboard and - // registered in the activation effect (see below for why not at mount). - let softKbToggle: (() => void) | null = null; - let mobileTouchScrollCleanup: (() => void) | undefined; + let softKeyboard = $state.raw>(); + let softKeyboardRequested = $state(false); + let touchScrollCleanup: (() => void) | undefined; const isLocal = $derived(tabType === "local"); const isPtyConnector = $derived(tabType === "docker_exec" || tabType === "kubectl_exec"); @@ -923,7 +924,7 @@ * after preventDefault. Stopping it first lets preventDefault actually * suppress the menu. */ function onTerminalContextMenu(e: MouseEvent) { - if (app.isMobile) return; // mobile keeps the native long-press menu + if (isTouchContextMenu(e)) return; // Preserve the native touch/pen long-press menu. // Cmd on macOS, Ctrl elsewhere + right-click falls back to the native menu // even in paste/copyPaste modes — an opt-in escape hatch toggled in Shell // settings. Returns without preventDefault/stopPropagation so xterm's own @@ -1049,6 +1050,12 @@ } const reservedSessionAttempt = createReservedSessionAttempt({ + // Serial handles are exclusive by native port identity, including when + // a replacement pane has a new tab ID. Other transports stay tab-local. + cleanup: untrack(() => sessionCleanupScope( + tabType === "serial" ? `serial:${meta.port}` : `tab:${tabId}`, + meta.host || meta.port || tabId, + )), makeId: () => crypto.randomUUID(), wireEvents: createSessionEventSubscription, close: async (sid) => { @@ -1130,7 +1137,7 @@ } } catch (e: any) { if (!isCurrent()) return false; - terminal.write(`\x1b[31mSerial open failed: ${e}\x1b[0m\r\n`); + terminal.write(`\x1b[31m${errMsg(e)}\x1b[0m\r\n`); terminal.write("\x1b[90mPress any key to retry.\x1b[0m\r\n"); disconnected = true; reportInitialConnectionFailure(e); @@ -1228,7 +1235,7 @@ if (!isCurrent()) { return false; } - terminal.write(`\x1b[31mLaunch failed: ${e}\x1b[0m\r\n`); + terminal.write(`\x1b[31m${errMsg(e)}\x1b[0m\r\n`); disconnected = true; reportInitialConnectionFailure(e); return false; @@ -1262,7 +1269,7 @@ } catch (e: any) { clearSshPromptUi(); if (!isCurrent()) return false; - terminal.write(`\x1b[31mConnection failed: ${e}\x1b[0m\r\n`); + terminal.write(`\x1b[31m${errMsg(e)}\x1b[0m\r\n`); terminal.write("\x1b[90mPress any key to reconnect.\x1b[0m\r\n"); disconnected = true; reportInitialConnectionFailure(e); @@ -1325,6 +1332,28 @@ return data; } + function writeControl(text: string) { + maybeReleaseBacklog(text); + if (sessionId && !disconnected) { + if (streamOpts) { + streamSendText(text); + } else { + invoke(writeCmd, {sessionId, data: Array.from(new TextEncoder().encode(text))}) + .catch((e) => console.warn(`[${tabType}] control write failed:`, e)); + } + } + } + + function sendControlArrow(dir: app.ArrowDir, mod: number) { + // DECCKM: bare arrows use SS3 in app-cursor mode, CSI otherwise. + // Modified arrows always use CSI with params; SS3 has no param form. + const appMode = terminal.modes.applicationCursorKeysMode; + const seq = mod === 0 + ? (appMode ? `\x1bO${dir}` : `\x1b[${dir}`) + : `\x1b[1;${mod}${dir}`; + writeControl(seq); + } + function setupReconnect() { reconnectDisposable?.dispose(); reconnectDisposable = terminal.onData(() => { @@ -1347,176 +1376,6 @@ } } - function setupMobileSoftKeyboard(helper: HTMLTextAreaElement) { - const originalHelperStyle = helper.getAttribute("style"); - let scrollResetRaf = 0; - let helperPinRaf = 0; - - function resetDocumentScroll() { - if (scrollResetRaf) return; - scrollResetRaf = requestAnimationFrame(() => { - scrollResetRaf = 0; - if (window.scrollX !== 0 || window.scrollY !== 0) window.scrollTo(0, 0); - document.documentElement.scrollTop = 0; - document.documentElement.scrollLeft = 0; - document.body.scrollTop = 0; - document.body.scrollLeft = 0; - }); - } - - function pinKeyboardHelper() { - const viewport = window.visualViewport; - const minTop = (viewport?.offsetTop ?? 0) + 1; - const maxTop = minTop + (viewport?.height ?? window.innerHeight) - 2; - const containerTop = containerEl.getBoundingClientRect().top + 8; - const top = Math.max(minTop, Math.min(maxTop, containerTop)); - - // xterm keeps this textarea far off-screen by default. On mobile - // WebView, focusing it makes the page pan to reveal it, and the IME - // composition/candidate UI anchors to its position — so off-screen - // means a yanked page and a misplaced input popup. Keep it invisible - // but in-view. (Layout-independent: not about any fixed chrome.) - helper.style.position = "fixed"; - helper.style.left = "1px"; - helper.style.top = `${Math.round(top)}px`; - helper.style.width = "1px"; - helper.style.height = "1px"; - helper.style.opacity = "0"; - helper.style.zIndex = "-1"; - helper.style.pointerEvents = "none"; - helper.style.caretColor = "transparent"; - helper.style.background = "transparent"; - helper.style.color = "transparent"; - helper.style.border = "0"; - helper.style.padding = "0"; - helper.style.margin = "0"; - helper.style.outline = "0"; - helper.style.resize = "none"; - helper.style.overflow = "hidden"; - } - - function onViewportChange() { - if (document.activeElement !== helper) return; - pinKeyboardHelper(); - resetDocumentScroll(); - } - - function onWindowScroll() { - if (document.activeElement === helper) resetDocumentScroll(); - } - - function keepKeyboardHelperInView() { - pinKeyboardHelper(); - resetDocumentScroll(); - if (helperPinRaf) cancelAnimationFrame(helperPinRaf); - helperPinRaf = requestAnimationFrame(() => { - helperPinRaf = 0; - pinKeyboardHelper(); - resetDocumentScroll(); - }); - } - - function lockKeyboard() { - helper.readOnly = true; - helper.setAttribute("readonly", "true"); - helper.setAttribute("inputmode", "none"); - helper.tabIndex = -1; - helper.inert = true; - } - - function unlockKeyboard() { - helper.inert = false; - helper.readOnly = false; - helper.removeAttribute("readonly"); - helper.setAttribute("inputmode", "text"); - helper.tabIndex = 0; - } - - function showKeyboard() { - pinKeyboardHelper(); - unlockKeyboard(); - helper.focus({ preventScroll: true }); - resetDocumentScroll(); - } - - function hideKeyboard() { - helper.blur(); - lockKeyboard(); - resetDocumentScroll(); - } - - // The keyboard opens ONLY from the keybar button, so terminal touches - // need no tap/drag/long-press classification — that state machine - // existed to decide whether a tap should OPEN it. Any touch on the - // terminal just dismisses a keyboard that is open, at pointerdown: - // sooner than the old drag-slop / long-press-timer paths ever did. - // No preventDefault anywhere: long-press must still reach the native - // copy/paste menu. - function onTerminalTouchDown(ev: PointerEvent) { - if (ev.pointerType !== "touch" && ev.pointerType !== "pen") return; - if (document.activeElement === helper) hideKeyboard(); - } - - function onFocus() { - app.setSoftKeyboardOpen(true); - } - - function onBlur() { - app.setSoftKeyboardOpen(false); - lockKeyboard(); - } - - // The keybar's keyboard button is the ONLY way to pop the keyboard. - // Only STORE the toggle here — registering at mount would let a hidden - // pane steal the store's single slot (panes stay mounted per tab); the - // activation effect registers it when THIS pane becomes the active tab. - softKbToggle = () => { - if (document.activeElement === helper) hideKeyboard(); - else showKeyboard(); - }; - pinKeyboardHelper(); - lockKeyboard(); - helper.blur(); - helper.addEventListener("focus", onFocus); - helper.addEventListener("blur", onBlur); - helper.addEventListener("input", keepKeyboardHelperInView); - helper.addEventListener("keydown", keepKeyboardHelperInView); - helper.addEventListener("compositionstart", keepKeyboardHelperInView); - helper.addEventListener("compositionupdate", keepKeyboardHelperInView); - window.addEventListener("scroll", onWindowScroll, { passive: true }); - window.visualViewport?.addEventListener("scroll", onViewportChange, { passive: true }); - window.visualViewport?.addEventListener("resize", onViewportChange, { passive: true }); - containerEl.addEventListener("pointerdown", onTerminalTouchDown, { capture: true, passive: true }); - // iOS overlays the keyboard instead of resizing the webview: pad the - // pane up onto the visible viewport so the keybar rides on top of the - // keyboard — up with the open animation, down with the close (settle - // mode inside setupSoftKeyboardInset). Registered after our own - // listeners so the helper pin runs before the padding write. No-op on - // Android, where the webview already resized; the pane's - // ResizeObserver refits the terminal as the pane shrinks. - const insetCleanup = setupSoftKeyboardInset(paneEl, helper); - - return () => { - if (scrollResetRaf) cancelAnimationFrame(scrollResetRaf); - if (helperPinRaf) cancelAnimationFrame(helperPinRaf); - insetCleanup(); - if (originalHelperStyle === null) helper.removeAttribute("style"); - else helper.setAttribute("style", originalHelperStyle); - helper.removeEventListener("focus", onFocus); - helper.removeEventListener("blur", onBlur); - if (softKbToggle) app.unregisterSoftKeyboardToggle(softKbToggle); - softKbToggle = null; - helper.removeEventListener("input", keepKeyboardHelperInView); - helper.removeEventListener("keydown", keepKeyboardHelperInView); - helper.removeEventListener("compositionstart", keepKeyboardHelperInView); - helper.removeEventListener("compositionupdate", keepKeyboardHelperInView); - window.removeEventListener("scroll", onWindowScroll); - window.visualViewport?.removeEventListener("scroll", onViewportChange); - window.visualViewport?.removeEventListener("resize", onViewportChange); - containerEl.removeEventListener("pointerdown", onTerminalTouchDown, { capture: true }); - }; - } - let unsubscribeTheme: (() => void) | null = null; let unsubscribeFont: (() => void) | null = null; let unsubscribeGpu: (() => void) | null = null; @@ -1530,8 +1389,7 @@ } onMount(async () => { - const IMAGE_STORAGE_LIMIT_MB = app.isMobile ? 32 : 128; - const IMAGE_PIXEL_LIMIT = app.isMobile ? 4_000_000 : 16_000_000; + const policy = terminalPolicy(); terminal = new Terminal({ cursorBlink: true, @@ -1562,23 +1420,21 @@ terminal.loadAddon(fitAddon); terminal.loadAddon(searchAddon); terminal.loadAddon(new Unicode11Addon()); - // SIXEL / iTerm IIP 图片协议。移动端把内存上限压一半。 + // The host owns image budgets; changing layout never changes limits. terminal.loadAddon(new ImageAddon({ sixelSupport: true, sixelScrolling: true, iipSupport: true, - storageLimit: IMAGE_STORAGE_LIMIT_MB, - pixelLimit: IMAGE_PIXEL_LIMIT, + storageLimit: policy.imageStorageLimitMb, + pixelLimit: policy.imagePixelLimit, })); terminal.open(containerEl); // GPU renderer: the default DomRenderer rebuilds DOM spans per paint // and drowns on flood output. WebGL draws from a texture atlas — the // "GPU acceleration" every modern terminal ships. Any failure (old // GPU, RDP, WebView without WebGL2) falls back to the DomRenderer. - // The user toggle decides (theme.termGpuRender, live): it defaults - // off on mobile because WebGL paints glyphs into a canvas, leaving - // no DOM text — iOS's native long-press selection (the blue handles) - // has nothing to grab. Toggling mid-session swaps renderers in place. + // The user's live renderer preference is shared across hosts. + // Toggling mid-session swaps renderers in place. unsubscribeGpu = theme.registerXtermGpuListener(on => { if (!on) { disposeWebgl(); @@ -1600,16 +1456,14 @@ ime229WorkaroundCleanup = setupXtermIme229Workaround({ terminal, host: containerEl, - enabled: keymap.isMac && !app.isMobile, + enabled: keymap.isMac && !isIOS, }); terminal.unicode.activeVersion = "11"; // Flood control: all data-event writes go through the feeder (see // output-feeder.ts). Synthetic UI writes (prompts, banners) bypass it. outputFeeder = createOutputFeeder({ write: (data, cb) => terminal.write(data, cb), - maxPendingBytes: app.isMobile - ? BACKLOG_MAX_PENDING_BYTES_MOBILE - : BACKLOG_MAX_PENDING_BYTES, + maxPendingBytes: policy.outputBacklogLimitBytes, }); // Keyword highlighting lives here: a decoration layer over the parsed // cell grid. The reactive $effect above feeds it the compiled rules. @@ -1638,22 +1492,26 @@ fitTerminal(); }); - // 移动端:键盘只从 keybar 的 ⌨ 按钮弹出(issue #225)。终端上任何 - // 触摸只负责收起已打开的键盘;helper 常态锁定,长按选择不受影响。 - if (app.isMobile) { - const helper = terminal.textarea ?? containerEl.querySelector(".xterm-helper-textarea"); - if (helper) { - mobileKeyboardCleanup = setupMobileSoftKeyboard(helper); - } - // 与上面的 pointer 键盘状态机正交:靠"位移超阈值才接管"避开点按(弹键盘)和 - // 长按(选字)。代价:长按选中后再拖动会变成滚动而非扩展选区——移动端有 - // 块复制 / 发 AI 取文,这个取舍可接受。 - mobileTouchScrollCleanup = setupTouchScroll(containerEl, terminal); + const helper = terminal.textarea ?? containerEl.querySelector(".xterm-helper-textarea"); + if (helper) { + softKeyboard = setupTerminalSoftKeyboard({ + helper, host: containerEl, pane: paneEl, touchAvailable: supportsTouch(), + onRequestedChange: (requested) => { + softKeyboardRequested = requested; + if (app.activeTabId() === tabId && !app.settingsActive()) { + app.setSoftKeyboardOpen(requested); + } + }, + }); } + // The handler consumes touch events only; mouse/wheel input keeps xterm's path. + touchScrollCleanup = setupTouchScroll(containerEl, terminal); app.registerTerminalControls(tabId, { getSelection: () => terminal.getSelection(), paste: pasteText, + writeControl, + sendArrow: sendControlArrow, sendText, focus: () => terminal.focus(), readViewport: () => readViewportSnapshot(terminal), @@ -1692,8 +1550,8 @@ for (const a of ACTIONS) { if (a.surface !== "terminal") continue; if (!matchBinding(e, keymap.binding(a.id))) continue; - // SFTP only applies to remote sessions on desktop; elsewhere let the shell have the key. - if (a.id === "term.sftp" && (!isSsh || app.isMobile)) return true; + // SFTP only applies to SSH sessions; other transports keep the key. + if (a.id === "term.sftp" && !isSsh) return true; e.preventDefault(); runTerminalShortcut(a.id); return false; @@ -1855,7 +1713,7 @@ if (visible) foldStore?.enforceAutoFold(); }); - // Focus terminal + register writer when this tab becomes active. + // Focus the terminal and expose its keyboard controller when it becomes active. // Double-rAF: the first frame lets the browser apply layout after // the pane switches from display:none → flex; the second frame // ensures the computed dimensions are stable before we fit. @@ -1863,32 +1721,15 @@ if (app.activeTabId() === tabId && !app.settingsActive()) { requestAnimationFrame(() => requestAnimationFrame(fitTerminal)); terminal?.focus(); - const writePty = (text: string) => { - maybeReleaseBacklog(text); - if (sessionId && !disconnected) { - if (streamOpts) { - streamSendText(text); - } else { - invoke(writeCmd, {sessionId, data: Array.from(new TextEncoder().encode(text))}) - .catch((e) => console.warn(`[${tabType}] control write failed:`, e)); - } - } - }; - app.registerTerminalWriter(writePty); - // DECCKM: bare arrows use SS3 (ESC O x) in app-cursor mode, CSI (ESC [ x) - // in normal mode. Modified arrows always use CSI with params — SS3 has - // no param form. That's the protocol, not a design choice. - app.registerTerminalArrowSender((dir, mod) => { - const appMode = terminal.modes.applicationCursorKeysMode; - const seq = mod === 0 - ? (appMode ? `\x1bO${dir}` : `\x1b[${dir}`) - : `\x1b[1;${mod}${dir}`; - writePty(seq); - }); - // Same re-own-on-activation as the writer above: the visible tab's - // ⌨ button must always control this pane's helper, never a hidden - // pane that happened to mount later. - if (softKbToggle) app.registerSoftKeyboardToggle(softKbToggle); + // The keyboard button must control this pane's helper, never a + // hidden pane that happened to mount later. + if (softKeyboard) { + app.registerSoftKeyboardToggle(softKeyboard.toggle); + untrack(() => app.setSoftKeyboardOpen(softKeyboardRequested)); + } + } else if (softKeyboard) { + untrack(() => softKeyboard?.hide()); + app.unregisterSoftKeyboardToggle(softKeyboard.toggle); } }); @@ -1932,8 +1773,11 @@ resizeObs?.disconnect(); ime229WorkaroundCleanup?.(); ime229WorkaroundCleanup = undefined; - mobileKeyboardCleanup?.(); - mobileTouchScrollCleanup?.(); + if (softKeyboard) { + app.unregisterSoftKeyboardToggle(softKeyboard.toggle); + softKeyboard.dispose(); + } + touchScrollCleanup?.(); paintScheduler?.dispose(); outputFeeder?.dispose(); outputFeeder = undefined; @@ -1941,8 +1785,6 @@ backlogRaf = null; foldStore?.dispose(); blockTracker?.dispose(); - app.unregisterTerminalWriter(); - app.unregisterTerminalArrowSender(); app.unregisterTerminalControls(tabId); unregisterBracketedPasteProvider(tabId); app.unregisterSession(tabId); @@ -1990,7 +1832,7 @@
{/if} -
+
{#if backlogBytes > BACKLOG_INDICATOR_BYTES}
@@ -2058,13 +1900,15 @@ onClose={() => (ctxMenu = null)} /> {/if} - {#if app.isMobile} - + + {#if app.activeTabId() === tabId && layout.compact()} + {/if}
diff --git a/src/lib/plugins/store.svelte.ts b/src/lib/plugins/store.svelte.ts index 8a636b49..d27c4b90 100644 --- a/src/lib/plugins/store.svelte.ts +++ b/src/lib/plugins/store.svelte.ts @@ -12,6 +12,7 @@ import { invoke, convertFileSrc } from "@tauri-apps/api/core"; import { createSidePanelState } from "../stores/panel-state.svelte.ts"; +import { capabilities } from "../stores/runtime.svelte.ts"; export type PluginArea = "side" | "strip"; export type SidePosition = "left" | "right"; @@ -144,13 +145,9 @@ export async function movePluginTo(id: string, targetId: string): Promise await load(); } -/** - * Whether this host can serve plugin files at all. In the real Tauri webview - * convertFileSrc maps to the asset protocol; the JCEF/browser shim returns - * the path unchanged (no scheme), where an iframe could never load it. - */ +/** Whether this host implements the plugin asset and execution adapters. */ export function hostSupported(): boolean { - return /^(asset|https?):/i.test(convertFileSrc("/probe")); + return capabilities().plugins; } // ── Per-tab panel state (kept alive across tab switches) ───────────────── diff --git a/src/lib/plugins/store.test.ts b/src/lib/plugins/store.test.ts index 2747f4ae..182e1bf4 100644 --- a/src/lib/plugins/store.test.ts +++ b/src/lib/plugins/store.test.ts @@ -1,5 +1,8 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; +const capabilitiesMock = vi.hoisted(() => vi.fn(() => ({ plugins: true }))); +vi.mock("../stores/runtime.svelte.ts", () => ({ capabilities: capabilitiesMock })); + // localStorage stub BEFORE the store module loads (it reads positions at // module top, same as the ai store — node env has no localStorage). const storage = new Map(); @@ -31,6 +34,7 @@ function plugin(id: string, area: "side" | "strip", enabled = true, sort_order = } beforeEach(() => { + capabilitiesMock.mockReturnValue({ plugins: true }); storage.clear(); invokeMock.mockReset(); convertFileSrcMock.mockClear(); @@ -223,12 +227,12 @@ describe("movePluginTo", () => { }); describe("hostSupported", () => { - it("true when convertFileSrc maps to a protocol URL", () => { + it("uses the host's implemented capability", () => { expect(store.hostSupported()).toBe(true); }); - it("false under the JCEF/browser shim (identity convertFileSrc)", () => { - convertFileSrcMock.mockImplementation((p: string) => p); + it("does not infer plugin support merely from a generated asset URL", () => { + capabilitiesMock.mockReturnValue({ plugins: false }); expect(store.hostSupported()).toBe(false); }); }); diff --git a/src/lib/save-file.test.ts b/src/lib/save-file.test.ts index bce74508..d5bd5318 100644 --- a/src/lib/save-file.test.ts +++ b/src/lib/save-file.test.ts @@ -1,30 +1,29 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -// The real-Tauri branch lazily imports these plugins; mock them so the branch -// is exercisable in node. vi.hoisted lets the factories reference the spies. -const { saveMock, writeTextFileMock } = vi.hoisted(() => ({ - saveMock: vi.fn(), - writeTextFileMock: vi.fn(), -})); -vi.mock("@tauri-apps/plugin-dialog", () => ({ save: saveMock })); -vi.mock("@tauri-apps/plugin-fs", () => ({ writeTextFile: writeTextFileMock })); - -import { saveTextFile } from "./save-file.ts"; +const { invokeMock } = vi.hoisted(() => ({ invokeMock: vi.fn() })); +vi.mock("@tauri-apps/api/core", () => ({ invoke: invokeMock })); let win: any; let anchor: any; +let saveTextFile: typeof import("./save-file.ts").saveTextFile; -beforeEach(() => { - saveMock.mockReset(); - writeTextFileMock.mockReset(); +beforeEach(async () => { + invokeMock.mockReset(); + vi.resetModules(); + vi.stubGlobal("navigator", { userAgent: "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15)", maxTouchPoints: 0 }); win = {}; anchor = { href: "", download: "", style: {} as Record, click: vi.fn(), remove: vi.fn() }; vi.stubGlobal("window", win); vi.stubGlobal("document", { createElement: () => anchor, body: { appendChild() {} } }); - vi.stubGlobal("URL", { createObjectURL: () => "blob:x", revokeObjectURL: vi.fn() }); + vi.spyOn(URL, "createObjectURL").mockReturnValue("blob:x"); + vi.spyOn(URL, "revokeObjectURL").mockImplementation(() => {}); + ({ saveTextFile } = await import("./save-file.ts")); }); -afterEach(() => vi.unstubAllGlobals()); +afterEach(() => { + vi.unstubAllGlobals(); + vi.restoreAllMocks(); +}); describe("saveTextFile", () => { it("browser: triggers a Blob download and returns the default name", async () => { @@ -41,20 +40,53 @@ describe("saveTextFile", () => { ); }); - it("real Tauri: save dialog + fs writeTextFile, returns the chosen path", async () => { + it.each([ + "Mozilla/5.0 (Macintosh)", + "Mozilla/5.0 (Linux; Android 14)", + "Mozilla/5.0 (iPhone)", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64)", + ])("native runtime uses the same save command: %s", async (userAgent) => { win.__TAURI_INTERNALS__ = {}; - saveMock.mockResolvedValue("/home/u/cfg.json"); - const r = await saveTextFile("data", { defaultName: "cfg.json" }); - expect(saveMock).toHaveBeenCalled(); - expect(writeTextFileMock).toHaveBeenCalledWith("/home/u/cfg.json", "data"); - expect(r).toBe("/home/u/cfg.json"); + vi.stubGlobal("navigator", { userAgent }); + vi.resetModules(); + ({ saveTextFile } = await import("./save-file.ts")); + invokeMock.mockResolvedValue("file://docs/cfg.json"); + const filters = [{ name: "JSON", extensions: ["json"] }]; + + await expect(saveTextFile("data", { defaultName: "cfg.json", filters })) + .resolves.toBe("file://docs/cfg.json"); + expect(invokeMock).toHaveBeenCalledExactlyOnceWith("save_text_file", { + defaultName: "cfg.json", contents: "data", filters, + }); }); - it("real Tauri: user cancels → returns null, no write", async () => { + it("native runtime preserves picker cancellation and write errors", async () => { win.__TAURI_INTERNALS__ = {}; - saveMock.mockResolvedValue(null); - const r = await saveTextFile("data", { defaultName: "cfg.json" }); - expect(writeTextFileMock).not.toHaveBeenCalled(); - expect(r).toBeNull(); + invokeMock.mockResolvedValueOnce(null); + await expect(saveTextFile("data", { defaultName: "cfg.json" })).resolves.toBeNull(); + invokeMock.mockRejectedValueOnce(new Error("native write failed")); + await expect(saveTextFile("data", { defaultName: "cfg.json" })).rejects.toThrow("native write failed"); + }); + + it("Android browser with the IPC shim still uses a Blob download", async () => { + win.__TAURI_INTERNALS__ = {}; + win.__RSSH_IPC_SHIM__ = true; + vi.stubGlobal("navigator", { userAgent: "Mozilla/5.0 (Linux; Android 14)" }); + vi.resetModules(); + ({ saveTextFile } = await import("./save-file.ts")); + + await expect(saveTextFile("data", { defaultName: "cfg.json" })).resolves.toBe("cfg.json"); + expect(anchor.click).toHaveBeenCalled(); + expect(invokeMock).not.toHaveBeenCalled(); + }); + + it("JCEF with the IPC shim preserves its explicit unsupported-save error", async () => { + win.__TAURI_INTERNALS__ = {}; + win.__RSSH_IPC_SHIM__ = true; + win.__RSSH_PICK__ = vi.fn(); + + await expect(saveTextFile("data", { defaultName: "cfg.json" })).rejects.toMatch(/file_save_unsupported_in_plugin/); + expect(anchor.click).not.toHaveBeenCalled(); + expect(invokeMock).not.toHaveBeenCalled(); }); }); diff --git a/src/lib/save-file.ts b/src/lib/save-file.ts index 06cb1237..2ae570e1 100644 --- a/src/lib/save-file.ts +++ b/src/lib/save-file.ts @@ -1,16 +1,4 @@ -/** - * Save text to a user-chosen file. One path for desktop, mobile and browser — - * the write side mirror of pickTextFile. - * - * - real Tauri (desktop + mobile): native save dialog (`@tauri-apps/plugin-dialog`) - * + write (`@tauri-apps/plugin-fs`). fs accepts both desktop paths and the - * `content://` URIs Android's SAF returns, so one call covers every target. - * - plain browser: a Blob download to the downloads folder. - * - JCEF (IDE plugin): downloads are silently dropped, so reject with a clear, - * localizable error instead of doing nothing. - * - * Resolves the chosen path/name, or null if the user cancelled. - */ +/** Save text through the native host, or a Blob download in a browser. */ export interface SaveOpts { defaultName: string; filters?: { name: string; extensions: string[] }[]; @@ -19,7 +7,7 @@ export interface SaveOpts { export async function saveTextFile(content: string, opts: SaveOpts): Promise { // Off-Tauri: no plugin runtime. The IDE plugin (JCEF) drops downloads, so it // gets a clear message; a plain browser rides a Blob download. - if (!(window as any).__TAURI_INTERNALS__) { + if (!(window as any).__TAURI_INTERNALS__ || (window as any).__RSSH_IPC_SHIM__) { if (typeof (window as any).__RSSH_PICK__ === "function") return Promise.reject( `__rssh_err__|${JSON.stringify({ code: "file_save_unsupported_in_plugin", params: {} })}`, @@ -28,13 +16,12 @@ export async function saveTextFile(content: string, opts: SaveOpts): Promise("save_text_file", { + defaultName: opts.defaultName, + contents: content, + filters: opts.filters ?? [], + }); } /** Trigger a browser download of `content` saved as `filename`. */ diff --git a/src/lib/sftp-name.test.ts b/src/lib/sftp-name.test.ts deleted file mode 100644 index 47190745..00000000 --- a/src/lib/sftp-name.test.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { remoteUploadName } from "./sftp-name.ts"; - -describe("remoteUploadName", () => { - it("recovers the display name from a SAF ExternalStorage content URI", () => { - // ACTION_GET_CONTENT on a user-visible provider encodes the path. - const uri = - "content://com.android.externalstorage.documents/document/primary%3ADownload%2Freport.pdf"; - expect(remoteUploadName(uri)).toBe("report.pdf"); - }); - - it("falls back to the document id for an opaque provider", () => { - const uri = "content://com.android.providers.downloads.documents/document/msf%3A1234"; - // No display name available — the id is the best we can do (caller may - // still override; this just must not be empty/garbage). - expect(remoteUploadName(uri)).toBe("1234"); - }); - - it("returns the basename for a plain filesystem path", () => { - expect(remoteUploadName("/sdcard/Documents/notes.txt")).toBe("notes.txt"); - expect(remoteUploadName("C:\\Users\\me\\key.pem")).toBe("key.pem"); - }); - - it("recovers a decoded basename from an iOS security-scoped file URL", () => { - expect(remoteUploadName("file:///private/var/mobile/Containers/Shared/AppGroup/report%20x.pdf")) - .toBe("report x.pdf"); - }); - - it("returns empty when nothing usable remains (caller adds a fallback)", () => { - expect(remoteUploadName("")).toBe(""); - expect(remoteUploadName("/")).toBe(""); - }); - - it("does not throw on a malformed percent-escape", () => { - expect(() => remoteUploadName("content://x/%E0%A4%A")).not.toThrow(); - }); -}); diff --git a/src/lib/sftp-name.ts b/src/lib/sftp-name.ts deleted file mode 100644 index 30e29ca7..00000000 --- a/src/lib/sftp-name.ts +++ /dev/null @@ -1,20 +0,0 @@ -/** - * Best-effort remote filename for a mobile SFTP upload, derived from the local - * source reference the dialog plugin returns. On Android that's a SAF - * `content://` URI whose last segment encodes the display name for user-visible - * providers (e.g. `...%2FDownload%2Freport.pdf` → `report.pdf`); opaque - * providers yield only an id. - * - * Returns the derived name, or "" when nothing usable can be recovered (the - * caller supplies a timestamped fallback). Splits on `/`, `\` and `:` so both - * path separators and SAF's `tree:` / `document:` id prefixes are stripped. - */ -export function remoteUploadName(ref: string): string { - let decoded = ref; - try { - decoded = decodeURIComponent(ref); - } catch { - /* malformed %-escape — fall back to the raw string */ - } - return (decoded.split(/[\\/:]/).pop() || "").trim(); -} diff --git a/src/lib/soft-keyboard-inset.ts b/src/lib/soft-keyboard-inset.ts index 95ebd089..211eda9e 100644 --- a/src/lib/soft-keyboard-inset.ts +++ b/src/lib/soft-keyboard-inset.ts @@ -4,7 +4,7 @@ * WKWebView does NOT resize the webview when the soft keyboard opens — the * keyboard overlays it. The layout viewport (the `100%` heights everything is * built on) stays full-size while only `visualViewport` shrinks, so content - * laid out at the bottom (the MobileKeybar, the chat input row) ends up + * laid out at the bottom (the TerminalKeybar, the chat input row) ends up * behind the keyboard, untappable. Android needs no help: `adjustResize` * shrinks the webview itself and the layout follows. * @@ -36,7 +36,7 @@ export function softKeyboardInset(paneBottom: number, viewport: ViewportRect): n * callers use the return to detect "keyboard fully gone" (0). Panel position * is measured, not assumed, so whatever chrome sits below the pane (safe-area * padding, tab bars) is automatically accounted for. Valid while the document - * is pinned to scroll 0 — which setupMobileSoftKeyboard already enforces — + * is pinned to scroll 0 — which setupTerminalSoftKeyboard already enforces — * because client coords and visualViewport offsets only agree there. */ export function applySoftKeyboardInset(pane: HTMLElement): number { diff --git a/src/lib/stores/app.svelte.test.ts b/src/lib/stores/app.svelte.test.ts index 26d5dbfd..0f476097 100644 --- a/src/lib/stores/app.svelte.test.ts +++ b/src/lib/stores/app.svelte.test.ts @@ -9,9 +9,8 @@ vi.mock("@tauri-apps/api/core", () => ({ invoke: invokeMock })); // module load (loadPos). Node has no localStorage, so give the import an // in-memory stub. Fresh per test to avoid cross-test leakage. // -// app.svelte.ts also reads navigator.userAgent at module load (isMobile). -// Node < 21 has no navigator global, so stub a desktop UA — the MRU tests -// don't exercise mobile behavior, any string is fine. +// Some imported platform helpers read navigator at module load. The MRU tests +// do not exercise platform behavior, so a neutral value is sufficient. beforeEach(() => { invokeMock.mockReset(); invokeMock.mockResolvedValue(null); @@ -38,6 +37,44 @@ async function loadAppModule() { const local = (id: string) => ({ id, type: "local" as const, label: id }); +describe("sidebar layout preferences", () => { + it.each([ + "Mozilla/5.0 (Linux; Android 14)", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15)", + ])("selects by width and preserves both preferences while resizing: %s", async (userAgent) => { + vi.stubGlobal("navigator", { userAgent }); + const viewport = Object.assign(new EventTarget(), { innerWidth: 900 }); + vi.stubGlobal("window", viewport); + localStorage.setItem("sidebar.position.desktop", "left"); + localStorage.setItem("sidebar.position.mobile", "right"); + const app = await loadAppModule(); + const layout = await import("./layout.svelte.ts"); + const stop = layout.observeViewport(); + const write = vi.spyOn(localStorage, "setItem"); + invokeMock.mockClear(); + + expect(app.sidebarPosition()).toBe("left"); + viewport.innerWidth = 400; + viewport.dispatchEvent(new Event("resize")); + expect(app.sidebarPosition()).toBe("right"); + expect(write).not.toHaveBeenCalled(); + expect(invokeMock).not.toHaveBeenCalled(); + + app.setSidebarPosition("top"); + expect(localStorage.getItem("sidebar.position.mobile")).toBe("top"); + expect(localStorage.getItem("sidebar.position.desktop")).toBe("left"); + write.mockClear(); + + viewport.innerWidth = 900; + viewport.dispatchEvent(new Event("resize")); + expect(app.sidebarPosition()).toBe("left"); + expect(write).not.toHaveBeenCalled(); + expect(invokeMock).not.toHaveBeenCalled(); + stop(); + write.mockRestore(); + }); +}); + describe("recent Home connections", () => { it("records every saved or discovered GUI connection through addTab", async () => { const app = await loadAppModule(); @@ -567,8 +604,83 @@ describe("command block line limit", () => { }); }); -describe("mobile key modifier lock", () => { - // Two ways to arm a modifier on the mobile keybar: +describe("terminal control routing", () => { + function controls() { + return { + getSelection: vi.fn(() => ""), + paste: vi.fn(), + writeControl: vi.fn(), + sendArrow: vi.fn(), + sendText: vi.fn(), + focus: vi.fn(), + }; + } + + it("closing a hidden pane leaves the active pane's control keys connected", async () => { + const app = await loadAppModule(); + const a = controls(); + const b = controls(); + app.addTab(local("a")); + app.addTab(local("b")); + app.registerTerminalControls("a", a); + app.registerTerminalControls("b", b); + app.setActiveTab("a"); + + app.unregisterTerminalControls("b"); + app.sendToTerminal("\x1b"); + app.sendArrow("A", 5); + + expect(a.writeControl).toHaveBeenCalledWith("\x1b"); + expect(a.sendArrow).toHaveBeenCalledWith("A", 5); + expect(b.writeControl).not.toHaveBeenCalled(); + expect(b.sendArrow).not.toHaveBeenCalled(); + }); + + it("routes to the newly active pane without registering again and keeps raw controls separate from text", async () => { + const app = await loadAppModule(); + const a = controls(); + const b = controls(); + app.addTab(local("a")); + app.addTab(local("b")); + app.registerTerminalControls("a", a); + app.registerTerminalControls("b", b); + app.setActiveTab("a"); + app.sendToTerminal("\t"); + + app.setActiveTab("b"); + app.sendToTerminal("\x1b"); + app.sendArrow("D", 0); + app.sendTextToActiveTerminal("line\n"); + + expect(a.writeControl).toHaveBeenCalledExactlyOnceWith("\t"); + expect(a.sendArrow).not.toHaveBeenCalled(); + expect(b.writeControl).toHaveBeenCalledExactlyOnceWith("\x1b"); + expect(b.sendArrow).toHaveBeenCalledExactlyOnceWith("D", 0); + expect(b.sendText).toHaveBeenCalledExactlyOnceWith("line\n"); + }); + + it("sends nothing when the active route has no registered terminal", async () => { + const app = await loadAppModule(); + const a = controls(); + app.addTab(local("a")); + app.registerTerminalControls("a", a); + app.setActiveTab("home"); + app.sendToTerminal("\x1b"); + app.sendArrow("B", 0); + app.sendTextToActiveTerminal("line\n"); + + app.setActiveTab("a"); + app.unregisterTerminalControls("a"); + app.sendToTerminal("\x1b"); + app.sendArrow("B", 0); + expect(a.writeControl).not.toHaveBeenCalled(); + expect(a.sendArrow).not.toHaveBeenCalled(); + expect(a.sendText).not.toHaveBeenCalled(); + }); +}); + +describe("terminal key modifier lock", () => { + // Two ways to arm a modifier on the compact keybar: // - short tap (setCtrl): one-shot — arms for the NEXT key, then clears // - long-press (lockCtrl): sticky — stays armed across many keys until tapped off // The distinction lives entirely in clearModifiers: it clears one-shot arms but diff --git a/src/lib/stores/app.svelte.ts b/src/lib/stores/app.svelte.ts index d3b6be26..cf78ff9f 100644 --- a/src/lib/stores/app.svelte.ts +++ b/src/lib/stores/app.svelte.ts @@ -2,7 +2,8 @@ import { invoke } from "@tauri-apps/api/core"; import * as ai from "../ai/store.svelte.ts"; import * as pluginStore from "../plugins/store.svelte.ts"; import { errMsg, t } from "../i18n/index.svelte.ts"; -import { isIOS, isMobile } from "../platform.ts"; +import { capabilities } from "./runtime.svelte.ts"; +import * as layout from "./layout.svelte.ts"; import type { CommandBlockRedactionRule as RedactionRule, CommandBlockRedactionSettings as RedactionSettings, @@ -23,7 +24,7 @@ import { createSidePanelState } from "./panel-state.svelte.ts"; /* ═══════════════════════════════════════════════════════ Platform ═══════════════════════════════════════════════════════ */ -export { isIOS, isMobile }; +export { capabilities }; /* ═══════════════════════════════════════════════════════ Types @@ -505,9 +506,7 @@ export function addTab(tab: Tab) { } // Plugin panels follow the manager's per-area auto-open toggles. Local // shell tabs run exec as a child process, same capability class as ssh. - // Mobile stays out of v1: the panels have no touch close affordance yet - // (desktop closes via Esc) — same scope the old desktop-only menu had. - if (!isMobile && (rootTab.type === "ssh" || rootTab.type === "local")) { + if (capabilities().plugins && (rootTab.type === "ssh" || rootTab.type === "local")) { pluginStore.openForNewTab(rootTab.id); } // MRU on: new tab is the most-recently-focused → front of the session region. @@ -638,31 +637,32 @@ export function settingsBack() { else _settingsPage = "menu"; } -/* ─── Sidebar position (per-device) ─── */ +/* ─── Sidebar position (wide / compact layout) ─── */ export type SidebarPosition = "left" | "right" | "top" | "bottom"; -const _SB_KEY_DESKTOP = "sidebar.position.desktop"; -const _SB_KEY_MOBILE = "sidebar.position.mobile"; +// Keep the existing storage keys so users retain their two layout preferences. +const _SB_KEY_WIDE = "sidebar.position.desktop"; +const _SB_KEY_COMPACT = "sidebar.position.mobile"; function _loadSidebarPos(key: string, fallback: SidebarPosition): SidebarPosition { const v = localStorage.getItem(key); return v === "left" || v === "right" || v === "top" || v === "bottom" ? v : fallback; } -let _sidebarPosDesktop = $state(_loadSidebarPos(_SB_KEY_DESKTOP, "top")); -let _sidebarPosMobile = $state(_loadSidebarPos(_SB_KEY_MOBILE, "top")); +let _sidebarPosWide = $state(_loadSidebarPos(_SB_KEY_WIDE, "top")); +let _sidebarPosCompact = $state(_loadSidebarPos(_SB_KEY_COMPACT, "top")); export function sidebarPosition(): SidebarPosition { - return isMobile ? _sidebarPosMobile : _sidebarPosDesktop; + return layout.compact() ? _sidebarPosCompact : _sidebarPosWide; } export function setSidebarPosition(pos: SidebarPosition) { - if (isMobile) { - _sidebarPosMobile = pos; - safeSetItem(_SB_KEY_MOBILE, pos); + if (layout.compact()) { + _sidebarPosCompact = pos; + safeSetItem(_SB_KEY_COMPACT, pos); } else { - _sidebarPosDesktop = pos; - safeSetItem(_SB_KEY_DESKTOP, pos); + _sidebarPosWide = pos; + safeSetItem(_SB_KEY_WIDE, pos); } } -/* ─── Mobile key modifiers (sticky Ctrl/Alt) ─── */ -// Two arming modes on the mobile keybar: +/* ─── Terminal key modifiers (sticky Ctrl/Alt) ─── */ +// Two arming modes on the terminal keybar: // - one-shot (setCtrl, a short tap): arms for the next key only // - lock (lockCtrl, a long-press): stays armed across keys until tapped off // clearModifiers clears one-shot arms but leaves locks — so a locked Ctrl keeps @@ -684,7 +684,7 @@ export function clearModifiers() { if (!_altLocked) _altActive = false; } -/* ─── Mobile soft keyboard gate ─── */ +/* ─── Active terminal soft keyboard controller ─── */ // The system keyboard opens ONLY from the keybar's keyboard button — terminal // taps never pop it (issue #225). The pane owns show/hide (it holds the hidden // helper textarea); the store just routes the toggle and mirrors the open state @@ -704,24 +704,17 @@ export function unregisterSoftKeyboardToggle(fn: () => void) { } export function toggleSoftKeyboard() { _softKeyboardToggle?.(); } -/* ─── Send to active terminal ─── */ -let _terminalWriter: ((text: string) => void) | null = null; -export function registerTerminalWriter(fn: (text: string) => void) { _terminalWriter = fn; } -export function unregisterTerminalWriter() { _terminalWriter = null; } -export function sendToTerminal(text: string) { _terminalWriter?.(text); } - /** Arrow keys need DECCKM-aware encoding (CSI vs SS3). The terminal owner - * holds that state, so it registers an encoder-sender here. */ + * holds that state and encodes the sequence before sending it. */ export type ArrowDir = "A" | "B" | "C" | "D"; -let _terminalArrowSender: ((dir: ArrowDir, mod: number) => void) | null = null; -export function registerTerminalArrowSender(fn: (dir: ArrowDir, mod: number) => void) { _terminalArrowSender = fn; } -export function unregisterTerminalArrowSender() { _terminalArrowSender = null; } -export function sendArrow(dir: ArrowDir, mod: number) { _terminalArrowSender?.(dir, mod); } -/* ─── Per-tab terminal copy/paste controls ─── */ +/* ─── Per-tab terminal controls ─── */ interface TerminalControls { getSelection(): string; paste(text: string): void; + /** Raw control sequences (Esc/Tab); distinct from line-oriented user text. */ + writeControl(text: string): void; + sendArrow(dir: ArrowDir, mod: number): void; /** Inject user text as input (snippet / broadcast). The pane applies its own * transport rules — serial EOL transform + slow-send — so callers here stay * transport-agnostic. NOT for control bytes (arrows/Esc/Tab): those are raw, @@ -743,6 +736,12 @@ export function registerTerminalControls(tabId: string, controls: TerminalContro export function unregisterTerminalControls(tabId: string) { _terminalControls.delete(tabId); } +export function sendToTerminal(text: string) { + _terminalControls.get(_activeTabId)?.writeControl(text); +} +export function sendArrow(dir: ArrowDir, mod: number) { + _terminalControls.get(_activeTabId)?.sendArrow(dir, mod); +} export function terminalGetSelection(tabId: string): string { return _terminalControls.get(tabId)?.getSelection() ?? ""; } @@ -1299,13 +1298,9 @@ export async function loadForwards(): Promise { return invoke("list_forwards"); } export async function loadSerialProfiles(): Promise { - // Desktop-only: the command isn't registered on mobile. Degrade to [] rather - // than rejecting, so callers (e.g. HomeScreen's Promise.all) don't break on mobile. - // On desktop the command IS registered, so a failure is a real problem (DB / - // serialization) — log it so it's diagnosable instead of silently showing "no - // profiles". Mobile stays quiet (expected "not registered"). + if (!capabilities().serial) return []; return invoke("list_serial_profiles").catch((e) => { - if (!isMobile) console.warn("[serial] list_serial_profiles failed:", e); + console.warn("[serial] list_serial_profiles failed:", e); return []; }); } diff --git a/src/lib/stores/layout.svelte.test.ts b/src/lib/stores/layout.svelte.test.ts new file mode 100644 index 00000000..14737d4f --- /dev/null +++ b/src/lib/stores/layout.svelte.test.ts @@ -0,0 +1,49 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +afterEach(() => { vi.unstubAllGlobals(); }); + +describe("viewport layout", () => { + it("uses a stable wide layout in hosts without viewport dimensions", async () => { + vi.stubGlobal("window", new EventTarget()); + vi.resetModules(); + const layout = await import("./layout.svelte.ts"); + expect(layout.viewportWidth()).toBe(1024); + expect(layout.compact()).toBe(false); + }); + + it("switches at 640 CSS pixels and stops observing when the root unmounts", async () => { + const viewport = Object.assign(new EventTarget(), { innerWidth: 640 }); + vi.stubGlobal("window", viewport); + vi.resetModules(); + const layout = await import("./layout.svelte.ts"); + const stop = layout.observeViewport(); + + expect(layout.compact()).toBe(false); + viewport.innerWidth = 639; + viewport.dispatchEvent(new Event("resize")); + expect(layout.viewportWidth()).toBe(639); + expect(layout.compact()).toBe(true); + + viewport.innerWidth = 900; + viewport.dispatchEvent(new Event("resize")); + expect(layout.compact()).toBe(false); + + stop(); + viewport.innerWidth = 320; + viewport.dispatchEvent(new Event("resize")); + expect(layout.viewportWidth()).toBe(900); + }); + + it("reads the latest width when observation starts after module loading", async () => { + const viewport = Object.assign(new EventTarget(), { innerWidth: 900 }); + vi.stubGlobal("window", viewport); + vi.resetModules(); + const layout = await import("./layout.svelte.ts"); + + viewport.innerWidth = 400; + const stop = layout.observeViewport(); + expect(layout.viewportWidth()).toBe(400); + expect(layout.compact()).toBe(true); + stop(); + }); +}); diff --git a/src/lib/stores/layout.svelte.ts b/src/lib/stores/layout.svelte.ts new file mode 100644 index 00000000..7fedcc34 --- /dev/null +++ b/src/lib/stores/layout.svelte.ts @@ -0,0 +1,19 @@ +/** Layout follows the CSS viewport, independently of the host and input devices. */ +const COMPACT_BREAKPOINT = 640; +function readViewportWidth(): number { + return typeof window !== "undefined" && Number.isFinite(window.innerWidth) + ? window.innerWidth + : 1024; +} +let _viewportWidth = $state(readViewportWidth()); + +export function viewportWidth(): number { return _viewportWidth; } +export function compact(): boolean { return _viewportWidth < COMPACT_BREAKPOINT; } + +/** The application root owns this listener; resizing never changes preferences. */ +export function observeViewport(): () => void { + const update = () => { _viewportWidth = readViewportWidth(); }; + update(); + window.addEventListener("resize", update); + return () => window.removeEventListener("resize", update); +} diff --git a/src/lib/stores/runtime.svelte.test.ts b/src/lib/stores/runtime.svelte.test.ts new file mode 100644 index 00000000..408d76f7 --- /dev/null +++ b/src/lib/stores/runtime.svelte.test.ts @@ -0,0 +1,159 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const invokeMock = vi.hoisted(() => vi.fn()); +vi.mock("@tauri-apps/api/core", () => ({ invoke: invokeMock })); +const subscribeMock = vi.hoisted(() => vi.fn()); +vi.mock("../ipc-shim.ts", () => ({ onRuntimeCapabilitiesChanged: subscribeMock })); + +function notifyCapabilitiesChanged(): void { + subscribeMock.mock.calls[0][0](); +} + +const desktop = { + localPty: true, + serial: true, + localDiscovery: true, + sshAgent: true, + defaultKeyFiles: true, + cliInstall: true, + multiWindow: true, + windowControls: true, + windowPin: true, + fileMultiSelect: true, + directoryTransfer: true, + plugins: true, + nativeClipboard: true, + releaseUpdateCheck: true, + terminalPolicy: { + imageStorageLimitMb: 128, + imagePixelLimit: 16_000_000, + outputBacklogLimitBytes: 128 * 1024 * 1024, + gpuRenderDefault: true, + }, +}; + +beforeEach(() => { + vi.resetModules(); + invokeMock.mockReset(); + subscribeMock.mockReset(); +}); +afterEach(() => { vi.unstubAllGlobals(); }); + +describe("runtime capabilities", () => { + it("keeps host terminal policy separate from feature capabilities", async () => { + const runtime = await import("./runtime.svelte.ts"); + expect(() => runtime.terminalPolicy()).toThrow(); + invokeMock.mockResolvedValue(desktop); + await runtime.load(); + expect(runtime.terminalPolicy()).toEqual(desktop.terminalPolicy); + expect(runtime.capabilities()).not.toHaveProperty("terminalPolicy"); + expect(runtime.capabilities().nativeClipboard).toBe(true); + }); + + it("uses the backend's resource limits without inferring a device from input or width", async () => { + vi.stubGlobal("window", { innerWidth: 1600 }); + vi.stubGlobal("navigator", { maxTouchPoints: 0, userAgent: "unrecognized host" }); + const runtime = await import("./runtime.svelte.ts"); + const terminalPolicy = { + imageStorageLimitMb: 32, + imagePixelLimit: 4_000_000, + outputBacklogLimitBytes: 32 * 1024 * 1024, + gpuRenderDefault: false, + }; + invokeMock.mockResolvedValue({ ...desktop, terminalPolicy }); + await runtime.load(); + expect(runtime.terminalPolicy()).toEqual(terminalPolicy); + }); + + it("keeps the resource barrier closed if the host omits its terminal policy", async () => { + const runtime = await import("./runtime.svelte.ts"); + const { terminalPolicy: _policy, ...capabilities } = desktop; + invokeMock.mockResolvedValue(capabilities); + await expect(runtime.load()).rejects.toThrow("terminal runtime policy"); + expect(runtime.loaded()).toBe(false); + }); + + it("does not enable native features until the backend reports support", async () => { + const runtime = await import("./runtime.svelte.ts"); + expect(runtime.loaded()).toBe(false); + expect(Object.values(runtime.capabilities()).every((supported) => supported === false)).toBe(true); + invokeMock.mockResolvedValue({ ...desktop, localPty: false, multiWindow: false, serial: false }); + await runtime.load(); + expect(invokeMock).toHaveBeenCalledWith("get_runtime_capabilities"); + expect(runtime.loaded()).toBe(true); + expect(runtime.capabilities().localPty).toBe(false); + expect(runtime.capabilities().multiWindow).toBe(false); + expect(runtime.capabilities().fileMultiSelect).toBe(true); + }); + + it("shares initialization and caches a successful result", async () => { + const runtime = await import("./runtime.svelte.ts"); + let complete!: (value: typeof desktop) => void; + invokeMock.mockReturnValue(new Promise((resolve) => { complete = resolve; })); + const first = runtime.load(); + const second = runtime.load(); + expect(invokeMock).toHaveBeenCalledTimes(1); + expect(runtime.loaded()).toBe(false); + complete(desktop); + await Promise.all([first, second]); + await runtime.load(); + expect(invokeMock).toHaveBeenCalledTimes(1); + const { terminalPolicy: _policy, ...capabilities } = desktop; + expect(runtime.capabilities()).toEqual(capabilities); + }); + + it("keeps failed initialization retryable without claiming unsupported features", async () => { + const runtime = await import("./runtime.svelte.ts"); + invokeMock.mockRejectedValueOnce(new Error("backend unavailable")); + await expect(runtime.load()).rejects.toThrow("backend unavailable"); + expect(runtime.loaded()).toBe(false); + invokeMock.mockResolvedValue(desktop); + await runtime.load(); + expect(runtime.loaded()).toBe(true); + expect(invokeMock).toHaveBeenCalledTimes(2); + }); + + it("refreshes changed capabilities without closing the resource barrier", async () => { + const runtime = await import("./runtime.svelte.ts"); + invokeMock.mockResolvedValueOnce({ ...desktop, fileMultiSelect: false, directoryTransfer: false }); + await runtime.load(); + expect(runtime.capabilities().directoryTransfer).toBe(false); + + let finish!: (value: typeof desktop) => void; + invokeMock.mockReturnValueOnce(new Promise((resolve) => { finish = resolve; })); + notifyCapabilitiesChanged(); + await vi.waitFor(() => expect(invokeMock).toHaveBeenCalledTimes(2)); + expect(runtime.loaded()).toBe(true); + finish(desktop); + await vi.waitFor(() => expect(runtime.capabilities().directoryTransfer).toBe(true)); + expect(runtime.loaded()).toBe(true); + }); + + it("does not lose a change notification delivered during the first capability request", async () => { + const runtime = await import("./runtime.svelte.ts"); + let finish!: (value: typeof desktop) => void; + invokeMock.mockReturnValueOnce(new Promise((resolve) => { finish = resolve; })); + invokeMock.mockResolvedValueOnce(desktop); + const initialization = runtime.load(); + notifyCapabilitiesChanged(); + finish({ ...desktop, fileMultiSelect: false, directoryTransfer: false }); + await initialization; + await vi.waitFor(() => expect(runtime.capabilities().directoryTransfer).toBe(true)); + expect(invokeMock).toHaveBeenCalledTimes(2); + }); + + it("leaves a failed startup to the App retry flow when capabilities change", async () => { + const runtime = await import("./runtime.svelte.ts"); + invokeMock.mockRejectedValueOnce(new Error("backend unavailable")); + await expect(runtime.load()).rejects.toThrow("backend unavailable"); + + invokeMock.mockResolvedValue(desktop); + notifyCapabilitiesChanged(); + expect(invokeMock).toHaveBeenCalledTimes(1); + expect(runtime.loaded()).toBe(false); + + await runtime.load(); + expect(invokeMock).toHaveBeenCalledTimes(2); + expect(runtime.loaded()).toBe(true); + }); +}); diff --git a/src/lib/stores/runtime.svelte.ts b/src/lib/stores/runtime.svelte.ts new file mode 100644 index 00000000..8aabe762 --- /dev/null +++ b/src/lib/stores/runtime.svelte.ts @@ -0,0 +1,93 @@ +import { invoke } from "@tauri-apps/api/core"; +import { onRuntimeCapabilitiesChanged } from "../ipc-shim.ts"; + +/** Host performance defaults are independent of layout and input hardware. */ +export interface TerminalRuntimePolicy { + imageStorageLimitMb: number; + imagePixelLimit: number; + outputBacklogLimitBytes: number; + gpuRenderDefault: boolean; +} + +/** The host reports implemented capabilities, independently of screen shape. */ +export interface RuntimeCapabilities { + localPty: boolean; + serial: boolean; + localDiscovery: boolean; + sshAgent: boolean; + defaultKeyFiles: boolean; + cliInstall: boolean; + multiWindow: boolean; + windowControls: boolean; + windowPin: boolean; + fileMultiSelect: boolean; + directoryTransfer: boolean; + plugins: boolean; + nativeClipboard: boolean; + releaseUpdateCheck: boolean; +} + +export interface RuntimeResponse extends RuntimeCapabilities { + terminalPolicy: TerminalRuntimePolicy; +} + +let _capabilities = $state>({ + localPty: false, + serial: false, + localDiscovery: false, + sshAgent: false, + defaultKeyFiles: false, + cliInstall: false, + multiWindow: false, + windowControls: false, + windowPin: false, + fileMultiSelect: false, + directoryTransfer: false, + plugins: false, + nativeClipboard: false, + releaseUpdateCheck: false, +}); +let _terminalPolicy = $state | null>(null); +let _loaded = $state(false); +let pending: Promise | undefined; +let observingCapabilities = false; + +export function capabilities(): Readonly { return _capabilities; } +export function loaded(): boolean { return _loaded; } +export function terminalPolicy(): Readonly { + if (!_terminalPolicy) throw new Error("Host terminal runtime policy is not loaded"); + return _terminalPolicy; +} + +/** App mounts resource-owning components only after this succeeds. */ +export function load(): Promise { + observeCapabilities(); + if (_loaded) return Promise.resolve(); + return requestCapabilities(); +} + +function requestCapabilities(): Promise { + pending ??= invoke("get_runtime_capabilities") + .then(({ terminalPolicy, ...capabilities }) => { + if (!terminalPolicy) throw new Error("Host did not provide a terminal runtime policy"); + _capabilities = capabilities; + _terminalPolicy = terminalPolicy; + _loaded = true; + }) + .finally(() => { pending = undefined; }); + return pending; +} + +function observeCapabilities(): void { + if (observingCapabilities) return; + observingCapabilities = true; + // Refresh capabilities without unmounting active sessions. + onRuntimeCapabilitiesChanged(() => { + // A failed first load belongs to App's retry flow, which also starts the + // remaining application services. A capability change must not bypass it. + const initialized = pending ?? (_loaded ? Promise.resolve() : undefined); + if (!initialized) return; + void initialized.then(requestCapabilities) + .catch((error) => console.warn("[runtime] host capability refresh failed:", error)); + }); +} diff --git a/src/lib/stores/serial.svelte.ts b/src/lib/stores/serial.svelte.ts new file mode 100644 index 00000000..6ea8a397 --- /dev/null +++ b/src/lib/stores/serial.svelte.ts @@ -0,0 +1,35 @@ +import { invoke } from "@tauri-apps/api/core"; + +/** Serial features belong to the host API, not the saved connection profile. */ +export interface SerialCapabilities { + flowControl: boolean; + xany: boolean; + signals: boolean; + /** Empty means the host accepts a custom rate. */ + baudRates: number[]; +} + +type LoadState = + | { kind: "idle" | "loading" } + | { kind: "ready"; value: SerialCapabilities } + | { kind: "error"; error: unknown }; + +let _state = $state({ kind: "idle" }); +let pending: Promise | undefined; + +export function state(): LoadState { return _state; } +export function capabilities(): SerialCapabilities | null { + return _state.kind === "ready" ? _state.value : null; +} + +/** Failed reads remain visible to callers; the next explicit load retries. */ +export function load(): Promise { + if (_state.kind === "ready") return Promise.resolve(); + if (pending) return pending; + _state = { kind: "loading" }; + pending = invoke("serial_get_capabilities") + .then((value) => { _state = { kind: "ready", value }; }) + .catch((error) => { _state = { kind: "error", error }; }) + .finally(() => { pending = undefined; }); + return pending; +} diff --git a/src/lib/stores/session-cleanup.svelte.ts b/src/lib/stores/session-cleanup.svelte.ts new file mode 100644 index 00000000..8ff6aa01 --- /dev/null +++ b/src/lib/stores/session-cleanup.svelte.ts @@ -0,0 +1,21 @@ +import { createSessionCleanupRegistry, type PendingSessionCleanup } from "../terminal/session-cleanup.ts"; + +let pending = $state([]); +const registry = createSessionCleanupRegistry(() => { pending = registry.pending(); }); + +/** Each webview owns its cleanup ledger for the lifetime of its session owner. */ +export const sessionCleanupScope = registry.forScope; + +export function failedSessionCleanups() { + return pending.flatMap((item) => item.status.kind === "failed" + ? [{ sessionId: item.sessionId, label: item.label, error: item.status.error }] + : []); +} + +export async function retrySessionCleanup(sessionId: string): Promise { + try { + await registry.retry(sessionId); + } catch { + // The ledger retains the new error and the persistent retry control. + } +} diff --git a/src/lib/stores/session-cleanup.test.ts b/src/lib/stores/session-cleanup.test.ts new file mode 100644 index 00000000..e5bc2dac --- /dev/null +++ b/src/lib/stores/session-cleanup.test.ts @@ -0,0 +1,23 @@ +import { expect, it, vi } from "vitest"; +import { createReservedSessionAttempt } from "../terminal/reserved-session-attempt.ts"; + +it("keeps the window retry control usable after a terminal pane is destroyed", async () => { + vi.resetModules(); + const store = await import("./session-cleanup.svelte.ts"); + const error = new Error("close service unavailable"); + const close = vi.fn().mockRejectedValueOnce(error).mockResolvedValue(undefined); + const pane = createReservedSessionAttempt({ + makeId: () => "session-1", + wireEvents: async () => () => {}, + cleanup: store.sessionCleanupScope("serial:usb-device-1", "USB device"), + close, + }); + await pane.open(async (id) => id); + pane.destroy(); + await vi.waitFor(() => expect(store.failedSessionCleanups()).toEqual([ + { sessionId: "session-1", label: "USB device", error }, + ])); + await store.retrySessionCleanup("session-1"); + expect(store.failedSessionCleanups()).toEqual([]); + expect(close.mock.calls).toEqual([["session-1"], ["session-1"]]); +}); diff --git a/src/lib/terminal/limits.ts b/src/lib/terminal/limits.ts index 1246f332..934921dc 100644 --- a/src/lib/terminal/limits.ts +++ b/src/lib/terminal/limits.ts @@ -37,6 +37,3 @@ export const BACKLOG_INDICATOR_BYTES = 64 * 1024; export const BACKLOG_DROP_TRIGGER_BYTES = 256 * 1024; /** Silence window that ends a quiescent drop after Ctrl+C. */ export const BACKLOG_QUIESCENCE_MS = 150; -/** Feeder queue cap: drop-oldest beyond this (desktop / mobile). */ -export const BACKLOG_MAX_PENDING_BYTES = 128 * 1024 * 1024; -export const BACKLOG_MAX_PENDING_BYTES_MOBILE = 32 * 1024 * 1024; diff --git a/src/lib/terminal/output-feeder.test.ts b/src/lib/terminal/output-feeder.test.ts index a1a85359..86bcc906 100644 --- a/src/lib/terminal/output-feeder.test.ts +++ b/src/lib/terminal/output-feeder.test.ts @@ -125,6 +125,40 @@ describe("createOutputFeeder", () => { expect(w.written).toEqual(["aa", "cc"]); }); + it.each([32, 128])("keeps each stalled terminal within its host's %i MiB flood budget", (budgetMb) => { + const budgetBytes = budgetMb * 1024 * 1024; + const terminals = Array.from({ length: 4 }, () => { + const writer = stubWrite(); + const feeder = createOutputFeeder({ + write: writer.write, + maxPendingBytes: budgetBytes, + }); + return { writer, feeder }; + }); + const first = new Uint8Array(64 * 1024); + const chunk = new Uint8Array(64 * 1024); + const latest = new Uint8Array(64 * 1024); + const budgetChunks = budgetBytes / chunk.byteLength; + for (const { feeder } of terminals) feeder.push(first); + // Reuse the payload: exercise byte accounting without allocating a + // real multi-tab flood. Leave parsing stalled throughout the flood. + for (let i = 0; i < budgetChunks + 8; i++) { + for (const { feeder } of terminals) feeder.push(chunk); + } + for (const { writer, feeder } of terminals) { + feeder.push(latest); + expect(feeder.pendingBytes()).toBe(budgetBytes); + // Resume parsing: the in-flight chunk survives, old queued chunks + // were evicted, and the newest output still reaches the terminal. + for (let i = 0; i < budgetChunks + 1 && feeder.pendingBytes() > 0; i++) writer.drain(); + expect(feeder.pendingBytes()).toBe(0); + expect(writer.written).toHaveLength(budgetChunks); + expect(writer.written[0]).toBe(first); + expect(writer.written[writer.written.length - 1]).toBe(latest); + feeder.dispose(); + } + }); + it("dispose stops feeding; idempotent", () => { const w = stubWrite(); const f = createOutputFeeder({ write: w.write, maxPendingBytes: 1024 }); diff --git a/src/lib/terminal/reserved-session-attempt.test.ts b/src/lib/terminal/reserved-session-attempt.test.ts index 0278e18d..2b430d03 100644 --- a/src/lib/terminal/reserved-session-attempt.test.ts +++ b/src/lib/terminal/reserved-session-attempt.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it, vi } from "vitest"; import { createReservedSessionAttempt } from "./reserved-session-attempt.ts"; +import { createSessionCleanupRegistry } from "./session-cleanup.ts"; function deferred() { let resolve!: (value: T) => void; @@ -31,6 +32,7 @@ describe("ReservedSessionAttempt", () => { it("closes both the reservation and a handle that returns after cancellation", async () => { const opened = deferred(); + const backendStarted = deferred(); const close = vi.fn(); const attempt = createReservedSessionAttempt({ makeId: () => "reserved-1", @@ -38,8 +40,11 @@ describe("ReservedSessionAttempt", () => { close, }); - const pending = attempt.open(() => opened.promise); - await vi.waitFor(() => expect(attempt.isPending()).toBe(true)); + const pending = attempt.open(() => { + backendStarted.resolve(undefined); + return opened.promise; + }); + await backendStarted.promise; attempt.cancel(); expect(close).toHaveBeenCalledWith("reserved-1"); @@ -51,17 +56,21 @@ describe("ReservedSessionAttempt", () => { it("destroys a wiring attempt and permanently rejects later opens", async () => { const wired = deferred<() => void>(); + const wiringStarted = deferred(); const disposeEvents = vi.fn(); const openBackend = vi.fn(async (id: string) => id); const close = vi.fn(); const attempt = createReservedSessionAttempt({ makeId: () => "reserved-1", - wireEvents: () => wired.promise, + wireEvents: () => { + wiringStarted.resolve(undefined); + return wired.promise; + }, close, }); const pending = attempt.open(openBackend); - await vi.waitFor(() => expect(attempt.isPending()).toBe(true)); + await wiringStarted.promise; attempt.destroy(); expect(close).toHaveBeenCalledWith("reserved-1"); @@ -76,14 +85,18 @@ describe("ReservedSessionAttempt", () => { it("accepts events only for the current reservation", async () => { const opened = deferred(); + const backendStarted = deferred(); const attempt = createReservedSessionAttempt({ makeId: () => "reserved-1", wireEvents: async () => () => {}, close: vi.fn(), }); - const pending = attempt.open(() => opened.promise); - await vi.waitFor(() => expect(attempt.isPending()).toBe(true)); + const pending = attempt.open(() => { + backendStarted.resolve(undefined); + return opened.promise; + }); + await backendStarted.promise; expect(attempt.accepts("reserved-1")).toBe(true); expect(attempt.accepts("stale")).toBe(false); @@ -132,6 +145,86 @@ describe("ReservedSessionAttempt", () => { expect(close).toHaveBeenCalledWith("reserved-1"); }); + it("retries a failed open after its backend reservation was already closed", async () => { + const registry = createSessionCleanupRegistry(); + const resources = new Map(); + const timeout = new Error("TCP connection timed out after 10 seconds"); + let nextId = 0; + let connectionAvailable = false; + const openBackend = vi.fn(async (id: string) => { + resources.set(id, "pending"); + if (!connectionAvailable) { + // The backend reservation's Drop closes a failed open before its + // rejection reaches the frontend. Closing that record is idempotent. + resources.set(id, "closed"); + throw timeout; + } + resources.set(id, "ready"); + return id; + }); + const close = vi.fn(async (id: string) => { + if (resources.has(id)) resources.set(id, "closed"); + }); + const attempt = createReservedSessionAttempt({ + makeId: () => `reserved-${++nextId}`, + wireEvents: async () => () => {}, + cleanup: registry.forScope("tab:ssh-1", "SSH server"), + close, + }); + + await expect(attempt.open(openBackend)).rejects.toBe(timeout); + expect(resources.get("reserved-1")).toBe("closed"); + expect(close).toHaveBeenCalledWith("reserved-1"); + connectionAvailable = true; + await expect(attempt.open(openBackend)).resolves.toEqual({ kind: "ready", sessionId: "reserved-2" }); + expect(openBackend).toHaveBeenCalledTimes(2); + expect(resources.get("reserved-2")).toBe("ready"); + expect(registry.pending()).toEqual([]); + }); + + it("keeps a real cleanup failure visible without blocking an independent connection", async () => { + const registry = createSessionCleanupRegistry(); + const resources = new Set(); + const cleanupFailure = new Error("native close failed"); + let cleanupAvailable = false; + const first = createReservedSessionAttempt({ + makeId: () => "session-1", + wireEvents: async () => () => {}, + cleanup: registry.forScope("tab:ssh-1", "first server"), + close: async (id) => { + if (!resources.has(id)) return; + if (!cleanupAvailable) throw cleanupFailure; + resources.delete(id); + }, + }); + await expect(first.open(async (id) => { + resources.add(id); + throw new Error("open response lost"); + })).rejects.toThrow("open response lost"); + + const second = createReservedSessionAttempt({ + makeId: () => "session-2", + wireEvents: async () => () => {}, + cleanup: registry.forScope("tab:ssh-2", "second server"), + close: async (id) => { resources.delete(id); }, + }); + await expect(second.open(async (id) => { + resources.add(id); + return id; + })).resolves.toEqual({ kind: "ready", sessionId: "session-2" }); + expect(registry.pending()).toEqual([{ + sessionId: "session-1", scope: "tab:ssh-1", label: "first server", + status: { kind: "failed", error: cleanupFailure }, + }]); + expect(resources).toEqual(new Set(["session-1", "session-2"])); + + cleanupAvailable = true; + await registry.retry("session-1"); + expect(registry.pending()).toEqual([]); + expect(resources).toEqual(new Set(["session-2"])); + expect(second.accepts("session-2")).toBe(true); + }); + it("rejects a backend id that differs from the canonical reservation", async () => { const close = vi.fn(); const disposeEvents = vi.fn(); @@ -153,6 +246,7 @@ describe("ReservedSessionAttempt", () => { it("keeps a newer ready attempt when the superseded open returns late", async () => { const firstOpened = deferred(); + const backendStarted = deferred(); const close = vi.fn(); let nextId = 0; const attempt = createReservedSessionAttempt({ @@ -161,8 +255,11 @@ describe("ReservedSessionAttempt", () => { close, }); - const first = attempt.open(() => firstOpened.promise); - await vi.waitFor(() => expect(attempt.accepts("reserved-1")).toBe(true)); + const first = attempt.open(() => { + backendStarted.resolve(undefined); + return firstOpened.promise; + }); + await backendStarted.promise; await expect(attempt.open(async (id) => id)).resolves.toEqual({ kind: "ready", @@ -188,4 +285,203 @@ describe("ReservedSessionAttempt", () => { throw new Error("open failed"); })).rejects.toThrow("open failed"); }); + + it("waits for the old native close before wiring and opening a replacement", async () => { + const closed = deferred(); + const disposeEvents = vi.fn(); + const wireEvents = vi.fn(async () => disposeEvents); + const openBackend = vi.fn(async (id: string) => id); + const close = vi.fn(() => closed.promise); + let nextId = 0; + const attempt = createReservedSessionAttempt({ + makeId: () => `reserved-${++nextId}`, + wireEvents, + close, + }); + + await attempt.open(openBackend); + const replacement = attempt.open(openBackend); + expect(attempt.accepts("reserved-1")).toBe(false); + expect(disposeEvents).toHaveBeenCalledOnce(); + expect(close).toHaveBeenCalledWith("reserved-1"); + await Promise.resolve(); + expect(wireEvents).toHaveBeenCalledTimes(1); + expect(openBackend).toHaveBeenCalledTimes(1); + + closed.resolve(undefined); + await expect(replacement).resolves.toEqual({ kind: "ready", sessionId: "reserved-2" }); + expect(wireEvents).toHaveBeenLastCalledWith("reserved-2"); + expect(openBackend).toHaveBeenLastCalledWith("reserved-2"); + }); + + it.each(["cancel", "destroy"] as const)("%s invalidates an attempt waiting for close immediately", async (action) => { + const closed = deferred(); + const wireEvents = vi.fn(async () => () => {}); + const openBackend = vi.fn(async (id: string) => id); + const close = vi.fn((id: string) => id === "reserved-1" ? closed.promise : Promise.resolve()); + let nextId = 0; + const attempt = createReservedSessionAttempt({ + makeId: () => `reserved-${++nextId}`, + wireEvents, + close, + }); + + await attempt.open(openBackend); + const replacement = attempt.open(openBackend); + expect(attempt.isPending()).toBe(true); + attempt[action](); + expect(attempt.isPending()).toBe(false); + expect(attempt.accepts("reserved-2")).toBe(false); + expect(close).toHaveBeenCalledWith("reserved-2"); + + closed.resolve(undefined); + await expect(replacement).resolves.toEqual({ kind: "cancelled" }); + expect(wireEvents).toHaveBeenCalledTimes(1); + expect(openBackend).toHaveBeenCalledTimes(1); + if (action === "destroy") { + await expect(attempt.open(openBackend)).resolves.toEqual({ kind: "cancelled" }); + } + }); + + it("waits for every queued close when another open supersedes a waiting attempt", async () => { + const firstClosed = deferred(); + const secondClosed = deferred(); + const wireEvents = vi.fn(async () => () => {}); + const openBackend = vi.fn(async (id: string) => id); + let nextId = 0; + const attempt = createReservedSessionAttempt({ + makeId: () => `reserved-${++nextId}`, + wireEvents, + close: (id) => id === "reserved-1" ? firstClosed.promise : secondClosed.promise, + }); + + await attempt.open(openBackend); + const second = attempt.open(openBackend); + const third = attempt.open(openBackend); + firstClosed.resolve(undefined); + await expect(second).resolves.toEqual({ kind: "cancelled" }); + expect(wireEvents).toHaveBeenCalledTimes(1); + expect(openBackend).toHaveBeenCalledTimes(1); + + secondClosed.resolve(undefined); + await expect(third).resolves.toEqual({ kind: "ready", sessionId: "reserved-3" }); + expect(wireEvents).toHaveBeenLastCalledWith("reserved-3"); + expect(openBackend).toHaveBeenLastCalledWith("reserved-3"); + }); + + it("returns the open error immediately but waits for its cleanup before retrying", async () => { + const closed = deferred(); + const wireEvents = vi.fn(async () => () => {}); + const retryBackend = vi.fn(async (id: string) => id); + let nextId = 0; + const attempt = createReservedSessionAttempt({ + makeId: () => `reserved-${++nextId}`, + wireEvents, + close: () => closed.promise, + }); + + await expect(attempt.open(async () => { throw new Error("response lost"); })) + .rejects.toThrow("response lost"); + const retry = attempt.open(retryBackend); + await Promise.resolve(); + expect(wireEvents).toHaveBeenCalledTimes(1); + expect(retryBackend).not.toHaveBeenCalled(); + + closed.resolve(undefined); + await expect(retry).resolves.toEqual({ kind: "ready", sessionId: "reserved-2" }); + }); + + it("includes cleanup of a late result queued while waiting for close", async () => { + const firstOpened = deferred(); + const backendStarted = deferred(); + const reservedClosed = deferred(); + const lateClosed = deferred(); + const retryBackend = vi.fn(async (id: string) => id); + let nextId = 0; + const attempt = createReservedSessionAttempt({ + makeId: () => `reserved-${++nextId}`, + wireEvents: async () => () => {}, + close: (id) => id === "reserved-1" ? reservedClosed.promise : lateClosed.promise, + }); + + const first = attempt.open(() => { + backendStarted.resolve(undefined); + return firstOpened.promise; + }); + await backendStarted.promise; + const replacement = attempt.open(retryBackend); + firstOpened.resolve("opened-1"); + await expect(first).resolves.toEqual({ kind: "cancelled" }); + reservedClosed.resolve(undefined); + // Let all continuations of the first close run; the later close must still + // prevent opening, rather than merely delaying it by another microtask. + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(retryBackend).not.toHaveBeenCalled(); + + lateClosed.resolve(undefined); + await expect(replacement).resolves.toEqual({ kind: "ready", sessionId: "reserved-2" }); + expect(attempt.accepts("reserved-2")).toBe(true); + }); + + it("preserves a failed close and retries that same id before opening the port", async () => { + let nextId = 0; + let nativeOwner: string | undefined; + let failClose = true; + const close = vi.fn(async (id: string) => { + if (id !== nativeOwner) return; + if (failClose) throw new Error("native close failed"); + nativeOwner = undefined; + }); + const openBackend = vi.fn(async (id: string) => { + if (nativeOwner) throw new Error("port already in use"); + nativeOwner = id; + return id; + }); + const attempt = createReservedSessionAttempt({ + makeId: () => `reserved-${++nextId}`, + wireEvents: async () => () => {}, + close, + }); + + await attempt.open(openBackend); + await expect(attempt.open(openBackend)).rejects.toThrow("native close failed"); + expect(attempt.isPending()).toBe(false); + expect(openBackend).toHaveBeenCalledTimes(1); + expect(nativeOwner).toBe("reserved-1"); + + failClose = false; + await expect(attempt.open(openBackend)).resolves.toEqual({ kind: "ready", sessionId: "reserved-3" }); + expect(close.mock.calls.filter(([id]) => id === "reserved-1")).toHaveLength(2); + expect(nativeOwner).toBe("reserved-3"); + }); + + it("retains a failed close after destroying the pane and cleans it before a new pane opens the port", async () => { + const registry = createSessionCleanupRegistry(); + const scope = registry.forScope("serial:device-1", "device-1"); + const closeFailure = new Error("native close failed"); + const close = vi.fn().mockRejectedValueOnce(closeFailure).mockResolvedValue(undefined); + const first = createReservedSessionAttempt({ + makeId: () => "old-session", + wireEvents: async () => () => {}, + cleanup: scope, + close, + }); + await first.open(async (id) => id); + first.destroy(); + await vi.waitFor(() => expect(registry.pending()[0]?.status).toEqual({ kind: "failed", error: closeFailure })); + + const openBackend = vi.fn(async (id: string) => { + expect(close).toHaveBeenLastCalledWith("old-session"); + expect(registry.pending()).toEqual([]); + return id; + }); + const second = createReservedSessionAttempt({ + makeId: () => "new-session", + wireEvents: async () => () => {}, + cleanup: registry.forScope("serial:device-1", "device-1"), + close, + }); + await expect(second.open(openBackend)).resolves.toEqual({ kind: "ready", sessionId: "new-session" }); + expect(close).toHaveBeenCalledTimes(2); + }); }); diff --git a/src/lib/terminal/reserved-session-attempt.ts b/src/lib/terminal/reserved-session-attempt.ts index 80e1676f..e7c3f999 100644 --- a/src/lib/terminal/reserved-session-attempt.ts +++ b/src/lib/terminal/reserved-session-attempt.ts @@ -1,3 +1,5 @@ +import { createSessionCleanupRegistry, type SessionCleanupScope } from "./session-cleanup.ts"; + export type ReservedSessionOpenResult = { kind: "ready"; sessionId: string; @@ -9,6 +11,7 @@ interface ReservedSessionAttemptDependencies { makeId: () => string; wireEvents: (sessionId: string) => Promise<() => void>; close: (sessionId: string) => void | Promise; + cleanup?: SessionCleanupScope; } export function createReservedSessionAttempt( @@ -23,17 +26,10 @@ export function createReservedSessionAttempt( let generation = 0; let current: CurrentAttempt | null = null; let destroyed = false; - - async function closeAndIgnore(sessionId: string): Promise { - try { - await dependencies.close(sessionId); - } catch { - // Cancellation is best-effort and must not revive a stale attempt. - } - } + const cleanup = dependencies.cleanup ?? createSessionCleanupRegistry().forScope("attempt", ""); function requestClose(sessionId: string): void { - void closeAndIgnore(sessionId); + cleanup.requestClose(sessionId, dependencies.close); } function cancel(): void { @@ -59,6 +55,22 @@ export function createReservedSessionAttempt( }; current = attempt; + // Cancellation invalidates ownership immediately; opening the replacement + // waits until the old native handle has released its exclusive resource. + // A late result may enqueue another close while this barrier is pending. + try { + await cleanup.wait(() => current === attempt && generation === attemptGeneration); + } catch (error) { + if (current !== attempt || generation !== attemptGeneration) { + return { kind: "cancelled" }; + } + current = null; + throw error; + } + if (current !== attempt || generation !== attemptGeneration) { + return { kind: "cancelled" }; + } + let disposeEvents: () => void; try { disposeEvents = await dependencies.wireEvents(reservedId); diff --git a/src/lib/terminal/session-cleanup.test.ts b/src/lib/terminal/session-cleanup.test.ts new file mode 100644 index 00000000..76cdf93f --- /dev/null +++ b/src/lib/terminal/session-cleanup.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, it, vi } from "vitest"; +import { createSessionCleanupRegistry } from "./session-cleanup.ts"; + +function deferred() { + let resolve!: () => void; + const promise = new Promise((done) => { resolve = done; }); + return { promise, resolve }; +} + +describe("session cleanup registry", () => { + it("merges concurrent closes and explicit retries of the same session", async () => { + const registry = createSessionCleanupRegistry(); + const scope = registry.forScope("serial:port-1", "port-1"); + const closed = deferred(); + const close = vi.fn(() => closed.promise); + scope.requestClose("session-1", close); + scope.requestClose("session-1", close); + const retry = registry.retry("session-1"); + const ready = scope.wait(); + expect(close).toHaveBeenCalledTimes(1); + closed.resolve(); + await Promise.all([retry, ready]); + expect(registry.pending()).toEqual([]); + }); + + it("keeps errors visible and only retries when requested", async () => { + const notify = vi.fn(); + const registry = createSessionCleanupRegistry(notify); + const scope = registry.forScope("serial:port-1", "port-1"); + const error = new Error("device did not release"); + const close = vi.fn().mockRejectedValueOnce(error).mockResolvedValue(undefined); + scope.requestClose("session-1", close); + await expect(scope.wait()).rejects.toBe(error); + expect(registry.pending()).toEqual([{ + sessionId: "session-1", scope: "serial:port-1", label: "port-1", status: { kind: "failed", error }, + }]); + await Promise.resolve(); + expect(close).toHaveBeenCalledTimes(1); + expect(notify).toHaveBeenCalled(); + await registry.retry("session-1"); + expect(close).toHaveBeenCalledTimes(2); + expect(registry.pending()).toEqual([]); + }); + + it("does not let one failed port block another port or a network session", async () => { + const registry = createSessionCleanupRegistry(); + const blocked = registry.forScope("serial:port-1", "port-1"); + const close = vi.fn(async () => { throw new Error("busy"); }); + blocked.requestClose("old-session", close); + await expect(blocked.wait()).rejects.toThrow("busy"); + await registry.forScope("serial:port-2", "port-2").wait(); + await registry.forScope("tab:ssh-1", "server-1").wait(); + expect(close).toHaveBeenCalledTimes(1); + expect(registry.pending()).toHaveLength(1); + }); +}); diff --git a/src/lib/terminal/session-cleanup.ts b/src/lib/terminal/session-cleanup.ts new file mode 100644 index 00000000..3dc9d0f1 --- /dev/null +++ b/src/lib/terminal/session-cleanup.ts @@ -0,0 +1,82 @@ +type CloseSession = (sessionId: string) => void | Promise; + +export interface PendingSessionCleanup { + sessionId: string; + scope: string; + label: string; + status: { kind: "closing" } | { kind: "failed"; error: unknown }; +} + +export interface SessionCleanupScope { + requestClose(sessionId: string, close: CloseSession): void; + wait(shouldContinue?: () => boolean): Promise; +} + +/** Owned by the window, not a terminal pane: a failed close still owns a handle. */ +export function createSessionCleanupRegistry(changed: () => void = () => {}) { + type Entry = PendingSessionCleanup & { + close: CloseSession; + inFlight?: Promise; + }; + const entries = new Map(); + + function run(entry: Entry): Promise { + if (entry.inFlight) return entry.inFlight; + let resolve!: () => void; + let reject!: (error: unknown) => void; + const task = new Promise((done, failed) => { resolve = done; reject = failed; }); + entry.inFlight = task; + entry.status = { kind: "closing" }; + changed(); + const succeeded = () => { + entries.delete(entry.sessionId); + changed(); + resolve(); + }; + const failed = (error: unknown) => { + entry.inFlight = undefined; + entry.status = { kind: "failed", error }; + changed(); + reject(error); + }; + try { + Promise.resolve(entry.close(entry.sessionId)).then(succeeded, failed); + } catch (error) { + failed(error); + } + return task; + } + + return { + pending(): PendingSessionCleanup[] { + return Array.from(entries.values(), ({ sessionId, scope, label, status }) => ({ sessionId, scope, label, status })); + }, + async retry(sessionId: string): Promise { + const entry = entries.get(sessionId); + if (entry) await run(entry); + }, + forScope(scope: string, label: string): SessionCleanupScope { + return { + requestClose(sessionId, close) { + let entry = entries.get(sessionId); + if (!entry) { + entry = { sessionId, scope, label, status: { kind: "closing" }, close }; + entries.set(sessionId, entry); + } + // Fire-and-forget cancellation is safe because failure remains owned + // and visible here; it is never converted into a successful close. + void run(entry).catch(() => {}); + }, + async wait(shouldContinue = () => true) { + // A late open result can add another handle while a close is pending. + // Each failed close stops this reconnect; only a new user action retries. + while (shouldContinue()) { + const pending = Array.from(entries.values()).filter((entry) => entry.scope === scope); + if (pending.length === 0) return; + await Promise.all(pending.map(run)); + } + }, + }; + }, + }; +} diff --git a/src/lib/terminal/soft-keyboard.test.ts b/src/lib/terminal/soft-keyboard.test.ts new file mode 100644 index 00000000..baec533c --- /dev/null +++ b/src/lib/terminal/soft-keyboard.test.ts @@ -0,0 +1,136 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { setupTerminalSoftKeyboard } from "./soft-keyboard.ts"; + +class FakeElement extends EventTarget { + readOnly = false; + inert = false; + style = { removeProperty: vi.fn() }; + attributes = new Map(); + getAttribute(name: string) { return this.attributes.get(name) ?? null; } + setAttribute(name: string, value: string) { this.attributes.set(name, value); } + removeAttribute(name: string) { this.attributes.delete(name); } + getBoundingClientRect() { return { top: 0, bottom: 500 }; } + focus() { + if (document.activeElement === this as unknown as Element) return; + Object.assign(document, { activeElement: this }); + this.dispatchEvent(new Event("focus")); + } + blur() { + if (document.activeElement !== this as unknown as Element) return; + Object.assign(document, { activeElement: null }); + this.dispatchEvent(new Event("blur")); + } +} + +function pointer(target: EventTarget, pointerType: string) { + const event = new Event("pointerdown"); + Object.defineProperty(event, "pointerType", { value: pointerType }); + target.dispatchEvent(event); +} + +/** Node EventTarget has no DOM tree. Dispatch the ancestor capture phase before + * the target phase explicitly, preserving the original textarea event target. */ +function dispatchInput(host: EventTarget, helper: EventTarget, type: string): Event { + const event = new Event(type, { cancelable: true }); + Object.defineProperty(event, "target", { value: helper }); + host.dispatchEvent(event); + helper.dispatchEvent(event); + return event; +} + +describe("terminal soft keyboard", () => { + let dispose: (() => void) | undefined; + + beforeEach(() => { + vi.stubGlobal("window", Object.assign(new EventTarget(), { innerHeight: 1000 })); + vi.stubGlobal("document", { + activeElement: null, + documentElement: { scrollTop: 0, scrollLeft: 0 }, + body: { scrollTop: 0, scrollLeft: 0 }, + }); + vi.stubGlobal("requestAnimationFrame", vi.fn(() => 1)); + vi.stubGlobal("cancelAnimationFrame", vi.fn()); + }); + + afterEach(() => { dispose?.(); vi.unstubAllGlobals(); }); + + function setup(touchAvailable: boolean, beforeInstall?: (helper: FakeElement) => void) { + const helper = new FakeElement(); + const host = new FakeElement(); + const onRequestedChange = vi.fn(); + helper.setAttribute("style", "left: 120px; top: 40px;"); + beforeInstall?.(helper); + const controller = setupTerminalSoftKeyboard({ + helper: helper as unknown as HTMLTextAreaElement, + host: host as unknown as HTMLElement, + pane: new FakeElement() as unknown as HTMLElement, + touchAvailable, + onRequestedChange, + }); + dispose = controller.dispose; + return { helper, host, onRequestedChange, controller }; + } + + it("ordinary desktop focus neither locks input nor requests a soft keyboard", () => { + const { helper, onRequestedChange } = setup(false); + helper.focus(); + expect(helper.readOnly).toBe(false); + expect(onRequestedChange).not.toHaveBeenCalled(); + }); + + it("unlocks on host capture before xterm's earlier textarea capture handler", () => { + let readOnlyAtXterm: boolean | undefined; + const { helper, host, onRequestedChange } = setup(true, (textarea) => { + // xterm registers its target capture handler before the keyboard adapter. + textarea.addEventListener("keydown", () => { readOnlyAtXterm = textarea.readOnly; }, true); + }); + expect(helper.readOnly).toBe(true); + expect(helper.inert).toBe(false); + helper.focus(); + expect(document.activeElement).toBe(helper); + const event = dispatchInput(host, helper, "keydown"); + expect(readOnlyAtXterm).toBe(false); + expect(event.defaultPrevented).toBe(false); + expect(onRequestedChange).not.toHaveBeenCalled(); + }); + + it("lets hardware IME start composing after touch input", () => { + const { helper, host } = setup(false); + pointer(host, "touch"); + expect(helper.readOnly).toBe(true); + dispatchInput(host, helper, "compositionstart"); + expect(helper.readOnly).toBe(false); + }); + + it("ignores keyboard events targeting a different input", () => { + const { helper, host } = setup(true); + dispatchInput(host, new FakeElement(), "keydown"); + expect(helper.readOnly).toBe(true); + }); + + it("tracks explicit requests rather than focus and restores mouse input geometry", () => { + const { helper, host, controller, onRequestedChange } = setup(true); + helper.focus(); + controller.toggle(); + expect(onRequestedChange).toHaveBeenLastCalledWith(true); + expect(helper.readOnly).toBe(false); + pointer(host, "mouse"); + expect(onRequestedChange).toHaveBeenLastCalledWith(false); + expect(helper.readOnly).toBe(false); + expect(helper.getAttribute("style")).toBe("left: 120px; top: 40px;"); + controller.toggle(); + expect(onRequestedChange).toHaveBeenLastCalledWith(true); + }); + + it("touch/pen dismissal and leaving the pane clear the explicit request", () => { + const { helper, host, controller, onRequestedChange } = setup(false); + controller.toggle(); + pointer(host, "pen"); + expect(onRequestedChange).toHaveBeenLastCalledWith(false); + expect(helper.readOnly).toBe(true); + controller.toggle(); + controller.hide(); + expect(onRequestedChange).toHaveBeenLastCalledWith(false); + expect(document.activeElement).not.toBe(helper); + }); +}); diff --git a/src/lib/terminal/soft-keyboard.ts b/src/lib/terminal/soft-keyboard.ts new file mode 100644 index 00000000..39b1aa24 --- /dev/null +++ b/src/lib/terminal/soft-keyboard.ts @@ -0,0 +1,175 @@ +import { setupSoftKeyboardInset } from "../soft-keyboard-inset.ts"; + +interface SoftKeyboardOptions { + helper: HTMLTextAreaElement; + host: HTMLElement; + pane: HTMLElement; + touchAvailable: boolean; + onRequestedChange: (requested: boolean) => void; +} + +/** Explicit soft-keyboard requests are separate from terminal focus. A locked + * textarea stays focusable so a hardware keyboard can unlock it on its first key. */ +export function setupTerminalSoftKeyboard({ + helper, host, pane, touchAvailable, onRequestedChange, +}: SoftKeyboardOptions) { + let unpinnedStyle: string | null | undefined; + const originalInputMode = helper.getAttribute("inputmode"); + const originalReadOnly = helper.readOnly; + let touchInput = touchAvailable; + let requested = false; + let scrollResetRaf = 0; + let helperPinRaf = 0; + + function setRequested(value: boolean) { + if (requested === value) return; + requested = value; + onRequestedChange(value); + } + + function restoreHelperStyle() { + if (helperPinRaf) cancelAnimationFrame(helperPinRaf); + helperPinRaf = 0; + if (unpinnedStyle === undefined) return; + if (unpinnedStyle === null) helper.removeAttribute("style"); + else helper.setAttribute("style", unpinnedStyle); + unpinnedStyle = undefined; + } + + function resetDocumentScroll() { + if (scrollResetRaf) return; + scrollResetRaf = requestAnimationFrame(() => { + scrollResetRaf = 0; + if (!requested) return; + if (window.scrollX !== 0 || window.scrollY !== 0) window.scrollTo(0, 0); + document.documentElement.scrollTop = document.body.scrollTop = 0; + document.documentElement.scrollLeft = document.body.scrollLeft = 0; + }); + } + + function pinHelper() { + if (!requested) return; + if (unpinnedStyle === undefined) unpinnedStyle = helper.getAttribute("style"); + const viewport = window.visualViewport; + const minTop = (viewport?.offsetTop ?? 0) + 1; + const maxTop = minTop + (viewport?.height ?? window.innerHeight) - 2; + const top = Math.max(minTop, Math.min(maxTop, host.getBoundingClientRect().top + 8)); + // Pin only while explicitly requesting the soft keyboard. Ordinary mouse + // and hardware-IME input keep xterm's own caret-aligned helper geometry. + Object.assign(helper.style, { + position: "fixed", left: "1px", top: `${Math.round(top)}px`, + width: "1px", height: "1px", opacity: "0", zIndex: "-1", + pointerEvents: "none", caretColor: "transparent", background: "transparent", + color: "transparent", border: "0", padding: "0", margin: "0", + outline: "0", resize: "none", overflow: "hidden", + }); + } + + function keepHelperInView() { + if (!requested) return; + pinHelper(); + resetDocumentScroll(); + if (helperPinRaf) cancelAnimationFrame(helperPinRaf); + helperPinRaf = requestAnimationFrame(() => { + helperPinRaf = 0; + pinHelper(); + resetDocumentScroll(); + }); + } + + function lock() { + helper.readOnly = true; + helper.setAttribute("inputmode", "none"); + } + + function unlock() { + helper.readOnly = false; + helper.setAttribute("inputmode", "text"); + } + + function hide() { + setRequested(false); + helper.blur(); + if (touchInput) lock(); + restoreHelperStyle(); + } + + function show() { + // Re-focus after readonly so an already-focused helper can summon the IME. + helper.blur(); + setRequested(true); + unlock(); + pinHelper(); + helper.focus({ preventScroll: true }); + resetDocumentScroll(); + } + + function onPointerDown(event: PointerEvent) { + if (event.pointerType === "touch" || event.pointerType === "pen") { + touchInput = true; + hide(); + } else if (event.pointerType === "mouse") { + touchInput = false; + setRequested(false); + restoreHelperStyle(); + unlock(); + } + } + + function onHardwareInput(event: Event) { + if (event.target !== helper) return; + if (helper.readOnly) { + unlock(); + restoreHelperStyle(); + } + keepHelperInView(); + } + + function onBlur() { + setRequested(false); + if (touchInput) lock(); + restoreHelperStyle(); + } + + function onViewportChange() { + if (requested && document.activeElement === helper) keepHelperInView(); + } + + if (touchInput) lock(); + host.addEventListener("pointerdown", onPointerDown, { capture: true, passive: true }); + // xterm already registered capture listeners on the textarea. Capture at its + // ancestor runs first regardless of registration order. Never preventDefault: + // the first hardware key (including an IME starter) must reach xterm. + host.addEventListener("keydown", onHardwareInput, true); + host.addEventListener("compositionstart", onHardwareInput, true); + helper.addEventListener("input", keepHelperInView); + helper.addEventListener("compositionupdate", keepHelperInView); + helper.addEventListener("blur", onBlur); + window.addEventListener("scroll", onViewportChange, { passive: true }); + window.visualViewport?.addEventListener("scroll", onViewportChange, { passive: true }); + window.visualViewport?.addEventListener("resize", onViewportChange, { passive: true }); + const insetCleanup = setupSoftKeyboardInset(pane, helper); + + return { + toggle: () => { if (requested) hide(); else show(); }, + hide, + dispose() { + host.removeEventListener("pointerdown", onPointerDown, true); + host.removeEventListener("keydown", onHardwareInput, true); + host.removeEventListener("compositionstart", onHardwareInput, true); + helper.removeEventListener("input", keepHelperInView); + helper.removeEventListener("compositionupdate", keepHelperInView); + helper.removeEventListener("blur", onBlur); + window.removeEventListener("scroll", onViewportChange); + window.visualViewport?.removeEventListener("scroll", onViewportChange); + window.visualViewport?.removeEventListener("resize", onViewportChange); + insetCleanup(); + setRequested(false); + restoreHelperStyle(); + if (scrollResetRaf) cancelAnimationFrame(scrollResetRaf); + helper.readOnly = originalReadOnly; + if (originalInputMode === null) helper.removeAttribute("inputmode"); + else helper.setAttribute("inputmode", originalInputMode); + }, + }; +} diff --git a/src/lib/terminal/touch-scroll.test.ts b/src/lib/terminal/touch-scroll.test.ts index 8e1c3287..e10a9414 100644 --- a/src/lib/terminal/touch-scroll.test.ts +++ b/src/lib/terminal/touch-scroll.test.ts @@ -1,5 +1,6 @@ -import { describe, it, expect } from "vitest"; -import { accumulateScroll } from "./touch-scroll.ts"; +import { describe, it, expect, vi } from "vitest"; +import type { Terminal } from "@xterm/xterm"; +import { accumulateScroll, setupTouchScroll } from "./touch-scroll.ts"; describe("accumulateScroll", () => { it("holds sub-row travel as remainder, scrolls nothing yet", () => { @@ -32,3 +33,26 @@ describe("accumulateScroll", () => { expect(accumulateScroll(7, 100, 0)).toEqual({ lines: 0, remainder: 7 }); }); }); + +it("leaves touch gestures on xterm's scrollbar to its own slider handler", () => { + class ScrollbarTarget extends EventTarget { + closest(selector: string) { return selector === ".scrollbar" ? this : null; } + } + vi.stubGlobal("Element", ScrollbarTarget); + const host = new ScrollbarTarget(); + const terminal = { scrollLines: vi.fn() }; + const cleanup = setupTouchScroll(host as unknown as HTMLElement, terminal as unknown as Terminal); + try { + const start = new Event("touchstart"); + Object.defineProperty(start, "touches", { value: [{ clientY: 0 }] }); + host.dispatchEvent(start); + const move = new Event("touchmove", { cancelable: true }); + Object.defineProperty(move, "touches", { value: [{ clientY: 200 }] }); + host.dispatchEvent(move); + expect(move.defaultPrevented).toBe(false); + expect(terminal.scrollLines).not.toHaveBeenCalled(); + } finally { + cleanup(); + vi.unstubAllGlobals(); + } +}); diff --git a/src/lib/terminal/touch-scroll.ts b/src/lib/terminal/touch-scroll.ts index d6cbfdd3..e6652e5c 100644 --- a/src/lib/terminal/touch-scroll.ts +++ b/src/lib/terminal/touch-scroll.ts @@ -3,7 +3,7 @@ import type { Terminal } from "@xterm/xterm"; import { terminalRowHeight } from "./row-height.ts"; /** - * Mobile touch-scroll for an xterm terminal. xterm 6.0.0 vendors a VS Code + * Touch-scroll for an xterm terminal. xterm 6.0.0 vendors a VS Code * touch-gesture service (with inertia!) but never calls addTarget(), so * touch-drag is wired to nothing — desktop has the wheel, touch had no scroll * path at all. This adds drag-to-scroll plus the fling momentum that every @@ -53,7 +53,8 @@ const PAUSE_MS = 60; // finger paused longer than this before release → n * tap (focus / soft keyboard) and a stationary long-press (native text * selection) still pass through untouched; only a real drag scrolls. A new * touch cancels any in-flight fling (grab-to-stop, like native lists). - * Caller decides when to install it (e.g. mobile only). + * Install on every host: only touch events enter this path. Gestures starting + * on xterm's scrollbar stay with its native slider handler, avoiding two owners. */ export function setupTouchScroll(host: HTMLElement, terminal: Terminal): () => void { let startY = 0; @@ -104,7 +105,7 @@ export function setupTouchScroll(host: HTMLElement, terminal: Terminal): () => v // Only a clean single-finger start tracks. A finger added mid-gesture (pinch) // disqualifies the gesture until ALL fingers lift and a fresh single touch // begins — never scroll from stale coordinates. - ignore = e.touches.length !== 1; + ignore = e.touches.length !== 1 || (e.target instanceof Element && !!e.target.closest(".scrollbar")); if (ignore) return; startY = lastY = e.touches[0].clientY; lastMoveTime = performance.now(); diff --git a/src/lib/themes/store.svelte.test.ts b/src/lib/themes/store.svelte.test.ts index 3aa97fe2..83861fc8 100644 --- a/src/lib/themes/store.svelte.test.ts +++ b/src/lib/themes/store.svelte.test.ts @@ -1,16 +1,20 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; // The theme store persists via the Tauri backend; stub invoke to record // writes and serve reads for init(). -const invokeMock = vi.hoisted(() => vi.fn(async () => null as unknown)); +const invokeMock = vi.hoisted(() => + vi.fn<(command: string, args?: unknown) => Promise>(async () => null), +); vi.mock("@tauri-apps/api/core", () => ({ invoke: invokeMock })); +const terminalPolicyMock = vi.hoisted(() => vi.fn()); +vi.mock("../stores/runtime.svelte.ts", () => ({ terminalPolicy: terminalPolicyMock })); // init() writes :root CSS variables — stub the minimum document surface. -// Node has no navigator either; platform.ts guards typeof, so a desktop -// default (isMobile=false) applies. beforeEach(() => { invokeMock.mockReset(); invokeMock.mockResolvedValue(null); + terminalPolicyMock.mockReset(); + terminalPolicyMock.mockReturnValue({ gpuRenderDefault: true }); vi.stubGlobal("document", { documentElement: { dataset: {}, @@ -18,6 +22,7 @@ beforeEach(() => { }, }); }); +afterEach(() => { vi.unstubAllGlobals(); }); async function loadStore() { vi.resetModules(); @@ -62,11 +67,57 @@ describe("term font CSS variable", () => { }); describe("term gpu render setting", () => { - it("defaults to on for desktop", async () => { + it("keeps the desktop host default even when a touchscreen is present", async () => { + vi.stubGlobal("navigator", { maxTouchPoints: 10 }); const theme = await loadStore(); expect(theme.termGpuRender()).toBe(true); }); + it("uses the host's DOM default even without detected touch hardware", async () => { + vi.stubGlobal("navigator", { maxTouchPoints: 0 }); + terminalPolicyMock.mockReturnValue({ gpuRenderDefault: false }); + const theme = await loadStore(); + expect(theme.termGpuRender()).toBe(false); + }); + + it("reads the loaded host policy when no explicit GPU setting exists", async () => { + const theme = await loadStore(); + await theme.init(); + terminalPolicyMock.mockReturnValue({ gpuRenderDefault: false }); + expect(theme.termGpuRender()).toBe(false); + const seen: boolean[] = []; + const off = theme.registerXtermGpuListener(value => seen.push(value)); + expect(seen).toEqual([false]); + off(); + }); + + it("keeps a saved explicit choice when host policy arrives later", async () => { + invokeMock.mockImplementation(async (cmd: string, args: any) => { + if (cmd === "get_setting" && args?.key === "theme.term-gpu-render") return "true"; + return null; + }); + const theme = await loadStore(); + await theme.init(); + terminalPolicyMock.mockReturnValue({ gpuRenderDefault: false }); + expect(theme.termGpuRender()).toBe(true); + }); + + it("does not overwrite a user toggle with an older in-flight setting read", async () => { + let finish!: (value: unknown) => void; + invokeMock.mockImplementation(async (cmd: string, args: any) => { + if (cmd === "get_setting" && args?.key === "theme.term-gpu-render") { + return new Promise(resolve => { finish = resolve; }); + } + return null; + }); + const theme = await loadStore(); + const initializing = theme.init(); + await theme.setTermGpuRender(true); + finish("false"); + await initializing; + expect(theme.termGpuRender()).toBe(true); + }); + it("persists explicit off and notifies listeners", async () => { const theme = await loadStore(); const seen: boolean[] = []; diff --git a/src/lib/themes/store.svelte.ts b/src/lib/themes/store.svelte.ts index 93645c76..75d0b322 100644 --- a/src/lib/themes/store.svelte.ts +++ b/src/lib/themes/store.svelte.ts @@ -31,7 +31,7 @@ import { type TermPaletteRef, } from "./term-palettes.ts"; import { composeTermFontStack } from "./term-font.ts"; -import { isMobile } from "../platform.ts"; +import { terminalPolicy } from "../stores/runtime.svelte.ts"; const SETTING_KEY_PALETTE = "theme.palette"; const SETTING_KEY_SHAPE = "theme.shape"; @@ -285,16 +285,16 @@ function notifyXtermFonts(): void { /* ─────────────────────────────────────────────────────────────── Terminal GPU rendering — whether xterm uses the WebGL addon or - the DOM renderer. Desktop default on (paint throughput); mobile - default off (WebGL paints glyphs into a canvas, leaving no DOM - text for iOS's native long-press selection). The user toggle is - the single authority — including on mobile. Applies live: panes + the DOM renderer. The native host supplies its established default; + input devices and window width never choose a performance policy. + The saved user setting takes precedence. Applies live: panes register a listener that loads/disposes the addon. ─────────────────────────────────────────────────────────────── */ -let _termGpuRender = $state(!isMobile); +// null means use the host policy, which loads independently of saved settings. +let _termGpuRender = $state(null); -export function termGpuRender(): boolean { return _termGpuRender; } +export function termGpuRender(): boolean { return _termGpuRender ?? terminalPolicy().gpuRenderDefault; } export async function setTermGpuRender(on: boolean): Promise { _termGpuRender = on; @@ -312,12 +312,12 @@ const _xtermGpuListeners = new Set(); /** Mirrors registerXtermFontListener: fires immediately + on every change. */ export function registerXtermGpuListener(fn: XtermGpuListener): () => void { _xtermGpuListeners.add(fn); - fn(_termGpuRender); + fn(termGpuRender()); return () => { _xtermGpuListeners.delete(fn); }; } function notifyXtermGpu(): void { - for (const fn of _xtermGpuListeners) fn(_termGpuRender); + for (const fn of _xtermGpuListeners) fn(termGpuRender()); } /* ─────────────────────────────────────────────────────────────── @@ -456,10 +456,12 @@ export async function init(): Promise { if (termBgFollow === "false") _termBgFollowsTheme = false; if (termFontRaw) _termFont = termFontRaw; if (termFontSizeRaw) _termFontSize = clampFontSize(parseInt(termFontSizeRaw, 10)); - // Explicit persisted value wins; absence keeps the platform default - // (desktop on / mobile off). - if (termGpuRaw === "true") _termGpuRender = true; - else if (termGpuRaw === "false") _termGpuRender = false; + // A user toggle made during startup outranks the earlier settings read. + // Otherwise a saved choice wins; absence continues to use the host policy. + if (_termGpuRender === null) { + if (termGpuRaw === "true") _termGpuRender = true; + else if (termGpuRaw === "false") _termGpuRender = false; + } apply(paletteById(_paletteId)); applyShape(_shapeId); applyDensity(_densityId); @@ -470,6 +472,6 @@ export async function init(): Promise { // Mirrors apply()'s notifyXterms() for the palette. notifyXtermFonts(); // Same late-mount race for the GPU toggle: a pane that mounted with the - // platform default may need to (un)load its WebGL addon. + // host default may need to (un)load its WebGL addon. notifyXtermGpu(); } diff --git a/src/main.ts b/src/main.ts index b05312f7..de93185b 100644 --- a/src/main.ts +++ b/src/main.ts @@ -7,10 +7,17 @@ import * as theme from "./lib/themes/store.svelte.ts"; import * as transfers from "./lib/stores/transfers.svelte.ts"; import * as app from "./lib/stores/app.svelte.ts"; import { installLocalFileDropNavigationGuard } from "./lib/local-drop.ts"; /*防止WebView直接打开文件卡死*/ +import { installInputTracking, isTouchContextMenu } from "./lib/input.ts"; installLocalFileDropNavigationGuard(); -document.body.addEventListener("contextmenu", (event) => { - if (!app.isMobile) event.preventDefault(); +const stopInputTracking = installInputTracking(); +function onContextMenu(event: MouseEvent) { + if (!isTouchContextMenu(event)) event.preventDefault(); +} +document.body.addEventListener("contextmenu", onContextMenu); +if (import.meta.hot) import.meta.hot.dispose(() => { + stopInputTracking(); + document.body.removeEventListener("contextmenu", onContextMenu); }); // Apply persisted theme before mount so first paint reflects the user's choice. diff --git a/src/update-capability.test.ts b/src/update-capability.test.ts new file mode 100644 index 00000000..c14629d5 --- /dev/null +++ b/src/update-capability.test.ts @@ -0,0 +1,79 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const { invokeMock, mounted } = vi.hoisted(() => ({ + invokeMock: vi.fn(), + mounted: [] as Array<() => void>, +})); +vi.mock("@tauri-apps/api/core", () => ({ invoke: invokeMock })); +vi.mock("@tauri-apps/api/app", () => ({ getVersion: async () => "1.0.0" })); +vi.mock("svelte", async (original) => ({ + ...await original(), + onMount: (callback: () => void) => { mounted.push(callback); }, +})); +vi.mock("./lib/ipc-shim.ts", () => ({ onRuntimeCapabilitiesChanged: vi.fn() })); +vi.mock("./lib/components/AppShell.svelte", () => ({ default: () => {} })); +vi.mock("./lib/components/WelcomeScreen.svelte", () => ({ default: () => {} })); +vi.mock("./lib/stores/app.svelte.ts", () => ({ + loadProfiles: async () => [], + loadForwards: async () => [], +})); +vi.mock("./lib/ai/store.svelte.ts", () => ({ settings: () => ({}), loadSettings: vi.fn() })); +vi.mock("./lib/stores/sync.svelte.ts", () => ({ + configurationRevision: () => 0, + startBackgroundChecks: vi.fn(), +})); +vi.mock("./lib/stores/cli.svelte.ts", () => ({ startBackgroundChecks: vi.fn() })); + +beforeEach(() => { + vi.resetModules(); + vi.useFakeTimers(); + mounted.length = 0; + invokeMock.mockReset(); + vi.stubGlobal("window", { + innerWidth: 1200, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + }); + vi.stubGlobal("localStorage", { getItem: () => "true", setItem: vi.fn() }); +}); +afterEach(() => { + vi.useRealTimers(); + vi.unstubAllGlobals(); +}); + +describe("release updates follow the RSSH host capability", () => { + it.each([ + ["iPad browser with a headless host", "Mozilla/5.0 (iPad; CPU OS 18_0)", true], + ["native iOS", "Mozilla/5.0 (iPad; CPU OS 18_0)", false], + ["unsupported host with a desktop browser", "Mozilla/5.0 (Macintosh)", false], + ])("keeps the About action and background checks consistent on %s", async (_name, userAgent, supported) => { + vi.stubGlobal("navigator", { userAgent, maxTouchPoints: userAgent.includes("iPad") ? 5 : 0 }); + invokeMock.mockResolvedValue({ + releaseUpdateCheck: supported, + cliInstall: false, + terminalPolicy: { + imageStorageLimitMb: 128, + imagePixelLimit: 16_000_000, + outputBacklogLimitBytes: 128 * 1024 * 1024, + gpuRenderDefault: true, + }, + }); + const runtime = await import("./lib/stores/runtime.svelte.ts"); + await runtime.load(); + const { setLocale, t } = await import("./lib/i18n/index.svelte.ts"); + setLocale("en"); + const { render } = await import("svelte/server"); + const { default: AboutScreen } = await import("./lib/components/AboutScreen.svelte"); + const about = render(AboutScreen).body; + expect(about.includes(t("about.update.check"))).toBe(supported); + + mounted.length = 0; + const { default: App } = await import("./App.svelte"); + render(App).body; + expect(mounted).toHaveLength(1); + mounted[0](); + await Promise.resolve(); + expect(vi.getTimerCount()).toBe(supported ? 1 : 0); + expect(invokeMock).toHaveBeenCalledExactlyOnceWith("get_runtime_capabilities"); + }); +});