🎉 Initial commit: PiAgentUI — AI Agent Desktop & Web UI
Build & Push Frontend Docker Image / build-and-push (push) Canceled after 0s
Build Validation / validate (push) Canceled after 6s
Build & Push Backend Docker Image / build-and-push (push) Failing after 10m10s
Build & Push Gateway Docker Image / build-and-push (push) Failing after 10m9s
Build & Push Frontend Docker Image / build-and-push (push) Canceled after 0s
Build Validation / validate (push) Canceled after 6s
Build & Push Backend Docker Image / build-and-push (push) Failing after 10m10s
Build & Push Gateway Docker Image / build-and-push (push) Failing after 10m9s
A professional workspace for AI coding agents with: - Real-time streaming chat with SSE - Session management (fork, clone, tree navigation) - MCP tools browser & metadata panel - Skills browser (global, project, package sources) - Voice input & STT provider configuration - Integrated terminal & file explorer - Model selector with capability metadata - Keyboard-first UI with command palette - 8 languages (en, pt-BR, es, ja, zh-CN, ar, bn, hi) - Tauri 2 desktop app - React 19 + TypeScript + Vite 8 + Tailwind CSS 4 - Pi Agent extension runtime Originally forked from OpenCodeUI, now an independent project. License: GPL-3.0-only
This commit is contained in:
commit
b3489e1ede
983 files changed
+158475
No files matched your search
Generated
+930
@@ -0,0 +1,930 @@
|
||||
# This file is automatically @generated by Cargo.
|
||||
# It is not intended for manual editing.
|
||||
version = 4
|
||||
|
||||
[[package]]
|
||||
name = "aho-corasick"
|
||||
version = "1.1.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301"
|
||||
dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "anstream"
|
||||
version = "0.6.21"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "43d5b281e737544384e969a5ccad3f1cdd24b48086a0fc1b2a5262a26b8f4f4a"
|
||||
dependencies = [
|
||||
"anstyle",
|
||||
"anstyle-parse",
|
||||
"anstyle-query",
|
||||
"anstyle-wincon",
|
||||
"colorchoice",
|
||||
"is_terminal_polyfill",
|
||||
"utf8parse",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "anstyle"
|
||||
version = "1.0.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000"
|
||||
|
||||
[[package]]
|
||||
name = "anstyle-parse"
|
||||
version = "0.2.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4e7644824f0aa2c7b9384579234ef10eb7efb6a0deb83f9630a49594dd9c15c2"
|
||||
dependencies = [
|
||||
"utf8parse",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "anstyle-query"
|
||||
version = "1.1.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc"
|
||||
dependencies = [
|
||||
"windows-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "anstyle-wincon"
|
||||
version = "3.0.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d"
|
||||
dependencies = [
|
||||
"anstyle",
|
||||
"once_cell_polyfill",
|
||||
"windows-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "atomic-waker"
|
||||
version = "1.1.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0"
|
||||
|
||||
[[package]]
|
||||
name = "axum"
|
||||
version = "0.8.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8b52af3cb4058c895d37317bb27508dccc8e5f2d39454016b297bf4a400597b8"
|
||||
dependencies = [
|
||||
"axum-core",
|
||||
"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",
|
||||
"sync_wrapper",
|
||||
"tokio",
|
||||
"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]]
|
||||
name = "base64"
|
||||
version = "0.22.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
|
||||
|
||||
[[package]]
|
||||
name = "bitflags"
|
||||
version = "2.11.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af"
|
||||
|
||||
[[package]]
|
||||
name = "bytes"
|
||||
version = "1.11.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33"
|
||||
|
||||
[[package]]
|
||||
name = "cfg-if"
|
||||
version = "1.0.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
|
||||
|
||||
[[package]]
|
||||
name = "colorchoice"
|
||||
version = "1.0.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570"
|
||||
|
||||
[[package]]
|
||||
name = "env_filter"
|
||||
version = "1.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7a1c3cc8e57274ec99de65301228b537f1e4eedc1b8e0f9411c6caac8ae7308f"
|
||||
dependencies = [
|
||||
"log",
|
||||
"regex",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "env_logger"
|
||||
version = "0.11.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b2daee4ea451f429a58296525ddf28b45a3b64f1acf6587e2067437bb11e218d"
|
||||
dependencies = [
|
||||
"anstream",
|
||||
"anstyle",
|
||||
"env_filter",
|
||||
"jiff",
|
||||
"log",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "equivalent"
|
||||
version = "1.0.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
|
||||
|
||||
[[package]]
|
||||
name = "errno"
|
||||
version = "0.3.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"windows-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fnv"
|
||||
version = "1.0.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1"
|
||||
|
||||
[[package]]
|
||||
name = "form_urlencoded"
|
||||
version = "1.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf"
|
||||
dependencies = [
|
||||
"percent-encoding",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "futures-channel"
|
||||
version = "0.3.32"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d"
|
||||
dependencies = [
|
||||
"futures-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "futures-core"
|
||||
version = "0.3.32"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d"
|
||||
|
||||
[[package]]
|
||||
name = "futures-sink"
|
||||
version = "0.3.32"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893"
|
||||
|
||||
[[package]]
|
||||
name = "futures-task"
|
||||
version = "0.3.32"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393"
|
||||
|
||||
[[package]]
|
||||
name = "futures-util"
|
||||
version = "0.3.32"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6"
|
||||
dependencies = [
|
||||
"futures-core",
|
||||
"futures-task",
|
||||
"pin-project-lite",
|
||||
"slab",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "getrandom"
|
||||
version = "0.3.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"libc",
|
||||
"r-efi",
|
||||
"wasip2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "h2"
|
||||
version = "0.4.13"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2f44da3a8150a6703ed5d34e164b875fd14c2cdab9af1252a9a1020bde2bdc54"
|
||||
dependencies = [
|
||||
"atomic-waker",
|
||||
"bytes",
|
||||
"fnv",
|
||||
"futures-core",
|
||||
"futures-sink",
|
||||
"http",
|
||||
"indexmap",
|
||||
"slab",
|
||||
"tokio",
|
||||
"tokio-util",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hashbrown"
|
||||
version = "0.16.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100"
|
||||
|
||||
[[package]]
|
||||
name = "http"
|
||||
version = "1.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"itoa",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "http-body"
|
||||
version = "1.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"http",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "http-body-util"
|
||||
version = "0.1.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"futures-core",
|
||||
"http",
|
||||
"http-body",
|
||||
"pin-project-lite",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "httparse"
|
||||
version = "1.10.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87"
|
||||
|
||||
[[package]]
|
||||
name = "httpdate"
|
||||
version = "1.0.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9"
|
||||
|
||||
[[package]]
|
||||
name = "hyper"
|
||||
version = "1.8.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2ab2d4f250c3d7b1c9fcdff1cece94ea4e2dfbec68614f7b87cb205f24ca9d11"
|
||||
dependencies = [
|
||||
"atomic-waker",
|
||||
"bytes",
|
||||
"futures-channel",
|
||||
"futures-core",
|
||||
"h2",
|
||||
"http",
|
||||
"http-body",
|
||||
"httparse",
|
||||
"httpdate",
|
||||
"itoa",
|
||||
"pin-project-lite",
|
||||
"pin-utils",
|
||||
"smallvec",
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hyper-util"
|
||||
version = "0.1.20"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"http",
|
||||
"http-body",
|
||||
"hyper",
|
||||
"pin-project-lite",
|
||||
"tokio",
|
||||
"tower-service",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "indexmap"
|
||||
version = "2.13.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017"
|
||||
dependencies = [
|
||||
"equivalent",
|
||||
"hashbrown",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "is_terminal_polyfill"
|
||||
version = "1.70.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695"
|
||||
|
||||
[[package]]
|
||||
name = "itoa"
|
||||
version = "1.0.17"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2"
|
||||
|
||||
[[package]]
|
||||
name = "jiff"
|
||||
version = "0.2.23"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1a3546dc96b6d42c5f24902af9e2538e82e39ad350b0c766eb3fbf2d8f3d8359"
|
||||
dependencies = [
|
||||
"jiff-static",
|
||||
"log",
|
||||
"portable-atomic",
|
||||
"portable-atomic-util",
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "jiff-static"
|
||||
version = "0.2.23"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2a8c8b344124222efd714b73bb41f8b5120b27a7cc1c75593a6ff768d9d05aa4"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "libc"
|
||||
version = "0.2.183"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b5b646652bf6661599e1da8901b3b9522896f01e736bad5f723fe7a3a27f899d"
|
||||
|
||||
[[package]]
|
||||
name = "lock_api"
|
||||
version = "0.4.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965"
|
||||
dependencies = [
|
||||
"scopeguard",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "log"
|
||||
version = "0.4.29"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897"
|
||||
|
||||
[[package]]
|
||||
name = "matchit"
|
||||
version = "0.8.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3"
|
||||
|
||||
[[package]]
|
||||
name = "memchr"
|
||||
version = "2.8.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79"
|
||||
|
||||
[[package]]
|
||||
name = "mime"
|
||||
version = "0.3.17"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
|
||||
|
||||
[[package]]
|
||||
name = "mio"
|
||||
version = "1.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"wasi",
|
||||
"windows-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "once_cell"
|
||||
version = "1.21.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
|
||||
|
||||
[[package]]
|
||||
name = "once_cell_polyfill"
|
||||
version = "1.70.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
|
||||
|
||||
[[package]]
|
||||
name = "opencodeui-router"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"axum",
|
||||
"base64",
|
||||
"env_logger",
|
||||
"log",
|
||||
"rand",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "parking_lot"
|
||||
version = "0.12.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a"
|
||||
dependencies = [
|
||||
"lock_api",
|
||||
"parking_lot_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "parking_lot_core"
|
||||
version = "0.9.12"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"libc",
|
||||
"redox_syscall",
|
||||
"smallvec",
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "percent-encoding"
|
||||
version = "2.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
|
||||
|
||||
[[package]]
|
||||
name = "pin-project-lite"
|
||||
version = "0.2.17"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
|
||||
|
||||
[[package]]
|
||||
name = "pin-utils"
|
||||
version = "0.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184"
|
||||
|
||||
[[package]]
|
||||
name = "portable-atomic"
|
||||
version = "1.13.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49"
|
||||
|
||||
[[package]]
|
||||
name = "portable-atomic-util"
|
||||
version = "0.2.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "091397be61a01d4be58e7841595bd4bfedb15f1cd54977d79b8271e94ed799a3"
|
||||
dependencies = [
|
||||
"portable-atomic",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ppv-lite86"
|
||||
version = "0.2.21"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9"
|
||||
dependencies = [
|
||||
"zerocopy",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "proc-macro2"
|
||||
version = "1.0.106"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934"
|
||||
dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quote"
|
||||
version = "1.0.45"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "r-efi"
|
||||
version = "5.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f"
|
||||
|
||||
[[package]]
|
||||
name = "rand"
|
||||
version = "0.9.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1"
|
||||
dependencies = [
|
||||
"rand_chacha",
|
||||
"rand_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand_chacha"
|
||||
version = "0.9.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb"
|
||||
dependencies = [
|
||||
"ppv-lite86",
|
||||
"rand_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand_core"
|
||||
version = "0.9.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c"
|
||||
dependencies = [
|
||||
"getrandom",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "redox_syscall"
|
||||
version = "0.5.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "regex"
|
||||
version = "1.12.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276"
|
||||
dependencies = [
|
||||
"aho-corasick",
|
||||
"memchr",
|
||||
"regex-automata",
|
||||
"regex-syntax",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "regex-automata"
|
||||
version = "0.4.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f"
|
||||
dependencies = [
|
||||
"aho-corasick",
|
||||
"memchr",
|
||||
"regex-syntax",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "regex-syntax"
|
||||
version = "0.8.10"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a"
|
||||
|
||||
[[package]]
|
||||
name = "ryu"
|
||||
version = "1.0.23"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f"
|
||||
|
||||
[[package]]
|
||||
name = "scopeguard"
|
||||
version = "1.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
|
||||
|
||||
[[package]]
|
||||
name = "serde"
|
||||
version = "1.0.228"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e"
|
||||
dependencies = [
|
||||
"serde_core",
|
||||
"serde_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_core"
|
||||
version = "1.0.228"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad"
|
||||
dependencies = [
|
||||
"serde_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_derive"
|
||||
version = "1.0.228"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_json"
|
||||
version = "1.0.149"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86"
|
||||
dependencies = [
|
||||
"itoa",
|
||||
"memchr",
|
||||
"serde",
|
||||
"serde_core",
|
||||
"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]]
|
||||
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 = "signal-hook-registry"
|
||||
version = "1.4.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b"
|
||||
dependencies = [
|
||||
"errno",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "slab"
|
||||
version = "0.4.12"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5"
|
||||
|
||||
[[package]]
|
||||
name = "smallvec"
|
||||
version = "1.15.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03"
|
||||
|
||||
[[package]]
|
||||
name = "socket2"
|
||||
version = "0.6.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"windows-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "syn"
|
||||
version = "2.0.117"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sync_wrapper"
|
||||
version = "1.0.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263"
|
||||
|
||||
[[package]]
|
||||
name = "tokio"
|
||||
version = "1.50.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "27ad5e34374e03cfffefc301becb44e9dc3c17584f414349ebe29ed26661822d"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"libc",
|
||||
"mio",
|
||||
"parking_lot",
|
||||
"pin-project-lite",
|
||||
"signal-hook-registry",
|
||||
"socket2",
|
||||
"tokio-macros",
|
||||
"windows-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tokio-macros"
|
||||
version = "2.6.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5c55a2eff8b69ce66c84f85e1da1c233edc36ceb85a2058d11b0d6a3c7e7569c"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tokio-util"
|
||||
version = "0.7.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"futures-core",
|
||||
"futures-sink",
|
||||
"pin-project-lite",
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tower"
|
||||
version = "0.5.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4"
|
||||
dependencies = [
|
||||
"futures-core",
|
||||
"futures-util",
|
||||
"pin-project-lite",
|
||||
"sync_wrapper",
|
||||
"tokio",
|
||||
"tower-layer",
|
||||
"tower-service",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tower-layer"
|
||||
version = "0.3.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e"
|
||||
|
||||
[[package]]
|
||||
name = "tower-service"
|
||||
version = "0.3.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3"
|
||||
|
||||
[[package]]
|
||||
name = "tracing"
|
||||
version = "0.1.44"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100"
|
||||
dependencies = [
|
||||
"log",
|
||||
"pin-project-lite",
|
||||
"tracing-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tracing-core"
|
||||
version = "0.1.36"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a"
|
||||
dependencies = [
|
||||
"once_cell",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "unicode-ident"
|
||||
version = "1.0.24"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
|
||||
|
||||
[[package]]
|
||||
name = "utf8parse"
|
||||
version = "0.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
|
||||
|
||||
[[package]]
|
||||
name = "wasi"
|
||||
version = "0.11.1+wasi-snapshot-preview1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
|
||||
|
||||
[[package]]
|
||||
name = "wasip2"
|
||||
version = "1.0.2+wasi-0.2.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5"
|
||||
dependencies = [
|
||||
"wit-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-link"
|
||||
version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
|
||||
|
||||
[[package]]
|
||||
name = "windows-sys"
|
||||
version = "0.61.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
|
||||
dependencies = [
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wit-bindgen"
|
||||
version = "0.51.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5"
|
||||
|
||||
[[package]]
|
||||
name = "zerocopy"
|
||||
version = "0.8.42"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f2578b716f8a7a858b7f02d5bd870c14bf4ddbbcf3a4c05414ba6503640505e3"
|
||||
dependencies = [
|
||||
"zerocopy-derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zerocopy-derive"
|
||||
version = "0.8.42"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7e6cc098ea4d3bd6246687de65af3f920c430e236bee1e3bf2e441463f08a02f"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zmij"
|
||||
version = "1.0.21"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"
|
||||
@@ -0,0 +1,29 @@
|
||||
[package]
|
||||
name = "opencodeui-router"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
axum = { version = "0.8.8", features = ["http2"] }
|
||||
base64 = "0.22.1"
|
||||
env_logger = "0.11.8"
|
||||
log = { version = "0.4.29", features = ["release_max_level_info"] }
|
||||
rand = "0.9.2"
|
||||
serde = { version = "1.0.228", features = ["derive"] }
|
||||
serde_json = "1.0.149"
|
||||
tokio = { version = "1.50.0", features = [
|
||||
"fs",
|
||||
"process",
|
||||
"macros",
|
||||
"net",
|
||||
"parking_lot",
|
||||
"rt-multi-thread",
|
||||
"signal",
|
||||
"time"
|
||||
] }
|
||||
|
||||
[profile.release]
|
||||
strip = true
|
||||
lto = "thin"
|
||||
panic = "abort"
|
||||
codegen-units = 1
|
||||
@@ -0,0 +1,71 @@
|
||||
use std::{collections::BTreeMap, path::Path};
|
||||
|
||||
use tokio::fs;
|
||||
|
||||
use crate::{
|
||||
config::{Config, PREVIEW_FILE, ROUTES_FILE},
|
||||
scanner,
|
||||
state::RouteInfo,
|
||||
};
|
||||
|
||||
pub(crate) async fn write_map(
|
||||
config: &Config,
|
||||
routes: &BTreeMap<String, RouteInfo>,
|
||||
) -> Result<(), String> {
|
||||
ensure_parent_dir(ROUTES_FILE).await?;
|
||||
|
||||
let mut lines = vec!["# Dynamic routes (auto generated by router)".to_string()];
|
||||
|
||||
for (token, info) in routes {
|
||||
lines.push(format!("@route_{token} path /p/{token} /p/{token}/*"));
|
||||
lines.push(format!("handle @route_{token} {{"));
|
||||
lines.push(format!("\turi strip_prefix /p/{token}"));
|
||||
lines.push(format!(
|
||||
"\treverse_proxy {}:{}",
|
||||
config.target_container(),
|
||||
info.port
|
||||
));
|
||||
lines.push("}".to_string());
|
||||
lines.push(String::new());
|
||||
}
|
||||
|
||||
fs::write(ROUTES_FILE, lines.join("\n") + "\n")
|
||||
.await
|
||||
.map_err(|err| err.to_string())
|
||||
}
|
||||
|
||||
pub(crate) async fn write_preview_conf(config: &Config, port: Option<u16>) -> Result<(), String> {
|
||||
ensure_parent_dir(PREVIEW_FILE).await?;
|
||||
|
||||
let body = match port {
|
||||
Some(port) => format!(
|
||||
"# Preview proxy (auto generated by router)\nreverse_proxy {}:{}\n",
|
||||
config.target_container(),
|
||||
port
|
||||
),
|
||||
None => concat!(
|
||||
"# Preview proxy (no port selected)\n",
|
||||
"respond \"No preview port configured. Set one from the Routes panel.\" 503\n"
|
||||
)
|
||||
.to_string(),
|
||||
};
|
||||
|
||||
fs::write(PREVIEW_FILE, body)
|
||||
.await
|
||||
.map_err(|err| err.to_string())
|
||||
}
|
||||
|
||||
pub(crate) async fn reload_gateway() -> Result<(), String> {
|
||||
scanner::run_cmd(&["caddy", "reload", "--config", "/etc/caddy/Caddyfile"]).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn ensure_parent_dir(path: &str) -> Result<(), String> {
|
||||
let Some(parent) = Path::new(path).parent() else {
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
fs::create_dir_all(parent)
|
||||
.await
|
||||
.map_err(|err| err.to_string())
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
use std::{collections::HashSet, env};
|
||||
|
||||
pub(crate) const ROUTES_FILE: &str = "/etc/caddy/routes.conf";
|
||||
pub(crate) const PREVIEW_FILE: &str = "/etc/caddy/preview.conf";
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Config {
|
||||
target_container: String,
|
||||
router_state_file: String,
|
||||
public_base_url: String,
|
||||
preview_domain: String,
|
||||
router_username: String,
|
||||
router_password: String,
|
||||
scan_interval: u64,
|
||||
token_length: usize,
|
||||
port_range: (u16, u16),
|
||||
exclude_ports: HashSet<u16>,
|
||||
}
|
||||
|
||||
impl Config {
|
||||
pub fn from_env() -> Self {
|
||||
Self {
|
||||
target_container: env::var("TARGET_CONTAINER")
|
||||
.unwrap_or_else(|_| "opencode-backend".to_string()),
|
||||
router_state_file: env::var("ROUTER_STATE_FILE")
|
||||
.unwrap_or_else(|_| "/data/routes.json".to_string()),
|
||||
public_base_url: env::var("PUBLIC_BASE_URL")
|
||||
.map(|value| value.trim_end_matches('/').to_string())
|
||||
.unwrap_or_default(),
|
||||
preview_domain: env::var("PREVIEW_DOMAIN")
|
||||
.map(|value| value.trim().to_string())
|
||||
.unwrap_or_default(),
|
||||
router_username: env::var("ROUTER_USERNAME").unwrap_or_default(),
|
||||
router_password: env::var("ROUTER_PASSWORD").unwrap_or_default(),
|
||||
scan_interval: parse_u64_env("ROUTER_SCAN_INTERVAL", 5),
|
||||
token_length: parse_usize_env("ROUTER_TOKEN_LENGTH", 12),
|
||||
port_range: parse_port_range(
|
||||
env::var("ROUTER_PORT_RANGE")
|
||||
.ok()
|
||||
.as_deref()
|
||||
.unwrap_or("3000-9999"),
|
||||
),
|
||||
exclude_ports: parse_exclude_ports(
|
||||
env::var("ROUTER_EXCLUDE_PORTS")
|
||||
.ok()
|
||||
.as_deref()
|
||||
.unwrap_or("4096"),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn target_container(&self) -> &str {
|
||||
&self.target_container
|
||||
}
|
||||
|
||||
pub fn router_state_file(&self) -> &str {
|
||||
&self.router_state_file
|
||||
}
|
||||
|
||||
pub fn public_base_url(&self) -> &str {
|
||||
&self.public_base_url
|
||||
}
|
||||
|
||||
pub fn preview_domain(&self) -> &str {
|
||||
&self.preview_domain
|
||||
}
|
||||
|
||||
pub fn router_username(&self) -> &str {
|
||||
&self.router_username
|
||||
}
|
||||
|
||||
pub fn router_password(&self) -> &str {
|
||||
&self.router_password
|
||||
}
|
||||
|
||||
pub fn scan_interval(&self) -> u64 {
|
||||
self.scan_interval
|
||||
}
|
||||
|
||||
pub fn token_length(&self) -> usize {
|
||||
self.token_length
|
||||
}
|
||||
|
||||
pub fn port_range(&self) -> (u16, u16) {
|
||||
self.port_range
|
||||
}
|
||||
|
||||
pub fn exclude_ports(&self) -> &HashSet<u16> {
|
||||
&self.exclude_ports
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_u64_env(name: &str, default: u64) -> u64 {
|
||||
env::var(name)
|
||||
.ok()
|
||||
.and_then(|value| value.parse::<u64>().ok())
|
||||
.unwrap_or(default)
|
||||
}
|
||||
|
||||
fn parse_usize_env(name: &str, default: usize) -> usize {
|
||||
env::var(name)
|
||||
.ok()
|
||||
.and_then(|value| value.parse::<usize>().ok())
|
||||
.unwrap_or(default)
|
||||
}
|
||||
|
||||
fn parse_port_range(value: &str) -> (u16, u16) {
|
||||
let Some((start, end)) = value.split_once('-') else {
|
||||
return (3000, 9999);
|
||||
};
|
||||
|
||||
let mut start = start.trim().parse::<u16>().unwrap_or(3000);
|
||||
let mut end = end.trim().parse::<u16>().unwrap_or(9999);
|
||||
|
||||
if start > end {
|
||||
std::mem::swap(&mut start, &mut end);
|
||||
}
|
||||
|
||||
(start, end)
|
||||
}
|
||||
|
||||
fn parse_exclude_ports(value: &str) -> HashSet<u16> {
|
||||
value
|
||||
.split(',')
|
||||
.filter_map(|item| item.trim().parse::<u16>().ok())
|
||||
.collect()
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
mod caddy;
|
||||
mod config;
|
||||
mod router;
|
||||
mod scanner;
|
||||
mod state;
|
||||
|
||||
use std::{net::SocketAddr, sync::Arc};
|
||||
|
||||
use config::Config;
|
||||
use router::AppState;
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() {
|
||||
env_logger::init();
|
||||
|
||||
let config = Arc::new(Config::from_env());
|
||||
let state = Arc::new(AppState::new(config));
|
||||
|
||||
state.initialize().await;
|
||||
state.spawn_sync_loop();
|
||||
|
||||
let app = router::app(state);
|
||||
let addr = SocketAddr::from(([0, 0, 0, 0], 7070));
|
||||
|
||||
log::info!("Router starting on {}", addr);
|
||||
|
||||
let listener = tokio::net::TcpListener::bind(addr).await.unwrap();
|
||||
axum::serve(listener, app)
|
||||
.with_graceful_shutdown(shutdown_signal())
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
async fn shutdown_signal() {
|
||||
let _ = tokio::signal::ctrl_c().await;
|
||||
}
|
||||
@@ -0,0 +1,730 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
||||
<title>Routes - OpenCode</title>
|
||||
<style>
|
||||
:root {
|
||||
--bg-0: #fafbfc;
|
||||
--bg-1: #f3f5f7;
|
||||
--bg-2: #e8ecef;
|
||||
--bg-card: #ffffff;
|
||||
--bg-hover: #f7f9fa;
|
||||
--text-1: #1a2332;
|
||||
--text-2: #4a5568;
|
||||
--text-3: #8694a7;
|
||||
--accent: #3b82f6;
|
||||
--accent-hover: #2563eb;
|
||||
--accent-soft: #eff6ff;
|
||||
--green: #10b981;
|
||||
--green-soft: #ecfdf5;
|
||||
--border: #e2e8f0;
|
||||
--border-focus: #93c5fd;
|
||||
--shadow: 0 1px 3px rgba(0, 0, 0, 0.06), 0 1px 2px rgba(0, 0, 0, 0.04);
|
||||
--shadow-lg: 0 4px 12px rgba(0, 0, 0, 0.08);
|
||||
--radius: 10px;
|
||||
--radius-lg: 14px;
|
||||
--mono: 'SF Mono', 'Cascadia Code', 'Fira Code', 'JetBrains Mono', monospace;
|
||||
--sans: -apple-system, BlinkMacSystemFont, 'Inter', 'Segoe UI', sans-serif;
|
||||
}
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--bg-0: #0f1419;
|
||||
--bg-1: #161b22;
|
||||
--bg-2: #1c2333;
|
||||
--bg-card: #1c2333;
|
||||
--bg-hover: #232b3a;
|
||||
--text-1: #e6edf3;
|
||||
--text-2: #8b949e;
|
||||
--text-3: #545d68;
|
||||
--accent: #58a6ff;
|
||||
--accent-hover: #79c0ff;
|
||||
--accent-soft: #161b22;
|
||||
--green: #3fb950;
|
||||
--green-soft: #0d1117;
|
||||
--border: #30363d;
|
||||
--border-focus: #388bfd;
|
||||
--shadow: 0 1px 3px rgba(0, 0, 0, 0.3);
|
||||
--shadow-lg: 0 4px 12px rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
}
|
||||
html[data-theme='light'] {
|
||||
--bg-0: #fafbfc;
|
||||
--bg-1: #f3f5f7;
|
||||
--bg-2: #e8ecef;
|
||||
--bg-card: #ffffff;
|
||||
--bg-hover: #f7f9fa;
|
||||
--text-1: #1a2332;
|
||||
--text-2: #4a5568;
|
||||
--text-3: #8694a7;
|
||||
--accent: #3b82f6;
|
||||
--accent-hover: #2563eb;
|
||||
--accent-soft: #eff6ff;
|
||||
--green: #10b981;
|
||||
--green-soft: #ecfdf5;
|
||||
--border: #e2e8f0;
|
||||
--border-focus: #93c5fd;
|
||||
--shadow: 0 1px 3px rgba(0, 0, 0, 0.06), 0 1px 2px rgba(0, 0, 0, 0.04);
|
||||
--shadow-lg: 0 4px 12px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
html[data-theme='dark'] {
|
||||
--bg-0: #0f1419;
|
||||
--bg-1: #161b22;
|
||||
--bg-2: #1c2333;
|
||||
--bg-card: #1c2333;
|
||||
--bg-hover: #232b3a;
|
||||
--text-1: #e6edf3;
|
||||
--text-2: #8b949e;
|
||||
--text-3: #545d68;
|
||||
--accent: #58a6ff;
|
||||
--accent-hover: #79c0ff;
|
||||
--accent-soft: #161b22;
|
||||
--green: #3fb950;
|
||||
--green-soft: #0d1117;
|
||||
--border: #30363d;
|
||||
--border-focus: #388bfd;
|
||||
--shadow: 0 1px 3px rgba(0, 0, 0, 0.3);
|
||||
--shadow-lg: 0 4px 12px rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: var(--sans);
|
||||
background: var(--bg-0);
|
||||
color: var(--text-1);
|
||||
min-height: 100vh;
|
||||
min-height: 100dvh;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
.page {
|
||||
max-width: 640px;
|
||||
margin: 0 auto;
|
||||
padding: 40px 16px 60px;
|
||||
}
|
||||
|
||||
/* ---- Header ---- */
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
.header-left {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
.title {
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.02em;
|
||||
line-height: 1.2;
|
||||
}
|
||||
.status {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-top: 6px;
|
||||
font-size: 12px;
|
||||
color: var(--text-3);
|
||||
line-height: 1;
|
||||
}
|
||||
.status-dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: var(--green);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.header-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* ---- Icon buttons ---- */
|
||||
.icon-btn {
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
background: var(--bg-card);
|
||||
color: var(--text-2);
|
||||
cursor: pointer;
|
||||
transition: all 120ms ease;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.icon-btn:hover {
|
||||
background: var(--bg-hover);
|
||||
color: var(--text-1);
|
||||
border-color: var(--border-focus);
|
||||
}
|
||||
.icon-btn:active {
|
||||
transform: scale(0.93);
|
||||
}
|
||||
.icon-btn svg {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
/* ---- Search ---- */
|
||||
.search-wrap {
|
||||
position: relative;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.search-icon {
|
||||
position: absolute;
|
||||
left: 11px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
width: 15px;
|
||||
height: 15px;
|
||||
color: var(--text-3);
|
||||
pointer-events: none;
|
||||
}
|
||||
.search {
|
||||
width: 100%;
|
||||
height: 38px;
|
||||
padding: 0 12px 0 34px;
|
||||
border-radius: var(--radius);
|
||||
border: 1px solid var(--border);
|
||||
background: var(--bg-card);
|
||||
color: var(--text-1);
|
||||
font-size: 13px;
|
||||
font-family: var(--sans);
|
||||
outline: none;
|
||||
transition:
|
||||
border-color 150ms,
|
||||
box-shadow 150ms;
|
||||
}
|
||||
.search::placeholder {
|
||||
color: var(--text-3);
|
||||
}
|
||||
.search:focus {
|
||||
border-color: var(--border-focus);
|
||||
box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 12%, transparent);
|
||||
}
|
||||
|
||||
/* ---- Card list ---- */
|
||||
.list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.card {
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 14px 16px;
|
||||
box-shadow: var(--shadow);
|
||||
transition:
|
||||
box-shadow 150ms,
|
||||
border-color 150ms;
|
||||
}
|
||||
.card:hover {
|
||||
box-shadow: var(--shadow-lg);
|
||||
border-color: var(--border-focus);
|
||||
}
|
||||
.card-top {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
.port-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
height: 24px;
|
||||
padding: 0 8px;
|
||||
border-radius: 6px;
|
||||
background: var(--accent-soft);
|
||||
color: var(--accent);
|
||||
font-family: var(--mono);
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
flex-shrink: 0;
|
||||
letter-spacing: 0.01em;
|
||||
}
|
||||
.card-time {
|
||||
font-size: 11px;
|
||||
color: var(--text-3);
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.card-url {
|
||||
display: block;
|
||||
margin-top: 8px;
|
||||
font-family: var(--mono);
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
color: var(--text-2);
|
||||
word-break: break-all;
|
||||
user-select: all;
|
||||
}
|
||||
/* ---- Empty state ---- */
|
||||
.empty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 56px 24px;
|
||||
text-align: center;
|
||||
border: 1px dashed var(--border);
|
||||
border-radius: var(--radius-lg);
|
||||
background: var(--bg-card);
|
||||
}
|
||||
.empty-icon {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
color: var(--text-3);
|
||||
opacity: 0.5;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.empty-title {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--text-2);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.empty-hint {
|
||||
font-size: 12px;
|
||||
color: var(--text-3);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
/* ---- Toast ---- */
|
||||
.toast {
|
||||
position: fixed;
|
||||
bottom: 24px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%) translateY(80px);
|
||||
padding: 8px 16px;
|
||||
border-radius: 8px;
|
||||
background: var(--text-1);
|
||||
color: var(--bg-0);
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
box-shadow: var(--shadow-lg);
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition: all 250ms cubic-bezier(0.4, 0, 0.2, 1);
|
||||
z-index: 100;
|
||||
}
|
||||
.toast.show {
|
||||
opacity: 1;
|
||||
transform: translateX(-50%) translateY(0);
|
||||
}
|
||||
|
||||
/* ---- Mobile ---- */
|
||||
@media (max-width: 480px) {
|
||||
.page {
|
||||
padding: 24px 12px 48px;
|
||||
}
|
||||
.title {
|
||||
font-size: 18px;
|
||||
}
|
||||
.card {
|
||||
padding: 12px 14px;
|
||||
}
|
||||
.card-time {
|
||||
font-size: 10px;
|
||||
}
|
||||
.card-url {
|
||||
font-size: 11px;
|
||||
}
|
||||
}
|
||||
|
||||
/* ---- Preview badge ---- */
|
||||
.preview-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 10px 14px;
|
||||
margin-bottom: 16px;
|
||||
border-radius: var(--radius);
|
||||
background: var(--accent-soft);
|
||||
border: 1px solid color-mix(in srgb, var(--accent) 20%, transparent);
|
||||
font-size: 12px;
|
||||
color: var(--text-2);
|
||||
}
|
||||
.preview-bar-icon {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
color: var(--accent);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.preview-bar a {
|
||||
color: var(--accent);
|
||||
text-decoration: none;
|
||||
font-family: var(--mono);
|
||||
font-weight: 500;
|
||||
word-break: break-all;
|
||||
}
|
||||
.preview-bar a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
.preview-bar-stop {
|
||||
margin-left: auto;
|
||||
padding: 3px 8px;
|
||||
border-radius: 5px;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--bg-card);
|
||||
color: var(--text-2);
|
||||
font-size: 11px;
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.preview-bar-stop:hover {
|
||||
background: var(--bg-hover);
|
||||
color: var(--text-1);
|
||||
}
|
||||
|
||||
.card.previewing {
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 0 2px color-mix(in srgb, var(--accent) 15%, transparent);
|
||||
}
|
||||
.card-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
margin-left: auto;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.card-btn {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
background: var(--bg-card);
|
||||
color: var(--text-3);
|
||||
cursor: pointer;
|
||||
transition: all 120ms ease;
|
||||
}
|
||||
.card-btn:hover {
|
||||
background: var(--bg-hover);
|
||||
color: var(--accent);
|
||||
border-color: var(--accent);
|
||||
}
|
||||
.card-btn:active {
|
||||
transform: scale(0.9);
|
||||
}
|
||||
.card-btn.active {
|
||||
background: var(--accent-soft);
|
||||
color: var(--accent);
|
||||
border-color: var(--accent);
|
||||
}
|
||||
.card-btn svg {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="page">
|
||||
<div class="header">
|
||||
<div class="header-left">
|
||||
<div class="title">Routes</div>
|
||||
<div class="status">
|
||||
<span class="status-dot"></span>
|
||||
<span id="count">0 active</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<button class="icon-btn" id="themeToggle" title="Toggle theme">
|
||||
<svg
|
||||
id="themeIcon"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<circle cx="12" cy="12" r="5" />
|
||||
<line x1="12" y1="1" x2="12" y2="3" />
|
||||
<line x1="12" y1="21" x2="12" y2="23" />
|
||||
<line x1="4.22" y1="4.22" x2="5.64" y2="5.64" />
|
||||
<line x1="18.36" y1="18.36" x2="19.78" y2="19.78" />
|
||||
<line x1="1" y1="12" x2="3" y2="12" />
|
||||
<line x1="21" y1="12" x2="23" y2="12" />
|
||||
<line x1="4.22" y1="19.78" x2="5.64" y2="18.36" />
|
||||
<line x1="18.36" y1="5.64" x2="19.78" y2="4.22" />
|
||||
</svg>
|
||||
</button>
|
||||
<button class="icon-btn" id="copyAll" title="Copy all URLs">
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<rect x="9" y="9" width="13" height="13" rx="2" ry="2" />
|
||||
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" />
|
||||
</svg>
|
||||
</button>
|
||||
<button class="icon-btn" id="refresh" title="Refresh">
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<polyline points="23 4 23 10 17 10" />
|
||||
<path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="previewBar"></div>
|
||||
|
||||
<div class="search-wrap">
|
||||
<svg
|
||||
class="search-icon"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<circle cx="11" cy="11" r="8" />
|
||||
<line x1="21" y1="21" x2="16.65" y2="16.65" />
|
||||
</svg>
|
||||
<input class="search" id="filter" placeholder="Filter by port or URL..." />
|
||||
</div>
|
||||
|
||||
<section id="content"></section>
|
||||
<div class="toast" id="toast"></div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const $ = id => document.getElementById(id)
|
||||
const content = $('content'),
|
||||
countEl = $('count')
|
||||
const filterInput = $('filter'),
|
||||
toastEl = $('toast')
|
||||
|
||||
// ---- Toast ----
|
||||
let toastTimer
|
||||
function toast(msg) {
|
||||
clearTimeout(toastTimer)
|
||||
toastEl.textContent = msg
|
||||
toastEl.classList.add('show')
|
||||
toastTimer = setTimeout(() => toastEl.classList.remove('show'), 1800)
|
||||
}
|
||||
|
||||
// ---- Theme ----
|
||||
const sunIcon =
|
||||
'<circle cx="12" cy="12" r="5"/><line x1="12" y1="1" x2="12" y2="3"/><line x1="12" y1="21" x2="12" y2="23"/><line x1="4.22" y1="4.22" x2="5.64" y2="5.64"/><line x1="18.36" y1="18.36" x2="19.78" y2="19.78"/><line x1="1" y1="12" x2="3" y2="12"/><line x1="21" y1="12" x2="23" y2="12"/><line x1="4.22" y1="19.78" x2="5.64" y2="18.36"/><line x1="18.36" y1="5.64" x2="19.78" y2="4.22"/>'
|
||||
const moonIcon = '<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/>'
|
||||
const autoIcon = '<circle cx="12" cy="12" r="10"/><path d="M12 2a10 10 0 0 1 0 20z" fill="currentColor"/>'
|
||||
|
||||
function applyTheme(t) {
|
||||
if (!t || t === 'auto') document.documentElement.removeAttribute('data-theme')
|
||||
else document.documentElement.setAttribute('data-theme', t)
|
||||
const icon = t === 'dark' ? moonIcon : t === 'light' ? sunIcon : autoIcon
|
||||
$('themeIcon').innerHTML = icon
|
||||
$('themeToggle').title = 'Theme: ' + (t || 'auto')
|
||||
}
|
||||
function cycleTheme() {
|
||||
const m = { light: 'dark', dark: 'auto', auto: 'light' }
|
||||
const cur = localStorage.getItem('routes-theme') || 'auto'
|
||||
const next = m[cur] || 'light'
|
||||
localStorage.setItem('routes-theme', next)
|
||||
applyTheme(next)
|
||||
}
|
||||
applyTheme(localStorage.getItem('routes-theme') || 'auto')
|
||||
$('themeToggle').addEventListener('click', cycleTheme)
|
||||
|
||||
// ---- Helpers ----
|
||||
function ago(ts) {
|
||||
if (!ts) return ''
|
||||
const s = Math.floor(Date.now() / 1000 - ts)
|
||||
if (s < 60) return 'just now'
|
||||
if (s < 3600) return Math.floor(s / 60) + 'm ago'
|
||||
if (s < 86400) return Math.floor(s / 3600) + 'h ago'
|
||||
return Math.floor(s / 86400) + 'd ago'
|
||||
}
|
||||
function getUrl(r) {
|
||||
if (r.publicUrl) return r.publicUrl
|
||||
const domain = currentData.previewDomain
|
||||
if (domain) return 'https://' + domain + '/p/' + r.token + '/'
|
||||
return location.origin + '/p/' + r.token + '/'
|
||||
}
|
||||
|
||||
// ---- Render ----
|
||||
function render(routes) {
|
||||
countEl.textContent = routes.length + ' active'
|
||||
|
||||
if (!routes.length) {
|
||||
content.innerHTML = `
|
||||
<div class="empty">
|
||||
<svg class="empty-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
|
||||
<rect x="2" y="3" width="20" height="14" rx="2" ry="2"/><line x1="8" y1="21" x2="16" y2="21"/><line x1="12" y1="17" x2="12" y2="21"/>
|
||||
</svg>
|
||||
<div class="empty-title">No active routes</div>
|
||||
<div class="empty-hint">Start a dev server in the backend container<br/>and it will appear here automatically</div>
|
||||
</div>`
|
||||
return
|
||||
}
|
||||
|
||||
const cards = routes.map(r => {
|
||||
const url = getUrl(r)
|
||||
const isPreviewing = currentData.previewPort === r.port
|
||||
const previewCls = isPreviewing ? ' previewing' : ''
|
||||
const btnCls = isPreviewing ? ' active' : ''
|
||||
return `<div class="card${previewCls}" data-url="${url}" data-port="${r.port}">
|
||||
<div class="card-top">
|
||||
<span class="port-badge">${r.port}</span>
|
||||
<span class="card-time">${ago(r.createdAt)}</span>
|
||||
<div class="card-actions">
|
||||
<button class="card-btn preview-btn${btnCls}" data-port="${r.port}" title="${isPreviewing ? 'Stop preview' : 'Preview this port'}">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/>
|
||||
</svg>
|
||||
</button>
|
||||
<button class="card-btn copy-btn" data-url="${url}" title="Copy URL">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<code class="card-url">${url}</code>
|
||||
</div>`
|
||||
})
|
||||
|
||||
content.innerHTML = '<div class="list">' + cards.join('') + '</div>'
|
||||
|
||||
content.querySelectorAll('.copy-btn').forEach(btn => {
|
||||
btn.addEventListener('click', async e => {
|
||||
e.stopPropagation()
|
||||
try {
|
||||
await navigator.clipboard.writeText(btn.dataset.url)
|
||||
toast('Copied')
|
||||
} catch (e) {}
|
||||
})
|
||||
})
|
||||
|
||||
content.querySelectorAll('.preview-btn').forEach(btn => {
|
||||
btn.addEventListener('click', async e => {
|
||||
e.stopPropagation()
|
||||
const port = parseInt(btn.dataset.port)
|
||||
const isActive = currentData.previewPort === port
|
||||
await setPreview(isActive ? null : port)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// ---- Data ----
|
||||
let currentData = __INITIAL_DATA__
|
||||
|
||||
// ---- Preview ----
|
||||
const previewBarEl = $('previewBar')
|
||||
const eyeOpenIcon = '<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/>'
|
||||
|
||||
function renderPreviewBar() {
|
||||
const port = currentData.previewPort
|
||||
const domain = currentData.previewDomain
|
||||
if (!port) {
|
||||
previewBarEl.innerHTML = ''
|
||||
return
|
||||
}
|
||||
const link = domain ? `<a href="https://${domain}" target="_blank">${domain}</a>` : `port ${port}`
|
||||
previewBarEl.innerHTML = `
|
||||
<div class="preview-bar">
|
||||
<svg class="preview-bar-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">${eyeOpenIcon}</svg>
|
||||
<span>Preview active: ${link} → :${port}</span>
|
||||
<button class="preview-bar-stop" id="stopPreview">Stop</button>
|
||||
</div>`
|
||||
$('stopPreview').addEventListener('click', () => setPreview(null))
|
||||
}
|
||||
|
||||
async function setPreview(port) {
|
||||
try {
|
||||
const res = await fetch('/preview/set', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'same-origin',
|
||||
body: JSON.stringify({ port }),
|
||||
})
|
||||
if (!res.ok) {
|
||||
toast('Failed: ' + res.status)
|
||||
return
|
||||
}
|
||||
currentData.previewPort = port
|
||||
toast(port ? 'Preview: port ' + port : 'Preview stopped')
|
||||
renderPreviewBar()
|
||||
applyFilter()
|
||||
} catch (e) {
|
||||
toast('Error setting preview')
|
||||
}
|
||||
}
|
||||
|
||||
function applyFilter() {
|
||||
const q = filterInput.value.trim().toLowerCase()
|
||||
const routes = (currentData.routes || []).filter(
|
||||
r =>
|
||||
!q ||
|
||||
r.token.toLowerCase().includes(q) ||
|
||||
String(r.port).includes(q) ||
|
||||
getUrl(r).toLowerCase().includes(q),
|
||||
)
|
||||
render(routes)
|
||||
}
|
||||
|
||||
async function load() {
|
||||
try {
|
||||
const res = await fetch('/routes?format=json', { cache: 'no-store', credentials: 'same-origin' })
|
||||
if (!res.ok) {
|
||||
countEl.textContent = 'error ' + res.status
|
||||
return
|
||||
}
|
||||
currentData = await res.json()
|
||||
applyFilter()
|
||||
renderPreviewBar()
|
||||
} catch (e) {
|
||||
countEl.textContent = 'offline'
|
||||
}
|
||||
}
|
||||
|
||||
$('refresh').addEventListener('click', () => {
|
||||
load()
|
||||
toast('Refreshing...')
|
||||
})
|
||||
filterInput.addEventListener('input', applyFilter)
|
||||
$('copyAll').addEventListener('click', async () => {
|
||||
const urls = (currentData.routes || []).map(getUrl).join('\\n')
|
||||
if (urls) {
|
||||
await navigator.clipboard.writeText(urls)
|
||||
toast('All URLs copied')
|
||||
}
|
||||
})
|
||||
|
||||
applyFilter()
|
||||
renderPreviewBar()
|
||||
setInterval(load, 8000)
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,396 @@
|
||||
use std::{
|
||||
collections::HashSet,
|
||||
sync::Arc,
|
||||
time::{Duration, SystemTime, UNIX_EPOCH},
|
||||
};
|
||||
|
||||
use axum::{
|
||||
Json, Router,
|
||||
extract::{Query, State},
|
||||
http::{HeaderMap, HeaderValue, StatusCode, header},
|
||||
response::{Html, IntoResponse, Response},
|
||||
routing::{get, post},
|
||||
};
|
||||
use base64::{Engine as _, engine::general_purpose::STANDARD};
|
||||
use rand::{Rng, distr::Alphanumeric, rng};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{Map, Value, json};
|
||||
use tokio::{
|
||||
sync::RwLock,
|
||||
time::{MissedTickBehavior, interval},
|
||||
};
|
||||
|
||||
use crate::{
|
||||
caddy,
|
||||
config::Config,
|
||||
scanner,
|
||||
state::{self, RouteInfo},
|
||||
};
|
||||
|
||||
const HTML_TEMPLATE: &str = include_str!("router.html");
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct AppState {
|
||||
config: Arc<Config>,
|
||||
/// In-memory cache of the full state map (routes + preview port).
|
||||
/// All reads/writes go through this lock; disk is only for persistence.
|
||||
state_map: Arc<RwLock<Map<String, Value>>>,
|
||||
preview_port: Arc<RwLock<Option<u16>>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct RoutePayloadItem {
|
||||
token: String,
|
||||
port: u16,
|
||||
public_url: String,
|
||||
created_at: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct RoutesPayload {
|
||||
routes: Vec<RoutePayloadItem>,
|
||||
preview_port: Option<u16>,
|
||||
preview_domain: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct PreviewSetRequest {
|
||||
port: Option<u16>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct RoutesQuery {
|
||||
format: Option<String>,
|
||||
}
|
||||
|
||||
impl AppState {
|
||||
pub fn new(config: Arc<Config>) -> Self {
|
||||
Self {
|
||||
config,
|
||||
state_map: Arc::new(RwLock::new(Map::new())),
|
||||
preview_port: Arc::new(RwLock::new(None)),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn initialize(&self) {
|
||||
let state_map = state::load_state_map(self.config.router_state_file()).await;
|
||||
let preview_port = state::load_preview_port(&state_map);
|
||||
|
||||
{
|
||||
let mut cached = self.state_map.write().await;
|
||||
*cached = state_map;
|
||||
}
|
||||
{
|
||||
let mut current = self.preview_port.write().await;
|
||||
*current = preview_port;
|
||||
}
|
||||
|
||||
if let Err(err) = caddy::write_preview_conf(&self.config, preview_port).await {
|
||||
log::error!("Failed to write preview config: {}", err);
|
||||
}
|
||||
|
||||
if let Some(port) = preview_port {
|
||||
log::info!("Restored preview port: {}", port);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn spawn_sync_loop(self: &Arc<Self>) {
|
||||
let state = Arc::clone(self);
|
||||
tokio::spawn(async move {
|
||||
log::info!(
|
||||
"Route scanner started (interval={}s, target={}, range={}-{})",
|
||||
state.config.scan_interval(),
|
||||
state.config.target_container(),
|
||||
state.config.port_range().0,
|
||||
state.config.port_range().1,
|
||||
);
|
||||
|
||||
let mut ticker = interval(Duration::from_secs(state.config.scan_interval()));
|
||||
ticker.set_missed_tick_behavior(MissedTickBehavior::Skip);
|
||||
|
||||
loop {
|
||||
ticker.tick().await;
|
||||
if let Err(err) = state.sync_routes().await {
|
||||
log::error!("Error in sync_routes: {}", err);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async fn sync_routes(&self) -> Result<(), String> {
|
||||
let ports = match scanner::list_listening_ports(&self.config).await {
|
||||
Ok(ports) => ports,
|
||||
Err(err) => {
|
||||
log::warn!(
|
||||
"Failed to query ports from {}: {}",
|
||||
self.config.target_container(),
|
||||
err
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
};
|
||||
|
||||
let mut state_map = self.state_map.write().await;
|
||||
let mut routes = state::extract_routes(&state_map);
|
||||
let existing_ports: HashSet<u16> = routes.values().map(|info| info.port).collect();
|
||||
let mut changed = false;
|
||||
|
||||
for port in &ports {
|
||||
if existing_ports.contains(port) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let mut token = generate_token(self.config.token_length());
|
||||
while state_map.contains_key(&token) {
|
||||
token = generate_token(self.config.token_length());
|
||||
}
|
||||
|
||||
let route = RouteInfo {
|
||||
port: *port,
|
||||
created_at: now_ts(),
|
||||
};
|
||||
|
||||
routes.insert(token.clone(), route.clone());
|
||||
state_map.insert(token.clone(), state::route_to_value(&route));
|
||||
log::info!("New route: port {} -> /p/{}", port, token);
|
||||
changed = true;
|
||||
}
|
||||
|
||||
let active_ports: HashSet<u16> = ports.iter().copied().collect();
|
||||
let stale_tokens: Vec<String> = routes
|
||||
.iter()
|
||||
.filter(|(_, info)| !active_ports.contains(&info.port))
|
||||
.map(|(token, _)| token.clone())
|
||||
.collect();
|
||||
|
||||
for token in stale_tokens {
|
||||
if let Some(info) = routes.remove(&token) {
|
||||
state_map.remove(&token);
|
||||
log::info!("Removed stale route: port {} (token {})", info.port, token);
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
|
||||
if changed {
|
||||
caddy::write_map(&self.config, &routes).await?;
|
||||
state::save_state_map(self.config.router_state_file(), &state_map).await?;
|
||||
self.reload_gateway().await;
|
||||
log::info!("Routes updated: {} active", routes.len());
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn save_preview_port(&self, port: Option<u16>) -> Result<(), String> {
|
||||
{
|
||||
let mut current = self.preview_port.write().await;
|
||||
*current = port;
|
||||
}
|
||||
|
||||
let mut state_map = self.state_map.write().await;
|
||||
state::set_preview_port_value(&mut state_map, port);
|
||||
state::save_state_map(self.config.router_state_file(), &state_map).await
|
||||
}
|
||||
|
||||
async fn set_preview_port(&self, port: Option<u16>) -> Result<(), String> {
|
||||
caddy::write_preview_conf(&self.config, port).await?;
|
||||
self.save_preview_port(port).await?;
|
||||
self.reload_gateway().await;
|
||||
log::info!("Preview port set to: {:?}", port);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn reload_gateway(&self) {
|
||||
if let Err(err) = caddy::reload_gateway().await {
|
||||
log::warn!("Failed to reload caddy: {}", err);
|
||||
}
|
||||
}
|
||||
|
||||
async fn build_routes_payload(&self) -> RoutesPayload {
|
||||
let state_map = self.state_map.read().await;
|
||||
let routes = state::extract_routes(&state_map)
|
||||
.into_iter()
|
||||
.map(|(token, info)| RoutePayloadItem {
|
||||
public_url: self.public_url(&token),
|
||||
token,
|
||||
port: info.port,
|
||||
created_at: info.created_at,
|
||||
})
|
||||
.collect();
|
||||
|
||||
RoutesPayload {
|
||||
routes,
|
||||
preview_port: *self.preview_port.read().await,
|
||||
preview_domain: non_empty(self.config.preview_domain()),
|
||||
}
|
||||
}
|
||||
|
||||
fn public_url(&self, token: &str) -> String {
|
||||
if !self.config.preview_domain().is_empty() {
|
||||
return format!("https://{}/p/{}/", self.config.preview_domain(), token);
|
||||
}
|
||||
|
||||
if !self.config.public_base_url().is_empty() {
|
||||
return format!("{}/p/{}/", self.config.public_base_url(), token);
|
||||
}
|
||||
|
||||
String::new()
|
||||
}
|
||||
}
|
||||
|
||||
pub fn app(state: Arc<AppState>) -> Router {
|
||||
Router::new()
|
||||
.route("/routes", get(get_routes))
|
||||
.route("/preview/set", post(post_preview_set))
|
||||
.route("/preview/status", get(get_preview_status))
|
||||
.with_state(state)
|
||||
}
|
||||
|
||||
async fn get_routes(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Query(query): Query<RoutesQuery>,
|
||||
headers: HeaderMap,
|
||||
) -> Response {
|
||||
if !check_basic_auth(&headers, &state.config) {
|
||||
return unauthorized_response();
|
||||
}
|
||||
|
||||
let payload = state.build_routes_payload().await;
|
||||
log::info!(
|
||||
"Serving /routes ({} routes, format={})",
|
||||
payload.routes.len(),
|
||||
query.format.as_deref().unwrap_or("html")
|
||||
);
|
||||
|
||||
if query.format.as_deref() == Some("json") {
|
||||
let mut response = Json(payload).into_response();
|
||||
set_no_cache_headers(response.headers_mut());
|
||||
return response;
|
||||
}
|
||||
|
||||
let initial_data = serde_json::to_string(&payload).unwrap_or_else(|_| "{}".to_string());
|
||||
let html = HTML_TEMPLATE.replace("__INITIAL_DATA__", &initial_data);
|
||||
let mut response = Html(html).into_response();
|
||||
set_no_cache_headers(response.headers_mut());
|
||||
response
|
||||
}
|
||||
|
||||
async fn get_preview_status(State(state): State<Arc<AppState>>, headers: HeaderMap) -> Response {
|
||||
if !check_basic_auth(&headers, &state.config) {
|
||||
return unauthorized_response();
|
||||
}
|
||||
|
||||
let payload = json!({
|
||||
"previewPort": *state.preview_port.read().await,
|
||||
"previewDomain": non_empty(state.config.preview_domain()),
|
||||
});
|
||||
Json(payload).into_response()
|
||||
}
|
||||
|
||||
async fn post_preview_set(
|
||||
State(state): State<Arc<AppState>>,
|
||||
headers: HeaderMap,
|
||||
payload: Result<Json<PreviewSetRequest>, axum::extract::rejection::JsonRejection>,
|
||||
) -> Response {
|
||||
if !check_basic_auth(&headers, &state.config) {
|
||||
return unauthorized_response();
|
||||
}
|
||||
|
||||
let Json(request) = match payload {
|
||||
Ok(payload) => payload,
|
||||
Err(_) => {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(json!({ "error": "invalid port" })),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
|
||||
match state.set_preview_port(request.port).await {
|
||||
Ok(()) => Json(json!({ "ok": true, "previewPort": request.port })).into_response(),
|
||||
Err(err) => {
|
||||
log::error!("Failed to set preview port: {}", err);
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({ "error": "failed to set preview port" })),
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn check_basic_auth(headers: &HeaderMap, config: &Config) -> bool {
|
||||
if config.router_password().is_empty() {
|
||||
return true;
|
||||
}
|
||||
|
||||
let Some(auth_header) = headers.get(header::AUTHORIZATION) else {
|
||||
return false;
|
||||
};
|
||||
|
||||
let Ok(auth_header) = auth_header.to_str() else {
|
||||
return false;
|
||||
};
|
||||
|
||||
let lower = auth_header.to_ascii_lowercase();
|
||||
if !lower.starts_with("basic ") {
|
||||
return false;
|
||||
};
|
||||
let raw = &auth_header["basic ".len()..];
|
||||
|
||||
let Ok(decoded) = STANDARD.decode(raw.trim()) else {
|
||||
return false;
|
||||
};
|
||||
|
||||
let Ok(decoded) = String::from_utf8(decoded) else {
|
||||
return false;
|
||||
};
|
||||
|
||||
let Some((user, password)) = decoded.split_once(':') else {
|
||||
return false;
|
||||
};
|
||||
|
||||
user == config.router_username() && password == config.router_password()
|
||||
}
|
||||
|
||||
fn unauthorized_response() -> Response {
|
||||
let mut response = (StatusCode::UNAUTHORIZED, "Unauthorized").into_response();
|
||||
response
|
||||
.headers_mut()
|
||||
.insert(header::WWW_AUTHENTICATE, HeaderValue::from_static("Basic"));
|
||||
response
|
||||
}
|
||||
|
||||
fn set_no_cache_headers(headers: &mut HeaderMap) {
|
||||
headers.insert(
|
||||
header::CACHE_CONTROL,
|
||||
HeaderValue::from_static("no-cache, no-store, must-revalidate"),
|
||||
);
|
||||
headers.insert(header::PRAGMA, HeaderValue::from_static("no-cache"));
|
||||
}
|
||||
|
||||
fn generate_token(length: usize) -> String {
|
||||
rng()
|
||||
.sample_iter(Alphanumeric)
|
||||
.take(length)
|
||||
.map(char::from)
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn now_ts() -> u64 {
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_secs()
|
||||
}
|
||||
|
||||
fn non_empty(value: &str) -> Option<String> {
|
||||
if value.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(value.to_string())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
use std::collections::HashSet;
|
||||
|
||||
use tokio::process::Command;
|
||||
|
||||
use crate::config::Config;
|
||||
|
||||
pub async fn list_listening_ports(config: &Config) -> Result<Vec<u16>, String> {
|
||||
let output = run_cmd(&[
|
||||
"docker",
|
||||
"exec",
|
||||
config.target_container(),
|
||||
"sh",
|
||||
"-c",
|
||||
"cat /proc/net/tcp /proc/net/tcp6",
|
||||
])
|
||||
.await?;
|
||||
|
||||
let ports = parse_proc_net_tcp(&output);
|
||||
let (start, end) = config.port_range();
|
||||
let mut filtered: Vec<u16> = ports
|
||||
.into_iter()
|
||||
.filter(|port| !config.exclude_ports().contains(port))
|
||||
.filter(|port| *port >= start && *port <= end)
|
||||
.collect();
|
||||
filtered.sort_unstable();
|
||||
Ok(filtered)
|
||||
}
|
||||
|
||||
pub(crate) async fn run_cmd(args: &[&str]) -> Result<String, String> {
|
||||
let (program, rest) = args
|
||||
.split_first()
|
||||
.ok_or_else(|| "empty command".to_string())?;
|
||||
|
||||
let output = Command::new(program)
|
||||
.args(rest)
|
||||
.output()
|
||||
.await
|
||||
.map_err(|err| err.to_string())?;
|
||||
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
|
||||
log::warn!(
|
||||
"Command failed (rc={}): {} | stderr: {}",
|
||||
output.status.code().unwrap_or(-1),
|
||||
args.join(" "),
|
||||
stderr,
|
||||
);
|
||||
return Err(stderr);
|
||||
}
|
||||
|
||||
Ok(String::from_utf8_lossy(&output.stdout).into_owned())
|
||||
}
|
||||
|
||||
fn parse_proc_net_tcp(output: &str) -> HashSet<u16> {
|
||||
let mut ports = HashSet::new();
|
||||
|
||||
for line in output.lines() {
|
||||
let line = line.trim();
|
||||
if line.is_empty() || line.starts_with("sl") {
|
||||
continue;
|
||||
}
|
||||
|
||||
let mut parts = line.split_whitespace();
|
||||
let _slot = parts.next();
|
||||
let Some(local) = parts.next() else {
|
||||
continue;
|
||||
};
|
||||
let _remote = parts.next();
|
||||
let Some(state) = parts.next() else {
|
||||
continue;
|
||||
};
|
||||
|
||||
if state != "0A" {
|
||||
continue;
|
||||
}
|
||||
|
||||
let Some((_, port_hex)) = local.rsplit_once(':') else {
|
||||
continue;
|
||||
};
|
||||
|
||||
let Ok(port) = u16::from_str_radix(port_hex, 16) else {
|
||||
continue;
|
||||
};
|
||||
|
||||
ports.insert(port);
|
||||
}
|
||||
|
||||
ports
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
use std::{collections::BTreeMap, path::Path};
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{Map, Value, json};
|
||||
use tokio::fs;
|
||||
|
||||
const PREVIEW_STATE_KEY: &str = "__preview_port__";
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub(crate) struct RouteInfo {
|
||||
pub(crate) port: u16,
|
||||
pub(crate) created_at: u64,
|
||||
}
|
||||
|
||||
pub(crate) async fn load_state_map(path: &str) -> Map<String, Value> {
|
||||
let Ok(content) = fs::read_to_string(path).await else {
|
||||
return Map::new();
|
||||
};
|
||||
|
||||
serde_json::from_str::<Map<String, Value>>(&content).unwrap_or_default()
|
||||
}
|
||||
|
||||
pub(crate) async fn save_state_map(
|
||||
path: &str,
|
||||
state_map: &Map<String, Value>,
|
||||
) -> Result<(), String> {
|
||||
ensure_parent_dir(path).await?;
|
||||
|
||||
let mut sorted = BTreeMap::new();
|
||||
for (key, value) in state_map {
|
||||
sorted.insert(key.clone(), value.clone());
|
||||
}
|
||||
|
||||
let body = serde_json::to_string_pretty(&sorted).map_err(|err| err.to_string())?;
|
||||
fs::write(path, format!("{body}\n"))
|
||||
.await
|
||||
.map_err(|err| err.to_string())
|
||||
}
|
||||
|
||||
pub(crate) fn extract_routes(state_map: &Map<String, Value>) -> BTreeMap<String, RouteInfo> {
|
||||
state_map
|
||||
.iter()
|
||||
.filter(|(key, _)| !key.starts_with("__"))
|
||||
.filter_map(|(token, value)| {
|
||||
serde_json::from_value::<RouteInfo>(value.clone())
|
||||
.ok()
|
||||
.map(|info| (token.clone(), info))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub(crate) fn route_to_value(route: &RouteInfo) -> Value {
|
||||
json!({
|
||||
"port": route.port,
|
||||
"created_at": route.created_at,
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn load_preview_port(state_map: &Map<String, Value>) -> Option<u16> {
|
||||
state_map
|
||||
.get(PREVIEW_STATE_KEY)
|
||||
.and_then(Value::as_u64)
|
||||
.and_then(|value| u16::try_from(value).ok())
|
||||
}
|
||||
|
||||
pub(crate) fn set_preview_port_value(state_map: &mut Map<String, Value>, port: Option<u16>) {
|
||||
match port {
|
||||
Some(port) => {
|
||||
state_map.insert(PREVIEW_STATE_KEY.to_string(), json!(port));
|
||||
}
|
||||
None => {
|
||||
state_map.remove(PREVIEW_STATE_KEY);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn ensure_parent_dir(path: &str) -> Result<(), String> {
|
||||
let Some(parent) = Path::new(path).parent() else {
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
fs::create_dir_all(parent)
|
||||
.await
|
||||
.map_err(|err| err.to_string())
|
||||
}
|
||||
Reference in new issue
Block a user