From 05e05709f0a920fc06877e334319b7d5ab3d2493 Mon Sep 17 00:00:00 2001 From: Xuz Date: Sat, 12 Sep 2026 09:27:14 +0800 Subject: [PATCH] =?UTF-8?q?=E5=AE=9E=E7=8E=B0=E7=9C=9F=E5=AE=9E=20SSH=20?= =?UTF-8?q?=E5=AE=A2=E6=88=B7=E7=AB=AF=E5=B9=B6=E6=94=AF=E6=8C=81=E5=A4=9A?= =?UTF-8?q?=E8=BF=9E=E6=8E=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Cargo.lock | 1084 +++++++++++++++++++++++- Cargo.toml | 3 + src/app/actions.rs | 30 +- src/app/mod.rs | 2 +- src/app/paths.rs | 8 + src/lib.rs | 3 +- src/session/database.rs | 69 +- src/session/model.rs | 46 +- src/session/session_dialog.rs | 102 ++- src/session/store.rs | 31 +- src/ssh/mod.rs | 5 + src/ssh/transport.rs | 1404 +++++++++++++++++++++++++++++++ src/terminal/engine.rs | 44 +- src/terminal/local_pty.rs | 6 +- src/terminal/mock_shell.rs | 128 --- src/terminal/mock_transport.rs | 153 ---- src/terminal/mod.rs | 15 +- src/terminal/model.rs | 4 + src/terminal/terminal_panel.rs | 106 ++- src/terminal/terminal_view.rs | 37 +- src/terminal/transport.rs | 262 +++++- src/workspace/workspace_view.rs | 665 +++++++++++++-- tests/ui.rs | 660 ++++++++++++++- 23 files changed, 4354 insertions(+), 513 deletions(-) create mode 100644 src/ssh/mod.rs create mode 100644 src/ssh/transport.rs delete mode 100644 src/terminal/mock_shell.rs delete mode 100644 src/terminal/mock_transport.rs diff --git a/Cargo.lock b/Cargo.lock index dcb000c..a8fff20 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -97,6 +97,16 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" +[[package]] +name = "aead" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1973cfbc1a2daf9cf550e74e1f088c28e7f7d8c1e1418fb6c9dc5184b7e84c99" +dependencies = [ + "crypto-common 0.2.2", + "inout 0.2.2", +] + [[package]] name = "aes" version = "0.8.4" @@ -104,11 +114,38 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" dependencies = [ "cfg-if", - "cipher", + "cipher 0.4.4", "cpufeatures 0.2.17", "zeroize", ] +[[package]] +name = "aes" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35f0f96ce78e38c3dc6d8948aa8163d06385be74000f3c7a95bf1eef35d3ea32" +dependencies = [ + "cipher 0.5.2", + "cpubits", + "cpufeatures 0.3.1", + "zeroize", +] + +[[package]] +name = "aes-gcm" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f2b8006a0c83f52b62ba44a97b58bf76fe2f70a329e588f67f89691d93d498f" +dependencies = [ + "aead", + "aes 0.9.3", + "cipher 0.5.2", + "ctr", + "ctutils", + "ghash", + "zeroize", +] + [[package]] name = "ahash" version = "0.8.12" @@ -238,6 +275,18 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "argon2" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "134c52ddac6d63c576bef8168db10c83c49c26444ecbc68060fef078925a901c" +dependencies = [ + "base64ct", + "blake2", + "cpufeatures 0.3.1", + "password-hash", +] + [[package]] name = "arraydeque" version = "0.5.1" @@ -561,6 +610,30 @@ dependencies = [ "arrayvec", ] +[[package]] +name = "aws-lc-rs" +version = "1.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b281d307588d634de920874890732659e2e7672f72b5e10e81badc1a8a83621e" +dependencies = [ + "aws-lc-sys", + "untrusted 0.7.1", + "zeroize", +] + +[[package]] +name = "aws-lc-sys" +version = "0.45.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9bff6c3b54fad79a2e60b8102caf565819711497c1f5f092f49508e2f5c31b27" +dependencies = [ + "cc", + "cmake", + "dunce", + "fs_extra", + "pkg-config", +] + [[package]] name = "backtrace" version = "0.3.76" @@ -576,6 +649,12 @@ dependencies = [ "windows-link 0.2.1", ] +[[package]] +name = "base16ct" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd307490d624467aa6f74b0eabb77633d1f758a7b25f12bceb0b22e08d9726f6" + [[package]] name = "base62" version = "2.2.6" @@ -594,6 +673,23 @@ version = "0.23.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5" +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + +[[package]] +name = "bcrypt-pbkdf" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "144e573728da132683b9488acd528274c790e07fc06ff81ee29f9d8f8b1041e0" +dependencies = [ + "blowfish", + "pbkdf2 0.13.0", + "sha2 0.11.0", +] + [[package]] name = "bindgen" version = "0.72.1" @@ -674,6 +770,15 @@ dependencies = [ "no_std_io2", ] +[[package]] +name = "blake2" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b5d4d889834ee8ecfc0f8426ad30faf7cdcb10f741a8e6d7224d95325479f6f" +dependencies = [ + "digest 0.11.3", +] + [[package]] name = "block" version = "0.1.6" @@ -686,7 +791,7 @@ version = "0.10.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" dependencies = [ - "generic-array", + "generic-array 0.14.7", ] [[package]] @@ -696,6 +801,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa" dependencies = [ "hybrid-array", + "zeroize", ] [[package]] @@ -704,7 +810,16 @@ version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a8894febbff9f758034a5b8e12d87918f56dfc64a8e1fe757d65e29041538d93" dependencies = [ - "generic-array", + "generic-array 0.14.7", +] + +[[package]] +name = "block-padding" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "710f1dd022ef4e93f8a438b4ba958de7f64308434fa6a87104481645cc30068b" +dependencies = [ + "hybrid-array", ] [[package]] @@ -738,6 +853,16 @@ dependencies = [ "piper", ] +[[package]] +name = "blowfish" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62ce3946557b35e71d1bbe07ec385073ce9eda05043f95de134eb578fcf1a298" +dependencies = [ + "byteorder", + "cipher 0.5.2", +] + [[package]] name = "borsh" version = "1.8.1" @@ -848,7 +973,16 @@ version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "26b52a9543ae338f279b96b0b9fed9c8093744685043739079ce85cd58f289a6" dependencies = [ - "cipher", + "cipher 0.4.4", +] + +[[package]] +name = "cbc" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce2dc9ee5f88d11e0beb842c88b33c8a5cf0d1329c4b19494af42b07dbfe8896" +dependencies = [ + "cipher 0.5.2", ] [[package]] @@ -924,8 +1058,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06" dependencies = [ "cfg-if", + "cipher 0.5.2", "cpufeatures 0.3.1", "rand_core 0.10.1", + "zeroize", ] [[package]] @@ -949,7 +1085,19 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" dependencies = [ "crypto-common 0.1.7", - "inout", + "inout 0.1.4", + "zeroize", +] + +[[package]] +name = "cipher" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8cf2a2c93cd704877c0858356ed03480ff301ee950b43f1cbe4573b088bfa6c" +dependencies = [ + "block-buffer 0.12.1", + "crypto-common 0.2.2", + "inout 0.2.2", "zeroize", ] @@ -964,6 +1112,21 @@ dependencies = [ "libloading", ] +[[package]] +name = "cmake" +version = "0.1.58" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678" +dependencies = [ + "cc", +] + +[[package]] +name = "cmov" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a" + [[package]] name = "cocoa" version = "0.25.0" @@ -1286,6 +1449,12 @@ dependencies = [ "unicode-segmentation", ] +[[package]] +name = "cpubits" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15b85f9c39137c3a891689859392b1bd49812121d0d61c9caf00d46ed5ce06ae" + [[package]] name = "cpufeatures" version = "0.2.17" @@ -1353,13 +1522,30 @@ version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" +[[package]] +name = "crypto-bigint" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a52aa3fcda4e6302a9f48734f234d35d4721b96f8fe07d073f07ce9df4f0271" +dependencies = [ + "cpubits", + "ctutils", + "getrandom 0.4.3", + "hybrid-array", + "num-traits", + "rand_core 0.10.1", + "serdect", + "subtle", + "zeroize", +] + [[package]] name = "crypto-common" version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" dependencies = [ - "generic-array", + "generic-array 0.14.7", "typenum", ] @@ -1369,7 +1555,19 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" dependencies = [ + "getrandom 0.4.3", "hybrid-array", + "rand_core 0.10.1", +] + +[[package]] +name = "crypto-primes" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3633a51a39c69ebbaa4feaa694bd83d241e4093901c84a0963b19d9bb3f0cf8f" +dependencies = [ + "crypto-bigint", + "rand_core 0.10.1", ] [[package]] @@ -1382,18 +1580,93 @@ dependencies = [ "linktime-proc-macro", ] +[[package]] +name = "ctr" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baaca1c4b237092596f64d571e9db6ce4109c4ef9742e27590f1709594461f21" +dependencies = [ + "cipher 0.5.2", +] + +[[package]] +name = "ctutils" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d5515a3834141de9eafb9717ad39eea8247b5674e6066c404e8c4b365d2a29e" +dependencies = [ + "cmov", + "subtle", +] + [[package]] name = "cursor-icon" version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f27ae1dd37df86211c42e150270f82743308803d90a6f6e6651cd730d5e1732f" +[[package]] +name = "curve25519-dalek" +version = "5.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5eed333089e2e1c1ac8c6c0398e5e2497b4c9926ca6d0365ed1e099afa5bc23" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.1", + "curve25519-dalek-derive", + "digest 0.11.3", + "fiat-crypto", + "rand_core 0.10.1", + "rustc_version", + "subtle", + "zeroize", +] + +[[package]] +name = "curve25519-dalek-derive" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "data-encoding" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4583a4551df46e2792f82ceeac45e850d2e2d5debba0b91f102385cda5b11f06" + [[package]] name = "data-url" version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "be1e0bca6c3637f992fc1cc7cbc52a78c1ef6db076dbf1059c4323d6a2048376" +[[package]] +name = "delegate" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "780eb241654bf097afb00fc5f054a09b687dad862e485fdcf8399bb056565370" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "der" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a878c850e9e421b20262e9b41f9c860e4785fa07541c266b62ff9d1ef998a80a" +dependencies = [ + "const-oid", + "pem-rfc7468", + "zeroize", +] + [[package]] name = "deranged" version = "0.5.8" @@ -1423,6 +1696,15 @@ dependencies = [ "unicode-xid", ] +[[package]] +name = "des" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "916a94e407b54f9034d71dd748234cd1e516ced6284009906ae246f177eafe5a" +dependencies = [ + "cipher 0.5.2", +] + [[package]] name = "digest" version = "0.10.7" @@ -1443,6 +1725,7 @@ dependencies = [ "block-buffer 0.12.1", "const-oid", "crypto-common 0.2.2", + "ctutils", ] [[package]] @@ -1564,12 +1847,75 @@ version = "1.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" +[[package]] +name = "ecdsa" +version = "0.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0681a4fc24c767085329728d8dfba959af91228aa4610cca4f8ce317ba46ae0" +dependencies = [ + "der", + "digest 0.11.3", + "elliptic-curve", + "rfc6979", + "signature", + "spki", + "zeroize", +] + +[[package]] +name = "ed25519" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29fcf32e6c73d1079f83ab4d782de2d81620346a5f38c6237a86a22f8368980a" +dependencies = [ + "pkcs8", + "signature", +] + +[[package]] +name = "ed25519-dalek" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ebaa1a2bf1290ab3bfe5a7b771d050ebffab2711c19a81691c683a5144a25de" +dependencies = [ + "curve25519-dalek", + "ed25519", + "rand_core 0.10.1", + "serde", + "sha2 0.11.0", + "signature", + "subtle", + "zeroize", +] + [[package]] name = "either" version = "1.18.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "252afb9ae5eaa683babdc6a068b3f5726eb19e05070c731f9b2a23a7c3e8ed34" +[[package]] +name = "elliptic-curve" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d65aa39b3a5c1c9c1b745c9a019234bb7a21b77abcb4f4d266d706e2d577d65" +dependencies = [ + "base16ct", + "crypto-bigint", + "crypto-common 0.2.2", + "digest 0.11.3", + "ff", + "group", + "hkdf 0.13.0", + "hybrid-array", + "pem-rfc7468", + "pkcs8", + "rand_core 0.10.1", + "sec1", + "subtle", + "zeroize", +] + [[package]] name = "embed-resource" version = "3.0.11" @@ -1634,6 +1980,18 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "enum_dispatch" +version = "0.3.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa18ce2bc66555b3218614519ac839ddb759a7d6720732f979ef8d13be147ecd" +dependencies = [ + "once_cell", + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "enumflags2" version = "0.7.12" @@ -1805,6 +2163,22 @@ dependencies = [ "simd-adler32", ] +[[package]] +name = "ff" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1f686ab92a9fb0eaf188f6c6c87b89490baa6fdb0db4544ba4dc47f7942489f" +dependencies = [ + "rand_core 0.10.1", + "subtle", +] + +[[package]] +name = "fiat-crypto" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64cd1e32ddd350061ae6edb1b082d7c54915b5c672c389143b9a63403a109f24" + [[package]] name = "filedescriptor" version = "0.8.3" @@ -1994,6 +2368,12 @@ dependencies = [ "pkg-config", ] +[[package]] +name = "fs_extra" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" + [[package]] name = "fsevent-sys" version = "4.1.0" @@ -2137,6 +2517,17 @@ dependencies = [ "version_check", ] +[[package]] +name = "generic-array" +version = "1.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "337d46834ee672ab3e48caca2cb0c78cc174fb12b3a68d0d88f99a0519a5e36e" +dependencies = [ + "generic-array 0.14.7", + "rustversion", + "typenum", +] + [[package]] name = "gethostname" version = "1.1.0" @@ -2188,6 +2579,16 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "ghash" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2eecf2d5dc9b66b732b97707a0210906b1d30523eb773193ab777c0c84b3e8d5" +dependencies = [ + "polyval", + "zeroize", +] + [[package]] name = "gif" version = "0.13.3" @@ -2959,6 +3360,17 @@ dependencies = [ "smallvec", ] +[[package]] +name = "group" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7fd1a1c7a5206c5b7a3f5a0d7ccd3ff85d0c8f5133d62a02680255b0004af5f4" +dependencies = [ + "ff", + "rand_core 0.10.1", + "subtle", +] + [[package]] name = "h2" version = "0.4.19" @@ -3090,6 +3502,12 @@ version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" +[[package]] +name = "hex-literal" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e712f64ec3850b98572bffac52e2c6f282b29fe6c5fa6d42334b30be438d95c1" + [[package]] name = "hexf-parse" version = "0.2.1" @@ -3102,7 +3520,16 @@ version = "0.12.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" dependencies = [ - "hmac", + "hmac 0.12.1", +] + +[[package]] +name = "hkdf" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4aaa26c720c68b866f2c96ef5c1264b3e6f473fe5d4ce61cd44bbe913e553018" +dependencies = [ + "hmac 0.13.0", ] [[package]] @@ -3114,6 +3541,15 @@ dependencies = [ "digest 0.10.7", ] +[[package]] +name = "hmac" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6303bc9732ae41b04cb554b844a762b4115a61bfaa81e3e83050991eeb56863f" +dependencies = [ + "digest 0.11.3", +] + [[package]] name = "home" version = "0.5.12" @@ -3182,7 +3618,10 @@ version = "0.4.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "27f864f10dfb56725ce5ce5472bc52252c8f93a4ab86327122cebf62c5f59a17" dependencies = [ + "ctutils", + "subtle", "typenum", + "zeroize", ] [[package]] @@ -3475,8 +3914,18 @@ version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" dependencies = [ - "block-padding", - "generic-array", + "block-padding 0.3.3", + "generic-array 0.14.7", +] + +[[package]] +name = "inout" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4250ce6452e92010fdf7268ccc5d14faa80bb12fc741938534c58f16804e03c7" +dependencies = [ + "block-padding 0.4.2", + "hybrid-array", ] [[package]] @@ -3630,6 +4079,26 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "keccak" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8f198d1db720e4940b5a493201d199d9f24f568f8f746bd13706243a2f71598" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.1", +] + +[[package]] +name = "kem" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01737161ba802849cfd486b5bd209d38ba4943494c249a8126005170c7621edd" +dependencies = [ + "crypto-common 0.2.2", + "rand_core 0.10.1", +] + [[package]] name = "khronos-egl" version = "6.0.0" @@ -4004,6 +4473,12 @@ dependencies = [ "digest 0.10.7", ] +[[package]] +name = "md5" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ebb8d8732c6a6df3d8f032a82911cfc747e00efb95cc46e8d0acd5b5b88570c" + [[package]] name = "memchr" version = "2.8.3" @@ -4106,6 +4581,31 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "ml-kem" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e15f3e5b957493873e396a66914e83e616b6afe335cdef7efe5c6e1216aba66" +dependencies = [ + "hybrid-array", + "kem", + "module-lattice", + "pkcs8", + "rand_core 0.10.1", + "sha3 0.11.0", +] + +[[package]] +name = "module-lattice" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c61b87c9683ab7cb1c6871d261ad5479b6b10ceb52c4352aaca3b5d35a8febe" +dependencies = [ + "ctutils", + "hybrid-array", + "num-traits", +] + [[package]] name = "moxcms" version = "0.8.1" @@ -4196,6 +4696,18 @@ dependencies = [ "libc", ] +[[package]] +name = "nix" +version = "0.31.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf20d2fde8ff38632c426f1165ed7436270b44f199fc55284c38276f9db47c3d" +dependencies = [ + "bitflags 2.13.2", + "cfg-if", + "cfg_aliases 0.2.2", + "libc", +] + [[package]] name = "no_std_io2" version = "0.9.4" @@ -4311,7 +4823,7 @@ version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "35bd024e8b2ff75562e5f34e7f4905839deb4b22955ef5e73d2fea1b9813cb23" dependencies = [ - "num-bigint", + "num-bigint 0.4.8", "num-complex", "num-integer", "num-iter", @@ -4329,6 +4841,18 @@ dependencies = [ "num-traits", ] +[[package]] +name = "num-bigint" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93e7820bc0a80a0238e650327316f929ba18d5be054b647490a3a6a339f3e7c0" +dependencies = [ + "num-integer", + "num-traits", + "rand 0.10.2", + "rand_core 0.10.1", +] + [[package]] name = "num-bigint-dig" version = "0.9.1" @@ -4398,7 +4922,7 @@ version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824" dependencies = [ - "num-bigint", + "num-bigint 0.4.8", "num-integer", "num-traits", ] @@ -4765,25 +5289,25 @@ version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "78f2bfed90f1618b4b48dcad9307f25e14ae894e2949642c87c351601d62cebd" dependencies = [ - "aes", + "aes 0.8.4", "ashpd", "async-fs", "async-io", "async-lock", "blocking", - "cbc", - "cipher", + "cbc 0.1.2", + "cipher 0.4.4", "digest 0.10.7", "endi", "futures-lite", "futures-util", "getrandom 0.4.3", - "hkdf", - "hmac", + "hkdf 0.12.4", + "hmac 0.12.1", "md-5", "num", "num-bigint-dig", - "pbkdf2", + "pbkdf2 0.12.2", "serde", "serde_bytes", "sha2 0.10.9", @@ -4835,6 +5359,67 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "p256" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2c9239b2dbc807adbbe147e8cf72ea7450c3a0aabe62cb8e75ff4ec22e1f72a" +dependencies = [ + "ecdsa", + "elliptic-curve", + "primefield", + "primeorder", + "sha2 0.11.0", +] + +[[package]] +name = "p384" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d17b851e6b3e378ab4ecb07fa2ed23f4d15f075735f8fec9fa1e7bdce5f8301f" +dependencies = [ + "ecdsa", + "elliptic-curve", + "fiat-crypto", + "primefield", + "primeorder", + "sha2 0.11.0", +] + +[[package]] +name = "p521" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ad64cc32c2dc466317c12ee5853e61f159f9eab1fe7efade0395dc2e7b43449" +dependencies = [ + "base16ct", + "ecdsa", + "elliptic-curve", + "primefield", + "primeorder", + "sha2 0.11.0", +] + +[[package]] +name = "pageant" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d8eab09a361a4afe0b1668be978cd97e4f052e927a92b0b608cf902965d49ce" +dependencies = [ + "base16ct", + "byteorder", + "bytes", + "delegate", + "futures", + "log", + "rand 0.10.2", + "sha2 0.11.0", + "thiserror 2.0.20", + "tokio", + "windows 0.62.2", + "windows-strings 0.5.1", +] + [[package]] name = "parking" version = "2.2.1" @@ -4864,6 +5449,15 @@ dependencies = [ "windows-link 0.2.1", ] +[[package]] +name = "password-hash" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aab41826031698d6ffcd9cff78ef56ef998e39dc7e5067cdfebe373842d4723b" +dependencies = [ + "phc", +] + [[package]] name = "paste" version = "1.0.15" @@ -4902,7 +5496,26 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8ed6a7761f76e3b9f92dfb0a60a6a6477c61024b775147ff0973a02653abaf2" dependencies = [ "digest 0.10.7", - "hmac", + "hmac 0.12.1", +] + +[[package]] +name = "pbkdf2" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112d82ceb8c5bf524d9af484d4e4970c9fd5a0cc15ba14ad93dccd28873b0629" +dependencies = [ + "digest 0.11.3", + "hmac 0.13.0", +] + +[[package]] +name = "pem-rfc7468" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6305423e0e7738146434843d1694d621cce767262b2a86910beab705e4493d9" +dependencies = [ + "base64ct", ] [[package]] @@ -4911,6 +5524,16 @@ version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" +[[package]] +name = "phc" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44dc769b75f93afdddd8c7fa12d685292ddeff1e66f7f0f3a234cf1818afe892" +dependencies = [ + "base64ct", + "ctutils", +] + [[package]] name = "phf" version = "0.11.3" @@ -5035,6 +5658,45 @@ dependencies = [ "futures-io", ] +[[package]] +name = "pkcs1" +version = "0.8.0-rc.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "986d2e952779af96ea048f160fd9194e1751b4faea78bcf3ceb456efe008088e" +dependencies = [ + "der", + "spki", +] + +[[package]] +name = "pkcs5" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63d440a804ec8d6fafbb6b84471e013286658d373248927692ab3366686220ca" +dependencies = [ + "aes 0.9.3", + "aes-gcm", + "cbc 0.2.1", + "der", + "pbkdf2 0.13.0", + "rand_core 0.10.1", + "scrypt", + "sha2 0.11.0", + "spki", +] + +[[package]] +name = "pkcs8" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "451913da69c775a56034ea8d9003d27ee8948e12443eae7c038ba100a4f21cb7" +dependencies = [ + "der", + "pkcs5", + "rand_core 0.10.1", + "spki", +] + [[package]] name = "pkg-config" version = "0.3.34" @@ -5093,6 +5755,17 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2f3a9f18d041e6d0e102a0a46750538147e5e8992d3b4873aaafee2520b00ce3" +[[package]] +name = "poly1305" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e2d0073b297041425c7c3df6eb4792d598a15323fe63346852b092eca02904c" +dependencies = [ + "cpufeatures 0.3.1", + "universal-hash", + "zeroize", +] + [[package]] name = "polycool" version = "0.4.0" @@ -5102,6 +5775,18 @@ dependencies = [ "arrayvec", ] +[[package]] +name = "polyval" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0fa31d631f2b2cb2a544d0aa321ce847a94764d701ca2becc411138b93d49cd" +dependencies = [ + "cpubits", + "cpufeatures 0.3.1", + "universal-hash", + "zeroize", +] + [[package]] name = "portable-atomic" version = "1.15.0" @@ -5130,7 +5815,7 @@ dependencies = [ "lazy_static", "libc", "log", - "nix", + "nix 0.28.0", "serial2", "shared_library", "shell-words", @@ -5201,6 +5886,33 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "primefield" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c555a6e4eb7d4e158fcb028c835c3b8642206ddc279b5c6b202ef9a8bdb592f4" +dependencies = [ + "crypto-bigint", + "crypto-common 0.2.2", + "ff", + "rand_core 0.10.1", + "subtle", + "zeroize", +] + +[[package]] +name = "primeorder" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c9f42978c78a00e3d68f69fc03e57a234debae69da4020a4fb588fcdcd07b06" +dependencies = [ + "elliptic-curve", + "once_cell", + "primefield", + "serdect", + "wnaf", +] + [[package]] name = "proc-macro-crate" version = "3.5.0" @@ -5757,6 +6469,16 @@ dependencies = [ "zune-jpeg 0.5.15", ] +[[package]] +name = "rfc6979" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4a459cddafb3fe76b31fd8f1108007566c40301feb64dc7b54656eb7388172b" +dependencies = [ + "crypto-bigint", + "hmac 0.13.0", +] + [[package]] name = "rgb" version = "0.8.53" @@ -5776,7 +6498,7 @@ dependencies = [ "cfg-if", "getrandom 0.2.17", "libc", - "untrusted", + "untrusted 0.9.0", "windows-sys 0.52.0", ] @@ -5804,6 +6526,25 @@ dependencies = [ "memchr", ] +[[package]] +name = "rsa" +version = "0.10.0-rc.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30b2aa4ba0d89f73d1e332df05be0eeab8840351c36ca5654341dfdb57bb3caf" +dependencies = [ + "const-oid", + "crypto-bigint", + "crypto-primes", + "digest 0.11.3", + "pkcs1", + "pkcs8", + "rand_core 0.10.1", + "sha2 0.11.0", + "signature", + "spki", + "zeroize", +] + [[package]] name = "rsqlite-vfs" version = "0.1.1" @@ -5829,6 +6570,101 @@ dependencies = [ "sqlite-wasm-rs", ] +[[package]] +name = "russh" +version = "0.63.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "036204edbd199552a5b3832f63c60dcdf395dc44c7f06b4af1c0e8139cc11bce" +dependencies = [ + "aes 0.9.3", + "aws-lc-rs", + "bitflags 2.13.2", + "block-padding 0.4.2", + "byteorder", + "bytes", + "cbc 0.2.1", + "cipher 0.5.2", + "crypto-bigint", + "ctr", + "curve25519-dalek", + "data-encoding", + "delegate", + "der", + "digest 0.11.3", + "ecdsa", + "ed25519-dalek", + "elliptic-curve", + "enum_dispatch", + "flate2", + "futures", + "generic-array 1.4.5", + "getrandom 0.4.3", + "ghash", + "hex-literal", + "hmac 0.13.0", + "inout 0.2.2", + "keccak", + "log", + "md5", + "ml-kem", + "module-lattice", + "num-bigint 0.5.1", + "p256", + "p384", + "p521", + "pageant", + "pbkdf2 0.13.0", + "pkcs1", + "pkcs5", + "pkcs8", + "polyval", + "rand 0.10.2", + "rand_core 0.10.1", + "rsa", + "russh-cryptovec", + "russh-util", + "salsa20", + "scrypt", + "sec1", + "sha1", + "sha2 0.11.0", + "sha3 0.12.0", + "signature", + "spki", + "ssh-encoding", + "ssh-key", + "subtle", + "thiserror 2.0.20", + "tokio", + "typenum", + "universal-hash", + "zeroize", +] + +[[package]] +name = "russh-cryptovec" +version = "0.62.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3aec6cb630dbe85d72ffd7bcd95f07e1bd69f9f270ee8adfa1afe443a6331438" +dependencies = [ + "log", + "nix 0.31.3", + "ssh-encoding", + "windows-sys 0.61.2", +] + +[[package]] +name = "russh-util" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "668424a5dde0bcb45b55ba7de8476b93831b4aa2fa6947e145f3b053e22c60b6" +dependencies = [ + "chrono", + "tokio", + "wasm-bindgen", + "wasm-bindgen-futures", +] + [[package]] name = "rust-embed" version = "8.12.0" @@ -6017,7 +6853,7 @@ checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2" dependencies = [ "ring", "rustls-pki-types", - "untrusted", + "untrusted 0.9.0", ] [[package]] @@ -6062,6 +6898,16 @@ version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" +[[package]] +name = "salsa20" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f874456e72520ff1375a06c588eaf074b0f01f9e9e1aada45bd9b7954a6e42c" +dependencies = [ + "cfg-if", + "cipher 0.5.2", +] + [[package]] name = "same-file" version = "1.0.6" @@ -6141,12 +6987,38 @@ dependencies = [ "once_cell", ] +[[package]] +name = "scrypt" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d87af57419b594aa23fa95f09f0e06d80d84ba01c26148c43844cad6ff4485f0" +dependencies = [ + "cfg-if", + "pbkdf2 0.13.0", + "salsa20", + "sha2 0.11.0", +] + [[package]] name = "seahash" version = "4.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1c107b6f4780854c8b126e228ea8869f4d7b71260f962fefb57b996b8959ba6b" +[[package]] +name = "sec1" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d56d437c2f19203ce5f7122e507831de96f3d2d4d3be5af44a0b0a09d8a80e4d" +dependencies = [ + "base16ct", + "ctutils", + "der", + "hybrid-array", + "subtle", + "zeroize", +] + [[package]] name = "security-framework" version = "3.7.0" @@ -6318,6 +7190,16 @@ dependencies = [ "serde", ] +[[package]] +name = "serdect" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66cf8fedced2fcf12406bcb34223dffb92eaf34908ede12fed414c82b7f00b3e" +dependencies = [ + "base16ct", + "serde", +] + [[package]] name = "serial2" version = "0.2.38" @@ -6329,6 +7211,17 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "sha1" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aacc4cc499359472b4abe1bf11d0b12e688af9a805fa5e3016f9a386dc2d0214" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.1", + "digest 0.11.3", +] + [[package]] name = "sha1_smol" version = "1.0.1" @@ -6357,6 +7250,27 @@ dependencies = [ "digest 0.11.3", ] +[[package]] +name = "sha3" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be176f1a57ce4e3d31c1a166222d9768de5954f811601fb7ca06fc8203905ce1" +dependencies = [ + "digest 0.11.3", + "keccak", +] + +[[package]] +name = "sha3" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc9bad02c26382724b2d2692c6f179285e4b54eeecd7968f52a50059c3c11759" +dependencies = [ + "digest 0.11.3", + "keccak", + "sponge-cursor", +] + [[package]] name = "sharded-slab" version = "0.1.7" @@ -6402,9 +7316,12 @@ dependencies = [ "gpui-kit", "portable-pty", "rusqlite", + "russh", "serde", "tempfile", + "tokio", "unicode-width", + "zeroize", ] [[package]] @@ -6439,6 +7356,16 @@ dependencies = [ "libc", ] +[[package]] +name = "signature" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28d567dcbaf0049cb8ac2608a76cd95ff9e4412e1899d389ee400918ca7537f5" +dependencies = [ + "digest 0.11.3", + "rand_core 0.10.1", +] + [[package]] name = "simd-adler32" version = "0.3.10" @@ -6580,6 +7507,22 @@ dependencies = [ "bitflags 2.13.2", ] +[[package]] +name = "spki" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d9efca8738c78ee9484207732f728b1ef517bbb1833d6fc0879ca898a522f6f" +dependencies = [ + "base64ct", + "der", +] + +[[package]] +name = "sponge-cursor" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a0219bd7d979d58245a4f41f695e1ac9f8befdffadd7f61f1bae9e39abc6620" + [[package]] name = "sqlite-wasm-rs" version = "0.5.5" @@ -6592,6 +7535,65 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "ssh-cipher" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d801accda99469cde6d73da741422610fdf6508a72d9a69d1b55cb241c720597" +dependencies = [ + "aead", + "aes 0.9.3", + "aes-gcm", + "chacha20", + "cipher 0.5.2", + "ctutils", + "des", + "poly1305", + "ssh-encoding", + "zeroize", +] + +[[package]] +name = "ssh-encoding" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b54d0ed0498daf3f78d82e00e28c8eec9d75a067c4cfbcc7a0f7d0f4077749e" +dependencies = [ + "base64ct", + "bytes", + "crypto-bigint", + "ctutils", + "digest 0.11.3", + "pem-rfc7468", + "zeroize", +] + +[[package]] +name = "ssh-key" +version = "0.7.0-rc.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9a32fae177b74a22aa9c5b01bf7e68b33545be32d9e381e248058d2adc15ce3" +dependencies = [ + "argon2", + "bcrypt-pbkdf", + "ctutils", + "ed25519-dalek", + "hex", + "hmac 0.13.0", + "p256", + "p384", + "p521", + "rand_core 0.10.1", + "rsa", + "sec1", + "sha1", + "sha2 0.11.0", + "signature", + "ssh-cipher", + "ssh-encoding", + "zeroize", +] + [[package]] name = "stable_deref_trait" version = "1.2.1" @@ -7094,9 +8096,21 @@ dependencies = [ "mio", "pin-project-lite", "socket2", + "tokio-macros", "windows-sys 0.61.2", ] +[[package]] +name = "tokio-macros" +version = "2.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.5", +] + [[package]] name = "tokio-rustls" version = "0.26.5" @@ -7443,6 +8457,22 @@ version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" +[[package]] +name = "universal-hash" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4987bdc12753382e0bec4a65c50738ffaabc998b9cdd1f952fb5f39b0048a96" +dependencies = [ + "crypto-common 0.2.2", + "ctutils", +] + +[[package]] +name = "untrusted" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a156c684c91ea7d62626509bce3cb4e1d9ed5c4d978f7b4352658f96a4c26b4a" + [[package]] name = "untrusted" version = "0.9.0" @@ -8754,6 +9784,18 @@ version = "0.57.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" +[[package]] +name = "wnaf" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "795ca18b3fdb5e62bf982199278341ddcf7ebf7d32e25e212ad05d496e95f6fa" +dependencies = [ + "ff", + "group", + "hybrid-array", + "primefield", +] + [[package]] name = "writeable" version = "0.6.4" diff --git a/Cargo.toml b/Cargo.toml index 8608778..1f4e97e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -12,9 +12,12 @@ anyhow = "1" async-channel = "2" dirs = "6" portable-pty = "0.9" +russh = "0.63" rusqlite = { version = "0.40", features = ["bundled"] } serde = { version = "1", features = ["derive"] } +tokio = { version = "1", features = ["io-util", "net", "rt", "sync", "time"] } unicode-width = "0.2" +zeroize = "1" [dev-dependencies] gpui-kit = { version = "0.6.1", features = ["test-support"] } diff --git a/src/app/actions.rs b/src/app/actions.rs index 21e75bf..35470a1 100644 --- a/src/app/actions.rs +++ b/src/app/actions.rs @@ -6,7 +6,7 @@ use serde::Deserialize; use crate::{ session::{GroupId, SessionId}, - terminal::LocalTerminalId, + terminal::{LocalTerminalId, RemoteTerminalId}, }; gpui_kit::actions!( @@ -70,26 +70,27 @@ macro_rules! local_terminal_action { }; } +macro_rules! remote_terminal_action { + ($(#[$doc:meta])* $name:ident) => { + $(#[$doc])* + #[derive(Action, Clone, PartialEq, Eq, Deserialize)] + #[action(namespace = shellr, no_json)] + pub struct $name(pub RemoteTerminalId); + }; +} + session_action!( - /// Open (or activate) the terminal tab of a session. + /// Open a new terminal connection for a session. ConnectSession ); session_action!( /// Mark a session disconnected. DisconnectSession ); -session_action!( - /// Reconnect a session (mock: a short "connecting" state). - ReconnectSession -); session_action!( /// Open (or activate) the SFTP explorer tab of a session. OpenExplorer ); -session_action!( - /// Close the terminal tab of a session. - CloseTerminal -); session_action!( /// Close the SFTP explorer tab of a session. CloseExplorer @@ -128,6 +129,15 @@ local_terminal_action!( /// Close a local terminal tab and terminate its child process. CloseLocalTerminal ); + +remote_terminal_action!( + /// Close one remote terminal connection and its tab. + CloseTerminal +); +remote_terminal_action!( + /// Reconnect one remote terminal using the session's latest settings. + ReconnectTerminal +); local_terminal_action!( /// Restart a local terminal with a fresh emulator and PTY. RestartLocalTerminal diff --git a/src/app/mod.rs b/src/app/mod.rs index c75edba..1997895 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -6,7 +6,7 @@ mod paths; pub use actions::*; pub use assets::{AppAssets, CatalogIcon}; -pub use paths::{data_dir, database_path}; +pub use paths::{data_dir, database_path, known_hosts_path}; use gpui_kit::component::dock::ToggleZoom; use gpui_kit::*; diff --git a/src/app/paths.rs b/src/app/paths.rs index 6bd047e..70de339 100644 --- a/src/app/paths.rs +++ b/src/app/paths.rs @@ -24,3 +24,11 @@ pub fn database_path() -> std::io::Result { std::fs::create_dir_all(&dir)?; Ok(dir.join("shellr.db")) } + +/// shellr's private host-key trust store. It deliberately does not read or +/// modify OpenSSH's `~/.ssh/known_hosts`. +pub fn known_hosts_path() -> std::io::Result { + let dir = data_dir(); + std::fs::create_dir_all(&dir)?; + Ok(dir.join("known_hosts")) +} diff --git a/src/lib.rs b/src/lib.rs index 93897a4..6af129f 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,4 +1,4 @@ -//! shellr: an SSH session manager with a real local terminal and mock remote sessions. +//! shellr: an SSH session manager with real SSH and local terminals. //! //! Modules are organized by capability so they can become crates later: //! `session` (会话管理), `terminal` (终端), `explorer` (SFTP 文件浏览), @@ -8,6 +8,7 @@ pub mod app; pub mod explorer; pub mod session; pub mod shared; +pub mod ssh; pub mod terminal; pub mod workspace; diff --git a/src/session/database.rs b/src/session/database.rs index 80cd9c9..0ef1d8a 100644 --- a/src/session/database.rs +++ b/src/session/database.rs @@ -23,7 +23,7 @@ fn from_sql(id: i64) -> u64 { } /// Bumped whenever `migrate` gains a step. Stored in `PRAGMA user_version`. -const SCHEMA_VERSION: i64 = 1; +const SCHEMA_VERSION: i64 = 2; const SCHEMA_V1: &str = "\ BEGIN; @@ -46,6 +46,13 @@ CREATE INDEX sessions_group_id ON sessions(group_id); PRAGMA user_version = 1; COMMIT;"; +const SCHEMA_V2: &str = "\ +BEGIN; +ALTER TABLE sessions ADD COLUMN key_path TEXT; +UPDATE sessions SET auth = 'auto' WHERE auth = 'key'; +PRAGMA user_version = 2; +COMMIT;"; + /// Everything one launch reads back from disk. pub struct StoredData { /// Groups in insertion order; `parent` gives the nesting. @@ -99,7 +106,7 @@ impl SessionDatabase { let sessions = self .connection .prepare( - "SELECT id, name, host, port, username, auth, group_id \ + "SELECT id, name, host, port, username, auth, group_id, key_path \ FROM sessions ORDER BY id", )? .query_map([], |row| { @@ -110,6 +117,7 @@ impl SessionDatabase { let user: String = row.get(4)?; let auth: String = row.get(5)?; let group: Option = row.get(6)?; + let key_path: Option = row.get(7)?; Ok(Session::new( SessionId(from_sql(id)), SessionDraft::new( @@ -119,7 +127,8 @@ impl SessionDatabase { user, AuthKind::from_stored(&auth), group.map(|id| GroupId(from_sql(id))), - ), + ) + .with_optional_key_path(key_path), )) })? .collect::>>()?; @@ -176,8 +185,8 @@ impl SessionDatabase { pub fn insert_session(&self, session: &Session) -> rusqlite::Result<()> { self.connection.execute( - "INSERT INTO sessions (id, name, host, port, username, auth, group_id) \ - VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)", + "INSERT INTO sessions (id, name, host, port, username, auth, group_id, key_path) \ + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)", params![ to_sql(session.id.0), session.name.as_ref(), @@ -186,6 +195,7 @@ impl SessionDatabase { session.user.as_ref(), session.auth.as_str(), session.group.map(|group| to_sql(group.0)), + session.key_path.as_deref(), ], )?; Ok(()) @@ -196,7 +206,7 @@ impl SessionDatabase { pub fn update_session(&self, session: &Session) -> rusqlite::Result<()> { self.connection.execute( "UPDATE sessions SET name = ?2, host = ?3, port = ?4, username = ?5, \ - auth = ?6, group_id = ?7 WHERE id = ?1", + auth = ?6, group_id = ?7, key_path = ?8 WHERE id = ?1", params![ to_sql(session.id.0), session.name.as_ref(), @@ -205,6 +215,7 @@ impl SessionDatabase { session.user.as_ref(), session.auth.as_str(), session.group.map(|group| to_sql(group.0)), + session.key_path.as_deref(), ], )?; Ok(()) @@ -235,6 +246,9 @@ fn migrate(connection: &mut Connection) -> rusqlite::Result<()> { if version < 1 { connection.execute_batch(SCHEMA_V1)?; } + if version < 2 { + connection.execute_batch(SCHEMA_V2)?; + } Ok(()) } @@ -313,12 +327,14 @@ mod tests { let mut moved = session(1, "web-01", Some(2)); moved.port = 2222; moved.auth = AuthKind::Key; + moved.key_path = Some("/tmp/test-key".into()); db.update_session(&moved).unwrap(); db.update_group(&group(2, "预发", None)).unwrap(); let data = db.load().unwrap(); assert_eq!(data.sessions[0].port, 2222); assert_eq!(data.sessions[0].auth, AuthKind::Key); + assert_eq!(data.sessions[0].key_path.as_deref(), Some("/tmp/test-key")); assert_eq!(data.sessions[0].group, Some(GroupId(2))); assert_eq!(data.groups[1].name.as_ref(), "预发"); } @@ -379,4 +395,45 @@ mod tests { assert_eq!(data.sessions[0].name.as_ref(), "web-01"); assert_eq!(data.recent, [SessionId(1)]); } + + #[test] + fn migrates_v1_key_auth_to_auto_and_adds_key_path() { + let connection = Connection::open_in_memory().unwrap(); + connection.execute_batch(SCHEMA_V1).unwrap(); + connection + .execute( + "INSERT INTO sessions (id, name, host, port, username, auth) \ + VALUES (1, '旧会话', 'example.test', 22, 'root', 'key')", + [], + ) + .unwrap(); + + let db = SessionDatabase::prepare(connection).unwrap(); + let version: i64 = db + .connection + .pragma_query_value(None, "user_version", |row| row.get(0)) + .unwrap(); + assert_eq!(version, 2); + let data = db.load().unwrap(); + assert_eq!(data.sessions[0].auth, AuthKind::Auto); + assert_eq!(data.sessions[0].key_path, None); + } + + #[test] + fn schema_never_contains_secret_columns() { + let db = SessionDatabase::in_memory().unwrap(); + let mut statement = db + .connection + .prepare("PRAGMA table_info(sessions)") + .unwrap(); + let columns = statement + .query_map([], |row| row.get::<_, String>(1)) + .unwrap() + .collect::>>() + .unwrap(); + assert!(columns.contains(&"key_path".to_string())); + assert!(!columns.iter().any(|column| { + column.contains("password") || column.contains("passphrase") || column.contains("otp") + })); + } } diff --git a/src/session/model.rs b/src/session/model.rs index 3417c73..a76b636 100644 --- a/src/session/model.rs +++ b/src/session/model.rs @@ -12,19 +12,21 @@ pub struct GroupId(pub u64); /// How a session authenticates. #[derive(Clone, Copy, Debug, PartialEq, Eq, Default)] pub enum AuthKind { - Password, #[default] + Auto, + Password, Key, } impl AuthKind { /// Every kind, in the order the session form lists them. - pub const ALL: [AuthKind; 2] = [AuthKind::Password, AuthKind::Key]; + pub const ALL: [AuthKind; 3] = [AuthKind::Auto, AuthKind::Password, AuthKind::Key]; pub fn label(self) -> &'static str { match self { + AuthKind::Auto => "自动", AuthKind::Password => "密码", - AuthKind::Key => "密钥", + AuthKind::Key => "私钥文件", } } @@ -32,6 +34,7 @@ impl AuthKind { /// cannot rewrite what is already in the database. pub fn as_str(self) -> &'static str { match self { + AuthKind::Auto => "auto", AuthKind::Password => "password", AuthKind::Key => "key", } @@ -41,8 +44,10 @@ impl AuthKind { /// newer version might have written. pub fn from_stored(value: &str) -> Self { match value { + "auto" => AuthKind::Auto, "password" => AuthKind::Password, - _ => AuthKind::Key, + "key" => AuthKind::Key, + _ => AuthKind::Auto, } } } @@ -82,6 +87,7 @@ pub struct Session { pub port: u16, pub user: SharedString, pub auth: AuthKind, + pub key_path: Option, pub group: Option, pub state: ConnectionState, } @@ -95,6 +101,7 @@ impl Session { port: draft.port, user: draft.user, auth: draft.auth, + key_path: draft.key_path, group: draft.group, state: ConnectionState::Disconnected, } @@ -113,6 +120,7 @@ impl Session { port: self.port, user: self.user.clone(), auth: self.auth, + key_path: self.key_path.clone(), group: self.group, } } @@ -155,6 +163,7 @@ pub struct SessionDraft { pub port: u16, pub user: SharedString, pub auth: AuthKind, + pub key_path: Option, pub group: Option, } @@ -173,9 +182,38 @@ impl SessionDraft { port, user: user.into(), auth, + key_path: None, group, } } + + /// Set the private key used by [`AuthKind::Key`]. Keeping this as a + /// builder preserves the existing six-argument constructor for callers. + pub fn with_key_path(mut self, path: impl Into) -> Self { + let path = path.into(); + self.key_path = (!path.trim().is_empty()).then_some(path); + self + } + + pub(crate) fn with_optional_key_path(mut self, path: Option) -> Self { + self.key_path = path + .filter(|path| !path.trim().is_empty()) + .map(SharedString::from); + self + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn auto_is_the_default_and_key_path_is_opt_in() { + assert_eq!(AuthKind::default(), AuthKind::Auto); + let draft = SessionDraft::new("server", "host", 22, "me", AuthKind::Key, None) + .with_key_path("/tmp/id_ed25519"); + assert_eq!(draft.key_path.as_deref(), Some("/tmp/id_ed25519")); + } } /// The values the group form commits. diff --git a/src/session/session_dialog.rs b/src/session/session_dialog.rs index 06c5d77..785c477 100644 --- a/src/session/session_dialog.rs +++ b/src/session/session_dialog.rs @@ -5,6 +5,7 @@ use gpui_kit::component::{ button::{Button, ButtonVariant, ButtonVariants as _}, dialog::{DialogAction, DialogButtonProps, DialogClose, DialogFooter}, form::{Field, Form}, + h_flex, input::{Input, InputState}, select::{Select, SelectState}, v_flex, @@ -30,10 +31,13 @@ pub struct SessionForm { port: Entity, user: Entity, auth: Entity>>, + key_path: Entity, group: Entity>>, /// Parallel to the group select's rows; `None` is the root of the tree. group_ids: Vec>, error: Option, + editing_connected: bool, + _auth_subscription: Subscription, } impl SessionForm { @@ -44,11 +48,14 @@ impl SessionForm { window: &mut Window, cx: &mut Context, ) -> Self { - let (draft, options) = { + let (draft, options, editing_connected) = { let read = store.read(cx); ( editing.and_then(|id| read.session(id)).map(Session::draft), group_options(read.groups(), &[]), + editing + .and_then(|id| read.session(id)) + .is_some_and(|session| session.state != super::ConnectionState::Disconnected), ) }; // A session with no group sits at the root of the tree, which is where @@ -60,7 +67,7 @@ impl SessionForm { group_names.push(path); } let draft = draft.unwrap_or_else(|| { - SessionDraft::new("", "", 22, "root", AuthKind::Key, preselect_group) + SessionDraft::new("", "", 22, "root", AuthKind::Auto, preselect_group) }); let name = cx.new(|cx| { @@ -98,6 +105,12 @@ impl SessionForm { cx, ) }); + let key_path = cx.new(|cx| { + InputState::new(window, cx) + .placeholder("选择 OpenSSH 私钥文件") + .default_value(draft.key_path.clone().unwrap_or_default()) + }); + let auth_subscription = cx.observe(&auth, |_, _, cx| cx.notify()); let group_ix = group_ids .iter() .position(|group| *group == draft.group) @@ -113,18 +126,53 @@ impl SessionForm { port, user, auth, + key_path, group, group_ids, error: None, + editing_connected, + _auth_subscription: auth_subscription, } } + fn choose_key(&mut self, _: &ClickEvent, window: &mut Window, cx: &mut Context) { + let receiver = cx.prompt_for_paths(PathPromptOptions { + files: true, + directories: false, + multiple: false, + prompt: Some("选择 SSH 私钥".into()), + }); + cx.spawn_in(window, async move |this, cx| { + let Ok(Ok(Some(paths))) = receiver.await else { + return; + }; + let Some(path) = paths.into_iter().next() else { + return; + }; + this.update_in(cx, |this, window, cx| { + this.key_path.update(cx, |input, cx| { + input.set_value(path.to_string_lossy().into_owned(), window, cx) + }); + cx.notify(); + }) + .ok(); + }) + .detach(); + } + /// Validate and write to the store. Returns whether the dialog may close. pub fn commit(&mut self, _: &mut Window, cx: &mut Context) -> bool { let name = self.name.read(cx).value().trim().to_string(); let host = self.host.read(cx).value().trim().to_string(); let user = self.user.read(cx).value().trim().to_string(); let port = self.port.read(cx).value().trim().parse::(); + let auth = self + .auth + .read(cx) + .selected_index(cx) + .and_then(|ix| AuthKind::ALL.get(ix.row).copied()) + .unwrap_or_default(); + let key_path = self.key_path.read(cx).value().trim().to_string(); let error = if name.is_empty() { Some("请输入名称") @@ -132,6 +180,8 @@ impl SessionForm { Some("请输入主机") } else if !matches!(port, Ok(1..=u16::MAX)) { Some("端口必须是 1 到 65535 之间的数字") + } else if auth == AuthKind::Key && key_path.is_empty() { + Some("私钥认证需要选择私钥文件") } else { None }; @@ -141,19 +191,13 @@ impl SessionForm { return false; } - let auth = self - .auth - .read(cx) - .selected_index(cx) - .and_then(|ix| AuthKind::ALL.get(ix.row).copied()) - .unwrap_or_default(); let group = self .group .read(cx) .selected_index(cx) .and_then(|ix| self.group_ids.get(ix.row).copied()) .unwrap_or(None); - let draft = SessionDraft::new( + let mut draft = SessionDraft::new( name, host, port.unwrap_or(22), @@ -165,6 +209,9 @@ impl SessionForm { auth, group, ); + if auth == AuthKind::Key { + draft = draft.with_key_path(key_path); + } let editing = self.editing; self.store.update(cx, |store, cx| match editing { @@ -182,6 +229,12 @@ impl SessionForm { impl Render for SessionForm { fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { + let auth = self + .auth + .read(cx) + .selected_index(cx) + .and_then(|ix| AuthKind::ALL.get(ix.row).copied()) + .unwrap_or_default(); v_flex() .gap_3() .w_full() @@ -212,14 +265,43 @@ impl Render for SessionForm { .child( Field::new() .label("认证方式") - .child(Select::new(&self.auth).small()), + .child(Select::new(&self.auth).id("session-auth").small()), ) + .when(auth == AuthKind::Key, |form| { + form.child( + Field::new().label("私钥文件").required(true).child( + h_flex() + .gap_2() + .w_full() + .child( + Input::new(&self.key_path) + .id("session-key-path") + .small() + .flex_1(), + ) + .child( + Button::new("choose-key") + .label("选择…") + .small() + .on_click(cx.listener(Self::choose_key)), + ), + ), + ) + }) .child( Field::new() .label("分组") .child(Select::new(&self.group).small()), ), ) + .when(self.editing_connected, |form| { + form.child( + div() + .text_sm() + .text_color(cx.theme().muted_foreground) + .child("保存后连接设置将立即生效并重连。"), + ) + }) .when_some(self.error.clone(), |form, error| { form.child( div() diff --git a/src/session/store.rs b/src/session/store.rs index f534e74..617973e 100644 --- a/src/session/store.rs +++ b/src/session/store.rs @@ -37,6 +37,9 @@ const MAX_RECENT: usize = 10; pub enum SessionStoreEvent { /// A change was applied in memory but could not be written to disk. PersistFailed(SharedString), + /// A live terminal must reconnect because its SSH endpoint or + /// authentication configuration changed. + ConnectionSettingsChanged(SessionId), } impl EventEmitter for SessionStore {} @@ -89,7 +92,7 @@ impl SessionStore { "10.0.1.12", 22, "root", - AuthKind::Key, + AuthKind::Auto, Some(production), ), SessionDraft::new( @@ -97,7 +100,7 @@ impl SessionStore { "10.0.1.13", 22, "root", - AuthKind::Key, + AuthKind::Auto, Some(production), ), SessionDraft::new( @@ -113,7 +116,7 @@ impl SessionStore { "10.0.9.20", 2222, "deploy", - AuthKind::Key, + AuthKind::Auto, Some(staging), ), SessionDraft::new( @@ -129,7 +132,7 @@ impl SessionStore { "192.168.1.20", 22, "xuz", - AuthKind::Key, + AuthKind::Auto, Some(development), ), ]; @@ -254,12 +257,22 @@ impl SessionStore { /// Replace the editable fields of a session; connection state is kept. pub fn update(&mut self, id: SessionId, draft: SessionDraft, cx: &mut Context) -> bool { + let connection_changed = self.session(id).is_some_and(|session| { + session.host != draft.host + || session.port != draft.port + || session.user != draft.user + || session.auth != draft.auth + || session.key_path != draft.key_path + }); let updated = self.update_unnotified(id, draft); if updated { if let (Some(database), Some(session)) = (self.database.as_ref(), self.session(id)) { let result = database.update_session(session); self.report(result, "保存会话", cx); } + if connection_changed { + cx.emit(SessionStoreEvent::ConnectionSettingsChanged(id)); + } cx.notify(); } updated @@ -451,7 +464,7 @@ mod tests { use super::*; fn draft(name: &str, group: Option) -> SessionDraft { - SessionDraft::new(name, "10.0.0.1", 22, "root", AuthKind::Key, group) + SessionDraft::new(name, "10.0.0.1", 22, "root", AuthKind::Auto, group) } #[test] @@ -495,9 +508,17 @@ mod tests { fn duplicate_places_a_copy_after_the_original() { let mut store = SessionStore::seed(); let web01 = store.sessions()[0].id; + let mut draft = store.session(web01).unwrap().draft(); + draft.auth = AuthKind::Key; + draft.key_path = Some("/tmp/id_ed25519".into()); + assert!(store.update_unnotified(web01, draft)); let copy = store.duplicate_unnotified(web01).unwrap(); assert_eq!(store.sessions()[1].id, copy); assert_eq!(store.sessions()[1].name.as_ref(), "web-01 副本"); + assert_eq!( + store.sessions()[1].key_path.as_deref(), + Some("/tmp/id_ed25519") + ); assert_eq!(store.sessions()[1].state, ConnectionState::Disconnected); } diff --git a/src/ssh/mod.rs b/src/ssh/mod.rs new file mode 100644 index 0000000..07437e6 --- /dev/null +++ b/src/ssh/mod.rs @@ -0,0 +1,5 @@ +//! Real SSH transport. SFTP intentionally remains a separate mock capability. + +mod transport; + +pub use transport::SshTerminalTransportProvider; diff --git a/src/ssh/transport.rs b/src/ssh/transport.rs new file mode 100644 index 0000000..5d232d0 --- /dev/null +++ b/src/ssh/transport.rs @@ -0,0 +1,1404 @@ +use std::collections::HashMap; +use std::future::Future; +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; +use std::sync::{Arc, Mutex, mpsc}; +use std::thread; +use std::time::Duration; + +use anyhow::{Context as _, Result, anyhow, bail}; +use async_channel::Sender; +use russh::ChannelMsg; +use russh::client::{self, AuthResult, KeyboardInteractiveAuthResponse}; +use russh::keys::agent::client::AgentClient; +use russh::keys::known_hosts::{known_host_keys_path, learn_known_hosts_path}; +use russh::keys::{ + HashAlg, PrivateKeyWithHashAlg, PublicKey, load_secret_key, parse_public_key_base64, +}; +use russh::{MethodKind, MethodSet}; +use tokio::sync::{mpsc as tokio_mpsc, oneshot, watch}; + +use crate::session::{AuthKind, Session}; +use crate::terminal::{ + RemoteTerminalTransportProvider, SharedTerminalTransportFactory, TerminalPrompt, + TerminalPromptField, TerminalPromptKind, TerminalPromptReply, TerminalSize, TerminalTransport, + TerminalTransportCommand, TerminalTransportEvent, TerminalTransportFactory, +}; + +const CONNECT_TIMEOUT: Duration = Duration::from_secs(15); +const KEEPALIVE_INTERVAL: Duration = Duration::from_secs(30); +const SHUTDOWN_TIMEOUT: Duration = Duration::from_secs(2); +const AUTH_RETRIES: usize = 3; +static NEXT_PROMPT_ID: AtomicU64 = AtomicU64::new(1); + +/// Production remote provider. Its write lock is shared by every connection, +/// making host-key checks and appends to shellr's trust file serial. +pub struct SshTerminalTransportProvider { + known_hosts_path: PathBuf, + known_hosts_lock: Arc>, +} + +impl SshTerminalTransportProvider { + pub fn new(path: impl Into) -> Self { + Self { + known_hosts_path: path.into(), + known_hosts_lock: Arc::new(Mutex::new(())), + } + } +} + +impl RemoteTerminalTransportProvider for SshTerminalTransportProvider { + fn factory_for(&self, session: &Session) -> SharedTerminalTransportFactory { + Arc::new(SshTerminalTransportFactory { + config: SshConnectionConfig::from(session), + known_hosts_path: self.known_hosts_path.clone(), + known_hosts_lock: self.known_hosts_lock.clone(), + }) + } +} + +#[derive(Clone)] +struct SshConnectionConfig { + host: String, + port: u16, + user: String, + auth: AuthKind, + key_path: Option, +} + +impl From<&Session> for SshConnectionConfig { + fn from(session: &Session) -> Self { + Self { + host: session.host.to_string(), + port: session.port, + user: session.user.to_string(), + auth: session.auth, + key_path: session + .key_path + .as_ref() + .map(|path| PathBuf::from(path.as_ref())), + } + } +} + +struct SshTerminalTransportFactory { + config: SshConnectionConfig, + known_hosts_path: PathBuf, + known_hosts_lock: Arc>, +} + +impl TerminalTransportFactory for SshTerminalTransportFactory { + fn create(&self) -> Box { + Box::new(SshTerminalTransport { + config: self.config.clone(), + known_hosts_path: self.known_hosts_path.clone(), + known_hosts_lock: self.known_hosts_lock.clone(), + }) + } +} + +struct SshTerminalTransport { + config: SshConnectionConfig, + known_hosts_path: PathBuf, + known_hosts_lock: Arc>, +} + +impl TerminalTransport for SshTerminalTransport { + fn run( + self: Box, + initial_size: TerminalSize, + commands: mpsc::Receiver, + events: Sender, + ) -> Result<()> { + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .context("无法启动 SSH 运行时")?; + let (command_tx, command_rx) = tokio_mpsc::unbounded_channel(); + let stop = Arc::new(AtomicBool::new(false)); + let bridge_stop = stop.clone(); + let bridge = thread::Builder::new() + .name("shellr-ssh-command-bridge".into()) + .spawn(move || { + while !bridge_stop.load(Ordering::Acquire) { + match commands.recv_timeout(Duration::from_millis(20)) { + Ok(command) => { + if command_tx.send(command).is_err() { + break; + } + } + Err(mpsc::RecvTimeoutError::Timeout) => {} + Err(mpsc::RecvTimeoutError::Disconnected) => break, + } + } + }) + .context("无法启动 SSH 命令桥接线程")?; + + let result = runtime.block_on(self.run_async(initial_size, command_rx, events)); + stop.store(true, Ordering::Release); + let _ = bridge.join(); + result + } +} + +impl SshTerminalTransport { + async fn run_async( + &self, + initial_size: TerminalSize, + mut bridge_commands: tokio_mpsc::UnboundedReceiver, + events: Sender, + ) -> Result<()> { + let (io_tx, mut io_rx) = tokio_mpsc::unbounded_channel(); + let (shutdown_tx, shutdown_rx) = watch::channel(false); + let broker = Arc::new(PromptBroker::new(events.clone(), shutdown_rx)); + let router_broker = broker.clone(); + let router = tokio::spawn(async move { + while let Some(command) = bridge_commands.recv().await { + match command { + TerminalTransportCommand::PromptReply { request_id, reply } => { + router_broker.respond(request_id, reply); + } + TerminalTransportCommand::Shutdown => { + let _ = shutdown_tx.send(true); + router_broker.cancel_all(); + let _ = io_tx.send(TerminalTransportCommand::Shutdown); + break; + } + command => { + if io_tx.send(command).is_err() { + break; + } + } + } + } + }); + + let config = client::Config { + keepalive_interval: Some(KEEPALIVE_INTERVAL), + keepalive_max: 3, + nodelay: true, + ..Default::default() + }; + let handler = SshClientHandler { + host: self.config.host.clone(), + port: self.config.port, + known_hosts_path: self.known_hosts_path.clone(), + known_hosts_lock: self.known_hosts_lock.clone(), + broker: broker.clone(), + }; + + let connect = client::connect( + Arc::new(config), + (self.config.host.as_str(), self.config.port), + handler, + ); + let mut shutdown = broker.shutdown_receiver(); + let mut handle = tokio::select! { + result = timeout_excluding_prompts( + connect, + broker.prompt_activity_receiver(), + CONNECT_TIMEOUT, + ) => { + result?.map_err(|error| anyhow!(safe_connect_error(&error)))? + } + _ = shutdown.changed() => bail!("连接已取消"), + }; + + tokio::select! { + result = authenticate(&mut handle, &self.config, &broker) => result?, + _ = shutdown.changed() => bail!("连接已取消"), + } + let mut channel = tokio::select! { + result = handle.channel_open_session() => { + result.map_err(|_| anyhow!("无法创建 SSH 会话通道"))? + } + _ = shutdown.changed() => bail!("连接已取消"), + }; + tokio::select! { + result = channel.request_pty( + true, + "xterm-256color", + dimension(initial_size.columns()), + dimension(initial_size.rows()), + pixel_dimension(initial_size.columns(), initial_size.cell_width()), + pixel_dimension(initial_size.rows(), initial_size.cell_height()), + &[], + ) => result.map_err(|_| anyhow!("服务器拒绝创建终端"))?, + _ = shutdown.changed() => bail!("连接已取消"), + } + tokio::select! { + result = channel.request_shell(true) => { + result.map_err(|_| anyhow!("服务器拒绝启动 Shell"))? + } + _ = shutdown.changed() => bail!("连接已取消"), + } + events + .send(TerminalTransportEvent::Started) + .await + .map_err(|_| anyhow!("终端标签页已关闭"))?; + + let mut exit_code = 0; + let mut exit_signal = None; + loop { + tokio::select! { + command = io_rx.recv() => match command { + Some(TerminalTransportCommand::Write(bytes)) => { + channel.data_bytes(bytes).await.map_err(|_| anyhow!("向远程终端写入失败"))?; + } + Some(TerminalTransportCommand::Resize(size)) => { + channel.window_change( + dimension(size.columns()), dimension(size.rows()), + pixel_dimension(size.columns(), size.cell_width()), + pixel_dimension(size.rows(), size.cell_height()), + ).await.map_err(|_| anyhow!("调整远程终端尺寸失败"))?; + } + Some(TerminalTransportCommand::Shutdown) | None => { + let _ = tokio::time::timeout(SHUTDOWN_TIMEOUT, async { + let _ = channel.eof().await; + let _ = channel.close().await; + let _ = handle.disconnect( + russh::Disconnect::ByApplication, + "shellr closed the terminal", + "zh-CN", + ).await; + }).await; + router.abort(); + return Ok(()); + } + Some(TerminalTransportCommand::PromptReply { .. }) => {} + }, + message = channel.wait() => match message { + Some(ChannelMsg::Data { data }) | Some(ChannelMsg::ExtendedData { data, .. }) => { + if events.send(TerminalTransportEvent::Output(data.to_vec())).await.is_err() { + break; + } + } + Some(ChannelMsg::ExitStatus { exit_status }) => exit_code = exit_status, + Some(ChannelMsg::ExitSignal { signal_name, .. }) => { + exit_signal = Some(format!("{signal_name:?}")); + } + Some(ChannelMsg::Eof | ChannelMsg::Close) | None => break, + _ => {} + } + } + } + let _ = events + .send(TerminalTransportEvent::Exited { + code: exit_code, + signal: exit_signal, + }) + .await; + router.abort(); + Ok(()) + } +} + +fn dimension(value: usize) -> u32 { + value.min(u32::MAX as usize) as u32 +} + +fn pixel_dimension(cells: usize, cell_size: u16) -> u32 { + cells + .saturating_mul(usize::from(cell_size)) + .min(u32::MAX as usize) as u32 +} + +struct PromptBroker { + pending: Mutex>>, + events: Sender, + shutdown: watch::Receiver, + prompt_activity: watch::Sender, +} + +impl PromptBroker { + fn new(events: Sender, shutdown: watch::Receiver) -> Self { + let (prompt_activity, _) = watch::channel(false); + Self { + pending: Mutex::new(HashMap::new()), + events, + shutdown, + prompt_activity, + } + } + + fn shutdown_receiver(&self) -> watch::Receiver { + self.shutdown.clone() + } + + fn prompt_activity_receiver(&self) -> watch::Receiver { + self.prompt_activity.subscribe() + } + + async fn ask(&self, kind: TerminalPromptKind) -> Result { + let request_id = NEXT_PROMPT_ID.fetch_add(1, Ordering::Relaxed); + let (sender, receiver) = oneshot::channel(); + self.pending + .lock() + .unwrap_or_else(|error| error.into_inner()) + .insert(request_id, sender); + let _ = self.prompt_activity.send(true); + let result = async { + self.events + .send(TerminalTransportEvent::Prompt(TerminalPrompt::new( + request_id, kind, + ))) + .await + .map_err(|_| anyhow!("终端标签页已关闭"))?; + let mut shutdown = self.shutdown.clone(); + tokio::select! { + reply = receiver => reply.map_err(|_| anyhow!("认证请求已取消")), + _ = shutdown.changed() => bail!("连接已取消"), + } + } + .await; + self.pending + .lock() + .unwrap_or_else(|error| error.into_inner()) + .remove(&request_id); + let _ = self.prompt_activity.send(false); + result + } + + async fn emit(&self, kind: TerminalPromptKind) { + let request_id = NEXT_PROMPT_ID.fetch_add(1, Ordering::Relaxed); + let _ = self + .events + .send(TerminalTransportEvent::Prompt(TerminalPrompt::new( + request_id, kind, + ))) + .await; + } + + fn respond(&self, request_id: u64, reply: TerminalPromptReply) { + if let Some(sender) = self + .pending + .lock() + .unwrap_or_else(|error| error.into_inner()) + .remove(&request_id) + { + let _ = sender.send(reply); + } + } + + fn cancel_all(&self) { + self.pending + .lock() + .unwrap_or_else(|error| error.into_inner()) + .clear(); + } +} + +/// Apply the network handshake timeout without counting time spent waiting +/// for an explicit answer from the user to a host-trust prompt. +async fn timeout_excluding_prompts( + future: F, + mut prompt_activity: watch::Receiver, + timeout: Duration, +) -> Result +where + F: Future, +{ + tokio::pin!(future); + let mut remaining = timeout; + loop { + if *prompt_activity.borrow() { + tokio::select! { + result = &mut future => return Ok(result), + changed = prompt_activity.changed() => { + if changed.is_err() { + bail!("连接已取消"); + } + } + } + continue; + } + + let started = tokio::time::Instant::now(); + tokio::select! { + result = &mut future => return Ok(result), + _ = tokio::time::sleep(remaining) => bail!("连接超时(15 秒)"), + changed = prompt_activity.changed() => { + if changed.is_err() { + bail!("连接已取消"); + } + remaining = remaining.saturating_sub(started.elapsed()); + if remaining.is_zero() && !*prompt_activity.borrow() { + bail!("连接超时(15 秒)"); + } + } + } + } +} + +struct SshClientHandler { + host: String, + port: u16, + known_hosts_path: PathBuf, + known_hosts_lock: Arc>, + broker: Arc, +} + +impl client::Handler for SshClientHandler { + type Error = anyhow::Error; + + async fn check_server_key( + &mut self, + server_public_key: &russh::keys::PublicKeyOrCertificate, + ) -> Result { + let key = server_public_key.public_key(); + let fingerprint = key.fingerprint(HashAlg::Sha256).to_string(); + let algorithm = key.algorithm().to_string(); + let known = { + let _guard = self + .known_hosts_lock + .lock() + .unwrap_or_else(|error| error.into_inner()); + read_known_keys(&self.host, self.port, &self.known_hosts_path)? + }; + if known.iter().any(|(_, saved)| saved == &key) { + return Ok(true); + } + if !known.is_empty() { + let old = known + .iter() + .map(|(_, saved)| saved.fingerprint(HashAlg::Sha256).to_string()) + .collect(); + self.broker + .emit(TerminalPromptKind::host_key_changed( + &self.host, + self.port, + algorithm, + old, + fingerprint, + &self.known_hosts_path, + )) + .await; + return Ok(false); + } + + let reply = self + .broker + .ask(TerminalPromptKind::unknown_host( + &self.host, + self.port, + algorithm, + fingerprint, + )) + .await?; + if !matches!(reply, TerminalPromptReply::TrustAndSave) { + return Ok(false); + } + let _guard = self + .known_hosts_lock + .lock() + .unwrap_or_else(|error| error.into_inner()); + let known = read_known_keys(&self.host, self.port, &self.known_hosts_path)?; + if known.iter().any(|(_, saved)| saved == &key) { + return Ok(true); + } + if !known.is_empty() { + bail!("保存主机密钥时发现信任文件已发生变化"); + } + learn_known_hosts_path(&self.host, self.port, &key, &self.known_hosts_path) + .map_err(|_| anyhow!("无法写入主机信任文件:{}", self.known_hosts_path.display()))?; + Ok(true) + } +} + +fn read_known_keys(host: &str, port: u16, path: &Path) -> Result> { + if path.exists() { + let contents = std::fs::read_to_string(path) + .with_context(|| format!("无法读取主机信任文件:{}", path.display()))?; + for line in contents.lines().map(str::trim) { + if line.is_empty() || line.starts_with('#') { + continue; + } + let mut fields = line.split_whitespace(); + let valid = fields.next().is_some() + && fields.next().is_some() + && fields + .next() + .is_some_and(|encoded| parse_public_key_base64(encoded).is_ok()); + if !valid { + bail!("主机信任文件已损坏:{}", path.display()); + } + } + } + known_host_keys_path(host, port, path) + .map_err(|_| anyhow!("主机信任文件已损坏或无法读取:{}", path.display())) +} + +async fn authenticate( + handle: &mut client::Handle, + config: &SshConnectionConfig, + broker: &PromptBroker, +) -> Result<()> +where + H::Error: From, +{ + let first = handle + .authenticate_none(&config.user) + .await + .map_err(|_| anyhow!("无法查询服务器支持的认证方式"))?; + if first.success() { + return Ok(()); + } + let mut methods = remaining_methods(first); + let mut partial = false; + + if config.auth == AuthKind::Auto + && methods.contains(&MethodKind::PublicKey) + && let Some(result) = try_agent(handle, &config.user).await? + { + if result.success() { + return Ok(()); + } + partial = is_partial(&result); + methods = remaining_methods(result); + } + + if matches!(config.auth, AuthKind::Auto | AuthKind::Key) + && methods.contains(&MethodKind::PublicKey) + { + let paths = key_paths(config)?; + for path in paths { + let Some(key) = load_private_key(&path, broker).await? else { + continue; + }; + let hash = handle + .best_supported_rsa_hash() + .await + .map_err(|_| anyhow!("无法协商 RSA 签名算法"))? + .flatten(); + let result = handle + .authenticate_publickey( + &config.user, + PrivateKeyWithHashAlg::new(Arc::new(key), hash), + ) + .await + .map_err(|_| anyhow!("私钥认证失败"))?; + if result.success() { + return Ok(()); + } + partial = is_partial(&result); + methods = remaining_methods(result); + if partial { + break; + } + } + if config.auth == AuthKind::Key && !partial { + bail!("服务器未接受指定的私钥"); + } + } + + if matches!(config.auth, AuthKind::Auto | AuthKind::Password) || partial { + if methods.contains(&MethodKind::Password) { + for _ in 0..AUTH_RETRIES { + let answer = ask_one_secret(broker, "SSH 登录", "请输入登录密码", "密码").await?; + let result = handle + .authenticate_password(&config.user, answer.into_inner()) + .await + .map_err(|_| anyhow!("密码认证失败"))?; + if result.success() { + return Ok(()); + } + partial = is_partial(&result); + methods = remaining_methods(result); + if partial || !methods.contains(&MethodKind::Password) { + break; + } + } + } + if methods.contains(&MethodKind::KeyboardInteractive) { + for _ in 0..AUTH_RETRIES { + if keyboard_interactive(handle, &config.user, broker).await? { + return Ok(()); + } + } + } + } + bail!("认证失败:服务器未接受可用的认证方式") +} + +async fn try_agent( + handle: &mut client::Handle, + user: &str, +) -> Result> +where + H::Error: From, +{ + #[cfg(unix)] + { + let Ok(mut agent) = AgentClient::connect_env().await else { + return Ok(None); + }; + let Ok(identities) = agent.request_identities().await else { + return Ok(None); + }; + let hash = handle + .best_supported_rsa_hash() + .await + .map_err(|_| anyhow!("无法协商 SSH Agent 签名算法"))? + .flatten(); + let mut last = None; + for identity in identities { + let key = identity.public_key().into_owned(); + match handle + .authenticate_publickey_with(user, key, hash, &mut agent) + .await + { + Ok(result) if result.success() => return Ok(Some(result)), + Ok(result) => last = Some(result), + Err(_) => continue, + } + } + Ok(last) + } + #[cfg(not(unix))] + { + let _ = (handle, user); + Ok(None) + } +} + +fn key_paths(config: &SshConnectionConfig) -> Result> { + if config.auth == AuthKind::Key { + return config + .key_path + .clone() + .map(|path| vec![path]) + .ok_or_else(|| anyhow!("私钥认证需要选择私钥文件")); + } + let Some(home) = dirs::home_dir() else { + return Ok(Vec::new()); + }; + Ok(["id_ed25519", "id_ecdsa", "id_rsa"] + .into_iter() + .map(|name| home.join(".ssh").join(name)) + .collect()) +} + +async fn load_private_key( + path: &Path, + broker: &PromptBroker, +) -> Result> { + if !path.exists() { + return Ok(None); + } + match load_secret_key(path, None) { + Ok(key) => return Ok(Some(key)), + Err(russh::keys::Error::KeyIsEncrypted) => {} + Err(_) => bail!("无法读取私钥文件:{}", path.display()), + } + for _ in 0..AUTH_RETRIES { + let answer = ask_one_secret( + broker, + "私钥口令", + &format!("请输入 {} 的口令", path.display()), + "口令", + ) + .await?; + if let Ok(key) = load_secret_key(path, Some(answer.expose())) { + return Ok(Some(key)); + } + } + bail!("私钥口令错误次数过多") +} + +async fn ask_one_secret( + broker: &PromptBroker, + title: &str, + instructions: &str, + label: &str, +) -> Result { + let reply = broker + .ask(TerminalPromptKind::authentication( + title, + instructions, + vec![TerminalPromptField::new(label, false)], + )) + .await?; + match reply { + TerminalPromptReply::Answers(mut answers) if answers.len() == 1 => Ok(answers.remove(0)), + TerminalPromptReply::Cancel => bail!("认证已取消"), + _ => bail!("认证回复无效"), + } +} + +async fn keyboard_interactive( + handle: &mut client::Handle, + user: &str, + broker: &PromptBroker, +) -> Result +where + H::Error: From, +{ + let mut response = handle + .authenticate_keyboard_interactive_start(user, None) + .await + .map_err(|_| anyhow!("无法开始交互式认证"))?; + loop { + match response { + KeyboardInteractiveAuthResponse::Success => return Ok(true), + KeyboardInteractiveAuthResponse::Failure { .. } => return Ok(false), + KeyboardInteractiveAuthResponse::InfoRequest { + name, + instructions, + prompts, + } => { + let fields = prompts + .into_iter() + .map(|prompt| TerminalPromptField::new(prompt.prompt, prompt.echo)) + .collect(); + let reply = broker + .ask(TerminalPromptKind::authentication( + name, + instructions, + fields, + )) + .await?; + let TerminalPromptReply::Answers(answers) = reply else { + bail!("交互式认证已取消") + }; + response = handle + .authenticate_keyboard_interactive_respond( + answers + .into_iter() + .map(|answer| answer.into_inner()) + .collect(), + ) + .await + .map_err(|_| anyhow!("交互式认证失败"))?; + } + } + } +} + +fn remaining_methods(result: AuthResult) -> MethodSet { + match result { + AuthResult::Success => MethodSet::empty(), + AuthResult::Failure { + remaining_methods, .. + } => remaining_methods, + } +} + +fn is_partial(result: &AuthResult) -> bool { + matches!( + result, + AuthResult::Failure { + partial_success: true, + .. + } + ) +} + +fn safe_connect_error(_: &anyhow::Error) -> &'static str { + "无法建立 SSH 连接,请检查主机、端口和主机密钥" +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::terminal::TerminalSecret; + use russh::server::{self, Server as _}; + use std::borrow::Cow; + + #[derive(Default)] + struct ServerState { + pty: Option<(String, u32, u32, u32, u32)>, + resize: Option<(u32, u32, u32, u32)>, + input: Vec, + } + + #[derive(Clone)] + struct TestServer { + state: Arc>, + auth: TestAuth, + } + + #[derive(Clone)] + enum TestAuth { + Password, + PublicKey(PublicKey), + KeyboardInteractive, + } + + impl TestAuth { + fn methods(&self) -> MethodSet { + let method = match self { + Self::Password => MethodKind::Password, + Self::PublicKey(_) => MethodKind::PublicKey, + Self::KeyboardInteractive => MethodKind::KeyboardInteractive, + }; + MethodSet::from(&[method][..]) + } + } + + impl server::Server for TestServer { + type Handler = Self; + + fn new_client(&mut self, _: Option) -> Self::Handler { + self.clone() + } + } + + impl server::Handler for TestServer { + type Error = russh::Error; + + async fn auth_password( + &mut self, + _: &str, + password: &str, + ) -> Result { + Ok( + if matches!(self.auth, TestAuth::Password) && password == "test-password" { + server::Auth::Accept + } else { + server::Auth::UnsupportedMethod + }, + ) + } + + async fn auth_publickey( + &mut self, + _: &str, + public_key: &PublicKey, + ) -> Result { + Ok(match &self.auth { + TestAuth::PublicKey(expected) if expected == public_key => server::Auth::Accept, + TestAuth::PublicKey(_) => server::Auth::reject(), + _ => server::Auth::UnsupportedMethod, + }) + } + + async fn auth_keyboard_interactive<'a>( + &'a mut self, + _: &str, + _: &str, + response: Option>, + ) -> Result { + if !matches!(self.auth, TestAuth::KeyboardInteractive) { + return Ok(server::Auth::UnsupportedMethod); + } + let Some(mut response) = response else { + return Ok(server::Auth::Partial { + name: Cow::Borrowed("双字段验证"), + instructions: Cow::Borrowed("输入别名和一次性验证码"), + prompts: Cow::Owned(vec![ + (Cow::Borrowed("别名"), true), + (Cow::Borrowed("验证码"), false), + ]), + }); + }; + let alias_ok = response + .next() + .is_some_and(|answer| answer.as_ref() == b"tester"); + let otp_ok = response + .next() + .is_some_and(|answer| answer.as_ref() == b"123456"); + Ok(if alias_ok && otp_ok { + server::Auth::Accept + } else { + server::Auth::reject() + }) + } + + async fn channel_open_session( + &mut self, + _: russh::Channel, + reply: server::ChannelOpenHandle, + _: &mut server::Session, + ) -> Result<(), Self::Error> { + reply.accept().await; + Ok(()) + } + + async fn pty_request( + &mut self, + channel: russh::ChannelId, + term: &str, + columns: u32, + rows: u32, + pixel_width: u32, + pixel_height: u32, + _: &[(russh::Pty, u32)], + session: &mut server::Session, + ) -> Result<(), Self::Error> { + self.state + .lock() + .unwrap_or_else(|error| error.into_inner()) + .pty = Some((term.to_string(), columns, rows, pixel_width, pixel_height)); + session.channel_success(channel)?; + Ok(()) + } + + async fn shell_request( + &mut self, + channel: russh::ChannelId, + session: &mut server::Session, + ) -> Result<(), Self::Error> { + session.channel_success(channel)?; + session.data(channel, &b"ready\r\n"[..])?; + Ok(()) + } + + async fn window_change_request( + &mut self, + _: russh::ChannelId, + columns: u32, + rows: u32, + pixel_width: u32, + pixel_height: u32, + _: &mut server::Session, + ) -> Result<(), Self::Error> { + self.state + .lock() + .unwrap_or_else(|error| error.into_inner()) + .resize = Some((columns, rows, pixel_width, pixel_height)); + Ok(()) + } + + async fn data( + &mut self, + channel: russh::ChannelId, + data: &[u8], + session: &mut server::Session, + ) -> Result<(), Self::Error> { + self.state + .lock() + .unwrap_or_else(|error| error.into_inner()) + .input + .extend_from_slice(data); + if data == b"exit\n" { + session.exit_status_request(channel, 7)?; + session.eof(channel)?; + session.close(channel)?; + } else { + session.data(channel, data.to_vec())?; + } + Ok(()) + } + } + + struct RunningTestServer { + port: u16, + state: Arc>, + handle: server::RunningServerHandle, + thread: Option>, + } + + impl Drop for RunningTestServer { + fn drop(&mut self) { + self.handle.shutdown("test complete".into()); + if let Some(thread) = self.thread.take() { + thread.join().unwrap(); + } + } + } + + fn start_server(auth: TestAuth) -> Option { + let state = Arc::new(Mutex::new(ServerState::default())); + let server_state = state.clone(); + let methods = auth.methods(); + let (ready_tx, ready_rx) = mpsc::channel(); + let thread = thread::spawn(move || { + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap(); + runtime.block_on(async move { + let listener = match tokio::net::TcpListener::bind(("127.0.0.1", 0)).await { + Ok(listener) => listener, + Err(error) => { + ready_tx.send(Err(error)).unwrap(); + return; + } + }; + let port = listener.local_addr().unwrap().port(); + let mut rng = russh::keys::key::safe_rng(); + let key = + russh::keys::PrivateKey::random(&mut rng, russh::keys::Algorithm::Ed25519) + .unwrap(); + let config = Arc::new(server::Config { + methods, + auth_rejection_time: Duration::ZERO, + auth_rejection_time_initial: Some(Duration::ZERO), + keys: vec![key], + ..server::Config::default() + }); + let mut server = TestServer { + state: server_state, + auth, + }; + let running = server.run_on_socket(config, &listener); + ready_tx.send(Ok((port, running.handle()))).unwrap(); + running.await.unwrap(); + }); + }); + let (port, handle) = match ready_rx.recv_timeout(Duration::from_secs(3)).unwrap() { + Ok(server) => server, + Err(error) if error.kind() == std::io::ErrorKind::PermissionDenied => { + thread.join().unwrap(); + return None; + } + Err(error) => panic!("failed to bind test server: {error}"), + }; + Some(RunningTestServer { + port, + state, + handle, + thread: Some(thread), + }) + } + + fn connect_then_shutdown( + session: Session, + known_hosts: &Path, + mut answer: impl FnMut(&TerminalPromptKind) -> TerminalPromptReply, + ) { + let provider = SshTerminalTransportProvider::new(known_hosts); + let factory = provider.factory_for(&session); + let (command_tx, command_rx) = mpsc::channel(); + let (event_tx, event_rx) = async_channel::unbounded(); + let (done_tx, done_rx) = mpsc::channel(); + let worker = thread::spawn(move || { + done_tx + .send( + factory + .create() + .run(TerminalSize::DEFAULT, command_rx, event_tx), + ) + .unwrap(); + }); + loop { + match event_rx.recv_blocking().unwrap() { + TerminalTransportEvent::Prompt(prompt) => command_tx + .send(TerminalTransportCommand::PromptReply { + request_id: prompt.request_id(), + reply: answer(prompt.kind()), + }) + .unwrap(), + TerminalTransportEvent::Started => break, + TerminalTransportEvent::Failed(error) => panic!("SSH failed: {error}"), + TerminalTransportEvent::Exited { .. } => panic!("unexpected early exit"), + TerminalTransportEvent::Output(_) => {} + } + } + command_tx.send(TerminalTransportCommand::Shutdown).unwrap(); + done_rx + .recv_timeout(SHUTDOWN_TIMEOUT + Duration::from_secs(1)) + .expect("SSH worker did not stop in time") + .unwrap(); + worker.join().unwrap(); + } + + #[test] + fn real_russh_server_covers_trust_password_pty_io_resize_and_exit() { + let Some(server) = start_server(TestAuth::Password) else { + eprintln!( + "loopback sockets are unavailable in this sandbox; skipping integration body" + ); + return; + }; + let directory = tempfile::tempdir().unwrap(); + let known_hosts = directory.path().join("known_hosts"); + let session = Session::new( + crate::session::SessionId(1), + crate::session::SessionDraft::new( + "test", + "127.0.0.1", + server.port, + "tester", + AuthKind::Password, + None, + ), + ); + let provider = SshTerminalTransportProvider::new(&known_hosts); + let factory = provider.factory_for(&session); + let (command_tx, command_rx) = mpsc::channel(); + let (event_tx, event_rx) = async_channel::unbounded(); + let worker = thread::spawn(move || { + factory + .create() + .run(TerminalSize::new(90, 30, 9, 18), command_rx, event_tx) + .unwrap(); + }); + + let deadline = std::time::Instant::now() + Duration::from_secs(5); + let mut started = false; + let mut ready = false; + while std::time::Instant::now() < deadline && !(started && ready) { + match event_rx.recv_blocking().unwrap() { + TerminalTransportEvent::Prompt(prompt) => match prompt.kind() { + TerminalPromptKind::UnknownHost(_) => command_tx + .send(TerminalTransportCommand::PromptReply { + request_id: prompt.request_id(), + reply: TerminalPromptReply::TrustAndSave, + }) + .unwrap(), + TerminalPromptKind::Authentication(_) => command_tx + .send(TerminalTransportCommand::PromptReply { + request_id: prompt.request_id(), + reply: TerminalPromptReply::Answers(vec![TerminalSecret::new( + "test-password", + )]), + }) + .unwrap(), + TerminalPromptKind::HostKeyChanged(_) => panic!("unexpected changed key"), + }, + TerminalTransportEvent::Started => started = true, + TerminalTransportEvent::Output(bytes) => { + ready |= String::from_utf8_lossy(&bytes).contains("ready") + } + TerminalTransportEvent::Failed(error) => panic!("SSH failed: {error}"), + TerminalTransportEvent::Exited { .. } => panic!("unexpected early exit"), + } + } + assert!(started && ready); + assert!(known_hosts.exists()); + + command_tx + .send(TerminalTransportCommand::Resize(TerminalSize::new( + 120, 40, 10, 20, + ))) + .unwrap(); + command_tx + .send(TerminalTransportCommand::Write(b"hello\n".to_vec())) + .unwrap(); + let echoed = loop { + if let TerminalTransportEvent::Output(bytes) = event_rx.recv_blocking().unwrap() { + break bytes; + } + }; + assert_eq!(echoed, b"hello\n"); + command_tx + .send(TerminalTransportCommand::Write(b"exit\n".to_vec())) + .unwrap(); + let exit = loop { + if let TerminalTransportEvent::Exited { code, signal } = + event_rx.recv_blocking().unwrap() + { + break (code, signal); + } + }; + assert_eq!(exit, (7, None)); + worker.join().unwrap(); + + // A second connection must trust the saved key without prompting and + // an active close must return the worker within the shutdown bound. + let factory = provider.factory_for(&session); + let (command_tx, command_rx) = mpsc::channel(); + let (event_tx, event_rx) = async_channel::unbounded(); + let (done_tx, done_rx) = mpsc::channel(); + let worker = thread::spawn(move || { + let result = + factory + .create() + .run(TerminalSize::new(90, 30, 9, 18), command_rx, event_tx); + done_tx.send(result).unwrap(); + }); + loop { + match event_rx.recv_blocking().unwrap() { + TerminalTransportEvent::Prompt(prompt) => match prompt.kind() { + TerminalPromptKind::UnknownHost(_) => { + panic!("saved host key prompted again") + } + TerminalPromptKind::Authentication(_) => command_tx + .send(TerminalTransportCommand::PromptReply { + request_id: prompt.request_id(), + reply: TerminalPromptReply::Answers(vec![TerminalSecret::new( + "test-password", + )]), + }) + .unwrap(), + TerminalPromptKind::HostKeyChanged(_) => panic!("unexpected changed key"), + }, + TerminalTransportEvent::Started => break, + TerminalTransportEvent::Failed(error) => panic!("SSH failed: {error}"), + _ => {} + } + } + command_tx.send(TerminalTransportCommand::Shutdown).unwrap(); + done_rx + .recv_timeout(SHUTDOWN_TIMEOUT + Duration::from_secs(1)) + .expect("SSH worker did not stop in time") + .unwrap(); + worker.join().unwrap(); + + let state = server + .state + .lock() + .unwrap_or_else(|error| error.into_inner()); + assert_eq!(state.pty, Some(("xterm-256color".into(), 90, 30, 810, 540))); + assert_eq!(state.resize, Some((120, 40, 1200, 800))); + assert_eq!(state.input, b"hello\nexit\n"); + } + + #[test] + fn secret_debug_never_contains_plaintext() { + let reply = TerminalPromptReply::Answers(vec![TerminalSecret::new("super-secret")]); + let debug = format!("{reply:?}"); + assert!(!debug.contains("super-secret")); + assert!(debug.contains("已隐藏")); + } + + #[test] + fn explicit_private_key_and_keyboard_interactive_authenticate() { + let directory = tempfile::tempdir().unwrap(); + let mut rng = russh::keys::key::safe_rng(); + let client_key = + russh::keys::PrivateKey::random(&mut rng, russh::keys::Algorithm::Ed25519).unwrap(); + let Some(key_server) = start_server(TestAuth::PublicKey(client_key.public_key().clone())) + else { + eprintln!( + "loopback sockets are unavailable in this sandbox; skipping integration body" + ); + return; + }; + let key_path = directory.path().join("id_ed25519"); + client_key + .write_openssh_file(&key_path, russh::keys::ssh_key::LineEnding::LF) + .unwrap(); + let key_session = Session::new( + crate::session::SessionId(1), + crate::session::SessionDraft::new( + "key-test", + "127.0.0.1", + key_server.port, + "tester", + AuthKind::Key, + None, + ) + .with_key_path(key_path.to_string_lossy().into_owned()), + ); + connect_then_shutdown( + key_session, + &directory.path().join("key-known-hosts"), + |prompt| match prompt { + TerminalPromptKind::UnknownHost(_) => TerminalPromptReply::TrustAndSave, + other => panic!("unexpected key-auth prompt: {other:?}"), + }, + ); + drop(key_server); + + let Some(interactive_server) = start_server(TestAuth::KeyboardInteractive) else { + panic!("loopback became unavailable during keyboard-interactive test") + }; + let interactive_session = Session::new( + crate::session::SessionId(2), + crate::session::SessionDraft::new( + "interactive-test", + "127.0.0.1", + interactive_server.port, + "tester", + AuthKind::Password, + None, + ), + ); + let mut saw_challenge = false; + connect_then_shutdown( + interactive_session, + &directory.path().join("interactive-known-hosts"), + |prompt| match prompt { + TerminalPromptKind::UnknownHost(_) => TerminalPromptReply::TrustAndSave, + TerminalPromptKind::Authentication(authentication) => { + saw_challenge = true; + assert_eq!(authentication.title(), "双字段验证"); + assert_eq!(authentication.fields().len(), 2); + assert!(authentication.fields()[0].echo()); + assert!(!authentication.fields()[1].echo()); + TerminalPromptReply::Answers(vec![ + TerminalSecret::new("tester"), + TerminalSecret::new("123456"), + ]) + } + other => panic!("unexpected interactive prompt: {other:?}"), + }, + ); + assert!(saw_challenge); + } + + #[test] + fn malformed_known_hosts_is_blocked() { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("known_hosts"); + std::fs::write(&path, "this is not a key\n").unwrap(); + let error = read_known_keys("example.test", 22, &path).unwrap_err(); + assert!(error.to_string().contains("已损坏")); + } + + #[test] + fn changed_host_key_is_reported_and_blocked() { + let Some(server) = start_server(TestAuth::Password) else { + eprintln!( + "loopback sockets are unavailable in this sandbox; skipping integration body" + ); + return; + }; + let directory = tempfile::tempdir().unwrap(); + let known_hosts = directory.path().join("known_hosts"); + let mut rng = russh::keys::key::safe_rng(); + let stale_key = + russh::keys::PrivateKey::random(&mut rng, russh::keys::Algorithm::Ed25519).unwrap(); + learn_known_hosts_path( + "127.0.0.1", + server.port, + stale_key.public_key(), + &known_hosts, + ) + .unwrap(); + let session = Session::new( + crate::session::SessionId(1), + crate::session::SessionDraft::new( + "test", + "127.0.0.1", + server.port, + "tester", + AuthKind::Password, + None, + ), + ); + let provider = SshTerminalTransportProvider::new(&known_hosts); + let factory = provider.factory_for(&session); + let (_command_tx, command_rx) = mpsc::channel(); + let (event_tx, event_rx) = async_channel::unbounded(); + let (done_tx, done_rx) = mpsc::channel(); + let worker = thread::spawn(move || { + done_tx + .send( + factory + .create() + .run(TerminalSize::DEFAULT, command_rx, event_tx), + ) + .unwrap(); + }); + + let deadline = std::time::Instant::now() + Duration::from_secs(3); + let changed = loop { + match event_rx.try_recv() { + Ok(TerminalTransportEvent::Prompt(prompt)) => match prompt.kind() { + TerminalPromptKind::HostKeyChanged(changed) => break changed.clone(), + other => panic!("unexpected prompt: {other:?}"), + }, + Ok(event) => panic!("unexpected event: {event:?}"), + Err(async_channel::TryRecvError::Empty) if std::time::Instant::now() < deadline => { + thread::sleep(Duration::from_millis(5)); + } + Err(error) => panic!("host-key prompt was not emitted: {error}"), + } + }; + assert_eq!(changed.host(), "127.0.0.1"); + assert_eq!(changed.port(), server.port); + assert_eq!(changed.known_hosts_path(), known_hosts); + assert_eq!(changed.old_fingerprints().len(), 1); + assert_ne!(changed.old_fingerprints()[0], changed.fingerprint()); + assert!( + done_rx + .recv_timeout(Duration::from_secs(3)) + .expect("mismatched host connection did not stop") + .is_err() + ); + worker.join().unwrap(); + } +} diff --git a/src/terminal/engine.rs b/src/terminal/engine.rs index 9bff555..826ab33 100644 --- a/src/terminal/engine.rs +++ b/src/terminal/engine.rs @@ -15,8 +15,8 @@ use alacritty_terminal::term::{Config, TermMode}; use alacritty_terminal::vte::ansi::{Color, CursorShape, CursorStyle, Processor, Rgb}; use super::{ - SharedTerminalTransportFactory, TerminalLifecycle, TerminalSize, TerminalStatus, - TerminalTransportCommand, TerminalTransportEvent, + SharedTerminalTransportFactory, TerminalLifecycle, TerminalPrompt, TerminalPromptReply, + TerminalSize, TerminalStatus, TerminalTransportCommand, TerminalTransportEvent, }; pub type AlacrittyTerm = Term; @@ -110,7 +110,9 @@ impl TerminalEngine { && this .update(cx, |this, cx| { for event in batch { - this.handle_event(event); + if let Some(prompt) = this.handle_event(event) { + cx.emit(TerminalEngineEvent::PromptRequested(prompt)); + } } cx.emit(TerminalEngineEvent::Changed); cx.notify(); @@ -123,9 +125,9 @@ impl TerminalEngine { }) } - fn handle_event(&mut self, event: TerminalUiEvent) { + fn handle_event(&mut self, event: TerminalUiEvent) -> Option { if event.generation != self.generation { - return; + return None; } match event.kind { @@ -141,12 +143,14 @@ impl TerminalEngine { TerminalUiEventKind::Failed(error) => { self.lifecycle = TerminalLifecycle::Failed(error); } + TerminalUiEventKind::Prompt(prompt) => return Some(prompt), TerminalUiEventKind::ColorRequest(index, formatter) => { let color = self.runtime.term.lock().colors()[index] .unwrap_or_else(|| default_query_color(index)); self.runtime.write(formatter(color).into_bytes()); } } + None } pub fn restart(&mut self, cx: &mut gpui_kit::Context) { @@ -164,6 +168,19 @@ impl TerminalEngine { cx.notify(); } + pub fn restart_with_factory( + &mut self, + factory: SharedTerminalTransportFactory, + cx: &mut gpui_kit::Context, + ) { + self.factory = factory; + self.restart(cx); + } + + pub fn reply_to_prompt(&self, request_id: u64, reply: TerminalPromptReply) { + self.runtime.reply_to_prompt(request_id, reply); + } + pub fn shutdown(&mut self, cx: &mut gpui_kit::Context) { self.generation = self.generation.saturating_add(1); self.lifecycle = TerminalLifecycle::Closing; @@ -392,9 +409,10 @@ impl Drop for TerminalEngine { } } -#[derive(Clone, Copy, Debug)] +#[derive(Clone, Debug)] pub enum TerminalEngineEvent { Changed, + PromptRequested(TerminalPrompt), } struct TerminalRuntime { @@ -441,6 +459,13 @@ impl TerminalRuntime { processor.advance(&mut *parser_term.lock(), &bytes); parser_proxy.wakeup(); } + TerminalTransportEvent::Prompt(prompt) => { + send_ui( + &parser_ui_events, + generation, + TerminalUiEventKind::Prompt(prompt), + ); + } TerminalTransportEvent::Exited { code, signal } => { let description = signal .as_deref() @@ -507,6 +532,12 @@ impl TerminalRuntime { fn shutdown(&self) { let _ = self.commands.send(TerminalTransportCommand::Shutdown); } + + fn reply_to_prompt(&self, request_id: u64, reply: TerminalPromptReply) { + let _ = self + .commands + .send(TerminalTransportCommand::PromptReply { request_id, reply }); + } } fn terminal_config() -> Config { @@ -611,6 +642,7 @@ enum TerminalUiEventKind { ResetTitle, Exited { code: u32, signal: Option }, Failed(String), + Prompt(TerminalPrompt), ColorRequest(usize, Arc String + Send + Sync>), } diff --git a/src/terminal/local_pty.rs b/src/terminal/local_pty.rs index bb99d25..f33c39a 100644 --- a/src/terminal/local_pty.rs +++ b/src/terminal/local_pty.rs @@ -167,7 +167,11 @@ mod tests { code = Some(exit_code); break; } - Ok(TerminalTransportEvent::Started | TerminalTransportEvent::Failed(_)) => {} + Ok( + TerminalTransportEvent::Started + | TerminalTransportEvent::Failed(_) + | TerminalTransportEvent::Prompt(_), + ) => {} Err(async_channel::TryRecvError::Empty) => thread::sleep(Duration::from_millis(5)), Err(async_channel::TryRecvError::Closed) => break, } diff --git a/src/terminal/mock_shell.rs b/src/terminal/mock_shell.rs deleted file mode 100644 index 9cfbc7e..0000000 --- a/src/terminal/mock_shell.rs +++ /dev/null @@ -1,128 +0,0 @@ -//! A tiny canned shell so the terminal mock feels alive. - -use gpui_kit::SharedString; - -use crate::session::Session; - -/// What a command asks the terminal to do besides printing. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum ShellEffect { - /// Clear the scrollback. - Clear, - /// End the session. - Exit, -} - -#[derive(Clone, Debug, Default, PartialEq, Eq)] -pub struct ShellReply { - pub lines: Vec, - pub effect: Option, -} - -impl ShellReply { - fn lines(lines: I) -> Self - where - I: IntoIterator, - S: Into, - { - Self { - lines: lines.into_iter().map(Into::into).collect(), - effect: None, - } - } -} - -/// `root@web-01:~$ ` -pub fn prompt(session: &Session) -> String { - let symbol = if session.user.as_ref() == "root" { - "#" - } else { - "$" - }; - format!("{}@{}:~{symbol} ", session.user, session.name) -} - -/// The lines shown when a terminal opens. -pub fn banner(session: &Session) -> Vec { - vec![ - format!("Connecting to {}...", session.address()).into(), - "Welcome to Ubuntu 24.04 LTS (GNU/Linux 6.8.0-45-generic x86_64)".into(), - "".into(), - " * Documentation: https://help.ubuntu.com".into(), - " * Support: https://ubuntu.com/pro".into(), - "".into(), - format!( - "Last login: Wed Sep 9 18:42:07 2026 from 10.0.0.2 ({})", - session.host - ) - .into(), - ] -} - -/// The canned reply to a command line. -pub fn reply(session: &Session, command: &str) -> ShellReply { - let command = command.trim(); - let (program, rest) = command.split_once(' ').unwrap_or((command, "")); - match program { - "" => ShellReply::default(), - "clear" => ShellReply { - lines: Vec::new(), - effect: Some(ShellEffect::Clear), - }, - "exit" | "logout" => ShellReply { - lines: vec!["logout".into()], - effect: Some(ShellEffect::Exit), - }, - "ls" => ShellReply::lines(["backups deploy.sh"]), - "pwd" => ShellReply::lines([format!("/home/{}", session.user)]), - "whoami" => ShellReply::lines([session.user.to_string()]), - "hostname" => ShellReply::lines([session.name.to_string()]), - "uname" => ShellReply::lines([if rest.contains('a') { - format!( - "Linux {} 6.8.0-45-generic #45-Ubuntu SMP x86_64 GNU/Linux", - session.name - ) - } else { - "Linux".to_string() - }]), - "uptime" => ShellReply::lines([ - " 20:41:13 up 37 days, 4:12, 1 user, load average: 0.08, 0.11, 0.09", - ]), - "date" => ShellReply::lines(["Thu Sep 10 20:41:13 CST 2026"]), - "echo" => ShellReply::lines([rest.to_string()]), - "help" => ShellReply::lines([ - "mock shell — 可用命令: ls pwd whoami hostname uname [-a] uptime date echo clear exit", - ]), - _ => ShellReply::lines([format!("bash: {program}: command not found")]), - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::session::SessionStore; - - fn session() -> Session { - SessionStore::seed().sessions()[0].clone() - } - - #[test] - fn unknown_command_reports_not_found() { - let reply = reply(&session(), "frobnicate --now"); - assert_eq!(reply.lines, ["bash: frobnicate: command not found"]); - assert_eq!(reply.effect, None); - } - - #[test] - fn clear_and_exit_carry_effects() { - assert_eq!(reply(&session(), "clear").effect, Some(ShellEffect::Clear)); - assert_eq!(reply(&session(), "exit").effect, Some(ShellEffect::Exit)); - assert_eq!(reply(&session(), " ").lines.len(), 0); - } - - #[test] - fn prompt_uses_hash_for_root() { - let session = session(); - assert_eq!(prompt(&session), "root@web-01:~# "); - } -} diff --git a/src/terminal/mock_transport.rs b/src/terminal/mock_transport.rs deleted file mode 100644 index 98d07d1..0000000 --- a/src/terminal/mock_transport.rs +++ /dev/null @@ -1,153 +0,0 @@ -use std::sync::mpsc; - -use anyhow::Result; -use async_channel::Sender; - -use crate::session::Session; - -use super::{ - ShellEffect, TerminalSize, TerminalTransport, TerminalTransportCommand, TerminalTransportEvent, - TerminalTransportFactory, banner, prompt, reply, send_event, -}; - -#[derive(Clone)] -pub struct MockSshTransportFactory { - session: Session, -} - -impl MockSshTransportFactory { - pub fn new(session: &Session) -> Self { - Self { - session: session.clone(), - } - } -} - -impl TerminalTransportFactory for MockSshTransportFactory { - fn create(&self) -> Box { - Box::new(MockSshTransport { - session: self.session.clone(), - }) - } -} - -struct MockSshTransport { - session: Session, -} - -impl TerminalTransport for MockSshTransport { - fn run( - self: Box, - _: TerminalSize, - commands: mpsc::Receiver, - events: Sender, - ) -> Result<()> { - send_event(&events, TerminalTransportEvent::Started); - - let mut opening = banner(&self.session) - .into_iter() - .map(|line| line.to_string()) - .collect::>() - .join("\r\n"); - opening.push_str("\r\n"); - opening.push_str(&prompt(&self.session)); - send_event( - &events, - TerminalTransportEvent::Output(opening.into_bytes()), - ); - - let mut input = Vec::new(); - let mut escape_state = 0_u8; - while let Ok(command) = commands.recv() { - match command { - TerminalTransportCommand::Write(bytes) => { - for byte in bytes { - if escape_state != 0 { - escape_state = match (escape_state, byte) { - (1, b'[' | b'O') => 2, - (2, 0x40..=0x7e) => 0, - (2, _) => 2, - _ => 0, - }; - continue; - } - match byte { - b'\r' | b'\n' => { - send_event( - &events, - TerminalTransportEvent::Output(b"\r\n".to_vec()), - ); - let command = String::from_utf8_lossy(&input).into_owned(); - input.clear(); - let response = reply(&self.session, &command); - if matches!(response.effect, Some(ShellEffect::Clear)) { - send_event( - &events, - TerminalTransportEvent::Output(b"\x1b[2J\x1b[H".to_vec()), - ); - } else { - for line in response.lines { - let mut output = line.to_string(); - output.push_str("\r\n"); - send_event( - &events, - TerminalTransportEvent::Output(output.into_bytes()), - ); - } - } - if matches!(response.effect, Some(ShellEffect::Exit)) { - send_event( - &events, - TerminalTransportEvent::Exited { - code: 0, - signal: None, - }, - ); - return Ok(()); - } - send_event( - &events, - TerminalTransportEvent::Output( - prompt(&self.session).into_bytes(), - ), - ); - } - 0x03 => { - input.clear(); - let output = format!("^C\r\n{}", prompt(&self.session)); - send_event( - &events, - TerminalTransportEvent::Output(output.into_bytes()), - ); - } - 0x7f | 0x08 => { - if input.pop().is_some() { - while input - .last() - .is_some_and(|byte| byte & 0b1100_0000 == 0b1000_0000) - { - input.pop(); - } - send_event( - &events, - TerminalTransportEvent::Output(b"\x08 \x08".to_vec()), - ); - } - } - // The mock has no command-line navigation; consume - // complete CSI/SS3 sequences without echoing them. - 0x1b => escape_state = 1, - byte => { - input.push(byte); - send_event(&events, TerminalTransportEvent::Output(vec![byte])); - } - } - } - } - TerminalTransportCommand::Resize(_) => {} - TerminalTransportCommand::Shutdown => break, - } - } - Ok(()) - } -} diff --git a/src/terminal/mod.rs b/src/terminal/mod.rs index 6616cd4..d0862a1 100644 --- a/src/terminal/mod.rs +++ b/src/terminal/mod.rs @@ -3,8 +3,6 @@ mod engine; mod local_pty; mod local_terminal_panel; -mod mock_shell; -mod mock_transport; mod model; mod terminal_panel; mod terminal_view; @@ -13,14 +11,17 @@ mod transport; pub use engine::{TerminalCell, TerminalEngine, TerminalEngineEvent, TerminalSnapshot}; pub use local_pty::LocalPtyTransportFactory; pub use local_terminal_panel::{LocalTerminalPanel, LocalTerminalPanelEvent}; -pub use mock_shell::*; -pub use mock_transport::MockSshTransportFactory; -pub use model::{LocalTerminalId, TerminalLifecycle, TerminalSize, TerminalStatus}; +pub use model::{ + LocalTerminalId, RemoteTerminalId, TerminalLifecycle, TerminalSize, TerminalStatus, +}; pub use terminal_panel::{TerminalPanel, TerminalPanelEvent}; pub(crate) use terminal_view::terminal_key_bindings; pub use terminal_view::{TERMINAL_KEY_CONTEXT, TerminalView, TerminalViewEvent}; pub(crate) use transport::send_event; pub use transport::{ - SharedTerminalTransportFactory, TerminalTransport, TerminalTransportCommand, - TerminalTransportEvent, TerminalTransportFactory, + AuthenticationPrompt, FixedRemoteTerminalTransportProvider, HostKeyChangedPrompt, + RemoteTerminalTransportProvider, SharedRemoteTerminalTransportProvider, + SharedTerminalTransportFactory, TerminalPrompt, TerminalPromptField, TerminalPromptKind, + TerminalPromptReply, TerminalSecret, TerminalTransport, TerminalTransportCommand, + TerminalTransportEvent, TerminalTransportFactory, UnknownHostPrompt, }; diff --git a/src/terminal/model.rs b/src/terminal/model.rs index 1d03a41..00cfa7c 100644 --- a/src/terminal/model.rs +++ b/src/terminal/model.rs @@ -7,6 +7,10 @@ use serde::Deserialize; #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Deserialize)] pub struct LocalTerminalId(pub u64); +/// Stable identity for one remote-terminal connection and its Dock tab. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Deserialize)] +pub struct RemoteTerminalId(pub u64); + /// The dimensions shared by the emulator and a terminal transport. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub struct TerminalSize { diff --git a/src/terminal/terminal_panel.rs b/src/terminal/terminal_panel.rs index b9e1323..c9d058d 100644 --- a/src/terminal/terminal_panel.rs +++ b/src/terminal/terminal_panel.rs @@ -1,5 +1,3 @@ -use std::sync::Arc; - use gpui_kit::component::{ Icon, button::Button, @@ -10,34 +8,42 @@ use gpui_kit::*; use crate::app::{ CatalogIcon, CloseTerminal, CopyTerminal, DuplicateSession, EditSession, OpenExplorer, - PasteTerminal, ReconnectSession, + PasteTerminal, ReconnectTerminal, }; -use crate::session::{ConnectionState, SessionId, SessionStore}; +use crate::session::{SessionId, SessionStore}; use crate::shared::ClosableTabTitle; -use super::{MockSshTransportFactory, TerminalLifecycle, TerminalView}; +use super::{ + RemoteTerminalId, SharedRemoteTerminalTransportProvider, TerminalLifecycle, TerminalPrompt, + TerminalPromptReply, TerminalView, TerminalViewEvent, +}; #[derive(Clone, Debug, PartialEq, Eq)] pub enum TerminalPanelEvent { - Activated(SessionId), - Closed(SessionId), - StatusChanged(SessionId), + Activated(RemoteTerminalId, SessionId), + Closed(RemoteTerminalId, SessionId), + StatusChanged(RemoteTerminalId, SessionId), + PromptRequested(RemoteTerminalId, SessionId, TerminalPrompt), } /// A remote-session Dock panel backed by the shared terminal engine. The -/// transport remains a canned SSH mock until the network feature arrives. +/// transport is created from the latest saved session on every connection. pub struct TerminalPanel { + id: RemoteTerminalId, session_id: SessionId, store: Entity, terminal: Entity, + remote_provider: SharedRemoteTerminalTransportProvider, tab_group: Option>, _subscriptions: Vec, } impl TerminalPanel { pub fn new( + id: RemoteTerminalId, session_id: SessionId, store: Entity, + remote_provider: SharedRemoteTerminalTransportProvider, window: &mut Window, cx: &mut Context, ) -> Self { @@ -48,40 +54,45 @@ impl TerminalPanel { .expect("terminal sessions must exist in the store"); let terminal = cx.new(|cx| { TerminalView::new( - ("terminal", session_id.0), + ("terminal", id.0), format!("{} 的终端", session.name), - Arc::new(MockSshTransportFactory::new(&session)), + remote_provider.factory_for(&session), window, cx, ) }); let subscriptions = vec![ cx.observe(&store, |_, _, cx| cx.notify()), - cx.observe(&terminal, |this, terminal, cx| { - let lifecycle = terminal.read(cx).lifecycle(cx); - if matches!( - lifecycle, - TerminalLifecycle::Exited { .. } | TerminalLifecycle::Failed(_) - ) { - let id = this.session_id; - this.store.update(cx, |store, cx| { - store.set_state(id, ConnectionState::Disconnected, cx) - }); - } - cx.emit(TerminalPanelEvent::StatusChanged(this.session_id)); + cx.observe(&terminal, |this, _, cx| { + cx.emit(TerminalPanelEvent::StatusChanged(this.id, this.session_id)); cx.notify(); }), + cx.subscribe(&terminal, |this, _, event: &TerminalViewEvent, cx| { + if let TerminalViewEvent::PromptRequested(prompt) = event { + cx.emit(TerminalPanelEvent::PromptRequested( + this.id, + this.session_id, + prompt.clone(), + )); + } + }), ]; Self { + id, session_id, store, terminal, + remote_provider, tab_group: None, _subscriptions: subscriptions, } } + pub fn id(&self) -> RemoteTerminalId { + self.id + } + pub fn session_id(&self) -> SessionId { self.session_id } @@ -94,6 +105,10 @@ impl TerminalPanel { &self.terminal } + pub fn lifecycle(&self, cx: &App) -> TerminalLifecycle { + self.terminal.read(cx).lifecycle(cx) + } + pub fn append_line(&mut self, line: impl AsRef, cx: &mut Context) { self.terminal.update(cx, |terminal, cx| { terminal.append_system_message(line.as_ref(), cx) @@ -101,8 +116,13 @@ impl TerminalPanel { } pub fn reconnect(&mut self, window: &mut Window, cx: &mut Context) { - self.terminal - .update(cx, |terminal, cx| terminal.restart(cx)); + let Some(session) = self.store.read(cx).session(self.session_id).cloned() else { + return; + }; + let factory = self.remote_provider.factory_for(&session); + self.terminal.update(cx, |terminal, cx| { + terminal.restart_with_factory(factory, cx) + }); let focus = self.terminal.read(cx).focus_handle(); window.focus(&focus, cx); } @@ -111,6 +131,17 @@ impl TerminalPanel { self.terminal .update(cx, |terminal, cx| terminal.stop("已断开连接", cx)); } + + pub fn cancel_connection(&mut self, cx: &mut Context) { + self.terminal + .update(cx, |terminal, cx| terminal.stop("连接已取消", cx)); + } + + pub fn reply_to_prompt(&self, request_id: u64, reply: TerminalPromptReply, cx: &App) { + self.terminal + .read(cx) + .reply_to_prompt(request_id, reply, cx); + } } impl EventEmitter for TerminalPanel {} @@ -136,7 +167,7 @@ impl BasePanel for TerminalPanel { .update(cx, |store, cx| store.set_active(Some(id), cx)); let focus = self.terminal.read(cx).focus_handle(); window.focus(&focus, cx); - cx.emit(TerminalPanelEvent::Activated(id)); + cx.emit(TerminalPanelEvent::Activated(self.id, id)); } fn on_added_to(&mut self, group: WeakEntity, _: &mut Window, _: &mut Context) { @@ -148,13 +179,7 @@ impl BasePanel for TerminalPanel { self.terminal .update(cx, |terminal, cx| terminal.shutdown(cx)); let id = self.session_id; - self.store.update(cx, |store, cx| { - store.set_state(id, ConnectionState::Disconnected, cx); - if store.active().map(|session| session.id) == Some(id) { - store.set_active(None, cx); - } - }); - cx.emit(TerminalPanelEvent::Closed(id)); + cx.emit(TerminalPanelEvent::Closed(self.id, id)); } } @@ -167,26 +192,27 @@ impl Panel for TerminalPanel { .map(|session| session.name.clone()) .unwrap_or_else(|| "终端".into()); ClosableTabTitle::new(CatalogIcon::Terminal, name).closable( - ("close-terminal", self.session_id.0), - Box::new(CloseTerminal(self.session_id)), + ("close-terminal", self.id.0), + Box::new(CloseTerminal(self.id)), ) } fn toolbar_buttons(&mut self, _: &mut Window, _: &mut Context) -> Option> { - let id = self.session_id; + let session_id = self.session_id; + let terminal_id = self.id; Some(vec![ - Button::new("sftp") + Button::new(("sftp", terminal_id.0)) .icon(Icon::new(CatalogIcon::FolderTree)) .label("SFTP") .tooltip("打开 SFTP 文件浏览") .on_click(move |_, window, cx| { - window.dispatch_action(Box::new(OpenExplorer(id)), cx) + window.dispatch_action(Box::new(OpenExplorer(session_id)), cx) }), - Button::new("reconnect") + Button::new(("reconnect", terminal_id.0)) .icon(Icon::new(CatalogIcon::RefreshCw)) .tooltip("重连") .on_click(move |_, window, cx| { - window.dispatch_action(Box::new(ReconnectSession(id)), cx) + window.dispatch_action(Box::new(ReconnectTerminal(terminal_id)), cx) }), ]) } diff --git a/src/terminal/terminal_view.rs b/src/terminal/terminal_view.rs index ef71433..f6c5c6f 100644 --- a/src/terminal/terminal_view.rs +++ b/src/terminal/terminal_view.rs @@ -15,8 +15,8 @@ use unicode_width::UnicodeWidthChar as _; use crate::app::{CopyTerminal, PasteTerminal}; use super::{ - SharedTerminalTransportFactory, TerminalEngine, TerminalLifecycle, TerminalSize, - TerminalSnapshot, TerminalStatus, + SharedTerminalTransportFactory, TerminalEngine, TerminalEngineEvent, TerminalLifecycle, + TerminalPrompt, TerminalPromptReply, TerminalSize, TerminalSnapshot, TerminalStatus, }; pub const TERMINAL_KEY_CONTEXT: &str = "Terminal"; @@ -30,9 +30,10 @@ pub(crate) fn terminal_key_bindings() -> [KeyBinding; 2] { ] } -#[derive(Clone, Copy, Debug)] +#[derive(Clone, Debug)] pub enum TerminalViewEvent { Changed, + PromptRequested(TerminalPrompt), } #[derive(Clone, Copy, Debug, Default)] @@ -159,6 +160,11 @@ impl TerminalView { cx.emit(TerminalViewEvent::Changed); cx.notify(); }), + cx.subscribe(&engine, |_, _, event: &TerminalEngineEvent, cx| { + if let TerminalEngineEvent::PromptRequested(prompt) = event { + cx.emit(TerminalViewEvent::PromptRequested(prompt.clone())); + } + }), cx.on_focus(&focus_handle, window, |this, _, cx| { this.focused = true; this.cursor_visible = true; @@ -236,6 +242,31 @@ impl TerminalView { self.engine.update(cx, |engine, cx| engine.restart(cx)); } + pub fn restart_with_factory( + &mut self, + factory: SharedTerminalTransportFactory, + cx: &mut Context, + ) { + self.reset_interaction(); + self.engine + .update(cx, |engine, cx| engine.restart_with_factory(factory, cx)); + } + + pub fn reply_to_prompt(&self, request_id: u64, reply: TerminalPromptReply, cx: &App) { + self.engine.read(cx).reply_to_prompt(request_id, reply); + } + + fn reset_interaction(&mut self) { + self.marked_text.clear(); + self.marked_selection = 0..0; + self.ime_cursor_bounds = None; + self.selection_gesture.finish(); + self.scroll_accumulator.reset(); + self.context_menu = None; + self.context_menu_subscription = None; + self.cursor_visible = true; + } + pub fn shutdown(&mut self, cx: &mut Context) { self.marked_text.clear(); self.marked_selection = 0..0; diff --git a/src/terminal/transport.rs b/src/terminal/transport.rs index 6d3c973..3e113d7 100644 --- a/src/terminal/transport.rs +++ b/src/terminal/transport.rs @@ -2,22 +2,255 @@ use std::sync::{Arc, mpsc}; use anyhow::Result; use async_channel::Sender; +use zeroize::Zeroizing; use super::TerminalSize; +use crate::session::Session; + +/// One field requested by an SSH keyboard-interactive challenge. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct TerminalPromptField { + label: String, + echo: bool, +} + +impl TerminalPromptField { + pub fn new(label: impl Into, echo: bool) -> Self { + Self { + label: label.into(), + echo, + } + } + + pub fn label(&self) -> &str { + &self.label + } + pub fn echo(&self) -> bool { + self.echo + } +} + +/// A transport question that must be answered by the UI before SSH setup can +/// continue. The id is scoped to a single transport generation. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct TerminalPrompt { + request_id: u64, + kind: TerminalPromptKind, +} + +impl TerminalPrompt { + pub fn new(request_id: u64, kind: TerminalPromptKind) -> Self { + Self { request_id, kind } + } + + pub fn request_id(&self) -> u64 { + self.request_id + } + pub fn kind(&self) -> &TerminalPromptKind { + &self.kind + } +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum TerminalPromptKind { + UnknownHost(UnknownHostPrompt), + HostKeyChanged(HostKeyChangedPrompt), + Authentication(AuthenticationPrompt), +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct UnknownHostPrompt { + host: String, + port: u16, + algorithm: String, + fingerprint: String, +} + +impl UnknownHostPrompt { + pub fn host(&self) -> &str { + &self.host + } + pub fn port(&self) -> u16 { + self.port + } + pub fn algorithm(&self) -> &str { + &self.algorithm + } + pub fn fingerprint(&self) -> &str { + &self.fingerprint + } +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct HostKeyChangedPrompt { + host: String, + port: u16, + algorithm: String, + old_fingerprints: Vec, + fingerprint: String, + known_hosts_path: std::path::PathBuf, +} + +impl HostKeyChangedPrompt { + pub fn host(&self) -> &str { + &self.host + } + pub fn port(&self) -> u16 { + self.port + } + pub fn algorithm(&self) -> &str { + &self.algorithm + } + pub fn old_fingerprints(&self) -> &[String] { + &self.old_fingerprints + } + pub fn fingerprint(&self) -> &str { + &self.fingerprint + } + pub fn known_hosts_path(&self) -> &std::path::Path { + &self.known_hosts_path + } +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct AuthenticationPrompt { + title: String, + instructions: String, + fields: Vec, +} + +impl AuthenticationPrompt { + pub fn title(&self) -> &str { + &self.title + } + pub fn instructions(&self) -> &str { + &self.instructions + } + pub fn fields(&self) -> &[TerminalPromptField] { + &self.fields + } +} + +impl TerminalPromptKind { + pub fn unknown_host( + host: impl Into, + port: u16, + algorithm: impl Into, + fingerprint: impl Into, + ) -> Self { + Self::UnknownHost(UnknownHostPrompt { + host: host.into(), + port, + algorithm: algorithm.into(), + fingerprint: fingerprint.into(), + }) + } + + pub fn host_key_changed( + host: impl Into, + port: u16, + algorithm: impl Into, + old_fingerprints: Vec, + fingerprint: impl Into, + known_hosts_path: impl Into, + ) -> Self { + Self::HostKeyChanged(HostKeyChangedPrompt { + host: host.into(), + port, + algorithm: algorithm.into(), + old_fingerprints, + fingerprint: fingerprint.into(), + known_hosts_path: known_hosts_path.into(), + }) + } + + pub fn authentication( + title: impl Into, + instructions: impl Into, + fields: Vec, + ) -> Self { + Self::Authentication(AuthenticationPrompt { + title: title.into(), + instructions: instructions.into(), + fields, + }) + } +} + +/// A secret that is erased when dropped and never prints its contents. +pub struct TerminalSecret(Zeroizing); + +impl TerminalSecret { + pub fn new(value: impl Into) -> Self { + Self(Zeroizing::new(value.into())) + } + pub(crate) fn expose(&self) -> &str { + self.0.as_str() + } + pub(crate) fn into_inner(mut self) -> String { + std::mem::take(&mut *self.0) + } +} + +impl std::fmt::Debug for TerminalSecret { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter.write_str("TerminalSecret([已隐藏])") + } +} + +/// UI response to a transport prompt. Deliberately not `Clone`: answers may +/// contain passwords, private-key passphrases or one-time codes. +pub enum TerminalPromptReply { + TrustAndSave, + Answers(Vec), + Cancel, +} + +impl std::fmt::Debug for TerminalPromptReply { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::TrustAndSave => formatter.write_str("TrustAndSave"), + Self::Answers(answers) => formatter + .debug_tuple("Answers") + .field(&format_args!("{} 个已隐藏答案", answers.len())) + .finish(), + Self::Cancel => formatter.write_str("Cancel"), + } + } +} /// Commands accepted by every terminal byte-stream transport. -#[derive(Clone, Debug, PartialEq, Eq)] pub enum TerminalTransportCommand { Write(Vec), Resize(TerminalSize), + PromptReply { + request_id: u64, + reply: TerminalPromptReply, + }, Shutdown, } +impl std::fmt::Debug for TerminalTransportCommand { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Write(bytes) => formatter.debug_tuple("Write").field(&bytes.len()).finish(), + Self::Resize(size) => formatter.debug_tuple("Resize").field(size).finish(), + Self::PromptReply { request_id, reply } => formatter + .debug_struct("PromptReply") + .field("request_id", request_id) + .field("reply", reply) + .finish(), + Self::Shutdown => formatter.write_str("Shutdown"), + } + } +} + /// Events produced by a terminal byte-stream transport. #[derive(Clone, Debug, PartialEq, Eq)] pub enum TerminalTransportEvent { Started, Output(Vec), + Prompt(TerminalPrompt), Exited { code: u32, signal: Option }, Failed(String), } @@ -38,6 +271,33 @@ pub trait TerminalTransportFactory: Send + Sync + 'static { fn create(&self) -> Box; } +/// Creates a fresh remote factory from the latest saved session. Keeping this +/// boundary at the workspace makes reconnects pick up edits immediately and +/// lets UI tests inject a deterministic fake. +pub trait RemoteTerminalTransportProvider: Send + Sync + 'static { + fn factory_for(&self, session: &Session) -> SharedTerminalTransportFactory; +} + +pub type SharedRemoteTerminalTransportProvider = Arc; + +/// Provider useful for tests and embedders that want every session to use the +/// same factory. +pub struct FixedRemoteTerminalTransportProvider { + factory: SharedTerminalTransportFactory, +} + +impl FixedRemoteTerminalTransportProvider { + pub fn new(factory: SharedTerminalTransportFactory) -> Self { + Self { factory } + } +} + +impl RemoteTerminalTransportProvider for FixedRemoteTerminalTransportProvider { + fn factory_for(&self, _: &Session) -> SharedTerminalTransportFactory { + self.factory.clone() + } +} + pub type SharedTerminalTransportFactory = Arc; pub(crate) fn send_event( diff --git a/src/workspace/workspace_view.rs b/src/workspace/workspace_view.rs index 6b2a6dd..afc7311 100644 --- a/src/workspace/workspace_view.rs +++ b/src/workspace/workspace_view.rs @@ -1,17 +1,28 @@ -use std::{collections::HashMap, rc::Rc, sync::Arc, time::Duration}; +use std::{ + collections::{HashMap, VecDeque}, + rc::Rc, + sync::Arc, + time::Duration, +}; use gpui_kit::component::{ - ActiveTheme as _, Root, Theme, ThemeMode, TitleBar, WindowExt as _, + ActiveTheme as _, Root, Sizable as _, Theme, ThemeMode, TitleBar, WindowExt as _, + button::{Button, ButtonVariant, ButtonVariants as _}, + dialog::{DialogAction, DialogButtonProps, DialogClose, DialogFooter}, dock::{DockArea, DockEvent, DockLayout, DockPlacement, PanelId, TabGroup, panel_handle}, + form::{Field, Form}, + input::{Input, InputContentType, InputState}, notification::Notification, + v_flex, }; +use gpui_kit::prelude::FluentBuilder as _; use gpui_kit::*; use crate::app::{ CloseActiveTab, CloseExplorer, CloseLocalTerminal, CloseTerminal, ConnectSession, CopyTerminal, DeleteGroup, DeleteSession, DisconnectSession, DuplicateSession, EditSession, FocusSearch, NewChildGroup, NewGroup, NewLocalTerminal, NewSession, NewSessionInGroup, OpenExplorer, - PasteTerminal, ReconnectSession, RenameGroup, RestartLocalTerminal, ToggleSessionPanel, + PasteTerminal, ReconnectTerminal, RenameGroup, RestartLocalTerminal, ToggleSessionPanel, ToggleTheme, ZoomIn, ZoomOut, ZoomReset, }; use crate::explorer::{ExplorerPanel, ExplorerPanelEvent}; @@ -21,7 +32,9 @@ use crate::session::{ }; use crate::terminal::{ LocalPtyTransportFactory, LocalTerminalId, LocalTerminalPanel, LocalTerminalPanelEvent, - SharedTerminalTransportFactory, TerminalPanel, TerminalPanelEvent, + RemoteTerminalId, SharedRemoteTerminalTransportProvider, SharedTerminalTransportFactory, + TerminalLifecycle, TerminalPanel, TerminalPanelEvent, TerminalPrompt, TerminalPromptField, + TerminalPromptKind, TerminalPromptReply, TerminalSecret, }; use super::{ @@ -53,7 +66,7 @@ pub fn window_options(cx: &mut App) -> WindowOptions { /// A center tab, by the session it belongs to. #[derive(Clone, Copy, Debug, PartialEq, Eq)] enum CenterTab { - Terminal(SessionId), + Terminal(RemoteTerminalId), Explorer(SessionId), LocalTerminal(LocalTerminalId), } @@ -69,13 +82,17 @@ pub struct Workspace { session_panel: Entity, /// The start page the dock skin shows while the center has no tab. recent: Entity, - terminals: HashMap>, + terminals: HashMap>, explorers: HashMap>, local_terminals: HashMap>, local_terminal_factory: SharedTerminalTransportFactory, + remote_terminal_provider: SharedRemoteTerminalTransportProvider, + next_remote_terminal_id: u64, next_local_terminal_id: u64, /// The center tab displayed most recently; `CloseActiveTab` closes it. active_tab: Option, + prompt_queue: VecDeque<(RemoteTerminalId, SessionId, TerminalPrompt)>, + active_prompt: Option<(RemoteTerminalId, SessionId, u64)>, /// Dispatch target for the title bar and start page: actions sent to it /// reach the workspace handlers whatever is focused. focus_handle: FocusHandle, @@ -86,7 +103,16 @@ impl Workspace { /// `store` is built by `main` from the database on disk, and by the UI /// tests from `SessionStore::seed`. pub fn new(store: Entity, window: &mut Window, cx: &mut Context) -> Self { - Self::new_with_local_terminal_factory(store, Arc::new(LocalPtyTransportFactory), window, cx) + let known_hosts = crate::app::known_hosts_path() + .unwrap_or_else(|_| crate::app::data_dir().join("known_hosts")); + let remote = Arc::new(crate::ssh::SshTerminalTransportProvider::new(known_hosts)); + Self::new_with_transport_providers( + store, + remote, + Arc::new(LocalPtyTransportFactory), + window, + cx, + ) } /// Alternate constructor used by UI tests to avoid launching a real @@ -96,6 +122,21 @@ impl Workspace { local_terminal_factory: SharedTerminalTransportFactory, window: &mut Window, cx: &mut Context, + ) -> Self { + let known_hosts = crate::app::known_hosts_path() + .unwrap_or_else(|_| crate::app::data_dir().join("known_hosts")); + let remote = Arc::new(crate::ssh::SshTerminalTransportProvider::new(known_hosts)); + Self::new_with_transport_providers(store, remote, local_terminal_factory, window, cx) + } + + /// Fully injectable constructor used by UI tests: remote sessions never + /// touch the network, while production uses the SSH provider above. + pub fn new_with_transport_providers( + store: Entity, + remote_terminal_provider: SharedRemoteTerminalTransportProvider, + local_terminal_factory: SharedTerminalTransportFactory, + window: &mut Window, + cx: &mut Context, ) -> Self { let focus_handle = cx.focus_handle(); let recent = cx.new(|cx| RecentSessions::new(store.clone(), focus_handle.clone(), cx)); @@ -119,9 +160,27 @@ impl Workspace { cx.subscribe_in( &store, window, - |_, _, event: &SessionStoreEvent, window, cx| { - let SessionStoreEvent::PersistFailed(message) = event; - window.push_notification(Notification::error(message.clone()), cx); + |this, _, event: &SessionStoreEvent, window, cx| match event { + SessionStoreEvent::PersistFailed(message) => { + window.push_notification(Notification::error(message.clone()), cx); + } + SessionStoreEvent::ConnectionSettingsChanged(id) => { + let panels: Vec<_> = this + .terminals + .values() + .filter(|panel| panel.read(cx).session_id() == *id) + .cloned() + .collect(); + if !panels.is_empty() { + this.cancel_prompts_for_session(*id, window, cx); + this.store.update(cx, |store, cx| { + store.set_state(*id, ConnectionState::Connecting, cx) + }); + for panel in panels { + panel.update(cx, |panel, cx| panel.reconnect(window, cx)); + } + } + } }, ), cx.subscribe_in( @@ -145,11 +204,21 @@ impl Workspace { .collect(); let mut terminals = HashMap::new(); let mut center = DockLayout::tabs(); + let mut next_remote_terminal_id = 1; for id in connected { - let (panel, subscription) = new_terminal_panel(&store, id, window, cx); + let terminal_id = RemoteTerminalId(next_remote_terminal_id); + next_remote_terminal_id += 1; + let (panel, subscription) = new_terminal_panel( + &store, + remote_terminal_provider.clone(), + terminal_id, + id, + window, + cx, + ); center = center.panel_view(panel_handle(panel.clone()), cx); subscriptions.push(subscription); - terminals.insert(id, panel); + terminals.insert(terminal_id, panel); } dock_area.update(cx, |area, cx| { @@ -182,8 +251,12 @@ impl Workspace { explorers: HashMap::new(), local_terminals: HashMap::new(), local_terminal_factory, + remote_terminal_provider, + next_remote_terminal_id, next_local_terminal_id: 1, active_tab: None, + prompt_queue: VecDeque::new(), + active_prompt: None, focus_handle, _subscriptions: subscriptions, } @@ -194,10 +267,24 @@ impl Workspace { &self.store } - pub fn terminal(&self, id: SessionId) -> Option<&Entity> { + pub fn terminal(&self, id: SessionId, cx: &App) -> Option<&Entity> { + self.terminals + .values() + .filter(|panel| panel.read(cx).session_id() == id) + .max_by_key(|panel| panel.read(cx).id().0) + } + + pub fn remote_terminal(&self, id: RemoteTerminalId) -> Option<&Entity> { self.terminals.get(&id) } + pub fn terminal_count(&self, id: SessionId, cx: &App) -> usize { + self.terminals + .values() + .filter(|panel| panel.read(cx).session_id() == id) + .count() + } + pub fn explorer(&self, id: SessionId) -> Option<&Entity> { self.explorers.get(&id) } @@ -219,6 +306,308 @@ impl Workspace { } } + fn enqueue_prompt( + &mut self, + terminal_id: RemoteTerminalId, + session_id: SessionId, + prompt: TerminalPrompt, + window: &mut Window, + cx: &mut Context, + ) { + self.prompt_queue + .push_back((terminal_id, session_id, prompt)); + cx.spawn_in(window, async move |this, cx| { + loop { + cx.background_executor() + .timer(Duration::from_millis(40)) + .await; + let done = this + .update_in(cx, |this, window, cx| { + if this.prompt_queue.is_empty() { + return true; + } + if this.active_prompt.is_some() || window.has_active_dialog(cx) { + return false; + } + this.open_next_prompt(window, cx); + true + }) + .unwrap_or(true); + if done { + break; + } + } + }) + .detach(); + } + + fn open_next_prompt(&mut self, window: &mut Window, cx: &mut Context) { + let (terminal_id, session_id, prompt) = loop { + let Some(next) = self.prompt_queue.pop_front() else { + return; + }; + if self.terminals.contains_key(&next.0) { + break next; + } + }; + let request_id = prompt.request_id(); + self.active_prompt = Some((terminal_id, session_id, request_id)); + let workspace = cx.entity().downgrade(); + match prompt.kind().clone() { + TerminalPromptKind::UnknownHost(prompt) => { + let description = format!( + "主机:{}:{}\n算法:{}\nSHA-256 指纹:{}\n\n请先确认该指纹来自可信渠道。", + prompt.host(), + prompt.port(), + prompt.algorithm(), + prompt.fingerprint(), + ); + window.open_alert_dialog(cx, move |alert, _, _| { + alert + .title("首次连接此主机") + .description(description.clone()) + .button_props( + DialogButtonProps::default() + .ok_text("信任并连接") + .cancel_text("取消"), + ) + .show_cancel(true) + .on_ok({ + let workspace = workspace.clone(); + move |_, _, cx| { + resolve_prompt( + &workspace, + terminal_id, + request_id, + TerminalPromptReply::TrustAndSave, + cx, + ); + true + } + }) + .on_cancel({ + let workspace = workspace.clone(); + move |_, _, cx| { + resolve_prompt( + &workspace, + terminal_id, + request_id, + TerminalPromptReply::Cancel, + cx, + ); + true + } + }) + .on_close({ + let workspace = workspace.clone(); + move |_, _, cx| { + resolve_prompt( + &workspace, + terminal_id, + request_id, + TerminalPromptReply::Cancel, + cx, + ); + } + }) + }); + } + TerminalPromptKind::HostKeyChanged(prompt) => { + let old = prompt.old_fingerprints().join("、"); + let description = format!( + "主机:{}:{}\n算法:{}\n已保存指纹:{old}\n服务器当前指纹:{}\n\n连接已阻断。请核验服务器身份后手动处理:{}", + prompt.host(), + prompt.port(), + prompt.algorithm(), + prompt.fingerprint(), + prompt.known_hosts_path().display(), + ); + window.open_alert_dialog(cx, move |alert, _, _| { + alert + .title("服务器主机密钥已变更") + .description(description.clone()) + .button_props( + DialogButtonProps::default() + .ok_text("关闭") + .ok_variant(ButtonVariant::Danger), + ) + .on_ok({ + let workspace = workspace.clone(); + move |_, _, cx| { + resolve_prompt( + &workspace, + terminal_id, + request_id, + TerminalPromptReply::Cancel, + cx, + ); + true + } + }) + .on_close({ + let workspace = workspace.clone(); + move |_, _, cx| { + resolve_prompt( + &workspace, + terminal_id, + request_id, + TerminalPromptReply::Cancel, + cx, + ); + } + }) + }); + } + TerminalPromptKind::Authentication(prompt) => { + let title = prompt.title().to_string(); + let instructions = prompt.instructions().to_string(); + let fields = prompt.fields().to_vec(); + let dialog_title: SharedString = if title.trim().is_empty() { + "SSH 认证".into() + } else { + title.clone().into() + }; + let form = cx.new(|cx| AuthenticationPromptForm::new(fields, window, cx)); + window.open_dialog(cx, { + let workspace_for_ok = workspace.clone(); + let workspace_for_cancel = workspace.clone(); + let form_for_ok = form.clone(); + move |dialog, _, _| { + dialog + .title(dialog_title.clone()) + .child( + v_flex() + .gap_3() + .when(!instructions.trim().is_empty(), |view| { + view.child(div().text_sm().child(instructions.clone())) + }) + .child(form.clone()), + ) + .footer( + DialogFooter::new() + .child( + DialogClose::new().trigger(|button| button.label("取消")), + ) + .child(DialogAction::new().child( + Button::new("ssh-auth-submit").primary().label("继续"), + )), + ) + .on_ok({ + let workspace = workspace_for_ok.clone(); + let form = form_for_ok.clone(); + move |_, window, cx| { + let answers = + form.update(cx, |form, cx| form.take_answers(window, cx)); + resolve_prompt( + &workspace, + terminal_id, + request_id, + TerminalPromptReply::Answers(answers), + cx, + ); + true + } + }) + .on_cancel({ + let workspace = workspace_for_cancel.clone(); + move |_, _, cx| { + resolve_prompt( + &workspace, + terminal_id, + request_id, + TerminalPromptReply::Cancel, + cx, + ); + true + } + }) + .on_close({ + let workspace = workspace.clone(); + move |_, _, cx| { + resolve_prompt( + &workspace, + terminal_id, + request_id, + TerminalPromptReply::Cancel, + cx, + ); + } + }) + .overlay_closable(false) + } + }); + } + } + } + + fn finish_prompt( + &mut self, + terminal_id: RemoteTerminalId, + request_id: u64, + reply: TerminalPromptReply, + cx: &mut Context, + ) { + let Some((active_terminal, session_id, active_request)) = self.active_prompt else { + return; + }; + if (active_terminal, active_request) != (terminal_id, request_id) { + return; + } + self.active_prompt = None; + let canceled = matches!(&reply, TerminalPromptReply::Cancel); + if let Some(panel) = self.terminals.get(&terminal_id).cloned() { + panel.read(cx).reply_to_prompt(request_id, reply, cx); + if canceled { + panel.update(cx, |panel, cx| panel.cancel_connection(cx)); + self.refresh_session_connection_state(session_id, cx); + } + } + } + + fn cancel_prompts_for_terminal( + &mut self, + terminal_id: RemoteTerminalId, + window: &mut Window, + cx: &mut Context, + ) { + self.prompt_queue.retain(|(id, _, _)| *id != terminal_id); + if let Some((id, _, request_id)) = self.active_prompt + && id == terminal_id + { + self.active_prompt = None; + if let Some(panel) = self.terminals.get(&terminal_id) { + panel + .read(cx) + .reply_to_prompt(request_id, TerminalPromptReply::Cancel, cx); + } + if window.has_active_dialog(cx) { + window.close_dialog(cx); + } + } + } + + fn cancel_prompts_for_session( + &mut self, + session_id: SessionId, + window: &mut Window, + cx: &mut Context, + ) { + self.prompt_queue.retain(|(_, id, _)| *id != session_id); + if let Some((terminal_id, id, request_id)) = self.active_prompt + && id == session_id + { + self.active_prompt = None; + if let Some(panel) = self.terminals.get(&terminal_id) { + panel + .read(cx) + .reply_to_prompt(request_id, TerminalPromptReply::Cancel, cx); + } + if window.has_active_dialog(cx) { + window.close_dialog(cx); + } + } + } + /// Select the tab of an already open panel. fn activate_tab( &self, @@ -242,10 +631,31 @@ impl Workspace { }); } - fn mark_connected(&mut self, id: SessionId, cx: &mut Context) { - self.store.update(cx, |store, cx| { - store.set_state(id, ConnectionState::Connected, cx); - }); + fn refresh_session_connection_state(&mut self, session_id: SessionId, cx: &mut Context) { + let mut has_starting = false; + let mut has_running = false; + for panel in self + .terminals + .values() + .filter(|panel| panel.read(cx).session_id() == session_id) + { + match panel.read(cx).lifecycle(cx) { + TerminalLifecycle::Running => has_running = true, + TerminalLifecycle::Starting => has_starting = true, + TerminalLifecycle::Exited { .. } + | TerminalLifecycle::Failed(_) + | TerminalLifecycle::Closing => {} + } + } + let state = if has_running { + ConnectionState::Connected + } else if has_starting { + ConnectionState::Connecting + } else { + ConnectionState::Disconnected + }; + self.store + .update(cx, |store, cx| store.set_state(session_id, state, cx)); } fn on_connect_session( @@ -254,19 +664,23 @@ impl Workspace { window: &mut Window, cx: &mut Context, ) { - let id = action.0; - if let Some(panel) = self.terminals.get(&id).cloned() { - let group = panel.read(cx).tab_group(); - self.activate_tab(group, panel.entity_id(), window, cx); + let session_id = action.0; + if self.store.read(cx).session(session_id).is_none() { return; } - if self.store.read(cx).session(id).is_none() { - return; - } - self.mark_connected(id, cx); - let (panel, subscription) = new_terminal_panel(&self.store, id, window, cx); + let terminal_id = RemoteTerminalId(self.next_remote_terminal_id); + self.next_remote_terminal_id += 1; + let (panel, subscription) = new_terminal_panel( + &self.store, + self.remote_terminal_provider.clone(), + terminal_id, + session_id, + window, + cx, + ); self._subscriptions.push(subscription); - self.terminals.insert(id, panel.clone()); + self.terminals.insert(terminal_id, panel.clone()); + self.refresh_session_connection_state(session_id, cx); self.dock_area.update(cx, |area, cx| { area.add_panel_view(panel_handle(panel), DockPlacement::Center, None, window, cx); }); @@ -287,7 +701,6 @@ impl Workspace { if self.store.read(cx).session(id).is_none() { return; } - self.mark_connected(id, cx); let (panel, subscription) = new_explorer_panel(&self.store, id, window, cx); self._subscriptions.push(subscription); self.explorers.insert(id, panel.clone()); @@ -416,48 +829,38 @@ impl Workspace { fn on_disconnect_session( &mut self, action: &DisconnectSession, - _: &mut Window, - cx: &mut Context, - ) { - let id = action.0; - self.store.update(cx, |store, cx| { - store.set_state(id, ConnectionState::Disconnected, cx); - }); - if let Some(terminal) = self.terminals.get(&id) { - terminal.update(cx, |terminal, cx| terminal.disconnect(cx)); - } - } - - fn on_reconnect_session( - &mut self, - action: &ReconnectSession, window: &mut Window, cx: &mut Context, ) { let id = action.0; - if self.store.read(cx).session(id).is_none() { - return; - } + self.cancel_prompts_for_session(id, window, cx); self.store.update(cx, |store, cx| { - store.set_state(id, ConnectionState::Connecting, cx); + store.set_state(id, ConnectionState::Disconnected, cx); }); - if let Some(terminal) = self.terminals.get(&id) { - terminal.update(cx, |terminal, cx| terminal.reconnect(window, cx)); + let terminals: Vec<_> = self + .terminals + .values() + .filter(|panel| panel.read(cx).session_id() == id) + .cloned() + .collect(); + for terminal in terminals { + terminal.update(cx, |terminal, cx| terminal.disconnect(cx)); + } + } + + fn on_reconnect_terminal( + &mut self, + action: &ReconnectTerminal, + window: &mut Window, + cx: &mut Context, + ) { + let id = action.0; + self.cancel_prompts_for_terminal(id, window, cx); + if let Some(terminal) = self.terminals.get(&id).cloned() { + let session_id = terminal.read(cx).session_id(); + terminal.update(cx, |terminal, cx| terminal.reconnect(window, cx)); + self.refresh_session_connection_state(session_id, cx); } - // The only asynchronous work in the mock: a short "connecting" state. - cx.spawn_in(window, async move |this, cx| { - cx.background_executor() - .timer(Duration::from_millis(600)) - .await; - this.update_in(cx, |this, _, cx| { - this.mark_connected(id, cx); - if let Some(terminal) = this.terminals.get(&id) { - terminal.update(cx, |terminal, cx| terminal.append_line("已重新连接", cx)); - } - }) - .ok(); - }) - .detach(); } fn on_new_session(&mut self, _: &NewSession, window: &mut Window, cx: &mut Context) { @@ -500,7 +903,11 @@ impl Workspace { let Some(session) = self.store.read(cx).session(id).cloned() else { return; }; - let closes_tabs = self.terminals.contains_key(&id) || self.explorers.contains_key(&id); + let closes_tabs = self + .terminals + .values() + .any(|panel| panel.read(cx).session_id() == id) + || self.explorers.contains_key(&id); let workspace = cx.entity().downgrade(); confirm_delete_session( &session, @@ -526,7 +933,14 @@ impl Workspace { /// alone. Deleting a session and deleting the group around it both need /// this, the latter for every session in the subtree. fn close_session_tabs(&mut self, id: SessionId, window: &mut Window, cx: &mut Context) { - if let Some(terminal) = self.terminals.remove(&id) { + self.cancel_prompts_for_session(id, window, cx); + let terminals: Vec<_> = self + .terminals + .values() + .filter(|panel| panel.read(cx).session_id() == id) + .cloned() + .collect(); + for terminal in terminals { self.dock_area .update(cx, |area, cx| area.remove_panel(terminal, window, cx)); } @@ -585,9 +999,12 @@ impl Workspace { let name = group.name.to_string(); let subgroups = store.descendant_groups(id).len(); let doomed = store.sessions_under(id); - let closes_tabs = doomed - .iter() - .any(|id| self.terminals.contains_key(id) || self.explorers.contains_key(id)); + let closes_tabs = doomed.iter().any(|id| { + self.terminals + .values() + .any(|panel| panel.read(cx).session_id() == *id) + || self.explorers.contains_key(id) + }); let workspace = cx.entity().downgrade(); confirm_delete_group( &name, @@ -670,27 +1087,119 @@ impl Workspace { fn new_terminal_panel( store: &Entity, - id: SessionId, + remote_provider: SharedRemoteTerminalTransportProvider, + terminal_id: RemoteTerminalId, + session_id: SessionId, window: &mut Window, cx: &mut Context, ) -> (Entity, Subscription) { - let panel = cx.new(|cx| TerminalPanel::new(id, store.clone(), window, cx)); - let subscription = cx.subscribe( + let panel = cx.new(|cx| { + TerminalPanel::new( + terminal_id, + session_id, + store.clone(), + remote_provider, + window, + cx, + ) + }); + let subscription = cx.subscribe_in( &panel, - |this, _, event: &TerminalPanelEvent, cx| match event { - TerminalPanelEvent::Activated(id) => this.active_tab = Some(CenterTab::Terminal(*id)), - TerminalPanelEvent::Closed(id) => { - this.terminals.remove(id); - if this.active_tab == Some(CenterTab::Terminal(*id)) { + window, + |this, _, event: &TerminalPanelEvent, window, cx| match event { + TerminalPanelEvent::Activated(terminal_id, _) => { + this.active_tab = Some(CenterTab::Terminal(*terminal_id)) + } + TerminalPanelEvent::Closed(terminal_id, session_id) => { + this.cancel_prompts_for_terminal(*terminal_id, window, cx); + this.terminals.remove(terminal_id); + if this.active_tab == Some(CenterTab::Terminal(*terminal_id)) { this.active_tab = None; } + this.refresh_session_connection_state(*session_id, cx); + if !this + .terminals + .values() + .any(|panel| panel.read(cx).session_id() == *session_id) + && !this.explorers.contains_key(session_id) + && this.store.read(cx).active().map(|session| session.id) == Some(*session_id) + { + this.store + .update(cx, |store, cx| store.set_active(None, cx)); + } + } + TerminalPanelEvent::StatusChanged(_, session_id) => { + this.refresh_session_connection_state(*session_id, cx); + cx.notify(); + } + TerminalPanelEvent::PromptRequested(terminal_id, session_id, prompt) => { + this.enqueue_prompt(*terminal_id, *session_id, prompt.clone(), window, cx) } - TerminalPanelEvent::StatusChanged(_) => cx.notify(), }, ); (panel, subscription) } +fn resolve_prompt( + workspace: &WeakEntity, + terminal_id: RemoteTerminalId, + request_id: u64, + reply: TerminalPromptReply, + cx: &mut App, +) { + workspace + .update(cx, |workspace, cx| { + workspace.finish_prompt(terminal_id, request_id, reply, cx) + }) + .ok(); +} + +struct AuthenticationPromptForm { + fields: Vec<(TerminalPromptField, Entity)>, +} + +impl AuthenticationPromptForm { + fn new(fields: Vec, window: &mut Window, cx: &mut Context) -> Self { + let fields = fields + .into_iter() + .map(|field| { + let echo = field.echo(); + let input = cx.new(|cx| InputState::new(window, cx).masked(!echo)); + (field, input) + }) + .collect(); + Self { fields } + } + + fn take_answers(&mut self, window: &mut Window, cx: &mut Context) -> Vec { + self.fields + .iter() + .map(|(_, input)| { + let answer = TerminalSecret::new(input.read(cx).value().to_string()); + input.update(cx, |input, cx| input.set_value("", window, cx)); + answer + }) + .collect() + } +} + +impl Render for AuthenticationPromptForm { + fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { + Form::new().children( + self.fields + .iter() + .enumerate() + .map(|(index, (field, input))| { + let mut input = Input::new(input).id(("ssh-auth-answer", index)).small(); + if !field.echo() { + input = input.content_type(InputContentType::Password).mask_toggle(); + } + Field::new().label(field.label().to_string()).child(input) + }), + ) + } +} + fn new_explorer_panel( store: &Entity, id: SessionId, @@ -772,7 +1281,7 @@ impl Render for Workspace { .on_action(cx.listener(Self::on_delete_group)) .on_action(cx.listener(Self::on_connect_session)) .on_action(cx.listener(Self::on_disconnect_session)) - .on_action(cx.listener(Self::on_reconnect_session)) + .on_action(cx.listener(Self::on_reconnect_terminal)) .on_action(cx.listener(Self::on_open_explorer)) .on_action(cx.listener(Self::on_close_terminal)) .on_action(cx.listener(Self::on_close_explorer)) diff --git a/tests/ui.rs b/tests/ui.rs index 2c367e6..4400350 100644 --- a/tests/ui.rs +++ b/tests/ui.rs @@ -12,11 +12,12 @@ use gpui_kit::{ MouseMoveEvent, TestAppContext, WindowHandle, point, px, size, }; -use shellr::app::{DeleteGroup, NewSessionInGroup, RenameGroup}; -use shellr::session::{GroupId, SessionDatabase, SessionId, SessionStore}; +use shellr::app::{ConnectSession, DeleteGroup, NewSessionInGroup, OpenExplorer, RenameGroup}; +use shellr::session::{AuthKind, GroupId, SessionDatabase, SessionDraft, SessionId, SessionStore}; use shellr::terminal::{ - LocalTerminalId, TerminalLifecycle, TerminalSize, TerminalTransport, TerminalTransportCommand, - TerminalTransportEvent, TerminalTransportFactory, + FixedRemoteTerminalTransportProvider, LocalTerminalId, RemoteTerminalId, TerminalLifecycle, + TerminalPrompt, TerminalPromptField, TerminalPromptKind, TerminalPromptReply, TerminalSize, + TerminalTransport, TerminalTransportCommand, TerminalTransportEvent, TerminalTransportFactory, }; use shellr::workspace::Workspace; @@ -24,6 +25,9 @@ use shellr::workspace::Workspace; const WEB_01: u64 = 1; const DB_01: u64 = 3; const STAGING_API: u64 = 4; +const INITIAL_WEB_TERMINAL: u64 = 1; +const INITIAL_STAGING_TERMINAL: u64 = 2; +const FIRST_NEW_TERMINAL: u64 = 3; /// Seeded group ids, in insertion order: 生产, 测试, 开发. const PRODUCTION: u64 = 1; const DEVELOPMENT: u64 = 3; @@ -42,7 +46,18 @@ fn open_workspace_with_store( let mut workspace = None; let handle = cx.open_window(size(px(1280.), px(800.)), |window, cx| { let store = cx.new(|_| store); - let view = cx.new(|cx| Workspace::new(store, window, cx)); + let remote = Arc::new(FixedRemoteTerminalTransportProvider::new(Arc::new( + FakeTerminalFactory::default(), + ))); + let view = cx.new(|cx| { + Workspace::new_with_transport_providers( + store, + remote, + Arc::new(FakeTerminalFactory::default()), + window, + cx, + ) + }); workspace = Some(view.clone()); Root::new(view, window, cx) }); @@ -91,6 +106,10 @@ impl FakeTerminalFactory { .collect(); String::from_utf8(bytes).expect("fake transport receives UTF-8 test input") } + + fn starts(&self) -> usize { + self.starts.load(Ordering::SeqCst) + } } impl TerminalTransportFactory for FakeTerminalFactory { @@ -152,6 +171,7 @@ impl TerminalTransport for FakeTerminalTransport { .unwrap_or_else(|error| error.into_inner()) .push(size); } + TerminalTransportCommand::PromptReply { .. } => {} TerminalTransportCommand::Shutdown => break, } } @@ -167,8 +187,11 @@ fn open_workspace_with_factory( let mut workspace = None; let handle = cx.open_window(size(px(1280.), px(800.)), |window, cx| { let store = cx.new(|_| SessionStore::seed()); + let remote = Arc::new(FixedRemoteTerminalTransportProvider::new(Arc::new( + FakeTerminalFactory::default(), + ))); let view = cx.new(|cx| { - Workspace::new_with_local_terminal_factory(store, factory.clone(), window, cx) + Workspace::new_with_transport_providers(store, remote, factory.clone(), window, cx) }); workspace = Some(view.clone()); Root::new(view, window, cx) @@ -176,15 +199,437 @@ fn open_workspace_with_factory( (handle, workspace.expect("workspace created")) } +fn open_workspace_with_remote_factory( + cx: &mut TestAppContext, + store: SessionStore, + factory: Arc, +) -> (WindowHandle, Entity) { + cx.update(shellr::init); + let mut workspace = None; + let handle = cx.open_window(size(px(1280.), px(800.)), |window, cx| { + let store = cx.new(|_| store); + let remote = Arc::new(FixedRemoteTerminalTransportProvider::new(factory.clone())); + let view = cx.new(|cx| { + Workspace::new_with_transport_providers( + store, + remote, + Arc::new(FakeTerminalFactory::default()), + window, + cx, + ) + }); + workspace = Some(view.clone()); + Root::new(view, window, cx) + }); + (handle, workspace.expect("workspace created")) +} + +#[derive(Clone, Copy)] +enum PromptBehavior { + Authentication, + UnknownHost, +} + +struct PromptTerminalFactory { + behavior: PromptBehavior, + accepted: Arc, + canceled: Arc, +} + +impl PromptTerminalFactory { + fn new(behavior: PromptBehavior) -> Self { + Self { + behavior, + accepted: Arc::new(AtomicUsize::new(0)), + canceled: Arc::new(AtomicUsize::new(0)), + } + } +} + +impl TerminalTransportFactory for PromptTerminalFactory { + fn create(&self) -> Box { + Box::new(PromptTerminalTransport { + behavior: self.behavior, + accepted: self.accepted.clone(), + canceled: self.canceled.clone(), + }) + } +} + +struct PromptTerminalTransport { + behavior: PromptBehavior, + accepted: Arc, + canceled: Arc, +} + +impl TerminalTransport for PromptTerminalTransport { + fn run( + self: Box, + _: TerminalSize, + commands: mpsc::Receiver, + events: async_channel::Sender, + ) -> anyhow::Result<()> { + let request_id = 9001; + let kind = match self.behavior { + PromptBehavior::Authentication => TerminalPromptKind::authentication( + "SSH 登录", + "请输入密码", + vec![TerminalPromptField::new("密码", false)], + ), + PromptBehavior::UnknownHost => TerminalPromptKind::unknown_host( + "example.test", + 22, + "ssh-ed25519", + "SHA256:test-fingerprint", + ), + }; + events.send_blocking(TerminalTransportEvent::Prompt(TerminalPrompt::new( + request_id, kind, + )))?; + loop { + match commands.recv()? { + TerminalTransportCommand::PromptReply { + request_id: reply_id, + reply, + } if reply_id == request_id => match reply { + TerminalPromptReply::TrustAndSave + if matches!(self.behavior, PromptBehavior::UnknownHost) => + { + self.accepted.fetch_add(1, Ordering::SeqCst); + break; + } + TerminalPromptReply::Answers(answers) + if matches!(self.behavior, PromptBehavior::Authentication) + && answers.len() == 1 => + { + self.accepted.fetch_add(1, Ordering::SeqCst); + break; + } + TerminalPromptReply::Cancel => { + self.canceled.fetch_add(1, Ordering::SeqCst); + events + .send_blocking(TerminalTransportEvent::Failed("认证已取消".into()))?; + return Ok(()); + } + _ => {} + }, + TerminalTransportCommand::Shutdown => return Ok(()), + _ => {} + } + } + events.send_blocking(TerminalTransportEvent::Started)?; + while let Ok(command) = commands.recv() { + if matches!(command, TerminalTransportCommand::Shutdown) { + break; + } + } + Ok(()) + } +} + +fn one_session_store(auth: AuthKind) -> (SessionStore, SessionId) { + let mut store = SessionStore::empty(); + let id = store.insert_unnotified(SessionDraft::new( + "prompt-host", + "example.test", + 22, + "tester", + auth, + None, + )); + (store, id) +} + #[gpui_kit::test] -fn double_click_on_session_opens_terminal_and_updates_status(cx: &mut TestAppContext) { +async fn session_form_switches_to_key_and_uses_native_path_picker(cx: &mut TestAppContext) { + let (handle, _) = open_workspace_with_store(cx, SessionStore::empty()); + + cx.update_window(handle.into(), |_, window, cx| { + window.render_frame(cx); + window.click("new-session", cx); + }) + .unwrap(); + cx.run_until_parked(); + + cx.update_window(handle.into(), |_, window, cx| { + window.render_frame(cx); + assert!(window.try_find("session-key-path").is_none()); + window.within("session-auth").click("input", cx); + window.within("session-auth").press("down", cx); + window.within("session-auth").press("down", cx); + window.within("session-auth").press("enter", cx); + }) + .unwrap(); + cx.run_until_parked(); + + cx.update_window(handle.into(), |_, window, cx| { + window.render_frame(cx); + assert!(window.find("session-key-path").visible()); + window.click("choose-key", cx); + }) + .unwrap(); + assert!(cx.did_prompt_for_paths()); + cx.simulate_path_prompt_response(|options| { + assert!(options.files); + assert!(!options.directories); + assert!(!options.multiple); + Some(vec!["/tmp/id_ed25519".into()]) + }); + cx.run_until_parked(); + + cx.update_window(handle.into(), |_, window, cx| { + window.render_frame(cx); + assert_eq!( + window.find("session-key-path").value(), + Some("/tmp/id_ed25519") + ); + }) + .unwrap(); +} + +#[gpui_kit::test] +async fn authentication_prompt_is_masked_and_drives_connected_state(cx: &mut TestAppContext) { + let (store, id) = one_session_store(AuthKind::Password); + let factory = Arc::new(PromptTerminalFactory::new(PromptBehavior::Authentication)); + let (handle, workspace) = open_workspace_with_remote_factory(cx, store, factory.clone()); + + cx.update_window(handle.into(), |_, window, cx| { + window.render_frame(cx); + window + .within("session-tree") + .double_click(("session-row", id.0), cx); + }) + .unwrap(); + cx.wait_for(handle.into(), Duration::from_secs(2), |window, cx| { + window.render_frame(cx); + window.try_find("ssh-auth-submit").is_some() + }) + .await; + + cx.update_window(handle.into(), |_, window, cx| { + window.render_frame(cx); + window.click("status-connection", cx); + }) + .unwrap(); + cx.run_until_parked(); + cx.update_window(handle.into(), |_, window, cx| { + window.render_frame(cx); + assert!( + window.try_find("ssh-auth-submit").is_some(), + "点击对话框外部不应取消认证" + ); + }) + .unwrap(); + + cx.update_window(handle.into(), |_, window, cx| { + window.render_frame(cx); + let password = window.find(("ssh-auth-answer", 0usize)); + assert_eq!( + password.value(), + None, + "masked input must not expose its value" + ); + window.click(("ssh-auth-answer", 0usize), cx); + window.input("test-password", cx); + window.click("ssh-auth-submit", cx); + }) + .unwrap(); + cx.wait_for(handle.into(), Duration::from_secs(2), |window, cx| { + window.render_frame(cx); + window.find("status-connection").label() == Some("已连接 prompt-host") + }) + .await; + + assert_eq!(factory.accepted.load(Ordering::SeqCst), 1); + assert!(workspace.read_with(cx, |workspace, cx| { + workspace + .store() + .read(cx) + .session(id) + .unwrap() + .state + .is_connected() + })); +} + +#[gpui_kit::test] +async fn canceling_unknown_host_prompt_keeps_session_disconnected(cx: &mut TestAppContext) { + let (store, id) = one_session_store(AuthKind::Auto); + let factory = Arc::new(PromptTerminalFactory::new(PromptBehavior::UnknownHost)); + let (handle, workspace) = open_workspace_with_remote_factory(cx, store, factory.clone()); + + cx.update_window(handle.into(), |_, window, cx| { + window.render_frame(cx); + window + .within("session-tree") + .double_click(("session-row", id.0), cx); + }) + .unwrap(); + cx.wait_for(handle.into(), Duration::from_secs(2), |window, cx| { + window.render_frame(cx); + window.try_find("cancel").is_some() + }) + .await; + cx.update_window(handle.into(), |_, window, cx| { + window.render_frame(cx); + window.click("status-connection", cx); + }) + .unwrap(); + cx.run_until_parked(); + cx.update_window(handle.into(), |_, window, cx| { + window.render_frame(cx); + assert!( + window.try_find("cancel").is_some(), + "点击对话框外部不应取消主机指纹确认" + ); + }) + .unwrap(); + cx.update_window(handle.into(), |_, window, cx| { + window.render_frame(cx); + window.click("cancel", cx); + }) + .unwrap(); + cx.wait_for(handle.into(), Duration::from_secs(2), |window, cx| { + window.render_frame(cx); + window.find("status-connection").label() == Some("未连接 prompt-host") + }) + .await; + + assert_eq!(factory.accepted.load(Ordering::SeqCst), 0); + assert_eq!(factory.canceled.load(Ordering::SeqCst), 1); + assert!(!workspace.read_with(cx, |workspace, cx| { + workspace + .store() + .read(cx) + .session(id) + .unwrap() + .state + .is_connected() + })); + + // The failed terminal tab remains visible, but connecting the session + // again must start a fresh transport instead of only activating that tab. + cx.update_window(handle.into(), |_, window, cx| { + window.render_frame(cx); + window + .within("session-tree") + .double_click(("session-row", id.0), cx); + }) + .unwrap(); + cx.wait_for(handle.into(), Duration::from_secs(2), |window, cx| { + window.render_frame(cx); + window.try_find("cancel").is_some() + }) + .await; + cx.update_window(handle.into(), |_, window, cx| { + window.render_frame(cx); + window.click("cancel", cx); + }) + .unwrap(); + cx.wait_for(handle.into(), Duration::from_secs(2), |window, cx| { + window.render_frame(cx); + factory.canceled.load(Ordering::SeqCst) == 2 + }) + .await; + + // The context-menu item dispatches this same command, so it must also + // restart the exited transport instead of merely activating the tab. + cx.update_window(handle.into(), |_, window, cx| { + window.render_frame(cx); + window.dispatch_action(Box::new(ConnectSession(id)), cx); + }) + .unwrap(); + cx.wait_for(handle.into(), Duration::from_secs(2), |window, cx| { + window.render_frame(cx); + window.try_find("cancel").is_some() + }) + .await; + cx.update_window(handle.into(), |_, window, cx| { + window.render_frame(cx); + window.click("cancel", cx); + }) + .unwrap(); +} + +#[gpui_kit::test] +async fn connection_edits_reconnect_once_but_display_edits_do_not(cx: &mut TestAppContext) { + let (store, id) = one_session_store(AuthKind::Auto); + let factory = Arc::new(FakeTerminalFactory::default()); + let (handle, workspace) = open_workspace_with_remote_factory(cx, store, factory.clone()); + + cx.update_window(handle.into(), |_, window, cx| { + window.render_frame(cx); + window + .within("session-tree") + .double_click(("session-row", id.0), cx); + }) + .unwrap(); + cx.wait_for(handle.into(), Duration::from_secs(2), |window, cx| { + window.render_frame(cx); + factory.starts() == 1 + && window.find("status-connection").label() == Some("已连接 prompt-host") + }) + .await; + + cx.update(|cx| { + let store = workspace.read(cx).store().clone(); + store.update(cx, |store, cx| { + let mut draft = store.session(id).unwrap().draft(); + draft.host = "new.example.test".into(); + assert!(store.update(id, draft, cx)); + }); + }); + cx.wait_for(handle.into(), Duration::from_secs(2), |window, cx| { + window.render_frame(cx); + factory.starts() == 2 + && window.find("status-connection").label() == Some("已连接 prompt-host") + }) + .await; + assert_eq!(factory.starts(), 2); + + cx.update(|cx| { + let store = workspace.read(cx).store().clone(); + store.update(cx, |store, cx| { + let mut draft = store.session(id).unwrap().draft(); + draft.name = "renamed".into(); + assert!(store.update(id, draft, cx)); + }); + }); + cx.run_until_parked(); + assert_eq!(factory.starts(), 2); +} + +#[gpui_kit::test] +fn opening_mock_sftp_does_not_change_connection_state(cx: &mut TestAppContext) { + let (store, id) = one_session_store(AuthKind::Auto); + let (handle, workspace) = open_workspace_with_store(cx, store); + + cx.update_window(handle.into(), |_, window, cx| { + window.render_frame(cx); + window.dispatch_action(Box::new(OpenExplorer(id)), cx); + }) + .unwrap(); + cx.run_until_parked(); + + cx.update(|cx| { + let workspace = workspace.read(cx); + assert!(workspace.explorer(id).is_some()); + assert_eq!( + workspace.store().read(cx).session(id).unwrap().state, + shellr::session::ConnectionState::Disconnected + ); + }); +} + +#[gpui_kit::test] +async fn double_click_on_session_opens_terminal_and_updates_status(cx: &mut TestAppContext) { let (handle, workspace) = open_workspace(cx); cx.update_window(handle.into(), |_, window, cx| { window.render_frame(cx); // Sessions seeded as connected already have terminal tabs. - assert!(window.find(("terminal", WEB_01)).visible()); - assert!(window.try_find(("terminal", DB_01)).is_none()); + assert!(window.find(("terminal", INITIAL_WEB_TERMINAL)).visible()); + assert!(window.try_find(("terminal", FIRST_NEW_TERMINAL)).is_none()); window .within("session-tree") @@ -193,9 +638,15 @@ fn double_click_on_session_opens_terminal_and_updates_status(cx: &mut TestAppCon .unwrap(); cx.run_until_parked(); + cx.wait_for(handle.into(), Duration::from_secs(2), |window, cx| { + window.render_frame(cx); + window.find("status-connection").label() == Some("已连接 db-01") + }) + .await; + cx.update_window(handle.into(), |_, window, cx| { window.render_frame(cx); - assert!(window.find(("terminal", DB_01)).visible()); + assert!(window.find(("terminal", FIRST_NEW_TERMINAL)).visible()); assert_eq!( window.find("status-connection").label(), Some("已连接 db-01") @@ -205,7 +656,7 @@ fn double_click_on_session_opens_terminal_and_updates_status(cx: &mut TestAppCon cx.update(|cx| { let workspace = workspace.read(cx); - assert!(workspace.terminal(SessionId(DB_01)).is_some()); + assert!(workspace.terminal(SessionId(DB_01), cx).is_some()); let store = workspace.store().read(cx); assert!( store @@ -217,6 +668,99 @@ fn double_click_on_session_opens_terminal_and_updates_status(cx: &mut TestAppCon }); } +#[gpui_kit::test] +async fn connected_session_opens_an_independent_terminal_each_time(cx: &mut TestAppContext) { + let (store, id) = one_session_store(AuthKind::Auto); + let factory = Arc::new(FakeTerminalFactory::default()); + let (handle, workspace) = open_workspace_with_remote_factory(cx, store, factory.clone()); + + cx.update_window(handle.into(), |_, window, cx| { + window.render_frame(cx); + window + .within("session-tree") + .double_click(("session-row", id.0), cx); + }) + .unwrap(); + cx.wait_for(handle.into(), Duration::from_secs(2), |window, cx| { + window.render_frame(cx); + factory.starts() == 1 + }) + .await; + + cx.update_window(handle.into(), |_, window, cx| { + window.render_frame(cx); + window + .within("session-tree") + .double_click(("session-row", id.0), cx); + }) + .unwrap(); + cx.wait_for(handle.into(), Duration::from_secs(2), |window, cx| { + window.render_frame(cx); + factory.starts() == 2 + }) + .await; + + // The right-click menu's “连接” item dispatches this same action. + cx.update_window(handle.into(), |_, window, cx| { + window.render_frame(cx); + window.dispatch_action(Box::new(ConnectSession(id)), cx); + }) + .unwrap(); + cx.wait_for(handle.into(), Duration::from_secs(2), |window, cx| { + window.render_frame(cx); + factory.starts() == 3 + && workspace + .read(cx) + .store() + .read(cx) + .session(id) + .unwrap() + .state + .is_connected() + }) + .await; + + cx.update(|cx| { + let workspace = workspace.read(cx); + assert_eq!(workspace.terminal_count(id, cx), 3); + assert!(workspace.remote_terminal(RemoteTerminalId(1)).is_some()); + assert!(workspace.remote_terminal(RemoteTerminalId(2)).is_some()); + assert!(workspace.remote_terminal(RemoteTerminalId(3)).is_some()); + }); + + cx.update_window(handle.into(), |_, window, cx| { + window.render_frame(cx); + window.click(("close-terminal", 3_u64), cx); + }) + .unwrap(); + cx.wait_for(handle.into(), Duration::from_secs(2), |window, cx| { + window.render_frame(cx); + let workspace = workspace.read(cx); + workspace.terminal_count(id, cx) == 2 + && workspace + .store() + .read(cx) + .session(id) + .unwrap() + .state + .is_connected() + }) + .await; + cx.update(|cx| { + let workspace = workspace.read(cx); + assert_eq!(workspace.terminal_count(id, cx), 2); + assert!( + workspace + .store() + .read(cx) + .session(id) + .unwrap() + .state + .is_connected() + ); + }); +} + #[gpui_kit::test] async fn new_session_dialog_validates_then_inserts(cx: &mut TestAppContext) { let (handle, workspace) = open_workspace(cx); @@ -316,7 +860,7 @@ fn sftp_button_opens_explorer_and_navigates(cx: &mut TestAppContext) { cx.update_window(handle.into(), |_, window, cx| { window.render_frame(cx); // The active terminal tab's toolbar shows the SFTP button. - window.click("sftp", cx); + window.click(("sftp", FIRST_NEW_TERMINAL), cx); }) .unwrap(); cx.run_until_parked(); @@ -377,24 +921,32 @@ fn tab_close_button_closes_the_terminal_and_disconnects(cx: &mut TestAppContext) cx.update_window(handle.into(), |_, window, cx| { window.render_frame(cx); - assert!(window.find(("terminal", DB_01)).visible()); - window.click(("close-terminal", DB_01), cx); + assert!(window.find(("terminal", FIRST_NEW_TERMINAL)).visible()); + window.click(("close-terminal", FIRST_NEW_TERMINAL), cx); }) .unwrap(); cx.run_until_parked(); cx.update_window(handle.into(), |_, window, cx| { window.render_frame(cx); - assert!(window.try_find(("terminal", DB_01)).is_none()); - assert!(window.try_find(("close-terminal", DB_01)).is_none()); + assert!(window.try_find(("terminal", FIRST_NEW_TERMINAL)).is_none()); + assert!( + window + .try_find(("close-terminal", FIRST_NEW_TERMINAL)) + .is_none() + ); // The other seeded tabs are untouched. - assert!(window.find(("close-terminal", WEB_01)).visible()); + assert!( + window + .find(("close-terminal", INITIAL_WEB_TERMINAL)) + .visible() + ); }) .unwrap(); cx.update(|cx| { let workspace = workspace.read(cx); - assert!(workspace.terminal(SessionId(DB_01)).is_none()); + assert!(workspace.terminal(SessionId(DB_01), cx).is_none()); let store = workspace.store().read(cx); assert!( !store @@ -407,31 +959,39 @@ fn tab_close_button_closes_the_terminal_and_disconnects(cx: &mut TestAppContext) } #[gpui_kit::test] -fn closing_every_tab_shows_the_recent_sessions(cx: &mut TestAppContext) { +async fn closing_every_tab_shows_the_recent_sessions(cx: &mut TestAppContext) { let (handle, workspace) = open_workspace(cx); cx.update_window(handle.into(), |_, window, cx| { window.render_frame(cx); // While tabs are open the start page stays out of the way. assert!(window.try_find("recent-sessions").is_none()); - window.click(("close-terminal", WEB_01), cx); + window.click(("close-terminal", INITIAL_WEB_TERMINAL), cx); }) .unwrap(); cx.run_until_parked(); cx.update_window(handle.into(), |_, window, cx| { window.render_frame(cx); - assert!(window.try_find(("terminal", WEB_01)).is_none()); + assert!( + window + .try_find(("terminal", INITIAL_WEB_TERMINAL)) + .is_none() + ); assert!(window.try_find("recent-sessions").is_none()); // The last tab closes too (the tab group alone would refuse). - window.click(("close-terminal", STAGING_API), cx); + window.click(("close-terminal", INITIAL_STAGING_TERMINAL), cx); }) .unwrap(); cx.run_until_parked(); cx.update_window(handle.into(), |_, window, cx| { window.render_frame(cx); - assert!(window.try_find(("terminal", STAGING_API)).is_none()); + assert!( + window + .try_find(("terminal", INITIAL_STAGING_TERMINAL)) + .is_none() + ); assert!(window.find("recent-sessions").visible()); // The page takes the focus the closed tab held. assert_eq!(window.find("recent-sessions").focused(), Some(true)); @@ -445,9 +1005,15 @@ fn closing_every_tab_shows_the_recent_sessions(cx: &mut TestAppContext) { .unwrap(); cx.run_until_parked(); + cx.wait_for(handle.into(), Duration::from_secs(2), |window, cx| { + window.render_frame(cx); + window.find("status-connection").label() == Some("已连接 web-01") + }) + .await; + cx.update_window(handle.into(), |_, window, cx| { window.render_frame(cx); - assert!(window.find(("terminal", WEB_01)).visible()); + assert!(window.find(("terminal", FIRST_NEW_TERMINAL)).visible()); assert!(window.try_find("recent-sessions").is_none()); assert_eq!( window.find("status-connection").label(), @@ -483,7 +1049,7 @@ fn close_shortcut_closes_the_displayed_tab_down_to_none(cx: &mut TestAppContext) cx.update_window(handle.into(), |_, window, cx| { window.render_frame(cx); // The new tab is displayed, with its input focused. - assert!(window.find(("terminal", DB_01)).visible()); + assert!(window.find(("terminal", FIRST_NEW_TERMINAL)).visible()); window.press("cmd-w", cx); }) .unwrap(); @@ -491,7 +1057,7 @@ fn close_shortcut_closes_the_displayed_tab_down_to_none(cx: &mut TestAppContext) cx.update_window(handle.into(), |_, window, cx| { window.render_frame(cx); - assert!(window.try_find(("terminal", DB_01)).is_none()); + assert!(window.try_find(("terminal", FIRST_NEW_TERMINAL)).is_none()); assert!(window.try_find("recent-sessions").is_none()); // Keep closing whichever tab the dock displays next. window.press("cmd-w", cx); @@ -508,8 +1074,16 @@ fn close_shortcut_closes_the_displayed_tab_down_to_none(cx: &mut TestAppContext) cx.update_window(handle.into(), |_, window, cx| { window.render_frame(cx); - assert!(window.try_find(("terminal", WEB_01)).is_none()); - assert!(window.try_find(("terminal", STAGING_API)).is_none()); + assert!( + window + .try_find(("terminal", INITIAL_WEB_TERMINAL)) + .is_none() + ); + assert!( + window + .try_find(("terminal", INITIAL_STAGING_TERMINAL)) + .is_none() + ); assert!(window.find("recent-sessions").visible()); // With nothing open the shortcut does nothing. window.press("cmd-w", cx); @@ -1046,8 +1620,8 @@ async fn deleting_a_group_removes_its_sessions_and_closes_their_tabs(cx: &mut Te cx.update(|cx| { let workspace = workspace.read(cx); - assert!(workspace.terminal(SessionId(WEB_01)).is_some()); - assert!(workspace.terminal(SessionId(DB_01)).is_some()); + assert!(workspace.terminal(SessionId(WEB_01), cx).is_some()); + assert!(workspace.terminal(SessionId(DB_01), cx).is_some()); }); cx.update_window(handle.into(), |_, window, cx| { @@ -1066,10 +1640,10 @@ async fn deleting_a_group_removes_its_sessions_and_closes_their_tabs(cx: &mut Te cx.update(|cx| { let workspace = workspace.read(cx); - assert!(workspace.terminal(SessionId(WEB_01)).is_none()); - assert!(workspace.terminal(SessionId(DB_01)).is_none()); + assert!(workspace.terminal(SessionId(WEB_01), cx).is_none()); + assert!(workspace.terminal(SessionId(DB_01), cx).is_none()); // staging-api is in another group and keeps its tab. - assert!(workspace.terminal(SessionId(STAGING_API)).is_some()); + assert!(workspace.terminal(SessionId(STAGING_API), cx).is_some()); let store = workspace.store().read(cx); assert!(store.group(GroupId(PRODUCTION)).is_none()); @@ -1151,6 +1725,12 @@ async fn groups_and_sessions_are_read_back_from_the_database(cx: &mut TestAppCon .unwrap(); cx.run_until_parked(); + cx.wait_for(handle.into(), Duration::from_secs(2), |window, cx| { + window.render_frame(cx); + window.find("status-connection").label() == Some("已连接 web-01") + }) + .await; + // Everything above went through the real write path; read it back with a // second connection to the same file. let reloaded = SessionStore::load(SessionDatabase::open(&path).expect("database reopened")) @@ -1192,7 +1772,7 @@ fn both_tabs_of_one_session_stay_closable(cx: &mut TestAppContext) { // for the same session beside it and activates that one. cx.update_window(handle.into(), |_, window, cx| { window.render_frame(cx); - window.click("sftp", cx); + window.click(("sftp", FIRST_NEW_TERMINAL), cx); }) .unwrap(); cx.run_until_parked(); @@ -1209,21 +1789,25 @@ fn both_tabs_of_one_session_stay_closable(cx: &mut TestAppContext) { cx.update_window(handle.into(), |_, window, cx| { window.render_frame(cx); assert!(window.try_find(("close-explorer", DB_01)).is_none()); - assert!(window.find(("terminal", DB_01)).visible()); - window.click(("close-terminal", DB_01), cx); + assert!(window.find(("terminal", FIRST_NEW_TERMINAL)).visible()); + window.click(("close-terminal", FIRST_NEW_TERMINAL), cx); }) .unwrap(); cx.run_until_parked(); cx.update_window(handle.into(), |_, window, cx| { window.render_frame(cx); - assert!(window.try_find(("close-terminal", DB_01)).is_none()); + assert!( + window + .try_find(("close-terminal", FIRST_NEW_TERMINAL)) + .is_none() + ); }) .unwrap(); cx.update(|cx| { let workspace = workspace.read(cx); assert!(workspace.explorer(SessionId(DB_01)).is_none()); - assert!(workspace.terminal(SessionId(DB_01)).is_none()); + assert!(workspace.terminal(SessionId(DB_01), cx).is_none()); }); }