diff --git a/.gitignore b/.gitignore index c0ae903..394af76 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,7 @@ dist/ src-tauri/gen/ src-tauri/target/ bin-work/ +cpa-core/ *.log *.tmp diff --git a/agent_docs/Instructions for running CLIProxyAPI.md b/agent_docs/Instructions for running CLIProxyAPI.md new file mode 100644 index 0000000..089656e --- /dev/null +++ b/agent_docs/Instructions for running CLIProxyAPI.md @@ -0,0 +1,8 @@ +CLIProxyAPI is the core file of this project. After downloading it from the GitHub repository, you can simply extract it locally and use it directly. + +Latest release page: https://github.com/router-for-me/CLIProxyAPI/releases/latest + +After extraction, you will find the cli-proxy-api binary file and the config.example.yaml configuration template. + +You need to copy config.example.yaml to config.yaml and modify the DIY configuration before running it. Of course, the default configuration can also run directly. + diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index c69a4f5..2f6d437 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -47,6 +47,15 @@ version = "1.0.103" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2a4385e2e34eb35d6b3efe798b9eb88096925d87726c0798709bf56d9ed84af3" +[[package]] +name = "arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1" +dependencies = [ + "derive_arbitrary", +] + [[package]] name = "atk" version = "0.18.2" @@ -309,6 +318,23 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +[[package]] +name = "cfg_aliases" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" + +[[package]] +name = "chacha20" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "rand_core", +] + [[package]] name = "chrono" version = "0.4.45" @@ -385,8 +411,18 @@ dependencies = [ name = "cpa-gui" version = "0.1.0" dependencies = [ + "flate2", + "futures-util", + "reqwest 0.12.28", + "serde", + "serde_json", + "sha2", + "tar", "tauri", "tauri-build", + "tokio", + "tokio-util", + "zip", ] [[package]] @@ -398,6 +434,15 @@ dependencies = [ "libc", ] +[[package]] +name = "cpufeatures" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" +dependencies = [ + "libc", +] + [[package]] name = "crc32fast" version = "1.5.0" @@ -525,6 +570,17 @@ dependencies = [ "serde_core", ] +[[package]] +name = "derive_arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + [[package]] name = "derive_more" version = "2.1.1" @@ -698,7 +754,7 @@ dependencies = [ "cc", "memchr", "rustc_version", - "toml 1.1.2+spec-1.1.0", + "toml 1.1.3+spec-1.1.0", "vswhom", "winreg", ] @@ -726,6 +782,16 @@ dependencies = [ "typeid", ] +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + [[package]] name = "fastrand" version = "2.4.1" @@ -751,6 +817,16 @@ dependencies = [ "rustc_version", ] +[[package]] +name = "filetime" +version = "0.2.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c287a33c7f0a620c38e641e7f60827713987b3c0f26e8ddc9462cc69cf75759" +dependencies = [ + "cfg-if", + "libc", +] + [[package]] name = "find-msvc-tools" version = "0.1.9" @@ -1002,8 +1078,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" dependencies = [ "cfg-if", + "js-sys", "libc", "wasi", + "wasm-bindgen", ] [[package]] @@ -1025,8 +1103,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" dependencies = [ "cfg-if", + "js-sys", "libc", "r-efi 6.0.0", + "rand_core", + "wasm-bindgen", ] [[package]] @@ -1276,6 +1357,22 @@ dependencies = [ "want", ] +[[package]] +name = "hyper-rustls" +version = "0.27.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "tokio", + "tokio-rustls", + "tower-service", + "webpki-roots", +] + [[package]] name = "hyper-util" version = "0.1.20" @@ -1655,6 +1752,12 @@ dependencies = [ "libc", ] +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + [[package]] name = "litemap" version = "0.8.2" @@ -1676,6 +1779,12 @@ version = "0.4.33" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" +[[package]] +name = "lru-slab" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" + [[package]] name = "markup5ever" version = "0.38.0" @@ -2229,7 +2338,7 @@ version = "3.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" dependencies = [ - "toml_edit 0.25.12+spec-1.1.0", + "toml_edit 0.25.13+spec-1.1.0", ] [[package]] @@ -2274,6 +2383,62 @@ dependencies = [ "memchr", ] +[[package]] +name = "quinn" +version = "0.11.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c1a41e437b6bbd489372cd4971de128e85c855f56c57f283d20ff016cf7c0a8" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls", + "socket2", + "thiserror 2.0.18", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f4bfc015262b9df63c8845072ce59068853ff5872180c2ce2f13038b970e560" +dependencies = [ + "bytes", + "getrandom 0.4.3", + "lru-slab", + "rand", + "rand_pcg", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "slab", + "thiserror 2.0.18", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35a133f956daabe89a61a685c2649f13d82d5aa4bd5d12d1277e1072a21c0694" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2", + "tracing", + "windows-sys 0.61.2", +] + [[package]] name = "quote" version = "1.0.46" @@ -2295,6 +2460,32 @@ version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" +[[package]] +name = "rand" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" +dependencies = [ + "chacha20", + "getrandom 0.4.3", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + +[[package]] +name = "rand_pcg" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a" +dependencies = [ + "rand_core", +] + [[package]] name = "raw-window-handle" version = "0.6.2" @@ -2370,6 +2561,47 @@ version = "0.8.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" +[[package]] +name = "reqwest" +version = "0.12.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" +dependencies = [ + "base64 0.22.1", + "bytes", + "futures-core", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tokio-util", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-streams 0.4.2", + "web-sys", + "webpki-roots", +] + [[package]] name = "reqwest" version = "0.13.4" @@ -2400,10 +2632,24 @@ dependencies = [ "url", "wasm-bindgen", "wasm-bindgen-futures", - "wasm-streams", + "wasm-streams 0.5.0", "web-sys", ] +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + [[package]] name = "rustc-hash" version = "2.1.3" @@ -2419,12 +2665,66 @@ dependencies = [ "semver", ] +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags 2.13.0", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls" +version = "0.23.42" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c54fcab019b409d04215d3a17cb438fd7fbf192ee61461f20f4fe18704bc138" +dependencies = [ + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pki-types" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "764899a24af3980067ee14bc143654f297b22eaebfe3c7b6b211920a5a59b046" +dependencies = [ + "web-time", + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + [[package]] name = "rustversion" version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + [[package]] name = "same-file" version = "1.0.6" @@ -2615,6 +2915,18 @@ dependencies = [ "serde_core", ] +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + [[package]] name = "serde_with" version = "3.21.0" @@ -2685,7 +2997,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" dependencies = [ "cfg-if", - "cpufeatures", + "cpufeatures 0.2.17", "digest", ] @@ -2813,6 +3125,12 @@ version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + [[package]] name = "swift-rs" version = "1.0.7" @@ -2929,6 +3247,17 @@ dependencies = [ "syn 2.0.118", ] +[[package]] +name = "tar" +version = "0.4.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f6221d9a6003c78398e3b239969f352578258df48c8eb051caadae0015bc840" +dependencies = [ + "filetime", + "libc", + "xattr", +] + [[package]] name = "target-lexicon" version = "0.12.16" @@ -2965,7 +3294,7 @@ dependencies = [ "percent-encoding", "plist", "raw-window-handle", - "reqwest", + "reqwest 0.13.4", "serde", "serde_json", "serde_repr", @@ -3130,7 +3459,7 @@ dependencies = [ "serde_with", "swift-rs", "thiserror 2.0.18", - "toml 1.1.2+spec-1.1.0", + "toml 1.1.3+spec-1.1.0", "url", "urlpattern", "uuid", @@ -3145,7 +3474,7 @@ checksum = "cc65d45c68858bfe420dd29e834b5d15dbecf8a07a8a16cf4d532c7b1f69d4b6" dependencies = [ "dunce", "embed-resource", - "toml 1.1.2+spec-1.1.0", + "toml 1.1.3+spec-1.1.0", ] [[package]] @@ -3263,9 +3592,31 @@ dependencies = [ "mio", "pin-project-lite", "socket2", + "tokio-macros", "windows-sys 0.61.2", ] +[[package]] +name = "tokio-macros" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls", + "tokio", +] + [[package]] name = "tokio-util" version = "0.7.18" @@ -3308,9 +3659,9 @@ dependencies = [ [[package]] name = "toml" -version = "1.1.2+spec-1.1.0" +version = "1.1.3+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "81f3d15e84cbcd896376e6730314d59fb5a87f31e4b038454184435cd57defee" +checksum = "53c96ecdfa941c8fc4fcaed14f99ada8ebed502eef533015095a07e3301d4c3c" dependencies = [ "indexmap 2.14.0", "serde_core", @@ -3374,9 +3725,9 @@ dependencies = [ [[package]] name = "toml_edit" -version = "0.25.12+spec-1.1.0" +version = "0.25.13+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d2153edc6955a6c354fad8f5efd38b6a8769bdccf9fe50f8e1329f81b0baa5d7" +checksum = "6975367e4d2ef766d86af01ffad14b622fecc8d4357a998fbc4deb6e9bacaf9b" dependencies = [ "indexmap 2.14.0", "toml_datetime 1.1.1+spec-1.1.0", @@ -3395,9 +3746,9 @@ dependencies = [ [[package]] name = "toml_writer" -version = "1.1.1+spec-1.1.0" +version = "1.1.2+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "756daf9b1013ebe47a8776667b466417e2d4c5679d441c26230efd9ef78692db" +checksum = "7d56353a2a665ad0f41a421187180aab746c8c325620617ad883a99a1cbe66d2" [[package]] name = "tower" @@ -3556,6 +3907,12 @@ version = "1.13.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + [[package]] name = "url" version = "2.5.8" @@ -3720,6 +4077,19 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "wasm-streams" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15053d8d85c7eccdbefef60f06769760a563c7f0a9d6902a13d35c7800b0ad65" +dependencies = [ + "futures-util", + "js-sys", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + [[package]] name = "wasm-streams" version = "0.5.0" @@ -3743,6 +4113,16 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + [[package]] name = "web_atoms" version = "0.2.5" @@ -3799,6 +4179,15 @@ dependencies = [ "system-deps", ] +[[package]] +name = "webpki-roots" +version = "1.0.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf85cb06032201fa7c6f829d7db5a7e5aa45bcc0655327713065f6f0576731bf" +dependencies = [ + "rustls-pki-types", +] + [[package]] name = "webview2-com" version = "0.38.2" @@ -4029,6 +4418,15 @@ dependencies = [ "windows-targets 0.42.2", ] +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + [[package]] name = "windows-sys" version = "0.59.0" @@ -4297,6 +4695,16 @@ dependencies = [ "pkg-config", ] +[[package]] +name = "xattr" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156" +dependencies = [ + "libc", + "rustix", +] + [[package]] name = "yoke" version = "0.8.3" @@ -4341,6 +4749,12 @@ dependencies = [ "synstructure", ] +[[package]] +name = "zeroize" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" + [[package]] name = "zerotrie" version = "0.2.4" @@ -4374,8 +4788,37 @@ dependencies = [ "syn 2.0.118", ] +[[package]] +name = "zip" +version = "2.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fabe6324e908f85a1c52063ce7aa26b68dcb7eb6dbc83a2d148403c9bc3eba50" +dependencies = [ + "arbitrary", + "crc32fast", + "crossbeam-utils", + "displaydoc", + "flate2", + "indexmap 2.14.0", + "memchr", + "thiserror 2.0.18", + "zopfli", +] + [[package]] name = "zmij" version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" + +[[package]] +name = "zopfli" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f05cd8797d63865425ff89b5c4a48804f35ba0ce8d125800027ad6017d2b5249" +dependencies = [ + "bumpalo", + "crc32fast", + "log", + "simd-adler32", +] diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 1316651..7a2f72a 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -9,4 +9,14 @@ edition = "2021" tauri-build = { version = "2", features = [] } [dependencies] +flate2 = "1" +futures-util = "0.3" +reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls", "stream"] } +serde = { version = "1", features = ["derive"] } +serde_json = "1" +sha2 = "0.10" +tar = "0.4" tauri = { version = "2", features = [] } +tokio = { version = "1", features = ["macros", "sync", "time"] } +tokio-util = "0.7" +zip = { version = "2", default-features = false, features = ["deflate"] } diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json new file mode 100644 index 0000000..467363e --- /dev/null +++ b/src-tauri/capabilities/default.json @@ -0,0 +1,7 @@ +{ + "$schema": "../gen/schemas/desktop-schema.json", + "identifier": "default", + "description": "Default permissions for the main CPA GUI window.", + "windows": ["main"], + "permissions": ["core:event:allow-listen", "core:event:allow-unlisten"] +} diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index 43f5b0d..38a62a7 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -1,13 +1,1242 @@ #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] +use flate2::read::GzDecoder; +use futures_util::StreamExt; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use std::{ + env, fs, + fs::File, + io::{self, Write}, + path::{Component, Path, PathBuf}, + process::{Child, Command, Stdio}, + sync::Mutex, + thread, + time::{Duration, SystemTime, UNIX_EPOCH}, +}; +use tar::Archive; +use tauri::Emitter; +use tokio_util::sync::CancellationToken; +use zip::ZipArchive; + +const RELEASE_API_URL: &str = + "https://api.github.com/repos/router-for-me/CLIProxyAPI/releases/latest"; +const CORE_INSTALL_PROGRESS_EVENT: &str = "core-install-progress"; +const CORE_METADATA_FILE: &str = "cpa-gui-meta.json"; +const CORE_CONFIG_FILE: &str = "config.yaml"; +const CORE_EXAMPLE_CONFIG_FILE: &str = "config.example.yaml"; +const USER_AGENT: &str = "CPA-GUI"; + +#[derive(Default)] +struct CoreDownloadState { + inner: Mutex, +} + +#[derive(Default)] +struct CoreDownloadInner { + running: bool, + token: Option, + task: CoreInstallTask, +} + +#[derive(Default)] +struct CoreProcessState { + child: Mutex>, +} + +#[derive(Clone, Serialize)] +#[serde(rename_all = "camelCase")] +struct CorePlatform { + os: String, + arch: String, + asset_os: String, + asset_arch: String, + archive_kind: String, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct CoreStatus { + installed: bool, + running: bool, + managed: bool, + process_id: Option, + current_version: Option, + install_dir: String, + binary_path: Option, + message: String, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct CoreLatest { + version: String, + asset_name: String, +} + +#[derive(Clone, Serialize)] +#[serde(rename_all = "camelCase")] +struct CoreInstallResult { + version: String, + asset_name: String, + install_dir: String, + binary_path: Option, +} + +#[derive(Clone, Serialize)] +#[serde(rename_all = "camelCase")] +struct CoreInstallTask { + running: bool, + cancellable: bool, + phase: String, + downloaded: u64, + total: Option, + percent: Option, + message: Option, + result: Option, +} + +impl Default for CoreInstallTask { + fn default() -> Self { + Self { + running: false, + cancellable: false, + phase: "空闲".to_string(), + downloaded: 0, + total: None, + percent: None, + message: None, + result: None, + } + } +} + +#[derive(Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +struct CoreMetadata { + version: String, + asset_name: String, + installed_at_unix: u64, +} + +struct DownloadedArchive { + size: u64, + sha256: String, +} + +impl CoreDownloadState { + fn start(&self, token: CancellationToken, version: Option) -> Result<(), String> { + let mut inner = self + .inner + .lock() + .map_err(|_| "内核安装状态锁已损坏".to_string())?; + + if inner.running { + return Err("已有内核安装任务正在运行".to_string()); + } + + inner.running = true; + inner.token = Some(token); + inner.task = CoreInstallTask { + running: true, + cancellable: true, + phase: version + .map(|version| format!("准备安装 {version}")) + .unwrap_or_else(|| "准备安装最新版".to_string()), + downloaded: 0, + total: None, + percent: None, + message: None, + result: None, + }; + + Ok(()) + } + + fn cancel(&self) { + if let Ok(inner) = self.inner.lock() { + if let Some(token) = &inner.token { + token.cancel(); + } + } + } + + fn snapshot(&self) -> CoreInstallTask { + self.inner + .lock() + .map(|inner| inner.task.clone()) + .unwrap_or_default() + } + + fn progress( + &self, + window: &tauri::Window, + phase: &str, + downloaded: u64, + total: Option, + cancellable: bool, + ) { + let percent = total + .filter(|total| *total > 0) + .map(|total| downloaded as f64 * 100.0 / total as f64); + + let task = { + let Ok(mut inner) = self.inner.lock() else { + return; + }; + + inner.task.running = inner.running; + inner.task.cancellable = cancellable; + inner.task.phase = phase.to_string(); + inner.task.downloaded = downloaded; + inner.task.total = total; + inner.task.percent = percent; + inner.task.clone() + }; + + let _ = window.emit(CORE_INSTALL_PROGRESS_EVENT, task); + } + + fn finish(&self, window: &tauri::Window, result: Result) { + let task = { + let Ok(mut inner) = self.inner.lock() else { + return; + }; + + inner.running = false; + inner.token = None; + inner.task.running = false; + inner.task.cancellable = false; + + match result { + Ok(result) => { + inner.task.phase = "安装完成".to_string(); + inner.task.downloaded = 1; + inner.task.total = Some(1); + inner.task.percent = Some(100.0); + inner.task.message = Some(format!("{} 安装完成", result.version)); + inner.task.result = Some(result); + } + Err(error) => { + inner.task.phase = if error.contains("取消") { + "已取消".to_string() + } else { + "安装失败".to_string() + }; + inner.task.message = Some(error); + inner.task.result = None; + } + } + + inner.task.clone() + }; + + let _ = window.emit(CORE_INSTALL_PROGRESS_EVENT, task); + } +} + +impl CoreProcessState { + fn managed_pid(&self) -> Option { + let Ok(mut child) = self.child.lock() else { + return None; + }; + + let Some(process) = child.as_mut() else { + return None; + }; + + match process.try_wait() { + Ok(None) => Some(process.id()), + Ok(Some(_)) | Err(_) => { + *child = None; + None + } + } + } + + fn take_child(&self) -> Option { + self.child.lock().ok().and_then(|mut child| child.take()) + } + + fn store_child(&self, child: Child) -> Result { + let pid = child.id(); + let mut managed_child = self + .child + .lock() + .map_err(|_| "内核进程状态锁已损坏".to_string())?; + *managed_child = Some(child); + + Ok(pid) + } +} + +#[derive(Deserialize)] +struct GithubRelease { + tag_name: String, + assets: Vec, +} + +#[derive(Deserialize)] +struct GithubAsset { + name: String, + browser_download_url: String, + size: Option, + digest: Option, +} + #[tauri::command] fn health_check() -> &'static str { "CPA GUI Rust backend is ready" } +#[tauri::command] +fn detect_core_platform() -> Result { + current_core_platform() +} + +#[tauri::command] +fn get_core_status( + process_state: tauri::State<'_, CoreProcessState>, +) -> Result { + current_core_status(Some(process_state.inner())) +} + +#[tauri::command] +async fn check_latest_core() -> Result { + let platform = current_core_platform()?; + let client = http_client()?; + let release = fetch_release(&client, None).await?; + let asset = select_release_asset(&release, &platform)?; + + Ok(CoreLatest { + version: normalize_version(&release.tag_name), + asset_name: asset.name.clone(), + }) +} + +#[tauri::command] +fn cancel_core_install(state: tauri::State<'_, CoreDownloadState>) { + state.cancel(); +} + +#[tauri::command] +fn get_core_install_task(state: tauri::State<'_, CoreDownloadState>) -> CoreInstallTask { + state.snapshot() +} + +#[tauri::command] +async fn install_core_version( + window: tauri::Window, + state: tauri::State<'_, CoreDownloadState>, + version: Option, +) -> Result { + let token = CancellationToken::new(); + state.start(token.clone(), version.clone())?; + let result = install_core_version_inner(&window, state.inner(), token, version).await; + if result.is_err() { + let _ = cleanup_core_work_dirs(); + } + state.finish(&window, result.clone()); + + result +} + +#[tauri::command] +fn start_core_process( + process_state: tauri::State<'_, CoreProcessState>, +) -> Result { + start_core_process_inner(process_state.inner())?; + current_core_status(Some(process_state.inner())) +} + +#[tauri::command] +fn stop_core_process( + process_state: tauri::State<'_, CoreProcessState>, +) -> Result { + stop_core_process_inner(process_state.inner())?; + current_core_status(Some(process_state.inner())) +} + +#[tauri::command] +fn restart_core_process( + process_state: tauri::State<'_, CoreProcessState>, +) -> Result { + let _ = stop_core_process_inner(process_state.inner()); + start_core_process_inner(process_state.inner())?; + current_core_status(Some(process_state.inner())) +} + +async fn install_core_version_inner( + window: &tauri::Window, + state: &CoreDownloadState, + token: CancellationToken, + version: Option, +) -> Result { + let platform = current_core_platform()?; + let client = http_client()?; + state.progress(window, "检查版本", 0, None, true); + let release = fetch_release_cancelable(&client, version.as_deref(), &token).await?; + let asset = select_release_asset(&release, &platform)?; + + let install_dir = core_install_dir()?; + let base_dir = core_base_dir()?; + let staging_dir = base_dir.join("cpa-core.staging"); + let backup_dir = base_dir.join("cpa-core.backup"); + let download_dir = base_dir.join("cpa-core.download"); + + if current_core_status(None)?.running { + return Err("CPA 内核正在运行,请先停止后再安装或更新".to_string()); + } + + reset_dir(&staging_dir)?; + reset_dir(&download_dir)?; + + let archive_file_name = Path::new(&asset.name) + .file_name() + .and_then(|file_name| file_name.to_str()) + .ok_or_else(|| format!("非法 asset 文件名: {}", asset.name))?; + let archive_path = download_dir.join(archive_file_name); + + let downloaded = download_asset( + &client, + &asset.browser_download_url, + &archive_path, + asset.size, + asset.digest.as_deref(), + window, + state, + &token, + ) + .await?; + validate_downloaded_asset(asset, &downloaded)?; + + ensure_not_cancelled(&token, Some(&archive_path))?; + state.progress( + window, + "解压中", + downloaded.size, + Some(downloaded.size), + false, + ); + match platform.archive_kind.as_str() { + "tar.gz" => extract_tar_gz(&archive_path, &staging_dir)?, + "zip" => extract_zip(&archive_path, &staging_dir)?, + other => return Err(format!("不支持的压缩包类型: {other}")), + } + ensure_not_cancelled(&token, Some(&archive_path))?; + + let binary_path = find_core_binary(&staging_dir) + .ok_or_else(|| "解压后未找到 CPA 内核二进制文件".to_string())?; + let binary_relative_path = binary_path + .strip_prefix(&staging_dir) + .map_err(|err| format!("计算内核二进制相对路径失败: {err}"))? + .to_path_buf(); + write_core_metadata( + &staging_dir, + &CoreMetadata { + version: normalize_version(&release.tag_name), + asset_name: asset.name.clone(), + installed_at_unix: unix_now(), + }, + )?; + + replace_install_dir(&install_dir, &staging_dir, &backup_dir)?; + let _ = fs::remove_dir_all(&download_dir); + + Ok(CoreInstallResult { + version: normalize_version(&release.tag_name), + asset_name: asset.name.clone(), + install_dir: path_to_string(&install_dir), + binary_path: Some(path_to_string(&install_dir.join(binary_relative_path))), + }) +} + +async fn fetch_release( + client: &reqwest::Client, + version: Option<&str>, +) -> Result { + let url = version.map_or_else( + || RELEASE_API_URL.to_string(), + |version| { + format!( + "https://api.github.com/repos/router-for-me/CLIProxyAPI/releases/tags/{}", + normalize_version(version) + ) + }, + ); + + client + .get(url) + .header(reqwest::header::USER_AGENT, USER_AGENT) + .send() + .await + .map_err(|err| format!("GitHub 请求失败: {err}"))? + .error_for_status() + .map_err(|err| format!("GitHub 返回错误状态: {err}"))? + .json::() + .await + .map_err(|err| format!("解析 GitHub release 失败: {err}")) +} + +async fn fetch_release_cancelable( + client: &reqwest::Client, + version: Option<&str>, + token: &CancellationToken, +) -> Result { + tokio::select! { + result = fetch_release(client, version) => result, + _ = token.cancelled() => Err("已取消下载".to_string()), + } +} + +fn http_client() -> Result { + reqwest::Client::builder() + .connect_timeout(Duration::from_secs(15)) + .read_timeout(Duration::from_secs(30)) + .timeout(Duration::from_secs(600)) + .build() + .map_err(|err| format!("创建 HTTP 客户端失败: {err}")) +} + +fn select_release_asset<'a>( + release: &'a GithubRelease, + platform: &CorePlatform, +) -> Result<&'a GithubAsset, String> { + let version = normalize_version(&release.tag_name); + let version = version.trim_start_matches('v'); + let expected_name = format!( + "CLIProxyAPI_{}_{}_{}.{}", + version, platform.asset_os, platform.asset_arch, platform.archive_kind + ); + let mut matches = release + .assets + .iter() + .filter(|asset| asset.name == expected_name && !asset.name.contains("_no-plugin")); + let asset = matches + .next() + .ok_or_else(|| format!("未找到匹配当前平台的 release asset: {expected_name}"))?; + + if matches.next().is_some() { + return Err(format!("找到多个匹配的 release asset: {expected_name}")); + } + + Ok(asset) +} + +async fn download_asset( + client: &reqwest::Client, + url: &str, + archive_path: &Path, + expected_total: Option, + expected_digest: Option<&str>, + window: &tauri::Window, + state: &CoreDownloadState, + token: &CancellationToken, +) -> Result { + let result = download_asset_inner( + client, + url, + archive_path, + expected_total, + expected_digest, + window, + state, + token, + ) + .await; + if result.is_err() { + let _ = fs::remove_file(archive_path); + } + + result +} + +async fn download_asset_inner( + client: &reqwest::Client, + url: &str, + archive_path: &Path, + expected_total: Option, + expected_digest: Option<&str>, + window: &tauri::Window, + state: &CoreDownloadState, + token: &CancellationToken, +) -> Result { + state.progress(window, "准备下载", 0, expected_total, true); + ensure_not_cancelled(token, Some(archive_path))?; + + let request = client + .get(url) + .header(reqwest::header::USER_AGENT, USER_AGENT) + .send(); + let response = tokio::select! { + response = request => response.map_err(|err| format!("下载内核压缩包失败: {err}"))?, + _ = token.cancelled() => return Err("已取消下载".to_string()), + } + .error_for_status() + .map_err(|err| format!("下载地址返回错误状态: {err}"))?; + let total = expected_total.or_else(|| response.content_length()); + let mut stream = response.bytes_stream(); + let mut file = + File::create(archive_path).map_err(|err| format!("创建内核压缩包失败: {err}"))?; + let mut downloaded = 0_u64; + let mut hasher = Sha256::new(); + + while let Some(chunk) = tokio::select! { + chunk = stream.next() => chunk, + _ = token.cancelled() => return Err("已取消下载".to_string()), + } { + ensure_not_cancelled(token, Some(archive_path))?; + + let chunk = chunk.map_err(|err| format!("读取下载数据失败: {err}"))?; + file.write_all(&chunk) + .map_err(|err| format!("保存下载数据失败: {err}"))?; + hasher.update(&chunk); + downloaded += chunk.len() as u64; + state.progress(window, "下载中", downloaded, total, true); + } + + file.flush() + .map_err(|err| format!("刷新内核压缩包失败: {err}"))?; + ensure_not_cancelled(token, Some(archive_path))?; + + let sha256 = format!("{:x}", hasher.finalize()); + validate_download_metadata(downloaded, expected_total, &sha256, expected_digest)?; + + Ok(DownloadedArchive { + size: downloaded, + sha256, + }) +} + +fn ensure_not_cancelled( + token: &CancellationToken, + archive_path: Option<&Path>, +) -> Result<(), String> { + if token.is_cancelled() { + if let Some(archive_path) = archive_path { + let _ = fs::remove_file(archive_path); + } + + return Err("已取消下载".to_string()); + } + + Ok(()) +} + +fn current_core_platform() -> Result { + let os = env::consts::OS; + let arch = env::consts::ARCH; + + let (asset_os, archive_kind) = match os { + "linux" => ("linux", "tar.gz"), + "macos" => ("darwin", "tar.gz"), + "windows" => ("windows", "zip"), + other => return Err(format!("不支持的操作系统: {other}")), + }; + + let asset_arch = match arch { + "x86_64" => "amd64", + "aarch64" => "aarch64", + other => return Err(format!("不支持的 CPU 架构: {other}")), + }; + + Ok(CorePlatform { + os: os.to_string(), + arch: arch.to_string(), + asset_os: asset_os.to_string(), + asset_arch: asset_arch.to_string(), + archive_kind: archive_kind.to_string(), + }) +} + +fn current_core_status(process_state: Option<&CoreProcessState>) -> Result { + let install_dir = core_install_dir()?; + let binary_path = find_core_binary(&install_dir); + let installed = binary_path.is_some(); + let managed_pid = process_state.and_then(|state| state.managed_pid()); + let process_ids = binary_path + .as_ref() + .map(|path| find_core_process_ids(path)) + .unwrap_or_default(); + let process_id = managed_pid.or_else(|| process_ids.first().copied()); + let running = process_id.is_some(); + let current_version = read_core_metadata(&install_dir).map(|metadata| metadata.version); + + let message = if !installed { + "未安装 CPA 内核,请先安装最新版".to_string() + } else if running { + "CPA 内核正在运行".to_string() + } else { + "CPA 内核已安装,当前未运行".to_string() + }; + + Ok(CoreStatus { + installed, + running, + managed: managed_pid.is_some(), + process_id, + current_version, + install_dir: path_to_string(&install_dir), + binary_path: binary_path.map(|path| path_to_string(&path)), + message, + }) +} + +fn start_core_process_inner(process_state: &CoreProcessState) -> Result<(), String> { + let install_dir = core_install_dir()?; + let binary_path = find_core_binary(&install_dir) + .ok_or_else(|| "未安装 CPA 内核,请先安装最新版".to_string())?; + let config_path = ensure_core_config(&install_dir)?; + + if process_state.managed_pid().is_some() || is_core_running(&binary_path) { + return Err("CPA 内核已经在运行".to_string()); + } + + let config_path = path_to_string(&config_path); + let mut child = Command::new(&binary_path) + .args(["-config", &config_path]) + .current_dir(&install_dir) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn() + .map_err(|err| format!("启动 CPA 内核失败: {err}"))?; + + thread::sleep(Duration::from_millis(200)); + if let Some(status) = child + .try_wait() + .map_err(|err| format!("检查 CPA 内核启动状态失败: {err}"))? + { + return Err(format!("CPA 内核启动后立即退出: {status}")); + } + + process_state.store_child(child)?; + + Ok(()) +} + +fn ensure_core_config(install_dir: &Path) -> Result { + let config_path = install_dir.join(CORE_CONFIG_FILE); + if config_path.is_file() { + return Ok(config_path); + } + + let example_config_path = install_dir.join(CORE_EXAMPLE_CONFIG_FILE); + if !example_config_path.is_file() { + return Err(format!( + "未找到内核配置模板: {}", + path_to_string(&example_config_path) + )); + } + + fs::copy(&example_config_path, &config_path).map_err(|err| { + format!( + "初始化内核配置失败 {} -> {}: {err}", + path_to_string(&example_config_path), + path_to_string(&config_path) + ) + })?; + + Ok(config_path) +} + +fn stop_core_process_inner(process_state: &CoreProcessState) -> Result<(), String> { + if let Some(mut child) = process_state.take_child() { + terminate_child(&mut child)?; + return Ok(()); + } + + let install_dir = core_install_dir()?; + let binary_path = find_core_binary(&install_dir) + .ok_or_else(|| "未安装 CPA 内核,请先安装最新版".to_string())?; + let process_ids = find_core_process_ids(&binary_path); + + if process_ids.is_empty() { + return Err("CPA 内核当前未运行".to_string()); + } + + for process_id in process_ids { + terminate_process_id(process_id)?; + } + + Ok(()) +} + +fn core_install_dir() -> Result { + Ok(core_base_dir()?.join("cpa-core")) +} + +fn core_base_dir() -> Result { + let exe_path = env::current_exe().map_err(|err| format!("读取当前程序路径失败: {err}"))?; + exe_path + .parent() + .map(|path| path.to_path_buf()) + .ok_or_else(|| format!("当前程序路径没有父目录: {}", path_to_string(&exe_path))) +} + +fn read_core_metadata(install_dir: &Path) -> Option { + let metadata_path = install_dir.join(CORE_METADATA_FILE); + let content = fs::read_to_string(metadata_path).ok()?; + serde_json::from_str(&content).ok() +} + +fn write_core_metadata(install_dir: &Path, metadata: &CoreMetadata) -> Result<(), String> { + let metadata_path = install_dir.join(CORE_METADATA_FILE); + let content = serde_json::to_string_pretty(metadata) + .map_err(|err| format!("生成内核元数据失败: {err}"))?; + fs::write(metadata_path, content).map_err(|err| format!("写入内核元数据失败: {err}")) +} + +fn validate_downloaded_asset( + asset: &GithubAsset, + downloaded: &DownloadedArchive, +) -> Result<(), String> { + validate_download_metadata( + downloaded.size, + asset.size, + &downloaded.sha256, + asset.digest.as_deref(), + ) +} + +fn validate_download_metadata( + downloaded: u64, + expected_total: Option, + sha256: &str, + expected_digest: Option<&str>, +) -> Result<(), String> { + if let Some(expected_total) = expected_total { + if downloaded != expected_total { + return Err(format!( + "下载大小校验失败: 实际 {downloaded} 字节,期望 {expected_total} 字节" + )); + } + } + + if let Some(expected_digest) = expected_digest { + let expected = expected_digest + .strip_prefix("sha256:") + .unwrap_or(expected_digest) + .to_ascii_lowercase(); + + if !expected.is_empty() && sha256 != expected { + return Err("下载文件 SHA-256 校验失败".to_string()); + } + } + + Ok(()) +} + +fn cleanup_core_work_dirs() -> Result<(), String> { + let base_dir = core_base_dir()?; + let mut last_error = None; + + for name in ["cpa-core.staging", "cpa-core.download"] { + let path = base_dir.join(name); + if path.exists() { + if let Err(err) = fs::remove_dir_all(&path) { + last_error = Some(format!("清理临时目录失败 {}: {err}", path_to_string(&path))); + } + } + } + + if let Some(error) = last_error { + Err(error) + } else { + Ok(()) + } +} + +fn unix_now() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|duration| duration.as_secs()) + .unwrap_or(0) +} + +fn normalize_version(version: &str) -> String { + let version = version.trim(); + + if version.starts_with('v') { + version.to_string() + } else { + format!("v{version}") + } +} + +fn is_core_running(binary_path: &Path) -> bool { + !find_core_process_ids(binary_path).is_empty() +} + +fn find_core_process_ids(binary_path: &Path) -> Vec { + #[cfg(target_os = "linux")] + { + find_core_process_ids_linux(binary_path) + } + + #[cfg(not(target_os = "linux"))] + { + let _ = binary_path; + find_core_process_ids_by_name() + } +} + +#[cfg(target_os = "linux")] +fn find_core_process_ids_linux(binary_path: &Path) -> Vec { + let Ok(expected) = fs::canonicalize(binary_path) else { + return Vec::new(); + }; + let output = Command::new("pgrep") + .args(["-x", core_binary_name()]) + .output(); + let Ok(output) = output else { + return Vec::new(); + }; + + String::from_utf8_lossy(&output.stdout) + .lines() + .filter_map(|line| line.trim().parse::().ok()) + .filter(|pid| { + fs::read_link(format!("/proc/{pid}/exe")) + .ok() + .and_then(|path| fs::canonicalize(path).ok()) + .map(|path| path == expected) + .unwrap_or(false) + }) + .collect() +} + +#[cfg(all(not(target_os = "linux"), target_os = "windows"))] +fn find_core_process_ids_by_name() -> Vec { + let image_name = core_binary_name(); + let filter = format!("IMAGENAME eq {image_name}"); + let output = Command::new("tasklist") + .args(["/FI", &filter, "/FO", "CSV", "/NH"]) + .output(); + let Ok(output) = output else { + return Vec::new(); + }; + + String::from_utf8_lossy(&output.stdout) + .lines() + .filter_map(|line| { + let columns = line + .trim() + .trim_matches('"') + .split("\",\"") + .collect::>(); + let name = columns.first()?; + let pid = columns.get(1)?; + + name.eq_ignore_ascii_case(image_name) + .then(|| pid.parse::().ok()) + .flatten() + }) + .collect() +} + +#[cfg(all(not(target_os = "linux"), not(target_os = "windows")))] +fn find_core_process_ids_by_name() -> Vec { + Command::new("pgrep") + .args(["-x", core_binary_name()]) + .output() + .ok() + .map(|output| { + String::from_utf8_lossy(&output.stdout) + .lines() + .filter_map(|line| line.trim().parse::().ok()) + .collect() + }) + .unwrap_or_default() +} + +fn terminate_child(child: &mut Child) -> Result<(), String> { + let process_id = child.id(); + + #[cfg(windows)] + { + child + .kill() + .map_err(|err| format!("关闭 CPA 内核进程失败: {err}"))?; + child + .wait() + .map_err(|err| format!("等待 CPA 内核进程退出失败: {err}"))?; + return Ok(()); + } + + #[cfg(not(windows))] + { + send_process_signal(process_id, "TERM")?; + + for _ in 0..20 { + match child.try_wait() { + Ok(Some(_)) => return Ok(()), + Ok(None) => thread::sleep(Duration::from_millis(100)), + Err(err) => return Err(format!("检查 CPA 内核进程状态失败: {err}")), + } + } + + child + .kill() + .map_err(|err| format!("强制关闭 CPA 内核进程失败: {err}"))?; + child + .wait() + .map_err(|err| format!("等待 CPA 内核进程退出失败: {err}"))?; + + Ok(()) + } +} + +fn terminate_process_id(process_id: u32) -> Result<(), String> { + #[cfg(windows)] + { + let process_id = process_id.to_string(); + let status = Command::new("taskkill") + .args(["/PID", &process_id, "/T", "/F"]) + .status() + .map_err(|err| format!("关闭 CPA 内核进程失败: {err}"))?; + + if status.success() { + return Ok(()); + } + + return Err(format!("关闭 CPA 内核进程失败: PID {process_id}")); + } + + #[cfg(not(windows))] + { + send_process_signal(process_id, "TERM")?; + + for _ in 0..20 { + if !is_process_alive(process_id) { + return Ok(()); + } + thread::sleep(Duration::from_millis(100)); + } + + send_process_signal(process_id, "KILL") + } +} + +#[cfg(not(windows))] +fn send_process_signal(process_id: u32, signal: &str) -> Result<(), String> { + let status = Command::new("kill") + .args([format!("-{signal}"), process_id.to_string()]) + .status() + .map_err(|err| format!("发送进程信号失败: {err}"))?; + + if status.success() { + Ok(()) + } else { + Err(format!("发送进程信号失败: PID {process_id}")) + } +} + +#[cfg(not(windows))] +fn is_process_alive(process_id: u32) -> bool { + let process_id = process_id.to_string(); + Command::new("kill") + .args(["-0", &process_id]) + .status() + .map(|status| status.success()) + .unwrap_or(false) +} + +fn reset_dir(path: &Path) -> Result<(), String> { + if path.exists() { + fs::remove_dir_all(path) + .map_err(|err| format!("清理目录失败 {}: {err}", path_to_string(path)))?; + } + + fs::create_dir_all(path).map_err(|err| format!("创建目录失败 {}: {err}", path_to_string(path))) +} + +fn replace_install_dir( + install_dir: &Path, + staging_dir: &Path, + backup_dir: &Path, +) -> Result<(), String> { + if backup_dir.exists() { + fs::remove_dir_all(backup_dir).map_err(|err| format!("清理备份目录失败: {err}"))?; + } + + if install_dir.exists() { + fs::rename(install_dir, backup_dir) + .map_err(|err| format!("备份旧内核目录失败,请确认 CPA 内核未运行: {err}"))?; + } + + if let Err(err) = fs::rename(staging_dir, install_dir) { + if backup_dir.exists() { + let _ = fs::rename(backup_dir, install_dir); + } + + return Err(format!("切换新内核目录失败: {err}")); + } + + if backup_dir.exists() { + fs::remove_dir_all(backup_dir).map_err(|err| format!("删除旧内核备份目录失败: {err}"))?; + } + + Ok(()) +} + +fn extract_tar_gz(archive_path: &Path, install_dir: &Path) -> Result<(), String> { + let archive_file = + File::open(archive_path).map_err(|err| format!("打开 tar.gz 失败: {err}"))?; + let decoder = GzDecoder::new(archive_file); + let mut archive = Archive::new(decoder); + let entries = archive + .entries() + .map_err(|err| format!("读取 tar.gz 条目失败: {err}"))?; + + for entry in entries { + let mut entry = entry.map_err(|err| format!("读取 tar.gz 条目失败: {err}"))?; + let entry_path = entry + .path() + .map_err(|err| format!("读取 tar.gz 条目路径失败: {err}"))?; + let out_path = checked_archive_path(install_dir, entry_path.as_ref())?; + let entry_type = entry.header().entry_type(); + + if entry_type.is_dir() { + fs::create_dir_all(&out_path).map_err(|err| format!("创建目录失败: {err}"))?; + } else if entry_type.is_file() { + if let Some(parent) = out_path.parent() { + fs::create_dir_all(parent).map_err(|err| format!("创建目录失败: {err}"))?; + } + entry + .unpack(&out_path) + .map_err(|err| format!("解压 tar.gz 文件失败: {err}"))?; + } else { + return Err(format!( + "tar.gz 包含不支持的条目类型: {}", + path_to_string(&out_path) + )); + } + } + + Ok(()) +} + +fn extract_zip(archive_path: &Path, install_dir: &Path) -> Result<(), String> { + let archive_file = File::open(archive_path).map_err(|err| format!("打开 zip 失败: {err}"))?; + let mut archive = + ZipArchive::new(archive_file).map_err(|err| format!("读取 zip 失败: {err}"))?; + + for index in 0..archive.len() { + let mut file = archive + .by_index(index) + .map_err(|err| format!("读取 zip 条目失败: {err}"))?; + let enclosed_name = file + .enclosed_name() + .ok_or_else(|| format!("zip 条目路径不安全: {}", file.name()))?; + let out_path = checked_archive_path(install_dir, &enclosed_name)?; + + if is_zip_symlink(&file) { + return Err(format!("zip 包含不支持的符号链接条目: {}", file.name())); + } + + if file.is_dir() { + fs::create_dir_all(&out_path).map_err(|err| format!("创建目录失败: {err}"))?; + continue; + } + + if let Some(parent) = out_path.parent() { + fs::create_dir_all(parent).map_err(|err| format!("创建目录失败: {err}"))?; + } + + let mut out_file = File::create(&out_path).map_err(|err| format!("创建文件失败: {err}"))?; + io::copy(&mut file, &mut out_file).map_err(|err| format!("写入文件失败: {err}"))?; + + #[cfg(unix)] + if let Some(mode) = file.unix_mode() { + use std::os::unix::fs::PermissionsExt; + fs::set_permissions(&out_path, fs::Permissions::from_mode(mode)) + .map_err(|err| format!("设置文件权限失败: {err}"))?; + } + } + + Ok(()) +} + +fn checked_archive_path(base_dir: &Path, entry_path: &Path) -> Result { + if entry_path.components().any(|component| { + matches!( + component, + Component::ParentDir | Component::RootDir | Component::Prefix(_) + ) + }) { + return Err(format!( + "压缩包条目路径不安全: {}", + path_to_string(entry_path) + )); + } + + Ok(base_dir.join(entry_path)) +} + +fn is_zip_symlink(file: &zip::read::ZipFile<'_>) -> bool { + file.unix_mode() + .map(|mode| mode & 0o170000 == 0o120000) + .unwrap_or(false) +} + +fn find_core_binary(install_dir: &Path) -> Option { + let binary_path = install_dir.join(core_binary_name()); + if binary_path.is_file() { + return Some(binary_path); + } + + let mut dirs = vec![install_dir.to_path_buf()]; + + while let Some(dir) = dirs.pop() { + let entries = fs::read_dir(dir).ok()?; + + for entry in entries.flatten() { + let path = entry.path(); + if path.is_dir() { + dirs.push(path); + } else if path + .file_name() + .and_then(|file_name| file_name.to_str()) + .map(|file_name| file_name == core_binary_name()) + .unwrap_or(false) + { + return Some(path); + } + } + } + + None +} + +fn core_binary_name() -> &'static str { + if env::consts::OS == "windows" { + "cli-proxy-api.exe" + } else { + "cli-proxy-api" + } +} + +fn path_to_string(path: &Path) -> String { + path.to_string_lossy().into_owned() +} + fn main() { tauri::Builder::default() - .invoke_handler(tauri::generate_handler![health_check]) + .manage(CoreDownloadState::default()) + .manage(CoreProcessState::default()) + .invoke_handler(tauri::generate_handler![ + health_check, + detect_core_platform, + get_core_status, + check_latest_core, + install_core_version, + cancel_core_install, + get_core_install_task, + start_core_process, + stop_core_process, + restart_core_process + ]) .run(tauri::generate_context!()) .expect("failed to run app"); } diff --git a/src/App.tsx b/src/App.tsx index 3605a4a..25ed164 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -3,8 +3,16 @@ import { ConfigPanelPage } from './pages/ConfigPanel'; import { KernelPage } from './pages/Kernel'; const pages = [ - { id: 'kernel', label: '内核管理', component: KernelPage }, - { id: 'config', label: '配置面板', component: ConfigPanelPage }, + { + id: 'kernel', + label: '内核', + component: KernelPage, + }, + { + id: 'config', + label: '配置', + component: ConfigPanelPage, + }, ] as const; type PageId = (typeof pages)[number]['id']; @@ -22,10 +30,14 @@ function App() {
-
- -
+
+
+ +
+
); } diff --git a/src/pages/ConfigPanel.tsx b/src/pages/ConfigPanel.tsx index 9a5c880..dd3b696 100644 --- a/src/pages/ConfigPanel.tsx +++ b/src/pages/ConfigPanel.tsx @@ -1,14 +1,46 @@ export function ConfigPanelPage() { return ( -
-

配置面板

+
+
+
+
+
+

基础配置

+

配置文件和工作目录

+
+
-
-

基础配置

- +
+ + +
+
+ +
+
+
+

连接信息

+

当前前端占位状态

+
+
+ +
+
+
配置源
+
未接入
+
+
+
保存状态
+
待实现
+
+
+
); diff --git a/src/pages/Kernel.tsx b/src/pages/Kernel.tsx index 2eb39a4..1c87b03 100644 --- a/src/pages/Kernel.tsx +++ b/src/pages/Kernel.tsx @@ -1,23 +1,708 @@ -import { useEffect, useState } from 'react'; +import { useEffect, useRef, useState } from 'react'; import { invoke } from '@tauri-apps/api/core'; +import { listen } from '@tauri-apps/api/event'; + +type CorePlatform = { + os: string; + arch: string; + assetOs: string; + assetArch: string; + archiveKind: 'tar.gz' | 'zip'; +}; + +type CoreStatus = { + installed: boolean; + running: boolean; + managed: boolean; + processId: number | null; + currentVersion: string | null; + installDir: string; + binaryPath: string | null; + message: string; +}; + +type CoreLatest = { + version: string; + assetName: string; +}; + +type CoreInstallResult = { + version: string; + assetName: string; + installDir: string; + binaryPath: string | null; +}; + +type CoreInstallTask = { + running: boolean; + cancellable: boolean; + phase: string; + downloaded: number; + total: number | null; + percent: number | null; + message: string | null; + result: CoreInstallResult | null; +}; + +type MessageType = 'info' | 'success' | 'error'; + +let latestAutoCheckStarted = false; +let cachedLatest: CoreLatest | null = null; +let cachedLatestError = ''; +let latestCheckPromise: Promise | null = null; + +function requestLatestCore() { + if (latestCheckPromise) { + return latestCheckPromise; + } + + latestCheckPromise = invoke('check_latest_core') + .then((result) => { + cachedLatest = result; + cachedLatestError = ''; + return result; + }) + .catch((error) => { + cachedLatest = null; + cachedLatestError = String(error); + throw error; + }) + .finally(() => { + latestCheckPromise = null; + }); + + return latestCheckPromise; +} export function KernelPage() { - const [status, setStatus] = useState('检测中'); + const [coreStatus, setCoreStatus] = useState(null); + const [statusError, setStatusError] = useState(''); + const [platform, setPlatform] = useState(null); + const [platformError, setPlatformError] = useState(''); + const [latest, setLatest] = useState(cachedLatest); + const [latestError, setLatestError] = useState(cachedLatestError); + const [checkingLatest, setCheckingLatest] = useState(Boolean(latestCheckPromise)); + const [allowLanAccess, setAllowLanAccess] = useState(false); + const [customPort, setCustomPort] = useState(''); + const [installing, setInstalling] = useState(false); + const [processBusy, setProcessBusy] = useState(false); + const [processMessage, setProcessMessage] = useState(''); + const [processMessageType, setProcessMessageType] = useState('info'); + const [message, setMessage] = useState(''); + const [messageType, setMessageType] = useState('info'); + const [progress, setProgress] = useState(null); + const [installDialogOpen, setInstallDialogOpen] = useState(false); + const [cancellingInstall, setCancellingInstall] = useState(false); + const installDialogRef = useRef(null); + + const applyInstallTask = (task: CoreInstallTask, showFinishedDialog = true) => { + if (!task.running && !task.message && !task.result) { + setProgress(null); + setInstalling(false); + setCancellingInstall(false); + return; + } + + setInstalling(task.running); + if (!task.running) { + setCancellingInstall(false); + } + + if (task.running || showFinishedDialog) { + setProgress(task); + setInstallDialogOpen(true); + } else { + setProgress(null); + } + + if (task.result) { + setMessage(task.message || `${task.result.version} 安装完成`); + setMessageType('success'); + void loadCoreStatus(); + return; + } + + if (task.message) { + setMessage(task.message); + setMessageType(task.phase === '安装失败' ? 'error' : 'info'); + } + }; useEffect(() => { - invoke('health_check') - .then(setStatus) - .catch(() => setStatus('需要在 Tauri 窗口中运行')); + let disposed = false; + let unlisten: (() => void) | null = null; + + listen('core-install-progress', (event) => { + applyInstallTask(event.payload); + }) + .then((unlistenProgress) => { + if (disposed) { + unlistenProgress(); + } else { + unlisten = unlistenProgress; + } + }) + .catch((error) => { + if (!disposed) { + setMessage(`监听下载进度失败: ${String(error)}`); + setMessageType('error'); + } + }); + + loadCoreStatus(); + loadPlatform(); + loadInstallTask(); + + if (!latestAutoCheckStarted) { + latestAutoCheckStarted = true; + void checkLatest(); + } else if (latestCheckPromise) { + void checkLatest(); + } + + return () => { + disposed = true; + unlisten?.(); + }; }, []); - return ( -
-

内核管理

+ useEffect(() => { + if (!installDialogOpen) { + return; + } -
-

运行状态

-

{status}

+ const previousOverflow = document.body.style.overflow; + document.body.style.overflow = 'hidden'; + installDialogRef.current?.focus(); + + const preventEscapeClose = (event: KeyboardEvent) => { + if (event.key === 'Escape') { + event.preventDefault(); + } + }; + + document.addEventListener('keydown', preventEscapeClose); + + return () => { + document.body.style.overflow = previousOverflow; + document.removeEventListener('keydown', preventEscapeClose); + }; + }, [installDialogOpen]); + + const loadCoreStatus = async () => { + try { + const result = await invoke('get_core_status'); + setCoreStatus(result); + setStatusError(''); + } catch (error) { + setCoreStatus(null); + setStatusError(String(error)); + } + }; + + const loadPlatform = async () => { + try { + const result = await invoke('detect_core_platform'); + setPlatform(result); + setPlatformError(''); + } catch (error) { + setPlatform(null); + setPlatformError(String(error)); + } + }; + + const checkLatest = async () => { + setCheckingLatest(true); + setLatestError(''); + setMessage(''); + setMessageType('info'); + + try { + const result = await requestLatestCore(); + setLatest(result); + } catch (error) { + setLatest(null); + setLatestError(String(error)); + } finally { + setCheckingLatest(false); + } + }; + + const loadInstallTask = async () => { + try { + const task = await invoke('get_core_install_task'); + applyInstallTask(task, false); + } catch (error) { + setMessage(`读取安装任务失败: ${String(error)}`); + setMessageType('error'); + } + }; + + const installVersion = async (version: string) => { + setInstalling(true); + setMessage(`正在安装 ${version}`); + setMessageType('info'); + setCancellingInstall(false); + setInstallDialogOpen(true); + setProgress({ + running: true, + cancellable: true, + phase: '准备下载', + downloaded: 0, + total: null, + percent: null, + message: null, + result: null, + }); + + try { + const result = await invoke('install_core_version', { version }); + setMessage(`${result.version} 安装完成`); + setMessageType('success'); + setProgress({ + running: false, + cancellable: false, + phase: '安装完成', + downloaded: 1, + total: 1, + percent: 100, + message: `${result.version} 安装完成`, + result, + }); + await loadCoreStatus(); + } catch (error) { + const errorMessage = String(error); + setMessage(errorMessage); + setMessageType(errorMessage.includes('取消') ? 'info' : 'error'); + setProgress((current) => ({ + running: false, + cancellable: false, + phase: errorMessage.includes('取消') ? '已取消' : '安装失败', + downloaded: current?.downloaded ?? 0, + total: current?.total ?? null, + percent: current?.percent ?? null, + message: errorMessage, + result: null, + })); + } finally { + setInstalling(false); + } + }; + + const cancelInstall = async () => { + if (cancellingInstall || !progress?.running || !progress.cancellable) { + return; + } + + setCancellingInstall(true); + setMessage('正在取消下载'); + setMessageType('info'); + + try { + await invoke('cancel_core_install'); + } catch (error) { + setCancellingInstall(false); + setMessage(String(error)); + setMessageType('error'); + } + }; + + const closeInstallDialog = () => { + if (installing || progress?.running) { + return; + } + + setInstallDialogOpen(false); + setProgress(null); + setCancellingInstall(false); + }; + + const runCoreProcessCommand = async (command: string, doing: string, done: string) => { + setProcessBusy(true); + setProcessMessage(doing); + setProcessMessageType('info'); + + try { + const result = await invoke(command); + setCoreStatus(result); + setStatusError(''); + setProcessMessage(done); + setProcessMessageType('success'); + } catch (error) { + setProcessMessage(String(error)); + setProcessMessageType('error'); + await loadCoreStatus(); + } finally { + setProcessBusy(false); + } + }; + + const latestVersion = latest?.version ?? ''; + const currentVersion = coreStatus?.currentVersion ?? ''; + const coreInstalled = Boolean(coreStatus?.installed); + const coreRunning = Boolean(coreStatus?.running); + const busy = checkingLatest || installing || processBusy; + const installDisabled = busy || Boolean(coreStatus?.running); + const computedPercent = + progress?.percent ?? + (progress?.total && progress.total > 0 ? (progress.downloaded / progress.total) * 100 : null); + const progressKnown = computedPercent !== null; + const progressPercent = clampPercent(computedPercent ?? 0); + const progressText = progress + ? progress.phase === '安装完成' + ? '已完成' + : progress.phase === '解压中' + ? '正在解压文件' + : progress.total + ? `${formatBytes(progress.downloaded)} / ${formatBytes(progress.total)}` + : progress.downloaded > 0 + ? formatBytes(progress.downloaded) + : '等待进度信息' + : ''; + const statusTone = statusError ? 'error' : coreRunning ? 'success' : 'neutral'; + const statusLabel = coreStatus + ? coreRunning + ? '运行中' + : coreInstalled + ? '已停止' + : '未安装' + : statusError + ? '检测失败' + : '检测中'; + const statusMessage = coreStatus?.message || statusError || '正在检测 CPA 内核'; + const latestLabel = checkingLatest + ? '检查中' + : latestVersion || (latestError ? '检查失败' : '未检查'); + const updateStateLabel = checkingLatest + ? '正在检查' + : latestError + ? '检查失败' + : !latestVersion + ? '尚未检查' + : currentVersion === latestVersion + ? '已是最新版本' + : '可安装新版本'; + const platformLabel = platform ? `${platform.os} / ${platform.arch}` : platformError || '检测中'; + const platformOsLabel = platform?.os || (platformError ? '检测失败' : '检测中'); + const platformArchLabel = platform?.arch || (platformError ? '检测失败' : '检测中'); + const installTaskRunning = Boolean(installing || progress?.running); + const controlSubtitle = processMessage || statusMessage; + const controlSubtitleTone: MessageType = processMessage + ? processMessageType + : statusError + ? 'error' + : 'info'; + const versionStatusLabel = installTaskRunning + ? cancellingInstall + ? '正在取消下载' + : progress?.phase || '正在安装' + : message || updateStateLabel; + const versionStatusTone: MessageType = installTaskRunning + ? 'info' + : message + ? messageType + : latestError + ? 'error' + : currentVersion && currentVersion === latestVersion + ? 'success' + : 'info'; + const installDialogTone: MessageType = progress?.result + ? 'success' + : progress?.phase === '安装失败' + ? 'error' + : 'info'; + const installDialogTitle = installTaskRunning + ? cancellingInstall + ? '正在取消下载' + : '正在安装内核' + : progress?.result + ? '安装完成' + : progress?.phase === '已取消' + ? '下载已取消' + : '安装失败'; + const installDialogMessage = cancellingInstall + ? '正在等待下载任务停止' + : progress?.message || (installTaskRunning ? message || '安装任务正在进行' : ''); + const installDialogAction = installTaskRunning + ? cancellingInstall + ? '正在取消' + : progress?.cancellable + ? '取消下载' + : '处理中' + : '关闭'; + const installDialogActionDisabled = + installTaskRunning && (cancellingInstall || !progress?.cancellable); + + return ( +
+
+
+ +
+ 内核状态 + {statusLabel} +
+
+
+ 当前版本 + {currentVersion || '未安装'} +
+
+ 最新版本 + {latestLabel} +
+
+ 运行平台 + {platformLabel} +
+ +
+
+
+
+

运行控制

+

+ {controlSubtitle} +

+
+ {statusLabel} +
+ +
+
+
安装状态
+
{coreStatus ? (coreInstalled ? '已安装' : '未安装') : '检测中'}
+
+
+
运行状态
+
{coreStatus ? (coreRunning ? '运行中' : '未运行') : '检测中'}
+
+
+
进程 PID
+
{coreStatus?.processId || '无'}
+
+
+
局域网
+
+ + setAllowLanAccess(event.currentTarget.checked)} + /> + + +
+
+
+
+ +
+
+ + setCustomPort(event.currentTarget.value.replace(/\D/g, '').slice(0, 5)) + } + /> +
+
+
+ +
+ + + + +
+ +
+ +
+
+
+

版本管理

+

检查和安装 CPA 内核版本

+
+
+ +
+
+
当前版本
+
{currentVersion || '未安装'}
+
+
+
最新版本
+
{latestLabel}
+
+
+
操作系统
+
{platformOsLabel}
+
+
+
系统架构
+
{platformArchLabel}
+
+
+
更新状态
+
+ {versionStatusLabel} +
+
+
+ +
+ + + +
+ +
+
+ + {installDialogOpen && progress ? ( +
+
+
+ 内核安装 +

{installDialogTitle}

+
+ +
+ 当前阶段 + {cancellingInstall ? '正在取消' : progress.phase} +
+ +
+ +
+ +
+ {progressKnown ? `${progressPercent.toFixed(1)}%` : '进度未知'} + {progressText} +
+ +
+ {installDialogMessage || '\u00a0'} +
+ + +
+
+ ) : null}
); } + +function clampPercent(percent: number) { + return Math.min(100, Math.max(0, percent)); +} + +function formatBytes(bytes: number) { + if (bytes < 1024) { + return `${bytes} B`; + } + + if (bytes < 1024 * 1024) { + return `${(bytes / 1024).toFixed(1)} KB`; + } + + return `${(bytes / 1024 / 1024).toFixed(1)} MB`; +} diff --git a/src/styles.css b/src/styles.css index d24ffd5..c70ac03 100644 --- a/src/styles.css +++ b/src/styles.css @@ -3,6 +3,8 @@ -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Helvetica Neue', Arial, sans-serif; color: #2d2a26; background: #faf9f5; + font-synthesis: none; + text-rendering: optimizeLegibility; } * { @@ -22,111 +24,883 @@ body { margin: 0; } -button { +button, +input { font: inherit; +} + +button { cursor: pointer; } +button:focus-visible, +input:focus-visible { + outline: 3px solid rgba(45, 42, 38, 0.18); + outline-offset: 2px; +} + .app-shell { display: grid; - grid-template-columns: 240px 1fr; + grid-template-columns: 236px minmax(0, 1fr); min-height: 100vh; + background: #faf9f5; } .sidebar { position: sticky; top: 0; + display: flex; + flex-direction: column; height: 100vh; - padding: 16px; + padding: 18px; background: #f0eee8; - border-right: 1px solid #e3e1db; + color: #2d2a26; } .sidebar-brand { - display: flex; - flex-direction: column; - padding: 8px 10px 20px; + display: grid; + grid-template-columns: 42px minmax(0, 1fr); + align-items: center; + gap: 12px; + min-height: 54px; + margin-bottom: 22px; +} + +.brand-mark { + display: grid; + width: 42px; + height: 42px; + place-items: center; + border: 1px solid #d5d2cb; + border-radius: 8px; + background: #2d2a26; + color: #fffdf8; + font-size: 14px; + font-weight: 800; +} + +.sidebar-brand strong, +.sidebar-brand span { + display: block; + min-width: 0; +} + +.sidebar-brand strong { + overflow: hidden; + color: #2d2a26; + font-size: 15px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.sidebar-brand div > span { + margin-top: 4px; + color: #7a746c; + font-size: 12px; } .nav-section { - display: flex; - flex-direction: column; + display: grid; gap: 6px; } .nav-section button { width: 100%; - padding: 11px 12px; + min-height: 42px; + padding: 0 12px; border: 1px solid transparent; border-radius: 8px; background: transparent; + color: #2d2a26; text-align: left; } +.nav-section button:hover { + background: #e8e5dc; + color: #2d2a26; +} + .nav-section button.active { - background: #dfdcd4; border-color: #d5d2cb; + background: #dfdcd4; + color: #2d2a26; font-weight: 700; } +.sidebar-footer { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + margin-top: auto; + padding-top: 18px; + border-top: 1px solid #e3e1db; + color: #7a746c; + font-size: 12px; +} + +.sidebar-footer strong { + color: #2d2a26; +} + +.workspace { + min-width: 0; +} + +.state-pill { + display: inline-flex; + align-items: center; + justify-content: center; + min-height: 28px; + padding: 0 10px; + border: 1px solid #d5d2cb; + border-radius: 999px; + background: #fffdf8; + color: #5f5a53; + font-size: 12px; + font-weight: 700; + white-space: nowrap; +} + +.state-pill.success { + border-color: #b6d3bd; + background: #eef7f0; + color: #2f6b3f; +} + +.state-pill.error { + border-color: #dfb7b0; + background: #fff1ee; + color: #9f2d20; +} + .content { - padding: 48px; + width: 100%; + padding: 24px 40px 42px; } .page { - max-width: 860px; + width: min(100%, 1180px); } -.page h1 { - margin: 0 0 24px; - font-size: 28px; +.kernel-page, +.config-page { + display: grid; + gap: 18px; } .panel { + min-width: 0; padding: 20px; border: 1px solid #e3e1db; border-radius: 8px; background: #fffdf8; + box-shadow: 0 1px 2px rgba(45, 42, 38, 0.04); +} + +.quiet-panel { + background: #faf9f5; +} + +.panel-heading { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 16px; + min-height: 49px; + margin-bottom: 16px; +} + +.panel-heading > div { + min-width: 0; +} + +.panel h2 { + margin: 0; + color: #2d2a26; + font-size: 16px; + line-height: 1.25; +} + +.panel-heading p { + min-height: 19px; + margin: 5px 0 0; + overflow: hidden; + color: #5f5a53; + font-size: 13px; + line-height: 1.45; + text-overflow: ellipsis; + white-space: nowrap; +} + +.panel-heading p.success { + color: #2f6b3f; +} + +.panel-heading p.error { + color: #9f2d20; +} + +.status-strip { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 12px; +} + +.status-card { + display: grid; + gap: 6px; + min-width: 0; + min-height: 84px; + padding: 16px; + border: 1px solid #e3e1db; + border-radius: 8px; + background: #fffdf8; } -.panel h2 { - margin: 0 0 12px; - font-size: 16px; +.status-card-primary { + grid-template-columns: auto minmax(0, 1fr); + align-items: center; + gap: 12px; } -.panel p { +.status-card-primary > div { + display: grid; + gap: 6px; + min-width: 0; +} + +.status-card span { + overflow: hidden; + color: #7a746c; + font-size: 12px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.status-card strong { + overflow: hidden; + color: #2d2a26; + font-size: 17px; + line-height: 1.25; + text-overflow: ellipsis; + white-space: nowrap; +} + +.status-dot { + display: block; + width: 12px; + height: 12px; + border-radius: 999px; + background: #9b958c; + box-shadow: 0 0 0 5px rgba(155, 149, 140, 0.14); +} + +.status-dot.success { + background: #2f6b3f; + box-shadow: 0 0 0 5px rgba(47, 107, 63, 0.15); +} + +.status-dot.error { + background: #9f2d20; + box-shadow: 0 0 0 5px rgba(159, 45, 32, 0.15); +} + +.status-dot.neutral { + background: #9b958c; +} + +.kernel-layout { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + align-items: stretch; + gap: 12px; +} + +.config-layout { + display: grid; + grid-template-columns: minmax(420px, 1.15fr) minmax(340px, 0.85fr); + align-items: start; + gap: 18px; +} + +.control-panel { + grid-column: span 2; +} + +.version-panel { + grid-column: span 2; +} + +.kernel-layout > .panel { + display: grid; + grid-template-columns: minmax(0, 1fr); + grid-template-rows: 65px 232px 56px; + height: 395px; +} + +.kernel-layout > .panel > .panel-heading { + min-height: 0; + height: 65px; margin: 0; + padding-bottom: 16px; +} + +.kernel-layout .panel-action-row { + height: 56px; + margin: 0; + padding-top: 18px; +} + +.panel-detail-grid { + display: grid; + grid-template-rows: repeat(5, 40px); + gap: 8px; + min-height: 0; + margin: 0; +} + +.panel-detail-row { + display: grid; + grid-template-columns: 92px minmax(0, 1fr); + align-items: center; + gap: 14px; + min-width: 0; + min-height: 40px; +} + +.panel-detail-row dt { + color: #7a746c; + font-size: 13px; +} + +.panel-detail-row dd { + min-width: 0; + margin: 0; + overflow: hidden; + color: #2d2a26; + text-align: right; + text-overflow: ellipsis; + white-space: nowrap; +} + +.panel-detail-row dd.success { + color: #2f6b3f; +} + +.panel-detail-row dd.error { + color: #9f2d20; +} + +.detail-control-cell { + display: flex; + align-items: center; + justify-content: flex-end; + height: 40px; + overflow: visible !important; +} + +.compact-text-input { + width: min(180px, 100%); + height: 34px; + padding: 0 10px; + border: 1px solid #d5d2cb; + border-radius: 6px; + background: #ffffff; + color: #2d2a26; + text-align: right; +} + +.compact-text-input::placeholder { + color: #9b958c; +} + +.control-action-row { + grid-template-columns: repeat(4, minmax(0, 1fr)); +} + +.version-action-row { + grid-template-columns: repeat(3, minmax(0, 1fr)); +} + +.info-list { + display: grid; + gap: 10px; + grid-template-rows: repeat(4, 20px); + min-height: 110px; + margin: 0; +} + +.info-list div { + display: grid; + grid-template-columns: 92px minmax(0, 1fr); + gap: 14px; + align-items: start; + min-height: 20px; +} + +.info-list dt { + color: #7a746c; + font-size: 13px; +} + +.info-list dd { + min-width: 0; + margin: 0; + overflow: hidden; + color: #2d2a26; + text-overflow: ellipsis; + white-space: nowrap; +} + +.empty-state { + display: grid; + gap: 6px; + padding: 18px; + border: 1px dashed #d5d2cb; + border-radius: 8px; + background: #fffdf8; color: #5f5a53; } +.empty-state strong { + color: #2d2a26; +} + +.button-row { + display: grid; + grid-auto-columns: minmax(0, 1fr); + grid-auto-flow: column; + gap: 10px; + min-height: 38px; + margin-top: 18px; +} + +.primary-button, +.secondary-button, +.danger-button { + display: inline-flex; + align-items: center; + justify-content: center; + min-height: 38px; + min-width: 0; + overflow: hidden; + padding: 0 10px; + border-radius: 6px; + font-weight: 700; + text-overflow: ellipsis; + white-space: nowrap; +} + +.primary-button { + border: 1px solid #2d2a26; + background: #2d2a26; + color: #fffdf8; +} + +.primary-button:hover:not(:disabled) { + background: #433f39; +} + +.secondary-button { + border: 1px solid #d5d2cb; + background: #fffdf8; + color: #2d2a26; +} + +.secondary-button:hover:not(:disabled) { + background: #f4f1ea; +} + +.danger-button { + border: 1px solid #a83f32; + background: #fffdf8; + color: #9f2d20; +} + +.danger-button:hover:not(:disabled) { + background: #fff1ee; +} + +.primary-button:disabled, +.secondary-button:disabled, +.danger-button:disabled { + cursor: not-allowed; + opacity: 0.55; +} + +.switch-control { + position: relative; + display: block; + width: 44px; + height: 24px; + flex: none; +} + +.switch-control input { + position: absolute; + inset: 0; + z-index: 1; + width: 100%; + height: 100%; + margin: 0; + cursor: pointer; + opacity: 0; +} + +.switch-track { + display: block; + width: 100%; + height: 100%; + border: 1px solid #d5d2cb; + border-radius: 999px; + background: #e8e5dc; + transition: + background 140ms ease, + border-color 140ms ease; +} + +.switch-track::after { + display: block; + width: 18px; + height: 18px; + margin: 2px; + border-radius: 999px; + background: #fffdf8; + box-shadow: 0 1px 2px rgba(45, 42, 38, 0.18); + transition: transform 140ms ease; + content: ''; +} + +.switch-control input:checked + .switch-track { + border-color: #2d2a26; + background: #2d2a26; +} + +.switch-control input:checked + .switch-track::after { + transform: translateX(20px); +} + +.form-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 16px; +} + .field { display: grid; gap: 8px; - max-width: 420px; + min-width: 0; color: #5f5a53; font-size: 14px; + font-weight: 700; } .field input { width: 100%; - padding: 10px 12px; + min-height: 40px; + padding: 0 12px; border: 1px solid #d5d2cb; border-radius: 6px; background: #ffffff; color: #2d2a26; - font: inherit; } -@media (max-width: 860px) { +.field input::placeholder { + color: #9b958c; +} + +.field input:disabled { + cursor: not-allowed; + background: #f4f1ea; + color: #9b958c; +} + +.install-dialog-backdrop { + position: fixed; + inset: 0; + z-index: 50; + display: grid; + place-items: center; + padding: 24px; + background: rgba(45, 42, 38, 0.42); + backdrop-filter: blur(3px); +} + +.install-dialog { + display: grid; + grid-template-rows: 48px 42px 10px 22px 72px 40px; + gap: 15px; + width: min(420px, 100%); + height: 360px; + padding: 24px; + border: 1px solid #d5d2cb; + border-radius: 8px; + outline: none; + background: #fffdf8; + box-shadow: 0 18px 48px rgba(45, 42, 38, 0.24); +} + +.install-dialog-heading { + min-width: 0; +} + +.install-dialog-heading > span { + display: block; + margin-bottom: 5px; + color: #7a746c; + font-size: 12px; + font-weight: 700; +} + +.install-dialog-heading h2 { + margin: 0; + overflow: hidden; + color: #2d2a26; + font-size: 20px; + line-height: 1.25; + text-overflow: ellipsis; + white-space: nowrap; +} + +.install-dialog-phase { + display: grid; + grid-template-columns: 76px minmax(0, 1fr); + align-items: center; + gap: 12px; + min-width: 0; + padding: 0 12px; + border-radius: 6px; + background: #f4f1ea; +} + +.install-dialog-phase span { + color: #7a746c; + font-size: 13px; +} + +.install-dialog-phase strong { + overflow: hidden; + color: #2d2a26; + text-align: right; + text-overflow: ellipsis; + white-space: nowrap; +} + +.install-progress-track { + width: 100%; + height: 10px; + overflow: hidden; + border-radius: 999px; + background: #e8e5dc; +} + +.install-progress-fill { + display: block; + width: 0; + height: 100%; + border-radius: inherit; + background: #2d2a26; + transition: width 160ms ease; +} + +.install-dialog.success .install-progress-fill { + background: #2f6b3f; +} + +.install-dialog.error .install-progress-fill { + background: #9f2d20; +} + +.install-progress-track.unknown .install-progress-fill { + visibility: hidden; + width: 36%; +} + +.install-progress-track.unknown.is-running .install-progress-fill { + visibility: visible; + animation: install-progress-slide 1.1s ease-in-out infinite; +} + +.install-progress-meta { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + min-width: 0; + color: #6d6760; + font-size: 13px; +} + +.install-progress-meta strong, +.install-progress-meta span { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.install-progress-meta strong { + color: #2d2a26; +} + +.install-dialog-message { + overflow: auto; + padding: 10px 12px; + border-radius: 6px; + background: #faf9f5; + color: #5f5a53; + font-size: 13px; + line-height: 1.45; + overflow-wrap: anywhere; +} + +.install-dialog-message.success { + background: #eef7f0; + color: #2f6b3f; +} + +.install-dialog-message.error { + background: #fff1ee; + color: #9f2d20; +} + +.install-dialog > button { + width: 100%; + height: 40px; +} + +@keyframes install-progress-slide { + from { + transform: translateX(-120%); + } + + to { + transform: translateX(300%); + } +} + +@media (max-width: 1080px) { .app-shell { - grid-template-columns: 160px 1fr; + grid-template-columns: 204px minmax(0, 1fr); + } + + .status-strip { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .kernel-layout { + grid-template-columns: 1fr; + } + + .config-layout { + grid-template-columns: 1fr; + } + + .control-panel, + .version-panel { + grid-column: auto; + } + +} + +@media (max-width: 760px) { + .app-shell { + grid-template-columns: 1fr; } .sidebar { - padding: 12px; + position: static; + display: grid; + grid-template-columns: minmax(0, 1fr); + gap: 12px; + height: auto; + padding: 14px; + } + + .sidebar-brand { + margin-bottom: 0; + } + + .nav-section { + display: flex; + overflow-x: auto; + padding-bottom: 2px; + } + + .nav-section button { + width: auto; + min-width: 96px; + text-align: center; + } + + .sidebar-footer { + display: none; } .content { - padding: 24px 16px; + padding: 18px; + } + + .status-strip, + .form-grid { + grid-template-columns: 1fr; + } + + .panel { + padding: 18px; + } + + .kernel-layout > .panel { + padding: 20px; + } +} + +@media (max-width: 520px) { + .panel-heading { + flex-direction: column; + } + + .button-row { + grid-auto-columns: auto; + grid-auto-flow: row; + grid-template-columns: 1fr; + } + + .primary-button, + .secondary-button, + .danger-button { + width: 100%; + } + + .kernel-layout .panel-heading { + flex-direction: row; + } + + .kernel-layout .panel-action-row { + grid-auto-flow: column; + } + + .kernel-layout .control-action-row { + grid-template-columns: repeat(4, minmax(0, 1fr)); + } + + .kernel-layout .version-action-row { + grid-template-columns: repeat(3, minmax(0, 1fr)); + } + + .info-list div { + grid-template-columns: 1fr; + gap: 4px; + } + + .install-dialog-backdrop { + padding: 16px; + } +} + +@media (prefers-reduced-motion: reduce) { + .install-progress-track.unknown.is-running .install-progress-fill { + animation-duration: 2.2s; } }