feat: add SSH key auth, SFTP, Git pull and packaging
This commit is contained in:
1 parent
a24550fbef
commit
a50b49dd89
25 files changed
+4996
-1404
No files matched your search
@@ -4,6 +4,36 @@ Every release entry is provided in English and Simplified Chinese.
|
||||
|
||||
每个版本条目均同时提供英文和简体中文说明。
|
||||
|
||||
## Unreleased
|
||||
|
||||
### English
|
||||
|
||||
#### Added
|
||||
|
||||
- **Five SSH sign-in modes** — each saved host can now use Automatic, Password, Private Key, Agent, or Keyboard-interactive authentication. Automatic mode follows Tabby's practical order, while an explicitly selected mode stays strict and does not silently fall back to another method.
|
||||
- **Private-key sign-in** — choose one or more OpenSSH, PEM, or PPK private keys from the host editor. Key paths stay on the local machine, encrypted-key passphrases are stored in Windows Credential Manager by key fingerprint, and public-key files are rejected with a clear message.
|
||||
- **SFTP file transfer** — open SFTP from an SSH host's right-click menu and reuse the same authenticated connection. The remote drawer supports editable paths, filtering, refresh, file and folder uploads, file and folder downloads, new folders, rename, recursive delete, symlink targets, progress, cancellation, and visible errors.
|
||||
- **Pull from the Git drawer** — the right-side Git view now has a Pull button beside Stage, Commit, and Push. Pulls use fast-forward-only mode so Nebula never creates an unexpected merge commit.
|
||||
|
||||
#### Improved
|
||||
|
||||
- **A cleaner portable archive** — the ZIP root now contains only `nebula.exe` and `README.md`. Runtime helpers, fonts, documentation, and licenses are grouped under `runtime`, `fonts`, `docs`, and `licenses`, and Nebula resolves the new runtime layout automatically.
|
||||
- **Safer transfers** — uploads and downloads stream in 256 KiB chunks instead of loading whole files into memory. Files are written to temporary paths first and moved into place only after the transfer succeeds, while failed or cancelled transfers clean up their partial files.
|
||||
|
||||
### 简体中文
|
||||
|
||||
#### 新增
|
||||
|
||||
- **五种 SSH 登录方式** — 每个已保存主机都可以选择自动、密码、密钥、Agent 或交互式认证。自动模式会按照 Tabby 的实用顺序逐项尝试;手动选择某种方式后则只使用该方式,不会失败后悄悄换成其他登录方式。
|
||||
- **私钥登录** — 主机编辑器现在可以选择一把或多把 OpenSSH、PEM 或 PPK 私钥。Nebula 只保存本机密钥路径,加密私钥的口令会按密钥指纹存入 Windows 凭据管理器;如果误选 `.pub` 公钥,也会直接提示重新选择私钥。
|
||||
- **SFTP 文件传输** — 在 SSH 主机右键菜单中即可打开 SFTP,并复用同一条已认证连接。远端抽屉支持手动路径、筛选、刷新、上传文件和文件夹、下载文件和文件夹、新建文件夹、重命名、递归删除、符号链接目标、传输进度、取消和可见错误提示。
|
||||
- **Git 抽屉可以拉取了** — 右侧 Git 页面在暂存、提交和推送旁新增“拉取”按钮。拉取只接受快进更新,不会在用户不知情时自动生成合并提交。
|
||||
|
||||
#### 改进
|
||||
|
||||
- **便携包不再乱糟糟** — ZIP 根目录现在只保留 `nebula.exe` 和 `README.md`;运行组件、字体、文档和许可证分别放进 `runtime`、`fonts`、`docs` 与 `licenses`,Nebula 会自动识别新的运行组件位置。
|
||||
- **传文件更稳妥** — 上传和下载按 256 KiB 分块流式传输,不会把整个大文件一次性塞进内存。文件会先写入临时位置,完整成功后才替换正式文件;失败或取消时也会清理未完成的临时文件。
|
||||
|
||||
## 0.4.0 - 2026-07-14
|
||||
|
||||
### Terminal Rendering And Interaction / 终端渲染与交互
|
||||
|
||||
Generated
+142
-79
@@ -74,7 +74,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ef6978589202a00cd7e118380c448a08b6ed394c3a8df3a430d0898e3a42d046"
|
||||
dependencies = [
|
||||
"android-properties",
|
||||
"bitflags 2.9.4",
|
||||
"bitflags 2.13.0",
|
||||
"cc",
|
||||
"cesu8",
|
||||
"jni",
|
||||
@@ -232,11 +232,11 @@ checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a"
|
||||
|
||||
[[package]]
|
||||
name = "bitflags"
|
||||
version = "2.9.4"
|
||||
version = "2.13.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2261d10cca569e4643e526d8dc2e62e433cc8aba21ab764233731f8d369bf394"
|
||||
checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -316,7 +316,7 @@ version = "0.13.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b99da2f8558ca23c71f4fd15dc57c906239752dd27ff3c00a1d56b685b7cbfec"
|
||||
dependencies = [
|
||||
"bitflags 2.9.4",
|
||||
"bitflags 2.13.0",
|
||||
"log",
|
||||
"polling",
|
||||
"rustix 0.38.44",
|
||||
@@ -738,6 +738,20 @@ dependencies = [
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dashmap"
|
||||
version = "6.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5041cc499144891f3790297212f32a74fb938e5136a14943f338ef9e0ae276cf"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"crossbeam-utils",
|
||||
"hashbrown 0.14.5",
|
||||
"lock_api",
|
||||
"once_cell",
|
||||
"parking_lot_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "data-encoding"
|
||||
version = "2.11.0"
|
||||
@@ -820,7 +834,7 @@ version = "0.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "89a09f22a6c6069a18470eb92d2298acf25463f14256d24778e1230d789a2aec"
|
||||
dependencies = [
|
||||
"bitflags 2.9.4",
|
||||
"bitflags 2.13.0",
|
||||
"objc2 0.6.3",
|
||||
]
|
||||
|
||||
@@ -1053,7 +1067,7 @@ version = "0.36.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5442dee36ca09604133580dc0553780e867936bb3cbef3275859e889026d2b17"
|
||||
dependencies = [
|
||||
"bitflags 2.9.4",
|
||||
"bitflags 2.13.0",
|
||||
"freetype-sys",
|
||||
"libc",
|
||||
]
|
||||
@@ -1272,13 +1286,25 @@ dependencies = [
|
||||
"xkeysym",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "gloo-timers"
|
||||
version = "0.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "482ce8a491a501da4cd806bd190275363d674f2845005c6ddbd5d3e1dd54495d"
|
||||
dependencies = [
|
||||
"futures-channel",
|
||||
"futures-core",
|
||||
"js-sys",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "glutin"
|
||||
version = "0.32.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "12124de845cacfebedff80e877bb37b5b75c34c5a4c89e47e1cdd67fb6041325"
|
||||
dependencies = [
|
||||
"bitflags 2.9.4",
|
||||
"bitflags 2.13.0",
|
||||
"cfg_aliases",
|
||||
"cgl",
|
||||
"dispatch2",
|
||||
@@ -1337,6 +1363,12 @@ dependencies = [
|
||||
"subtle",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hashbrown"
|
||||
version = "0.14.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1"
|
||||
|
||||
[[package]]
|
||||
name = "hashbrown"
|
||||
version = "0.16.0"
|
||||
@@ -1418,7 +1450,7 @@ dependencies = [
|
||||
"js-sys",
|
||||
"log",
|
||||
"wasm-bindgen",
|
||||
"windows-core 0.61.2",
|
||||
"windows-core 0.62.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1437,7 +1469,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4b0f83760fb341a774ed326568e19f5a863af4a952def8c39f9ab92fd95b88e5"
|
||||
dependencies = [
|
||||
"equivalent",
|
||||
"hashbrown",
|
||||
"hashbrown 0.16.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1446,7 +1478,7 @@ version = "0.11.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f37dccff2791ab604f9babef0ba14fbe0be30bd368dc541e2b08d07c8aa908f3"
|
||||
dependencies = [
|
||||
"bitflags 2.9.4",
|
||||
"bitflags 2.13.0",
|
||||
"inotify-sys",
|
||||
"libc",
|
||||
]
|
||||
@@ -1528,11 +1560,12 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "js-sys"
|
||||
version = "0.3.81"
|
||||
version = "0.3.103"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ec48937a97411dcb524a265206ccd4c90bb711fca92b2792c407f268825b9305"
|
||||
checksum = "53b44bfcdb3f8d5837a46dae1ca9660a837176eee74a28b229bc626816589102"
|
||||
dependencies = [
|
||||
"once_cell",
|
||||
"cfg-if",
|
||||
"futures-util",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
@@ -1562,7 +1595,7 @@ version = "0.7.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b750dcadc39a09dbadd74e118f6dd6598df77fa01df0cfcdc52c28dece74528a"
|
||||
dependencies = [
|
||||
"bitflags 2.9.4",
|
||||
"bitflags 2.13.0",
|
||||
"serde",
|
||||
"unicode-segmentation",
|
||||
]
|
||||
@@ -1621,7 +1654,7 @@ version = "0.1.10"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "416f7e718bdb06000964960ffa43b4335ad4012ae8b99060261aa4a8088d5ccb"
|
||||
dependencies = [
|
||||
"bitflags 2.9.4",
|
||||
"bitflags 2.13.0",
|
||||
"libc",
|
||||
"redox_syscall 0.5.18",
|
||||
]
|
||||
@@ -1649,11 +1682,11 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "log"
|
||||
version = "0.4.28"
|
||||
version = "0.4.33"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "34080505efa8e45a4b816c349525ebe327ceaa8559756f0356cba97ef3bf7432"
|
||||
checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1749,7 +1782,7 @@ version = "0.9.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c3f42e7bbe13d351b6bead8286a43aac9534b82bd3cc43e47037f012ebfd62d4"
|
||||
dependencies = [
|
||||
"bitflags 2.9.4",
|
||||
"bitflags 2.13.0",
|
||||
"jni-sys",
|
||||
"log",
|
||||
"ndk-sys",
|
||||
@@ -1779,7 +1812,8 @@ version = "0.4.0"
|
||||
dependencies = [
|
||||
"ahash",
|
||||
"base64",
|
||||
"bitflags 2.9.4",
|
||||
"bitflags 2.13.0",
|
||||
"chrono",
|
||||
"clap",
|
||||
"clap_complete",
|
||||
"copypasta",
|
||||
@@ -1806,9 +1840,11 @@ dependencies = [
|
||||
"polling",
|
||||
"pulldown-cmark",
|
||||
"russh",
|
||||
"russh-sftp",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"serde_yaml",
|
||||
"sha2",
|
||||
"signal-hook",
|
||||
"tauri-winrt-notification",
|
||||
"tempfile",
|
||||
@@ -1865,7 +1901,7 @@ name = "nebula_terminal"
|
||||
version = "0.4.0"
|
||||
dependencies = [
|
||||
"base64",
|
||||
"bitflags 2.9.4",
|
||||
"bitflags 2.13.0",
|
||||
"home",
|
||||
"libc",
|
||||
"log",
|
||||
@@ -1890,7 +1926,7 @@ version = "0.31.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cf20d2fde8ff38632c426f1165ed7436270b44f199fc55284c38276f9db47c3d"
|
||||
dependencies = [
|
||||
"bitflags 2.9.4",
|
||||
"bitflags 2.13.0",
|
||||
"cfg-if",
|
||||
"cfg_aliases",
|
||||
"libc",
|
||||
@@ -1902,7 +1938,7 @@ version = "8.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4d3d07927151ff8575b7087f245456e549fea62edf0ec4e565a5ee50c8402bc3"
|
||||
dependencies = [
|
||||
"bitflags 2.9.4",
|
||||
"bitflags 2.13.0",
|
||||
"fsevent-sys",
|
||||
"inotify",
|
||||
"kqueue",
|
||||
@@ -2020,7 +2056,7 @@ version = "0.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e4e89ad9e3d7d297152b17d39ed92cd50ca8063a89a9fa569046d41568891eff"
|
||||
dependencies = [
|
||||
"bitflags 2.9.4",
|
||||
"bitflags 2.13.0",
|
||||
"block2",
|
||||
"libc",
|
||||
"objc2 0.5.2",
|
||||
@@ -2036,7 +2072,7 @@ version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c"
|
||||
dependencies = [
|
||||
"bitflags 2.9.4",
|
||||
"bitflags 2.13.0",
|
||||
"objc2 0.6.3",
|
||||
"objc2-core-foundation",
|
||||
"objc2-foundation 0.3.2",
|
||||
@@ -2048,7 +2084,7 @@ version = "0.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "74dd3b56391c7a0596a295029734d3c1c5e7e510a4cb30245f8221ccea96b009"
|
||||
dependencies = [
|
||||
"bitflags 2.9.4",
|
||||
"bitflags 2.13.0",
|
||||
"block2",
|
||||
"objc2 0.5.2",
|
||||
"objc2-core-location",
|
||||
@@ -2072,7 +2108,7 @@ version = "0.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "617fbf49e071c178c0b24c080767db52958f716d9eabdf0890523aeae54773ef"
|
||||
dependencies = [
|
||||
"bitflags 2.9.4",
|
||||
"bitflags 2.13.0",
|
||||
"block2",
|
||||
"objc2 0.5.2",
|
||||
"objc2-foundation 0.2.2",
|
||||
@@ -2084,7 +2120,7 @@ version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536"
|
||||
dependencies = [
|
||||
"bitflags 2.9.4",
|
||||
"bitflags 2.13.0",
|
||||
"dispatch2",
|
||||
"objc2 0.6.3",
|
||||
]
|
||||
@@ -2125,7 +2161,7 @@ version = "0.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0ee638a5da3799329310ad4cfa62fbf045d5f56e3ef5ba4149e7452dcf89d5a8"
|
||||
dependencies = [
|
||||
"bitflags 2.9.4",
|
||||
"bitflags 2.13.0",
|
||||
"block2",
|
||||
"dispatch",
|
||||
"libc",
|
||||
@@ -2138,7 +2174,7 @@ version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272"
|
||||
dependencies = [
|
||||
"bitflags 2.9.4",
|
||||
"bitflags 2.13.0",
|
||||
"objc2 0.6.3",
|
||||
"objc2-core-foundation",
|
||||
]
|
||||
@@ -2161,7 +2197,7 @@ version = "0.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dd0cba1276f6023976a406a14ffa85e1fdd19df6b0f737b063b95f6c8c7aadd6"
|
||||
dependencies = [
|
||||
"bitflags 2.9.4",
|
||||
"bitflags 2.13.0",
|
||||
"block2",
|
||||
"objc2 0.5.2",
|
||||
"objc2-foundation 0.2.2",
|
||||
@@ -2173,7 +2209,7 @@ version = "0.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e42bee7bff906b14b167da2bac5efe6b6a07e6f7c0a21a7308d40c960242dc7a"
|
||||
dependencies = [
|
||||
"bitflags 2.9.4",
|
||||
"bitflags 2.13.0",
|
||||
"block2",
|
||||
"objc2 0.5.2",
|
||||
"objc2-foundation 0.2.2",
|
||||
@@ -2196,7 +2232,7 @@ version = "0.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b8bb46798b20cd6b91cbd113524c490f1686f4c4e8f49502431415f3512e2b6f"
|
||||
dependencies = [
|
||||
"bitflags 2.9.4",
|
||||
"bitflags 2.13.0",
|
||||
"block2",
|
||||
"objc2 0.5.2",
|
||||
"objc2-cloud-kit",
|
||||
@@ -2228,7 +2264,7 @@ version = "0.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "76cfcbf642358e8689af64cee815d139339f3ed8ad05103ed5eaf73db8d84cb3"
|
||||
dependencies = [
|
||||
"bitflags 2.9.4",
|
||||
"bitflags 2.13.0",
|
||||
"block2",
|
||||
"objc2 0.5.2",
|
||||
"objc2-core-location",
|
||||
@@ -2568,7 +2604,7 @@ version = "0.13.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e9f068eba8e7071c5f9511831b44f32c740d5adf574e990f946ddb53db2f314e"
|
||||
dependencies = [
|
||||
"bitflags 2.9.4",
|
||||
"bitflags 2.13.0",
|
||||
"memchr",
|
||||
"unicase",
|
||||
]
|
||||
@@ -2641,7 +2677,7 @@ version = "0.5.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d"
|
||||
dependencies = [
|
||||
"bitflags 2.9.4",
|
||||
"bitflags 2.13.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2703,7 +2739,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ca6c3c1dd0a9ad3a9915a2f6e907d158f9a7e9ac32ddc772b003faafc027d9a8"
|
||||
dependencies = [
|
||||
"aes",
|
||||
"bitflags 2.9.4",
|
||||
"bitflags 2.13.0",
|
||||
"block-padding",
|
||||
"byteorder",
|
||||
"bytes",
|
||||
@@ -2777,6 +2813,26 @@ dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "russh-sftp"
|
||||
version = "2.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9ed8949eca4163c18a8f59ff96d32cf61e9c13b9735e21ef32b3907f4aafa1a9"
|
||||
dependencies = [
|
||||
"bitflags 2.13.0",
|
||||
"bytes",
|
||||
"chrono",
|
||||
"dashmap",
|
||||
"gloo-timers",
|
||||
"log",
|
||||
"serde",
|
||||
"serde_bytes",
|
||||
"thiserror 2.0.18",
|
||||
"tokio",
|
||||
"tokio-util",
|
||||
"wasm-bindgen-futures",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "russh-util"
|
||||
version = "0.52.0"
|
||||
@@ -2804,7 +2860,7 @@ version = "0.38.44"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154"
|
||||
dependencies = [
|
||||
"bitflags 2.9.4",
|
||||
"bitflags 2.13.0",
|
||||
"errno",
|
||||
"libc",
|
||||
"linux-raw-sys 0.4.15",
|
||||
@@ -2817,7 +2873,7 @@ version = "1.1.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cd15f8a2c5551a84d56efdc1cd049089e409ac19a3072d5037a17fd70719ff3e"
|
||||
dependencies = [
|
||||
"bitflags 2.9.4",
|
||||
"bitflags 2.13.0",
|
||||
"errno",
|
||||
"libc",
|
||||
"linux-raw-sys 0.11.0",
|
||||
@@ -2932,6 +2988,16 @@ dependencies = [
|
||||
"serde_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_bytes"
|
||||
version = "0.11.19"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a5d440709e79d88e51ac01c4b72fc6cb7314017bb7da9eeff678aa94c10e3ea8"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_core"
|
||||
version = "1.0.228"
|
||||
@@ -3089,7 +3155,7 @@ version = "0.19.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3457dea1f0eb631b4034d61d4d8c32074caa6cd1ab2d59f2327bd8461e2c0016"
|
||||
dependencies = [
|
||||
"bitflags 2.9.4",
|
||||
"bitflags 2.13.0",
|
||||
"calloop",
|
||||
"calloop-wayland-source",
|
||||
"cursor-icon",
|
||||
@@ -3349,6 +3415,20 @@ dependencies = [
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tokio-util"
|
||||
version = "0.7.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"futures-core",
|
||||
"futures-sink",
|
||||
"futures-util",
|
||||
"pin-project-lite",
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "toml"
|
||||
version = "0.9.11+spec-1.1.0"
|
||||
@@ -3520,7 +3600,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a5924018406ce0063cd67f8e008104968b74b563ee1b85dde3ed1f7cb87d3dbd"
|
||||
dependencies = [
|
||||
"arrayvec",
|
||||
"bitflags 2.9.4",
|
||||
"bitflags 2.13.0",
|
||||
"cursor-icon",
|
||||
"log",
|
||||
"memchr",
|
||||
@@ -3563,9 +3643,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen"
|
||||
version = "0.2.104"
|
||||
version = "0.2.126"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c1da10c01ae9f1ae40cbfac0bac3b1e724b320abfcf52229f80b547c0d250e2d"
|
||||
checksum = "4b067c0c11094aef6b7a801c1e34a26affafdf3d051dba08456b868789aaf9a4"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"once_cell",
|
||||
@@ -3574,38 +3654,21 @@ dependencies = [
|
||||
"wasm-bindgen-shared",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen-backend"
|
||||
version = "0.2.104"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "671c9a5a66f49d8a47345ab942e2cb93c7d1d0339065d4f8139c486121b43b19"
|
||||
dependencies = [
|
||||
"bumpalo",
|
||||
"log",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
"wasm-bindgen-shared",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen-futures"
|
||||
version = "0.4.54"
|
||||
version = "0.4.76"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7e038d41e478cc73bae0ff9b36c60cff1c98b8f38f8d7e8061e79ee63608ac5c"
|
||||
checksum = "c62df1340f32221cb9c54d6a27b030e3dba64361d4a95bed55f9aacb44da291d"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"js-sys",
|
||||
"once_cell",
|
||||
"wasm-bindgen",
|
||||
"web-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen-macro"
|
||||
version = "0.2.104"
|
||||
version = "0.2.126"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7ca60477e4c59f5f2986c50191cd972e3a50d8a95603bc9434501cf156a9a119"
|
||||
checksum = "167ce5e579f6bcf889c4f7175a8a5a585de84e8ff93976ce393efa5f2837aab1"
|
||||
dependencies = [
|
||||
"quote",
|
||||
"wasm-bindgen-macro-support",
|
||||
@@ -3613,22 +3676,22 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen-macro-support"
|
||||
version = "0.2.104"
|
||||
version = "0.2.126"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9f07d2f20d4da7b26400c9f4a0511e6e0345b040694e8a75bd41d578fa4421d7"
|
||||
checksum = "f3997c7839262f4ef12cf90b818d6340c18e80f263f1a94bf157d0ec4420380e"
|
||||
dependencies = [
|
||||
"bumpalo",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
"wasm-bindgen-backend",
|
||||
"wasm-bindgen-shared",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen-shared"
|
||||
version = "0.2.104"
|
||||
version = "0.2.126"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bad67dc8b2a1a6e5448428adec4c3e84c43e561d8c9ee8a9e5aabeb193ec41d1"
|
||||
checksum = "dc1b4cb0cc549fcf58d7dfc081778139b3d283a081644e833e84682ad71cea24"
|
||||
dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
@@ -3653,7 +3716,7 @@ version = "0.31.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c66a47e840dc20793f2264eb4b3e4ecb4b75d91c0dd4af04b456128e0bdd449d"
|
||||
dependencies = [
|
||||
"bitflags 2.9.4",
|
||||
"bitflags 2.13.0",
|
||||
"rustix 1.1.2",
|
||||
"wayland-backend",
|
||||
"wayland-scanner",
|
||||
@@ -3665,7 +3728,7 @@ version = "0.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "625c5029dbd43d25e6aa9615e88b829a5cad13b2819c4ae129fdbb7c31ab4c7e"
|
||||
dependencies = [
|
||||
"bitflags 2.9.4",
|
||||
"bitflags 2.13.0",
|
||||
"cursor-icon",
|
||||
"wayland-backend",
|
||||
]
|
||||
@@ -3687,7 +3750,7 @@ version = "0.32.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "efa790ed75fbfd71283bd2521a1cfdc022aabcc28bdcff00851f9e4ae88d9901"
|
||||
dependencies = [
|
||||
"bitflags 2.9.4",
|
||||
"bitflags 2.13.0",
|
||||
"wayland-backend",
|
||||
"wayland-client",
|
||||
"wayland-scanner",
|
||||
@@ -3699,7 +3762,7 @@ version = "0.3.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a07a14257c077ab3279987c4f8bb987851bf57081b93710381daea94f2c2c032"
|
||||
dependencies = [
|
||||
"bitflags 2.9.4",
|
||||
"bitflags 2.13.0",
|
||||
"wayland-backend",
|
||||
"wayland-client",
|
||||
"wayland-protocols",
|
||||
@@ -3712,7 +3775,7 @@ version = "0.3.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "efd94963ed43cf9938a090ca4f7da58eb55325ec8200c3848963e98dc25b78ec"
|
||||
dependencies = [
|
||||
"bitflags 2.9.4",
|
||||
"bitflags 2.13.0",
|
||||
"wayland-backend",
|
||||
"wayland-client",
|
||||
"wayland-protocols",
|
||||
@@ -3744,9 +3807,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "web-sys"
|
||||
version = "0.3.81"
|
||||
version = "0.3.103"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9367c417a924a74cae129e6a2ae3b47fabb1f8995595ab474029da749a8be120"
|
||||
checksum = "8622dcb61c0bcc9fffa6938bed81210af2da9a7e4a1a834b2e37a59b6dfb6141"
|
||||
dependencies = [
|
||||
"js-sys",
|
||||
"wasm-bindgen",
|
||||
@@ -4241,7 +4304,7 @@ dependencies = [
|
||||
"ahash",
|
||||
"android-activity",
|
||||
"atomic-waker",
|
||||
"bitflags 2.9.4",
|
||||
"bitflags 2.13.0",
|
||||
"block2",
|
||||
"bytemuck",
|
||||
"calloop",
|
||||
@@ -4389,7 +4452,7 @@ version = "0.4.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d039de8032a9a8856a6be89cea3e5d12fdd82306ab7c94d74e6deab2460651c5"
|
||||
dependencies = [
|
||||
"bitflags 2.9.4",
|
||||
"bitflags 2.13.0",
|
||||
"dlib",
|
||||
"log",
|
||||
"once_cell",
|
||||
|
||||
+21
-10
@@ -5,19 +5,23 @@
|
||||
1. Download `NebulaTerminal-v0.4.0-windows-x64.zip` from the
|
||||
[Releases](https://github.com/Kuddev/nebula/releases) page.
|
||||
2. Unzip anywhere (no installer, no admin rights).
|
||||
3. **Install the font**: double-click `MapleMonoNormal-NF-CN-Regular.ttf` and press
|
||||
3. **Install the font**: open `fonts`, double-click
|
||||
`MapleMonoNormal-NF-CN-Regular.ttf`, and press
|
||||
*Install*. Nebula's powerline prompt and icons need this Nerd Font —
|
||||
without it they render as `□` boxes.
|
||||
4. Run `nebula.exe`.
|
||||
|
||||
Keep these files together in one directory:
|
||||
Keep the extracted directory structure intact:
|
||||
|
||||
| File | Purpose |
|
||||
| Path | Purpose |
|
||||
| --- | --- |
|
||||
| `nebula.exe` | the terminal |
|
||||
| `nebula-hook.exe` | AI turn-notification bridge (Claude Code / Codex) |
|
||||
| `conpty.dll` + `OpenConsole.exe` | modern ConPTY host (correct resize, fast tab spawn) |
|
||||
| `MapleMonoNormal-NF-CN-Regular.ttf` | Nerd Font for powerline/icons — install once (SIL OFL 1.1) |
|
||||
| `README.md` | overview and usage |
|
||||
| `runtime/nebula-hook.exe` | AI turn-notification bridge (Claude Code / Codex) |
|
||||
| `runtime/conpty.dll` + `runtime/OpenConsole.exe` | modern ConPTY host (correct resize, fast tab spawn) |
|
||||
| `fonts/MapleMonoNormal-NF-CN-Regular.ttf` | Nerd Font for powerline/icons — install once (SIL OFL 1.1) |
|
||||
| `docs/CHANGELOG.md` + `docs/INSTALL.md` | release changes and installation details |
|
||||
| `licenses/` | Nebula and third-party license notices |
|
||||
|
||||
## Build from source
|
||||
|
||||
@@ -29,10 +33,17 @@ cd nebula
|
||||
cargo build --release
|
||||
```
|
||||
|
||||
Artifacts land in `target/release/`. Copy
|
||||
`assets/windows/conhost/{conpty.dll,OpenConsole.exe}` next to `nebula.exe`
|
||||
when distributing (the build runs fine without them, falling back to the
|
||||
in-box ConPTY, but resize behavior of full-screen TUIs is worse).
|
||||
Build and assemble the portable archive with:
|
||||
|
||||
```powershell
|
||||
powershell -ExecutionPolicy Bypass -File scripts/package-release.ps1 `
|
||||
-Version unreleased -Force
|
||||
```
|
||||
|
||||
The script builds the release workspace, verifies every required input, stages
|
||||
the directory layout above, creates the ZIP, and prints its file count, packed
|
||||
and unpacked sizes, and SHA-256. Use `-SkipBuild` only when the release binaries
|
||||
have already been built and verified.
|
||||
|
||||
## First run
|
||||
|
||||
|
||||
@@ -98,6 +98,11 @@ experience that works without extra setup.
|
||||
passwords, and keyboard-interactive/MFA. Accepted host keys use the standard
|
||||
`known_hosts` store, and additional tabs reuse an already authenticated
|
||||
connection to the same `user@host:port` for a faster second shell.
|
||||
- **Built-in SFTP transfers** — open a remote file drawer from any saved SSH
|
||||
host and reuse its authenticated connection. Browse or type remote paths,
|
||||
filter entries, upload and download files or folders, create and rename
|
||||
folders, recursively delete, follow symlink targets, and cancel transfers
|
||||
with visible progress and errors.
|
||||
- **AI-aware over SSH** — remote Hook envelopes can return through a
|
||||
per-channel, randomly authenticated private OSC bridge. Nebula validates the
|
||||
channel token, replaces any remote pane identifier with the local pane, and
|
||||
@@ -146,6 +151,9 @@ experience that works without extra setup.
|
||||
persists across restarts.
|
||||
- **Tabs & splits** — sidebar tabs with drag-to-reorder and drag-to-dock into
|
||||
splits; unfocused panes dim instead of growing borders.
|
||||
- **Files, Git and SFTP drawers** — browse local or remote files without
|
||||
leaving the terminal. The Git drawer can stage, commit, pull fast-forward
|
||||
updates, and push.
|
||||
- **Quick terminal** — global <kbd>Ctrl</kbd>+<kbd>`</kbd> drops a Quake-style
|
||||
terminal from the top edge.
|
||||
- **Floating settings panel & command palette** — themes, background
|
||||
@@ -158,15 +166,17 @@ experience that works without extra setup.
|
||||
> **⚠️ Install the bundled font first.** Nebula's powerline prompt, program
|
||||
> icons and AI brand marks are drawn with **Maple Mono Normal NF CN** (a Nerd Font).
|
||||
> Without it, those glyphs render as `□` boxes. The font ships in the release
|
||||
> zip and the repo at `assets/fonts/MapleMonoNormal-NF-CN-Regular.ttf` — double-click
|
||||
> zip under `fonts/` and the repo at
|
||||
> `assets/fonts/MapleMonoNormal-NF-CN-Regular.ttf` — double-click
|
||||
> it and press **Install**, then launch Nebula. (Licensed under SIL OFL 1.1.)
|
||||
|
||||
**Release build (recommended)** — download
|
||||
`NebulaTerminal-v0.4.0-windows-x64.zip` from
|
||||
[Releases](https://github.com/Kuddev/nebula/releases), unzip
|
||||
anywhere, install `MapleMonoNormal-NF-CN-Regular.ttf`, then run `nebula.exe`. Keep
|
||||
the bundled files next to the exe (`nebula-hook.exe` powers AI notifications;
|
||||
`conpty.dll` + `OpenConsole.exe` provide the modern ConPTY host).
|
||||
anywhere, install `fonts/MapleMonoNormal-NF-CN-Regular.ttf`, then run
|
||||
`nebula.exe`. Keep the extracted folders in place: AI and ConPTY helpers live
|
||||
under `runtime/`, while fonts, documentation, and notices live under `fonts/`,
|
||||
`docs/`, and `licenses/`.
|
||||
|
||||
**From source**
|
||||
|
||||
@@ -245,6 +255,9 @@ Nebula 是一款 Windows 上的终端模拟器,以 Rust 编写,构建在 GPU
|
||||
口令、Windows OpenSSH Agent、Pageant、Windows 凭据管理器密码以及
|
||||
keyboard-interactive/MFA。主机密钥写入标准 `known_hosts`;同一
|
||||
`user@host:port` 再开标签页会复用已认证连接,让第二个 Shell 更快出现。
|
||||
- **内置 SFTP 传输** — 从已保存 SSH 主机即可打开远端文件抽屉,并复用已认证
|
||||
连接。支持浏览或手动输入远端路径、筛选、上传/下载文件与文件夹、新建与重命名
|
||||
文件夹、递归删除、跟随符号链接目标,以及带进度、取消和错误提示的后台传输。
|
||||
- **SSH 里也 AI 感知** — 远端 Hook 信封可通过每通道随机认证的私有 OSC 桥
|
||||
返回。Nebula 会校验通道令牌,用本地 Pane 身份覆盖远端字段,再复用现有侧栏
|
||||
状态和 Windows 通知链路。需要端口转发、配置查询或显式远程命令时,仍可使用
|
||||
@@ -286,6 +299,8 @@ Nebula 是一款 Windows 上的终端模拟器,以 Rust 编写,构建在 GPU
|
||||
系统驱动 chrome、提示符与弹窗,主题选择跨重启持久化。
|
||||
- **标签与分屏** — 侧边栏标签支持拖拽排序、拖入终端区四方位分屏;非焦点
|
||||
面板压暗而非描边。
|
||||
- **文件、Git 与 SFTP 抽屉** — 不离开终端即可浏览本地或远端文件;Git 抽屉
|
||||
支持暂存、提交、仅快进拉取和推送。
|
||||
- **快速终端** — 全局 <kbd>Ctrl</kbd>+<kbd>`</kbd> 从屏幕顶部滑出
|
||||
Quake 式终端。
|
||||
- **悬浮设置面板与命令面板** — 主题、背景图/透明度、shell 选择、补全行为,
|
||||
@@ -297,15 +312,16 @@ Nebula 是一款 Windows 上的终端模拟器,以 Rust 编写,构建在 GPU
|
||||
> **⚠️ 请先安装随附字体。** Nebula 的 powerline 提示符、程序图标与 AI 品牌
|
||||
> 标识都用 **Maple Mono Normal NF CN**(一款 Nerd Font)绘制。不装的话这些字形会显
|
||||
> 示成 `□` 方框。字体在 release 包内、仓库里也有:
|
||||
> `assets/fonts/MapleMonoNormal-NF-CN-Regular.ttf` —— 双击它点**安装**,再启动
|
||||
> `assets/fonts/MapleMonoNormal-NF-CN-Regular.ttf`;压缩包内位于 `fonts/` ——
|
||||
> 双击它点**安装**,再启动
|
||||
> Nebula。(SIL OFL 1.1 许可。)
|
||||
|
||||
**Release 包(推荐)** — 从
|
||||
[Releases](https://github.com/Kuddev/nebula/releases) 下载
|
||||
`NebulaTerminal-v0.4.0-windows-x64.zip`,解压到任意目录,先安装
|
||||
`MapleMonoNormal-NF-CN-Regular.ttf`,再运行 `nebula.exe`。请保持随包文件与 exe
|
||||
同目录(`nebula-hook.exe` 驱动 AI 通知;`conpty.dll` + `OpenConsole.exe`
|
||||
提供现代 ConPTY 宿主)。
|
||||
`fonts/MapleMonoNormal-NF-CN-Regular.ttf`,再运行 `nebula.exe`。请保持解压后的
|
||||
目录结构不变:AI 通知与 ConPTY 组件位于 `runtime/`,字体、文档和许可说明分别
|
||||
位于 `fonts/`、`docs/` 与 `licenses/`。
|
||||
|
||||
**从源码构建**
|
||||
|
||||
|
||||
@@ -33,6 +33,7 @@ version = "0.4.0"
|
||||
ahash = { version = "0.8.6", features = ["no-rng"] }
|
||||
base64 = "0.22.0"
|
||||
bitflags = "2.2.1"
|
||||
chrono = "0.4.45"
|
||||
clap = { version = "4.2.7", features = ["derive", "env"] }
|
||||
copypasta = { version = "0.10.1", default-features = false }
|
||||
crossfont = "0.8.1"
|
||||
@@ -47,16 +48,18 @@ polling = "3.11.0"
|
||||
# event parser; `display::markdown_view` renders the folded block AST.
|
||||
pulldown-cmark = { version = "0.13", default-features = false }
|
||||
russh = { version = "0.62.2", default-features = false, features = ["ring"] }
|
||||
russh-sftp = "2.3.0"
|
||||
# preserve_order: we rewrite the user's claude settings.json (ai_hook
|
||||
# self-heal) and must not alphabetize their keys in the process.
|
||||
serde_json = { version = "1", features = ["preserve_order"] }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_yaml = "0.9.25"
|
||||
sha2 = "0.11"
|
||||
signal-hook = { version = "0.4.3", default-features = false }
|
||||
tempfile = "3.12.0"
|
||||
toml.workspace = true
|
||||
toml_edit.workspace = true
|
||||
tokio = { version = "1", features = ["rt-multi-thread", "sync", "time"] }
|
||||
tokio = { version = "1", features = ["fs", "io-util", "rt-multi-thread", "sync", "time"] }
|
||||
unicode-width = "0.2.0"
|
||||
winit = { version = "0.30.9", default-features = false, features = ["rwh_06", "serde"] }
|
||||
global-hotkey = "0.8.0"
|
||||
@@ -111,6 +114,7 @@ windows-sys = { version = "0.59", features = [
|
||||
"Win32_System_Com_StructuredStorage",
|
||||
"Win32_System_Ole",
|
||||
"Win32_UI_Shell",
|
||||
"Win32_UI_Shell_Common",
|
||||
"Win32_UI_Shell_PropertiesSystem",
|
||||
"Win32_System_Variant",
|
||||
"Win32_System_Threading",
|
||||
|
||||
+57
-18
@@ -224,8 +224,9 @@ mod win {
|
||||
let p = helper.display().to_string().replace('\\', "/");
|
||||
unsafe { std::env::set_var(HOOK_EXE_ENV, p) };
|
||||
}
|
||||
if let Err(err) =
|
||||
std::thread::Builder::new().name("nebula-ai-pipe".into()).spawn(move || serve(&name, proxy))
|
||||
if let Err(err) = std::thread::Builder::new()
|
||||
.name("nebula-ai-pipe".into())
|
||||
.spawn(move || serve(&name, proxy))
|
||||
{
|
||||
log::warn!("ai_hook: failed to spawn pipe server: {err}");
|
||||
}
|
||||
@@ -336,7 +337,10 @@ mod win {
|
||||
let (claude_dir, codex_dir) = loop {
|
||||
let claude = claude_config_dir().filter(|d| d.exists());
|
||||
let codex = codex_config_dir().filter(|d| d.exists());
|
||||
if claude.is_some() || codex.is_some() || opencode_config_dir().is_some_and(|d| d.exists()) {
|
||||
if claude.is_some()
|
||||
|| codex.is_some()
|
||||
|| opencode_config_dir().is_some_and(|d| d.exists())
|
||||
{
|
||||
break (claude, codex);
|
||||
}
|
||||
std::thread::sleep(Duration::from_secs(300));
|
||||
@@ -373,9 +377,8 @@ mod win {
|
||||
Ok(ev) => {
|
||||
ev.paths.is_empty()
|
||||
|| ev.paths.iter().any(|p| {
|
||||
p.file_name().is_some_and(|n| {
|
||||
n == "settings.json" || n == "config.toml"
|
||||
})
|
||||
p.file_name()
|
||||
.is_some_and(|n| n == "settings.json" || n == "config.toml")
|
||||
})
|
||||
},
|
||||
Err(_) => true,
|
||||
@@ -597,10 +600,7 @@ export const NebulaNotify = async ({ $ }) => {
|
||||
// Mid-rewrite by a concurrent writer, or genuinely broken:
|
||||
// never "repair" by clobbering. The watcher retries on the
|
||||
// next change, the boot pass on the next start.
|
||||
log::warn!(
|
||||
"ai_hook: {} is not valid JSON ({err}); left alone",
|
||||
path.display()
|
||||
);
|
||||
log::warn!("ai_hook: {} is not valid JSON ({err}); left alone", path.display());
|
||||
return false;
|
||||
},
|
||||
},
|
||||
@@ -757,7 +757,7 @@ export const NebulaNotify = async ({ $ }) => {
|
||||
match helper_command() {
|
||||
Some(command) => println!("hook 命令:{command}"),
|
||||
None => {
|
||||
eprintln!("nebula-hook.exe 不在 nebula.exe 旁边,无法安装。");
|
||||
eprintln!("runtime/ 和 nebula.exe 同目录中均未找到 nebula-hook.exe,无法安装。");
|
||||
return 1;
|
||||
},
|
||||
}
|
||||
@@ -783,7 +783,10 @@ export const NebulaNotify = async ({ $ }) => {
|
||||
match opencode_config_dir() {
|
||||
Some(cfg) if cfg.exists() => {
|
||||
if ensure_opencode_plugin() {
|
||||
println!("opencode: 已安装 {}。", cfg.join("plugins").join("nebula.js").display());
|
||||
println!(
|
||||
"opencode: 已安装 {}。",
|
||||
cfg.join("plugins").join("nebula.js").display()
|
||||
);
|
||||
} else {
|
||||
println!("opencode: 插件已是最新。");
|
||||
}
|
||||
@@ -858,15 +861,24 @@ export const NebulaNotify = async ({ $ }) => {
|
||||
Ok(false)
|
||||
}
|
||||
|
||||
/// Absolute path of the bridge exe (must sit next to nebula.exe).
|
||||
/// Absolute path of the bridge exe.
|
||||
fn helper_path() -> Option<PathBuf> {
|
||||
let exe = std::env::current_exe().ok()?;
|
||||
let helper = exe.parent()?.join("nebula-hook.exe");
|
||||
if !helper.exists() {
|
||||
log::warn!("ai_hook: {} missing; AI integrations not installed", helper.display());
|
||||
return None;
|
||||
let helper = helper_path_from_exe(&exe);
|
||||
if helper.is_none() {
|
||||
log::warn!(
|
||||
"ai_hook: nebula-hook.exe missing from runtime/ and executable directory; AI integrations not installed"
|
||||
);
|
||||
}
|
||||
Some(helper)
|
||||
helper
|
||||
}
|
||||
|
||||
fn helper_path_from_exe(exe: &Path) -> Option<PathBuf> {
|
||||
let exe_dir = exe.parent()?;
|
||||
// 新包优先使用分类目录,旧同目录位置仅用于开发构建和兼容历史包。
|
||||
[exe_dir.join("runtime").join("nebula-hook.exe"), exe_dir.join("nebula-hook.exe")]
|
||||
.into_iter()
|
||||
.find(|path| path.is_file())
|
||||
}
|
||||
|
||||
/// The quoted claude hook command. Forward slashes on purpose: they
|
||||
@@ -884,4 +896,31 @@ export const NebulaNotify = async ({ $ }) => {
|
||||
std::fs::write(&tmp, data)?;
|
||||
std::fs::rename(&tmp, path)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod runtime_asset_tests {
|
||||
use super::helper_path_from_exe;
|
||||
|
||||
#[test]
|
||||
fn hook_helper_prefers_runtime_directory() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let exe = dir.path().join("nebula.exe");
|
||||
let runtime = dir.path().join("runtime");
|
||||
std::fs::create_dir(&runtime).unwrap();
|
||||
std::fs::write(dir.path().join("nebula-hook.exe"), b"legacy").unwrap();
|
||||
std::fs::write(runtime.join("nebula-hook.exe"), b"structured").unwrap();
|
||||
|
||||
assert_eq!(helper_path_from_exe(&exe), Some(runtime.join("nebula-hook.exe")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hook_helper_falls_back_to_legacy_sibling() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let exe = dir.path().join("nebula.exe");
|
||||
let legacy = dir.path().join("nebula-hook.exe");
|
||||
std::fs::write(&legacy, b"legacy").unwrap();
|
||||
|
||||
assert_eq!(helper_path_from_exe(&exe), Some(legacy));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -992,11 +992,8 @@ pub(super) fn draw_chrome(d: &mut Display) {
|
||||
ChromeHit::TabsSection | ChromeHit::NewTab | ChromeHit::NewTabMenu
|
||||
);
|
||||
if !d.nebula_settings_open && tabs_plus_visible {
|
||||
let plus_ink = if d.nebula_chrome_hover == ChromeHit::NewTab {
|
||||
sk.icon_hover
|
||||
} else {
|
||||
sk.icon
|
||||
};
|
||||
let plus_ink =
|
||||
if d.nebula_chrome_hover == ChromeHit::NewTab { sk.icon_hover } else { sk.icon };
|
||||
push_centered_add_icon(&mut quads, tab_layout.plus, scale, plus_ink);
|
||||
}
|
||||
// The dropdown chevron beside "+": same hover-only lift.
|
||||
@@ -1009,11 +1006,7 @@ pub(super) fn draw_chrome(d: &mut Display) {
|
||||
&mut quads,
|
||||
tab_layout.menu,
|
||||
scale,
|
||||
if d.nebula_chrome_hover == ChromeHit::NewTabMenu {
|
||||
sk.icon_hover
|
||||
} else {
|
||||
sk.icon
|
||||
},
|
||||
if d.nebula_chrome_hover == ChromeHit::NewTabMenu { sk.icon_hover } else { sk.icon },
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1032,10 +1025,7 @@ pub(super) fn draw_chrome(d: &mut Display) {
|
||||
let (ax, ay, aw, ah) = tab_layout.hosts_add;
|
||||
quads.push(UiQuad::solid(ax, ay, aw, ah, s(6.0), HOVER_FILL_STRONG));
|
||||
}
|
||||
if matches!(
|
||||
d.nebula_chrome_hover,
|
||||
ChromeHit::HostsSection | ChromeHit::AddSshHost
|
||||
) {
|
||||
if matches!(d.nebula_chrome_hover, ChromeHit::HostsSection | ChromeHit::AddSshHost) {
|
||||
push_centered_add_icon(
|
||||
&mut quads,
|
||||
tab_layout.hosts_add,
|
||||
@@ -1108,14 +1098,26 @@ pub(super) fn draw_chrome(d: &mut Display) {
|
||||
// drawing through slide-out; animation stepping is centralized in Display.
|
||||
d.step_chrome_anims();
|
||||
if d.side_panel_visible() {
|
||||
side_panel::push_quads(
|
||||
&d.nebula_side_panel,
|
||||
&d.side_panel_layout(),
|
||||
&d.nebula_theme,
|
||||
&mut quads,
|
||||
scale,
|
||||
size.cell_width(),
|
||||
);
|
||||
if let Some(panel) = d.nebula_sftp_panel.as_ref() {
|
||||
let layout = sftp_panel::layout(&d.side_panel_layout(), scale);
|
||||
sftp_panel::push_quads(
|
||||
panel,
|
||||
&layout,
|
||||
&d.nebula_theme,
|
||||
&mut quads,
|
||||
scale,
|
||||
size.cell_width(),
|
||||
);
|
||||
} else {
|
||||
side_panel::push_quads(
|
||||
&d.nebula_side_panel,
|
||||
&d.side_panel_layout(),
|
||||
&d.nebula_theme,
|
||||
&mut quads,
|
||||
scale,
|
||||
size.cell_width(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// No base pill behind the gear — just the icon (hover still fills).
|
||||
@@ -1455,7 +1457,7 @@ pub(super) fn draw_chrome(d: &mut Display) {
|
||||
// destination here once the connection confirms (`~/.ssh/config`
|
||||
// aliases still appear automatically too). Styled as helper text,
|
||||
// NOT as content: smaller and fainter than the caption above it,
|
||||
// and indented to the row-label depth (`tab_pad + s(14)`, where
|
||||
// and indented to the row-label depth (`tab_pad + s(14)`, where
|
||||
// tab/host row text starts) so it reads as a child of the section.
|
||||
// The hint wraps to the sidebar width; drawing it as one line
|
||||
// would bleed across the seam onto the terminal grid.
|
||||
@@ -1578,23 +1580,36 @@ pub(super) fn draw_chrome(d: &mut Display) {
|
||||
// Palette text (query + result rows) sits on top of every chrome label.
|
||||
// Drawer text stays below settings; otherwise it pierces the modal glass.
|
||||
if d.side_panel_visible() && !d.nebula_settings_open {
|
||||
// File-tree rows use the terminal's live ANSI palette so the drawer
|
||||
// matches `ls` colors exactly (dirs blue, executables green) and
|
||||
// follows theme switches with it.
|
||||
let ls_colors = side_panel::LsColors {
|
||||
dir: d.colors[nebula_terminal::vte::ansi::NamedColor::Blue],
|
||||
exec: d.colors[nebula_terminal::vte::ansi::NamedColor::Green],
|
||||
};
|
||||
side_panel::draw_text(
|
||||
&d.nebula_side_panel,
|
||||
&d.side_panel_layout(),
|
||||
&d.nebula_theme,
|
||||
ls_colors,
|
||||
&mut d.renderer,
|
||||
&mut d.glyph_cache,
|
||||
&d.size_info,
|
||||
d.window.scale_factor as f32,
|
||||
);
|
||||
if let Some(panel) = d.nebula_sftp_panel.as_ref() {
|
||||
let scale = d.window.scale_factor as f32;
|
||||
let layout = sftp_panel::layout(&d.side_panel_layout(), scale);
|
||||
sftp_panel::draw_text(
|
||||
panel,
|
||||
&layout,
|
||||
&d.nebula_theme,
|
||||
&mut d.renderer,
|
||||
&mut d.glyph_cache,
|
||||
&d.size_info,
|
||||
scale,
|
||||
);
|
||||
} else {
|
||||
// File-tree rows use the terminal's live ANSI palette so the drawer
|
||||
// matches `ls` colors exactly (dirs blue, executables green).
|
||||
let ls_colors = side_panel::LsColors {
|
||||
dir: d.colors[nebula_terminal::vte::ansi::NamedColor::Blue],
|
||||
exec: d.colors[nebula_terminal::vte::ansi::NamedColor::Green],
|
||||
};
|
||||
side_panel::draw_text(
|
||||
&d.nebula_side_panel,
|
||||
&d.side_panel_layout(),
|
||||
&d.nebula_theme,
|
||||
ls_colors,
|
||||
&mut d.renderer,
|
||||
&mut d.glyph_cache,
|
||||
&d.size_info,
|
||||
d.window.scale_factor as f32,
|
||||
);
|
||||
}
|
||||
}
|
||||
let shell_icon_draws = command_palette::draw_text(
|
||||
&d.nebula_palette,
|
||||
|
||||
@@ -6,8 +6,8 @@
|
||||
|
||||
use std::time::Instant;
|
||||
|
||||
use super::*;
|
||||
use super::design_tokens::{control, elevation, motion, space};
|
||||
use super::*;
|
||||
|
||||
// Maple Mono Normal NF CN ships the stable Codicon block below. Keep menu
|
||||
// symbols on that block: newer Codicon additions such as U+EC86 are absent
|
||||
@@ -33,6 +33,7 @@ pub const TAB_COLORS: [Rgb; 7] = [
|
||||
pub enum ContextMenuTarget {
|
||||
Tab(usize),
|
||||
Ssh(usize),
|
||||
Sftp(usize),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
@@ -44,9 +45,13 @@ pub enum ContextMenuAction {
|
||||
CloseTab(usize),
|
||||
SetTabColor { index: usize, color: Option<Rgb> },
|
||||
ConnectSsh(usize),
|
||||
OpenSftp(usize),
|
||||
CopySshAddress(usize),
|
||||
EditSsh(usize),
|
||||
DeleteSsh(usize),
|
||||
DownloadSftp(usize),
|
||||
RenameSftp(usize),
|
||||
DeleteSftp(usize),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
@@ -149,14 +154,11 @@ fn layout(menu: &ContextMenu, size: SizeInfo, scale: f32, animated_y_offset: f32
|
||||
let color_label_gap = s(space::XS);
|
||||
let swatch = s(control::ICON_BUTTON);
|
||||
let color_bottom_pad = s(10.0);
|
||||
let color_h = color_top_pad
|
||||
+ size.cell_height()
|
||||
+ color_label_gap
|
||||
+ swatch
|
||||
+ color_bottom_pad;
|
||||
let color_h = color_top_pad + size.cell_height() + color_label_gap + swatch + color_bottom_pad;
|
||||
let (row_count, separators, extra) = match menu.target {
|
||||
ContextMenuTarget::Tab(_) => (5usize, 2usize, color_h),
|
||||
ContextMenuTarget::Ssh(_) => (4usize, 1usize, 0.0),
|
||||
ContextMenuTarget::Ssh(_) => (5usize, 1usize, 0.0),
|
||||
ContextMenuTarget::Sftp(_) => (3usize, 1usize, 0.0),
|
||||
};
|
||||
let height = pad * 2.0 + row_count as f32 * row_h + separators as f32 * sep_h + extra;
|
||||
let margin = s(8.0);
|
||||
@@ -268,6 +270,15 @@ fn layout(menu: &ContextMenu, size: SizeInfo, scale: f32, animated_y_offset: f32
|
||||
false,
|
||||
));
|
||||
cursor_y += row_h;
|
||||
rows.push(row(
|
||||
ContextMenuAction::OpenSftp(index),
|
||||
"\u{f0c7}",
|
||||
"打开 SFTP",
|
||||
"",
|
||||
cursor_y,
|
||||
false,
|
||||
));
|
||||
cursor_y += row_h;
|
||||
rows.push(row(
|
||||
ContextMenuAction::CopySshAddress(index),
|
||||
ICON_COPY,
|
||||
@@ -296,6 +307,35 @@ fn layout(menu: &ContextMenu, size: SizeInfo, scale: f32, animated_y_offset: f32
|
||||
true,
|
||||
));
|
||||
},
|
||||
ContextMenuTarget::Sftp(index) => {
|
||||
rows.push(row(
|
||||
ContextMenuAction::DownloadSftp(index),
|
||||
"\u{eb4a}",
|
||||
"下载",
|
||||
"",
|
||||
cursor_y,
|
||||
false,
|
||||
));
|
||||
cursor_y += row_h;
|
||||
rows.push(row(
|
||||
ContextMenuAction::RenameSftp(index),
|
||||
ICON_EDIT,
|
||||
"重命名",
|
||||
"F2",
|
||||
cursor_y,
|
||||
false,
|
||||
));
|
||||
cursor_y += row_h;
|
||||
separator(&mut cursor_y);
|
||||
rows.push(row(
|
||||
ContextMenuAction::DeleteSftp(index),
|
||||
"\u{ea81}",
|
||||
"删除",
|
||||
"",
|
||||
cursor_y,
|
||||
true,
|
||||
));
|
||||
},
|
||||
}
|
||||
|
||||
MenuLayout { panel, rows, separators: separator_rects, color_label, colors }
|
||||
@@ -509,20 +549,22 @@ mod tests {
|
||||
let layout = layout(&menu, size(), 1.0, 0.0);
|
||||
let first = layout.rows[0];
|
||||
assert_eq!(
|
||||
hit_test(
|
||||
&menu,
|
||||
size(),
|
||||
1.0,
|
||||
first.rect.0 + 2.0,
|
||||
first.rect.1 + 2.0,
|
||||
),
|
||||
hit_test(&menu, size(), 1.0, first.rect.0 + 2.0, first.rect.1 + 2.0,),
|
||||
ContextMenuHit::Action(ContextMenuAction::DuplicateTab(3))
|
||||
);
|
||||
assert_eq!(layout.colors.len(), 8);
|
||||
assert_eq!(layout.colors[0].0, None);
|
||||
assert_eq!(
|
||||
layout.colors[0].1,
|
||||
ContextMenuAction::SetTabColor { index: 3, color: None }
|
||||
);
|
||||
assert_eq!(layout.colors[0].1, ContextMenuAction::SetTabColor { index: 3, color: None });
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sftp_row_menu_exposes_download_rename_and_delete() {
|
||||
let menu = ContextMenu::new(ContextMenuTarget::Sftp(4), (100.0, 100.0), None);
|
||||
let layout = layout(&menu, size(), 1.0, 0.0);
|
||||
|
||||
assert_eq!(layout.rows.len(), 3);
|
||||
assert_eq!(layout.rows[0].action, ContextMenuAction::DownloadSftp(4));
|
||||
assert_eq!(layout.rows[1].action, ContextMenuAction::RenameSftp(4));
|
||||
assert_eq!(layout.rows[2].action, ContextMenuAction::DeleteSftp(4));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,237 @@
|
||||
use std::path::PathBuf;
|
||||
|
||||
use winit::raw_window_handle::RawWindowHandle;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub(super) enum PrivateKeyFileKind {
|
||||
PrivateKey,
|
||||
PublicKey,
|
||||
Unsupported,
|
||||
}
|
||||
|
||||
pub(super) fn classify_private_key_contents(contents: &[u8]) -> PrivateKeyFileKind {
|
||||
let Ok(text) = std::str::from_utf8(contents) else {
|
||||
return PrivateKeyFileKind::Unsupported;
|
||||
};
|
||||
let trimmed = text.trim_start();
|
||||
if trimmed.starts_with("ssh-")
|
||||
|| trimmed.starts_with("ecdsa-sha2-")
|
||||
|| trimmed.starts_with("sk-ssh-")
|
||||
|| trimmed.starts_with("sk-ecdsa-")
|
||||
|| russh::keys::ssh_key::PublicKey::from_openssh(trimmed).is_ok()
|
||||
{
|
||||
return PrivateKeyFileKind::PublicKey;
|
||||
}
|
||||
if [
|
||||
"-----BEGIN OPENSSH PRIVATE KEY-----",
|
||||
"-----BEGIN RSA PRIVATE KEY-----",
|
||||
"-----BEGIN EC PRIVATE KEY-----",
|
||||
"-----BEGIN ENCRYPTED PRIVATE KEY-----",
|
||||
"-----BEGIN PRIVATE KEY-----",
|
||||
"PuTTY-User-Key-File-",
|
||||
]
|
||||
.iter()
|
||||
.any(|header| trimmed.starts_with(header))
|
||||
{
|
||||
PrivateKeyFileKind::PrivateKey
|
||||
} else {
|
||||
PrivateKeyFileKind::Unsupported
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn pick_image_file(owner: RawWindowHandle) -> Option<String> {
|
||||
pick_file(
|
||||
owner,
|
||||
"Images (*.png;*.jpg;*.jpeg;*.webp;*.bmp)\0*.png;*.jpg;*.jpeg;*.webp;*.bmp\0All files (*.*)\0*.*\0\0",
|
||||
"Choose background image",
|
||||
)
|
||||
.map(|path| path.to_string_lossy().into_owned())
|
||||
}
|
||||
|
||||
pub(super) fn pick_private_key_file(owner: RawWindowHandle) -> Option<Result<PathBuf, String>> {
|
||||
let path = pick_file(
|
||||
owner,
|
||||
"SSH private keys (id_*;*.pem;*.key;*.ppk)\0id_*;*.pem;*.key;*.ppk\0All files (*.*)\0*.*\0\0",
|
||||
"Choose SSH private key",
|
||||
)?;
|
||||
let contents = match std::fs::read(&path) {
|
||||
Ok(contents) => contents,
|
||||
Err(err) => return Some(Err(format!("无法读取私钥 {}: {err}", path.display()))),
|
||||
};
|
||||
Some(match classify_private_key_contents(&contents) {
|
||||
PrivateKeyFileKind::PrivateKey => Ok(path),
|
||||
PrivateKeyFileKind::PublicKey => Err("请选择私钥文件,不要选择 .pub 公钥".to_owned()),
|
||||
PrivateKeyFileKind::Unsupported => {
|
||||
Err("文件不是受支持的 OpenSSH、PEM 或 PPK 私钥".to_owned())
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn pick_upload_files(owner: RawWindowHandle) -> Vec<PathBuf> {
|
||||
pick_files(owner, "All files (*.*)\0*.*\0\0", "选择要上传的文件", true)
|
||||
}
|
||||
|
||||
pub(super) fn pick_upload_directory(owner: RawWindowHandle) -> Option<PathBuf> {
|
||||
pick_folder(owner, "选择要上传的文件夹")
|
||||
}
|
||||
|
||||
pub(super) fn pick_download_directory(owner: RawWindowHandle) -> Option<PathBuf> {
|
||||
pick_folder(owner, "选择下载位置")
|
||||
}
|
||||
|
||||
fn pick_file(owner: RawWindowHandle, filter: &str, title: &str) -> Option<PathBuf> {
|
||||
pick_files(owner, filter, title, false).into_iter().next()
|
||||
}
|
||||
|
||||
fn pick_files(owner: RawWindowHandle, filter: &str, title: &str, multiple: bool) -> Vec<PathBuf> {
|
||||
use windows_sys::Win32::Foundation::HWND;
|
||||
use windows_sys::Win32::UI::Controls::Dialogs::{
|
||||
GetOpenFileNameW, OFN_ALLOWMULTISELECT, OFN_EXPLORER, OFN_FILEMUSTEXIST, OFN_HIDEREADONLY,
|
||||
OFN_NOCHANGEDIR, OFN_PATHMUSTEXIST, OPENFILENAMEW,
|
||||
};
|
||||
|
||||
let hwnd: HWND = match owner {
|
||||
RawWindowHandle::Win32(handle) => handle.hwnd.get() as HWND,
|
||||
_ => std::ptr::null_mut(),
|
||||
};
|
||||
let filter: Vec<u16> = filter.encode_utf16().collect();
|
||||
let title: Vec<u16> = title.encode_utf16().chain(std::iter::once(0)).collect();
|
||||
let mut file_buffer = vec![0u16; 32768];
|
||||
let mut dialog: OPENFILENAMEW = unsafe { std::mem::zeroed() };
|
||||
dialog.lStructSize = std::mem::size_of::<OPENFILENAMEW>() as u32;
|
||||
dialog.hwndOwner = hwnd;
|
||||
dialog.lpstrFilter = filter.as_ptr();
|
||||
dialog.lpstrFile = file_buffer.as_mut_ptr();
|
||||
dialog.nMaxFile = file_buffer.len() as u32;
|
||||
dialog.lpstrTitle = title.as_ptr();
|
||||
dialog.Flags = OFN_EXPLORER
|
||||
| OFN_FILEMUSTEXIST
|
||||
| OFN_PATHMUSTEXIST
|
||||
| OFN_NOCHANGEDIR
|
||||
| OFN_HIDEREADONLY
|
||||
| if multiple { OFN_ALLOWMULTISELECT } else { 0 };
|
||||
|
||||
if unsafe { GetOpenFileNameW(&mut dialog) } == 0 {
|
||||
return Vec::new();
|
||||
}
|
||||
parse_open_file_buffer(&file_buffer)
|
||||
}
|
||||
|
||||
fn parse_open_file_buffer(buffer: &[u16]) -> Vec<PathBuf> {
|
||||
let parts = buffer
|
||||
.split(|value| *value == 0)
|
||||
.take_while(|part| !part.is_empty())
|
||||
.map(|part| String::from_utf16_lossy(part))
|
||||
.collect::<Vec<_>>();
|
||||
match parts.as_slice() {
|
||||
[] => Vec::new(),
|
||||
[path] => vec![PathBuf::from(path)],
|
||||
[directory, names @ ..] => {
|
||||
let directory = PathBuf::from(directory);
|
||||
names.iter().map(|name| directory.join(name)).collect()
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn pick_folder(owner: RawWindowHandle, title: &str) -> Option<PathBuf> {
|
||||
use windows_sys::Win32::Foundation::HWND;
|
||||
use windows_sys::Win32::System::Com::CoTaskMemFree;
|
||||
use windows_sys::Win32::UI::Shell::{
|
||||
BIF_NEWDIALOGSTYLE, BIF_RETURNONLYFSDIRS, BROWSEINFOW, SHBrowseForFolderW,
|
||||
SHGetPathFromIDListW,
|
||||
};
|
||||
|
||||
let hwnd: HWND = match owner {
|
||||
RawWindowHandle::Win32(handle) => handle.hwnd.get() as HWND,
|
||||
_ => std::ptr::null_mut(),
|
||||
};
|
||||
let title: Vec<u16> = title.encode_utf16().chain(std::iter::once(0)).collect();
|
||||
let mut display_name = vec![0u16; 260];
|
||||
let dialog = BROWSEINFOW {
|
||||
hwndOwner: hwnd,
|
||||
pidlRoot: std::ptr::null_mut(),
|
||||
pszDisplayName: display_name.as_mut_ptr(),
|
||||
lpszTitle: title.as_ptr(),
|
||||
ulFlags: BIF_RETURNONLYFSDIRS | BIF_NEWDIALOGSTYLE,
|
||||
lpfn: None,
|
||||
lParam: 0,
|
||||
iImage: 0,
|
||||
};
|
||||
let item = unsafe { SHBrowseForFolderW(&dialog) };
|
||||
if item.is_null() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let mut path = vec![0u16; 260];
|
||||
let ok = unsafe { SHGetPathFromIDListW(item, path.as_mut_ptr()) } != 0;
|
||||
unsafe { CoTaskMemFree(item.cast()) };
|
||||
if !ok {
|
||||
return None;
|
||||
}
|
||||
let length = path.iter().position(|value| *value == 0).unwrap_or(path.len());
|
||||
(length > 0).then(|| PathBuf::from(String::from_utf16_lossy(&path[..length])))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::path::PathBuf;
|
||||
|
||||
use super::{PrivateKeyFileKind, classify_private_key_contents, parse_open_file_buffer};
|
||||
|
||||
fn wide_parts(parts: &[&str]) -> Vec<u16> {
|
||||
let mut buffer = Vec::new();
|
||||
for part in parts {
|
||||
buffer.extend(part.encode_utf16());
|
||||
buffer.push(0);
|
||||
}
|
||||
buffer.push(0);
|
||||
buffer
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn public_key_text_is_rejected_as_private_key() {
|
||||
assert_eq!(
|
||||
classify_private_key_contents(b"ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAITest user@host"),
|
||||
PrivateKeyFileKind::PublicKey
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn encrypted_and_putty_private_key_headers_are_accepted_for_later_unlock() {
|
||||
assert_eq!(
|
||||
classify_private_key_contents(
|
||||
b"-----BEGIN OPENSSH PRIVATE KEY-----\nnot-decoded-until-passphrase\n"
|
||||
),
|
||||
PrivateKeyFileKind::PrivateKey
|
||||
);
|
||||
assert_eq!(
|
||||
classify_private_key_contents(
|
||||
b"PuTTY-User-Key-File-3: ssh-ed25519\nEncryption: aes256-cbc"
|
||||
),
|
||||
PrivateKeyFileKind::PrivateKey
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unrelated_file_is_not_accepted_as_private_key() {
|
||||
assert_eq!(
|
||||
classify_private_key_contents(b"this is not an SSH key"),
|
||||
PrivateKeyFileKind::Unsupported
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn native_multi_select_buffer_joins_names_to_the_selected_directory() {
|
||||
let buffer = wide_parts(&[r"D:\上传 文件", "alpha.txt", "测试.zip"]);
|
||||
assert_eq!(
|
||||
parse_open_file_buffer(&buffer),
|
||||
vec![PathBuf::from(r"D:\上传 文件\alpha.txt"), PathBuf::from(r"D:\上传 文件\测试.zip"),]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn native_single_select_buffer_keeps_the_absolute_path() {
|
||||
let buffer = wide_parts(&[r"D:\release\nebula.zip"]);
|
||||
assert_eq!(parse_open_file_buffer(&buffer), vec![PathBuf::from(r"D:\release\nebula.zip")]);
|
||||
}
|
||||
}
|
||||
+336
-492
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,717 @@
|
||||
//! SFTP 右侧抽屉的状态、几何和绘制;传输协议留在 `ssh_sftp`。
|
||||
|
||||
use std::time::Instant;
|
||||
|
||||
use super::*;
|
||||
use crate::ssh_sftp::{SftpController, SftpEntry, SftpEntryKind, SftpPhase, SftpSnapshot};
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
enum EditorKind {
|
||||
Filter,
|
||||
Path,
|
||||
CreateDirectory,
|
||||
Rename(SftpEntry),
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
struct Editor {
|
||||
kind: EditorKind,
|
||||
text: String,
|
||||
selection: super::text_input::SelectAllState,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
pub enum SftpHit {
|
||||
#[default]
|
||||
None,
|
||||
Close,
|
||||
Path,
|
||||
Up,
|
||||
Refresh,
|
||||
UploadFiles,
|
||||
UploadDirectory,
|
||||
NewDirectory,
|
||||
Filter,
|
||||
Row(usize),
|
||||
Cancel,
|
||||
Inside,
|
||||
}
|
||||
|
||||
pub struct SftpPanel {
|
||||
controller: SftpController,
|
||||
pub filter: String,
|
||||
pub selected: Option<String>,
|
||||
pub scroll: usize,
|
||||
pub hover: SftpHit,
|
||||
editor: Option<Editor>,
|
||||
editor_error: Option<String>,
|
||||
last_click: Option<(String, Instant)>,
|
||||
}
|
||||
|
||||
impl SftpPanel {
|
||||
pub fn new(controller: SftpController) -> Self {
|
||||
Self {
|
||||
controller,
|
||||
filter: String::new(),
|
||||
selected: None,
|
||||
scroll: 0,
|
||||
hover: SftpHit::None,
|
||||
editor: None,
|
||||
editor_error: None,
|
||||
last_click: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn snapshot(&self) -> SftpSnapshot {
|
||||
self.controller.snapshot()
|
||||
}
|
||||
|
||||
pub fn visible_entries(&self) -> Vec<SftpEntry> {
|
||||
filtered_entries(&self.snapshot().entries, &self.filter)
|
||||
}
|
||||
|
||||
pub fn visible_entry(&self, index: usize) -> Option<SftpEntry> {
|
||||
self.visible_entries().into_iter().nth(self.scroll + index)
|
||||
}
|
||||
|
||||
pub fn set_hover(&mut self, hit: SftpHit) -> bool {
|
||||
if self.hover == hit {
|
||||
return false;
|
||||
}
|
||||
self.hover = hit;
|
||||
true
|
||||
}
|
||||
|
||||
pub fn scroll_by(&mut self, delta: i32, visible_rows: usize) {
|
||||
let max = self.visible_entries().len().saturating_sub(visible_rows);
|
||||
self.scroll = (self.scroll as i64 + delta as i64).clamp(0, max as i64) as usize;
|
||||
}
|
||||
|
||||
pub fn go_up(&self) {
|
||||
let snapshot = self.snapshot();
|
||||
self.controller
|
||||
.refresh(super::super::ssh_sftp::normalize_remote_path(&snapshot.path, ".."));
|
||||
}
|
||||
|
||||
pub fn refresh(&self) {
|
||||
self.controller.refresh(self.snapshot().path);
|
||||
}
|
||||
|
||||
pub fn navigate(&mut self, entry: &SftpEntry) -> bool {
|
||||
if matches!(entry.kind, SftpEntryKind::Directory | SftpEntryKind::Symlink) {
|
||||
self.selected = None;
|
||||
self.scroll = 0;
|
||||
self.controller.refresh(entry.path.clone());
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
pub fn select_row(&mut self, index: usize) -> Option<(SftpEntry, bool)> {
|
||||
let entry = self.visible_entry(index)?;
|
||||
self.selected = Some(entry.path.clone());
|
||||
let now = Instant::now();
|
||||
let double = self.last_click.as_ref().is_some_and(|(path, at)| {
|
||||
*path == entry.path && at.elapsed() < std::time::Duration::from_millis(400)
|
||||
});
|
||||
self.last_click = if double { None } else { Some((entry.path.clone(), now)) };
|
||||
Some((entry, double))
|
||||
}
|
||||
|
||||
pub fn begin_filter(&mut self) {
|
||||
self.begin_editor(EditorKind::Filter, self.filter.clone());
|
||||
}
|
||||
|
||||
pub fn begin_path(&mut self) {
|
||||
self.begin_editor(EditorKind::Path, self.snapshot().path);
|
||||
}
|
||||
|
||||
pub fn begin_create_directory(&mut self) {
|
||||
self.begin_editor(EditorKind::CreateDirectory, String::new());
|
||||
}
|
||||
|
||||
pub fn begin_rename(&mut self, entry: SftpEntry) {
|
||||
let name = entry.name.clone();
|
||||
self.begin_editor(EditorKind::Rename(entry), name);
|
||||
}
|
||||
|
||||
fn begin_editor(&mut self, kind: EditorKind, text: String) {
|
||||
let mut selection = super::text_input::SelectAllState::default();
|
||||
selection.select(&text);
|
||||
self.editor = Some(Editor { kind, text, selection });
|
||||
self.editor_error = None;
|
||||
}
|
||||
|
||||
pub fn editor_active(&self) -> bool {
|
||||
self.editor.is_some()
|
||||
}
|
||||
|
||||
pub fn editor_insert(&mut self, text: &str) {
|
||||
let Some(editor) = self.editor.as_mut() else { return };
|
||||
let text: String = text.chars().filter(|character| !character.is_control()).collect();
|
||||
editor.selection.insert(&mut editor.text, &text);
|
||||
if matches!(editor.kind, EditorKind::Filter) {
|
||||
self.filter.clone_from(&editor.text);
|
||||
self.scroll = 0;
|
||||
self.selected = None;
|
||||
}
|
||||
}
|
||||
|
||||
pub fn editor_backspace(&mut self) {
|
||||
let Some(editor) = self.editor.as_mut() else { return };
|
||||
editor.selection.backspace(&mut editor.text);
|
||||
if matches!(editor.kind, EditorKind::Filter) {
|
||||
self.filter.clone_from(&editor.text);
|
||||
self.scroll = 0;
|
||||
self.selected = None;
|
||||
}
|
||||
}
|
||||
|
||||
pub fn editor_select_all(&mut self) {
|
||||
if let Some(editor) = self.editor.as_mut() {
|
||||
editor.selection.select(&editor.text);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn editor_selected_text(&self) -> Option<String> {
|
||||
self.editor.as_ref().and_then(|editor| editor.selection.selected_text(&editor.text))
|
||||
}
|
||||
|
||||
pub fn editor_cancel(&mut self) {
|
||||
if self.editor.as_ref().is_some_and(|editor| matches!(editor.kind, EditorKind::Filter)) {
|
||||
self.filter.clear();
|
||||
self.scroll = 0;
|
||||
}
|
||||
self.editor = None;
|
||||
self.editor_error = None;
|
||||
}
|
||||
|
||||
pub fn editor_unfocus(&mut self) {
|
||||
if self.editor.as_ref().is_some_and(|editor| matches!(editor.kind, EditorKind::Filter)) {
|
||||
self.editor = None;
|
||||
} else {
|
||||
self.editor_cancel();
|
||||
}
|
||||
}
|
||||
|
||||
pub fn editor_submit(&mut self) -> Result<(), String> {
|
||||
let Some(editor) = self.editor.take() else { return Ok(()) };
|
||||
let result = match editor.kind.clone() {
|
||||
EditorKind::Filter => {
|
||||
self.filter.clone_from(&editor.text);
|
||||
Ok(())
|
||||
},
|
||||
EditorKind::Path => {
|
||||
self.scroll = 0;
|
||||
self.selected = None;
|
||||
self.controller.refresh(editor.text.clone());
|
||||
Ok(())
|
||||
},
|
||||
EditorKind::CreateDirectory => self.controller.create_directory(editor.text.trim()),
|
||||
EditorKind::Rename(entry) => self.controller.rename(entry, editor.text.trim()),
|
||||
};
|
||||
if let Err(err) = result.as_ref() {
|
||||
self.editor_error = Some(err.clone());
|
||||
self.editor = Some(editor);
|
||||
} else {
|
||||
self.editor_error = None;
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
pub fn editor_view(&self) -> Option<(&str, bool, &'static str)> {
|
||||
let editor = self.editor.as_ref()?;
|
||||
let hint = match editor.kind {
|
||||
EditorKind::Filter => "筛选远端文件",
|
||||
EditorKind::Path => "输入远端路径",
|
||||
EditorKind::CreateDirectory => "新文件夹名称",
|
||||
EditorKind::Rename(_) => "输入新名称",
|
||||
};
|
||||
Some((&editor.text, editor.selection.is_selected(), hint))
|
||||
}
|
||||
|
||||
pub fn upload_paths(&self, paths: Vec<std::path::PathBuf>) {
|
||||
self.controller.upload_paths(paths);
|
||||
}
|
||||
|
||||
pub fn download(&self, entry: SftpEntry, directory: std::path::PathBuf) {
|
||||
self.controller.download(entry, directory);
|
||||
}
|
||||
|
||||
pub fn delete(&self, entry: SftpEntry) {
|
||||
self.controller.delete(entry);
|
||||
}
|
||||
|
||||
pub fn cancel_transfer(&self) {
|
||||
self.controller.cancel();
|
||||
}
|
||||
}
|
||||
|
||||
fn filtered_entries(entries: &[SftpEntry], query: &str) -> Vec<SftpEntry> {
|
||||
let query = query.trim().to_lowercase();
|
||||
entries
|
||||
.iter()
|
||||
.filter(|entry| query.is_empty() || entry.name.to_lowercase().contains(&query))
|
||||
.cloned()
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct SftpLayout {
|
||||
pub panel: (f32, f32, f32, f32),
|
||||
pub close: (f32, f32, f32, f32),
|
||||
pub path: (f32, f32, f32, f32),
|
||||
pub toolbar: [(f32, f32, f32, f32); 5],
|
||||
pub filter: (f32, f32, f32, f32),
|
||||
pub list_y: f32,
|
||||
pub row_h: f32,
|
||||
pub max_rows: usize,
|
||||
pub cancel: (f32, f32, f32, f32),
|
||||
}
|
||||
|
||||
pub fn layout(base: &side_panel::PanelLayout, scale: f32) -> SftpLayout {
|
||||
let s = |value: f32| value * scale;
|
||||
let (x, y, width, height) = base.panel;
|
||||
let close = (x + width - s(36.0), y + s(6.0), s(28.0), s(28.0));
|
||||
let path = (x + s(12.0), y + s(46.0), width - s(24.0), s(34.0));
|
||||
let toolbar_y = path.1 + path.3 + s(8.0);
|
||||
let gap = s(5.0);
|
||||
let button_w = (path.2 - gap * 4.0) / 5.0;
|
||||
let toolbar = std::array::from_fn(|index| {
|
||||
(path.0 + index as f32 * (button_w + gap), toolbar_y, button_w, s(30.0))
|
||||
});
|
||||
let filter = (path.0, toolbar_y + s(38.0), path.2, s(34.0));
|
||||
let list_y = filter.1 + filter.3 + s(10.0);
|
||||
let row_h = s(48.0);
|
||||
let cancel = (x + s(12.0), y + height - s(38.0), width - s(24.0), s(28.0));
|
||||
let max_rows = ((cancel.1 - s(8.0) - list_y) / row_h).max(0.0) as usize;
|
||||
SftpLayout { panel: base.panel, close, path, toolbar, filter, list_y, row_h, max_rows, cancel }
|
||||
}
|
||||
|
||||
fn contains(rect: (f32, f32, f32, f32), x: f32, y: f32) -> bool {
|
||||
x >= rect.0 && x < rect.0 + rect.2 && y >= rect.1 && y < rect.1 + rect.3
|
||||
}
|
||||
|
||||
pub fn hit_test(layout: &SftpLayout, working: bool, x: f32, y: f32) -> SftpHit {
|
||||
if !contains(layout.panel, x, y) {
|
||||
return SftpHit::None;
|
||||
}
|
||||
if contains(layout.close, x, y) {
|
||||
return SftpHit::Close;
|
||||
}
|
||||
if contains(layout.path, x, y) {
|
||||
return SftpHit::Path;
|
||||
}
|
||||
let toolbar_hits = [
|
||||
SftpHit::Up,
|
||||
SftpHit::Refresh,
|
||||
SftpHit::UploadFiles,
|
||||
SftpHit::UploadDirectory,
|
||||
SftpHit::NewDirectory,
|
||||
];
|
||||
for (rect, hit) in layout.toolbar.iter().zip(toolbar_hits) {
|
||||
if contains(*rect, x, y) {
|
||||
return hit;
|
||||
}
|
||||
}
|
||||
if contains(layout.filter, x, y) {
|
||||
return SftpHit::Filter;
|
||||
}
|
||||
if working && contains(layout.cancel, x, y) {
|
||||
return SftpHit::Cancel;
|
||||
}
|
||||
if y >= layout.list_y {
|
||||
let row = ((y - layout.list_y) / layout.row_h) as usize;
|
||||
if row < layout.max_rows {
|
||||
return SftpHit::Row(row);
|
||||
}
|
||||
}
|
||||
SftpHit::Inside
|
||||
}
|
||||
|
||||
pub(super) fn push_quads(
|
||||
panel: &SftpPanel,
|
||||
layout: &SftpLayout,
|
||||
theme: &NebulaTheme,
|
||||
quads: &mut Vec<UiQuad>,
|
||||
scale: f32,
|
||||
cell_w: f32,
|
||||
) {
|
||||
let s = |value: f32| value * scale;
|
||||
let palette = theme.palette();
|
||||
let skin = theme.skin();
|
||||
let radius = s(UI_CORNER_RADIUS_LOGICAL);
|
||||
let (x, y, width, height) = layout.panel;
|
||||
quads.push(UiQuad::solid(x, y, width, height, radius, palette.panel));
|
||||
|
||||
for (index, rect) in layout.toolbar.iter().enumerate() {
|
||||
let hit = [
|
||||
SftpHit::Up,
|
||||
SftpHit::Refresh,
|
||||
SftpHit::UploadFiles,
|
||||
SftpHit::UploadDirectory,
|
||||
SftpHit::NewDirectory,
|
||||
][index];
|
||||
quads.push(UiQuad::solid(rect.0, rect.1, rect.2, rect.3, s(6.0), skin.input));
|
||||
if panel.hover == hit {
|
||||
quads.push(UiQuad::solid(rect.0, rect.1, rect.2, rect.3, s(6.0), skin.hover));
|
||||
}
|
||||
}
|
||||
for (rect, focused) in [
|
||||
(
|
||||
layout.path,
|
||||
panel.editor.as_ref().is_some_and(|editor| matches!(editor.kind, EditorKind::Path)),
|
||||
),
|
||||
(
|
||||
layout.filter,
|
||||
panel.editor.as_ref().is_some_and(|editor| !matches!(editor.kind, EditorKind::Path)),
|
||||
),
|
||||
] {
|
||||
if focused {
|
||||
quads.push(UiQuad::solid(
|
||||
rect.0 - s(1.0),
|
||||
rect.1 - s(1.0),
|
||||
rect.2 + s(2.0),
|
||||
rect.3 + s(2.0),
|
||||
s(7.0),
|
||||
Rgba::new(skin.accent.r, skin.accent.g, skin.accent.b, 190),
|
||||
));
|
||||
}
|
||||
quads.push(UiQuad::solid(rect.0, rect.1, rect.2, rect.3, s(6.0), skin.input));
|
||||
}
|
||||
if let Some((text, true, _)) = panel.editor_view() {
|
||||
let rect = if panel
|
||||
.editor
|
||||
.as_ref()
|
||||
.is_some_and(|editor| matches!(editor.kind, EditorKind::Path))
|
||||
{
|
||||
layout.path
|
||||
} else {
|
||||
layout.filter
|
||||
};
|
||||
let columns: usize = text.chars().map(|character| character.width().unwrap_or(0)).sum();
|
||||
let width = (columns as f32 * cell_w).min(rect.2 - s(16.0));
|
||||
quads.push(UiQuad::solid(
|
||||
rect.0 + s(6.0),
|
||||
rect.1 + s(6.0),
|
||||
width + s(4.0),
|
||||
rect.3 - s(12.0),
|
||||
s(4.0),
|
||||
skin.accent_soft,
|
||||
));
|
||||
}
|
||||
if panel.hover == SftpHit::Close {
|
||||
quads.push(UiQuad::solid(
|
||||
layout.close.0,
|
||||
layout.close.1,
|
||||
layout.close.2,
|
||||
layout.close.3,
|
||||
s(6.0),
|
||||
skin.hover,
|
||||
));
|
||||
}
|
||||
|
||||
if let SftpHit::Row(index) = panel.hover {
|
||||
let row_y = layout.list_y + index as f32 * layout.row_h;
|
||||
quads.push(UiQuad::solid(
|
||||
x + s(8.0),
|
||||
row_y,
|
||||
width - s(16.0),
|
||||
layout.row_h - s(3.0),
|
||||
s(6.0),
|
||||
skin.hover,
|
||||
));
|
||||
}
|
||||
if let Some(selected) = panel.selected.as_ref() {
|
||||
if let Some(index) = panel
|
||||
.visible_entries()
|
||||
.iter()
|
||||
.skip(panel.scroll)
|
||||
.take(layout.max_rows)
|
||||
.position(|entry| &entry.path == selected)
|
||||
{
|
||||
let row_y = layout.list_y + index as f32 * layout.row_h;
|
||||
quads.push(UiQuad::solid(
|
||||
x + s(8.0),
|
||||
row_y,
|
||||
width - s(16.0),
|
||||
layout.row_h - s(3.0),
|
||||
s(6.0),
|
||||
skin.accent_soft,
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
let snapshot = panel.snapshot();
|
||||
if snapshot.phase == SftpPhase::Working {
|
||||
quads.push(UiQuad::solid(
|
||||
layout.cancel.0,
|
||||
layout.cancel.1,
|
||||
layout.cancel.2,
|
||||
layout.cancel.3,
|
||||
s(6.0),
|
||||
skin.input,
|
||||
));
|
||||
if let Some(progress) = snapshot.progress {
|
||||
quads.push(UiQuad::solid(
|
||||
layout.cancel.0,
|
||||
layout.cancel.1,
|
||||
layout.cancel.2 * progress.fraction(),
|
||||
layout.cancel.3,
|
||||
s(6.0),
|
||||
skin.accent_soft,
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn draw_text(
|
||||
panel: &SftpPanel,
|
||||
layout: &SftpLayout,
|
||||
theme: &NebulaTheme,
|
||||
renderer: &mut Renderer,
|
||||
glyph_cache: &mut GlyphCache,
|
||||
size: &SizeInfo,
|
||||
scale: f32,
|
||||
) {
|
||||
let s = |value: f32| value * scale;
|
||||
let skin = theme.skin();
|
||||
let cell_w = size.cell_width();
|
||||
let cell_h = size.cell_height();
|
||||
let snapshot = panel.snapshot();
|
||||
let text_y = |rect: (f32, f32, f32, f32)| rect.1 + (rect.3 - cell_h) * 0.5;
|
||||
|
||||
renderer.draw_chrome_text(
|
||||
size,
|
||||
layout.panel.0 + s(14.0),
|
||||
layout.panel.1 + s(10.0),
|
||||
skin.ink_strong,
|
||||
"SFTP",
|
||||
glyph_cache,
|
||||
);
|
||||
renderer.draw_chrome_text(
|
||||
size,
|
||||
layout.panel.0 + s(58.0),
|
||||
layout.panel.1 + s(10.0),
|
||||
skin.ink_dim,
|
||||
&super::truncate_tab_label(&snapshot.destination, 19),
|
||||
glyph_cache,
|
||||
);
|
||||
renderer.draw_chrome_text(
|
||||
size,
|
||||
layout.close.0 + (layout.close.2 - cell_w) * 0.5,
|
||||
text_y(layout.close),
|
||||
skin.ink_dim,
|
||||
"×",
|
||||
glyph_cache,
|
||||
);
|
||||
|
||||
let path_editor =
|
||||
panel.editor.as_ref().filter(|editor| matches!(editor.kind, EditorKind::Path));
|
||||
let path_text = path_editor.map(|editor| editor.text.as_str()).unwrap_or(&snapshot.path);
|
||||
let path_shown = if path_editor
|
||||
.is_some_and(|editor| !editor.selection.is_selected() && super::caret_blink_on())
|
||||
{
|
||||
format!("{path_text}▏")
|
||||
} else {
|
||||
path_text.to_owned()
|
||||
};
|
||||
renderer.draw_chrome_text(
|
||||
size,
|
||||
layout.path.0 + s(8.0),
|
||||
text_y(layout.path),
|
||||
skin.ink_strong,
|
||||
&super::truncate_tab_label(&path_shown, 30),
|
||||
glyph_cache,
|
||||
);
|
||||
|
||||
for (rect, label) in layout.toolbar.iter().zip(["上", "刷新", "传文", "传夹", "新建"])
|
||||
{
|
||||
let width = label.chars().count() as f32 * cell_w;
|
||||
renderer.draw_chrome_text(
|
||||
size,
|
||||
rect.0 + (rect.2 - width) * 0.5,
|
||||
text_y(*rect),
|
||||
skin.ink_dim,
|
||||
label,
|
||||
glyph_cache,
|
||||
);
|
||||
}
|
||||
|
||||
let filter_editor =
|
||||
panel.editor.as_ref().filter(|editor| !matches!(editor.kind, EditorKind::Path));
|
||||
let filter_text = filter_editor.map(|editor| editor.text.as_str()).unwrap_or(&panel.filter);
|
||||
let filter_hint = filter_editor.map_or("筛选文件", |editor| match editor.kind {
|
||||
EditorKind::Filter => "筛选文件",
|
||||
EditorKind::CreateDirectory => "新文件夹名称",
|
||||
EditorKind::Rename(_) => "输入新名称",
|
||||
EditorKind::Path => "远端路径",
|
||||
});
|
||||
let filter_shown = if filter_editor
|
||||
.is_some_and(|editor| !editor.selection.is_selected() && super::caret_blink_on())
|
||||
{
|
||||
format!("{filter_text}▏")
|
||||
} else {
|
||||
filter_text.to_owned()
|
||||
};
|
||||
renderer.draw_chrome_text(
|
||||
size,
|
||||
layout.filter.0 + s(8.0),
|
||||
text_y(layout.filter),
|
||||
if filter_text.is_empty() { skin.ink_faint } else { skin.ink_strong },
|
||||
if filter_shown.is_empty() { filter_hint } else { &filter_shown },
|
||||
glyph_cache,
|
||||
);
|
||||
|
||||
if let Some(error) = panel.editor_error.as_deref().or(snapshot.error.as_deref()) {
|
||||
renderer.draw_chrome_text(
|
||||
size,
|
||||
layout.panel.0 + s(14.0),
|
||||
layout.list_y,
|
||||
Rgb::new(skin.danger.r, skin.danger.g, skin.danger.b),
|
||||
&super::truncate_tab_label(error, 30),
|
||||
glyph_cache,
|
||||
);
|
||||
} else if matches!(snapshot.phase, SftpPhase::Connecting | SftpPhase::Loading) {
|
||||
renderer.draw_chrome_text(
|
||||
size,
|
||||
layout.panel.0 + s(14.0),
|
||||
layout.list_y,
|
||||
skin.ink_dim,
|
||||
"正在读取远端目录…",
|
||||
glyph_cache,
|
||||
);
|
||||
} else {
|
||||
for (index, entry) in
|
||||
panel.visible_entries().iter().skip(panel.scroll).take(layout.max_rows).enumerate()
|
||||
{
|
||||
let y = layout.list_y + index as f32 * layout.row_h + s(3.0);
|
||||
let icon = match entry.kind {
|
||||
SftpEntryKind::Directory => "▸",
|
||||
SftpEntryKind::Symlink => "↗",
|
||||
SftpEntryKind::File => "·",
|
||||
};
|
||||
renderer.draw_chrome_text(
|
||||
size,
|
||||
layout.panel.0 + s(14.0),
|
||||
y,
|
||||
skin.accent,
|
||||
icon,
|
||||
glyph_cache,
|
||||
);
|
||||
renderer.draw_chrome_text(
|
||||
size,
|
||||
layout.panel.0 + s(34.0),
|
||||
y,
|
||||
skin.ink_strong,
|
||||
&super::truncate_tab_label(&entry.name, 27),
|
||||
glyph_cache,
|
||||
);
|
||||
let details = format!(
|
||||
"{} {} {}",
|
||||
entry.permissions,
|
||||
format_size(entry.size),
|
||||
format_time(entry.modified)
|
||||
);
|
||||
renderer.draw_chrome_text(
|
||||
size,
|
||||
layout.panel.0 + s(34.0),
|
||||
y + s(18.0),
|
||||
skin.ink_faint,
|
||||
&super::truncate_tab_label(&details, 31),
|
||||
glyph_cache,
|
||||
);
|
||||
}
|
||||
if panel.visible_entries().is_empty() {
|
||||
renderer.draw_chrome_text(
|
||||
size,
|
||||
layout.panel.0 + s(14.0),
|
||||
layout.list_y,
|
||||
skin.ink_faint,
|
||||
if panel.filter.is_empty() { "此目录为空" } else { "没有匹配文件" },
|
||||
glyph_cache,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if snapshot.phase == SftpPhase::Working {
|
||||
let label = snapshot
|
||||
.progress
|
||||
.as_ref()
|
||||
.map_or("正在处理 · 点击取消", |progress| progress.label.as_str());
|
||||
renderer.draw_chrome_text(
|
||||
size,
|
||||
layout.cancel.0 + s(8.0),
|
||||
text_y(layout.cancel),
|
||||
skin.ink_strong,
|
||||
&super::truncate_tab_label(label, 24),
|
||||
glyph_cache,
|
||||
);
|
||||
renderer.draw_chrome_text(
|
||||
size,
|
||||
layout.cancel.0 + layout.cancel.2 - s(42.0),
|
||||
text_y(layout.cancel),
|
||||
skin.ink_dim,
|
||||
"取消",
|
||||
glyph_cache,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn format_size(bytes: u64) -> String {
|
||||
const UNITS: [&str; 4] = ["B", "KB", "MB", "GB"];
|
||||
let mut value = bytes as f64;
|
||||
let mut unit = 0usize;
|
||||
while value >= 1024.0 && unit + 1 < UNITS.len() {
|
||||
value /= 1024.0;
|
||||
unit += 1;
|
||||
}
|
||||
if unit == 0 { format!("{bytes} B") } else { format!("{value:.1} {}", UNITS[unit]) }
|
||||
}
|
||||
|
||||
fn format_time(timestamp: u64) -> String {
|
||||
if timestamp == 0 {
|
||||
return "-".to_owned();
|
||||
}
|
||||
chrono::DateTime::from_timestamp(timestamp as i64, 0)
|
||||
.map(|time| time.with_timezone(&chrono::Local).format("%Y-%m-%d %H:%M").to_string())
|
||||
.unwrap_or_else(|| "-".to_owned())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn entry(name: &str) -> SftpEntry {
|
||||
SftpEntry {
|
||||
name: name.to_owned(),
|
||||
path: format!("/{name}"),
|
||||
kind: SftpEntryKind::File,
|
||||
size: 0,
|
||||
modified: 0,
|
||||
permissions: "-rw-r--r--".to_owned(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn filter_is_case_insensitive_without_reordering_entries() {
|
||||
let entries = vec![entry("Alpha.txt"), entry("beta.log"), entry("alphabet.md")];
|
||||
let filtered = filtered_entries(&entries, "ALPHA");
|
||||
assert_eq!(
|
||||
filtered.iter().map(|entry| entry.name.as_str()).collect::<Vec<_>>(),
|
||||
vec!["Alpha.txt", "alphabet.md"]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn toolbar_hit_targets_share_the_drawn_geometry() {
|
||||
let base = side_panel::panel_layout(1000.0, 800.0, 0.0, 0.0, 1.0, 1.0);
|
||||
let layout = layout(&base, 1.0);
|
||||
let rect = layout.toolbar[2];
|
||||
assert_eq!(
|
||||
hit_test(&layout, false, rect.0 + rect.2 * 0.5, rect.1 + rect.3 * 0.5),
|
||||
SftpHit::UploadFiles
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -152,6 +152,10 @@ pub struct SidePanel {
|
||||
needs_refresh: bool,
|
||||
}
|
||||
|
||||
fn git_pull_args() -> Vec<String> {
|
||||
vec!["pull".into(), "--ff-only".into()]
|
||||
}
|
||||
|
||||
impl SidePanel {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
@@ -307,7 +311,7 @@ impl SidePanel {
|
||||
}
|
||||
}
|
||||
|
||||
// ---- git mutations (add / commit / push) ----
|
||||
// ---- git mutations (add / commit / pull / push) ----
|
||||
|
||||
/// Whether a git mutation is in flight (buttons gray out).
|
||||
pub fn op_running(&self) -> bool {
|
||||
@@ -426,6 +430,13 @@ impl SidePanel {
|
||||
}
|
||||
}
|
||||
|
||||
/// Pull only fast-forward updates, never creating an implicit merge commit.
|
||||
pub fn git_pull(&mut self) {
|
||||
if self.git.is_some() && !self.op_running() {
|
||||
self.spawn_git(git_pull_args());
|
||||
}
|
||||
}
|
||||
|
||||
/// Depth-first flatten of `dir` into `rows`, following `expanded`.
|
||||
fn flatten_dir(&mut self, dir: &Path, depth: usize) {
|
||||
if self.rows.len() >= MAX_ROWS {
|
||||
@@ -987,8 +998,7 @@ pub(super) fn push_quads(
|
||||
));
|
||||
quads.push(UiQuad::solid(sx, sy, sw, sh, radius, sk.input));
|
||||
if panel.commit_all_selected() && !panel.commit_msg.is_empty() {
|
||||
let columns: usize =
|
||||
panel.commit_msg.chars().map(|c| c.width().unwrap_or(0)).sum();
|
||||
let columns: usize = panel.commit_msg.chars().map(|c| c.width().unwrap_or(0)).sum();
|
||||
let selection_w = (columns as f32 * cell_w).min(sw - s(16.0));
|
||||
quads.push(UiQuad::solid(
|
||||
sx + s(6.0),
|
||||
@@ -1000,13 +1010,13 @@ pub(super) fn push_quads(
|
||||
));
|
||||
}
|
||||
} else {
|
||||
for (bx, bw) in git_button_rects(sx, sw, s(8.0)) {
|
||||
for (bx, bw) in git_button_rects(sx, sw, s(6.0)) {
|
||||
quads.push(UiQuad::solid(bx, sy, bw, sh, radius, sk.input));
|
||||
}
|
||||
// Hovered action button brightens (hover wash over the pill).
|
||||
if panel.hover == PanelHit::Search {
|
||||
let (hx, _) = panel.hover_pos;
|
||||
for (bx, bw) in git_button_rects(sx, sw, s(8.0)) {
|
||||
for (bx, bw) in git_button_rects(sx, sw, s(6.0)) {
|
||||
if hx >= bx && hx < bx + bw {
|
||||
quads.push(UiQuad::solid(bx, sy, bw, sh, radius, sk.hover));
|
||||
}
|
||||
@@ -1016,11 +1026,10 @@ pub(super) fn push_quads(
|
||||
}
|
||||
}
|
||||
|
||||
/// The three git action buttons' `(x, w)` spans inside the strip at `sx..sx+sw`
|
||||
/// (暂存 / 提交 / 推送). Shared by quads, text and hit-testing.
|
||||
pub fn git_button_rects(sx: f32, sw: f32, gap: f32) -> [(f32, f32); 3] {
|
||||
let bw = (sw - 2.0 * gap) / 3.0;
|
||||
[(sx, bw), (sx + bw + gap, bw), (sx + 2.0 * (bw + gap), bw)]
|
||||
/// The four git action buttons' `(x, w)` spans inside `sx..sx+sw`.
|
||||
pub fn git_button_rects(sx: f32, sw: f32, gap: f32) -> [(f32, f32); 4] {
|
||||
let bw = (sw - 3.0 * gap) / 4.0;
|
||||
[(sx, bw), (sx + bw + gap, bw), (sx + 2.0 * (bw + gap), bw), (sx + 3.0 * (bw + gap), bw)]
|
||||
}
|
||||
|
||||
/// Draw the drawer's text: header tabs, the summary line (cwd tail or the
|
||||
@@ -1233,19 +1242,21 @@ pub(super) fn draw_text(
|
||||
let busy = panel.op_running();
|
||||
let stage_on = !busy && !git.unstaged.is_empty();
|
||||
let commit_on = !busy && !git.staged.is_empty();
|
||||
let pull_on = !busy;
|
||||
let push_on = !busy && git.ahead > 0;
|
||||
let push_label = if git.ahead > 0 {
|
||||
format!("推送 ↑{}", git.ahead)
|
||||
} else {
|
||||
"推送".to_string()
|
||||
};
|
||||
let labels: [(&str, bool); 3] = [
|
||||
(if busy { "…" } else { "暂存全部" }, stage_on),
|
||||
let labels: [(&str, bool); 4] = [
|
||||
(if busy { "…" } else { "暂存" }, stage_on),
|
||||
("提交", commit_on),
|
||||
("拉取", pull_on),
|
||||
(&push_label, push_on),
|
||||
];
|
||||
for ((bx, bw), (label, enabled)) in
|
||||
git_button_rects(sx, sw, s(8.0)).into_iter().zip(labels)
|
||||
git_button_rects(sx, sw, s(6.0)).into_iter().zip(labels)
|
||||
{
|
||||
let hovered = panel.hover == PanelHit::Search
|
||||
&& panel.hover_pos.0 >= bx
|
||||
@@ -1395,4 +1406,18 @@ mod tests {
|
||||
panel.commit_input("new commit");
|
||||
assert_eq!(panel.commit_msg, "new commit");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn git_action_strip_has_four_equal_buttons() {
|
||||
let rects = git_button_rects(10.0, 430.0, 10.0);
|
||||
assert_eq!(rects.len(), 4);
|
||||
assert!(rects.windows(2).all(|pair| (pair[0].1 - pair[1].1).abs() < f32::EPSILON));
|
||||
let last = rects.last().expect("at least one git action");
|
||||
assert!((last.0 + last.1 - 440.0).abs() < f32::EPSILON);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn git_pull_is_fast_forward_only() {
|
||||
assert_eq!(git_pull_args(), vec!["pull", "--ff-only"]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,696 @@
|
||||
use unicode_width::UnicodeWidthChar;
|
||||
|
||||
use super::ssh_ui::auth_sections;
|
||||
use super::*;
|
||||
use crate::ssh_profiles::SshAuthMode;
|
||||
|
||||
type Rect = (f32, f32, f32, f32);
|
||||
|
||||
impl Display {
|
||||
pub(super) fn draw_ssh_editor_modal(&mut self) {
|
||||
self.nebula_ui_anims
|
||||
.ssh_editor
|
||||
.step_with_speed(if self.nebula_ssh_editor_open { 1.0 } else { 0.0 }, 26.0);
|
||||
let progress = self.nebula_ui_anims.ssh_editor.value().clamp(0.0, 1.0);
|
||||
if !self.nebula_ssh_editor_open && progress <= 0.004 {
|
||||
self.nebula_ssh_editor = None;
|
||||
self.nebula_ssh_editor_rects = None;
|
||||
self.nebula_ssh_editor_hover = SshEditorHit::None;
|
||||
return;
|
||||
}
|
||||
let Some(editor) = self.nebula_ssh_editor.clone() else {
|
||||
self.nebula_ssh_editor_rects = None;
|
||||
return;
|
||||
};
|
||||
|
||||
let size = self.size_info;
|
||||
let scale = self.window.scale_factor as f32;
|
||||
let s = |value: f32| value * scale;
|
||||
let skin = self.nebula_theme.skin();
|
||||
let accent = Rgba::new(skin.accent.r, skin.accent.g, skin.accent.b, 255);
|
||||
let cell_h = size.cell_height();
|
||||
let cell_w = size.cell_width();
|
||||
let text_width = |text: &str| -> f32 {
|
||||
text.chars().map(|c| c.width().unwrap_or(1)).sum::<usize>() as f32 * cell_w
|
||||
};
|
||||
let (show_password, show_keys) = auth_sections(editor.auth);
|
||||
|
||||
let box_w = s(700.0).min(size.width() - s(32.0));
|
||||
let desired_h = if show_password && show_keys {
|
||||
610.0
|
||||
} else if show_keys {
|
||||
520.0
|
||||
} else if show_password {
|
||||
440.0
|
||||
} else {
|
||||
370.0
|
||||
};
|
||||
let box_h = s(desired_h).min(size.height() - s(32.0));
|
||||
let bx = (size.width() - box_w) * 0.5;
|
||||
let resting_y = (size.height() - box_h) * 0.5;
|
||||
let by = resting_y - (1.0 - progress) * s(14.0);
|
||||
let pad = s(28.0);
|
||||
let field_h = s(42.0);
|
||||
let field_w = box_w - pad * 2.0;
|
||||
let destination = (bx + pad, by + s(76.0), field_w, field_h);
|
||||
let auth_y = destination.1 + destination.3 + s(42.0);
|
||||
let auth_gap = s(6.0);
|
||||
let auth_w = (field_w - auth_gap * 4.0) / 5.0;
|
||||
let auth_modes = [
|
||||
SshAuthMode::Auto,
|
||||
SshAuthMode::Password,
|
||||
SshAuthMode::PublicKey,
|
||||
SshAuthMode::Agent,
|
||||
SshAuthMode::KeyboardInteractive,
|
||||
];
|
||||
let auth = std::array::from_fn(|index| {
|
||||
(
|
||||
auth_modes[index],
|
||||
(destination.0 + index as f32 * (auth_w + auth_gap), auth_y, auth_w, s(34.0)),
|
||||
)
|
||||
});
|
||||
let content_y = auth_y + s(76.0);
|
||||
let zero = (0.0, 0.0, 0.0, 0.0);
|
||||
let password =
|
||||
if show_password { (destination.0, content_y, field_w, field_h) } else { zero };
|
||||
let password_toggle = if show_password {
|
||||
(password.0 + password.2 - s(38.0), password.1 + s(4.0), s(34.0), password.3 - s(8.0))
|
||||
} else {
|
||||
zero
|
||||
};
|
||||
let save_label = "保存密码到 Windows 凭据管理器";
|
||||
let save_toggle = if show_password {
|
||||
(
|
||||
destination.0,
|
||||
password.1 + password.3 + s(12.0),
|
||||
(s(28.0) + text_width(save_label)).min(field_w),
|
||||
s(28.0),
|
||||
)
|
||||
} else {
|
||||
zero
|
||||
};
|
||||
let save_checkbox = if show_password {
|
||||
(save_toggle.0, save_toggle.1 + s(5.0), s(18.0), s(18.0))
|
||||
} else {
|
||||
zero
|
||||
};
|
||||
|
||||
let key_header_y = if show_password { save_toggle.1 + s(56.0) } else { content_y };
|
||||
let add_private_key = if show_keys {
|
||||
(destination.0 + field_w - s(150.0), key_header_y - s(8.0), s(150.0), s(32.0))
|
||||
} else {
|
||||
zero
|
||||
};
|
||||
let key_rows_y = key_header_y + s(30.0);
|
||||
let footer_y = by + box_h - s(58.0);
|
||||
let available_rows =
|
||||
(((footer_y - s(22.0) - key_rows_y) / s(36.0)).floor() as isize).max(1) as usize;
|
||||
let visible_start = editor.private_keys.len().saturating_sub(available_rows);
|
||||
let visible_keys = if show_keys {
|
||||
editor
|
||||
.private_keys
|
||||
.iter()
|
||||
.enumerate()
|
||||
.skip(visible_start)
|
||||
.take(available_rows)
|
||||
.map(|(index, _)| {
|
||||
let row = (
|
||||
destination.0,
|
||||
key_rows_y + (index - visible_start) as f32 * s(36.0),
|
||||
field_w,
|
||||
s(32.0),
|
||||
);
|
||||
let remove = (row.0 + row.2 - s(34.0), row.1, s(34.0), row.3);
|
||||
(index, row, remove)
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
|
||||
let primary_label = "保存 Enter";
|
||||
let cancel_label = "取消 Esc";
|
||||
let button_pad = s(16.0);
|
||||
let primary_w = s(112.0).max(text_width(primary_label) + button_pad * 2.0);
|
||||
let cancel_w = s(112.0).max(text_width(cancel_label) + button_pad * 2.0);
|
||||
let primary = (bx + box_w - pad - primary_w, footer_y, primary_w, s(36.0));
|
||||
let cancel = (primary.0 - s(12.0) - cancel_w, primary.1, cancel_w, s(36.0));
|
||||
|
||||
self.nebula_ssh_editor_rects = Some(SshEditorRects {
|
||||
destination,
|
||||
password,
|
||||
password_toggle,
|
||||
auth,
|
||||
add_private_key,
|
||||
private_key_rows: visible_keys
|
||||
.iter()
|
||||
.map(|(index, _, remove)| (*index, *remove))
|
||||
.collect(),
|
||||
save_checkbox,
|
||||
save_toggle,
|
||||
primary,
|
||||
cancel,
|
||||
});
|
||||
|
||||
let mut quads = vec![
|
||||
UiQuad::solid(
|
||||
0.0,
|
||||
0.0,
|
||||
size.width(),
|
||||
size.height(),
|
||||
0.0,
|
||||
Rgba::new(0, 0, 0, (170.0 * progress).round() as u8),
|
||||
),
|
||||
UiQuad::solid(
|
||||
bx - s(1.0),
|
||||
by - s(1.0),
|
||||
box_w + s(2.0),
|
||||
box_h + s(2.0),
|
||||
s(13.0),
|
||||
skin.hairline,
|
||||
),
|
||||
UiQuad::solid(bx, by, box_w, box_h, s(12.0), skin.panel),
|
||||
];
|
||||
input_quads(
|
||||
&mut quads,
|
||||
destination,
|
||||
editor.field == SshEditorField::Destination,
|
||||
self.nebula_ssh_editor_hover == SshEditorHit::Destination,
|
||||
accent,
|
||||
&skin,
|
||||
scale,
|
||||
);
|
||||
if show_password {
|
||||
input_quads(
|
||||
&mut quads,
|
||||
password,
|
||||
editor.field == SshEditorField::Password,
|
||||
self.nebula_ssh_editor_hover == SshEditorHit::Password,
|
||||
accent,
|
||||
&skin,
|
||||
scale,
|
||||
);
|
||||
}
|
||||
for (mode, rect) in auth {
|
||||
let active = editor.auth == mode;
|
||||
let hovered = self.nebula_ssh_editor_hover == SshEditorHit::Auth(mode);
|
||||
quads.push(UiQuad::solid(
|
||||
rect.0,
|
||||
rect.1,
|
||||
rect.2,
|
||||
rect.3,
|
||||
s(7.0),
|
||||
if active {
|
||||
skin.accent_soft
|
||||
} else if hovered {
|
||||
skin.hover
|
||||
} else {
|
||||
skin.input
|
||||
},
|
||||
));
|
||||
if active {
|
||||
quads.push(UiQuad::solid(
|
||||
rect.0,
|
||||
rect.1 + rect.3 - s(2.0),
|
||||
rect.2,
|
||||
s(2.0),
|
||||
0.0,
|
||||
accent,
|
||||
));
|
||||
}
|
||||
}
|
||||
if show_password {
|
||||
if self.nebula_ssh_editor_hover == SshEditorHit::PasswordToggle {
|
||||
quads.push(UiQuad::solid(
|
||||
password_toggle.0,
|
||||
password_toggle.1,
|
||||
password_toggle.2,
|
||||
password_toggle.3,
|
||||
s(6.0),
|
||||
skin.hover,
|
||||
));
|
||||
}
|
||||
quads.push(UiQuad::solid(
|
||||
save_checkbox.0 - s(1.0),
|
||||
save_checkbox.1 - s(1.0),
|
||||
save_checkbox.2 + s(2.0),
|
||||
save_checkbox.3 + s(2.0),
|
||||
s(5.0),
|
||||
skin.hairline,
|
||||
));
|
||||
quads.push(UiQuad::solid(
|
||||
save_checkbox.0,
|
||||
save_checkbox.1,
|
||||
save_checkbox.2,
|
||||
save_checkbox.3,
|
||||
s(4.0),
|
||||
skin.input,
|
||||
));
|
||||
}
|
||||
if show_keys {
|
||||
quads.push(UiQuad::solid(
|
||||
add_private_key.0,
|
||||
add_private_key.1,
|
||||
add_private_key.2,
|
||||
add_private_key.3,
|
||||
s(7.0),
|
||||
if self.nebula_ssh_editor_hover == SshEditorHit::AddPrivateKey {
|
||||
skin.hover
|
||||
} else {
|
||||
skin.input
|
||||
},
|
||||
));
|
||||
for (index, row, remove) in &visible_keys {
|
||||
quads.push(UiQuad::solid(row.0, row.1, row.2, row.3, s(6.0), skin.input));
|
||||
if self.nebula_ssh_editor_hover == SshEditorHit::RemovePrivateKey(*index) {
|
||||
quads.push(UiQuad::solid(
|
||||
remove.0,
|
||||
remove.1,
|
||||
remove.2,
|
||||
remove.3,
|
||||
s(6.0),
|
||||
skin.hover,
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
button_quads(
|
||||
&mut quads,
|
||||
cancel,
|
||||
primary,
|
||||
self.nebula_ssh_editor_hover,
|
||||
accent,
|
||||
&skin,
|
||||
scale,
|
||||
);
|
||||
|
||||
let caret_field = if editor.field == SshEditorField::Password && show_password {
|
||||
SshEditorField::Password
|
||||
} else {
|
||||
SshEditorField::Destination
|
||||
};
|
||||
let (caret_rect, caret_columns, selected) = match caret_field {
|
||||
SshEditorField::Destination => (
|
||||
destination,
|
||||
editor.destination.chars().count(),
|
||||
editor.destination_selection.is_selected(),
|
||||
),
|
||||
SshEditorField::Password => {
|
||||
(password, editor.password.chars().count(), editor.password_selection.is_selected())
|
||||
},
|
||||
};
|
||||
draw_caret_quad(
|
||||
&mut quads,
|
||||
caret_rect,
|
||||
caret_columns,
|
||||
selected,
|
||||
caret_field == SshEditorField::Password,
|
||||
cell_w,
|
||||
scale,
|
||||
&skin,
|
||||
);
|
||||
self.renderer.draw_ui(&size, &quads);
|
||||
|
||||
let glyph_cache = &mut self.glyph_cache;
|
||||
self.renderer.draw_doc_text(
|
||||
&size,
|
||||
bx + pad,
|
||||
by + s(20.0),
|
||||
1.35,
|
||||
skin.ink_strong,
|
||||
Flags::empty(),
|
||||
if editor.original_destination.is_some() {
|
||||
"编辑 SSH 主机"
|
||||
} else {
|
||||
"添加 SSH 主机"
|
||||
},
|
||||
glyph_cache,
|
||||
);
|
||||
self.renderer.draw_chrome_text(
|
||||
&size,
|
||||
destination.0,
|
||||
destination.1 - cell_h - s(5.0),
|
||||
if editor.error.is_some() {
|
||||
if skin.is_light { Rgb::new(207, 34, 46) } else { Rgb::new(248, 81, 73) }
|
||||
} else {
|
||||
skin.ink_dim
|
||||
},
|
||||
editor.error.as_deref().unwrap_or("用户名@地址(非 22 端口可用 ssh://user@host:port)"),
|
||||
glyph_cache,
|
||||
);
|
||||
self.renderer.draw_chrome_text(
|
||||
&size,
|
||||
destination.0 + s(12.0),
|
||||
destination.1 + (field_h - cell_h) / 2.0,
|
||||
if editor.destination.is_empty() { skin.ink_faint } else { skin.ink },
|
||||
if editor.destination.is_empty() { "user@example.com" } else { &editor.destination },
|
||||
glyph_cache,
|
||||
);
|
||||
self.renderer.draw_chrome_text(
|
||||
&size,
|
||||
destination.0,
|
||||
auth_y - cell_h - s(5.0),
|
||||
skin.ink_dim,
|
||||
"认证方式",
|
||||
glyph_cache,
|
||||
);
|
||||
let auth_labels = ["自动", "密码", "密钥", "Agent", "交互式"];
|
||||
for ((_, rect), label) in auth.iter().zip(auth_labels) {
|
||||
self.renderer.draw_chrome_text(
|
||||
&size,
|
||||
rect.0 + (rect.2 - text_width(label)) * 0.5,
|
||||
rect.1 + (rect.3 - cell_h) / 2.0,
|
||||
if editor.auth == auth_mode_for_label(label) { skin.ink_strong } else { skin.ink },
|
||||
label,
|
||||
glyph_cache,
|
||||
);
|
||||
}
|
||||
|
||||
if show_password {
|
||||
draw_password_text(
|
||||
&mut self.renderer,
|
||||
glyph_cache,
|
||||
&size,
|
||||
&editor,
|
||||
password,
|
||||
password_toggle,
|
||||
save_toggle,
|
||||
save_checkbox,
|
||||
save_label,
|
||||
field_h,
|
||||
cell_h,
|
||||
cell_w,
|
||||
scale,
|
||||
&skin,
|
||||
self.nebula_ssh_editor_hover,
|
||||
);
|
||||
}
|
||||
if show_keys {
|
||||
self.renderer.draw_chrome_text(
|
||||
&size,
|
||||
destination.0,
|
||||
key_header_y,
|
||||
skin.ink_dim,
|
||||
"私钥",
|
||||
glyph_cache,
|
||||
);
|
||||
self.renderer.draw_chrome_text(
|
||||
&size,
|
||||
add_private_key.0 + (add_private_key.2 - text_width("+ 添加私钥")) * 0.5,
|
||||
add_private_key.1 + (add_private_key.3 - cell_h) / 2.0,
|
||||
skin.ink,
|
||||
"+ 添加私钥",
|
||||
glyph_cache,
|
||||
);
|
||||
if editor.private_keys.is_empty() {
|
||||
self.renderer.draw_chrome_text(
|
||||
&size,
|
||||
destination.0,
|
||||
key_rows_y + s(7.0),
|
||||
skin.ink_faint,
|
||||
"未指定;将使用 IdentityFile 和默认 id_* 私钥",
|
||||
glyph_cache,
|
||||
);
|
||||
}
|
||||
for (index, row, remove) in &visible_keys {
|
||||
let label = path_tail(&editor.private_keys[*index], 64);
|
||||
self.renderer.draw_chrome_text(
|
||||
&size,
|
||||
row.0 + s(10.0),
|
||||
row.1 + (row.3 - cell_h) / 2.0,
|
||||
skin.ink,
|
||||
&label,
|
||||
glyph_cache,
|
||||
);
|
||||
self.renderer.draw_chrome_text(
|
||||
&size,
|
||||
remove.0 + (remove.2 - text_width("×")) * 0.5,
|
||||
remove.1 + (remove.3 - cell_h) / 2.0,
|
||||
skin.icon,
|
||||
"×",
|
||||
glyph_cache,
|
||||
);
|
||||
}
|
||||
} else if editor.auth == SshAuthMode::Agent {
|
||||
self.renderer.draw_chrome_text(
|
||||
&size,
|
||||
destination.0,
|
||||
content_y,
|
||||
skin.ink_dim,
|
||||
"仅使用 Windows OpenSSH Agent 与 Pageant,不回退密码。",
|
||||
glyph_cache,
|
||||
);
|
||||
} else if editor.auth == SshAuthMode::KeyboardInteractive {
|
||||
self.renderer.draw_chrome_text(
|
||||
&size,
|
||||
destination.0,
|
||||
content_y,
|
||||
skin.ink_dim,
|
||||
"仅响应服务器的 keyboard-interactive / MFA 提示。",
|
||||
glyph_cache,
|
||||
);
|
||||
}
|
||||
draw_button_text(
|
||||
&mut self.renderer,
|
||||
glyph_cache,
|
||||
&size,
|
||||
cancel,
|
||||
primary,
|
||||
cancel_label,
|
||||
primary_label,
|
||||
cell_h,
|
||||
&text_width,
|
||||
&skin,
|
||||
);
|
||||
|
||||
if self.nebula_ui_anims.ssh_editor.animating_to(if self.nebula_ssh_editor_open {
|
||||
1.0
|
||||
} else {
|
||||
0.0
|
||||
}) {
|
||||
self.pending_update.dirty = true;
|
||||
self.window.request_redraw();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn auth_mode_for_label(label: &str) -> SshAuthMode {
|
||||
match label {
|
||||
"密码" => SshAuthMode::Password,
|
||||
"密钥" => SshAuthMode::PublicKey,
|
||||
"Agent" => SshAuthMode::Agent,
|
||||
"交互式" => SshAuthMode::KeyboardInteractive,
|
||||
_ => SshAuthMode::Auto,
|
||||
}
|
||||
}
|
||||
|
||||
fn input_quads(
|
||||
quads: &mut Vec<UiQuad>,
|
||||
rect: Rect,
|
||||
active: bool,
|
||||
hovered: bool,
|
||||
accent: Rgba,
|
||||
skin: &theme::Skin,
|
||||
scale: f32,
|
||||
) {
|
||||
let s = |value: f32| value * scale;
|
||||
quads.push(UiQuad::solid(
|
||||
rect.0 - s(1.0),
|
||||
rect.1 - s(1.0),
|
||||
rect.2 + s(2.0),
|
||||
rect.3 + s(2.0),
|
||||
s(7.0),
|
||||
if active {
|
||||
accent
|
||||
} else if hovered {
|
||||
skin.hover_strong
|
||||
} else {
|
||||
skin.hairline
|
||||
},
|
||||
));
|
||||
quads.push(UiQuad::solid(rect.0, rect.1, rect.2, rect.3, s(6.0), skin.input));
|
||||
}
|
||||
|
||||
fn button_quads(
|
||||
quads: &mut Vec<UiQuad>,
|
||||
cancel: Rect,
|
||||
primary: Rect,
|
||||
hover: SshEditorHit,
|
||||
accent: Rgba,
|
||||
skin: &theme::Skin,
|
||||
scale: f32,
|
||||
) {
|
||||
let s = |value: f32| value * scale;
|
||||
for (rect, edge) in [(cancel, skin.hairline), (primary, accent)] {
|
||||
quads.push(UiQuad::solid(
|
||||
rect.0 - s(1.0),
|
||||
rect.1 - s(1.0),
|
||||
rect.2 + s(2.0),
|
||||
rect.3 + s(2.0),
|
||||
s(9.0),
|
||||
edge,
|
||||
));
|
||||
}
|
||||
quads.push(UiQuad::solid(cancel.0, cancel.1, cancel.2, cancel.3, s(8.0), skin.input));
|
||||
quads.push(UiQuad::solid(primary.0, primary.1, primary.2, primary.3, s(8.0), accent));
|
||||
if hover == SshEditorHit::Cancel {
|
||||
quads.push(UiQuad::solid(cancel.0, cancel.1, cancel.2, cancel.3, s(8.0), skin.hover));
|
||||
}
|
||||
if hover == SshEditorHit::Primary {
|
||||
quads.push(UiQuad::solid(
|
||||
primary.0,
|
||||
primary.1,
|
||||
primary.2,
|
||||
primary.3,
|
||||
s(8.0),
|
||||
skin.hover_strong,
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
fn draw_caret_quad(
|
||||
quads: &mut Vec<UiQuad>,
|
||||
rect: Rect,
|
||||
columns: usize,
|
||||
selected: bool,
|
||||
password: bool,
|
||||
cell_w: f32,
|
||||
scale: f32,
|
||||
skin: &theme::Skin,
|
||||
) {
|
||||
let s = |value: f32| value * scale;
|
||||
let right_pad = if password { s(48.0) } else { s(10.0) };
|
||||
if selected && columns > 0 {
|
||||
let width = (columns as f32 * cell_w).min(rect.2 - s(12.0) - right_pad);
|
||||
quads.push(UiQuad::solid(
|
||||
rect.0 + s(10.0),
|
||||
rect.1 + s(7.0),
|
||||
width + s(4.0),
|
||||
rect.3 - s(14.0),
|
||||
s(4.0),
|
||||
skin.accent_soft,
|
||||
));
|
||||
} else {
|
||||
let x = (rect.0 + s(12.0) + columns as f32 * cell_w).min(rect.0 + rect.2 - right_pad);
|
||||
quads.push(UiQuad::solid(
|
||||
x,
|
||||
rect.1 + s(10.0),
|
||||
s(1.5).max(1.0),
|
||||
rect.3 - s(20.0),
|
||||
0.0,
|
||||
Rgba::new(skin.ink_strong.r, skin.ink_strong.g, skin.ink_strong.b, 235),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn draw_password_text(
|
||||
renderer: &mut Renderer,
|
||||
glyph_cache: &mut GlyphCache,
|
||||
size: &SizeInfo,
|
||||
editor: &SshHostEditor,
|
||||
password: Rect,
|
||||
password_toggle: Rect,
|
||||
save_toggle: Rect,
|
||||
save_checkbox: Rect,
|
||||
save_label: &str,
|
||||
field_h: f32,
|
||||
cell_h: f32,
|
||||
cell_w: f32,
|
||||
scale: f32,
|
||||
skin: &theme::Skin,
|
||||
hover: SshEditorHit,
|
||||
) {
|
||||
let s = |value: f32| value * scale;
|
||||
renderer.draw_chrome_text(
|
||||
size,
|
||||
password.0,
|
||||
password.1 - cell_h - s(5.0),
|
||||
skin.ink_dim,
|
||||
"密码",
|
||||
glyph_cache,
|
||||
);
|
||||
let masked = if editor.password.is_empty() {
|
||||
"连接时询问".to_owned()
|
||||
} else if editor.show_password {
|
||||
editor.password.clone()
|
||||
} else {
|
||||
"•".repeat(editor.password.chars().count())
|
||||
};
|
||||
renderer.draw_chrome_text(
|
||||
size,
|
||||
password.0 + s(12.0),
|
||||
password.1 + (field_h - cell_h) / 2.0,
|
||||
if editor.password.is_empty() { skin.ink_faint } else { skin.ink },
|
||||
&masked,
|
||||
glyph_cache,
|
||||
);
|
||||
let eye = if editor.show_password { "" } else { "" };
|
||||
renderer.draw_chrome_text(
|
||||
size,
|
||||
password_toggle.0 + (password_toggle.2 - cell_w) * 0.5,
|
||||
password_toggle.1 + (password_toggle.3 - cell_h) / 2.0,
|
||||
if hover == SshEditorHit::PasswordToggle { skin.icon_hover } else { skin.icon },
|
||||
eye,
|
||||
glyph_cache,
|
||||
);
|
||||
renderer.draw_chrome_text(
|
||||
size,
|
||||
save_toggle.0 + s(28.0),
|
||||
save_toggle.1 + (save_toggle.3 - cell_h) / 2.0,
|
||||
skin.ink,
|
||||
save_label,
|
||||
glyph_cache,
|
||||
);
|
||||
if editor.save_password {
|
||||
renderer.draw_chrome_text(
|
||||
size,
|
||||
save_checkbox.0 + (save_checkbox.2 - cell_w) * 0.5,
|
||||
save_checkbox.1 + (save_checkbox.3 - cell_h) / 2.0,
|
||||
skin.icon_hover,
|
||||
"",
|
||||
glyph_cache,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn draw_button_text(
|
||||
renderer: &mut Renderer,
|
||||
glyph_cache: &mut GlyphCache,
|
||||
size: &SizeInfo,
|
||||
cancel: Rect,
|
||||
primary: Rect,
|
||||
cancel_label: &str,
|
||||
primary_label: &str,
|
||||
cell_h: f32,
|
||||
text_width: &impl Fn(&str) -> f32,
|
||||
skin: &theme::Skin,
|
||||
) {
|
||||
renderer.draw_chrome_text(
|
||||
size,
|
||||
cancel.0 + (cancel.2 - text_width(cancel_label)) * 0.5,
|
||||
cancel.1 + (cancel.3 - cell_h) / 2.0,
|
||||
skin.ink,
|
||||
cancel_label,
|
||||
glyph_cache,
|
||||
);
|
||||
renderer.draw_chrome_text(
|
||||
size,
|
||||
primary.0 + (primary.2 - text_width(primary_label)) * 0.5,
|
||||
primary.1 + (primary.3 - cell_h) / 2.0,
|
||||
skin.ink_on_accent,
|
||||
primary_label,
|
||||
glyph_cache,
|
||||
);
|
||||
}
|
||||
|
||||
fn path_tail(path: &std::path::Path, max_chars: usize) -> String {
|
||||
let value = path.to_string_lossy();
|
||||
let count = value.chars().count();
|
||||
if count <= max_chars {
|
||||
value.into_owned()
|
||||
} else {
|
||||
format!("…{}", value.chars().skip(count - max_chars + 1).collect::<String>())
|
||||
}
|
||||
}
|
||||
@@ -5,10 +5,31 @@
|
||||
//! disarm it.
|
||||
|
||||
use super::text_input::SelectAllState;
|
||||
use crate::ssh_profiles::SshAuthMode;
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// How long a destructive SSH action stays reversible in the in-app bar.
|
||||
pub const SSH_DELETE_UNDO_DURATION: std::time::Duration = std::time::Duration::from_secs(8);
|
||||
|
||||
pub fn auth_sections(mode: SshAuthMode) -> (bool, bool) {
|
||||
match mode {
|
||||
SshAuthMode::Auto => (true, true),
|
||||
SshAuthMode::Password => (true, false),
|
||||
SshAuthMode::PublicKey => (false, true),
|
||||
SshAuthMode::Agent | SshAuthMode::KeyboardInteractive => (false, false),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn push_private_key(keys: &mut Vec<PathBuf>, path: PathBuf) -> bool {
|
||||
let normalized = path.to_string_lossy();
|
||||
if keys.iter().any(|existing| existing.to_string_lossy().eq_ignore_ascii_case(&normalized)) {
|
||||
false
|
||||
} else {
|
||||
keys.push(path);
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub(super) struct SshDeleteUndo {
|
||||
pub(super) host: String,
|
||||
@@ -25,6 +46,11 @@ impl Drop for SshDeleteUndo {
|
||||
#[cfg(windows)]
|
||||
if self.delete_credential_on_drop {
|
||||
let _ = crate::ssh_credentials::forget_password(&self.host);
|
||||
let path = super::nebula_data_dir().join("ssh_profiles.json");
|
||||
if let Ok(mut profiles) = crate::ssh_profiles::SshProfiles::load(&path) {
|
||||
profiles.remove(&self.host);
|
||||
let _ = profiles.save(&path);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -41,6 +67,9 @@ pub enum SshEditorHit {
|
||||
Destination,
|
||||
Password,
|
||||
PasswordToggle,
|
||||
Auth(SshAuthMode),
|
||||
AddPrivateKey,
|
||||
RemovePrivateKey(usize),
|
||||
SaveToggleBox,
|
||||
SaveToggleLabel,
|
||||
Primary,
|
||||
@@ -55,19 +84,49 @@ pub struct SshHostEditor {
|
||||
pub password: String,
|
||||
pub save_password: bool,
|
||||
pub show_password: bool,
|
||||
pub auth: SshAuthMode,
|
||||
pub private_keys: Vec<PathBuf>,
|
||||
pub field: SshEditorField,
|
||||
pub error: Option<String>,
|
||||
pub(super) destination_selection: SelectAllState,
|
||||
pub(super) password_selection: SelectAllState,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct SshEditorRects {
|
||||
pub destination: (f32, f32, f32, f32),
|
||||
pub password: (f32, f32, f32, f32),
|
||||
pub password_toggle: (f32, f32, f32, f32),
|
||||
pub auth: [(SshAuthMode, (f32, f32, f32, f32)); 5],
|
||||
pub add_private_key: (f32, f32, f32, f32),
|
||||
pub private_key_rows: Vec<(usize, (f32, f32, f32, f32))>,
|
||||
pub save_checkbox: (f32, f32, f32, f32),
|
||||
pub save_toggle: (f32, f32, f32, f32),
|
||||
pub primary: (f32, f32, f32, f32),
|
||||
pub cancel: (f32, f32, f32, f32),
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{auth_sections, push_private_key};
|
||||
use crate::ssh_profiles::SshAuthMode;
|
||||
use std::path::PathBuf;
|
||||
|
||||
#[test]
|
||||
fn auth_modes_show_the_same_sections_as_tabby() {
|
||||
assert_eq!(auth_sections(SshAuthMode::Auto), (true, true));
|
||||
assert_eq!(auth_sections(SshAuthMode::Password), (true, false));
|
||||
assert_eq!(auth_sections(SshAuthMode::PublicKey), (false, true));
|
||||
assert_eq!(auth_sections(SshAuthMode::Agent), (false, false));
|
||||
assert_eq!(auth_sections(SshAuthMode::KeyboardInteractive), (false, false));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn private_key_list_keeps_order_and_deduplicates_windows_paths() {
|
||||
let mut keys = Vec::new();
|
||||
assert!(push_private_key(&mut keys, PathBuf::from(r"C:\Keys\first")));
|
||||
assert!(!push_private_key(&mut keys, PathBuf::from(r"c:\keys\FIRST")));
|
||||
assert!(push_private_key(&mut keys, PathBuf::from(r"C:\Keys\second")));
|
||||
assert_eq!(keys, vec![PathBuf::from(r"C:\Keys\first"), PathBuf::from(r"C:\Keys\second")]);
|
||||
}
|
||||
}
|
||||
+76
-48
@@ -749,6 +749,12 @@ impl ApplicationHandler<Event> for Processor {
|
||||
window_context.display.window.request_redraw();
|
||||
}
|
||||
},
|
||||
(EventType::SftpUpdated, Some(window_id)) => {
|
||||
if let Some(window_context) = self.windows.get_mut(window_id) {
|
||||
window_context.dirty = true;
|
||||
window_context.display.window.request_redraw();
|
||||
}
|
||||
},
|
||||
(EventType::NebulaTab(request), Some(window_id)) => {
|
||||
if let Some(window_context) = self.windows.get_mut(window_id) {
|
||||
let close_window = window_context.handle_tab_request(request);
|
||||
@@ -933,6 +939,8 @@ pub enum EventType {
|
||||
NebulaResizeSettled,
|
||||
/// The SSH deletion grace period ended; commit its delayed credential cleanup.
|
||||
SshDeleteUndoExpired,
|
||||
/// An SFTP background operation published a new local UI snapshot.
|
||||
SftpUpdated,
|
||||
/// Typed AI-CLI lifecycle event from the nebula-hook pipe (see `ai_hook`).
|
||||
AiHook(crate::ai_hook::AiHookEvent),
|
||||
/// A toast was clicked: focus the originating window and, when known,
|
||||
@@ -1378,6 +1386,12 @@ impl<'a, N: Notify + 'a, T: EventListener> input::ActionContext<T> for ActionCon
|
||||
});
|
||||
}
|
||||
|
||||
fn nebula_open_sftp(&mut self, destination: String) {
|
||||
if let Err(err) = self.display.open_sftp_panel(destination, self.event_proxy.clone()) {
|
||||
log::error!("{err}");
|
||||
}
|
||||
}
|
||||
|
||||
fn spawn_new_instance(&mut self) {
|
||||
let mut env_args = env::args();
|
||||
let nebula = env_args.next().unwrap();
|
||||
@@ -2452,7 +2466,9 @@ impl input::Processor<EventProxy, ActionContext<'_, Notifier, EventProxy>> {
|
||||
// Clock ticks are handled at the window-context level.
|
||||
EventType::NebulaTick | EventType::NebulaAttach => (),
|
||||
// Resize settling is handled at the window-context level.
|
||||
EventType::NebulaResizeSettled | EventType::SshDeleteUndoExpired => (),
|
||||
EventType::NebulaResizeSettled
|
||||
| EventType::SshDeleteUndoExpired
|
||||
| EventType::SftpUpdated => (),
|
||||
// AI hook events are handled at the Processor level (they may
|
||||
// target any window's pane); FocusWindow likewise.
|
||||
EventType::AiHook(_) | EventType::FocusWindow { .. } => (),
|
||||
@@ -2791,6 +2807,9 @@ impl input::Processor<EventProxy, ActionContext<'_, Notifier, EventProxy>> {
|
||||
let panel = &mut self.ctx.display.nebula_side_panel;
|
||||
panel.search_unfocus(false);
|
||||
panel.commit_unfocus();
|
||||
if let Some(panel) = self.ctx.display.nebula_sftp_panel.as_mut() {
|
||||
panel.editor_unfocus();
|
||||
}
|
||||
}
|
||||
|
||||
// Nebula: always redraw on focus change, and clear the
|
||||
@@ -2837,54 +2856,63 @@ impl input::Processor<EventProxy, ActionContext<'_, Notifier, EventProxy>> {
|
||||
*self.ctx.dirty = true;
|
||||
}
|
||||
},
|
||||
WindowEvent::Ime(ime) => match ime {
|
||||
Ime::Commit(text) => {
|
||||
*self.ctx.dirty = true;
|
||||
// Tab rename owns committed text while editing: on
|
||||
// Windows (and any IME), printable characters are
|
||||
// delivered here, NOT through key_input — so the
|
||||
// rename buffer must consume them here or typing
|
||||
// silently pastes into the shell behind the box.
|
||||
if self.ctx.display.nebula_tab_rename.is_some() {
|
||||
// Caret-aware insert (type-to-overwrite on a
|
||||
// pending select-all) — same code path as the
|
||||
// non-IME keyboard fallback.
|
||||
self.ctx.display.tab_rename_insert(&text);
|
||||
} else if self.ctx.display.nebula_side_panel.search_focus {
|
||||
// Side-panel filter box: same IME contract as
|
||||
// tab rename — committed text must land in the
|
||||
// box, not paste into the shell behind it.
|
||||
self.ctx.display.nebula_side_panel.search_input(&text);
|
||||
} else if self.ctx.display.nebula_side_panel.commit_focus {
|
||||
self.ctx.display.nebula_side_panel.commit_input(&text);
|
||||
} else if self.ctx.display.nebula_ssh_editor.is_some() {
|
||||
self.ctx.display.ssh_editor_insert(&text);
|
||||
} else if self.ctx.display.command_palette_open() {
|
||||
self.ctx.display.palette_input_text(&text);
|
||||
} else {
|
||||
// Don't use bracketed paste for single char input.
|
||||
self.ctx.paste(&text, text.chars().count() > 1);
|
||||
}
|
||||
self.ctx.update_cursor_blinking();
|
||||
},
|
||||
Ime::Preedit(text, cursor_offset) => {
|
||||
let preedit =
|
||||
(!text.is_empty()).then(|| Preedit::new(text, cursor_offset));
|
||||
|
||||
if self.ctx.display.ime.preedit() != preedit.as_ref() {
|
||||
self.ctx.display.ime.set_preedit(preedit);
|
||||
self.ctx.update_cursor_blinking();
|
||||
WindowEvent::Ime(ime) => {
|
||||
match ime {
|
||||
Ime::Commit(text) => {
|
||||
*self.ctx.dirty = true;
|
||||
}
|
||||
},
|
||||
Ime::Enabled => {
|
||||
self.ctx.display.ime.set_enabled(true);
|
||||
*self.ctx.dirty = true;
|
||||
},
|
||||
Ime::Disabled => {
|
||||
self.ctx.display.ime.set_enabled(false);
|
||||
*self.ctx.dirty = true;
|
||||
},
|
||||
// Tab rename owns committed text while editing: on
|
||||
// Windows (and any IME), printable characters are
|
||||
// delivered here, NOT through key_input — so the
|
||||
// rename buffer must consume them here or typing
|
||||
// silently pastes into the shell behind the box.
|
||||
if self.ctx.display.nebula_tab_rename.is_some() {
|
||||
// Caret-aware insert (type-to-overwrite on a
|
||||
// pending select-all) — same code path as the
|
||||
// non-IME keyboard fallback.
|
||||
self.ctx.display.tab_rename_insert(&text);
|
||||
} else if self.ctx.display.nebula_sftp_panel.as_ref().is_some_and(
|
||||
crate::display::sftp_panel::SftpPanel::editor_active,
|
||||
) {
|
||||
if let Some(panel) = self.ctx.display.nebula_sftp_panel.as_mut()
|
||||
{
|
||||
panel.editor_insert(&text);
|
||||
}
|
||||
} else if self.ctx.display.nebula_side_panel.search_focus {
|
||||
// Side-panel filter box: same IME contract as
|
||||
// tab rename — committed text must land in the
|
||||
// box, not paste into the shell behind it.
|
||||
self.ctx.display.nebula_side_panel.search_input(&text);
|
||||
} else if self.ctx.display.nebula_side_panel.commit_focus {
|
||||
self.ctx.display.nebula_side_panel.commit_input(&text);
|
||||
} else if self.ctx.display.nebula_ssh_editor.is_some() {
|
||||
self.ctx.display.ssh_editor_insert(&text);
|
||||
} else if self.ctx.display.command_palette_open() {
|
||||
self.ctx.display.palette_input_text(&text);
|
||||
} else {
|
||||
// Don't use bracketed paste for single char input.
|
||||
self.ctx.paste(&text, text.chars().count() > 1);
|
||||
}
|
||||
self.ctx.update_cursor_blinking();
|
||||
},
|
||||
Ime::Preedit(text, cursor_offset) => {
|
||||
let preedit =
|
||||
(!text.is_empty()).then(|| Preedit::new(text, cursor_offset));
|
||||
|
||||
if self.ctx.display.ime.preedit() != preedit.as_ref() {
|
||||
self.ctx.display.ime.set_preedit(preedit);
|
||||
self.ctx.update_cursor_blinking();
|
||||
*self.ctx.dirty = true;
|
||||
}
|
||||
},
|
||||
Ime::Enabled => {
|
||||
self.ctx.display.ime.set_enabled(true);
|
||||
*self.ctx.dirty = true;
|
||||
},
|
||||
Ime::Disabled => {
|
||||
self.ctx.display.ime.set_enabled(false);
|
||||
*self.ctx.dirty = true;
|
||||
},
|
||||
}
|
||||
},
|
||||
WindowEvent::ThemeChanged(theme) => {
|
||||
self.ctx.display.system_theme_changed(theme);
|
||||
|
||||
@@ -128,6 +128,69 @@ impl<T: EventListener, A: ActionContext<T>> Processor<T, A> {
|
||||
return;
|
||||
}
|
||||
|
||||
// SFTP path/filter/create/rename fields share the same editing contract.
|
||||
if self
|
||||
.ctx
|
||||
.display()
|
||||
.nebula_sftp_panel
|
||||
.as_ref()
|
||||
.is_some_and(crate::display::sftp_panel::SftpPanel::editor_active)
|
||||
{
|
||||
match &key.logical_key {
|
||||
Key::Named(NamedKey::Escape) => {
|
||||
if let Some(panel) = self.ctx.display().nebula_sftp_panel.as_mut() {
|
||||
panel.editor_cancel();
|
||||
}
|
||||
},
|
||||
Key::Named(NamedKey::Enter) => {
|
||||
if let Some(panel) = self.ctx.display().nebula_sftp_panel.as_mut() {
|
||||
let _ = panel.editor_submit();
|
||||
}
|
||||
},
|
||||
Key::Named(NamedKey::Backspace) => {
|
||||
if let Some(panel) = self.ctx.display().nebula_sftp_panel.as_mut() {
|
||||
panel.editor_backspace();
|
||||
}
|
||||
},
|
||||
Key::Character(c)
|
||||
if mods.control_key() && !mods.alt_key() && c.eq_ignore_ascii_case("a") =>
|
||||
{
|
||||
if let Some(panel) = self.ctx.display().nebula_sftp_panel.as_mut() {
|
||||
panel.editor_select_all();
|
||||
}
|
||||
},
|
||||
Key::Character(c)
|
||||
if mods.control_key() && !mods.alt_key() && c.eq_ignore_ascii_case("c") =>
|
||||
{
|
||||
let text = self
|
||||
.ctx
|
||||
.display()
|
||||
.nebula_sftp_panel
|
||||
.as_ref()
|
||||
.and_then(|panel| panel.editor_selected_text());
|
||||
if let Some(text) = text {
|
||||
self.ctx.clipboard_mut().store(ClipboardType::Clipboard, text);
|
||||
}
|
||||
},
|
||||
Key::Character(c)
|
||||
if mods.control_key() && !mods.alt_key() && c.eq_ignore_ascii_case("v") =>
|
||||
{
|
||||
let text = self.ctx.clipboard_mut().load(ClipboardType::Clipboard);
|
||||
if let Some(panel) = self.ctx.display().nebula_sftp_panel.as_mut() {
|
||||
panel.editor_insert(&text);
|
||||
}
|
||||
},
|
||||
Key::Character(text) if mods.is_empty() || mods.shift_key() => {
|
||||
if let Some(panel) = self.ctx.display().nebula_sftp_panel.as_mut() {
|
||||
panel.editor_insert(text);
|
||||
}
|
||||
},
|
||||
_ => {},
|
||||
}
|
||||
self.ctx.mark_dirty();
|
||||
return;
|
||||
}
|
||||
|
||||
// Side-panel filter box: consume keyboard while it has focus, same
|
||||
// modal contract as tab rename. Printable text arrives via Ime::Commit
|
||||
// on Windows/IME; the Character arm is the non-IME fallback.
|
||||
@@ -220,25 +283,19 @@ impl<T: EventListener, A: ActionContext<T>> Processor<T, A> {
|
||||
Key::Named(NamedKey::Tab) => self.ctx.display().ssh_editor_next_field(),
|
||||
Key::Named(NamedKey::Backspace) => self.ctx.display().ssh_editor_backspace(),
|
||||
Key::Character(c)
|
||||
if mods.control_key()
|
||||
&& !mods.alt_key()
|
||||
&& c.eq_ignore_ascii_case("a") =>
|
||||
if mods.control_key() && !mods.alt_key() && c.eq_ignore_ascii_case("a") =>
|
||||
{
|
||||
self.ctx.display().ssh_editor_select_all();
|
||||
},
|
||||
Key::Character(c)
|
||||
if mods.control_key()
|
||||
&& !mods.alt_key()
|
||||
&& c.eq_ignore_ascii_case("c") =>
|
||||
if mods.control_key() && !mods.alt_key() && c.eq_ignore_ascii_case("c") =>
|
||||
{
|
||||
if let Some(text) = self.ctx.display().ssh_editor_selected_text() {
|
||||
self.ctx.clipboard_mut().store(ClipboardType::Clipboard, text);
|
||||
}
|
||||
},
|
||||
Key::Character(c)
|
||||
if mods.control_key()
|
||||
&& !mods.alt_key()
|
||||
&& c.eq_ignore_ascii_case("v") =>
|
||||
if mods.control_key() && !mods.alt_key() && c.eq_ignore_ascii_case("v") =>
|
||||
{
|
||||
// SSH 编辑器是应用自有文本框。直接写入当前焦点字段,避免后续
|
||||
// 终端粘贴绑定把地址或密码误发送到后台 PTY。
|
||||
|
||||
+636
-564
File diff suppressed because it is too large.
Load diff
@@ -43,6 +43,8 @@ mod macos;
|
||||
mod markdown;
|
||||
mod message_bar;
|
||||
mod migrate;
|
||||
#[cfg(windows)]
|
||||
mod mux;
|
||||
mod nebula_history;
|
||||
mod notify;
|
||||
#[cfg(windows)]
|
||||
@@ -55,13 +57,15 @@ mod scheduler;
|
||||
mod session;
|
||||
mod shell_detect;
|
||||
#[cfg(windows)]
|
||||
mod ssh_credentials;
|
||||
#[cfg(windows)]
|
||||
mod mux;
|
||||
#[cfg(windows)]
|
||||
mod ssh;
|
||||
#[cfg(windows)]
|
||||
mod ssh_credentials;
|
||||
#[cfg(windows)]
|
||||
mod ssh_profiles;
|
||||
#[cfg(windows)]
|
||||
mod ssh_session;
|
||||
#[cfg(windows)]
|
||||
mod ssh_sftp;
|
||||
mod string;
|
||||
mod window_context;
|
||||
|
||||
|
||||
@@ -34,6 +34,18 @@ pub fn credential_target(destination: &str) -> String {
|
||||
format!("Nebula/SSH/{destination}")
|
||||
}
|
||||
|
||||
pub fn private_key_credential_target(private_key: &[u8]) -> String {
|
||||
use sha2::{Digest, Sha512};
|
||||
use std::fmt::Write as _;
|
||||
|
||||
let digest = Sha512::digest(private_key);
|
||||
let mut fingerprint = String::with_capacity(digest.len() * 2);
|
||||
for byte in digest {
|
||||
let _ = write!(fingerprint, "{byte:02x}");
|
||||
}
|
||||
format!("Nebula/SSH/KeyPassphrase/{fingerprint}")
|
||||
}
|
||||
|
||||
pub fn is_askpass_env(mut get: impl FnMut(&str) -> Option<String>) -> bool {
|
||||
get(ASKPASS_FLAG).as_deref() == Some("1")
|
||||
&& get(DESTINATION_ENV).is_some_and(|destination| !destination.trim().is_empty())
|
||||
@@ -66,8 +78,8 @@ mod windows_store {
|
||||
value.encode_utf16().chain(std::iter::once(0)).collect()
|
||||
}
|
||||
|
||||
pub fn load_password(destination: &str) -> io::Result<Option<Vec<u8>>> {
|
||||
let target = wide(&credential_target(destination));
|
||||
pub fn load_secret(target_name: &str) -> io::Result<Option<Vec<u8>>> {
|
||||
let target = wide(target_name);
|
||||
let mut raw = null_mut();
|
||||
let ok = unsafe { CredReadW(target.as_ptr(), CRED_TYPE_GENERIC, 0, &mut raw) };
|
||||
if ok == 0 {
|
||||
@@ -95,13 +107,13 @@ mod windows_store {
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
pub fn save_password(destination: &str, password: &[u8]) -> io::Result<()> {
|
||||
if password.len() > u32::MAX as usize {
|
||||
pub fn save_secret(target_name: &str, username: &str, secret: &[u8]) -> io::Result<()> {
|
||||
if secret.len() > u32::MAX as usize {
|
||||
return Err(io::Error::new(io::ErrorKind::InvalidInput, "password too long"));
|
||||
}
|
||||
let mut target = wide(&credential_target(destination));
|
||||
let mut user = wide(username_hint(destination).as_deref().unwrap_or(""));
|
||||
let mut blob = password.to_vec();
|
||||
let mut target = wide(target_name);
|
||||
let mut user = wide(username);
|
||||
let mut blob = secret.to_vec();
|
||||
let cred = CREDENTIALW {
|
||||
Flags: 0,
|
||||
Type: CRED_TYPE_GENERIC,
|
||||
@@ -126,8 +138,8 @@ mod windows_store {
|
||||
if ok == 0 { Err(io::Error::last_os_error()) } else { Ok(()) }
|
||||
}
|
||||
|
||||
pub fn delete_password(destination: &str) -> io::Result<()> {
|
||||
let target = wide(&credential_target(destination));
|
||||
pub fn delete_secret(target_name: &str) -> io::Result<()> {
|
||||
let target = wide(target_name);
|
||||
let ok = unsafe { CredDeleteW(target.as_ptr(), CRED_TYPE_GENERIC, 0) };
|
||||
if ok == 0 {
|
||||
let code = std::io::Error::last_os_error().raw_os_error().unwrap_or_default();
|
||||
@@ -139,6 +151,22 @@ mod windows_store {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn load_password(destination: &str) -> io::Result<Option<Vec<u8>>> {
|
||||
load_secret(&credential_target(destination))
|
||||
}
|
||||
|
||||
pub fn save_password(destination: &str, password: &[u8]) -> io::Result<()> {
|
||||
save_secret(
|
||||
&credential_target(destination),
|
||||
username_hint(destination).as_deref().unwrap_or(""),
|
||||
password,
|
||||
)
|
||||
}
|
||||
|
||||
pub fn delete_password(destination: &str) -> io::Result<()> {
|
||||
delete_secret(&credential_target(destination))
|
||||
}
|
||||
|
||||
pub fn prompt_password(
|
||||
destination: &str,
|
||||
initial: Option<&[u8]>,
|
||||
@@ -309,6 +337,25 @@ pub fn load_stored_password(destination: &str) -> std::io::Result<Option<Vec<u8>
|
||||
windows_store::load_password(destination)
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
pub fn store_private_key_passphrase(private_key: &[u8], passphrase: &[u8]) -> std::io::Result<()> {
|
||||
windows_store::save_secret(
|
||||
&private_key_credential_target(private_key),
|
||||
"private-key",
|
||||
passphrase,
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
pub fn load_private_key_passphrase(private_key: &[u8]) -> std::io::Result<Option<Vec<u8>>> {
|
||||
windows_store::load_secret(&private_key_credential_target(private_key))
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
pub fn forget_private_key_passphrase(private_key: &[u8]) -> std::io::Result<()> {
|
||||
windows_store::delete_secret(&private_key_credential_target(private_key))
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
pub fn prompt_password(
|
||||
destination: &str,
|
||||
@@ -357,6 +404,19 @@ mod tests {
|
||||
assert!(!target.contains("correct horse battery staple"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn private_key_passphrase_target_depends_on_contents_not_path() {
|
||||
let first = private_key_credential_target(b"encrypted-private-key");
|
||||
let moved = private_key_credential_target(b"encrypted-private-key");
|
||||
let changed = private_key_credential_target(b"different-private-key");
|
||||
|
||||
assert_eq!(first, moved);
|
||||
assert_ne!(first, changed);
|
||||
assert!(first.starts_with("Nebula/SSH/KeyPassphrase/"));
|
||||
assert!(!first.contains("Users"));
|
||||
assert!(!first.contains(".ssh"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn first_saved_password_is_used_without_prompting() {
|
||||
assert_eq!(askpass_action(true, false), AskpassAction::UseStored);
|
||||
@@ -397,4 +457,20 @@ mod tests {
|
||||
windows_store::delete_password(&destination).unwrap();
|
||||
assert_eq!(windows_store::load_password(&destination).unwrap(), None);
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
fn private_key_passphrase_uses_its_own_credential_namespace() {
|
||||
let key = format!("test-private-key-{}", std::process::id()).into_bytes();
|
||||
let passphrase = b"not-a-real-passphrase";
|
||||
let _ = forget_private_key_passphrase(&key);
|
||||
|
||||
store_private_key_passphrase(&key, passphrase).unwrap();
|
||||
assert_eq!(
|
||||
load_private_key_passphrase(&key).unwrap().as_deref(),
|
||||
Some(passphrase.as_slice())
|
||||
);
|
||||
forget_private_key_passphrase(&key).unwrap();
|
||||
assert_eq!(load_private_key_passphrase(&key).unwrap(), None);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,246 @@
|
||||
use std::io;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use serde::{Deserialize, Deserializer, Serialize};
|
||||
use windows_sys::Win32::Storage::FileSystem::{
|
||||
MOVEFILE_REPLACE_EXISTING, MOVEFILE_WRITE_THROUGH, MoveFileExW,
|
||||
};
|
||||
|
||||
const PROFILE_VERSION: u32 = 1;
|
||||
|
||||
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Serialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum SshAuthMode {
|
||||
#[default]
|
||||
Auto,
|
||||
Password,
|
||||
PublicKey,
|
||||
Agent,
|
||||
KeyboardInteractive,
|
||||
}
|
||||
|
||||
impl<'de> Deserialize<'de> for SshAuthMode {
|
||||
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
|
||||
where
|
||||
D: Deserializer<'de>,
|
||||
{
|
||||
let value = String::deserialize(deserializer)?;
|
||||
Ok(match value.as_str() {
|
||||
"password" => Self::Password,
|
||||
"public_key" => Self::PublicKey,
|
||||
"agent" => Self::Agent,
|
||||
"keyboard_interactive" => Self::KeyboardInteractive,
|
||||
_ => Self::Auto,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct SshProfileAuth {
|
||||
pub destination: String,
|
||||
#[serde(default)]
|
||||
pub auth: SshAuthMode,
|
||||
#[serde(default)]
|
||||
pub private_keys: Vec<PathBuf>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct SshProfiles {
|
||||
#[serde(default = "profile_version")]
|
||||
version: u32,
|
||||
#[serde(default)]
|
||||
profiles: Vec<SshProfileAuth>,
|
||||
}
|
||||
|
||||
impl Default for SshProfiles {
|
||||
fn default() -> Self {
|
||||
Self { version: PROFILE_VERSION, profiles: Vec::new() }
|
||||
}
|
||||
}
|
||||
|
||||
impl SshProfiles {
|
||||
pub fn load(path: &Path) -> io::Result<Self> {
|
||||
let data = match std::fs::read(path) {
|
||||
Ok(data) => data,
|
||||
Err(err) if err.kind() == io::ErrorKind::NotFound => return Ok(Self::default()),
|
||||
Err(err) => return Err(err),
|
||||
};
|
||||
let mut profiles: Self = serde_json::from_slice(&data)
|
||||
.map_err(|err| io::Error::new(io::ErrorKind::InvalidData, err))?;
|
||||
for profile in &mut profiles.profiles {
|
||||
deduplicate_key_paths(&mut profile.private_keys);
|
||||
}
|
||||
profiles.version = PROFILE_VERSION;
|
||||
Ok(profiles)
|
||||
}
|
||||
|
||||
pub fn save(&self, path: &Path) -> io::Result<()> {
|
||||
if let Some(parent) = path.parent() {
|
||||
std::fs::create_dir_all(parent)?;
|
||||
}
|
||||
let data = serde_json::to_vec_pretty(self).map_err(io::Error::other)?;
|
||||
let temporary = path.with_extension(format!("nebula-tmp-{}", std::process::id()));
|
||||
std::fs::write(&temporary, data)?;
|
||||
let result = replace_file(&temporary, path);
|
||||
if result.is_err() {
|
||||
let _ = std::fs::remove_file(&temporary);
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
pub fn for_destination(&self, destination: &str) -> SshProfileAuth {
|
||||
self.profiles
|
||||
.iter()
|
||||
.find(|profile| profile.destination == destination)
|
||||
.cloned()
|
||||
.unwrap_or_else(|| SshProfileAuth {
|
||||
destination: destination.to_owned(),
|
||||
auth: SshAuthMode::Auto,
|
||||
private_keys: Vec::new(),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn upsert(&mut self, mut profile: SshProfileAuth) {
|
||||
deduplicate_key_paths(&mut profile.private_keys);
|
||||
if let Some(existing) =
|
||||
self.profiles.iter_mut().find(|existing| existing.destination == profile.destination)
|
||||
{
|
||||
*existing = profile;
|
||||
} else {
|
||||
self.profiles.push(profile);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn remove(&mut self, destination: &str) {
|
||||
self.profiles.retain(|profile| profile.destination != destination);
|
||||
}
|
||||
|
||||
pub fn rename(&mut self, old: &str, new: &str) {
|
||||
let Some(mut profile) =
|
||||
self.profiles.iter().find(|profile| profile.destination == old).cloned()
|
||||
else {
|
||||
return;
|
||||
};
|
||||
self.remove(old);
|
||||
profile.destination = new.to_owned();
|
||||
self.upsert(profile);
|
||||
}
|
||||
}
|
||||
|
||||
fn profile_version() -> u32 {
|
||||
PROFILE_VERSION
|
||||
}
|
||||
|
||||
fn deduplicate_key_paths(paths: &mut Vec<PathBuf>) {
|
||||
let mut seen = Vec::<String>::new();
|
||||
paths.retain(|path| {
|
||||
let normalized = path.to_string_lossy().to_lowercase();
|
||||
if seen.contains(&normalized) {
|
||||
false
|
||||
} else {
|
||||
seen.push(normalized);
|
||||
true
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
fn replace_file(source: &Path, destination: &Path) -> io::Result<()> {
|
||||
let source = wide_path(source);
|
||||
let destination = wide_path(destination);
|
||||
let ok = unsafe {
|
||||
MoveFileExW(
|
||||
source.as_ptr(),
|
||||
destination.as_ptr(),
|
||||
MOVEFILE_REPLACE_EXISTING | MOVEFILE_WRITE_THROUGH,
|
||||
)
|
||||
};
|
||||
if ok == 0 { Err(io::Error::last_os_error()) } else { Ok(()) }
|
||||
}
|
||||
|
||||
fn wide_path(path: &Path) -> Vec<u16> {
|
||||
use std::os::windows::ffi::OsStrExt;
|
||||
path.as_os_str().encode_wide().chain(std::iter::once(0)).collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{SshAuthMode, SshProfileAuth, SshProfiles};
|
||||
use std::path::PathBuf;
|
||||
|
||||
#[test]
|
||||
fn missing_profile_defaults_to_auto_without_keys() {
|
||||
let profiles = SshProfiles::default();
|
||||
|
||||
assert_eq!(profiles.for_destination("dev@example.com").auth, SshAuthMode::Auto);
|
||||
assert!(profiles.for_destination("dev@example.com").private_keys.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn profile_round_trip_preserves_mode_and_key_order() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("ssh_profiles.json");
|
||||
let mut profiles = SshProfiles::default();
|
||||
profiles.upsert(SshProfileAuth {
|
||||
destination: "dev@example.com".to_owned(),
|
||||
auth: SshAuthMode::PublicKey,
|
||||
private_keys: vec![PathBuf::from(r"C:\Keys\first"), PathBuf::from(r"C:\Keys\second")],
|
||||
});
|
||||
profiles.save(&path).unwrap();
|
||||
|
||||
let loaded = SshProfiles::load(&path).unwrap();
|
||||
let profile = loaded.for_destination("dev@example.com");
|
||||
assert_eq!(profile.auth, SshAuthMode::PublicKey);
|
||||
assert_eq!(
|
||||
profile.private_keys,
|
||||
vec![PathBuf::from(r"C:\Keys\first"), PathBuf::from(r"C:\Keys\second")]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duplicate_windows_key_paths_are_removed_without_reordering() {
|
||||
let mut profiles = SshProfiles::default();
|
||||
profiles.upsert(SshProfileAuth {
|
||||
destination: "dev@example.com".to_owned(),
|
||||
auth: SshAuthMode::Auto,
|
||||
private_keys: vec![
|
||||
PathBuf::from(r"C:\Keys\id_ed25519"),
|
||||
PathBuf::from(r"c:\keys\ID_ED25519"),
|
||||
PathBuf::from(r"C:\Keys\id_rsa"),
|
||||
],
|
||||
});
|
||||
|
||||
assert_eq!(
|
||||
profiles.for_destination("dev@example.com").private_keys,
|
||||
vec![PathBuf::from(r"C:\Keys\id_ed25519"), PathBuf::from(r"C:\Keys\id_rsa")]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn renaming_profile_moves_auth_metadata() {
|
||||
let mut profiles = SshProfiles::default();
|
||||
profiles.upsert(SshProfileAuth {
|
||||
destination: "old@example.com".to_owned(),
|
||||
auth: SshAuthMode::Agent,
|
||||
private_keys: vec![PathBuf::from(r"C:\Keys\id_ed25519")],
|
||||
});
|
||||
|
||||
profiles.rename("old@example.com", "new@example.com");
|
||||
|
||||
assert_eq!(profiles.for_destination("old@example.com").auth, SshAuthMode::Auto);
|
||||
assert_eq!(profiles.for_destination("new@example.com").auth, SshAuthMode::Agent);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_auth_mode_falls_back_to_auto() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("ssh_profiles.json");
|
||||
std::fs::write(
|
||||
&path,
|
||||
r#"{"version":1,"profiles":[{"destination":"dev@example.com","auth":"future-mode","private_keys":[]}]}"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let loaded = SshProfiles::load(&path).unwrap();
|
||||
assert_eq!(loaded.for_destination("dev@example.com").auth, SshAuthMode::Auto);
|
||||
}
|
||||
}
|
||||
+320
-77
@@ -16,12 +16,12 @@ use nebula_terminal::event::{Event as TerminalEvent, WindowSize};
|
||||
use nebula_terminal::event_loop::{EventLoopSender, Msg, StreamProcessor};
|
||||
use nebula_terminal::sync::FairMutex;
|
||||
use nebula_terminal::term::Term;
|
||||
use russh::ChannelMsg;
|
||||
use russh::client::{self, AuthResult, KeyboardInteractiveAuthResponse};
|
||||
use russh::keys::agent::AgentIdentity;
|
||||
use russh::keys::agent::client::AgentClient;
|
||||
use russh::keys::ssh_key;
|
||||
use russh::keys::{HashAlg, PrivateKeyWithHashAlg};
|
||||
use russh::ChannelMsg;
|
||||
use tokio::io::{AsyncRead, AsyncWrite};
|
||||
|
||||
use crate::event::EventProxy;
|
||||
@@ -30,6 +30,61 @@ type SessionError = Box<dyn std::error::Error + Send + Sync>;
|
||||
type ClientSession = client::Handle<ClientHandler>;
|
||||
type SharedSession = Arc<tokio::sync::Mutex<ClientSession>>;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
enum AuthMethod {
|
||||
PrivateKey(PathBuf),
|
||||
Agent,
|
||||
StoredPassword,
|
||||
KeyboardInteractive,
|
||||
PromptPassword,
|
||||
}
|
||||
|
||||
fn authentication_plan(
|
||||
mode: crate::ssh_profiles::SshAuthMode,
|
||||
explicit_keys: &[PathBuf],
|
||||
resolved_keys: &[PathBuf],
|
||||
) -> Vec<AuthMethod> {
|
||||
use crate::ssh_profiles::SshAuthMode;
|
||||
|
||||
let key_methods = || {
|
||||
let mut seen = Vec::<String>::new();
|
||||
explicit_keys
|
||||
.iter()
|
||||
.chain(resolved_keys)
|
||||
.filter(|path| {
|
||||
let normalized = path.to_string_lossy().to_lowercase();
|
||||
if seen.contains(&normalized) {
|
||||
false
|
||||
} else {
|
||||
seen.push(normalized);
|
||||
true
|
||||
}
|
||||
})
|
||||
.cloned()
|
||||
.map(AuthMethod::PrivateKey)
|
||||
.collect::<Vec<_>>()
|
||||
};
|
||||
|
||||
match mode {
|
||||
SshAuthMode::Auto => {
|
||||
let mut methods = key_methods();
|
||||
methods.extend([
|
||||
AuthMethod::Agent,
|
||||
AuthMethod::StoredPassword,
|
||||
AuthMethod::KeyboardInteractive,
|
||||
AuthMethod::PromptPassword,
|
||||
]);
|
||||
methods
|
||||
},
|
||||
SshAuthMode::Password => {
|
||||
vec![AuthMethod::StoredPassword, AuthMethod::PromptPassword]
|
||||
},
|
||||
SshAuthMode::PublicKey => key_methods(),
|
||||
SshAuthMode::Agent => vec![AuthMethod::Agent],
|
||||
SshAuthMode::KeyboardInteractive => vec![AuthMethod::KeyboardInteractive],
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct SshDestination {
|
||||
pub original: String,
|
||||
@@ -106,9 +161,9 @@ impl SshDestination {
|
||||
|
||||
fn parse_host_port(host_port: &str) -> io::Result<(String, u16)> {
|
||||
let (host, port) = if let Some(rest) = host_port.strip_prefix('[') {
|
||||
let (host, suffix) = rest.split_once(']').ok_or_else(|| {
|
||||
io::Error::new(io::ErrorKind::InvalidInput, "无效的 IPv6 SSH 地址")
|
||||
})?;
|
||||
let (host, suffix) = rest
|
||||
.split_once(']')
|
||||
.ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "无效的 IPv6 SSH 地址"))?;
|
||||
let port = suffix
|
||||
.strip_prefix(':')
|
||||
.map(str::parse)
|
||||
@@ -233,12 +288,11 @@ impl client::Handler for ClientHandler {
|
||||
}
|
||||
}
|
||||
|
||||
fn runtime() -> io::Result<&'static tokio::runtime::Runtime> {
|
||||
pub(crate) fn runtime() -> io::Result<&'static tokio::runtime::Runtime> {
|
||||
static RUNTIME: OnceLock<Result<tokio::runtime::Runtime, String>> = OnceLock::new();
|
||||
match RUNTIME.get_or_init(|| {
|
||||
let workers = std::thread::available_parallelism()
|
||||
.map(|count| count.get().clamp(2, 4))
|
||||
.unwrap_or(2);
|
||||
let workers =
|
||||
std::thread::available_parallelism().map(|count| count.get().clamp(2, 4)).unwrap_or(2);
|
||||
tokio::runtime::Builder::new_multi_thread()
|
||||
.enable_all()
|
||||
.worker_threads(workers)
|
||||
@@ -264,12 +318,32 @@ pub fn spawn_session(
|
||||
event_proxy: EventProxy,
|
||||
) -> io::Result<EventLoopSender> {
|
||||
let (sender, receiver) = EventLoopSender::standalone()?;
|
||||
let profiles_path = crate::display::nebula_data_dir().join("ssh_profiles.json");
|
||||
runtime()?.spawn(async move {
|
||||
let raw = destination.clone();
|
||||
let resolved = tokio::task::spawn_blocking(move || SshDestination::resolve(&raw)).await;
|
||||
let resolved = tokio::task::spawn_blocking(move || {
|
||||
let resolved = SshDestination::resolve(&raw)?;
|
||||
let profiles = match crate::ssh_profiles::SshProfiles::load(&profiles_path) {
|
||||
Ok(profiles) => profiles,
|
||||
Err(err) => {
|
||||
warn!("加载 SSH Profile 失败,使用自动认证: {err}");
|
||||
crate::ssh_profiles::SshProfiles::default()
|
||||
},
|
||||
};
|
||||
Ok::<_, io::Error>((resolved, profiles.for_destination(&raw)))
|
||||
})
|
||||
.await;
|
||||
let result = match resolved {
|
||||
Ok(Ok(destination)) => {
|
||||
run_session_async(destination, initial_size, terminal.clone(), event_proxy.clone(), receiver).await
|
||||
Ok(Ok((destination, profile))) => {
|
||||
run_session_async(
|
||||
destination,
|
||||
profile,
|
||||
initial_size,
|
||||
terminal.clone(),
|
||||
event_proxy.clone(),
|
||||
receiver,
|
||||
)
|
||||
.await
|
||||
},
|
||||
Ok(Err(err)) => Err(err.into()),
|
||||
Err(err) => Err(format!("SSH 地址解析任务失败: {err}").into()),
|
||||
@@ -286,6 +360,7 @@ pub fn spawn_session(
|
||||
|
||||
async fn run_session_async(
|
||||
destination: SshDestination,
|
||||
profile: crate::ssh_profiles::SshProfileAuth,
|
||||
initial_size: WindowSize,
|
||||
terminal: Arc<FairMutex<Term<EventProxy>>>,
|
||||
event_proxy: EventProxy,
|
||||
@@ -295,7 +370,7 @@ async fn run_session_async(
|
||||
return Err(format!("当前直连模式尚未接入跳板机 {proxy_jump}").into());
|
||||
}
|
||||
|
||||
let session = authenticated_session(&destination).await?;
|
||||
let session = authenticated_session(&destination, &profile).await?;
|
||||
let mut channel = {
|
||||
let session = session.lock().await;
|
||||
session.channel_open_session().await?
|
||||
@@ -312,9 +387,7 @@ async fn run_session_async(
|
||||
)
|
||||
.await?;
|
||||
let hook_token = remote_hook_token()?;
|
||||
channel
|
||||
.set_env(false, "NEBULA_REMOTE_HOOK_TOKEN", hook_token.clone())
|
||||
.await?;
|
||||
channel.set_env(false, "NEBULA_REMOTE_HOOK_TOKEN", hook_token.clone()).await?;
|
||||
channel.request_shell(true).await?;
|
||||
|
||||
let (input_tx, mut input_rx) = tokio::sync::mpsc::unbounded_channel();
|
||||
@@ -371,7 +444,10 @@ async fn wait_for_sync(deadline: Option<std::time::Instant>) {
|
||||
}
|
||||
}
|
||||
|
||||
async fn authenticated_session(destination: &SshDestination) -> Result<SharedSession, SessionError> {
|
||||
async fn authenticated_session(
|
||||
destination: &SshDestination,
|
||||
profile: &crate::ssh_profiles::SshProfileAuth,
|
||||
) -> Result<SharedSession, SessionError> {
|
||||
let key = destination.pool_key();
|
||||
if let Some(existing) = connection_pool().lock().await.get(&key).cloned() {
|
||||
if !existing.lock().await.is_closed() {
|
||||
@@ -388,13 +464,9 @@ async fn authenticated_session(destination: &SshDestination) -> Result<SharedSes
|
||||
..Default::default()
|
||||
});
|
||||
let handler = ClientHandler { host: destination.host.clone(), port: destination.port };
|
||||
let mut session = client::connect(
|
||||
config,
|
||||
(destination.host.as_str(), destination.port),
|
||||
handler,
|
||||
)
|
||||
.await?;
|
||||
authenticate(&mut session, destination).await?;
|
||||
let mut session =
|
||||
client::connect(config, (destination.host.as_str(), destination.port), handler).await?;
|
||||
authenticate(&mut session, destination, profile).await?;
|
||||
|
||||
let session = Arc::new(tokio::sync::Mutex::new(session));
|
||||
let mut pool = connection_pool().lock().await;
|
||||
@@ -410,50 +482,143 @@ async fn authenticated_session(destination: &SshDestination) -> Result<SharedSes
|
||||
async fn authenticate(
|
||||
session: &mut ClientSession,
|
||||
destination: &SshDestination,
|
||||
profile: &crate::ssh_profiles::SshProfileAuth,
|
||||
) -> Result<(), SessionError> {
|
||||
if session.authenticate_none(&destination.user).await?.success() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
for path in &destination.identity_files {
|
||||
if try_private_key(session, destination, path).await? {
|
||||
return Ok(());
|
||||
let plan =
|
||||
authentication_plan(profile.auth, &profile.private_keys, &destination.identity_files);
|
||||
let key_count =
|
||||
plan.iter().filter(|method| matches!(method, AuthMethod::PrivateKey(_))).count();
|
||||
if profile.auth == crate::ssh_profiles::SshAuthMode::Auto && key_count >= 3 {
|
||||
warn!(
|
||||
"自动认证将尝试 {key_count} 把私钥;若服务器触发 MaxAuthTries,请在 SSH Profile 中明确指定密钥"
|
||||
);
|
||||
}
|
||||
|
||||
let mut reusable_password = None;
|
||||
let mut loaded_stored_password = false;
|
||||
let mut stored_password_was_present = false;
|
||||
for method in plan {
|
||||
match method {
|
||||
AuthMethod::PrivateKey(path) => {
|
||||
if try_private_key(session, destination, &path).await? {
|
||||
clear_secret(&mut reusable_password);
|
||||
return Ok(());
|
||||
}
|
||||
},
|
||||
AuthMethod::Agent => {
|
||||
if try_windows_agents(session, &destination.user, &profile.private_keys).await {
|
||||
clear_secret(&mut reusable_password);
|
||||
return Ok(());
|
||||
}
|
||||
},
|
||||
AuthMethod::StoredPassword => {
|
||||
if !loaded_stored_password {
|
||||
reusable_password =
|
||||
crate::ssh_credentials::load_stored_password(&destination.original)?;
|
||||
loaded_stored_password = true;
|
||||
stored_password_was_present = reusable_password.is_some();
|
||||
}
|
||||
if let Some(password) = reusable_password.as_deref() {
|
||||
if authenticate_password(session, &destination.user, password).await? {
|
||||
clear_secret(&mut reusable_password);
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
},
|
||||
AuthMethod::KeyboardInteractive => {
|
||||
if try_keyboard_interactive(session, destination, reusable_password.as_deref())
|
||||
.await?
|
||||
{
|
||||
clear_secret(&mut reusable_password);
|
||||
return Ok(());
|
||||
}
|
||||
},
|
||||
AuthMethod::PromptPassword => {
|
||||
if let Some((mut password, save)) =
|
||||
prompt_secret(destination.original.clone(), None, true).await?
|
||||
{
|
||||
let accepted =
|
||||
authenticate_password(session, &destination.user, &password).await?;
|
||||
if accepted {
|
||||
if save {
|
||||
crate::ssh_credentials::store_password(
|
||||
&destination.original,
|
||||
&password,
|
||||
)?;
|
||||
}
|
||||
password.fill(0);
|
||||
clear_secret(&mut reusable_password);
|
||||
return Ok(());
|
||||
}
|
||||
password.fill(0);
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
if try_windows_agents(session, &destination.user).await {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let mut reusable_password = crate::ssh_credentials::load_stored_password(&destination.original)?;
|
||||
if let Some(password) = reusable_password.as_ref() {
|
||||
if authenticate_password(session, &destination.user, password).await? {
|
||||
clear_secret(&mut reusable_password);
|
||||
return Ok(());
|
||||
}
|
||||
if stored_password_was_present {
|
||||
crate::ssh_credentials::forget_password(&destination.original)?;
|
||||
clear_secret(&mut reusable_password);
|
||||
}
|
||||
|
||||
if let Some((mut password, save)) = prompt_secret(destination.original.clone(), None, true).await?
|
||||
{
|
||||
let accepted = authenticate_password(session, &destination.user, &password).await?;
|
||||
if accepted {
|
||||
if save {
|
||||
crate::ssh_credentials::store_password(&destination.original, &password)?;
|
||||
}
|
||||
password.fill(0);
|
||||
return Ok(());
|
||||
}
|
||||
reusable_password = Some(password);
|
||||
}
|
||||
|
||||
if try_keyboard_interactive(session, destination, reusable_password.as_deref()).await? {
|
||||
clear_secret(&mut reusable_password);
|
||||
return Ok(());
|
||||
}
|
||||
clear_secret(&mut reusable_password);
|
||||
Err("服务器拒绝了所有可用的 SSH 认证方式".into())
|
||||
Err(auth_failure(profile.auth, key_count).into())
|
||||
}
|
||||
|
||||
/// 在现有认证连接上打开独立 SFTP 子系统;连接池和认证策略仍只有一份。
|
||||
pub(crate) async fn open_sftp(
|
||||
raw_destination: &str,
|
||||
) -> Result<russh_sftp::client::SftpSession, SessionError> {
|
||||
let profiles_path = crate::display::nebula_data_dir().join("ssh_profiles.json");
|
||||
let raw = raw_destination.to_owned();
|
||||
let (destination, profile) = tokio::task::spawn_blocking(move || {
|
||||
let destination = SshDestination::resolve(&raw)?;
|
||||
let profiles = match crate::ssh_profiles::SshProfiles::load(&profiles_path) {
|
||||
Ok(profiles) => profiles,
|
||||
Err(err) => {
|
||||
warn!("加载 SSH Profile 失败,SFTP 使用自动认证: {err}");
|
||||
crate::ssh_profiles::SshProfiles::default()
|
||||
},
|
||||
};
|
||||
Ok::<_, io::Error>((destination, profiles.for_destination(&raw)))
|
||||
})
|
||||
.await
|
||||
.map_err(|err| format!("SSH 地址解析任务失败: {err}"))??;
|
||||
|
||||
if let Some(proxy_jump) = destination.proxy_jump.as_deref() {
|
||||
return Err(format!("当前 SFTP 模式尚未接入跳板机 {proxy_jump}").into());
|
||||
}
|
||||
|
||||
let session = authenticated_session(&destination, &profile).await?;
|
||||
let channel = {
|
||||
let session = session.lock().await;
|
||||
session.channel_open_session().await?
|
||||
};
|
||||
channel.request_subsystem(true, "sftp").await?;
|
||||
Ok(russh_sftp::client::SftpSession::new(channel.into_stream()).await?)
|
||||
}
|
||||
|
||||
fn auth_failure(mode: crate::ssh_profiles::SshAuthMode, key_count: usize) -> String {
|
||||
use crate::ssh_profiles::SshAuthMode;
|
||||
match mode {
|
||||
SshAuthMode::Auto if key_count >= 3 => format!(
|
||||
"服务器拒绝了自动认证;已尝试 {key_count} 把私钥,可能触发 MaxAuthTries,请明确选择一把密钥"
|
||||
),
|
||||
SshAuthMode::Auto => "服务器拒绝了所有可用的 SSH 认证方式".to_owned(),
|
||||
SshAuthMode::Password => "服务器拒绝了密码认证,未回退到其他认证方式".to_owned(),
|
||||
SshAuthMode::PublicKey if key_count == 0 => {
|
||||
"密钥认证没有可用的私钥,请选择私钥文件或配置 IdentityFile".to_owned()
|
||||
},
|
||||
SshAuthMode::PublicKey => "服务器拒绝了指定的私钥,未回退到密码认证".to_owned(),
|
||||
SshAuthMode::Agent => {
|
||||
"OpenSSH Agent 与 Pageant 均未提供可用身份,未回退到密码认证".to_owned()
|
||||
},
|
||||
SshAuthMode::KeyboardInteractive => {
|
||||
"服务器拒绝了 keyboard-interactive 认证,未回退到密码认证".to_owned()
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
async fn try_private_key(
|
||||
@@ -461,11 +626,15 @@ async fn try_private_key(
|
||||
destination: &SshDestination,
|
||||
path: &Path,
|
||||
) -> Result<bool, SessionError> {
|
||||
if !path.is_file() {
|
||||
return Ok(false);
|
||||
}
|
||||
let target = format!("密钥口令: {}", path.display());
|
||||
let mut stored = crate::ssh_credentials::load_stored_password(&target)?;
|
||||
let private_key = match std::fs::read(path) {
|
||||
Ok(private_key) => private_key,
|
||||
Err(err) => {
|
||||
warn!("无法读取 SSH 私钥 {}: {err}", path.display());
|
||||
return Ok(false);
|
||||
},
|
||||
};
|
||||
let prompt = format!("密钥口令: {}", path.display());
|
||||
let mut stored = crate::ssh_credentials::load_private_key_passphrase(&private_key)?;
|
||||
let mut key = stored
|
||||
.as_deref()
|
||||
.and_then(|bytes| std::str::from_utf8(bytes).ok())
|
||||
@@ -474,12 +643,12 @@ async fn try_private_key(
|
||||
|
||||
if key.is_none() {
|
||||
clear_secret(&mut stored);
|
||||
crate::ssh_credentials::forget_password(&target)?;
|
||||
if let Some((mut passphrase, save)) = prompt_secret(target.clone(), None, true).await? {
|
||||
crate::ssh_credentials::forget_private_key_passphrase(&private_key)?;
|
||||
if let Some((mut passphrase, save)) = prompt_secret(prompt, None, true).await? {
|
||||
let text = String::from_utf8_lossy(&passphrase).into_owned();
|
||||
key = russh::keys::load_secret_key(path, Some(&text)).ok();
|
||||
if key.is_some() && save {
|
||||
crate::ssh_credentials::store_password(&target, &passphrase)?;
|
||||
crate::ssh_credentials::store_private_key_passphrase(&private_key, &passphrase)?;
|
||||
}
|
||||
passphrase.fill(0);
|
||||
}
|
||||
@@ -506,15 +675,32 @@ async fn try_private_key(
|
||||
Ok(session.authenticate_publickey(&destination.user, key).await?.success())
|
||||
}
|
||||
|
||||
async fn try_windows_agents(session: &mut ClientSession, user: &str) -> bool {
|
||||
async fn try_windows_agents(
|
||||
session: &mut ClientSession,
|
||||
user: &str,
|
||||
preferred_private_keys: &[PathBuf],
|
||||
) -> bool {
|
||||
let preferred_public_keys = preferred_private_keys
|
||||
.iter()
|
||||
.filter_map(|path| {
|
||||
let public_path = PathBuf::from(format!("{}.pub", path.display()));
|
||||
russh::keys::load_public_key(public_path).ok()
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
const OPENSSH_AGENT: &str = r"\\.\pipe\openssh-ssh-agent";
|
||||
if let Ok(mut agent) = AgentClient::connect_named_pipe(OPENSSH_AGENT).await {
|
||||
if authenticate_agent(session, user, &mut agent).await.unwrap_or(false) {
|
||||
if authenticate_agent(session, user, &preferred_public_keys, &mut agent)
|
||||
.await
|
||||
.unwrap_or(false)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
if let Ok(mut agent) = AgentClient::connect_pageant().await {
|
||||
if authenticate_agent(session, user, &mut agent).await.unwrap_or(false) {
|
||||
if authenticate_agent(session, user, &preferred_public_keys, &mut agent)
|
||||
.await
|
||||
.unwrap_or(false)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -524,12 +710,16 @@ async fn try_windows_agents(session: &mut ClientSession, user: &str) -> bool {
|
||||
async fn authenticate_agent<S>(
|
||||
session: &mut ClientSession,
|
||||
user: &str,
|
||||
preferred_public_keys: &[ssh_key::PublicKey],
|
||||
agent: &mut AgentClient<S>,
|
||||
) -> Result<bool, SessionError>
|
||||
where
|
||||
S: AsyncRead + AsyncWrite + Unpin + Send,
|
||||
{
|
||||
let identities = agent.request_identities().await?;
|
||||
let mut identities = agent.request_identities().await?;
|
||||
identities.sort_by_key(|identity| {
|
||||
(!preferred_public_keys.iter().any(|key| key == identity.public_key().as_ref())) as u8
|
||||
});
|
||||
for identity in identities.into_iter().take(5) {
|
||||
let is_rsa = identity.public_key().algorithm().is_rsa();
|
||||
let hash = rsa_hash_for(session, is_rsa).await;
|
||||
@@ -572,9 +762,8 @@ async fn try_keyboard_interactive(
|
||||
destination: &SshDestination,
|
||||
password: Option<&[u8]>,
|
||||
) -> Result<bool, SessionError> {
|
||||
let mut state = session
|
||||
.authenticate_keyboard_interactive_start(&destination.user, None::<String>)
|
||||
.await?;
|
||||
let mut state =
|
||||
session.authenticate_keyboard_interactive_start(&destination.user, None::<String>).await?;
|
||||
for _ in 0..8 {
|
||||
match state {
|
||||
KeyboardInteractiveAuthResponse::Success => return Ok(true),
|
||||
@@ -591,10 +780,7 @@ async fn try_keyboard_interactive(
|
||||
}
|
||||
let label = format!(
|
||||
"{} - {} {} {}",
|
||||
destination.original,
|
||||
name,
|
||||
instructions,
|
||||
prompt.prompt
|
||||
destination.original, name, instructions, prompt.prompt
|
||||
);
|
||||
let Some((mut response, _)) = prompt_secret(label, None, false).await? else {
|
||||
return Ok(false);
|
||||
@@ -700,7 +886,9 @@ fn wide(value: &str) -> Vec<u16> {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{SshDestination, parse_resolved_config};
|
||||
use super::{AuthMethod, SshDestination, authentication_plan, parse_resolved_config};
|
||||
use crate::ssh_profiles::SshAuthMode;
|
||||
use std::path::PathBuf;
|
||||
|
||||
#[test]
|
||||
fn parses_saved_destinations() {
|
||||
@@ -722,11 +910,66 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn parses_resolved_ssh_config() {
|
||||
let config = "user deploy\nhostname server.internal\nport 2200\nidentityfile ~/.ssh/id_ed25519\n";
|
||||
let config =
|
||||
"user deploy\nhostname server.internal\nport 2200\nidentityfile ~/.ssh/id_ed25519\n";
|
||||
let destination = parse_resolved_config("prod", config).unwrap();
|
||||
assert_eq!(destination.user, "deploy");
|
||||
assert_eq!(destination.host, "server.internal");
|
||||
assert_eq!(destination.port, 2200);
|
||||
assert_eq!(destination.identity_files.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn auto_auth_plan_matches_tabby_order_and_deduplicates_keys() {
|
||||
let explicit = vec![PathBuf::from(r"C:\Keys\chosen"), PathBuf::from(r"c:\keys\CHOSEN")];
|
||||
let resolved = vec![PathBuf::from(r"C:\Keys\config")];
|
||||
|
||||
assert_eq!(
|
||||
authentication_plan(SshAuthMode::Auto, &explicit, &resolved),
|
||||
vec![
|
||||
AuthMethod::PrivateKey(PathBuf::from(r"C:\Keys\chosen")),
|
||||
AuthMethod::PrivateKey(PathBuf::from(r"C:\Keys\config")),
|
||||
AuthMethod::Agent,
|
||||
AuthMethod::StoredPassword,
|
||||
AuthMethod::KeyboardInteractive,
|
||||
AuthMethod::PromptPassword,
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn password_mode_never_falls_back_to_other_methods() {
|
||||
assert_eq!(
|
||||
authentication_plan(
|
||||
SshAuthMode::Password,
|
||||
&[PathBuf::from(r"C:\Keys\ignored")],
|
||||
&[PathBuf::from(r"C:\Keys\ignored-config")],
|
||||
),
|
||||
vec![AuthMethod::StoredPassword, AuthMethod::PromptPassword]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn public_key_mode_uses_only_key_sources() {
|
||||
assert_eq!(
|
||||
authentication_plan(
|
||||
SshAuthMode::PublicKey,
|
||||
&[PathBuf::from(r"C:\Keys\chosen")],
|
||||
&[PathBuf::from(r"C:\Keys\config")],
|
||||
),
|
||||
vec![
|
||||
AuthMethod::PrivateKey(PathBuf::from(r"C:\Keys\chosen")),
|
||||
AuthMethod::PrivateKey(PathBuf::from(r"C:\Keys\config")),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn agent_and_interactive_modes_are_strict() {
|
||||
assert_eq!(authentication_plan(SshAuthMode::Agent, &[], &[]), vec![AuthMethod::Agent]);
|
||||
assert_eq!(
|
||||
authentication_plan(SshAuthMode::KeyboardInteractive, &[], &[]),
|
||||
vec![AuthMethod::KeyboardInteractive]
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,782 @@
|
||||
use std::collections::HashSet;
|
||||
use std::future::Future;
|
||||
use std::io;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
|
||||
use std::sync::{Arc, Mutex, MutexGuard};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use russh_sftp::client::SftpSession;
|
||||
use russh_sftp::protocol::{FileType, OpenFlags};
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
use winit::event_loop::EventLoopProxy;
|
||||
use winit::window::WindowId;
|
||||
|
||||
use crate::event::{Event, EventType};
|
||||
|
||||
type SftpError = Box<dyn std::error::Error + Send + Sync>;
|
||||
type SftpResult<T> = Result<T, SftpError>;
|
||||
|
||||
const TRANSFER_CHUNK: usize = 256 * 1024;
|
||||
const MAX_RECURSIVE_ENTRIES: usize = 100_000;
|
||||
static TRANSFER_NONCE: AtomicU64 = AtomicU64::new(1);
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum SftpEntryKind {
|
||||
Directory,
|
||||
File,
|
||||
Symlink,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct SftpEntry {
|
||||
pub name: String,
|
||||
pub path: String,
|
||||
pub kind: SftpEntryKind,
|
||||
pub size: u64,
|
||||
pub modified: u64,
|
||||
pub permissions: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct TransferProgress {
|
||||
pub label: String,
|
||||
pub transferred: u64,
|
||||
pub total: u64,
|
||||
}
|
||||
|
||||
impl TransferProgress {
|
||||
pub fn new(label: impl Into<String>, total: u64) -> Self {
|
||||
Self { label: label.into(), transferred: 0, total }
|
||||
}
|
||||
|
||||
pub fn advance(&mut self, bytes: u64) {
|
||||
self.transferred = self.transferred.saturating_add(bytes).min(self.total);
|
||||
}
|
||||
|
||||
pub fn fraction(&self) -> f32 {
|
||||
if self.total == 0 { 1.0 } else { self.transferred as f32 / self.total as f32 }
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum SftpPhase {
|
||||
Connecting,
|
||||
Loading,
|
||||
Ready,
|
||||
Working,
|
||||
Error,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct SftpSnapshot {
|
||||
pub destination: String,
|
||||
pub path: String,
|
||||
pub entries: Vec<SftpEntry>,
|
||||
pub phase: SftpPhase,
|
||||
pub error: Option<String>,
|
||||
pub progress: Option<TransferProgress>,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct SftpController {
|
||||
state: Arc<Mutex<SftpSnapshot>>,
|
||||
cancel: Arc<AtomicBool>,
|
||||
generation: Arc<AtomicU64>,
|
||||
proxy: EventLoopProxy<Event>,
|
||||
window_id: WindowId,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct TaskContext {
|
||||
state: Arc<Mutex<SftpSnapshot>>,
|
||||
cancel: Arc<AtomicBool>,
|
||||
generation: Arc<AtomicU64>,
|
||||
task_generation: u64,
|
||||
proxy: EventLoopProxy<Event>,
|
||||
window_id: WindowId,
|
||||
last_wake: Arc<Mutex<Instant>>,
|
||||
}
|
||||
|
||||
impl SftpEntry {
|
||||
#[cfg(test)]
|
||||
fn test(name: &str, kind: SftpEntryKind) -> Self {
|
||||
Self {
|
||||
name: name.to_owned(),
|
||||
path: format!("/{name}"),
|
||||
kind,
|
||||
size: 0,
|
||||
modified: 0,
|
||||
permissions: String::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl SftpController {
|
||||
pub fn new(
|
||||
destination: impl Into<String>,
|
||||
proxy: EventLoopProxy<Event>,
|
||||
window_id: WindowId,
|
||||
) -> io::Result<Self> {
|
||||
crate::ssh_session::runtime()?;
|
||||
let controller = Self {
|
||||
state: Arc::new(Mutex::new(SftpSnapshot {
|
||||
destination: destination.into(),
|
||||
path: ".".to_owned(),
|
||||
entries: Vec::new(),
|
||||
phase: SftpPhase::Connecting,
|
||||
error: None,
|
||||
progress: None,
|
||||
})),
|
||||
cancel: Arc::new(AtomicBool::new(false)),
|
||||
generation: Arc::new(AtomicU64::new(0)),
|
||||
proxy,
|
||||
window_id,
|
||||
};
|
||||
controller.refresh(".");
|
||||
Ok(controller)
|
||||
}
|
||||
|
||||
pub fn snapshot(&self) -> SftpSnapshot {
|
||||
lock(&self.state).clone()
|
||||
}
|
||||
|
||||
pub fn refresh(&self, requested_path: impl Into<String>) {
|
||||
let requested_path = requested_path.into();
|
||||
let destination = self.snapshot().destination;
|
||||
self.start_job(SftpPhase::Loading, None, move |_context| async move {
|
||||
let sftp = crate::ssh_session::open_sftp(&destination).await?;
|
||||
let path = sftp.canonicalize(requested_path).await?;
|
||||
let entries = read_remote_dir(&sftp, &path).await?;
|
||||
Ok((path, entries))
|
||||
});
|
||||
}
|
||||
|
||||
pub fn upload_paths(&self, local_paths: Vec<PathBuf>) {
|
||||
if local_paths.is_empty() {
|
||||
return;
|
||||
}
|
||||
let snapshot = self.snapshot();
|
||||
let destination = snapshot.destination;
|
||||
let remote_dir = snapshot.path;
|
||||
let label = if local_paths.len() == 1 {
|
||||
local_paths[0]
|
||||
.file_name()
|
||||
.map(|name| name.to_string_lossy().into_owned())
|
||||
.unwrap_or_else(|| "上传".to_owned())
|
||||
} else {
|
||||
format!("上传 {} 项", local_paths.len())
|
||||
};
|
||||
self.start_job(
|
||||
SftpPhase::Working,
|
||||
Some(TransferProgress::new(label, 0)),
|
||||
move |context| async move {
|
||||
let sftp = crate::ssh_session::open_sftp(&destination).await?;
|
||||
upload_local_paths(&sftp, local_paths, &remote_dir, &context).await?;
|
||||
let entries = read_remote_dir(&sftp, &remote_dir).await?;
|
||||
Ok((remote_dir, entries))
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
pub fn download(&self, entry: SftpEntry, local_directory: PathBuf) {
|
||||
let snapshot = self.snapshot();
|
||||
let destination = snapshot.destination;
|
||||
let path = snapshot.path;
|
||||
let progress = TransferProgress::new(entry.name.clone(), entry.size);
|
||||
self.start_job(SftpPhase::Working, Some(progress), move |context| async move {
|
||||
let sftp = crate::ssh_session::open_sftp(&destination).await?;
|
||||
download_remote_entry(&sftp, entry, local_directory, &context).await?;
|
||||
let entries = read_remote_dir(&sftp, &path).await?;
|
||||
Ok((path, entries))
|
||||
});
|
||||
}
|
||||
|
||||
pub fn create_directory(&self, name: &str) -> Result<(), String> {
|
||||
let name = validate_name(name).map_err(str::to_owned)?.to_owned();
|
||||
let snapshot = self.snapshot();
|
||||
let destination = snapshot.destination;
|
||||
let path = snapshot.path;
|
||||
let new_path = normalize_remote_path(&path, &name);
|
||||
self.start_job(SftpPhase::Working, None, move |_context| async move {
|
||||
let sftp = crate::ssh_session::open_sftp(&destination).await?;
|
||||
sftp.create_dir(new_path).await?;
|
||||
let entries = read_remote_dir(&sftp, &path).await?;
|
||||
Ok((path, entries))
|
||||
});
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn rename(&self, entry: SftpEntry, name: &str) -> Result<(), String> {
|
||||
let name = validate_name(name).map_err(str::to_owned)?.to_owned();
|
||||
let snapshot = self.snapshot();
|
||||
let destination = snapshot.destination;
|
||||
let path = snapshot.path;
|
||||
let new_path = normalize_remote_path(&path, &name);
|
||||
self.start_job(SftpPhase::Working, None, move |_context| async move {
|
||||
let sftp = crate::ssh_session::open_sftp(&destination).await?;
|
||||
sftp.rename(entry.path, new_path).await?;
|
||||
let entries = read_remote_dir(&sftp, &path).await?;
|
||||
Ok((path, entries))
|
||||
});
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn delete(&self, entry: SftpEntry) {
|
||||
let snapshot = self.snapshot();
|
||||
let destination = snapshot.destination;
|
||||
let path = snapshot.path;
|
||||
self.start_job(SftpPhase::Working, None, move |context| async move {
|
||||
let sftp = crate::ssh_session::open_sftp(&destination).await?;
|
||||
delete_remote_entry(&sftp, entry, &context).await?;
|
||||
let entries = read_remote_dir(&sftp, &path).await?;
|
||||
Ok((path, entries))
|
||||
});
|
||||
}
|
||||
|
||||
pub fn cancel(&self) {
|
||||
self.cancel.store(true, Ordering::Release);
|
||||
let mut state = lock(&self.state);
|
||||
if state.phase == SftpPhase::Working {
|
||||
state.error = Some("正在取消传输…".to_owned());
|
||||
}
|
||||
drop(state);
|
||||
wake(&self.proxy, self.window_id);
|
||||
}
|
||||
|
||||
fn start_job<J, F>(&self, phase: SftpPhase, progress: Option<TransferProgress>, job: J)
|
||||
where
|
||||
J: FnOnce(TaskContext) -> F + Send + 'static,
|
||||
F: Future<Output = SftpResult<(String, Vec<SftpEntry>)>> + Send + 'static,
|
||||
{
|
||||
self.cancel.store(false, Ordering::Release);
|
||||
let task_generation = self.generation.fetch_add(1, Ordering::AcqRel) + 1;
|
||||
{
|
||||
let mut state = lock(&self.state);
|
||||
state.phase = phase;
|
||||
state.error = None;
|
||||
state.progress = progress;
|
||||
}
|
||||
wake(&self.proxy, self.window_id);
|
||||
|
||||
let context = TaskContext {
|
||||
state: self.state.clone(),
|
||||
cancel: self.cancel.clone(),
|
||||
generation: self.generation.clone(),
|
||||
task_generation,
|
||||
proxy: self.proxy.clone(),
|
||||
window_id: self.window_id,
|
||||
last_wake: Arc::new(Mutex::new(Instant::now() - Duration::from_secs(1))),
|
||||
};
|
||||
let completion = context.clone();
|
||||
crate::ssh_session::runtime().expect("SFTP runtime checked at construction").spawn(
|
||||
async move {
|
||||
let result = job(context).await;
|
||||
completion.finish(result);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
impl TaskContext {
|
||||
fn check_cancelled(&self) -> SftpResult<()> {
|
||||
if self.cancel.load(Ordering::Acquire) || !self.is_current() {
|
||||
Err(io::Error::new(io::ErrorKind::Interrupted, "操作已取消").into())
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn set_total(&self, total: u64) {
|
||||
if !self.is_current() {
|
||||
return;
|
||||
}
|
||||
if let Some(progress) = lock(&self.state).progress.as_mut() {
|
||||
progress.total = total;
|
||||
progress.transferred = progress.transferred.min(total);
|
||||
}
|
||||
self.wake_throttled(true);
|
||||
}
|
||||
|
||||
fn advance(&self, bytes: u64) {
|
||||
if !self.is_current() {
|
||||
return;
|
||||
}
|
||||
if let Some(progress) = lock(&self.state).progress.as_mut() {
|
||||
progress.advance(bytes);
|
||||
}
|
||||
self.wake_throttled(false);
|
||||
}
|
||||
|
||||
fn is_current(&self) -> bool {
|
||||
self.generation.load(Ordering::Acquire) == self.task_generation
|
||||
}
|
||||
|
||||
fn wake_throttled(&self, force: bool) {
|
||||
let mut last = lock(&self.last_wake);
|
||||
if force || last.elapsed() >= Duration::from_millis(50) {
|
||||
*last = Instant::now();
|
||||
wake(&self.proxy, self.window_id);
|
||||
}
|
||||
}
|
||||
|
||||
fn finish(&self, result: SftpResult<(String, Vec<SftpEntry>)>) {
|
||||
if !self.is_current() {
|
||||
return;
|
||||
}
|
||||
let mut state = lock(&self.state);
|
||||
match result {
|
||||
Ok((path, entries)) => {
|
||||
state.path = path;
|
||||
state.entries = entries;
|
||||
state.phase = SftpPhase::Ready;
|
||||
state.error = None;
|
||||
state.progress = None;
|
||||
},
|
||||
Err(err) => {
|
||||
state.phase = SftpPhase::Error;
|
||||
state.error = Some(err.to_string());
|
||||
state.progress = None;
|
||||
},
|
||||
}
|
||||
drop(state);
|
||||
wake(&self.proxy, self.window_id);
|
||||
}
|
||||
}
|
||||
|
||||
fn lock<T>(mutex: &Mutex<T>) -> MutexGuard<'_, T> {
|
||||
mutex.lock().unwrap_or_else(|poisoned| poisoned.into_inner())
|
||||
}
|
||||
|
||||
fn wake(proxy: &EventLoopProxy<Event>, window_id: WindowId) {
|
||||
let _ = proxy.send_event(Event::new(EventType::SftpUpdated, window_id));
|
||||
}
|
||||
|
||||
pub fn normalize_remote_path(base: &str, path: &str) -> String {
|
||||
let normalized_path = path.replace('\\', "/");
|
||||
let mut components = Vec::new();
|
||||
|
||||
if !normalized_path.starts_with('/') {
|
||||
components.extend(base.replace('\\', "/").split('/').map(str::to_owned));
|
||||
}
|
||||
components.extend(normalized_path.split('/').map(str::to_owned));
|
||||
|
||||
let mut resolved = Vec::new();
|
||||
for component in components {
|
||||
match component.as_str() {
|
||||
"" | "." => {},
|
||||
".." => {
|
||||
resolved.pop();
|
||||
},
|
||||
_ => resolved.push(component),
|
||||
}
|
||||
}
|
||||
|
||||
if resolved.is_empty() { "/".to_owned() } else { format!("/{}", resolved.join("/")) }
|
||||
}
|
||||
|
||||
pub fn validate_name(name: &str) -> Result<&str, &'static str> {
|
||||
if name.is_empty() || matches!(name, "." | "..") {
|
||||
return Err("名称不能为空,也不能使用 . 或 ..");
|
||||
}
|
||||
if name.contains(['/', '\\', '\0']) {
|
||||
return Err("名称不能包含路径分隔符或空字符");
|
||||
}
|
||||
Ok(name)
|
||||
}
|
||||
|
||||
pub fn temporary_upload_path(destination: &str, nonce: u64) -> String {
|
||||
let normalized = normalize_remote_path("/", destination);
|
||||
let (parent, name) = normalized.rsplit_once('/').unwrap_or(("", normalized.as_str()));
|
||||
let parent = if parent.is_empty() { "/" } else { parent };
|
||||
let separator = if parent == "/" { "" } else { "/" };
|
||||
format!("{parent}{separator}.{name}.nebula-upload-{nonce:016x}")
|
||||
}
|
||||
|
||||
pub fn sort_entries(entries: &mut [SftpEntry]) {
|
||||
entries.sort_by(|left, right| {
|
||||
let left_rank = !matches!(left.kind, SftpEntryKind::Directory);
|
||||
let right_rank = !matches!(right.kind, SftpEntryKind::Directory);
|
||||
left_rank
|
||||
.cmp(&right_rank)
|
||||
.then_with(|| left.name.to_lowercase().cmp(&right.name.to_lowercase()))
|
||||
.then_with(|| left.name.cmp(&right.name))
|
||||
});
|
||||
}
|
||||
|
||||
async fn read_remote_dir(sftp: &SftpSession, path: &str) -> SftpResult<Vec<SftpEntry>> {
|
||||
let mut entries = Vec::new();
|
||||
for entry in sftp.read_dir(path.to_owned()).await? {
|
||||
let metadata = entry.metadata();
|
||||
let kind = match metadata.file_type() {
|
||||
FileType::Dir => SftpEntryKind::Directory,
|
||||
FileType::Symlink => SftpEntryKind::Symlink,
|
||||
FileType::File | FileType::Other => SftpEntryKind::File,
|
||||
};
|
||||
let type_prefix = match kind {
|
||||
SftpEntryKind::Directory => 'd',
|
||||
SftpEntryKind::Symlink => 'l',
|
||||
SftpEntryKind::File => '-',
|
||||
};
|
||||
entries.push(SftpEntry {
|
||||
name: entry.file_name(),
|
||||
path: entry.path(),
|
||||
kind,
|
||||
size: metadata.len(),
|
||||
modified: u64::from(metadata.mtime.unwrap_or(0)),
|
||||
permissions: format!("{type_prefix}{}", metadata.permissions()),
|
||||
});
|
||||
}
|
||||
sort_entries(&mut entries);
|
||||
Ok(entries)
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct UploadPlan {
|
||||
directories: Vec<String>,
|
||||
files: Vec<(PathBuf, String, u64)>,
|
||||
total: u64,
|
||||
}
|
||||
|
||||
fn build_upload_plan(local_paths: Vec<PathBuf>, remote_dir: String) -> SftpResult<UploadPlan> {
|
||||
let mut directories = Vec::new();
|
||||
let mut files = Vec::new();
|
||||
let mut total = 0u64;
|
||||
let mut stack = Vec::new();
|
||||
|
||||
for local in local_paths.into_iter().rev() {
|
||||
let name = local
|
||||
.file_name()
|
||||
.and_then(|name| name.to_str())
|
||||
.ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "本地路径缺少有效名称"))?
|
||||
.to_owned();
|
||||
validate_name(&name)
|
||||
.map_err(|message| io::Error::new(io::ErrorKind::InvalidInput, message))?;
|
||||
stack.push((local, normalize_remote_path(&remote_dir, &name)));
|
||||
}
|
||||
|
||||
while let Some((local, remote)) = stack.pop() {
|
||||
if files.len() + directories.len() >= MAX_RECURSIVE_ENTRIES {
|
||||
return Err(io::Error::other("上传目录超过 100000 项,已停止").into());
|
||||
}
|
||||
let metadata = std::fs::symlink_metadata(&local)?;
|
||||
if metadata.file_type().is_symlink() {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::InvalidInput,
|
||||
format!("暂不上传本地符号链接: {}", local.display()),
|
||||
)
|
||||
.into());
|
||||
}
|
||||
if metadata.is_dir() {
|
||||
directories.push(remote.clone());
|
||||
let mut children = std::fs::read_dir(&local)?.collect::<Result<Vec<_>, _>>()?;
|
||||
children.sort_by_key(|entry| entry.file_name().to_string_lossy().to_lowercase());
|
||||
for child in children.into_iter().rev() {
|
||||
let name = child.file_name().to_string_lossy().into_owned();
|
||||
validate_name(&name)
|
||||
.map_err(|message| io::Error::new(io::ErrorKind::InvalidInput, message))?;
|
||||
stack.push((child.path(), normalize_remote_path(&remote, &name)));
|
||||
}
|
||||
} else if metadata.is_file() {
|
||||
total = total.saturating_add(metadata.len());
|
||||
files.push((local, remote, metadata.len()));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(UploadPlan { directories, files, total })
|
||||
}
|
||||
|
||||
async fn upload_local_paths(
|
||||
sftp: &SftpSession,
|
||||
local_paths: Vec<PathBuf>,
|
||||
remote_dir: &str,
|
||||
context: &TaskContext,
|
||||
) -> SftpResult<()> {
|
||||
let remote_dir = remote_dir.to_owned();
|
||||
let plan = tokio::task::spawn_blocking(move || build_upload_plan(local_paths, remote_dir))
|
||||
.await
|
||||
.map_err(|err| format!("扫描上传目录失败: {err}"))??;
|
||||
context.set_total(plan.total);
|
||||
|
||||
for directory in plan.directories {
|
||||
context.check_cancelled()?;
|
||||
// 与 Tabby 一致:递归上传允许目标目录已存在。
|
||||
let _ = sftp.create_dir(directory).await;
|
||||
}
|
||||
for (local, remote, _) in plan.files {
|
||||
context.check_cancelled()?;
|
||||
upload_file_atomic(sftp, &local, &remote, context).await?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn upload_file_atomic(
|
||||
sftp: &SftpSession,
|
||||
local: &Path,
|
||||
destination: &str,
|
||||
context: &TaskContext,
|
||||
) -> SftpResult<()> {
|
||||
let nonce = TRANSFER_NONCE.fetch_add(1, Ordering::Relaxed);
|
||||
let temporary = temporary_upload_path(destination, nonce);
|
||||
let result = async {
|
||||
let mut source = tokio::fs::File::open(local).await?;
|
||||
let mut target = sftp
|
||||
.open_with_flags(
|
||||
temporary.clone(),
|
||||
OpenFlags::CREATE | OpenFlags::TRUNCATE | OpenFlags::WRITE,
|
||||
)
|
||||
.await?;
|
||||
let mut buffer = vec![0u8; TRANSFER_CHUNK];
|
||||
loop {
|
||||
context.check_cancelled()?;
|
||||
let count = source.read(&mut buffer).await?;
|
||||
if count == 0 {
|
||||
break;
|
||||
}
|
||||
target.write_all(&buffer[..count]).await?;
|
||||
context.advance(count as u64);
|
||||
}
|
||||
target.shutdown().await?;
|
||||
|
||||
// SFTP v3 rename通常不覆盖;与 Tabby 一样先删除旧目标,再替换临时文件。
|
||||
let _ = sftp.remove_file(destination.to_owned()).await;
|
||||
sftp.rename(temporary.clone(), destination.to_owned()).await?;
|
||||
Ok::<_, SftpError>(())
|
||||
}
|
||||
.await;
|
||||
|
||||
if result.is_err() {
|
||||
let _ = sftp.remove_file(temporary).await;
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct DownloadPlan {
|
||||
directories: Vec<PathBuf>,
|
||||
files: Vec<(String, PathBuf, u64)>,
|
||||
total: u64,
|
||||
}
|
||||
|
||||
async fn build_download_plan(
|
||||
sftp: &SftpSession,
|
||||
entry: SftpEntry,
|
||||
local_directory: PathBuf,
|
||||
context: &TaskContext,
|
||||
) -> SftpResult<DownloadPlan> {
|
||||
validate_name(&entry.name)
|
||||
.map_err(|message| io::Error::new(io::ErrorKind::InvalidData, message))?;
|
||||
let mut directories = Vec::new();
|
||||
let mut files = Vec::new();
|
||||
let mut total = 0u64;
|
||||
let mut stack = vec![(entry, local_directory)];
|
||||
let mut visited_directories = HashSet::new();
|
||||
|
||||
while let Some((entry, parent)) = stack.pop() {
|
||||
context.check_cancelled()?;
|
||||
if files.len() + directories.len() >= MAX_RECURSIVE_ENTRIES {
|
||||
return Err(io::Error::other("下载目录超过 100000 项,已停止").into());
|
||||
}
|
||||
validate_name(&entry.name)
|
||||
.map_err(|message| io::Error::new(io::ErrorKind::InvalidData, message))?;
|
||||
let local = parent.join(&entry.name);
|
||||
let (remote, kind, size) = if entry.kind == SftpEntryKind::Symlink {
|
||||
let target = sftp.read_link(entry.path.clone()).await?;
|
||||
let parent = entry.path.rsplit_once('/').map(|(parent, _)| parent).unwrap_or("/");
|
||||
let target = normalize_remote_path(parent, &target);
|
||||
let metadata = sftp.metadata(target.clone()).await?;
|
||||
let kind =
|
||||
if metadata.is_dir() { SftpEntryKind::Directory } else { SftpEntryKind::File };
|
||||
(target, kind, metadata.len())
|
||||
} else {
|
||||
(entry.path, entry.kind, entry.size)
|
||||
};
|
||||
|
||||
if kind == SftpEntryKind::Directory {
|
||||
if !visited_directories.insert(remote.clone()) {
|
||||
return Err(
|
||||
io::Error::other(format!("检测到远端符号链接目录循环: {remote}")).into()
|
||||
);
|
||||
}
|
||||
directories.push(local.clone());
|
||||
let children = read_remote_dir(sftp, &remote).await?;
|
||||
for child in children.into_iter().rev() {
|
||||
stack.push((child, local.clone()));
|
||||
}
|
||||
} else {
|
||||
total = total.saturating_add(size);
|
||||
files.push((remote, local, size));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(DownloadPlan { directories, files, total })
|
||||
}
|
||||
|
||||
async fn download_remote_entry(
|
||||
sftp: &SftpSession,
|
||||
entry: SftpEntry,
|
||||
local_directory: PathBuf,
|
||||
context: &TaskContext,
|
||||
) -> SftpResult<()> {
|
||||
let plan = build_download_plan(sftp, entry, local_directory, context).await?;
|
||||
context.set_total(plan.total);
|
||||
for directory in plan.directories {
|
||||
context.check_cancelled()?;
|
||||
tokio::fs::create_dir_all(directory).await?;
|
||||
}
|
||||
for (remote, local, _) in plan.files {
|
||||
context.check_cancelled()?;
|
||||
download_file_atomic(sftp, &remote, &local, context).await?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn download_file_atomic(
|
||||
sftp: &SftpSession,
|
||||
remote: &str,
|
||||
destination: &Path,
|
||||
context: &TaskContext,
|
||||
) -> SftpResult<()> {
|
||||
if let Some(parent) = destination.parent() {
|
||||
tokio::fs::create_dir_all(parent).await?;
|
||||
}
|
||||
let name = destination
|
||||
.file_name()
|
||||
.and_then(|name| name.to_str())
|
||||
.ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "下载路径缺少有效文件名"))?;
|
||||
let nonce = TRANSFER_NONCE.fetch_add(1, Ordering::Relaxed);
|
||||
let temporary = destination.with_file_name(format!(".{name}.nebula-download-{nonce:016x}"));
|
||||
let result = async {
|
||||
let mut source = sftp.open(remote.to_owned()).await?;
|
||||
let mut target = tokio::fs::File::create(&temporary).await?;
|
||||
let mut buffer = vec![0u8; TRANSFER_CHUNK];
|
||||
loop {
|
||||
context.check_cancelled()?;
|
||||
let count = source.read(&mut buffer).await?;
|
||||
if count == 0 {
|
||||
break;
|
||||
}
|
||||
target.write_all(&buffer[..count]).await?;
|
||||
context.advance(count as u64);
|
||||
}
|
||||
target.flush().await?;
|
||||
target.shutdown().await?;
|
||||
let _ = tokio::fs::remove_file(destination).await;
|
||||
tokio::fs::rename(&temporary, destination).await?;
|
||||
Ok::<_, SftpError>(())
|
||||
}
|
||||
.await;
|
||||
|
||||
if result.is_err() {
|
||||
let _ = tokio::fs::remove_file(temporary).await;
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
async fn delete_remote_entry(
|
||||
sftp: &SftpSession,
|
||||
entry: SftpEntry,
|
||||
context: &TaskContext,
|
||||
) -> SftpResult<()> {
|
||||
enum Step {
|
||||
Visit(SftpEntry),
|
||||
RemoveDirectory(String),
|
||||
}
|
||||
|
||||
let mut stack = vec![Step::Visit(entry)];
|
||||
let mut visited = 0usize;
|
||||
while let Some(step) = stack.pop() {
|
||||
context.check_cancelled()?;
|
||||
visited += 1;
|
||||
if visited > MAX_RECURSIVE_ENTRIES {
|
||||
return Err(io::Error::other("删除目录超过 100000 项,已停止").into());
|
||||
}
|
||||
match step {
|
||||
Step::Visit(entry) if entry.kind == SftpEntryKind::Directory => {
|
||||
let children = read_remote_dir(sftp, &entry.path).await?;
|
||||
stack.push(Step::RemoveDirectory(entry.path));
|
||||
for child in children.into_iter().rev() {
|
||||
stack.push(Step::Visit(child));
|
||||
}
|
||||
},
|
||||
Step::Visit(entry) => sftp.remove_file(entry.path).await?,
|
||||
Step::RemoveDirectory(path) => sftp.remove_dir(path).await?,
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{
|
||||
SftpEntry, SftpEntryKind, TransferProgress, normalize_remote_path, sort_entries,
|
||||
temporary_upload_path, validate_name,
|
||||
};
|
||||
|
||||
#[test]
|
||||
fn remote_paths_are_posix_and_cannot_escape_root() {
|
||||
assert_eq!(normalize_remote_path("/home/dev", "../logs"), "/home/logs");
|
||||
assert_eq!(normalize_remote_path("/", "../../etc"), "/etc");
|
||||
assert_eq!(normalize_remote_path("/home/dev", r"child\file"), "/home/dev/child/file");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_and_rename_reject_path_separators_and_special_names() {
|
||||
for invalid in ["", ".", "..", "folder/name", r"folder\name", "bad\0name"] {
|
||||
assert!(validate_name(invalid).is_err(), "{invalid:?} should be rejected");
|
||||
}
|
||||
assert_eq!(validate_name("release-assets").unwrap(), "release-assets");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn directories_sort_before_files_then_by_name() {
|
||||
let mut entries = vec![
|
||||
SftpEntry::test("z.txt", SftpEntryKind::File),
|
||||
SftpEntry::test("Beta", SftpEntryKind::Directory),
|
||||
SftpEntry::test("alpha", SftpEntryKind::Directory),
|
||||
SftpEntry::test("A.txt", SftpEntryKind::File),
|
||||
];
|
||||
sort_entries(&mut entries);
|
||||
|
||||
assert_eq!(
|
||||
entries.iter().map(|entry| entry.name.as_str()).collect::<Vec<_>>(),
|
||||
vec!["alpha", "Beta", "A.txt", "z.txt"]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn temporary_upload_stays_beside_destination_and_is_hidden() {
|
||||
assert_eq!(
|
||||
temporary_upload_path("/home/dev/release.zip", 0x2a),
|
||||
"/home/dev/.release.zip.nebula-upload-000000000000002a"
|
||||
);
|
||||
assert_eq!(
|
||||
temporary_upload_path("/release.zip", 0),
|
||||
"/.release.zip.nebula-upload-0000000000000000"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transfer_progress_never_exceeds_total() {
|
||||
let mut progress = TransferProgress::new("release.zip", 10);
|
||||
progress.advance(6);
|
||||
progress.advance(8);
|
||||
|
||||
assert_eq!(progress.transferred, 10);
|
||||
assert_eq!(progress.fraction(), 1.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn symlinks_sort_with_files_but_keep_their_kind() {
|
||||
let mut entries = vec![
|
||||
SftpEntry::test("z-link", SftpEntryKind::Symlink),
|
||||
SftpEntry::test("folder", SftpEntryKind::Directory),
|
||||
SftpEntry::test("a.txt", SftpEntryKind::File),
|
||||
];
|
||||
sort_entries(&mut entries);
|
||||
|
||||
assert_eq!(entries[0].kind, SftpEntryKind::Directory);
|
||||
assert_eq!(entries[1].name, "a.txt");
|
||||
assert_eq!(entries[2].kind, SftpEntryKind::Symlink);
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ use std::ffi::OsStr;
|
||||
use std::io::{Error, Result};
|
||||
use std::os::windows::ffi::OsStrExt;
|
||||
use std::os::windows::io::IntoRawHandle;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::{mem, ptr};
|
||||
|
||||
use windows_sys::Win32::Foundation::{HANDLE, S_OK};
|
||||
@@ -83,21 +84,16 @@ impl ConptyApi {
|
||||
fn load_conpty() -> Option<Self> {
|
||||
type LoadedFn = unsafe extern "system" fn() -> isize;
|
||||
|
||||
// Only sideload when BOTH pieces sit next to nebula.exe, and load the
|
||||
// DLL by absolute path. A stray conpty.dll picked up from the search
|
||||
// path — or one whose OpenConsole.exe is missing — can silently run
|
||||
// the in-box host while `sideloaded` stays true, and then the
|
||||
// pre-primed DA1 answer leaks into the shell as typed input.
|
||||
// 只接受同一候选目录中的完整文件对,避免把半套 runtime 与根目录混用。
|
||||
// DLL 始终按绝对路径加载,不能让 PATH 中的同名文件进入认证边界。
|
||||
let exe_dir = std::env::current_exe().ok()?.parent()?.to_path_buf();
|
||||
if !exe_dir.join("OpenConsole.exe").is_file() {
|
||||
info!("OpenConsole.exe not found beside the executable; using in-box ConPTY");
|
||||
let Some(conpty_dir) = bundled_conpty_dir(&exe_dir) else {
|
||||
info!(
|
||||
"complete conpty.dll/OpenConsole.exe pair not found in runtime/ or executable directory; using in-box ConPTY"
|
||||
);
|
||||
return None;
|
||||
}
|
||||
let dll_path = exe_dir.join("conpty.dll");
|
||||
if !dll_path.is_file() {
|
||||
info!("conpty.dll not found beside the executable; using in-box ConPTY");
|
||||
return None;
|
||||
}
|
||||
};
|
||||
let dll_path = conpty_dir.join("conpty.dll");
|
||||
let dll_wide: Vec<u16> =
|
||||
dll_path.as_os_str().encode_wide().chain(std::iter::once(0)).collect();
|
||||
|
||||
@@ -120,6 +116,12 @@ impl ConptyApi {
|
||||
}
|
||||
}
|
||||
|
||||
fn bundled_conpty_dir(exe_dir: &Path) -> Option<PathBuf> {
|
||||
[exe_dir.join("runtime"), exe_dir.to_path_buf()]
|
||||
.into_iter()
|
||||
.find(|dir| dir.join("conpty.dll").is_file() && dir.join("OpenConsole.exe").is_file())
|
||||
}
|
||||
|
||||
/// RAII Pseudoconsole.
|
||||
pub struct Conpty {
|
||||
pub handle: HPCON,
|
||||
@@ -372,3 +374,70 @@ impl From<WindowSize> for COORD {
|
||||
COORD { X: columns as i16, Y: lines as i16 }
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod runtime_asset_tests {
|
||||
use super::bundled_conpty_dir;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
|
||||
static NEXT_DIR: AtomicUsize = AtomicUsize::new(0);
|
||||
|
||||
struct TestDir(PathBuf);
|
||||
|
||||
impl TestDir {
|
||||
fn new(name: &str) -> Self {
|
||||
let sequence = NEXT_DIR.fetch_add(1, Ordering::Relaxed);
|
||||
let path = std::env::temp_dir()
|
||||
.join(format!("nebula-conpty-{name}-{}-{sequence}", std::process::id()));
|
||||
std::fs::create_dir_all(&path).unwrap();
|
||||
Self(path)
|
||||
}
|
||||
|
||||
fn path(&self) -> &Path {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for TestDir {
|
||||
fn drop(&mut self) {
|
||||
let _ = std::fs::remove_dir_all(&self.0);
|
||||
}
|
||||
}
|
||||
|
||||
fn write_pair(dir: &Path) {
|
||||
std::fs::create_dir_all(dir).unwrap();
|
||||
std::fs::write(dir.join("conpty.dll"), b"dll").unwrap();
|
||||
std::fs::write(dir.join("OpenConsole.exe"), b"host").unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn conpty_prefers_complete_runtime_pair() {
|
||||
let dir = TestDir::new("runtime");
|
||||
write_pair(dir.path());
|
||||
write_pair(&dir.path().join("runtime"));
|
||||
|
||||
assert_eq!(bundled_conpty_dir(dir.path()), Some(dir.path().join("runtime")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn conpty_does_not_mix_partial_runtime_with_legacy_pair() {
|
||||
let dir = TestDir::new("partial");
|
||||
write_pair(dir.path());
|
||||
let runtime = dir.path().join("runtime");
|
||||
std::fs::create_dir(&runtime).unwrap();
|
||||
std::fs::write(runtime.join("conpty.dll"), b"dll-only").unwrap();
|
||||
|
||||
assert_eq!(bundled_conpty_dir(dir.path()), Some(dir.path().to_path_buf()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn conpty_returns_none_without_complete_pair() {
|
||||
let dir = TestDir::new("missing");
|
||||
let runtime = dir.path().join("runtime");
|
||||
std::fs::create_dir(&runtime).unwrap();
|
||||
std::fs::write(runtime.join("OpenConsole.exe"), b"host-only").unwrap();
|
||||
|
||||
assert_eq!(bundled_conpty_dir(dir.path()), None);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[ValidatePattern('^[0-9A-Za-z][0-9A-Za-z.-]*$')]
|
||||
[string] $Version = 'unreleased',
|
||||
|
||||
[ValidateSet('debug', 'release')]
|
||||
[string] $Configuration = 'release',
|
||||
|
||||
[switch] $SkipBuild,
|
||||
[switch] $Force,
|
||||
[string] $OutputDirectory
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
Set-StrictMode -Version Latest
|
||||
|
||||
$repo = (Resolve-Path (Join-Path $PSScriptRoot '..')).Path
|
||||
if ([string]::IsNullOrWhiteSpace($OutputDirectory)) {
|
||||
$OutputDirectory = Join-Path $repo 'dist'
|
||||
} elseif (-not [System.IO.Path]::IsPathRooted($OutputDirectory)) {
|
||||
$OutputDirectory = Join-Path $repo $OutputDirectory
|
||||
}
|
||||
$outputRoot = [System.IO.Path]::GetFullPath($OutputDirectory)
|
||||
$targetRoot = Join-Path $repo "target\$Configuration"
|
||||
$stage = Join-Path $outputRoot ".stage-$Version-$PID"
|
||||
$zipPath = Join-Path $outputRoot "NebulaTerminal-$Version-windows-x64.zip"
|
||||
$temporaryZip = Join-Path $outputRoot ".NebulaTerminal-$Version-windows-x64-$PID.tmp.zip"
|
||||
|
||||
$manifest = [ordered]@{
|
||||
'nebula.exe' = Join-Path $targetRoot 'nebula.exe'
|
||||
'README.md' = Join-Path $repo 'README.md'
|
||||
'runtime/nebula-hook.exe' = Join-Path $targetRoot 'nebula-hook.exe'
|
||||
'runtime/conpty.dll' = Join-Path $targetRoot 'conpty.dll'
|
||||
'runtime/OpenConsole.exe' = Join-Path $targetRoot 'OpenConsole.exe'
|
||||
'fonts/MapleMonoNormal-NF-CN-Regular.ttf' = Join-Path $repo 'assets\fonts\MapleMonoNormal-NF-CN-Regular.ttf'
|
||||
'docs/CHANGELOG.md' = Join-Path $repo 'CHANGELOG.md'
|
||||
'docs/INSTALL.md' = Join-Path $repo 'INSTALL.md'
|
||||
'licenses/LICENSE' = Join-Path $repo 'LICENSE'
|
||||
'licenses/THIRD-PARTY-NOTICES' = Join-Path $repo 'THIRD-PARTY-NOTICES'
|
||||
}
|
||||
|
||||
function Assert-Manifest([string] $Root, [string[]] $Expected) {
|
||||
$rootPrefix = [System.IO.Path]::GetFullPath($Root).TrimEnd('\') + '\'
|
||||
$actual = @(Get-ChildItem -LiteralPath $Root -Recurse -File | ForEach-Object {
|
||||
$fullPath = [System.IO.Path]::GetFullPath($_.FullName)
|
||||
if (-not $fullPath.StartsWith($rootPrefix, [System.StringComparison]::OrdinalIgnoreCase)) {
|
||||
throw "Package entry escaped the staging directory: $fullPath"
|
||||
}
|
||||
$fullPath.Substring($rootPrefix.Length).Replace('\', '/')
|
||||
} | Sort-Object)
|
||||
$difference = @(Compare-Object -ReferenceObject @($Expected | Sort-Object) -DifferenceObject $actual)
|
||||
if ($difference.Count -ne 0) {
|
||||
throw "Package manifest differs from the required layout:`n$($difference | Out-String)"
|
||||
}
|
||||
}
|
||||
|
||||
function Remove-StageSafely([string] $Path) {
|
||||
if (-not (Test-Path -LiteralPath $Path)) {
|
||||
return
|
||||
}
|
||||
$resolved = (Resolve-Path -LiteralPath $Path).Path
|
||||
$prefix = $outputRoot.TrimEnd('\') + '\.stage-'
|
||||
if (-not $resolved.StartsWith($prefix, [System.StringComparison]::OrdinalIgnoreCase)) {
|
||||
throw "Refusing to remove path outside the package staging boundary: $resolved"
|
||||
}
|
||||
Remove-Item -LiteralPath $resolved -Recurse -Force
|
||||
}
|
||||
|
||||
if (-not $SkipBuild) {
|
||||
Push-Location $repo
|
||||
try {
|
||||
if ($Configuration -eq 'release') {
|
||||
& cargo build --workspace --release
|
||||
} else {
|
||||
& cargo build --workspace
|
||||
}
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw "Cargo build failed with exit code $LASTEXITCODE"
|
||||
}
|
||||
} finally {
|
||||
Pop-Location
|
||||
}
|
||||
}
|
||||
|
||||
$missing = @($manifest.GetEnumerator() | Where-Object {
|
||||
-not (Test-Path -LiteralPath $_.Value -PathType Leaf)
|
||||
} | ForEach-Object { "$($_.Key) <- $($_.Value)" })
|
||||
if ($missing.Count -ne 0) {
|
||||
throw "Required package files are missing:`n$($missing -join "`n")"
|
||||
}
|
||||
|
||||
New-Item -ItemType Directory -Path $outputRoot -Force | Out-Null
|
||||
if (Test-Path -LiteralPath $zipPath) {
|
||||
if (-not $Force) {
|
||||
throw "Package already exists: $zipPath (pass -Force to replace it)"
|
||||
}
|
||||
Remove-Item -LiteralPath $zipPath -Force
|
||||
}
|
||||
if (Test-Path -LiteralPath $temporaryZip) {
|
||||
Remove-Item -LiteralPath $temporaryZip -Force
|
||||
}
|
||||
|
||||
try {
|
||||
Remove-StageSafely $stage
|
||||
New-Item -ItemType Directory -Path $stage | Out-Null
|
||||
foreach ($entry in $manifest.GetEnumerator()) {
|
||||
$destination = Join-Path $stage $entry.Key.Replace('/', '\')
|
||||
$parent = Split-Path -Parent $destination
|
||||
New-Item -ItemType Directory -Path $parent -Force | Out-Null
|
||||
Copy-Item -LiteralPath $entry.Value -Destination $destination
|
||||
}
|
||||
|
||||
Assert-Manifest $stage @($manifest.Keys)
|
||||
Compress-Archive -Path (Join-Path $stage '*') -DestinationPath $temporaryZip -CompressionLevel Optimal
|
||||
|
||||
Add-Type -AssemblyName System.IO.Compression.FileSystem
|
||||
$archive = [System.IO.Compression.ZipFile]::OpenRead($temporaryZip)
|
||||
try {
|
||||
$zipManifest = @($archive.Entries |
|
||||
Where-Object { -not $_.FullName.EndsWith('/') } |
|
||||
ForEach-Object { $_.FullName.Replace('\', '/') } |
|
||||
Sort-Object)
|
||||
$difference = @(
|
||||
Compare-Object -ReferenceObject @($manifest.Keys | Sort-Object) -DifferenceObject $zipManifest
|
||||
)
|
||||
if ($difference.Count -ne 0) {
|
||||
throw "ZIP manifest differs from the required layout:`n$($difference | Out-String)"
|
||||
}
|
||||
$unpackedSize = ($archive.Entries | Measure-Object -Property Length -Sum).Sum
|
||||
} finally {
|
||||
$archive.Dispose()
|
||||
}
|
||||
|
||||
Move-Item -LiteralPath $temporaryZip -Destination $zipPath
|
||||
$zip = Get-Item -LiteralPath $zipPath
|
||||
$sha256 = (Get-FileHash -LiteralPath $zipPath -Algorithm SHA256).Hash
|
||||
[PSCustomObject]@{
|
||||
Path = $zip.FullName
|
||||
Files = $manifest.Count
|
||||
Size = $zip.Length
|
||||
UnpackedSize = $unpackedSize
|
||||
SHA256 = $sha256
|
||||
} | Format-List
|
||||
} finally {
|
||||
Remove-StageSafely $stage
|
||||
if (Test-Path -LiteralPath $temporaryZip) {
|
||||
Remove-Item -LiteralPath $temporaryZip -Force
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
$ErrorActionPreference = 'Stop'
|
||||
Set-StrictMode -Version Latest
|
||||
|
||||
$repo = (Resolve-Path (Join-Path $PSScriptRoot '..\..')).Path
|
||||
$packageScript = Join-Path $repo 'scripts\package-release.ps1'
|
||||
$output = Join-Path $repo 'target\package-script-test'
|
||||
$expectedRoot = [System.IO.Path]::GetFullPath((Join-Path $repo 'target'))
|
||||
$resolvedOutput = [System.IO.Path]::GetFullPath($output)
|
||||
|
||||
if (-not $resolvedOutput.StartsWith($expectedRoot, [System.StringComparison]::OrdinalIgnoreCase)) {
|
||||
throw "Refusing to use test output outside target: $resolvedOutput"
|
||||
}
|
||||
|
||||
if (Test-Path -LiteralPath $resolvedOutput) {
|
||||
Remove-Item -LiteralPath $resolvedOutput -Recurse -Force
|
||||
}
|
||||
|
||||
try {
|
||||
if (-not (Test-Path -LiteralPath $packageScript -PathType Leaf)) {
|
||||
throw "Packaging script is missing: $packageScript"
|
||||
}
|
||||
|
||||
& $packageScript -Version 'unreleased' -SkipBuild -OutputDirectory $resolvedOutput
|
||||
$zipPath = Join-Path $resolvedOutput 'NebulaTerminal-unreleased-windows-x64.zip'
|
||||
if (-not (Test-Path -LiteralPath $zipPath -PathType Leaf)) {
|
||||
throw "Packaging script did not create $zipPath"
|
||||
}
|
||||
|
||||
Add-Type -AssemblyName System.IO.Compression.FileSystem
|
||||
$archive = [System.IO.Compression.ZipFile]::OpenRead($zipPath)
|
||||
try {
|
||||
$actual = @($archive.Entries |
|
||||
Where-Object { -not $_.FullName.EndsWith('/') } |
|
||||
ForEach-Object { $_.FullName.Replace('\', '/') } |
|
||||
Sort-Object)
|
||||
} finally {
|
||||
$archive.Dispose()
|
||||
}
|
||||
|
||||
$expected = @(
|
||||
'README.md'
|
||||
'docs/CHANGELOG.md'
|
||||
'docs/INSTALL.md'
|
||||
'fonts/MapleMonoNormal-NF-CN-Regular.ttf'
|
||||
'licenses/LICENSE'
|
||||
'licenses/THIRD-PARTY-NOTICES'
|
||||
'nebula.exe'
|
||||
'runtime/OpenConsole.exe'
|
||||
'runtime/conpty.dll'
|
||||
'runtime/nebula-hook.exe'
|
||||
) | Sort-Object
|
||||
|
||||
$difference = @(Compare-Object -ReferenceObject $expected -DifferenceObject $actual)
|
||||
if ($difference.Count -ne 0) {
|
||||
throw "ZIP file manifest differs from the required layout:`n$($difference | Out-String)"
|
||||
}
|
||||
|
||||
$rootFiles = @($actual | Where-Object { -not $_.Contains('/') })
|
||||
if (@(Compare-Object -ReferenceObject @('README.md', 'nebula.exe') -DifferenceObject $rootFiles).Count -ne 0) {
|
||||
throw "ZIP root must contain only README.md and nebula.exe"
|
||||
}
|
||||
|
||||
Write-Output "package-release.tests.ps1: PASS ($($actual.Count) files)"
|
||||
} finally {
|
||||
if (Test-Path -LiteralPath $resolvedOutput) {
|
||||
Remove-Item -LiteralPath $resolvedOutput -Recurse -Force
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user