Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9ee0d34c19 | |||
| 922543cc04 | |||
| 13038989b8 | |||
| b55de8d75e | |||
| 8512ca615d | |||
| ebae39026f | |||
| d34e8e2c6d | |||
| 3935104cb5 |
279
app/src-tauri/Cargo.lock
generated
279
app/src-tauri/Cargo.lock
generated
@@ -213,6 +213,61 @@ version = "1.5.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8"
|
checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "axum"
|
||||||
|
version = "0.8.8"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "8b52af3cb4058c895d37317bb27508dccc8e5f2d39454016b297bf4a400597b8"
|
||||||
|
dependencies = [
|
||||||
|
"axum-core",
|
||||||
|
"base64 0.22.1",
|
||||||
|
"bytes",
|
||||||
|
"form_urlencoded",
|
||||||
|
"futures-util",
|
||||||
|
"http",
|
||||||
|
"http-body",
|
||||||
|
"http-body-util",
|
||||||
|
"hyper",
|
||||||
|
"hyper-util",
|
||||||
|
"itoa",
|
||||||
|
"matchit",
|
||||||
|
"memchr",
|
||||||
|
"mime",
|
||||||
|
"percent-encoding",
|
||||||
|
"pin-project-lite",
|
||||||
|
"serde_core",
|
||||||
|
"serde_json",
|
||||||
|
"serde_path_to_error",
|
||||||
|
"serde_urlencoded",
|
||||||
|
"sha1",
|
||||||
|
"sync_wrapper",
|
||||||
|
"tokio",
|
||||||
|
"tokio-tungstenite",
|
||||||
|
"tower",
|
||||||
|
"tower-layer",
|
||||||
|
"tower-service",
|
||||||
|
"tracing",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "axum-core"
|
||||||
|
version = "0.5.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1"
|
||||||
|
dependencies = [
|
||||||
|
"bytes",
|
||||||
|
"futures-core",
|
||||||
|
"http",
|
||||||
|
"http-body",
|
||||||
|
"http-body-util",
|
||||||
|
"mime",
|
||||||
|
"pin-project-lite",
|
||||||
|
"sync_wrapper",
|
||||||
|
"tower-layer",
|
||||||
|
"tower-service",
|
||||||
|
"tracing",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "base64"
|
name = "base64"
|
||||||
version = "0.21.7"
|
version = "0.21.7"
|
||||||
@@ -664,14 +719,38 @@ dependencies = [
|
|||||||
"syn 2.0.117",
|
"syn 2.0.117",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "darling"
|
||||||
|
version = "0.20.11"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee"
|
||||||
|
dependencies = [
|
||||||
|
"darling_core 0.20.11",
|
||||||
|
"darling_macro 0.20.11",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "darling"
|
name = "darling"
|
||||||
version = "0.21.3"
|
version = "0.21.3"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "9cdf337090841a411e2a7f3deb9187445851f91b309c0c0a29e05f74a00a48c0"
|
checksum = "9cdf337090841a411e2a7f3deb9187445851f91b309c0c0a29e05f74a00a48c0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"darling_core",
|
"darling_core 0.21.3",
|
||||||
"darling_macro",
|
"darling_macro 0.21.3",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "darling_core"
|
||||||
|
version = "0.20.11"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "0d00b9596d185e565c2207a0b01f8bd1a135483d02d9b7b0a54b11da8d53412e"
|
||||||
|
dependencies = [
|
||||||
|
"fnv",
|
||||||
|
"ident_case",
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"strsim",
|
||||||
|
"syn 2.0.117",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -688,17 +767,34 @@ dependencies = [
|
|||||||
"syn 2.0.117",
|
"syn 2.0.117",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "darling_macro"
|
||||||
|
version = "0.20.11"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead"
|
||||||
|
dependencies = [
|
||||||
|
"darling_core 0.20.11",
|
||||||
|
"quote",
|
||||||
|
"syn 2.0.117",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "darling_macro"
|
name = "darling_macro"
|
||||||
version = "0.21.3"
|
version = "0.21.3"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "d38308df82d1080de0afee5d069fa14b0326a88c14f15c5ccda35b4a6c414c81"
|
checksum = "d38308df82d1080de0afee5d069fa14b0326a88c14f15c5ccda35b4a6c414c81"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"darling_core",
|
"darling_core 0.21.3",
|
||||||
"quote",
|
"quote",
|
||||||
"syn 2.0.117",
|
"syn 2.0.117",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "data-encoding"
|
||||||
|
version = "2.10.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d7a1e2f27636f116493b8b860f5546edb47c8d8f8ea73e1d2a20be88e28d1fea"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "deranged"
|
name = "deranged"
|
||||||
version = "0.5.8"
|
version = "0.5.8"
|
||||||
@@ -709,6 +805,37 @@ dependencies = [
|
|||||||
"serde_core",
|
"serde_core",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "derive_builder"
|
||||||
|
version = "0.20.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "507dfb09ea8b7fa618fcf76e953f4f5e192547945816d5358edffe39f6f94947"
|
||||||
|
dependencies = [
|
||||||
|
"derive_builder_macro",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "derive_builder_core"
|
||||||
|
version = "0.20.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "2d5bcf7b024d6835cfb3d473887cd966994907effbe9227e8c8219824d06c4e8"
|
||||||
|
dependencies = [
|
||||||
|
"darling 0.20.11",
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn 2.0.117",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "derive_builder_macro"
|
||||||
|
version = "0.20.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ab63b0e2bf4d5928aff72e83a7dace85d7bba5fe12dcc3c5a572d78caffd3f3c"
|
||||||
|
dependencies = [
|
||||||
|
"derive_builder_core",
|
||||||
|
"syn 2.0.117",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "derive_more"
|
name = "derive_more"
|
||||||
version = "0.99.20"
|
version = "0.99.20"
|
||||||
@@ -841,6 +968,12 @@ version = "1.0.20"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555"
|
checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "either"
|
||||||
|
version = "1.15.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "embed-resource"
|
name = "embed-resource"
|
||||||
version = "3.0.6"
|
version = "3.0.6"
|
||||||
@@ -1309,6 +1442,18 @@ dependencies = [
|
|||||||
"wasip3",
|
"wasip3",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "getset"
|
||||||
|
version = "0.1.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9cf0fc11e47561d47397154977bc219f4cf809b2974facc3ccb3b89e2436f912"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro-error2",
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn 2.0.117",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "gio"
|
name = "gio"
|
||||||
version = "0.18.4"
|
version = "0.18.4"
|
||||||
@@ -2085,6 +2230,17 @@ version = "0.8.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77"
|
checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "local-ip-address"
|
||||||
|
version = "0.6.10"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "79ef8c257c92ade496781a32a581d43e3d512cf8ce714ecf04ea80f93ed0ff4a"
|
||||||
|
dependencies = [
|
||||||
|
"libc",
|
||||||
|
"neli",
|
||||||
|
"windows-sys 0.61.2",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "lock_api"
|
name = "lock_api"
|
||||||
version = "0.4.14"
|
version = "0.4.14"
|
||||||
@@ -2143,6 +2299,12 @@ version = "0.1.10"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "2532096657941c2fea9c289d370a250971c689d4f143798ff67113ec042024a5"
|
checksum = "2532096657941c2fea9c289d370a250971c689d4f143798ff67113ec042024a5"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "matchit"
|
||||||
|
version = "0.8.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "memchr"
|
name = "memchr"
|
||||||
version = "2.8.0"
|
version = "2.8.0"
|
||||||
@@ -2246,6 +2408,35 @@ dependencies = [
|
|||||||
"jni-sys",
|
"jni-sys",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "neli"
|
||||||
|
version = "0.7.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "22f9786d56d972959e1408b6a93be6af13b9c1392036c5c1fafa08a1b0c6ee87"
|
||||||
|
dependencies = [
|
||||||
|
"bitflags 2.11.0",
|
||||||
|
"byteorder",
|
||||||
|
"derive_builder",
|
||||||
|
"getset",
|
||||||
|
"libc",
|
||||||
|
"log",
|
||||||
|
"neli-proc-macros",
|
||||||
|
"parking_lot",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "neli-proc-macros"
|
||||||
|
version = "0.2.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "05d8d08c6e98f20a62417478ebf7be8e1425ec9acecc6f63e22da633f6b71609"
|
||||||
|
dependencies = [
|
||||||
|
"either",
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"serde",
|
||||||
|
"syn 2.0.117",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "new_debug_unreachable"
|
name = "new_debug_unreachable"
|
||||||
version = "1.0.6"
|
version = "1.0.6"
|
||||||
@@ -2916,6 +3107,28 @@ dependencies = [
|
|||||||
"version_check",
|
"version_check",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "proc-macro-error-attr2"
|
||||||
|
version = "2.0.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "96de42df36bb9bba5542fe9f1a054b8cc87e172759a1868aa05c1f3acc89dfc5"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "proc-macro-error2"
|
||||||
|
version = "2.0.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "11ec05c52be0a07b08061f7dd003e7d7092e0472bc731b4af7bb1ef876109802"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro-error-attr2",
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn 2.0.117",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "proc-macro-hack"
|
name = "proc-macro-hack"
|
||||||
version = "0.5.20+deprecated"
|
version = "0.5.20+deprecated"
|
||||||
@@ -3594,6 +3807,17 @@ dependencies = [
|
|||||||
"zmij",
|
"zmij",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "serde_path_to_error"
|
||||||
|
version = "0.1.20"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457"
|
||||||
|
dependencies = [
|
||||||
|
"itoa",
|
||||||
|
"serde",
|
||||||
|
"serde_core",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "serde_repr"
|
name = "serde_repr"
|
||||||
version = "0.1.20"
|
version = "0.1.20"
|
||||||
@@ -3660,7 +3884,7 @@ version = "3.17.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "a6d4e30573c8cb306ed6ab1dca8423eec9a463ea0e155f45399455e0368b27e0"
|
checksum = "a6d4e30573c8cb306ed6ab1dca8423eec9a463ea0e155f45399455e0368b27e0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"darling",
|
"darling 0.21.3",
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
"syn 2.0.117",
|
"syn 2.0.117",
|
||||||
@@ -3698,6 +3922,17 @@ dependencies = [
|
|||||||
"stable_deref_trait",
|
"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]]
|
[[package]]
|
||||||
name = "sha2"
|
name = "sha2"
|
||||||
version = "0.10.9"
|
version = "0.10.9"
|
||||||
@@ -4459,6 +4694,18 @@ dependencies = [
|
|||||||
"tokio",
|
"tokio",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tokio-tungstenite"
|
||||||
|
version = "0.28.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d25a406cddcc431a75d3d9afc6a7c0f7428d4891dd973e4d54c56b46127bf857"
|
||||||
|
dependencies = [
|
||||||
|
"futures-util",
|
||||||
|
"log",
|
||||||
|
"tokio",
|
||||||
|
"tungstenite",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tokio-util"
|
name = "tokio-util"
|
||||||
version = "0.7.18"
|
version = "0.7.18"
|
||||||
@@ -4581,6 +4828,7 @@ dependencies = [
|
|||||||
"tokio",
|
"tokio",
|
||||||
"tower-layer",
|
"tower-layer",
|
||||||
"tower-service",
|
"tower-service",
|
||||||
|
"tracing",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -4619,6 +4867,7 @@ version = "0.1.44"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100"
|
checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"log",
|
||||||
"pin-project-lite",
|
"pin-project-lite",
|
||||||
"tracing-attributes",
|
"tracing-attributes",
|
||||||
"tracing-core",
|
"tracing-core",
|
||||||
@@ -4670,6 +4919,8 @@ dependencies = [
|
|||||||
name = "triple-c"
|
name = "triple-c"
|
||||||
version = "0.2.0"
|
version = "0.2.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"axum",
|
||||||
|
"base64 0.22.1",
|
||||||
"bollard",
|
"bollard",
|
||||||
"chrono",
|
"chrono",
|
||||||
"dirs",
|
"dirs",
|
||||||
@@ -4677,7 +4928,9 @@ dependencies = [
|
|||||||
"futures-util",
|
"futures-util",
|
||||||
"iana-time-zone",
|
"iana-time-zone",
|
||||||
"keyring",
|
"keyring",
|
||||||
|
"local-ip-address",
|
||||||
"log",
|
"log",
|
||||||
|
"rand 0.9.2",
|
||||||
"reqwest 0.12.28",
|
"reqwest 0.12.28",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
@@ -4689,6 +4942,7 @@ dependencies = [
|
|||||||
"tauri-plugin-opener",
|
"tauri-plugin-opener",
|
||||||
"tauri-plugin-store",
|
"tauri-plugin-store",
|
||||||
"tokio",
|
"tokio",
|
||||||
|
"tower-http",
|
||||||
"uuid",
|
"uuid",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -4698,6 +4952,23 @@ version = "0.2.5"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
|
checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tungstenite"
|
||||||
|
version = "0.28.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "8628dcc84e5a09eb3d8423d6cb682965dea9133204e8fb3efee74c2a0c259442"
|
||||||
|
dependencies = [
|
||||||
|
"bytes",
|
||||||
|
"data-encoding",
|
||||||
|
"http",
|
||||||
|
"httparse",
|
||||||
|
"log",
|
||||||
|
"rand 0.9.2",
|
||||||
|
"sha1",
|
||||||
|
"thiserror 2.0.18",
|
||||||
|
"utf-8",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "typeid"
|
name = "typeid"
|
||||||
version = "1.0.3"
|
version = "1.0.3"
|
||||||
|
|||||||
@@ -31,6 +31,11 @@ tar = "0.4"
|
|||||||
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
|
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
|
||||||
iana-time-zone = "0.1"
|
iana-time-zone = "0.1"
|
||||||
sha2 = "0.10"
|
sha2 = "0.10"
|
||||||
|
axum = { version = "0.8", features = ["ws"] }
|
||||||
|
tower-http = { version = "0.6", features = ["cors"] }
|
||||||
|
base64 = "0.22"
|
||||||
|
rand = "0.9"
|
||||||
|
local-ip-address = "0.6"
|
||||||
|
|
||||||
[build-dependencies]
|
[build-dependencies]
|
||||||
tauri-build = { version = "2", features = [] }
|
tauri-build = { version = "2", features = [] }
|
||||||
|
|||||||
@@ -7,3 +7,4 @@ pub mod project_commands;
|
|||||||
pub mod settings_commands;
|
pub mod settings_commands;
|
||||||
pub mod terminal_commands;
|
pub mod terminal_commands;
|
||||||
pub mod update_commands;
|
pub mod update_commands;
|
||||||
|
pub mod web_terminal_commands;
|
||||||
|
|||||||
143
app/src-tauri/src/commands/web_terminal_commands.rs
Normal file
143
app/src-tauri/src/commands/web_terminal_commands.rs
Normal file
@@ -0,0 +1,143 @@
|
|||||||
|
use serde::Serialize;
|
||||||
|
use tauri::State;
|
||||||
|
|
||||||
|
use crate::web_terminal::WebTerminalServer;
|
||||||
|
use crate::AppState;
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
pub struct WebTerminalInfo {
|
||||||
|
pub running: bool,
|
||||||
|
pub port: u16,
|
||||||
|
pub access_token: String,
|
||||||
|
pub local_ip: Option<String>,
|
||||||
|
pub url: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn generate_token() -> String {
|
||||||
|
use rand::Rng;
|
||||||
|
let mut rng = rand::rng();
|
||||||
|
let bytes: Vec<u8> = (0..32).map(|_| rng.random::<u8>()).collect();
|
||||||
|
use base64::Engine;
|
||||||
|
base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(&bytes)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_local_ip() -> Option<String> {
|
||||||
|
local_ip_address::local_ip().ok().map(|ip| ip.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_info(running: bool, port: u16, token: &str) -> WebTerminalInfo {
|
||||||
|
let local_ip = get_local_ip();
|
||||||
|
let url = if running {
|
||||||
|
local_ip
|
||||||
|
.as_ref()
|
||||||
|
.map(|ip| format!("http://{}:{}?token={}", ip, port, token))
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
WebTerminalInfo {
|
||||||
|
running,
|
||||||
|
port,
|
||||||
|
access_token: token.to_string(),
|
||||||
|
local_ip,
|
||||||
|
url,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn start_web_terminal(state: State<'_, AppState>) -> Result<WebTerminalInfo, String> {
|
||||||
|
let mut server_guard = state.web_terminal_server.lock().await;
|
||||||
|
if server_guard.is_some() {
|
||||||
|
return Err("Web terminal server is already running".to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut settings = state.settings_store.get();
|
||||||
|
|
||||||
|
// Auto-generate token if not set
|
||||||
|
if settings.web_terminal.access_token.is_none() {
|
||||||
|
settings.web_terminal.access_token = Some(generate_token());
|
||||||
|
settings.web_terminal.enabled = true;
|
||||||
|
state.settings_store.update(settings.clone()).map_err(|e| format!("Failed to save settings: {}", e))?;
|
||||||
|
}
|
||||||
|
|
||||||
|
let token = settings.web_terminal.access_token.clone().unwrap_or_default();
|
||||||
|
let port = settings.web_terminal.port;
|
||||||
|
|
||||||
|
let server = WebTerminalServer::start(
|
||||||
|
port,
|
||||||
|
token.clone(),
|
||||||
|
state.exec_manager.clone(),
|
||||||
|
state.projects_store.clone(),
|
||||||
|
state.settings_store.clone(),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
*server_guard = Some(server);
|
||||||
|
|
||||||
|
// Mark as enabled in settings
|
||||||
|
if !settings.web_terminal.enabled {
|
||||||
|
settings.web_terminal.enabled = true;
|
||||||
|
let _ = state.settings_store.update(settings);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(build_info(true, port, &token))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn stop_web_terminal(state: State<'_, AppState>) -> Result<(), String> {
|
||||||
|
let mut server_guard = state.web_terminal_server.lock().await;
|
||||||
|
if let Some(server) = server_guard.take() {
|
||||||
|
server.stop();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mark as disabled in settings
|
||||||
|
let mut settings = state.settings_store.get();
|
||||||
|
if settings.web_terminal.enabled {
|
||||||
|
settings.web_terminal.enabled = false;
|
||||||
|
let _ = state.settings_store.update(settings);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn get_web_terminal_status(state: State<'_, AppState>) -> Result<WebTerminalInfo, String> {
|
||||||
|
let server_guard = state.web_terminal_server.lock().await;
|
||||||
|
let settings = state.settings_store.get();
|
||||||
|
let token = settings.web_terminal.access_token.clone().unwrap_or_default();
|
||||||
|
let running = server_guard.is_some();
|
||||||
|
Ok(build_info(running, settings.web_terminal.port, &token))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn regenerate_web_terminal_token(state: State<'_, AppState>) -> Result<WebTerminalInfo, String> {
|
||||||
|
// Stop current server if running
|
||||||
|
{
|
||||||
|
let mut server_guard = state.web_terminal_server.lock().await;
|
||||||
|
if let Some(server) = server_guard.take() {
|
||||||
|
server.stop();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate new token and save
|
||||||
|
let new_token = generate_token();
|
||||||
|
let mut settings = state.settings_store.get();
|
||||||
|
settings.web_terminal.access_token = Some(new_token.clone());
|
||||||
|
state.settings_store.update(settings.clone()).map_err(|e| format!("Failed to save settings: {}", e))?;
|
||||||
|
|
||||||
|
// Restart if was enabled
|
||||||
|
if settings.web_terminal.enabled {
|
||||||
|
let server = WebTerminalServer::start(
|
||||||
|
settings.web_terminal.port,
|
||||||
|
new_token.clone(),
|
||||||
|
state.exec_manager.clone(),
|
||||||
|
state.projects_store.clone(),
|
||||||
|
state.settings_store.clone(),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
let mut server_guard = state.web_terminal_server.lock().await;
|
||||||
|
*server_guard = Some(server);
|
||||||
|
return Ok(build_info(true, settings.web_terminal.port, &new_token));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(build_info(false, settings.web_terminal.port, &new_token))
|
||||||
|
}
|
||||||
@@ -704,6 +704,13 @@ pub async fn create_container(
|
|||||||
labels.insert("triple-c.timezone".to_string(), timezone.unwrap_or("").to_string());
|
labels.insert("triple-c.timezone".to_string(), timezone.unwrap_or("").to_string());
|
||||||
labels.insert("triple-c.mcp-fingerprint".to_string(), compute_mcp_fingerprint(mcp_servers));
|
labels.insert("triple-c.mcp-fingerprint".to_string(), compute_mcp_fingerprint(mcp_servers));
|
||||||
labels.insert("triple-c.mission-control".to_string(), project.mission_control_enabled.to_string());
|
labels.insert("triple-c.mission-control".to_string(), project.mission_control_enabled.to_string());
|
||||||
|
labels.insert("triple-c.custom-env-fingerprint".to_string(), custom_env_fingerprint.clone());
|
||||||
|
labels.insert("triple-c.instructions-fingerprint".to_string(),
|
||||||
|
combined_instructions.as_ref().map(|s| sha256_hex(s)).unwrap_or_default());
|
||||||
|
labels.insert("triple-c.git-user-name".to_string(), project.git_user_name.clone().unwrap_or_default());
|
||||||
|
labels.insert("triple-c.git-user-email".to_string(), project.git_user_email.clone().unwrap_or_default());
|
||||||
|
labels.insert("triple-c.git-token-hash".to_string(),
|
||||||
|
project.git_token.as_ref().map(|t| sha256_hex(t)).unwrap_or_default());
|
||||||
|
|
||||||
let host_config = HostConfig {
|
let host_config = HostConfig {
|
||||||
mounts: Some(mounts),
|
mounts: Some(mounts),
|
||||||
@@ -1000,41 +1007,32 @@ pub async fn container_needs_recreation(
|
|||||||
return Ok(true);
|
return Ok(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Git environment variables ────────────────────────────────────────
|
// ── Git settings (label-based to avoid stale snapshot env vars) ─────
|
||||||
let env_vars = info
|
let expected_git_name = project.git_user_name.clone().unwrap_or_default();
|
||||||
.config
|
let container_git_name = get_label("triple-c.git-user-name").unwrap_or_default();
|
||||||
.as_ref()
|
if container_git_name != expected_git_name {
|
||||||
.and_then(|c| c.env.as_ref());
|
log::info!("GIT_USER_NAME mismatch (container={:?}, project={:?})", container_git_name, expected_git_name);
|
||||||
|
|
||||||
let get_env = |name: &str| -> Option<String> {
|
|
||||||
env_vars.and_then(|vars| {
|
|
||||||
vars.iter()
|
|
||||||
.find(|v| v.starts_with(&format!("{}=", name)))
|
|
||||||
.map(|v| v[name.len() + 1..].to_string())
|
|
||||||
})
|
|
||||||
};
|
|
||||||
|
|
||||||
let container_git_name = get_env("GIT_USER_NAME");
|
|
||||||
let container_git_email = get_env("GIT_USER_EMAIL");
|
|
||||||
let container_git_token = get_env("GIT_TOKEN");
|
|
||||||
|
|
||||||
if container_git_name.as_deref() != project.git_user_name.as_deref() {
|
|
||||||
log::info!("GIT_USER_NAME mismatch (container={:?}, project={:?})", container_git_name, project.git_user_name);
|
|
||||||
return Ok(true);
|
return Ok(true);
|
||||||
}
|
}
|
||||||
if container_git_email.as_deref() != project.git_user_email.as_deref() {
|
|
||||||
log::info!("GIT_USER_EMAIL mismatch (container={:?}, project={:?})", container_git_email, project.git_user_email);
|
let expected_git_email = project.git_user_email.clone().unwrap_or_default();
|
||||||
|
let container_git_email = get_label("triple-c.git-user-email").unwrap_or_default();
|
||||||
|
if container_git_email != expected_git_email {
|
||||||
|
log::info!("GIT_USER_EMAIL mismatch (container={:?}, project={:?})", container_git_email, expected_git_email);
|
||||||
return Ok(true);
|
return Ok(true);
|
||||||
}
|
}
|
||||||
if container_git_token.as_deref() != project.git_token.as_deref() {
|
|
||||||
|
let expected_git_token_hash = project.git_token.as_ref().map(|t| sha256_hex(t)).unwrap_or_default();
|
||||||
|
let container_git_token_hash = get_label("triple-c.git-token-hash").unwrap_or_default();
|
||||||
|
if container_git_token_hash != expected_git_token_hash {
|
||||||
log::info!("GIT_TOKEN mismatch");
|
log::info!("GIT_TOKEN mismatch");
|
||||||
return Ok(true);
|
return Ok(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Custom environment variables ──────────────────────────────────────
|
// ── Custom environment variables (label-based fingerprint) ──────────
|
||||||
let merged_env = merge_custom_env_vars(global_custom_env_vars, &project.custom_env_vars);
|
let merged_env = merge_custom_env_vars(global_custom_env_vars, &project.custom_env_vars);
|
||||||
let expected_fingerprint = compute_env_fingerprint(&merged_env);
|
let expected_fingerprint = compute_env_fingerprint(&merged_env);
|
||||||
let container_fingerprint = get_env("TRIPLE_C_CUSTOM_ENV").unwrap_or_default();
|
let container_fingerprint = get_label("triple-c.custom-env-fingerprint").unwrap_or_default();
|
||||||
if container_fingerprint != expected_fingerprint {
|
if container_fingerprint != expected_fingerprint {
|
||||||
log::info!("Custom env vars mismatch (container={:?}, expected={:?})", container_fingerprint, expected_fingerprint);
|
log::info!("Custom env vars mismatch (container={:?}, expected={:?})", container_fingerprint, expected_fingerprint);
|
||||||
return Ok(true);
|
return Ok(true);
|
||||||
@@ -1048,15 +1046,16 @@ pub async fn container_needs_recreation(
|
|||||||
return Ok(true);
|
return Ok(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Claude instructions ───────────────────────────────────────────────
|
// ── Claude instructions (label-based fingerprint) ─────────────────────
|
||||||
let expected_instructions = build_claude_instructions(
|
let expected_instructions = build_claude_instructions(
|
||||||
global_claude_instructions,
|
global_claude_instructions,
|
||||||
project.claude_instructions.as_deref(),
|
project.claude_instructions.as_deref(),
|
||||||
&project.port_mappings,
|
&project.port_mappings,
|
||||||
project.mission_control_enabled,
|
project.mission_control_enabled,
|
||||||
);
|
);
|
||||||
let container_instructions = get_env("CLAUDE_INSTRUCTIONS");
|
let expected_instructions_fp = expected_instructions.as_ref().map(|s| sha256_hex(s)).unwrap_or_default();
|
||||||
if container_instructions.as_deref() != expected_instructions.as_deref() {
|
let container_instructions_fp = get_label("triple-c.instructions-fingerprint").unwrap_or_default();
|
||||||
|
if container_instructions_fp != expected_instructions_fp {
|
||||||
log::info!("CLAUDE_INSTRUCTIONS mismatch");
|
log::info!("CLAUDE_INSTRUCTIONS mismatch");
|
||||||
return Ok(true);
|
return Ok(true);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,44 +3,55 @@ mod docker;
|
|||||||
mod logging;
|
mod logging;
|
||||||
mod models;
|
mod models;
|
||||||
mod storage;
|
mod storage;
|
||||||
|
pub mod web_terminal;
|
||||||
|
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
use docker::exec::ExecSessionManager;
|
use docker::exec::ExecSessionManager;
|
||||||
use storage::projects_store::ProjectsStore;
|
use storage::projects_store::ProjectsStore;
|
||||||
use storage::settings_store::SettingsStore;
|
use storage::settings_store::SettingsStore;
|
||||||
use storage::mcp_store::McpStore;
|
use storage::mcp_store::McpStore;
|
||||||
use tauri::Manager;
|
use tauri::Manager;
|
||||||
|
use web_terminal::WebTerminalServer;
|
||||||
|
|
||||||
pub struct AppState {
|
pub struct AppState {
|
||||||
pub projects_store: ProjectsStore,
|
pub projects_store: Arc<ProjectsStore>,
|
||||||
pub settings_store: SettingsStore,
|
pub settings_store: Arc<SettingsStore>,
|
||||||
pub mcp_store: McpStore,
|
pub mcp_store: Arc<McpStore>,
|
||||||
pub exec_manager: ExecSessionManager,
|
pub exec_manager: Arc<ExecSessionManager>,
|
||||||
|
pub web_terminal_server: Arc<tokio::sync::Mutex<Option<WebTerminalServer>>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn run() {
|
pub fn run() {
|
||||||
logging::init();
|
logging::init();
|
||||||
|
|
||||||
let projects_store = match ProjectsStore::new() {
|
let projects_store = Arc::new(match ProjectsStore::new() {
|
||||||
Ok(s) => s,
|
Ok(s) => s,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
log::error!("Failed to initialize projects store: {}", e);
|
log::error!("Failed to initialize projects store: {}", e);
|
||||||
panic!("Failed to initialize projects store: {}", e);
|
panic!("Failed to initialize projects store: {}", e);
|
||||||
}
|
}
|
||||||
};
|
});
|
||||||
let settings_store = match SettingsStore::new() {
|
let settings_store = Arc::new(match SettingsStore::new() {
|
||||||
Ok(s) => s,
|
Ok(s) => s,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
log::error!("Failed to initialize settings store: {}", e);
|
log::error!("Failed to initialize settings store: {}", e);
|
||||||
panic!("Failed to initialize settings store: {}", e);
|
panic!("Failed to initialize settings store: {}", e);
|
||||||
}
|
}
|
||||||
};
|
});
|
||||||
let mcp_store = match McpStore::new() {
|
let mcp_store = Arc::new(match McpStore::new() {
|
||||||
Ok(s) => s,
|
Ok(s) => s,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
log::error!("Failed to initialize MCP store: {}", e);
|
log::error!("Failed to initialize MCP store: {}", e);
|
||||||
panic!("Failed to initialize MCP store: {}", e);
|
panic!("Failed to initialize MCP store: {}", e);
|
||||||
}
|
}
|
||||||
};
|
});
|
||||||
|
let exec_manager = Arc::new(ExecSessionManager::new());
|
||||||
|
|
||||||
|
// Clone Arcs for the setup closure (web terminal auto-start)
|
||||||
|
let projects_store_setup = projects_store.clone();
|
||||||
|
let settings_store_setup = settings_store.clone();
|
||||||
|
let exec_manager_setup = exec_manager.clone();
|
||||||
|
|
||||||
tauri::Builder::default()
|
tauri::Builder::default()
|
||||||
.plugin(tauri_plugin_store::Builder::default().build())
|
.plugin(tauri_plugin_store::Builder::default().build())
|
||||||
@@ -50,9 +61,10 @@ pub fn run() {
|
|||||||
projects_store,
|
projects_store,
|
||||||
settings_store,
|
settings_store,
|
||||||
mcp_store,
|
mcp_store,
|
||||||
exec_manager: ExecSessionManager::new(),
|
exec_manager,
|
||||||
|
web_terminal_server: Arc::new(tokio::sync::Mutex::new(None)),
|
||||||
})
|
})
|
||||||
.setup(|app| {
|
.setup(move |app| {
|
||||||
match tauri::image::Image::from_bytes(include_bytes!("../icons/icon.png")) {
|
match tauri::image::Image::from_bytes(include_bytes!("../icons/icon.png")) {
|
||||||
Ok(icon) => {
|
Ok(icon) => {
|
||||||
if let Some(window) = app.get_webview_window("main") {
|
if let Some(window) = app.get_webview_window("main") {
|
||||||
@@ -63,12 +75,54 @@ pub fn run() {
|
|||||||
log::error!("Failed to load window icon: {}", e);
|
log::error!("Failed to load window icon: {}", e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Auto-start web terminal server if enabled in settings
|
||||||
|
let settings = settings_store_setup.get();
|
||||||
|
if settings.web_terminal.enabled {
|
||||||
|
if let Some(token) = &settings.web_terminal.access_token {
|
||||||
|
let token = token.clone();
|
||||||
|
let port = settings.web_terminal.port;
|
||||||
|
let exec_mgr = exec_manager_setup.clone();
|
||||||
|
let proj_store = projects_store_setup.clone();
|
||||||
|
let set_store = settings_store_setup.clone();
|
||||||
|
let state = app.state::<AppState>();
|
||||||
|
let web_server_mutex = state.web_terminal_server.clone();
|
||||||
|
|
||||||
|
tauri::async_runtime::spawn(async move {
|
||||||
|
match WebTerminalServer::start(
|
||||||
|
port,
|
||||||
|
token,
|
||||||
|
exec_mgr,
|
||||||
|
proj_store,
|
||||||
|
set_store,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(server) => {
|
||||||
|
let mut guard = web_server_mutex.lock().await;
|
||||||
|
*guard = Some(server);
|
||||||
|
log::info!("Web terminal auto-started on port {}", port);
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
log::error!("Failed to auto-start web terminal: {}", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
})
|
})
|
||||||
.on_window_event(|window, event| {
|
.on_window_event(|window, event| {
|
||||||
if let tauri::WindowEvent::CloseRequested { .. } = event {
|
if let tauri::WindowEvent::CloseRequested { .. } = event {
|
||||||
let state = window.state::<AppState>();
|
let state = window.state::<AppState>();
|
||||||
tauri::async_runtime::block_on(async {
|
tauri::async_runtime::block_on(async {
|
||||||
|
// Stop web terminal server
|
||||||
|
let mut server_guard = state.web_terminal_server.lock().await;
|
||||||
|
if let Some(server) = server_guard.take() {
|
||||||
|
server.stop();
|
||||||
|
}
|
||||||
|
// Close all exec sessions
|
||||||
state.exec_manager.close_all_sessions().await;
|
state.exec_manager.close_all_sessions().await;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -122,6 +176,11 @@ pub fn run() {
|
|||||||
commands::update_commands::check_image_update,
|
commands::update_commands::check_image_update,
|
||||||
// Help
|
// Help
|
||||||
commands::help_commands::get_help_content,
|
commands::help_commands::get_help_content,
|
||||||
|
// Web Terminal
|
||||||
|
commands::web_terminal_commands::start_web_terminal,
|
||||||
|
commands::web_terminal_commands::stop_web_terminal,
|
||||||
|
commands::web_terminal_commands::get_web_terminal_status,
|
||||||
|
commands::web_terminal_commands::regenerate_web_terminal_token,
|
||||||
])
|
])
|
||||||
.run(tauri::generate_context!())
|
.run(tauri::generate_context!())
|
||||||
.expect("error while running tauri application");
|
.expect("error while running tauri application");
|
||||||
|
|||||||
@@ -74,6 +74,32 @@ pub struct AppSettings {
|
|||||||
pub default_microphone: Option<String>,
|
pub default_microphone: Option<String>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub dismissed_image_digest: Option<String>,
|
pub dismissed_image_digest: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub web_terminal: WebTerminalSettings,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_web_terminal_port() -> u16 {
|
||||||
|
7681
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct WebTerminalSettings {
|
||||||
|
#[serde(default)]
|
||||||
|
pub enabled: bool,
|
||||||
|
#[serde(default = "default_web_terminal_port")]
|
||||||
|
pub port: u16,
|
||||||
|
#[serde(default)]
|
||||||
|
pub access_token: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for WebTerminalSettings {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
enabled: false,
|
||||||
|
port: 7681,
|
||||||
|
access_token: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for AppSettings {
|
impl Default for AppSettings {
|
||||||
@@ -93,6 +119,7 @@ impl Default for AppSettings {
|
|||||||
timezone: None,
|
timezone: None,
|
||||||
default_microphone: None,
|
default_microphone: None,
|
||||||
dismissed_image_digest: None,
|
dismissed_image_digest: None,
|
||||||
|
web_terminal: WebTerminalSettings::default(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
4
app/src-tauri/src/web_terminal/mod.rs
Normal file
4
app/src-tauri/src/web_terminal/mod.rs
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
pub mod server;
|
||||||
|
mod ws_handler;
|
||||||
|
|
||||||
|
pub use server::WebTerminalServer;
|
||||||
155
app/src-tauri/src/web_terminal/server.rs
Normal file
155
app/src-tauri/src/web_terminal/server.rs
Normal file
@@ -0,0 +1,155 @@
|
|||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use axum::extract::{Query, State as AxumState, WebSocketUpgrade};
|
||||||
|
use axum::response::{Html, IntoResponse};
|
||||||
|
use axum::routing::get;
|
||||||
|
use axum::Router;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use tokio::sync::watch;
|
||||||
|
use tower_http::cors::CorsLayer;
|
||||||
|
|
||||||
|
use crate::docker::exec::ExecSessionManager;
|
||||||
|
use crate::storage::projects_store::ProjectsStore;
|
||||||
|
use crate::storage::settings_store::SettingsStore;
|
||||||
|
|
||||||
|
use super::ws_handler;
|
||||||
|
|
||||||
|
/// Shared state passed to all axum handlers.
|
||||||
|
pub struct WebTerminalState {
|
||||||
|
pub exec_manager: Arc<ExecSessionManager>,
|
||||||
|
pub projects_store: Arc<ProjectsStore>,
|
||||||
|
pub settings_store: Arc<SettingsStore>,
|
||||||
|
pub access_token: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Manages the lifecycle of the axum HTTP+WS server.
|
||||||
|
pub struct WebTerminalServer {
|
||||||
|
shutdown_tx: watch::Sender<()>,
|
||||||
|
port: u16,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct TokenQuery {
|
||||||
|
pub token: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
struct ProjectInfo {
|
||||||
|
id: String,
|
||||||
|
name: String,
|
||||||
|
status: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl WebTerminalServer {
|
||||||
|
/// Start the web terminal server on the given port.
|
||||||
|
pub async fn start(
|
||||||
|
port: u16,
|
||||||
|
access_token: String,
|
||||||
|
exec_manager: Arc<ExecSessionManager>,
|
||||||
|
projects_store: Arc<ProjectsStore>,
|
||||||
|
settings_store: Arc<SettingsStore>,
|
||||||
|
) -> Result<Self, String> {
|
||||||
|
let (shutdown_tx, shutdown_rx) = watch::channel(());
|
||||||
|
|
||||||
|
let shared_state = Arc::new(WebTerminalState {
|
||||||
|
exec_manager,
|
||||||
|
projects_store,
|
||||||
|
settings_store,
|
||||||
|
access_token,
|
||||||
|
});
|
||||||
|
|
||||||
|
let app = Router::new()
|
||||||
|
.route("/", get(serve_html))
|
||||||
|
.route("/ws", get(ws_upgrade))
|
||||||
|
.route("/api/projects", get(list_projects))
|
||||||
|
.layer(CorsLayer::permissive())
|
||||||
|
.with_state(shared_state);
|
||||||
|
|
||||||
|
let addr = format!("0.0.0.0:{}", port);
|
||||||
|
let listener = tokio::net::TcpListener::bind(&addr)
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("Failed to bind web terminal to {}: {}", addr, e))?;
|
||||||
|
|
||||||
|
log::info!("Web terminal server listening on {}", addr);
|
||||||
|
|
||||||
|
let mut shutdown_rx_clone = shutdown_rx.clone();
|
||||||
|
tokio::spawn(async move {
|
||||||
|
axum::serve(listener, app)
|
||||||
|
.with_graceful_shutdown(async move {
|
||||||
|
let _ = shutdown_rx_clone.changed().await;
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap_or_else(|e| {
|
||||||
|
log::error!("Web terminal server error: {}", e);
|
||||||
|
});
|
||||||
|
log::info!("Web terminal server shut down");
|
||||||
|
});
|
||||||
|
|
||||||
|
Ok(Self { shutdown_tx, port })
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stop the server gracefully.
|
||||||
|
pub fn stop(&self) {
|
||||||
|
log::info!("Stopping web terminal server on port {}", self.port);
|
||||||
|
let _ = self.shutdown_tx.send(());
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn port(&self) -> u16 {
|
||||||
|
self.port
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Serve the embedded HTML page.
|
||||||
|
async fn serve_html() -> Html<&'static str> {
|
||||||
|
Html(include_str!("terminal.html"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Validate token from query params.
|
||||||
|
fn validate_token(state: &WebTerminalState, token: &Option<String>) -> bool {
|
||||||
|
match token {
|
||||||
|
Some(t) => t == &state.access_token,
|
||||||
|
None => false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// WebSocket upgrade handler.
|
||||||
|
async fn ws_upgrade(
|
||||||
|
ws: WebSocketUpgrade,
|
||||||
|
AxumState(state): AxumState<Arc<WebTerminalState>>,
|
||||||
|
Query(query): Query<TokenQuery>,
|
||||||
|
) -> impl IntoResponse {
|
||||||
|
if !validate_token(&state, &query.token) {
|
||||||
|
return (axum::http::StatusCode::UNAUTHORIZED, "Invalid token").into_response();
|
||||||
|
}
|
||||||
|
ws.on_upgrade(move |socket| ws_handler::handle_connection(socket, state))
|
||||||
|
.into_response()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// List running projects (REST endpoint).
|
||||||
|
async fn list_projects(
|
||||||
|
AxumState(state): AxumState<Arc<WebTerminalState>>,
|
||||||
|
Query(query): Query<TokenQuery>,
|
||||||
|
) -> impl IntoResponse {
|
||||||
|
if !validate_token(&state, &query.token) {
|
||||||
|
return (
|
||||||
|
axum::http::StatusCode::UNAUTHORIZED,
|
||||||
|
axum::Json(serde_json::json!({"error": "Invalid token"})),
|
||||||
|
)
|
||||||
|
.into_response();
|
||||||
|
}
|
||||||
|
|
||||||
|
let projects = state.projects_store.list();
|
||||||
|
let infos: Vec<ProjectInfo> = projects
|
||||||
|
.into_iter()
|
||||||
|
.map(|p| ProjectInfo {
|
||||||
|
id: p.id,
|
||||||
|
name: p.name,
|
||||||
|
status: serde_json::to_value(&p.status)
|
||||||
|
.ok()
|
||||||
|
.and_then(|v| v.as_str().map(|s| s.to_string()))
|
||||||
|
.unwrap_or_else(|| "unknown".to_string()),
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
axum::Json(infos).into_response()
|
||||||
|
}
|
||||||
662
app/src-tauri/src/web_terminal/terminal.html
Normal file
662
app/src-tauri/src/web_terminal/terminal.html
Normal file
@@ -0,0 +1,662 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
|
||||||
|
<title>Triple-C Web Terminal</title>
|
||||||
|
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@xterm/xterm@5.5.0/css/xterm.min.css">
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/@xterm/xterm@5.5.0/lib/xterm.min.js"></script>
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/@xterm/addon-fit@0.10.0/lib/addon-fit.min.js"></script>
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/@xterm/addon-web-links@0.11.0/lib/addon-web-links.min.js"></script>
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--bg-primary: #1a1b26;
|
||||||
|
--bg-secondary: #24283b;
|
||||||
|
--bg-tertiary: #2f3347;
|
||||||
|
--text-primary: #c0caf5;
|
||||||
|
--text-secondary: #565f89;
|
||||||
|
--accent: #7aa2f7;
|
||||||
|
--accent-hover: #89b4fa;
|
||||||
|
--border: #3b3f57;
|
||||||
|
--success: #9ece6a;
|
||||||
|
--warning: #e0af68;
|
||||||
|
--error: #f7768e;
|
||||||
|
}
|
||||||
|
|
||||||
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||||
|
|
||||||
|
body {
|
||||||
|
background: var(--bg-primary);
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||||
|
height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
-webkit-tap-highlight-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Top Bar ─────────────────────────────── */
|
||||||
|
.topbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 6px 12px;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
flex-shrink: 0;
|
||||||
|
min-height: 42px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar-title {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--accent);
|
||||||
|
white-space: nowrap;
|
||||||
|
margin-right: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-dot {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--error);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.status-dot.connected { background: var(--success); }
|
||||||
|
.status-dot.reconnecting { background: var(--warning); animation: pulse 1s infinite; }
|
||||||
|
|
||||||
|
@keyframes pulse { 0%,100% { opacity: 1; } 50% { opacity: 0.4; } }
|
||||||
|
|
||||||
|
select, button {
|
||||||
|
font-size: 12px;
|
||||||
|
padding: 4px 8px;
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
color: var(--text-primary);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
touch-action: manipulation;
|
||||||
|
}
|
||||||
|
|
||||||
|
select:focus, button:focus { outline: none; border-color: var(--accent); }
|
||||||
|
button:hover { background: var(--border); }
|
||||||
|
button:active { background: var(--accent); color: var(--bg-primary); }
|
||||||
|
|
||||||
|
.btn-new {
|
||||||
|
font-weight: 600;
|
||||||
|
min-width: 44px;
|
||||||
|
min-height: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Tab Bar ─────────────────────────────── */
|
||||||
|
.tabbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1px;
|
||||||
|
padding: 0 8px;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
flex-shrink: 0;
|
||||||
|
overflow-x: auto;
|
||||||
|
-webkit-overflow-scrolling: touch;
|
||||||
|
min-height: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 6px 12px;
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
cursor: pointer;
|
||||||
|
white-space: nowrap;
|
||||||
|
border-bottom: 2px solid transparent;
|
||||||
|
transition: all 0.15s;
|
||||||
|
min-height: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab:hover { color: var(--text-primary); }
|
||||||
|
.tab.active {
|
||||||
|
color: var(--text-primary);
|
||||||
|
border-bottom-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-close {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
border-radius: 3px;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
padding: 0;
|
||||||
|
min-width: unset;
|
||||||
|
min-height: unset;
|
||||||
|
}
|
||||||
|
.tab-close:hover { background: var(--error); color: white; }
|
||||||
|
|
||||||
|
/* ── Terminal Area ───────────────────────── */
|
||||||
|
.terminal-area {
|
||||||
|
flex: 1;
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.terminal-container {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
display: none;
|
||||||
|
padding: 4px;
|
||||||
|
}
|
||||||
|
.terminal-container.active { display: block; }
|
||||||
|
|
||||||
|
/* ── Input Bar (mobile/tablet) ──────────── */
|
||||||
|
.input-bar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 6px 8px;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-bar input {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
padding: 8px 10px;
|
||||||
|
font-size: 16px; /* prevents iOS zoom on focus */
|
||||||
|
font-family: 'Cascadia Code', 'Fira Code', 'JetBrains Mono', 'Menlo', monospace;
|
||||||
|
background: var(--bg-primary);
|
||||||
|
color: var(--text-primary);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 6px;
|
||||||
|
outline: none;
|
||||||
|
-webkit-appearance: none;
|
||||||
|
}
|
||||||
|
.input-bar input:focus { border-color: var(--accent); }
|
||||||
|
|
||||||
|
.input-bar .key-btn {
|
||||||
|
padding: 8px 10px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
min-width: 40px;
|
||||||
|
min-height: 36px;
|
||||||
|
border-radius: 6px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Scroll-to-bottom FAB ──────────────── */
|
||||||
|
.scroll-bottom-btn {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 12px;
|
||||||
|
right: 16px;
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--accent);
|
||||||
|
color: var(--bg-primary);
|
||||||
|
border: none;
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: bold;
|
||||||
|
display: none;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
cursor: pointer;
|
||||||
|
box-shadow: 0 2px 8px rgba(0,0,0,0.4);
|
||||||
|
z-index: 10;
|
||||||
|
padding: 0;
|
||||||
|
min-width: unset;
|
||||||
|
min-height: unset;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
.scroll-bottom-btn:hover { background: var(--accent-hover); }
|
||||||
|
.scroll-bottom-btn.visible { display: flex; }
|
||||||
|
|
||||||
|
/* ── Empty State ─────────────────────────── */
|
||||||
|
.empty-state {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
height: 100%;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 14px;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state .hint {
|
||||||
|
font-size: 12px;
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Scrollbar ───────────────────────────── */
|
||||||
|
::-webkit-scrollbar { width: 6px; height: 6px; }
|
||||||
|
::-webkit-scrollbar-track { background: transparent; }
|
||||||
|
::-webkit-scrollbar-thumb { background: var(--border); border-radius: 3px; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<!-- Top Bar -->
|
||||||
|
<div class="topbar">
|
||||||
|
<span class="topbar-title">Triple-C</span>
|
||||||
|
<span class="status-dot" id="statusDot"></span>
|
||||||
|
<select id="projectSelect" style="flex:1; max-width:240px;">
|
||||||
|
<option value="">Select project...</option>
|
||||||
|
</select>
|
||||||
|
<button class="btn-new" id="btnClaude" title="New Claude session">Claude</button>
|
||||||
|
<button class="btn-new" id="btnBash" title="New Bash session">Bash</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Tab Bar -->
|
||||||
|
<div class="tabbar" id="tabbar"></div>
|
||||||
|
|
||||||
|
<!-- Terminal Area -->
|
||||||
|
<div class="terminal-area" id="terminalArea">
|
||||||
|
<div class="empty-state" id="emptyState">
|
||||||
|
<div>Select a project and open a terminal session</div>
|
||||||
|
<div class="hint">Use the buttons above to start a Claude or Bash session</div>
|
||||||
|
</div>
|
||||||
|
<button class="scroll-bottom-btn" id="scrollBottomBtn" title="Scroll to bottom">↓</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Input Bar for mobile/tablet -->
|
||||||
|
<div class="input-bar" id="inputBar">
|
||||||
|
<input type="text" id="mobileInput" placeholder="Type here..."
|
||||||
|
autocomplete="off" autocorrect="off" autocapitalize="off" spellcheck="false"
|
||||||
|
enterkeyhint="send" inputmode="text">
|
||||||
|
<button class="key-btn" id="btnEnter">Enter</button>
|
||||||
|
<button class="key-btn" id="btnTab">Tab</button>
|
||||||
|
<button class="key-btn" id="btnCtrlC">^C</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
(function() {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
// ── State ──────────────────────────────────
|
||||||
|
const params = new URLSearchParams(window.location.search);
|
||||||
|
const TOKEN = params.get('token') || '';
|
||||||
|
let ws = null;
|
||||||
|
let reconnectTimer = null;
|
||||||
|
let sessions = {}; // { sessionId: { term, fitAddon, projectName, type, containerId } }
|
||||||
|
let activeSessionId = null;
|
||||||
|
|
||||||
|
// ── DOM refs ───────────────────────────────
|
||||||
|
const statusDot = document.getElementById('statusDot');
|
||||||
|
const projectSelect = document.getElementById('projectSelect');
|
||||||
|
const btnClaude = document.getElementById('btnClaude');
|
||||||
|
const btnBash = document.getElementById('btnBash');
|
||||||
|
const tabbar = document.getElementById('tabbar');
|
||||||
|
const terminalArea = document.getElementById('terminalArea');
|
||||||
|
const emptyState = document.getElementById('emptyState');
|
||||||
|
const mobileInput = document.getElementById('mobileInput');
|
||||||
|
const btnEnter = document.getElementById('btnEnter');
|
||||||
|
const btnTab = document.getElementById('btnTab');
|
||||||
|
const btnCtrlC = document.getElementById('btnCtrlC');
|
||||||
|
const scrollBottomBtn = document.getElementById('scrollBottomBtn');
|
||||||
|
|
||||||
|
// ── WebSocket ──────────────────────────────
|
||||||
|
function connect() {
|
||||||
|
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||||
|
const url = `${proto}//${location.host}/ws?token=${encodeURIComponent(TOKEN)}`;
|
||||||
|
ws = new WebSocket(url);
|
||||||
|
|
||||||
|
ws.onopen = () => {
|
||||||
|
statusDot.className = 'status-dot connected';
|
||||||
|
clearTimeout(reconnectTimer);
|
||||||
|
send({ type: 'list_projects' });
|
||||||
|
// Start keepalive
|
||||||
|
ws._pingInterval = setInterval(() => send({ type: 'ping' }), 30000);
|
||||||
|
};
|
||||||
|
|
||||||
|
ws.onmessage = (evt) => {
|
||||||
|
try {
|
||||||
|
const msg = JSON.parse(evt.data);
|
||||||
|
handleMessage(msg);
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Parse error:', e);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
ws.onclose = () => {
|
||||||
|
statusDot.className = 'status-dot reconnecting';
|
||||||
|
if (ws && ws._pingInterval) clearInterval(ws._pingInterval);
|
||||||
|
reconnectTimer = setTimeout(connect, 2000);
|
||||||
|
};
|
||||||
|
|
||||||
|
ws.onerror = () => {
|
||||||
|
ws.close();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function send(msg) {
|
||||||
|
if (ws && ws.readyState === WebSocket.OPEN) {
|
||||||
|
ws.send(JSON.stringify(msg));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Message handling ───────────────────────
|
||||||
|
function handleMessage(msg) {
|
||||||
|
switch (msg.type) {
|
||||||
|
case 'projects':
|
||||||
|
updateProjectList(msg.projects);
|
||||||
|
break;
|
||||||
|
case 'opened':
|
||||||
|
onSessionOpened(msg.session_id, msg.project_name);
|
||||||
|
break;
|
||||||
|
case 'output':
|
||||||
|
onSessionOutput(msg.session_id, msg.data);
|
||||||
|
break;
|
||||||
|
case 'exit':
|
||||||
|
onSessionExit(msg.session_id);
|
||||||
|
break;
|
||||||
|
case 'error':
|
||||||
|
console.error('Server error:', msg.message);
|
||||||
|
// Show in active terminal if available
|
||||||
|
if (activeSessionId && sessions[activeSessionId]) {
|
||||||
|
sessions[activeSessionId].term.writeln(`\r\n\x1b[31mError: ${msg.message}\x1b[0m`);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case 'pong':
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateProjectList(projects) {
|
||||||
|
const current = projectSelect.value;
|
||||||
|
projectSelect.innerHTML = '<option value="">Select project...</option>';
|
||||||
|
projects.forEach(p => {
|
||||||
|
const opt = document.createElement('option');
|
||||||
|
opt.value = p.id;
|
||||||
|
opt.textContent = `${p.name} (${p.status})`;
|
||||||
|
opt.disabled = p.status !== 'running';
|
||||||
|
projectSelect.appendChild(opt);
|
||||||
|
});
|
||||||
|
// Restore selection if still valid
|
||||||
|
if (current) projectSelect.value = current;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Session management ─────────────────────
|
||||||
|
let pendingSessionType = null;
|
||||||
|
|
||||||
|
function openSession(type) {
|
||||||
|
const projectId = projectSelect.value;
|
||||||
|
if (!projectId) {
|
||||||
|
alert('Please select a running project first.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
pendingSessionType = type;
|
||||||
|
send({
|
||||||
|
type: 'open',
|
||||||
|
project_id: projectId,
|
||||||
|
session_type: type,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function onSessionOpened(sessionId, projectName) {
|
||||||
|
const sessionType = pendingSessionType || 'claude';
|
||||||
|
pendingSessionType = null;
|
||||||
|
|
||||||
|
// Create terminal
|
||||||
|
const term = new Terminal({
|
||||||
|
theme: {
|
||||||
|
background: '#1a1b26',
|
||||||
|
foreground: '#c0caf5',
|
||||||
|
cursor: '#c0caf5',
|
||||||
|
selectionBackground: '#33467c',
|
||||||
|
black: '#15161e',
|
||||||
|
red: '#f7768e',
|
||||||
|
green: '#9ece6a',
|
||||||
|
yellow: '#e0af68',
|
||||||
|
blue: '#7aa2f7',
|
||||||
|
magenta: '#bb9af7',
|
||||||
|
cyan: '#7dcfff',
|
||||||
|
white: '#a9b1d6',
|
||||||
|
brightBlack: '#414868',
|
||||||
|
brightRed: '#f7768e',
|
||||||
|
brightGreen: '#9ece6a',
|
||||||
|
brightYellow: '#e0af68',
|
||||||
|
brightBlue: '#7aa2f7',
|
||||||
|
brightMagenta: '#bb9af7',
|
||||||
|
brightCyan: '#7dcfff',
|
||||||
|
brightWhite: '#c0caf5',
|
||||||
|
},
|
||||||
|
fontSize: 14,
|
||||||
|
fontFamily: "'Cascadia Code', 'Fira Code', 'JetBrains Mono', 'Menlo', monospace",
|
||||||
|
cursorBlink: true,
|
||||||
|
allowProposedApi: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const fitAddon = new FitAddon.FitAddon();
|
||||||
|
term.loadAddon(fitAddon);
|
||||||
|
|
||||||
|
const webLinksAddon = new WebLinksAddon.WebLinksAddon();
|
||||||
|
term.loadAddon(webLinksAddon);
|
||||||
|
|
||||||
|
// Create container div
|
||||||
|
const container = document.createElement('div');
|
||||||
|
container.className = 'terminal-container';
|
||||||
|
container.id = `term-${sessionId}`;
|
||||||
|
terminalArea.appendChild(container);
|
||||||
|
|
||||||
|
term.open(container);
|
||||||
|
fitAddon.fit();
|
||||||
|
|
||||||
|
// Send initial resize
|
||||||
|
send({
|
||||||
|
type: 'resize',
|
||||||
|
session_id: sessionId,
|
||||||
|
cols: term.cols,
|
||||||
|
rows: term.rows,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Handle user input
|
||||||
|
term.onData(data => {
|
||||||
|
const bytes = new TextEncoder().encode(data);
|
||||||
|
const b64 = btoa(String.fromCharCode(...bytes));
|
||||||
|
send({
|
||||||
|
type: 'input',
|
||||||
|
session_id: sessionId,
|
||||||
|
data: b64,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Track scroll position for scroll-to-bottom button
|
||||||
|
term.onScroll(() => updateScrollButton());
|
||||||
|
|
||||||
|
// Store session
|
||||||
|
sessions[sessionId] = { term, fitAddon, projectName, type: sessionType, container };
|
||||||
|
|
||||||
|
// Add tab and switch to it
|
||||||
|
addTab(sessionId, projectName, sessionType);
|
||||||
|
switchToSession(sessionId);
|
||||||
|
|
||||||
|
emptyState.style.display = 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
function onSessionOutput(sessionId, b64data) {
|
||||||
|
const session = sessions[sessionId];
|
||||||
|
if (!session) return;
|
||||||
|
const bytes = Uint8Array.from(atob(b64data), c => c.charCodeAt(0));
|
||||||
|
session.term.write(bytes);
|
||||||
|
// Update scroll button if this is the active session
|
||||||
|
if (sessionId === activeSessionId) updateScrollButton();
|
||||||
|
}
|
||||||
|
|
||||||
|
function onSessionExit(sessionId) {
|
||||||
|
const session = sessions[sessionId];
|
||||||
|
if (!session) return;
|
||||||
|
session.term.writeln('\r\n\x1b[90m[Session ended]\x1b[0m');
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeSession(sessionId) {
|
||||||
|
send({ type: 'close', session_id: sessionId });
|
||||||
|
removeSession(sessionId);
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeSession(sessionId) {
|
||||||
|
const session = sessions[sessionId];
|
||||||
|
if (!session) return;
|
||||||
|
|
||||||
|
session.term.dispose();
|
||||||
|
session.container.remove();
|
||||||
|
delete sessions[sessionId];
|
||||||
|
|
||||||
|
// Remove tab
|
||||||
|
const tab = document.getElementById(`tab-${sessionId}`);
|
||||||
|
if (tab) tab.remove();
|
||||||
|
|
||||||
|
// Switch to another session or show empty state
|
||||||
|
const remaining = Object.keys(sessions);
|
||||||
|
if (remaining.length > 0) {
|
||||||
|
switchToSession(remaining[remaining.length - 1]);
|
||||||
|
} else {
|
||||||
|
activeSessionId = null;
|
||||||
|
emptyState.style.display = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Tab bar ────────────────────────────────
|
||||||
|
function addTab(sessionId, projectName, sessionType) {
|
||||||
|
const tab = document.createElement('div');
|
||||||
|
tab.className = 'tab';
|
||||||
|
tab.id = `tab-${sessionId}`;
|
||||||
|
|
||||||
|
const label = document.createElement('span');
|
||||||
|
label.textContent = `${projectName} (${sessionType})`;
|
||||||
|
tab.appendChild(label);
|
||||||
|
|
||||||
|
const close = document.createElement('button');
|
||||||
|
close.className = 'tab-close';
|
||||||
|
close.textContent = '\u00d7';
|
||||||
|
close.onclick = (e) => { e.stopPropagation(); closeSession(sessionId); };
|
||||||
|
tab.appendChild(close);
|
||||||
|
|
||||||
|
tab.onclick = () => switchToSession(sessionId);
|
||||||
|
tabbar.appendChild(tab);
|
||||||
|
}
|
||||||
|
|
||||||
|
function switchToSession(sessionId) {
|
||||||
|
activeSessionId = sessionId;
|
||||||
|
|
||||||
|
// Update tab styles
|
||||||
|
document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
|
||||||
|
const tab = document.getElementById(`tab-${sessionId}`);
|
||||||
|
if (tab) tab.classList.add('active');
|
||||||
|
|
||||||
|
// Show/hide terminal containers
|
||||||
|
document.querySelectorAll('.terminal-container').forEach(c => c.classList.remove('active'));
|
||||||
|
const container = document.getElementById(`term-${sessionId}`);
|
||||||
|
if (container) {
|
||||||
|
container.classList.add('active');
|
||||||
|
const session = sessions[sessionId];
|
||||||
|
if (session) {
|
||||||
|
// Fit after making visible
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
session.fitAddon.fit();
|
||||||
|
session.term.focus();
|
||||||
|
updateScrollButton();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Resize handling ────────────────────────
|
||||||
|
function handleResize() {
|
||||||
|
if (activeSessionId && sessions[activeSessionId]) {
|
||||||
|
const session = sessions[activeSessionId];
|
||||||
|
session.fitAddon.fit();
|
||||||
|
send({
|
||||||
|
type: 'resize',
|
||||||
|
session_id: activeSessionId,
|
||||||
|
cols: session.term.cols,
|
||||||
|
rows: session.term.rows,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let resizeTimeout;
|
||||||
|
window.addEventListener('resize', () => {
|
||||||
|
clearTimeout(resizeTimeout);
|
||||||
|
resizeTimeout = setTimeout(handleResize, 100);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Send helper ─────────────────────────────
|
||||||
|
function sendTerminalInput(str) {
|
||||||
|
if (!activeSessionId) return;
|
||||||
|
const bytes = new TextEncoder().encode(str);
|
||||||
|
const b64 = btoa(String.fromCharCode(...bytes));
|
||||||
|
send({
|
||||||
|
type: 'input',
|
||||||
|
session_id: activeSessionId,
|
||||||
|
data: b64,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Input bar (mobile/tablet) ──────────────
|
||||||
|
// Send characters immediately, bypassing IME composition buffering.
|
||||||
|
// Clearing value on each input event cancels any active composition.
|
||||||
|
mobileInput.addEventListener('input', () => {
|
||||||
|
const val = mobileInput.value;
|
||||||
|
if (val) {
|
||||||
|
sendTerminalInput(val);
|
||||||
|
mobileInput.value = '';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Catch Enter in the input field itself
|
||||||
|
mobileInput.addEventListener('keydown', (e) => {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
e.preventDefault();
|
||||||
|
const val = mobileInput.value;
|
||||||
|
if (val) {
|
||||||
|
sendTerminalInput(val);
|
||||||
|
mobileInput.value = '';
|
||||||
|
}
|
||||||
|
sendTerminalInput('\r');
|
||||||
|
} else if (e.key === 'Tab') {
|
||||||
|
e.preventDefault();
|
||||||
|
sendTerminalInput('\t');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
btnEnter.onclick = () => { sendTerminalInput('\r'); mobileInput.focus(); };
|
||||||
|
btnTab.onclick = () => { sendTerminalInput('\t'); mobileInput.focus(); };
|
||||||
|
btnCtrlC.onclick = () => { sendTerminalInput('\x03'); mobileInput.focus(); };
|
||||||
|
|
||||||
|
// ── Scroll to bottom ──────────────────────
|
||||||
|
function updateScrollButton() {
|
||||||
|
if (!activeSessionId || !sessions[activeSessionId]) {
|
||||||
|
scrollBottomBtn.classList.remove('visible');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const term = sessions[activeSessionId].term;
|
||||||
|
const isAtBottom = term.buffer.active.viewportY >= term.buffer.active.baseY;
|
||||||
|
scrollBottomBtn.classList.toggle('visible', !isAtBottom);
|
||||||
|
}
|
||||||
|
|
||||||
|
scrollBottomBtn.onclick = () => {
|
||||||
|
if (activeSessionId && sessions[activeSessionId]) {
|
||||||
|
sessions[activeSessionId].term.scrollToBottom();
|
||||||
|
scrollBottomBtn.classList.remove('visible');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Event listeners ────────────────────────
|
||||||
|
btnClaude.onclick = () => openSession('claude');
|
||||||
|
btnBash.onclick = () => openSession('bash');
|
||||||
|
|
||||||
|
// ── Init ───────────────────────────────────
|
||||||
|
connect();
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
324
app/src-tauri/src/web_terminal/ws_handler.rs
Normal file
324
app/src-tauri/src/web_terminal/ws_handler.rs
Normal file
@@ -0,0 +1,324 @@
|
|||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use axum::extract::ws::{Message, WebSocket};
|
||||||
|
use base64::engine::general_purpose::STANDARD as BASE64;
|
||||||
|
use base64::Engine;
|
||||||
|
use futures_util::{SinkExt, StreamExt};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use tokio::sync::mpsc;
|
||||||
|
|
||||||
|
use crate::models::{Backend, BedrockAuthMethod, Project, ProjectStatus};
|
||||||
|
|
||||||
|
use super::server::WebTerminalState;
|
||||||
|
|
||||||
|
// ── Wire protocol types ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
#[serde(tag = "type", rename_all = "snake_case")]
|
||||||
|
enum ClientMessage {
|
||||||
|
ListProjects,
|
||||||
|
Open {
|
||||||
|
project_id: String,
|
||||||
|
session_type: Option<String>,
|
||||||
|
},
|
||||||
|
Input {
|
||||||
|
session_id: String,
|
||||||
|
data: String, // base64
|
||||||
|
},
|
||||||
|
Resize {
|
||||||
|
session_id: String,
|
||||||
|
cols: u16,
|
||||||
|
rows: u16,
|
||||||
|
},
|
||||||
|
Close {
|
||||||
|
session_id: String,
|
||||||
|
},
|
||||||
|
Ping,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
#[serde(tag = "type", rename_all = "snake_case")]
|
||||||
|
enum ServerMessage {
|
||||||
|
Projects {
|
||||||
|
projects: Vec<ProjectEntry>,
|
||||||
|
},
|
||||||
|
Opened {
|
||||||
|
session_id: String,
|
||||||
|
project_name: String,
|
||||||
|
},
|
||||||
|
Output {
|
||||||
|
session_id: String,
|
||||||
|
data: String, // base64
|
||||||
|
},
|
||||||
|
Exit {
|
||||||
|
session_id: String,
|
||||||
|
},
|
||||||
|
Error {
|
||||||
|
message: String,
|
||||||
|
},
|
||||||
|
Pong,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
struct ProjectEntry {
|
||||||
|
id: String,
|
||||||
|
name: String,
|
||||||
|
status: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Connection handler ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
pub async fn handle_connection(socket: WebSocket, state: Arc<WebTerminalState>) {
|
||||||
|
let (mut ws_tx, mut ws_rx) = socket.split();
|
||||||
|
|
||||||
|
// Channel for sending messages from session output tasks → WS writer
|
||||||
|
let (out_tx, mut out_rx) = mpsc::unbounded_channel::<ServerMessage>();
|
||||||
|
|
||||||
|
// Track session IDs owned by this connection for cleanup
|
||||||
|
let owned_sessions: Arc<tokio::sync::Mutex<Vec<String>>> =
|
||||||
|
Arc::new(tokio::sync::Mutex::new(Vec::new()));
|
||||||
|
|
||||||
|
// Writer task: serializes ServerMessages and sends as WS text frames
|
||||||
|
let writer_handle = tokio::spawn(async move {
|
||||||
|
while let Some(msg) = out_rx.recv().await {
|
||||||
|
if let Ok(json) = serde_json::to_string(&msg) {
|
||||||
|
if ws_tx.send(Message::Text(json.into())).await.is_err() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Reader loop: parse incoming messages and dispatch
|
||||||
|
while let Some(Ok(msg)) = ws_rx.next().await {
|
||||||
|
let text = match &msg {
|
||||||
|
Message::Text(t) => t.to_string(),
|
||||||
|
Message::Close(_) => break,
|
||||||
|
_ => continue,
|
||||||
|
};
|
||||||
|
|
||||||
|
let client_msg: ClientMessage = match serde_json::from_str(&text) {
|
||||||
|
Ok(m) => m,
|
||||||
|
Err(e) => {
|
||||||
|
let _ = out_tx.send(ServerMessage::Error {
|
||||||
|
message: format!("Invalid message: {}", e),
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
match client_msg {
|
||||||
|
ClientMessage::Ping => {
|
||||||
|
let _ = out_tx.send(ServerMessage::Pong);
|
||||||
|
}
|
||||||
|
|
||||||
|
ClientMessage::ListProjects => {
|
||||||
|
let projects = state.projects_store.list();
|
||||||
|
let entries: Vec<ProjectEntry> = projects
|
||||||
|
.into_iter()
|
||||||
|
.map(|p| ProjectEntry {
|
||||||
|
id: p.id,
|
||||||
|
name: p.name,
|
||||||
|
status: serde_json::to_value(&p.status)
|
||||||
|
.ok()
|
||||||
|
.and_then(|v| v.as_str().map(|s| s.to_string()))
|
||||||
|
.unwrap_or_else(|| "unknown".to_string()),
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
let _ = out_tx.send(ServerMessage::Projects { projects: entries });
|
||||||
|
}
|
||||||
|
|
||||||
|
ClientMessage::Open {
|
||||||
|
project_id,
|
||||||
|
session_type,
|
||||||
|
} => {
|
||||||
|
let result = handle_open(
|
||||||
|
&state,
|
||||||
|
&project_id,
|
||||||
|
session_type.as_deref(),
|
||||||
|
&out_tx,
|
||||||
|
&owned_sessions,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
if let Err(e) = result {
|
||||||
|
let _ = out_tx.send(ServerMessage::Error { message: e });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ClientMessage::Input { session_id, data } => {
|
||||||
|
match BASE64.decode(&data) {
|
||||||
|
Ok(bytes) => {
|
||||||
|
if let Err(e) = state.exec_manager.send_input(&session_id, bytes).await {
|
||||||
|
let _ = out_tx.send(ServerMessage::Error {
|
||||||
|
message: format!("Input error: {}", e),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
let _ = out_tx.send(ServerMessage::Error {
|
||||||
|
message: format!("Base64 decode error: {}", e),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ClientMessage::Resize {
|
||||||
|
session_id,
|
||||||
|
cols,
|
||||||
|
rows,
|
||||||
|
} => {
|
||||||
|
if let Err(e) = state.exec_manager.resize(&session_id, cols, rows).await {
|
||||||
|
let _ = out_tx.send(ServerMessage::Error {
|
||||||
|
message: format!("Resize error: {}", e),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ClientMessage::Close { session_id } => {
|
||||||
|
state.exec_manager.close_session(&session_id).await;
|
||||||
|
// Remove from owned list
|
||||||
|
owned_sessions
|
||||||
|
.lock()
|
||||||
|
.await
|
||||||
|
.retain(|id| id != &session_id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Connection closed — clean up all owned sessions
|
||||||
|
log::info!("Web terminal WebSocket disconnected, cleaning up sessions");
|
||||||
|
let sessions = owned_sessions.lock().await.clone();
|
||||||
|
for session_id in sessions {
|
||||||
|
state.exec_manager.close_session(&session_id).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
writer_handle.abort();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build the command for a terminal session, mirroring terminal_commands.rs logic.
|
||||||
|
fn build_terminal_cmd(project: &Project, settings_store: &crate::storage::settings_store::SettingsStore) -> Vec<String> {
|
||||||
|
let is_bedrock_profile = project.backend == Backend::Bedrock
|
||||||
|
&& project
|
||||||
|
.bedrock_config
|
||||||
|
.as_ref()
|
||||||
|
.map(|b| b.auth_method == BedrockAuthMethod::Profile)
|
||||||
|
.unwrap_or(false);
|
||||||
|
|
||||||
|
if !is_bedrock_profile {
|
||||||
|
let mut cmd = vec!["claude".to_string()];
|
||||||
|
if project.full_permissions {
|
||||||
|
cmd.push("--dangerously-skip-permissions".to_string());
|
||||||
|
}
|
||||||
|
return cmd;
|
||||||
|
}
|
||||||
|
|
||||||
|
let profile = project
|
||||||
|
.bedrock_config
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|b| b.aws_profile.clone())
|
||||||
|
.or_else(|| settings_store.get().global_aws.aws_profile.clone())
|
||||||
|
.unwrap_or_else(|| "default".to_string());
|
||||||
|
|
||||||
|
let claude_cmd = if project.full_permissions {
|
||||||
|
"exec claude --dangerously-skip-permissions"
|
||||||
|
} else {
|
||||||
|
"exec claude"
|
||||||
|
};
|
||||||
|
|
||||||
|
let script = format!(
|
||||||
|
r#"
|
||||||
|
echo "Validating AWS session for profile '{profile}'..."
|
||||||
|
if aws sts get-caller-identity --profile '{profile}' >/dev/null 2>&1; then
|
||||||
|
echo "AWS session valid."
|
||||||
|
else
|
||||||
|
echo "AWS session expired or invalid."
|
||||||
|
if aws configure get sso_start_url --profile '{profile}' >/dev/null 2>&1 || \
|
||||||
|
aws configure get sso_session --profile '{profile}' >/dev/null 2>&1; then
|
||||||
|
echo "Starting SSO login..."
|
||||||
|
echo ""
|
||||||
|
triple-c-sso-refresh
|
||||||
|
if [ $? -ne 0 ]; then
|
||||||
|
echo ""
|
||||||
|
echo "SSO login failed or was cancelled. Starting Claude anyway..."
|
||||||
|
echo "You may see authentication errors."
|
||||||
|
echo ""
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
echo "Profile '{profile}' does not use SSO. Check your AWS credentials."
|
||||||
|
echo "Starting Claude anyway..."
|
||||||
|
echo ""
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
{claude_cmd}
|
||||||
|
"#,
|
||||||
|
profile = profile,
|
||||||
|
claude_cmd = claude_cmd
|
||||||
|
);
|
||||||
|
|
||||||
|
vec!["bash".to_string(), "-c".to_string(), script]
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Open a new terminal session for a project.
|
||||||
|
async fn handle_open(
|
||||||
|
state: &WebTerminalState,
|
||||||
|
project_id: &str,
|
||||||
|
session_type: Option<&str>,
|
||||||
|
out_tx: &mpsc::UnboundedSender<ServerMessage>,
|
||||||
|
owned_sessions: &Arc<tokio::sync::Mutex<Vec<String>>>,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
let project = state
|
||||||
|
.projects_store
|
||||||
|
.get(project_id)
|
||||||
|
.ok_or_else(|| format!("Project {} not found", project_id))?;
|
||||||
|
|
||||||
|
if project.status != ProjectStatus::Running {
|
||||||
|
return Err(format!("Project '{}' is not running", project.name));
|
||||||
|
}
|
||||||
|
|
||||||
|
let container_id = project
|
||||||
|
.container_id
|
||||||
|
.as_ref()
|
||||||
|
.ok_or_else(|| "Container not running".to_string())?;
|
||||||
|
|
||||||
|
let cmd = match session_type {
|
||||||
|
Some("bash") => vec!["bash".to_string(), "-l".to_string()],
|
||||||
|
_ => build_terminal_cmd(&project, &state.settings_store),
|
||||||
|
};
|
||||||
|
|
||||||
|
let session_id = uuid::Uuid::new_v4().to_string();
|
||||||
|
let project_name = project.name.clone();
|
||||||
|
|
||||||
|
// Set up output routing through the WS channel
|
||||||
|
let out_tx_output = out_tx.clone();
|
||||||
|
let session_id_output = session_id.clone();
|
||||||
|
let on_output = move |data: Vec<u8>| {
|
||||||
|
let encoded = BASE64.encode(&data);
|
||||||
|
let _ = out_tx_output.send(ServerMessage::Output {
|
||||||
|
session_id: session_id_output.clone(),
|
||||||
|
data: encoded,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
let out_tx_exit = out_tx.clone();
|
||||||
|
let session_id_exit = session_id.clone();
|
||||||
|
let on_exit = Box::new(move || {
|
||||||
|
let _ = out_tx_exit.send(ServerMessage::Exit {
|
||||||
|
session_id: session_id_exit,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
state
|
||||||
|
.exec_manager
|
||||||
|
.create_session(container_id, &session_id, cmd, on_output, on_exit)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
// Track this session for cleanup on disconnect
|
||||||
|
owned_sessions.lock().await.push(session_id.clone());
|
||||||
|
|
||||||
|
let _ = out_tx.send(ServerMessage::Opened {
|
||||||
|
session_id,
|
||||||
|
project_name,
|
||||||
|
});
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
@@ -8,6 +8,7 @@ import EnvVarsModal from "../projects/EnvVarsModal";
|
|||||||
import { detectHostTimezone } from "../../lib/tauri-commands";
|
import { detectHostTimezone } from "../../lib/tauri-commands";
|
||||||
import type { EnvVar } from "../../lib/types";
|
import type { EnvVar } from "../../lib/types";
|
||||||
import Tooltip from "../ui/Tooltip";
|
import Tooltip from "../ui/Tooltip";
|
||||||
|
import WebTerminalSettings from "./WebTerminalSettings";
|
||||||
|
|
||||||
export default function SettingsPanel() {
|
export default function SettingsPanel() {
|
||||||
const { appSettings, saveSettings } = useSettings();
|
const { appSettings, saveSettings } = useSettings();
|
||||||
@@ -116,6 +117,9 @@ export default function SettingsPanel() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Web Terminal */}
|
||||||
|
<WebTerminalSettings />
|
||||||
|
|
||||||
{/* Updates section */}
|
{/* Updates section */}
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium mb-2">Updates<Tooltip text="Check for new versions of the Triple-C app and container image." /></label>
|
<label className="block text-sm font-medium mb-2">Updates<Tooltip text="Check for new versions of the Triple-C app and container image." /></label>
|
||||||
|
|||||||
128
app/src/components/settings/WebTerminalSettings.tsx
Normal file
128
app/src/components/settings/WebTerminalSettings.tsx
Normal file
@@ -0,0 +1,128 @@
|
|||||||
|
import { useState, useEffect } from "react";
|
||||||
|
import { startWebTerminal, stopWebTerminal, getWebTerminalStatus, regenerateWebTerminalToken } from "../../lib/tauri-commands";
|
||||||
|
import type { WebTerminalInfo } from "../../lib/types";
|
||||||
|
import Tooltip from "../ui/Tooltip";
|
||||||
|
|
||||||
|
export default function WebTerminalSettings() {
|
||||||
|
const [info, setInfo] = useState<WebTerminalInfo | null>(null);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [copied, setCopied] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
getWebTerminalStatus().then(setInfo).catch(console.error);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleToggle = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
if (info?.running) {
|
||||||
|
await stopWebTerminal();
|
||||||
|
const updated = await getWebTerminalStatus();
|
||||||
|
setInfo(updated);
|
||||||
|
} else {
|
||||||
|
const updated = await startWebTerminal();
|
||||||
|
setInfo(updated);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Web terminal toggle failed:", e);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRegenerate = async () => {
|
||||||
|
try {
|
||||||
|
const updated = await regenerateWebTerminalToken();
|
||||||
|
setInfo(updated);
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Token regeneration failed:", e);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCopyUrl = async () => {
|
||||||
|
if (info?.url) {
|
||||||
|
await navigator.clipboard.writeText(info.url);
|
||||||
|
setCopied(true);
|
||||||
|
setTimeout(() => setCopied(false), 2000);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCopyToken = async () => {
|
||||||
|
if (info?.access_token) {
|
||||||
|
await navigator.clipboard.writeText(info.access_token);
|
||||||
|
setCopied(true);
|
||||||
|
setTimeout(() => setCopied(false), 2000);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium mb-1">
|
||||||
|
Web Terminal
|
||||||
|
<Tooltip text="Access your terminals from a tablet or phone on the local network via a web browser." />
|
||||||
|
</label>
|
||||||
|
<p className="text-xs text-[var(--text-secondary)] mb-2">
|
||||||
|
Serves a browser-based terminal UI on your local network for remote access to running projects.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
{/* Toggle */}
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
onClick={handleToggle}
|
||||||
|
disabled={loading}
|
||||||
|
className={`px-2 py-0.5 text-xs rounded transition-colors ${
|
||||||
|
info?.running
|
||||||
|
? "bg-[var(--success)] text-white"
|
||||||
|
: "bg-[var(--bg-primary)] border border-[var(--border-color)] text-[var(--text-secondary)]"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{loading ? "..." : info?.running ? "ON" : "OFF"}
|
||||||
|
</button>
|
||||||
|
<span className="text-xs text-[var(--text-secondary)]">
|
||||||
|
{info?.running
|
||||||
|
? `Running on port ${info.port}`
|
||||||
|
: "Stopped"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* URL + Copy */}
|
||||||
|
{info?.running && info.url && (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<code className="text-xs text-[var(--accent)] bg-[var(--bg-primary)] px-2 py-1 rounded border border-[var(--border-color)] truncate flex-1">
|
||||||
|
{info.url}
|
||||||
|
</code>
|
||||||
|
<button
|
||||||
|
onClick={handleCopyUrl}
|
||||||
|
className="text-xs px-2 py-0.5 text-[var(--accent)] hover:text-[var(--accent-hover)] hover:bg-[var(--bg-primary)] rounded transition-colors"
|
||||||
|
>
|
||||||
|
{copied ? "Copied!" : "Copy URL"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Token */}
|
||||||
|
{info && (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-xs text-[var(--text-secondary)]">Token:</span>
|
||||||
|
<code className="text-xs text-[var(--text-primary)] bg-[var(--bg-primary)] px-2 py-0.5 rounded border border-[var(--border-color)] truncate max-w-[160px]">
|
||||||
|
{info.access_token ? `${info.access_token.slice(0, 12)}...` : "None"}
|
||||||
|
</code>
|
||||||
|
<button
|
||||||
|
onClick={handleCopyToken}
|
||||||
|
className="text-xs px-2 py-0.5 text-[var(--accent)] hover:text-[var(--accent-hover)] hover:bg-[var(--bg-primary)] rounded transition-colors"
|
||||||
|
>
|
||||||
|
Copy
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleRegenerate}
|
||||||
|
className="text-xs px-2 py-0.5 text-[var(--warning,#f59e0b)] hover:bg-[var(--bg-primary)] rounded transition-colors"
|
||||||
|
>
|
||||||
|
Regenerate
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -35,6 +35,12 @@ export default function TerminalView({ sessionId, active }: Props) {
|
|||||||
const [detectedUrl, setDetectedUrl] = useState<string | null>(null);
|
const [detectedUrl, setDetectedUrl] = useState<string | null>(null);
|
||||||
const [imagePasteMsg, setImagePasteMsg] = useState<string | null>(null);
|
const [imagePasteMsg, setImagePasteMsg] = useState<string | null>(null);
|
||||||
const [isAtBottom, setIsAtBottom] = useState(true);
|
const [isAtBottom, setIsAtBottom] = useState(true);
|
||||||
|
const [isAutoFollow, setIsAutoFollow] = useState(true);
|
||||||
|
const isAtBottomRef = useRef(true);
|
||||||
|
// Tracks user intent to follow output — only set to false by explicit user
|
||||||
|
// actions (mouse wheel up), not by xterm scroll events during writes.
|
||||||
|
const autoFollowRef = useRef(true);
|
||||||
|
const lastUserScrollTimeRef = useRef(0);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!containerRef.current) return;
|
if (!containerRef.current) return;
|
||||||
@@ -131,10 +137,40 @@ export default function TerminalView({ sessionId, active }: Props) {
|
|||||||
sendInput(sessionId, data);
|
sendInput(sessionId, data);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Track scroll position to show "Jump to Current" button
|
// Detect user-initiated scroll-up (mouse wheel) to pause auto-follow.
|
||||||
|
// Captured during capture phase so it fires before xterm's own handler.
|
||||||
|
const handleWheel = (e: WheelEvent) => {
|
||||||
|
lastUserScrollTimeRef.current = Date.now();
|
||||||
|
if (e.deltaY < 0) {
|
||||||
|
autoFollowRef.current = false;
|
||||||
|
setIsAutoFollow(false);
|
||||||
|
isAtBottomRef.current = false;
|
||||||
|
setIsAtBottom(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
containerRef.current.addEventListener("wheel", handleWheel, { capture: true, passive: true });
|
||||||
|
|
||||||
|
// Track scroll position to show "Jump to Current" button.
|
||||||
|
// Debounce state updates via rAF to avoid excessive re-renders during rapid output.
|
||||||
|
let scrollStateRafId: number | null = null;
|
||||||
const scrollDisposable = term.onScroll(() => {
|
const scrollDisposable = term.onScroll(() => {
|
||||||
const buf = term.buffer.active;
|
const buf = term.buffer.active;
|
||||||
setIsAtBottom(buf.viewportY >= buf.baseY);
|
const atBottom = buf.viewportY >= buf.baseY;
|
||||||
|
isAtBottomRef.current = atBottom;
|
||||||
|
|
||||||
|
// Re-enable auto-follow only when USER scrolls to bottom (not write-triggered)
|
||||||
|
const isUserScroll = (Date.now() - lastUserScrollTimeRef.current) < 300;
|
||||||
|
if (atBottom && isUserScroll && !autoFollowRef.current) {
|
||||||
|
autoFollowRef.current = true;
|
||||||
|
setIsAutoFollow(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (scrollStateRafId === null) {
|
||||||
|
scrollStateRafId = requestAnimationFrame(() => {
|
||||||
|
scrollStateRafId = null;
|
||||||
|
setIsAtBottom(isAtBottomRef.current);
|
||||||
|
});
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Track text selection to show copy hint in status bar
|
// Track text selection to show copy hint in status bar
|
||||||
@@ -187,7 +223,15 @@ export default function TerminalView({ sessionId, active }: Props) {
|
|||||||
|
|
||||||
const outputPromise = onOutput(sessionId, (data) => {
|
const outputPromise = onOutput(sessionId, (data) => {
|
||||||
if (aborted) return;
|
if (aborted) return;
|
||||||
term.write(data);
|
term.write(data, () => {
|
||||||
|
if (autoFollowRef.current) {
|
||||||
|
term.scrollToBottom();
|
||||||
|
if (!isAtBottomRef.current) {
|
||||||
|
isAtBottomRef.current = true;
|
||||||
|
setIsAtBottom(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
detector.feed(data);
|
detector.feed(data);
|
||||||
|
|
||||||
// Scan for SSO refresh marker in terminal output
|
// Scan for SSO refresh marker in terminal output
|
||||||
@@ -231,6 +275,9 @@ export default function TerminalView({ sessionId, active }: Props) {
|
|||||||
if (!containerRef.current || containerRef.current.offsetWidth === 0) return;
|
if (!containerRef.current || containerRef.current.offsetWidth === 0) return;
|
||||||
fitAddon.fit();
|
fitAddon.fit();
|
||||||
resize(sessionId, term.cols, term.rows);
|
resize(sessionId, term.cols, term.rows);
|
||||||
|
if (autoFollowRef.current) {
|
||||||
|
term.scrollToBottom();
|
||||||
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
resizeObserver.observe(containerRef.current);
|
resizeObserver.observe(containerRef.current);
|
||||||
@@ -246,9 +293,11 @@ export default function TerminalView({ sessionId, active }: Props) {
|
|||||||
scrollDisposable.dispose();
|
scrollDisposable.dispose();
|
||||||
selectionDisposable.dispose();
|
selectionDisposable.dispose();
|
||||||
setTerminalHasSelection(false);
|
setTerminalHasSelection(false);
|
||||||
|
containerRef.current?.removeEventListener("wheel", handleWheel, { capture: true });
|
||||||
containerRef.current?.removeEventListener("paste", handlePaste, { capture: true });
|
containerRef.current?.removeEventListener("paste", handlePaste, { capture: true });
|
||||||
outputPromise.then((fn) => fn?.());
|
outputPromise.then((fn) => fn?.());
|
||||||
exitPromise.then((fn) => fn?.());
|
exitPromise.then((fn) => fn?.());
|
||||||
|
if (scrollStateRafId !== null) cancelAnimationFrame(scrollStateRafId);
|
||||||
if (resizeRafId !== null) cancelAnimationFrame(resizeRafId);
|
if (resizeRafId !== null) cancelAnimationFrame(resizeRafId);
|
||||||
resizeObserver.disconnect();
|
resizeObserver.disconnect();
|
||||||
try { webglRef.current?.dispose(); } catch { /* may already be disposed */ }
|
try { webglRef.current?.dispose(); } catch { /* may already be disposed */ }
|
||||||
@@ -280,6 +329,9 @@ export default function TerminalView({ sessionId, active }: Props) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
fitRef.current?.fit();
|
fitRef.current?.fit();
|
||||||
|
if (autoFollowRef.current) {
|
||||||
|
term.scrollToBottom();
|
||||||
|
}
|
||||||
term.focus();
|
term.focus();
|
||||||
} else {
|
} else {
|
||||||
// Release WebGL context for inactive terminals
|
// Release WebGL context for inactive terminals
|
||||||
@@ -314,8 +366,30 @@ export default function TerminalView({ sessionId, active }: Props) {
|
|||||||
}, [detectedUrl]);
|
}, [detectedUrl]);
|
||||||
|
|
||||||
const handleScrollToBottom = useCallback(() => {
|
const handleScrollToBottom = useCallback(() => {
|
||||||
termRef.current?.scrollToBottom();
|
const term = termRef.current;
|
||||||
setIsAtBottom(true);
|
if (term) {
|
||||||
|
autoFollowRef.current = true;
|
||||||
|
setIsAutoFollow(true);
|
||||||
|
fitRef.current?.fit();
|
||||||
|
term.scrollToBottom();
|
||||||
|
isAtBottomRef.current = true;
|
||||||
|
setIsAtBottom(true);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleToggleAutoFollow = useCallback(() => {
|
||||||
|
const next = !autoFollowRef.current;
|
||||||
|
autoFollowRef.current = next;
|
||||||
|
setIsAutoFollow(next);
|
||||||
|
if (next) {
|
||||||
|
const term = termRef.current;
|
||||||
|
if (term) {
|
||||||
|
fitRef.current?.fit();
|
||||||
|
term.scrollToBottom();
|
||||||
|
isAtBottomRef.current = true;
|
||||||
|
setIsAtBottom(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -338,6 +412,19 @@ export default function TerminalView({ sessionId, active }: Props) {
|
|||||||
{imagePasteMsg}
|
{imagePasteMsg}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{/* Auto-follow toggle - top right */}
|
||||||
|
<button
|
||||||
|
onClick={handleToggleAutoFollow}
|
||||||
|
className={`absolute top-2 right-4 z-50 px-2 py-1 rounded text-[10px] font-medium border shadow-sm transition-colors cursor-pointer ${
|
||||||
|
isAutoFollow
|
||||||
|
? "bg-[#1a2332] text-[#3fb950] border-[#238636] hover:bg-[#1f2d3d]"
|
||||||
|
: "bg-[#1f2937] text-[#8b949e] border-[#30363d] hover:bg-[#2d3748]"
|
||||||
|
}`}
|
||||||
|
title={isAutoFollow ? "Auto-scrolling to latest output (click to pause)" : "Auto-scroll paused (click to resume)"}
|
||||||
|
>
|
||||||
|
{isAutoFollow ? "▼ Following" : "▽ Paused"}
|
||||||
|
</button>
|
||||||
|
{/* Jump to Current - bottom right, when scrolled up */}
|
||||||
{!isAtBottom && (
|
{!isAtBottom && (
|
||||||
<button
|
<button
|
||||||
onClick={handleScrollToBottom}
|
onClick={handleScrollToBottom}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { invoke } from "@tauri-apps/api/core";
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
import type { Project, ProjectPath, ContainerInfo, SiblingContainer, AppSettings, UpdateInfo, ImageUpdateInfo, McpServer, FileEntry } from "./types";
|
import type { Project, ProjectPath, ContainerInfo, SiblingContainer, AppSettings, UpdateInfo, ImageUpdateInfo, McpServer, FileEntry, WebTerminalInfo } from "./types";
|
||||||
|
|
||||||
// Docker
|
// Docker
|
||||||
export const checkDocker = () => invoke<boolean>("check_docker");
|
export const checkDocker = () => invoke<boolean>("check_docker");
|
||||||
@@ -88,3 +88,13 @@ export const checkImageUpdate = () =>
|
|||||||
|
|
||||||
// Help
|
// Help
|
||||||
export const getHelpContent = () => invoke<string>("get_help_content");
|
export const getHelpContent = () => invoke<string>("get_help_content");
|
||||||
|
|
||||||
|
// Web Terminal
|
||||||
|
export const startWebTerminal = () =>
|
||||||
|
invoke<WebTerminalInfo>("start_web_terminal");
|
||||||
|
export const stopWebTerminal = () =>
|
||||||
|
invoke<void>("stop_web_terminal");
|
||||||
|
export const getWebTerminalStatus = () =>
|
||||||
|
invoke<WebTerminalInfo>("get_web_terminal_status");
|
||||||
|
export const regenerateWebTerminalToken = () =>
|
||||||
|
invoke<WebTerminalInfo>("regenerate_web_terminal_token");
|
||||||
|
|||||||
@@ -118,6 +118,21 @@ export interface AppSettings {
|
|||||||
timezone: string | null;
|
timezone: string | null;
|
||||||
default_microphone: string | null;
|
default_microphone: string | null;
|
||||||
dismissed_image_digest: string | null;
|
dismissed_image_digest: string | null;
|
||||||
|
web_terminal: WebTerminalSettings;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WebTerminalSettings {
|
||||||
|
enabled: boolean;
|
||||||
|
port: number;
|
||||||
|
access_token: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WebTerminalInfo {
|
||||||
|
running: boolean;
|
||||||
|
port: number;
|
||||||
|
access_token: string;
|
||||||
|
local_ip: string | null;
|
||||||
|
url: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UpdateInfo {
|
export interface UpdateInfo {
|
||||||
|
|||||||
Reference in New Issue
Block a user