diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 16f13c6..a88b395 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -50,6 +50,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" dependencies = [ "cfg-if", + "const-random", "once_cell", "version_check", "zerocopy", @@ -156,6 +157,17 @@ dependencies = [ "password-hash", ] +[[package]] +name = "async-trait" +version = "0.1.89" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "atk" version = "0.18.2" @@ -191,6 +203,12 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" +[[package]] +name = "base16ct" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf" + [[package]] name = "base64" version = "0.21.7" @@ -209,6 +227,17 @@ version = "1.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" +[[package]] +name = "bcrypt-pbkdf" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6aeac2e1fe888769f34f05ac343bbef98b14d1ffb292ab69d4608b3abc86f2a2" +dependencies = [ + "blowfish", + "pbkdf2", + "sha2", +] + [[package]] name = "bit-set" version = "0.8.0" @@ -257,6 +286,15 @@ dependencies = [ "generic-array", ] +[[package]] +name = "block-padding" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8894febbff9f758034a5b8e12d87918f56dfc64a8e1fe757d65e29041538d93" +dependencies = [ + "generic-array", +] + [[package]] name = "block2" version = "0.6.2" @@ -266,6 +304,16 @@ dependencies = [ "objc2", ] +[[package]] +name = "blowfish" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e412e2cd0f2b2d93e02543ceae7917b3c70331573df19ee046bcbc35e45e87d7" +dependencies = [ + "byteorder", + "cipher", +] + [[package]] name = "brotli" version = "8.0.2" @@ -381,6 +429,15 @@ dependencies = [ "toml 0.9.12+spec-1.1.0", ] +[[package]] +name = "cbc" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26b52a9543ae338f279b96b0b9fed9c8093744685043739079ce85cd58f289a6" +dependencies = [ + "cipher", +] + [[package]] name = "cc" version = "1.2.57" @@ -424,6 +481,17 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +[[package]] +name = "chacha20" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3613f74bd2eac03dad61bd53dbe620703d4371614fe0bc3b9f04dd36fe4e818" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures", +] + [[package]] name = "chrono" version = "0.4.44" @@ -431,8 +499,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0" dependencies = [ "iana-time-zone", + "js-sys", "num-traits", "serde", + "wasm-bindgen", "windows-link 0.2.1", ] @@ -462,6 +532,32 @@ dependencies = [ "memchr", ] +[[package]] +name = "const-oid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" + +[[package]] +name = "const-random" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87e00182fe74b066627d63b85fd550ac2998d4b0bd86bfed477a0ae4c7c71359" +dependencies = [ + "const-random-macro", +] + +[[package]] +name = "const-random-macro" +version = "0.1.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9d839f2a20b0aee515dc581a6172f2321f96cab76c1a38a4c584a194955390e" +dependencies = [ + "getrandom 0.2.17", + "once_cell", + "tiny-keccak", +] + [[package]] name = "convert_case" version = "0.4.0" @@ -551,6 +647,24 @@ version = "0.8.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" +[[package]] +name = "crunchy" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" + +[[package]] +name = "crypto-bigint" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76" +dependencies = [ + "generic-array", + "rand_core 0.6.4", + "subtle", + "zeroize", +] + [[package]] name = "crypto-common" version = "0.1.7" @@ -621,6 +735,33 @@ dependencies = [ "cipher", ] +[[package]] +name = "curve25519-dalek" +version = "4.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be" +dependencies = [ + "cfg-if", + "cpufeatures", + "curve25519-dalek-derive", + "digest", + "fiat-crypto", + "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.117", +] + [[package]] name = "darling" version = "0.23.0" @@ -669,6 +810,34 @@ dependencies = [ "parking_lot_core", ] +[[package]] +name = "data-encoding" +version = "2.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7a1e2f27636f116493b8b860f5546edb47c8d8f8ea73e1d2a20be88e28d1fea" + +[[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.117", +] + +[[package]] +name = "der" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" +dependencies = [ + "const-oid", + "pem-rfc7468", + "zeroize", +] + [[package]] name = "deranged" version = "0.5.8" @@ -713,6 +882,15 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "des" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ffdd80ce8ce993de27e9f063a444a4d53ce8e8db4c1f00cc03af5ad5a9867a1e" +dependencies = [ + "cipher", +] + [[package]] name = "digest" version = "0.10.7" @@ -720,6 +898,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ "block-buffer", + "const-oid", "crypto-common", "subtle", ] @@ -842,6 +1021,66 @@ version = "1.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" +[[package]] +name = "ecdsa" +version = "0.16.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca" +dependencies = [ + "der", + "digest", + "elliptic-curve", + "rfc6979", + "signature", + "spki", +] + +[[package]] +name = "ed25519" +version = "2.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53" +dependencies = [ + "pkcs8", + "signature", +] + +[[package]] +name = "ed25519-dalek" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9" +dependencies = [ + "curve25519-dalek", + "ed25519", + "rand_core 0.6.4", + "serde", + "sha2", + "subtle", + "zeroize", +] + +[[package]] +name = "elliptic-curve" +version = "0.13.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47" +dependencies = [ + "base16ct", + "crypto-bigint", + "digest", + "ff", + "generic-array", + "group", + "hkdf", + "pem-rfc7468", + "pkcs8", + "rand_core 0.6.4", + "sec1", + "subtle", + "zeroize", +] + [[package]] name = "embed-resource" version = "3.0.7" @@ -948,6 +1187,22 @@ dependencies = [ "simd-adler32", ] +[[package]] +name = "ff" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0b50bfb653653f9ca9095b427bed08ab8d75a137839d9ad64eb11810d5b6393" +dependencies = [ + "rand_core 0.6.4", + "subtle", +] + +[[package]] +name = "fiat-crypto" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" + [[package]] name = "field-offset" version = "0.3.6" @@ -974,6 +1229,18 @@ dependencies = [ "miniz_oxide", ] +[[package]] +name = "flurry" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf5efcf77a4da27927d3ab0509dec5b0954bb3bc59da5a1de9e52642ebd4cdf9" +dependencies = [ + "ahash", + "num_cpus", + "parking_lot", + "seize", +] + [[package]] name = "fnv" version = "1.0.7" @@ -1038,6 +1305,21 @@ dependencies = [ "new_debug_unreachable", ] +[[package]] +name = "futures" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + [[package]] name = "futures-channel" version = "0.3.32" @@ -1045,6 +1327,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" dependencies = [ "futures-core", + "futures-sink", ] [[package]] @@ -1099,6 +1382,7 @@ version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" dependencies = [ + "futures-channel", "futures-core", "futures-io", "futures-macro", @@ -1225,6 +1509,7 @@ checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" dependencies = [ "typenum", "version_check", + "zeroize", ] [[package]] @@ -1245,8 +1530,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" dependencies = [ "cfg-if", + "js-sys", "libc", "wasi 0.11.1+wasi-snapshot-preview1", + "wasm-bindgen", ] [[package]] @@ -1380,6 +1667,17 @@ dependencies = [ "system-deps", ] +[[package]] +name = "group" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63" +dependencies = [ + "ff", + "rand_core 0.6.4", + "subtle", +] + [[package]] name = "gtk" version = "0.18.2" @@ -1483,12 +1781,51 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +[[package]] +name = "hermit-abi" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" + [[package]] name = "hex" version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" +[[package]] +name = "hex-literal" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6fe2267d4ed49bc07b63801559be28c718ea06c4738b7a03c94df7386d2cde46" + +[[package]] +name = "hkdf" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" +dependencies = [ + "hmac", +] + +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest", +] + +[[package]] +name = "home" +version = "0.5.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc627f471c528ff0c4a49e1d5e60450c8f6461dd6d10ba9dcd3a61d3dff7728d" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "html5ever" version = "0.29.1" @@ -1780,6 +2117,7 @@ version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" dependencies = [ + "block-padding", "generic-array", ] @@ -1954,6 +2292,15 @@ dependencies = [ "selectors 0.24.0", ] +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" +dependencies = [ + "spin", +] + [[package]] name = "leb128fmt" version = "0.1.0" @@ -2000,6 +2347,12 @@ dependencies = [ "winapi", ] +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + [[package]] name = "libredox" version = "0.1.14" @@ -2089,6 +2442,12 @@ version = "0.1.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2532096657941c2fea9c289d370a250971c689d4f143798ff67113ec042024a5" +[[package]] +name = "md5" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "490cc448043f947bae3cbee9c203358d62dbee0db12107a74be5c30ccfd09771" + [[package]] name = "memchr" version = "2.8.0" @@ -2194,12 +2553,59 @@ version = "0.1.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72ef4a56884ca558e5ddb05a1d1e7e1bfd9a68d9ed024c21704cc98872dae1bb" +[[package]] +name = "num-bigint" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" +dependencies = [ + "num-integer", + "num-traits", + "rand 0.8.5", +] + +[[package]] +name = "num-bigint-dig" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e661dda6640fad38e827a6d4a310ff4763082116fe217f279885c97f511bb0b7" +dependencies = [ + "lazy_static", + "libm", + "num-integer", + "num-iter", + "num-traits", + "rand 0.8.5", + "smallvec", + "zeroize", +] + [[package]] name = "num-conv" version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf97ec579c3c42f953ef76dbf8d55ac91fb219dde70e49aa4a6b7d74e9919050" +[[package]] +name = "num-integer" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-iter" +version = "0.1.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1429034a0490724d0075ebb2bc9e875d6503c3cf69e235a8941aa757d83ef5bf" +dependencies = [ + "autocfg", + "num-integer", + "num-traits", +] + [[package]] name = "num-traits" version = "0.2.19" @@ -2207,6 +2613,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" dependencies = [ "autocfg", + "libm", +] + +[[package]] +name = "num_cpus" +version = "1.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91df4bbde75afed763b708b7eee1e8e7651e02d97f6d5dd763e89367e957b23b" +dependencies = [ + "hermit-abi", + "libc", ] [[package]] @@ -2400,6 +2817,59 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "p256" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9863ad85fa8f4460f9c48cb909d38a0d689dba1f6f6988a5e3e0d31071bcd4b" +dependencies = [ + "ecdsa", + "elliptic-curve", + "primeorder", + "sha2", +] + +[[package]] +name = "p384" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe42f1670a52a47d448f14b6a5c61dd78fce51856e68edaa38f7ae3a46b8d6b6" +dependencies = [ + "ecdsa", + "elliptic-curve", + "primeorder", + "sha2", +] + +[[package]] +name = "p521" +version = "0.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fc9e2161f1f215afdfce23677034ae137bbd45016a880c2eb3ba8eb95f085b2" +dependencies = [ + "base16ct", + "ecdsa", + "elliptic-curve", + "primeorder", + "rand_core 0.6.4", + "sha2", +] + +[[package]] +name = "pageant" +version = "0.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "032d6201d2fb765158455ae0d5a510c016bb6da7232e5040e39e9c8db12b0afc" +dependencies = [ + "bytes", + "delegate", + "futures", + "rand 0.8.5", + "thiserror 1.0.69", + "tokio", + "windows 0.58.0", +] + [[package]] name = "pango" version = "0.18.3" @@ -2465,6 +2935,25 @@ version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df94ce210e5bc13cb6651479fa48d14f601d9858cfe0467f43ae157023b938d3" +[[package]] +name = "pbkdf2" +version = "0.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ed6a7761f76e3b9f92dfb0a60a6a6477c61024b775147ff0973a02653abaf2" +dependencies = [ + "digest", + "hmac", +] + +[[package]] +name = "pem-rfc7468" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412" +dependencies = [ + "base64ct", +] + [[package]] name = "percent-encoding" version = "2.3.2" @@ -2670,6 +3159,44 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" +[[package]] +name = "pkcs1" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8ffb9f10fa047879315e6625af03c164b16962a5368d724ed16323b68ace47f" +dependencies = [ + "der", + "pkcs8", + "spki", +] + +[[package]] +name = "pkcs5" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e847e2c91a18bfa887dd028ec33f2fe6f25db77db3619024764914affe8b69a6" +dependencies = [ + "aes", + "cbc", + "der", + "pbkdf2", + "scrypt", + "sha2", + "spki", +] + +[[package]] +name = "pkcs8" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" +dependencies = [ + "der", + "pkcs5", + "rand_core 0.6.4", + "spki", +] + [[package]] name = "pkg-config" version = "0.3.32" @@ -2702,6 +3229,17 @@ dependencies = [ "miniz_oxide", ] +[[package]] +name = "poly1305" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8159bd90725d2df49889a078b54f4f79e87f1f8a8444194cdca81d38f5393abf" +dependencies = [ + "cpufeatures", + "opaque-debug", + "universal-hash", +] + [[package]] name = "polyval" version = "0.6.2" @@ -2769,6 +3307,15 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "primeorder" +version = "0.13.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "353e1ca18966c16d9deb1c69278edbc5f194139612772bd9537af60ac231e1e6" +dependencies = [ + "elliptic-curve", +] + [[package]] name = "proc-macro-crate" version = "1.3.1" @@ -3086,6 +3633,37 @@ dependencies = [ "web-sys", ] +[[package]] +name = "rfc6979" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dd2a808d456c4a54e300a23e9f5a67e122c3024119acbfd73e3bf664491cb2" +dependencies = [ + "hmac", + "subtle", +] + +[[package]] +name = "rsa" +version = "0.9.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8573f03f5883dcaebdfcf4725caa1ecb9c15b2ef50c43a07b816e06799bb12d" +dependencies = [ + "const-oid", + "digest", + "num-bigint-dig", + "num-integer", + "num-traits", + "pkcs1", + "pkcs8", + "rand_core 0.6.4", + "sha2", + "signature", + "spki", + "subtle", + "zeroize", +] + [[package]] name = "rusqlite" version = "0.32.1" @@ -3100,6 +3678,151 @@ dependencies = [ "smallvec", ] +[[package]] +name = "russh" +version = "0.48.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c086efe0c429fa0b4e448c67147366d0319ed1c9a051d91b8f38e93fef25cc7" +dependencies = [ + "aes", + "aes-gcm", + "async-trait", + "bitflags 2.11.0", + "byteorder", + "bytes", + "cbc", + "chacha20", + "ctr", + "curve25519-dalek", + "delegate", + "des", + "digest", + "elliptic-curve", + "flate2", + "futures", + "generic-array", + "hex-literal", + "hmac", + "log", + "num-bigint", + "once_cell", + "p256", + "p384", + "p521", + "poly1305", + "rand 0.8.5", + "rand_core 0.6.4", + "rsa", + "russh-cryptovec", + "russh-keys", + "russh-sftp", + "russh-util", + "sha1", + "sha2", + "signature", + "ssh-encoding", + "ssh-key", + "subtle", + "thiserror 1.0.69", + "tokio", +] + +[[package]] +name = "russh-cryptovec" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d8e7e854e1a87e4be00fa287c98cad23faa064d0464434beaa9f014ec3baa98" +dependencies = [ + "libc", + "ssh-encoding", + "winapi", +] + +[[package]] +name = "russh-keys" +version = "0.48.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fed9bf3da16ad2892a44b840e40483010295bfc8fda8134650c381654cb1ef36" +dependencies = [ + "aes", + "async-trait", + "bcrypt-pbkdf", + "block-padding", + "byteorder", + "bytes", + "cbc", + "ctr", + "data-encoding", + "der", + "digest", + "ecdsa", + "ed25519-dalek", + "elliptic-curve", + "futures", + "getrandom 0.2.17", + "hmac", + "home", + "inout", + "log", + "md5", + "num-integer", + "p256", + "p384", + "p521", + "pageant", + "pbkdf2", + "pkcs1", + "pkcs5", + "pkcs8", + "rand 0.8.5", + "rand_core 0.6.4", + "rsa", + "russh-cryptovec", + "russh-util", + "sec1", + "serde", + "sha1", + "sha2", + "signature", + "spki", + "ssh-encoding", + "ssh-key", + "thiserror 1.0.69", + "tokio", + "tokio-stream", + "typenum", + "zeroize", +] + +[[package]] +name = "russh-sftp" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3bb94393cafad0530145b8f626d8687f1ee1dedb93d7ba7740d6ae81868b13b5" +dependencies = [ + "bitflags 2.11.0", + "bytes", + "chrono", + "flurry", + "log", + "serde", + "thiserror 2.0.18", + "tokio", + "tokio-util", +] + +[[package]] +name = "russh-util" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92c7dd577958c0cefbc8f8a2c05c48c88c42e2fdb760dbe9b96ae31d4de97a1f" +dependencies = [ + "chrono", + "tokio", + "wasm-bindgen", + "wasm-bindgen-futures", +] + [[package]] name = "rustc-hash" version = "2.1.1" @@ -3121,6 +3844,15 @@ version = "1.0.22" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" +[[package]] +name = "salsa20" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97a22f5af31f73a954c10289c93e8a50cc23d971e80ee446f1f6f7137a088213" +dependencies = [ + "cipher", +] + [[package]] name = "same-file" version = "1.0.6" @@ -3187,6 +3919,37 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "scrypt" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0516a385866c09368f0b5bcd1caff3366aace790fcd46e2bb032697bb172fd1f" +dependencies = [ + "pbkdf2", + "salsa20", + "sha2", +] + +[[package]] +name = "sec1" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc" +dependencies = [ + "base16ct", + "der", + "generic-array", + "pkcs8", + "subtle", + "zeroize", +] + +[[package]] +name = "seize" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "689224d06523904ebcc9b482c6a3f4f7fb396096645c4cd10c0d2ff7371a34d3" + [[package]] name = "selectors" version = "0.24.0" @@ -3401,6 +4164,17 @@ dependencies = [ "stable_deref_trait", ] +[[package]] +name = "sha1" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + [[package]] name = "sha2" version = "0.10.9" @@ -3460,6 +4234,16 @@ dependencies = [ "libc", ] +[[package]] +name = "signature" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" +dependencies = [ + "digest", + "rand_core 0.6.4", +] + [[package]] name = "simd-adler32" version = "0.3.8" @@ -3548,6 +4332,74 @@ dependencies = [ "system-deps", ] +[[package]] +name = "spin" +version = "0.9.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" + +[[package]] +name = "spki" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" +dependencies = [ + "base64ct", + "der", +] + +[[package]] +name = "ssh-cipher" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caac132742f0d33c3af65bfcde7f6aa8f62f0e991d80db99149eb9d44708784f" +dependencies = [ + "aes", + "aes-gcm", + "cbc", + "chacha20", + "cipher", + "ctr", + "poly1305", + "ssh-encoding", + "subtle", +] + +[[package]] +name = "ssh-encoding" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb9242b9ef4108a78e8cd1a2c98e193ef372437f8c22be363075233321dd4a15" +dependencies = [ + "base64ct", + "bytes", + "pem-rfc7468", + "sha2", +] + +[[package]] +name = "ssh-key" +version = "0.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b86f5297f0f04d08cabaa0f6bff7cb6aec4d9c3b49d87990d63da9d9156a8c3" +dependencies = [ + "bcrypt-pbkdf", + "ed25519-dalek", + "num-bigint-dig", + "p256", + "p384", + "p521", + "rand_core 0.6.4", + "rsa", + "sec1", + "sha2", + "signature", + "ssh-cipher", + "ssh-encoding", + "subtle", + "zeroize", +] + [[package]] name = "stable_deref_trait" version = "1.2.1" @@ -3713,7 +4565,7 @@ dependencies = [ "tao-macros", "unicode-segmentation", "url", - "windows", + "windows 0.61.3", "windows-core 0.61.2", "windows-version", "x11-dl", @@ -3784,7 +4636,7 @@ dependencies = [ "webkit2gtk", "webview2-com", "window-vibrancy", - "windows", + "windows 0.61.3", ] [[package]] @@ -3910,7 +4762,7 @@ dependencies = [ "url", "webkit2gtk", "webview2-com", - "windows", + "windows 0.61.3", ] [[package]] @@ -3935,7 +4787,7 @@ dependencies = [ "url", "webkit2gtk", "webview2-com", - "windows", + "windows 0.61.3", "wry", ] @@ -4080,6 +4932,15 @@ dependencies = [ "time-core", ] +[[package]] +name = "tiny-keccak" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c9d3793400a45f954c52e73d068316d76b6f4e36977e3fcebb13a2721e80237" +dependencies = [ + "crunchy", +] + [[package]] name = "tinystr" version = "0.8.2" @@ -4118,6 +4979,18 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "tokio-stream" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32da49809aab5c3bc678af03902d4ccddea2a87d028d86392a4b1560c6906c70" +dependencies = [ + "futures-core", + "pin-project-lite", + "tokio", + "tokio-util", +] + [[package]] name = "tokio-util" version = "0.7.18" @@ -4731,10 +5604,10 @@ checksum = "7130243a7a5b33c54a444e54842e6a9e133de08b5ad7b5861cd8ed9a6a5bc96a" dependencies = [ "webview2-com-macros", "webview2-com-sys", - "windows", + "windows 0.61.3", "windows-core 0.61.2", - "windows-implement", - "windows-interface", + "windows-implement 0.60.2", + "windows-interface 0.59.3", ] [[package]] @@ -4755,7 +5628,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "381336cfffd772377d291702245447a5251a2ffa5bad679c99e61bc48bacbf9c" dependencies = [ "thiserror 2.0.18", - "windows", + "windows 0.61.3", "windows-core 0.61.2", ] @@ -4805,6 +5678,16 @@ dependencies = [ "windows-version", ] +[[package]] +name = "windows" +version = "0.58.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd04d41d93c4992d421894c18c8b43496aa748dd4c081bac0dc93eb0489272b6" +dependencies = [ + "windows-core 0.58.0", + "windows-targets 0.52.6", +] + [[package]] name = "windows" version = "0.61.3" @@ -4827,14 +5710,27 @@ dependencies = [ "windows-core 0.61.2", ] +[[package]] +name = "windows-core" +version = "0.58.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ba6d44ec8c2591c134257ce647b7ea6b20335bf6379a27dac5f1641fcf59f99" +dependencies = [ + "windows-implement 0.58.0", + "windows-interface 0.58.0", + "windows-result 0.2.0", + "windows-strings 0.1.0", + "windows-targets 0.52.6", +] + [[package]] name = "windows-core" version = "0.61.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c0fdd3ddb90610c7638aa2b3a3ab2904fb9e5cdbecc643ddb3647212781c4ae3" dependencies = [ - "windows-implement", - "windows-interface", + "windows-implement 0.60.2", + "windows-interface 0.59.3", "windows-link 0.1.3", "windows-result 0.3.4", "windows-strings 0.4.2", @@ -4846,8 +5742,8 @@ version = "0.62.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" dependencies = [ - "windows-implement", - "windows-interface", + "windows-implement 0.60.2", + "windows-interface 0.59.3", "windows-link 0.2.1", "windows-result 0.4.1", "windows-strings 0.5.1", @@ -4864,6 +5760,17 @@ dependencies = [ "windows-threading", ] +[[package]] +name = "windows-implement" +version = "0.58.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2bbd5b46c938e506ecbce286b6628a02171d56153ba733b6c741fc627ec9579b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "windows-implement" version = "0.60.2" @@ -4875,6 +5782,17 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "windows-interface" +version = "0.58.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053c4c462dc91d3b1504c6fe5a726dd15e216ba718e84a0e46a88fbe5ded3515" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "windows-interface" version = "0.59.3" @@ -4908,6 +5826,15 @@ dependencies = [ "windows-link 0.1.3", ] +[[package]] +name = "windows-result" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d1043d8214f791817bab27572aaa8af63732e11bf84aa21a45a78d6c317ae0e" +dependencies = [ + "windows-targets 0.52.6", +] + [[package]] name = "windows-result" version = "0.3.4" @@ -4926,6 +5853,16 @@ dependencies = [ "windows-link 0.2.1", ] +[[package]] +name = "windows-strings" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cd9b125c486025df0eabcb585e62173c6c9eddcec5d117d3b6e8c30e2ee4d10" +dependencies = [ + "windows-result 0.2.0", + "windows-targets 0.52.6", +] + [[package]] name = "windows-strings" version = "0.4.2" @@ -5306,6 +6243,7 @@ version = "0.1.0" dependencies = [ "aes-gcm", "argon2", + "async-trait", "base64 0.22.1", "dashmap", "env_logger", @@ -5313,8 +6251,10 @@ dependencies = [ "log", "rand 0.9.2", "rusqlite", + "russh", "serde", "serde_json", + "ssh-key", "tauri", "tauri-build", "tauri-plugin-shell", @@ -5367,7 +6307,7 @@ dependencies = [ "webkit2gtk", "webkit2gtk-sys", "webview2-com", - "windows", + "windows 0.61.3", "windows-core 0.61.2", "windows-version", "x11-dl", @@ -5458,6 +6398,12 @@ dependencies = [ "synstructure", ] +[[package]] +name = "zeroize" +version = "1.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" + [[package]] name = "zerotrie" version = "0.2.3" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index c9ba795..e1934eb 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -24,6 +24,9 @@ uuid = { version = "1", features = ["v4"] } base64 = "0.22" dashmap = "6" tokio = { version = "1", features = ["full"] } +async-trait = "0.1" log = "0.4" env_logger = "0.11" thiserror = "2" +russh = "0.48" +ssh-key = { version = "0.6", features = ["ed25519", "rsa"] } diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index 799d162..f84731b 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -2,3 +2,4 @@ pub mod vault; pub mod settings; pub mod connections; pub mod credentials; +pub mod ssh_commands; diff --git a/src-tauri/src/commands/ssh_commands.rs b/src-tauri/src/commands/ssh_commands.rs new file mode 100644 index 0000000..5b5e137 --- /dev/null +++ b/src-tauri/src/commands/ssh_commands.rs @@ -0,0 +1,115 @@ +//! Tauri commands for SSH session management. +//! +//! All commands are async because russh operations are inherently asynchronous. +//! The SSH service is accessed via `State` and the `AppHandle` is +//! used for event emission. + +use tauri::{AppHandle, State}; + +use crate::ssh::session::{AuthMethod, SessionInfo}; +use crate::AppState; + +/// Connect to an SSH server with password authentication. +/// +/// Opens a PTY, starts a shell, and begins streaming output via +/// `ssh:data:{session_id}` events. Returns the session UUID. +#[tauri::command] +pub async fn connect_ssh( + hostname: String, + port: u16, + username: String, + password: String, + cols: u32, + rows: u32, + app_handle: AppHandle, + state: State<'_, AppState>, +) -> Result { + state + .ssh + .connect( + app_handle, + &hostname, + port, + &username, + AuthMethod::Password(password), + cols, + rows, + ) + .await +} + +/// Connect to an SSH server with private key authentication. +/// +/// The `private_key_pem` should be the PEM-encoded private key content. +/// `passphrase` is `None` if the key is not encrypted. +/// +/// Opens a PTY, starts a shell, and begins streaming output via +/// `ssh:data:{session_id}` events. Returns the session UUID. +#[tauri::command] +pub async fn connect_ssh_with_key( + hostname: String, + port: u16, + username: String, + private_key_pem: String, + passphrase: Option, + cols: u32, + rows: u32, + app_handle: AppHandle, + state: State<'_, AppState>, +) -> Result { + state + .ssh + .connect( + app_handle, + &hostname, + port, + &username, + AuthMethod::Key { + private_key_pem, + passphrase, + }, + cols, + rows, + ) + .await +} + +/// Write data to a session's PTY stdin. +/// +/// The `data` parameter is a string that will be sent as UTF-8 bytes. +#[tauri::command] +pub async fn ssh_write( + session_id: String, + data: String, + state: State<'_, AppState>, +) -> Result<(), String> { + state.ssh.write(&session_id, data.as_bytes()).await +} + +/// Resize the PTY window for a session. +#[tauri::command] +pub async fn ssh_resize( + session_id: String, + cols: u32, + rows: u32, + state: State<'_, AppState>, +) -> Result<(), String> { + state.ssh.resize(&session_id, cols, rows).await +} + +/// Disconnect an SSH session — closes the channel and removes it. +#[tauri::command] +pub async fn disconnect_ssh( + session_id: String, + state: State<'_, AppState>, +) -> Result<(), String> { + state.ssh.disconnect(&session_id).await +} + +/// List all active SSH sessions (metadata only). +#[tauri::command] +pub async fn list_ssh_sessions( + state: State<'_, AppState>, +) -> Result, String> { + Ok(state.ssh.list_sessions()) +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index f1790c1..c4ce53b 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -3,6 +3,7 @@ pub mod vault; pub mod settings; pub mod connections; pub mod credentials; +pub mod ssh; pub mod commands; use std::path::PathBuf; @@ -13,6 +14,7 @@ use vault::VaultService; use credentials::CredentialService; use settings::SettingsService; use connections::ConnectionService; +use ssh::session::SshService; /// Application state shared across all Tauri commands via State. pub struct AppState { @@ -21,6 +23,7 @@ pub struct AppState { pub settings: SettingsService, pub connections: ConnectionService, pub credentials: Mutex>, + pub ssh: SshService, } impl AppState { @@ -33,6 +36,7 @@ impl AppState { let settings = SettingsService::new(database.clone()); let connections = ConnectionService::new(database.clone()); + let ssh = SshService::new(database.clone()); Ok(Self { db: database, @@ -40,6 +44,7 @@ impl AppState { settings, connections, credentials: Mutex::new(None), + ssh, }) } @@ -103,6 +108,12 @@ pub fn run() { commands::credentials::create_password, commands::credentials::create_ssh_key, commands::credentials::delete_credential, + commands::ssh_commands::connect_ssh, + commands::ssh_commands::connect_ssh_with_key, + commands::ssh_commands::ssh_write, + commands::ssh_commands::ssh_resize, + commands::ssh_commands::disconnect_ssh, + commands::ssh_commands::list_ssh_sessions, ]) .run(tauri::generate_context!()) .expect("error while running tauri application"); diff --git a/src-tauri/src/ssh/cwd.rs b/src-tauri/src/ssh/cwd.rs new file mode 100644 index 0000000..75f1ac2 --- /dev/null +++ b/src-tauri/src/ssh/cwd.rs @@ -0,0 +1,129 @@ +//! CWD tracking via a SEPARATE SSH exec channel. +//! +//! This module opens an independent exec channel on the same SSH connection and +//! periodically runs `pwd` to determine the remote shell's current working +//! directory. The terminal data stream is NEVER touched — CWD is tracked +//! entirely out of band. +//! +//! When the CWD changes, an `ssh:cwd:{session_id}` event is emitted to the +//! frontend. + +use std::sync::Arc; + +use log::{debug, error, warn}; +use russh::client::Handle; +use russh::ChannelMsg; +use tauri::{AppHandle, Emitter}; +use tokio::sync::watch; +use tokio::sync::Mutex as TokioMutex; + +use crate::ssh::session::SshClient; + +/// Tracks the current working directory of a remote shell by periodically +/// running `pwd` over a separate exec channel. +pub struct CwdTracker { + _sender: watch::Sender, + pub receiver: watch::Receiver, +} + +impl CwdTracker { + /// Create a new CWD tracker with an empty initial directory. + pub fn new() -> Self { + let (sender, receiver) = watch::channel(String::new()); + Self { + _sender: sender, + receiver, + } + } + + /// Spawn a background tokio task that polls `pwd` every 2 seconds on a + /// separate exec channel. + /// + /// The task runs until the SSH connection is closed or the channel cannot + /// be opened. CWD changes are emitted as `ssh:cwd:{session_id}` events. + pub fn start( + &self, + handle: Arc>>, + app_handle: AppHandle, + session_id: String, + ) { + let sender = self._sender.clone(); + + tokio::spawn(async move { + // Brief initial delay to let the shell start up. + tokio::time::sleep(tokio::time::Duration::from_millis(500)).await; + + let mut previous_cwd = String::new(); + + loop { + // Open a fresh exec channel for each `pwd` invocation. + // Some SSH servers do not allow multiple exec requests on a + // single channel, so we open a new one each time. + let result = { + let handle_guard = handle.lock().await; + handle_guard.channel_open_session().await + }; + + let mut exec_channel = match result { + Ok(ch) => ch, + Err(e) => { + debug!( + "CWD tracker for session {} could not open exec channel: {} — stopping", + session_id, e + ); + break; + } + }; + + // Execute `pwd` on the exec channel. + if let Err(e) = exec_channel.exec(true, "pwd").await { + warn!( + "CWD tracker for session {}: failed to exec pwd: {}", + session_id, e + ); + break; + } + + // Read the output. + let mut output = String::new(); + loop { + match exec_channel.wait().await { + Some(ChannelMsg::Data { ref data }) => { + if let Ok(text) = std::str::from_utf8(data.as_ref()) { + output.push_str(text); + } + } + Some(ChannelMsg::Eof) | Some(ChannelMsg::Close) | None => break, + Some(ChannelMsg::ExitStatus { .. }) => { + // pwd exited — we may still get data, keep reading + } + _ => {} + } + } + + let cwd = output.trim().to_string(); + + if !cwd.is_empty() && cwd != previous_cwd { + previous_cwd = cwd.clone(); + + // Update the watch channel. + let _ = sender.send(cwd.clone()); + + // Emit event to frontend. + let event_name = format!("ssh:cwd:{}", session_id); + if let Err(e) = app_handle.emit(&event_name, &cwd) { + error!( + "CWD tracker for session {}: failed to emit event: {}", + session_id, e + ); + } + } + + // Wait 2 seconds before the next poll. + tokio::time::sleep(tokio::time::Duration::from_secs(2)).await; + } + + debug!("CWD tracker for session {} stopped", session_id); + }); + } +} diff --git a/src-tauri/src/ssh/host_key.rs b/src-tauri/src/ssh/host_key.rs new file mode 100644 index 0000000..fd9371a --- /dev/null +++ b/src-tauri/src/ssh/host_key.rs @@ -0,0 +1,203 @@ +//! TOFU (Trust On First Use) host key verification backed by SQLite. +//! +//! On first connection to a host, the server's public key fingerprint is stored. +//! On subsequent connections, the stored fingerprint is compared against the +//! presented key. If the fingerprint has changed, the connection is flagged as +//! potentially compromised (MITM warning). + +use rusqlite::params; + +use crate::db::Database; + +/// Result of verifying a host key against the local store. +#[derive(Debug, PartialEq, Eq)] +pub enum HostKeyResult { + /// No key on file for this host — first connection. + New, + /// Stored fingerprint matches the presented key. + Match, + /// Stored fingerprint differs from the presented key (possible MITM). + Changed, +} + +/// Persistent host key store using the `host_keys` SQLite table. +pub struct HostKeyStore { + db: Database, +} + +impl HostKeyStore { + pub fn new(db: Database) -> Self { + Self { db } + } + + /// Check whether a host key fingerprint is known, matches, or has changed. + /// + /// - Returns `New` if no row exists for `(hostname, port, key_type)`. + /// - Returns `Match` if the stored fingerprint equals `fingerprint`. + /// - Returns `Changed` if the stored fingerprint differs. + pub fn verify( + &self, + hostname: &str, + port: u16, + key_type: &str, + fingerprint: &str, + ) -> Result { + let conn = self.db.conn(); + + let stored: Option = conn + .query_row( + "SELECT fingerprint FROM host_keys + WHERE hostname = ?1 AND port = ?2 AND key_type = ?3", + params![hostname, port as i64, key_type], + |row| row.get(0), + ) + .ok(); + + match stored { + None => Ok(HostKeyResult::New), + Some(ref stored_fp) if stored_fp == fingerprint => Ok(HostKeyResult::Match), + Some(_) => Ok(HostKeyResult::Changed), + } + } + + /// Store (or update) a host key fingerprint. + /// + /// Uses `INSERT OR REPLACE` so that calling `store` after a `Changed` + /// result will update the key (i.e. the user accepted the new key). + pub fn store( + &self, + hostname: &str, + port: u16, + key_type: &str, + fingerprint: &str, + raw_key: &str, + ) -> Result<(), String> { + let conn = self.db.conn(); + + conn.execute( + "INSERT OR REPLACE INTO host_keys (hostname, port, key_type, fingerprint, raw_key, first_seen) + VALUES (?1, ?2, ?3, ?4, ?5, CURRENT_TIMESTAMP)", + params![hostname, port as i64, key_type, fingerprint, raw_key], + ) + .map_err(|e| format!("Failed to store host key for {hostname}:{port}: {e}"))?; + + Ok(()) + } + + /// Remove a stored host key for a specific host/port. + pub fn delete(&self, hostname: &str, port: u16) -> Result<(), String> { + let conn = self.db.conn(); + + conn.execute( + "DELETE FROM host_keys WHERE hostname = ?1 AND port = ?2", + params![hostname, port as i64], + ) + .map_err(|e| format!("Failed to delete host key for {hostname}:{port}: {e}"))?; + + Ok(()) + } +} + +// ── tests ───────────────────────────────────────────────────────────────────── + +#[cfg(test)] +mod tests { + use super::*; + use crate::db::Database; + + fn make_store() -> HostKeyStore { + let db = Database::open(std::path::Path::new(":memory:")).unwrap(); + db.migrate().unwrap(); + HostKeyStore::new(db) + } + + #[test] + fn new_host_returns_new() { + let store = make_store(); + let result = store + .verify("example.com", 22, "ssh-ed25519", "SHA256:abc123") + .unwrap(); + assert_eq!(result, HostKeyResult::New); + } + + #[test] + fn stored_host_returns_match() { + let store = make_store(); + store + .store("example.com", 22, "ssh-ed25519", "SHA256:abc123", "AAAA") + .unwrap(); + + let result = store + .verify("example.com", 22, "ssh-ed25519", "SHA256:abc123") + .unwrap(); + assert_eq!(result, HostKeyResult::Match); + } + + #[test] + fn changed_fingerprint_returns_changed() { + let store = make_store(); + store + .store("example.com", 22, "ssh-ed25519", "SHA256:abc123", "AAAA") + .unwrap(); + + let result = store + .verify("example.com", 22, "ssh-ed25519", "SHA256:DIFFERENT") + .unwrap(); + assert_eq!(result, HostKeyResult::Changed); + } + + #[test] + fn different_port_is_separate_host() { + let store = make_store(); + store + .store("example.com", 22, "ssh-ed25519", "SHA256:abc123", "AAAA") + .unwrap(); + + let result = store + .verify("example.com", 2222, "ssh-ed25519", "SHA256:abc123") + .unwrap(); + assert_eq!(result, HostKeyResult::New); + } + + #[test] + fn store_overwrites_on_replace() { + let store = make_store(); + store + .store("example.com", 22, "ssh-ed25519", "SHA256:old", "AAAA") + .unwrap(); + store + .store("example.com", 22, "ssh-ed25519", "SHA256:new", "BBBB") + .unwrap(); + + let result = store + .verify("example.com", 22, "ssh-ed25519", "SHA256:new") + .unwrap(); + assert_eq!(result, HostKeyResult::Match); + } + + #[test] + fn delete_removes_all_key_types_for_host() { + let store = make_store(); + store + .store("example.com", 22, "ssh-ed25519", "SHA256:abc", "AAAA") + .unwrap(); + store + .store("example.com", 22, "ssh-rsa", "SHA256:def", "BBBB") + .unwrap(); + + store.delete("example.com", 22).unwrap(); + + assert_eq!( + store + .verify("example.com", 22, "ssh-ed25519", "SHA256:abc") + .unwrap(), + HostKeyResult::New + ); + assert_eq!( + store + .verify("example.com", 22, "ssh-rsa", "SHA256:def") + .unwrap(), + HostKeyResult::New + ); + } +} diff --git a/src-tauri/src/ssh/mod.rs b/src-tauri/src/ssh/mod.rs new file mode 100644 index 0000000..b1c59be --- /dev/null +++ b/src-tauri/src/ssh/mod.rs @@ -0,0 +1,3 @@ +pub mod session; +pub mod host_key; +pub mod cwd; diff --git a/src-tauri/src/ssh/session.rs b/src-tauri/src/ssh/session.rs new file mode 100644 index 0000000..339f696 --- /dev/null +++ b/src-tauri/src/ssh/session.rs @@ -0,0 +1,419 @@ +//! SSH session manager — connects, authenticates, manages PTY channels. +//! +//! Each SSH session runs asynchronously via tokio. Terminal stdout is read in a +//! loop and emitted to the frontend via Tauri events (`ssh:data:{session_id}`, +//! base64 encoded). Terminal stdin receives data from the frontend via Tauri +//! commands. +//! +//! Sessions are stored in a `DashMap>`. + +use std::sync::Arc; + +use async_trait::async_trait; +use base64::Engine; +use dashmap::DashMap; +use log::{debug, error, info, warn}; +use russh::client::{self, Handle, Msg}; +use russh::{Channel, ChannelMsg, Disconnect}; +use serde::Serialize; +use tauri::{AppHandle, Emitter}; +use tokio::sync::Mutex as TokioMutex; + +use crate::db::Database; +use crate::ssh::cwd::CwdTracker; +use crate::ssh::host_key::{HostKeyResult, HostKeyStore}; + +// ── auth method ────────────────────────────────────────────────────────────── + +/// Authentication method for SSH connections. +pub enum AuthMethod { + Password(String), + Key { + private_key_pem: String, + passphrase: Option, + }, +} + +// ── session info (serializable for frontend) ───────────────────────────────── + +#[derive(Debug, Serialize, Clone)] +#[serde(rename_all = "camelCase")] +pub struct SessionInfo { + pub id: String, + pub hostname: String, + pub port: u16, + pub username: String, +} + +// ── SSH session ────────────────────────────────────────────────────────────── + +/// Represents a single active SSH session with a PTY channel. +pub struct SshSession { + pub id: String, + pub hostname: String, + pub port: u16, + pub username: String, + /// The PTY channel used for interactive shell I/O. + pub channel: Arc>>, + /// Handle to the underlying SSH connection (used for opening new channels). + pub handle: Arc>>, + /// CWD tracker that polls via a separate exec channel. + pub cwd_tracker: Option, +} + +// ── SSH client handler ─────────────────────────────────────────────────────── + +/// Minimal `russh::client::Handler` implementation. +/// +/// Host key verification is done via TOFU in the `HostKeyStore`. The handler +/// stores the verification result so the connect flow can check it after +/// `client::connect` returns. +pub struct SshClient { + host_key_store: HostKeyStore, + hostname: String, + port: u16, +} + +#[async_trait] +impl client::Handler for SshClient { + type Error = russh::Error; + + async fn check_server_key( + &mut self, + server_public_key: &ssh_key::PublicKey, + ) -> Result { + let key_type = server_public_key.algorithm().to_string(); + let fingerprint = server_public_key + .fingerprint(ssh_key::HashAlg::Sha256) + .to_string(); + + let raw_key = server_public_key + .to_openssh() + .unwrap_or_default(); + + match self + .host_key_store + .verify(&self.hostname, self.port, &key_type, &fingerprint) + { + Ok(HostKeyResult::New) => { + info!( + "New host key for {}:{} ({}): {}", + self.hostname, self.port, key_type, fingerprint + ); + // TOFU: store the key on first contact. + if let Err(e) = self.host_key_store.store( + &self.hostname, + self.port, + &key_type, + &fingerprint, + &raw_key, + ) { + warn!("Failed to store host key: {}", e); + } + Ok(true) + } + Ok(HostKeyResult::Match) => { + debug!( + "Host key match for {}:{} ({})", + self.hostname, self.port, key_type + ); + Ok(true) + } + Ok(HostKeyResult::Changed) => { + error!( + "HOST KEY CHANGED for {}:{} ({})! Expected stored fingerprint, got {}. \ + Possible man-in-the-middle attack.", + self.hostname, self.port, key_type, fingerprint + ); + // Reject the connection — the frontend should prompt the user + // to accept the new key and call delete + reconnect. + Ok(false) + } + Err(e) => { + error!("Host key verification error: {}", e); + // On DB error, reject to be safe. + Ok(false) + } + } + } +} + +// ── SSH service ────────────────────────────────────────────────────────────── + +/// Manages all active SSH sessions. +pub struct SshService { + sessions: DashMap>, + db: Database, +} + +impl SshService { + pub fn new(db: Database) -> Self { + Self { + sessions: DashMap::new(), + db, + } + } + + /// Establish an SSH connection, authenticate, open a PTY, start a shell, + /// and begin streaming output to the frontend. + /// + /// Returns the session UUID on success. + pub async fn connect( + &self, + app_handle: AppHandle, + hostname: &str, + port: u16, + username: &str, + auth: AuthMethod, + cols: u32, + rows: u32, + ) -> Result { + let session_id = uuid::Uuid::new_v4().to_string(); + + // Build russh client config. + let config = russh::client::Config::default(); + let config = Arc::new(config); + + // Build our handler with TOFU host key verification. + let handler = SshClient { + host_key_store: HostKeyStore::new(self.db.clone()), + hostname: hostname.to_string(), + port, + }; + + // Connect to the SSH server. + let mut handle = client::connect(config, (hostname, port), handler) + .await + .map_err(|e| format!("SSH connection to {}:{} failed: {}", hostname, port, e))?; + + // Authenticate. + let auth_success = match auth { + AuthMethod::Password(password) => { + handle + .authenticate_password(username, &password) + .await + .map_err(|e| format!("Password authentication failed: {}", e))? + } + AuthMethod::Key { + private_key_pem, + passphrase, + } => { + let key = russh::keys::decode_secret_key( + &private_key_pem, + passphrase.as_deref(), + ) + .map_err(|e| format!("Failed to decode private key: {}", e))?; + + handle + .authenticate_publickey(username, Arc::new(key)) + .await + .map_err(|e| format!("Public key authentication failed: {}", e))? + } + }; + + if !auth_success { + return Err("Authentication failed: server rejected credentials".to_string()); + } + + // Open a session channel. + let channel = handle + .channel_open_session() + .await + .map_err(|e| format!("Failed to open session channel: {}", e))?; + + // Request a PTY. + channel + .request_pty( + true, + "xterm-256color", + cols, + rows, + 0, // pix_width + 0, // pix_height + &[], + ) + .await + .map_err(|e| format!("Failed to request PTY: {}", e))?; + + // Start a shell. + channel + .request_shell(true) + .await + .map_err(|e| format!("Failed to start shell: {}", e))?; + + let handle = Arc::new(TokioMutex::new(handle)); + let channel = Arc::new(TokioMutex::new(channel)); + + // Start CWD tracker. + let cwd_tracker = CwdTracker::new(); + cwd_tracker.start( + handle.clone(), + app_handle.clone(), + session_id.clone(), + ); + + // Build session object. + let session = Arc::new(SshSession { + id: session_id.clone(), + hostname: hostname.to_string(), + port, + username: username.to_string(), + channel: channel.clone(), + handle: handle.clone(), + cwd_tracker: Some(cwd_tracker), + }); + + self.sessions.insert(session_id.clone(), session); + + // Spawn the stdout read loop. + let sid = session_id.clone(); + let chan = channel.clone(); + let app = app_handle.clone(); + + tokio::spawn(async move { + loop { + let msg = { + let mut ch = chan.lock().await; + ch.wait().await + }; + + match msg { + Some(ChannelMsg::Data { ref data }) => { + let encoded = base64::engine::general_purpose::STANDARD + .encode(data.as_ref()); + let event_name = format!("ssh:data:{}", sid); + if let Err(e) = app.emit(&event_name, encoded) { + error!("Failed to emit SSH data event: {}", e); + break; + } + } + Some(ChannelMsg::ExtendedData { ref data, .. }) => { + // stderr — emit on the same event channel so the + // terminal renders it inline (same as a real terminal). + let encoded = base64::engine::general_purpose::STANDARD + .encode(data.as_ref()); + let event_name = format!("ssh:data:{}", sid); + if let Err(e) = app.emit(&event_name, encoded) { + error!("Failed to emit SSH stderr event: {}", e); + break; + } + } + Some(ChannelMsg::ExitStatus { exit_status }) => { + info!("SSH session {} exited with status {}", sid, exit_status); + let event_name = format!("ssh:exit:{}", sid); + let _ = app.emit(&event_name, exit_status); + break; + } + Some(ChannelMsg::Eof) => { + debug!("SSH session {} received EOF", sid); + } + Some(ChannelMsg::Close) => { + info!("SSH session {} channel closed", sid); + let event_name = format!("ssh:close:{}", sid); + let _ = app.emit(&event_name, ()); + break; + } + None => { + info!("SSH session {} channel stream ended", sid); + let event_name = format!("ssh:close:{}", sid); + let _ = app.emit(&event_name, ()); + break; + } + _ => { + // Ignore other channel messages (WindowAdjust, etc.) + } + } + } + }); + + info!( + "SSH session {} connected to {}@{}:{}", + session_id, username, hostname, port + ); + + Ok(session_id) + } + + /// Write data to a session's PTY stdin. + pub async fn write(&self, session_id: &str, data: &[u8]) -> Result<(), String> { + let session = self + .sessions + .get(session_id) + .ok_or_else(|| format!("Session {} not found", session_id))?; + + let channel: tokio::sync::MutexGuard<'_, Channel> = + session.channel.lock().await; + channel + .data(&data[..]) + .await + .map_err(|e| format!("Failed to write to session {}: {}", session_id, e)) + } + + /// Resize the PTY window for a session. + pub async fn resize( + &self, + session_id: &str, + cols: u32, + rows: u32, + ) -> Result<(), String> { + let session = self + .sessions + .get(session_id) + .ok_or_else(|| format!("Session {} not found", session_id))?; + + let channel: tokio::sync::MutexGuard<'_, Channel> = + session.channel.lock().await; + channel + .window_change(cols, rows, 0, 0) + .await + .map_err(|e| format!("Failed to resize session {}: {}", session_id, e)) + } + + /// Disconnect a session — close the channel and remove it from the map. + pub async fn disconnect(&self, session_id: &str) -> Result<(), String> { + let (_, session) = self + .sessions + .remove(session_id) + .ok_or_else(|| format!("Session {} not found", session_id))?; + + // Close the channel gracefully. + { + let channel: tokio::sync::MutexGuard<'_, Channel> = + session.channel.lock().await; + let _ = channel.eof().await; + let _ = channel.close().await; + } + + // Disconnect the SSH connection. + { + let handle = session.handle.lock().await; + let _ = handle + .disconnect(Disconnect::ByApplication, "", "en") + .await; + } + + info!("SSH session {} disconnected", session_id); + Ok(()) + } + + /// Get a reference to a session by ID. + pub fn get_session(&self, session_id: &str) -> Option> { + self.sessions.get(session_id).map(|entry| entry.clone()) + } + + /// List all active sessions (metadata only). + pub fn list_sessions(&self) -> Vec { + self.sessions + .iter() + .map(|entry| { + let s = entry.value(); + SessionInfo { + id: s.id.clone(), + hostname: s.hostname.clone(), + port: s.port, + username: s.username.clone(), + } + }) + .collect() + } +} diff --git a/src/assets/css/terminal.css b/src/assets/css/terminal.css new file mode 100644 index 0000000..c4ff062 --- /dev/null +++ b/src/assets/css/terminal.css @@ -0,0 +1,49 @@ +/* xterm.js terminal container styling */ + +.terminal-container { + width: 100%; + height: 100%; + position: relative; + overflow: hidden; + background: var(--wraith-bg-primary); +} + +.terminal-container .xterm { + height: 100%; +} + +.terminal-container .xterm-viewport { + overflow-y: auto !important; +} + +.terminal-container .xterm-screen { + height: 100%; +} + +/* Selection styling */ +.terminal-container .xterm-selection div { + background-color: rgba(88, 166, 255, 0.3) !important; +} + +/* Cursor styling */ +.terminal-container .xterm-cursor-layer { + z-index: 4; +} + +/* Scrollbar inside terminal */ +.terminal-container .xterm-viewport::-webkit-scrollbar { + width: 8px; +} + +.terminal-container .xterm-viewport::-webkit-scrollbar-track { + background: var(--wraith-bg-primary); +} + +.terminal-container .xterm-viewport::-webkit-scrollbar-thumb { + background: var(--wraith-border); + border-radius: 4px; +} + +.terminal-container .xterm-viewport::-webkit-scrollbar-thumb:hover { + background: var(--wraith-text-muted); +} diff --git a/src/components/common/StatusBar.vue b/src/components/common/StatusBar.vue new file mode 100644 index 0000000..c272b26 --- /dev/null +++ b/src/components/common/StatusBar.vue @@ -0,0 +1,69 @@ + + + diff --git a/src/components/session/SessionContainer.vue b/src/components/session/SessionContainer.vue new file mode 100644 index 0000000..34eeacd --- /dev/null +++ b/src/components/session/SessionContainer.vue @@ -0,0 +1,43 @@ + + + diff --git a/src/components/session/TabBar.vue b/src/components/session/TabBar.vue new file mode 100644 index 0000000..0d93d7b --- /dev/null +++ b/src/components/session/TabBar.vue @@ -0,0 +1,126 @@ + + + diff --git a/src/components/sidebar/ConnectionTree.vue b/src/components/sidebar/ConnectionTree.vue new file mode 100644 index 0000000..7762976 --- /dev/null +++ b/src/components/sidebar/ConnectionTree.vue @@ -0,0 +1,251 @@ + + + diff --git a/src/components/sidebar/SidebarToggle.vue b/src/components/sidebar/SidebarToggle.vue new file mode 100644 index 0000000..99635af --- /dev/null +++ b/src/components/sidebar/SidebarToggle.vue @@ -0,0 +1,34 @@ + + + diff --git a/src/components/terminal/TerminalView.vue b/src/components/terminal/TerminalView.vue new file mode 100644 index 0000000..3be2e5f --- /dev/null +++ b/src/components/terminal/TerminalView.vue @@ -0,0 +1,89 @@ + + + diff --git a/src/composables/useTerminal.ts b/src/composables/useTerminal.ts new file mode 100644 index 0000000..4e42571 --- /dev/null +++ b/src/composables/useTerminal.ts @@ -0,0 +1,236 @@ +import { onBeforeUnmount } from "vue"; +import { Terminal } from "@xterm/xterm"; +import { FitAddon } from "@xterm/addon-fit"; +import { SearchAddon } from "@xterm/addon-search"; +import { WebLinksAddon } from "@xterm/addon-web-links"; +import { invoke } from "@tauri-apps/api/core"; +import { listen, type UnlistenFn } from "@tauri-apps/api/event"; +import "@xterm/xterm/css/xterm.css"; + +/** MobaXTerm Classic–inspired terminal theme colors. */ +const defaultTheme = { + background: "#0d1117", + foreground: "#e0e0e0", + cursor: "#58a6ff", + cursorAccent: "#0d1117", + selectionBackground: "rgba(88, 166, 255, 0.3)", + selectionForeground: "#ffffff", + black: "#0d1117", + red: "#f85149", + green: "#3fb950", + yellow: "#e3b341", + blue: "#58a6ff", + magenta: "#bc8cff", + cyan: "#39c5cf", + white: "#e0e0e0", + brightBlack: "#484f58", + brightRed: "#ff7b72", + brightGreen: "#56d364", + brightYellow: "#e3b341", + brightBlue: "#79c0ff", + brightMagenta: "#d2a8ff", + brightCyan: "#56d4dd", + brightWhite: "#f0f6fc", +}; + +export interface UseTerminalReturn { + terminal: Terminal; + fitAddon: FitAddon; + mount: (container: HTMLElement) => void; + destroy: () => void; + write: (data: string) => void; + fit: () => void; +} + +/** + * Composable that manages an xterm.js Terminal lifecycle. + * + * Wires bidirectional I/O: + * - User keystrokes → ssh_write (via Tauri invoke) + * - SSH stdout → xterm.js (via Tauri listen, base64 encoded) + * - Terminal resize → ssh_resize (via Tauri invoke) + */ +export function useTerminal(sessionId: string): UseTerminalReturn { + const fitAddon = new FitAddon(); + const searchAddon = new SearchAddon(); + const webLinksAddon = new WebLinksAddon(); + + const terminal = new Terminal({ + theme: defaultTheme, + fontFamily: "'Cascadia Mono', 'Cascadia Code', Consolas, 'JetBrains Mono', 'Fira Code', Menlo, Monaco, 'Courier New', monospace", + fontSize: 14, + lineHeight: 1.2, + cursorBlink: true, + cursorStyle: "block", + scrollback: 10000, + allowProposedApi: true, + convertEol: true, + rightClickSelectsWord: false, + }); + + terminal.loadAddon(fitAddon); + terminal.loadAddon(searchAddon); + terminal.loadAddon(webLinksAddon); + + // Forward typed data to the SSH backend + terminal.onData((data: string) => { + invoke("ssh_write", { sessionId, data }).catch((err: unknown) => { + console.error("SSH write error:", err); + }); + }); + + // Forward resize events to the SSH backend + terminal.onResize((size: { cols: number; rows: number }) => { + invoke("ssh_resize", { sessionId, cols: size.cols, rows: size.rows }).catch((err: unknown) => { + console.error("SSH resize error:", err); + }); + }); + + // MobaXTerm-style clipboard: highlight to copy, right-click to paste + const selectionDisposable = terminal.onSelectionChange(() => { + const sel = terminal.getSelection(); + if (sel) { + navigator.clipboard.writeText(sel).catch(() => {}); + } + }); + + function handleRightClickPaste(e: MouseEvent): void { + e.preventDefault(); + e.stopPropagation(); + navigator.clipboard.readText().then((text) => { + if (text) { + invoke("ssh_write", { sessionId, data: text }).catch(() => {}); + } + }).catch(() => {}); + } + + // Listen for SSH output events from the Rust backend (base64 encoded) + // Tauri listen() returns a Promise — store both the promise and + // the resolved unlisten function so we can clean up properly. + let unlistenFn: UnlistenFn | null = null; + let unlistenPromise: Promise | null = null; + + let resizeObserver: ResizeObserver | null = null; + + // Streaming TextDecoder persists across events so split multi-byte UTF-8 + // sequences at chunk boundaries are decoded correctly (e.g. a 3-byte em-dash + // split across two Rust read() calls). + const utf8Decoder = new TextDecoder("utf-8", { fatal: false }); + + // Write batching — accumulate chunks and flush once per animation frame. + // Without this, every tiny SSH read (sometimes single characters) triggers + // a separate terminal.write(), producing a laggy typewriter effect. + let pendingData = ""; + let rafId: number | null = null; + + function flushPendingData(): void { + rafId = null; + if (pendingData) { + terminal.write(pendingData); + pendingData = ""; + } + } + + function queueWrite(data: string): void { + pendingData += data; + if (rafId === null) { + rafId = requestAnimationFrame(flushPendingData); + } + } + + function mount(container: HTMLElement): void { + terminal.open(container); + + // Wait for fonts to load before measuring cell dimensions. + // If the font (Cascadia Mono etc.) isn't loaded when fitAddon.fit() + // runs, canvas.measureText() uses a fallback font and gets wrong + // cell widths — producing tiny dashes and 200+ column terminals. + document.fonts.ready.then(() => { + fitAddon.fit(); + }); + + // Right-click paste on the terminal's DOM element + terminal.element?.addEventListener("contextmenu", handleRightClickPaste); + + // Subscribe to SSH output events for this session. + // Tauri v2 listen() callback receives { payload: T } — the base64 string + // is in event.payload (not event.data as in Wails). + unlistenPromise = listen(`ssh:data:${sessionId}`, (event) => { + const b64data = event.payload; + + try { + // atob() returns Latin-1 — each byte becomes a char code 0x00–0xFF. + // Reconstruct raw bytes, then decode with the streaming TextDecoder + // which buffers incomplete multi-byte sequences between calls. + const binaryStr = atob(b64data); + const bytes = new Uint8Array(binaryStr.length); + for (let i = 0; i < binaryStr.length; i++) { + bytes[i] = binaryStr.charCodeAt(i); + } + const decoded = utf8Decoder.decode(bytes, { stream: true }); + if (decoded) { + queueWrite(decoded); + } + } catch { + // Fallback: write raw if not valid base64 + queueWrite(b64data); + } + }); + + // Capture the resolved unlisten function for synchronous cleanup + unlistenPromise.then((fn) => { + unlistenFn = fn; + }); + + // Auto-fit when the container resizes + resizeObserver = new ResizeObserver(() => { + fitAddon.fit(); + }); + resizeObserver.observe(container); + } + + function destroy(): void { + if (rafId !== null) { + cancelAnimationFrame(rafId); + rafId = null; + } + // Flush any remaining buffered data before teardown + if (pendingData) { + terminal.write(pendingData); + pendingData = ""; + } + terminal.element?.removeEventListener("contextmenu", handleRightClickPaste); + selectionDisposable.dispose(); + + // Clean up the Tauri event listener. + // If the promise already resolved, call unlisten directly. + // If it's still pending, wait for resolution then call it. + if (unlistenFn) { + unlistenFn(); + unlistenFn = null; + } else if (unlistenPromise) { + unlistenPromise.then((fn) => fn()); + } + unlistenPromise = null; + + if (resizeObserver) { + resizeObserver.disconnect(); + resizeObserver = null; + } + terminal.dispose(); + } + + function write(data: string): void { + terminal.write(data); + } + + function fit(): void { + fitAddon.fit(); + } + + onBeforeUnmount(() => { + destroy(); + }); + + return { terminal, fitAddon, mount, destroy, write, fit }; +} diff --git a/src/layouts/MainLayout.vue b/src/layouts/MainLayout.vue index 641519e..bc80adb 100644 --- a/src/layouts/MainLayout.vue +++ b/src/layouts/MainLayout.vue @@ -1,87 +1,446 @@ - - + + + + diff --git a/src/stores/connection.store.ts b/src/stores/connection.store.ts new file mode 100644 index 0000000..a74b7dc --- /dev/null +++ b/src/stores/connection.store.ts @@ -0,0 +1,110 @@ +import { defineStore } from "pinia"; +import { ref, computed } from "vue"; +import { invoke } from "@tauri-apps/api/core"; + +export interface Connection { + id: number; + name: string; + hostname: string; + port: number; + protocol: "ssh" | "rdp"; + groupId: number | null; + credentialId?: number | null; + color?: string; + tags?: string[]; + notes?: string; + options?: string; + sortOrder?: number; + lastConnected?: string | null; + createdAt?: string; + updatedAt?: string; +} + +export interface Group { + id: number; + name: string; + parentId: number | null; + sortOrder?: number; + icon?: string; + children?: Group[]; +} + +/** + * Connection store. + * Manages connections, groups, and search state. + * Loads data from the Rust backend via Tauri invoke. + */ +export const useConnectionStore = defineStore("connection", () => { + const connections = ref([]); + const groups = ref([]); + const searchQuery = ref(""); + + /** Filter connections by search query. */ + const filteredConnections = computed(() => { + const q = searchQuery.value.toLowerCase().trim(); + if (!q) return connections.value; + return connections.value.filter( + (c) => + c.name.toLowerCase().includes(q) || + c.hostname.toLowerCase().includes(q) || + c.tags?.some((t) => t.toLowerCase().includes(q)), + ); + }); + + /** Get connections belonging to a specific group. */ + function connectionsByGroup(groupId: number): Connection[] { + const q = searchQuery.value.toLowerCase().trim(); + const groupConns = connections.value.filter((c) => c.groupId === groupId); + if (!q) return groupConns; + return groupConns.filter( + (c) => + c.name.toLowerCase().includes(q) || + c.hostname.toLowerCase().includes(q) || + c.tags?.some((t) => t.toLowerCase().includes(q)), + ); + } + + /** Check if a group has any matching connections (for search filtering). */ + function groupHasResults(groupId: number): boolean { + return connectionsByGroup(groupId).length > 0; + } + + /** Load connections from the Rust backend. */ + async function loadConnections(): Promise { + try { + const conns = await invoke("list_connections"); + connections.value = conns || []; + } catch (err) { + console.error("Failed to load connections:", err); + connections.value = []; + } + } + + /** Load groups from the Rust backend. */ + async function loadGroups(): Promise { + try { + const grps = await invoke("list_groups"); + groups.value = grps || []; + } catch (err) { + console.error("Failed to load groups:", err); + groups.value = []; + } + } + + /** Load both connections and groups from the Rust backend. */ + async function loadAll(): Promise { + await Promise.all([loadConnections(), loadGroups()]); + } + + return { + connections, + groups, + searchQuery, + filteredConnections, + connectionsByGroup, + groupHasResults, + loadConnections, + loadGroups, + loadAll, + }; +}); diff --git a/src/stores/session.store.ts b/src/stores/session.store.ts new file mode 100644 index 0000000..0f241d6 --- /dev/null +++ b/src/stores/session.store.ts @@ -0,0 +1,202 @@ +import { defineStore } from "pinia"; +import { ref, computed } from "vue"; +import { invoke } from "@tauri-apps/api/core"; +import { useConnectionStore } from "@/stores/connection.store"; +import type { ThemeDefinition } from "@/components/common/ThemePicker.vue"; + +export interface Session { + id: string; + connectionId: number; + name: string; + protocol: "ssh" | "rdp"; + active: boolean; + username?: string; +} + +export interface TerminalDimensions { + cols: number; + rows: number; +} + +export const useSessionStore = defineStore("session", () => { + const sessions = ref([]); + const activeSessionId = ref(null); + const connecting = ref(false); + const lastError = ref(null); + + /** Active terminal theme — applied to all terminal instances. */ + const activeTheme = ref(null); + + /** Per-session terminal dimensions (cols x rows). */ + const terminalDimensions = ref>({}); + + const activeSession = computed(() => + sessions.value.find((s) => s.id === activeSessionId.value) ?? null, + ); + + const sessionCount = computed(() => sessions.value.length); + + function activateSession(id: string): void { + activeSessionId.value = id; + } + + async function closeSession(id: string): Promise { + const idx = sessions.value.findIndex((s) => s.id === id); + if (idx === -1) return; + + const session = sessions.value[idx]; + + // Disconnect the backend session + try { + await invoke("disconnect_session", { sessionId: session.id }); + } catch (err) { + console.error("Failed to disconnect session:", err); + } + + sessions.value.splice(idx, 1); + + if (activeSessionId.value === id) { + if (sessions.value.length === 0) { + activeSessionId.value = null; + } else { + const nextIdx = Math.min(idx, sessions.value.length - 1); + activeSessionId.value = sessions.value[nextIdx].id; + } + } + } + + /** Count how many sessions already exist for this connection (for tab name disambiguation). */ + function sessionCountForConnection(connId: number): number { + return sessions.value.filter((s) => s.connectionId === connId).length; + } + + /** Generate a disambiguated tab name like "Asgard", "Asgard (2)", "Asgard (3)". */ + function disambiguatedName(baseName: string, connId: number): string { + const count = sessionCountForConnection(connId); + return count === 0 ? baseName : `${baseName} (${count + 1})`; + } + + /** + * Connect to a server by connection ID. + * Multiple sessions to the same host are allowed (MobaXTerm-style). + * Each gets its own tab with a disambiguated name like "Asgard (2)". + * + * For Tauri: we must resolve the connection details ourselves and pass + * hostname/port/username/password directly to connect_ssh, because the + * Rust side has no knowledge of connection IDs — the vault owns credentials. + */ + async function connect(connectionId: number): Promise { + const connectionStore = useConnectionStore(); + const conn = connectionStore.connections.find((c) => c.id === connectionId); + if (!conn) return; + + connecting.value = true; + try { + if (conn.protocol === "ssh") { + let sessionId: string; + let resolvedUsername: string | undefined; + let resolvedPassword = ""; + + // Extract stored username from connection options JSON if present + if (conn.options) { + try { + const opts = JSON.parse(conn.options); + if (opts?.username) resolvedUsername = opts.username; + if (opts?.password) resolvedPassword = opts.password; + } catch { + // ignore malformed options + } + } + + try { + sessionId = await invoke("connect_ssh", { + hostname: conn.hostname, + port: conn.port, + username: resolvedUsername ?? "", + password: resolvedPassword, + cols: 120, + rows: 40, + }); + } catch (sshErr: unknown) { + const errMsg = sshErr instanceof Error + ? sshErr.message + : typeof sshErr === "string" + ? sshErr + : String(sshErr); + + // If no credentials or auth failed, prompt for username/password + if (errMsg.includes("NO_CREDENTIALS") || errMsg.includes("unable to authenticate") || errMsg.includes("authentication")) { + const username = prompt(`Username for ${conn.hostname}:`, "root"); + if (!username) throw new Error("Connection cancelled"); + const password = prompt(`Password for ${username}@${conn.hostname}:`); + if (password === null) throw new Error("Connection cancelled"); + + resolvedUsername = username; + sessionId = await invoke("connect_ssh", { + hostname: conn.hostname, + port: conn.port, + username, + password, + cols: 120, + rows: 40, + }); + } else { + throw sshErr; + } + } + + sessions.value.push({ + id: sessionId, + connectionId, + name: disambiguatedName(conn.name, connectionId), + protocol: "ssh", + active: true, + username: resolvedUsername, + }); + activeSessionId.value = sessionId; + } + // RDP support will be added in a future phase + } catch (err: unknown) { + const msg = err instanceof Error ? err.message : typeof err === "string" ? err : String(err); + console.error("Connection failed:", msg); + lastError.value = msg; + // Show error as native alert so it's visible without DevTools + alert(`Connection failed: ${msg}`); + } finally { + connecting.value = false; + } + } + + /** Apply a theme to all active terminal instances. */ + function setTheme(theme: ThemeDefinition): void { + activeTheme.value = theme; + } + + /** Update the recorded dimensions for a terminal session. */ + function setTerminalDimensions(sessionId: string, cols: number, rows: number): void { + terminalDimensions.value[sessionId] = { cols, rows }; + } + + /** Get the dimensions for the active session, or null if not tracked yet. */ + const activeDimensions = computed(() => { + if (!activeSessionId.value) return null; + return terminalDimensions.value[activeSessionId.value] ?? null; + }); + + return { + sessions, + activeSessionId, + activeSession, + sessionCount, + connecting, + lastError, + activeTheme, + terminalDimensions, + activeDimensions, + activateSession, + closeSession, + connect, + setTheme, + setTerminalDimensions, + }; +});