diff --git a/.gitignore b/.gitignore index 1eba73c..60eb95e 100644 --- a/.gitignore +++ b/.gitignore @@ -2,7 +2,26 @@ /dist /dist-ui /node_modules -/gen /tools *.zip .claude/ + +# Сгенерированный Android-проект коммитится: в нём манифест, разрешения и +# Kotlin-код сервиса. Игнорируются только артефакты сборки и локальные пути. +/gen/apple +# ACL-схемы генерируются tauri-build при каждой сборке. +/gen/schemas +/gen/android/.gradle +/gen/android/.idea +/gen/android/build +/gen/android/app/build +/gen/android/buildSrc/build +/gen/android/local.properties +/gen/android/.tauri +/gen/android/tauri.settings.gradle +/gen/android/app/src/main/jniLibs +/gen/android/app/src/main/assets +*.keystore +*.jks +keystore.properties +key.properties diff --git a/Cargo.lock b/Cargo.lock index 4d1f669..953610e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -108,6 +108,137 @@ version = "1.0.104" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" +[[package]] +name = "async-broadcast" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "435a87a52755b8f27fcf321ac4f04b2802e337c8c4872923137471ec39c37532" +dependencies = [ + "event-listener", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-channel" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "924ed96dd52d1b75e9c1a3e6275715fd320f5f9439fb5a4a11fa51f4221158d2" +dependencies = [ + "concurrent-queue", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-executor" +version = "1.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c96bf972d85afc50bf5ab8fe2d54d1586b4e0b46c97c50a0c9e71e2f7bcd812a" +dependencies = [ + "async-task", + "concurrent-queue", + "fastrand", + "futures-lite", + "pin-project-lite", + "slab", +] + +[[package]] +name = "async-io" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "456b8a8feb6f42d237746d4b3e9a178494627745c3c56c6ea55d92ba50d026fc" +dependencies = [ + "autocfg", + "cfg-if", + "concurrent-queue", + "futures-io", + "futures-lite", + "parking", + "polling", + "rustix", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-lock" +version = "3.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311" +dependencies = [ + "event-listener", + "event-listener-strategy", + "pin-project-lite", +] + +[[package]] +name = "async-process" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc50921ec0055cdd8a16de48773bfeec5c972598674347252c0399676be7da75" +dependencies = [ + "async-channel", + "async-io", + "async-lock", + "async-signal", + "async-task", + "blocking", + "cfg-if", + "event-listener", + "futures-lite", + "rustix", +] + +[[package]] +name = "async-recursion" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "async-signal" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52b5aaafa020cf5053a01f2a60e8ff5dccf550f0f77ec54a4e47285ac2bab485" +dependencies = [ + "async-io", + "async-lock", + "atomic-waker", + "cfg-if", + "futures-core", + "futures-io", + "rustix", + "signal-hook-registry", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-task" +version = "4.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de" + +[[package]] +name = "async-trait" +version = "0.1.91" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae36dc4177970ef04fde5178d3e2429882def40e57a451f919c098f72baa6cec" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + [[package]] name = "atk" version = "0.18.2" @@ -203,6 +334,19 @@ dependencies = [ "objc2", ] +[[package]] +name = "blocking" +version = "1.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e83f8d02be6967315521be875afa792a316e28d57b5a2d401897e2a7921b7f21" +dependencies = [ + "async-channel", + "async-task", + "futures-io", + "futures-lite", + "piper", +] + [[package]] name = "brotli" version = "8.0.4" @@ -448,6 +592,15 @@ dependencies = [ "memchr", ] +[[package]] +name = "concurrent-queue" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" +dependencies = [ + "crossbeam-utils", +] + [[package]] name = "cookie" version = "0.18.1" @@ -483,7 +636,7 @@ dependencies = [ "bitflags 2.13.1", "core-foundation", "core-graphics-types", - "foreign-types 0.5.0", + "foreign-types", "libc", ] @@ -833,6 +986,33 @@ version = "1.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4ef6b89e5b37196644d8796de5268852ff179b44e96276cf4290264843743bb7" +[[package]] +name = "endi" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099" + +[[package]] +name = "enumflags2" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1027f7680c853e056ebcec683615fb6fbbc07dbaa13b4d5d9442b146ded4ecef" +dependencies = [ + "enumflags2_derive", + "serde", +] + +[[package]] +name = "enumflags2_derive" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "equivalent" version = "1.0.2" @@ -860,6 +1040,26 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "event-listener" +version = "5.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a23add41df1562121a9393cb065eab5146a1242410f23a644851e90cfd669d2" +dependencies = [ + "parking", + "pin-project-lite", +] + +[[package]] +name = "event-listener-strategy" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93" +dependencies = [ + "event-listener", + "pin-project-lite", +] + [[package]] name = "fastrand" version = "2.5.0" @@ -913,15 +1113,6 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" -[[package]] -name = "foreign-types" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" -dependencies = [ - "foreign-types-shared 0.1.1", -] - [[package]] name = "foreign-types" version = "0.5.0" @@ -929,7 +1120,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965" dependencies = [ "foreign-types-macros", - "foreign-types-shared 0.3.1", + "foreign-types-shared", ] [[package]] @@ -943,12 +1134,6 @@ dependencies = [ "syn 3.0.3", ] -[[package]] -name = "foreign-types-shared" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" - [[package]] name = "foreign-types-shared" version = "0.3.1" @@ -996,6 +1181,19 @@ version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4577ecaa3c4f96589d473f679a71b596316f6641bc350038b962a5daf0085d7a" +[[package]] +name = "futures-lite" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad" +dependencies = [ + "fastrand", + "futures-core", + "futures-io", + "parking", + "pin-project-lite", +] + [[package]] name = "futures-macro" version = "0.3.33" @@ -1350,6 +1548,12 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +[[package]] +name = "hermit-abi" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" + [[package]] name = "hex" version = "0.4.3" @@ -1939,23 +2143,6 @@ dependencies = [ "windows-sys 0.61.2", ] -[[package]] -name = "native-tls" -version = "0.2.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "465500e14ea162429d264d44189adc38b199b62b1c21eea9f69e4b73cb03bbf2" -dependencies = [ - "libc", - "log", - "openssl", - "openssl-probe", - "openssl-sys", - "schannel", - "security-framework", - "security-framework-sys", - "tempfile", -] - [[package]] name = "ndk" version = "0.9.0" @@ -2236,59 +2423,27 @@ version = "5.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a0b3d059e795d52b8a72fef45658620edd4d9c359b338564aa14391ffa511ed5" dependencies = [ + "dunce", "is-wsl", "libc", ] -[[package]] -name = "openssl" -version = "0.10.81" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "77823a27f0babb03091cb9ed9ef80af3b39dbc82f97e8fa530374b7dafd87a45" -dependencies = [ - "bitflags 2.13.1", - "cfg-if", - "foreign-types 0.3.2", - "libc", - "openssl-macros", - "openssl-sys", -] - -[[package]] -name = "openssl-macros" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", -] - -[[package]] -name = "openssl-probe" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" - -[[package]] -name = "openssl-sys" -version = "0.9.117" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b47e7e6bb2c38cd930d25a23b40fa52e068c10e85f3e03a7f5ba5aaca5713695" -dependencies = [ - "cc", - "libc", - "pkg-config", - "vcpkg", -] - [[package]] name = "option-ext" version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" +[[package]] +name = "ordered-stream" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aa2b01e1d916879f73a53d01d1d6cee68adbb31d6d9177a8cfce093cced1d50" +dependencies = [ + "futures-core", + "pin-project-lite", +] + [[package]] name = "pango" version = "0.18.3" @@ -2314,6 +2469,12 @@ dependencies = [ "system-deps", ] +[[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + [[package]] name = "parking_lot" version = "0.12.5" @@ -2402,6 +2563,17 @@ version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" +[[package]] +name = "piper" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c835479a4443ded371d6c535cbfd8d31ad92c5d23ae9770a61bc155e4992a3c1" +dependencies = [ + "atomic-waker", + "fastrand", + "futures-io", +] + [[package]] name = "pkg-config" version = "0.3.33" @@ -2447,6 +2619,20 @@ dependencies = [ "miniz_oxide", ] +[[package]] +name = "polling" +version = "3.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218" +dependencies = [ + "cfg-if", + "concurrent-queue", + "hermit-abi", + "pin-project-lite", + "rustix", + "windows-sys 0.61.2", +] + [[package]] name = "potential_utf" version = "0.1.5" @@ -2708,6 +2894,20 @@ dependencies = [ "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" @@ -2736,6 +2936,40 @@ dependencies = [ "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 = [ + "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" @@ -2751,15 +2985,6 @@ dependencies = [ "winapi-util", ] -[[package]] -name = "schannel" -version = "0.1.29" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" -dependencies = [ - "windows-sys 0.61.2", -] - [[package]] name = "schemars" version = "0.8.22" @@ -2817,29 +3042,6 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" -[[package]] -name = "security-framework" -version = "3.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" -dependencies = [ - "bitflags 2.13.1", - "core-foundation", - "core-foundation-sys", - "libc", - "security-framework-sys", -] - -[[package]] -name = "security-framework-sys" -version = "2.17.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" -dependencies = [ - "core-foundation-sys", - "libc", -] - [[package]] name = "selectors" version = "0.36.1" @@ -3183,6 +3385,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" @@ -3429,6 +3637,44 @@ dependencies = [ "tauri-utils", ] +[[package]] +name = "tauri-plugin" +version = "2.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "74be5dd4bed9afbd145e5716b5fa2ec28cbc29c34ffa61c258c9273d896c8020" +dependencies = [ + "anyhow", + "glob", + "plist", + "schemars 0.8.22", + "serde", + "serde_json", + "tauri-utils", + "walkdir", +] + +[[package]] +name = "tauri-plugin-opener" +version = "2.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "17e1bea14edce6b793a04e2417e3fd924b9bc4faae83cdee7d714156cceeed29" +dependencies = [ + "dunce", + "glob", + "objc2-app-kit", + "objc2-foundation", + "open", + "schemars 0.8.22", + "serde", + "serde_json", + "tauri", + "tauri-plugin", + "thiserror 2.0.19", + "url", + "windows", + "zbus", +] + [[package]] name = "tauri-runtime" version = "2.11.3" @@ -3560,14 +3806,14 @@ dependencies = [ "clap", "ctr", "futures-util", - "native-tls", - "open", "rand", + "rustls", "serde", "serde_json", "sha2", "tauri", "tauri-build", + "tauri-plugin-opener", "tokio", "tokio-tungstenite", ] @@ -3695,12 +3941,12 @@ dependencies = [ ] [[package]] -name = "tokio-native-tls" -version = "0.3.1" +name = "tokio-rustls" +version = "0.26.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" dependencies = [ - "native-tls", + "rustls", "tokio", ] @@ -3712,10 +3958,12 @@ checksum = "edc5f74e248dc973e0dbb7b74c7e0d6fcc301c694ff50049504004ef4d0cdcd9" dependencies = [ "futures-util", "log", - "native-tls", + "rustls", + "rustls-pki-types", "tokio", - "tokio-native-tls", + "tokio-rustls", "tungstenite", + "webpki-roots 0.26.11", ] [[package]] @@ -3903,9 +4151,21 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" dependencies = [ "pin-project-lite", + "tracing-attributes", "tracing-core", ] +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "tracing-core" version = "0.1.36" @@ -3955,8 +4215,9 @@ dependencies = [ "http", "httparse", "log", - "native-tls", "rand", + "rustls", + "rustls-pki-types", "sha1", "thiserror 1.0.69", "utf-8", @@ -3974,6 +4235,17 @@ version = "1.20.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" +[[package]] +name = "uds_windows" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e" +dependencies = [ + "memoffset", + "tempfile", + "windows-sys 0.61.2", +] + [[package]] name = "unic-char-property" version = "0.9.0" @@ -4027,6 +4299,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" @@ -4082,12 +4360,6 @@ dependencies = [ "wasm-bindgen", ] -[[package]] -name = "vcpkg" -version = "0.2.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" - [[package]] name = "version-compare" version = "0.2.1" @@ -4288,6 +4560,24 @@ dependencies = [ "system-deps", ] +[[package]] +name = "webpki-roots" +version = "0.26.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9" +dependencies = [ + "webpki-roots 1.0.9", +] + +[[package]] +name = "webpki-roots" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a" +dependencies = [ + "rustls-pki-types", +] + [[package]] name = "webview2-com" version = "0.38.2" @@ -4487,6 +4777,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" @@ -4778,6 +5077,67 @@ dependencies = [ "synstructure", ] +[[package]] +name = "zbus" +version = "5.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe18fb60dc696039e738717b76eaea21e7a4489bbb1885020b43c94236d7e98a" +dependencies = [ + "async-broadcast", + "async-executor", + "async-io", + "async-lock", + "async-process", + "async-recursion", + "async-task", + "async-trait", + "blocking", + "enumflags2", + "event-listener", + "futures-core", + "futures-lite", + "hex", + "libc", + "ordered-stream", + "rustix", + "serde", + "serde_repr", + "tracing", + "uds_windows", + "uuid", + "windows-sys 0.61.2", + "winnow 1.0.4", + "zbus_macros", + "zbus_names", + "zvariant", +] + +[[package]] +name = "zbus_macros" +version = "5.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe96480bed92df2b442a1a30df364e12d08eed03aeb061f2b8dc6afb2be91119" +dependencies = [ + "proc-macro-crate 3.5.0", + "proc-macro2", + "quote", + "syn 2.0.119", + "zbus_names", + "zvariant", + "zvariant_utils", +] + +[[package]] +name = "zbus_names" +version = "4.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8bf88b4a3ff53e883001e0e0115b297a9d53c31b9c1edd2bfdd853e3428624e" +dependencies = [ + "serde", + "winnow 1.0.4", + "zvariant", +] + [[package]] name = "zerocopy" version = "0.8.55" @@ -4819,6 +5179,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" @@ -4857,3 +5223,43 @@ name = "zmij" version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" + +[[package]] +name = "zvariant" +version = "5.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bee2a0bcd2a907786a456fff45aaaaf54c9ba5f50b71ae9ec1a4edd200c94911" +dependencies = [ + "endi", + "enumflags2", + "serde", + "winnow 1.0.4", + "zvariant_derive", + "zvariant_utils", +] + +[[package]] +name = "zvariant_derive" +version = "5.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38a708216a18780796770bfe3f4739c7c83a3e8f789b755534bbbc06e4e23e12" +dependencies = [ + "proc-macro-crate 3.5.0", + "proc-macro2", + "quote", + "syn 2.0.119", + "zvariant_utils", +] + +[[package]] +name = "zvariant_utils" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90cb9383f9b45290407a1258b202d3f8f01db719eb60b4e4055c6375af4fc7c7" +dependencies = [ + "proc-macro2", + "quote", + "serde", + "syn 2.0.119", + "winnow 1.0.4", +] diff --git a/Cargo.toml b/Cargo.toml index bc5edab..9c6cf78 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -9,14 +9,20 @@ autobins = false [features] default = ["gui"] -# The desktop GUI. Turning it off drops Tauri, the system WebView and the -# frontend bundle from the build, which is what makes headless servers and -# machines without a GPU or monitor able to build and run TGLock at all. -gui = ["dep:tauri", "dep:tauri-build", "dep:open"] +# The graphical application, on desktop and on Android. Turning it off drops +# Tauri, the system WebView and the frontend bundle from the build, which is +# what makes headless servers and machines without a GPU or monitor able to +# build and run TGLock at all. +gui = ["dep:tauri", "dep:tauri-build", "dep:tauri-plugin-opener"] [lib] -name = "tglock" +# Отличается от имени бинаря намеренно: одинаковые имена lib и bin дают +# коллизию выходных файлов, которую cargo обещает сделать ошибкой. +name = "tglock_lib" path = "src/lib.rs" +# Android загружает приложение как нативную библиотеку, а не как исполняемый +# файл, поэтому нужен cdylib. rlib остаётся для CLI и тестов. +crate-type = ["staticlib", "cdylib", "rlib"] [[bin]] name = "tglock" @@ -29,7 +35,8 @@ path = "src/bin/cli.rs" [dependencies] tauri = { version = "2", features = [], optional = true } -open = { version = "5", optional = true } +# Opens the tg:// link. Unlike the `open` crate this works on Android too. +tauri-plugin-opener = { version = "2", optional = true } clap = { version = "4", features = ["derive"] } serde = { version = "1", features = ["derive"] } serde_json = "1" @@ -42,8 +49,19 @@ tokio = { version = "1", features = [ "sync", "signal", ] } -tokio-tungstenite = { version = "0.24", features = ["native-tls"] } -native-tls = "0.2" +# rustls вместо native-tls: на чистом Rust, поэтому кросскомпилируется под +# Android без OpenSSL, а на Linux снимает зависимость от системного libssl. +# webpki-roots несёт корневые сертификаты с собой, так что поведение одинаково +# на всех платформах и не зависит от системного хранилища. +tokio-tungstenite = { version = "0.24", features = ["rustls-tls-webpki-roots"] } +# Провайдер выбирается явно: rustls 0.23 не определяет его сам, если фичи не +# включают ровно один, и падает паникой при первом TLS-соединении. ring выбран +# потому что кросскомпилируется под Android без внешнего тулчейна. +rustls = { version = "0.23", default-features = false, features = [ + "ring", + "std", + "tls12", +] } futures-util = "0.3" aes = "0.8" ctr = "0.9" diff --git a/README.md b/README.md index 2afdd50..792ba89 100644 --- a/README.md +++ b/README.md @@ -223,8 +223,6 @@ COPY . . RUN cargo build --release --locked --no-default-features --bin tglock-cli FROM debian:bookworm-slim -RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates \ - && rm -rf /var/lib/apt/lists/* COPY --from=build /src/target/release/tglock-cli /usr/local/bin/tglock-cli EXPOSE 1080 ENTRYPOINT ["tglock-cli", "--lan", "--secret-file", "/data/secret"] @@ -234,7 +232,7 @@ ENTRYPOINT ["tglock-cli", "--lan", "--secret-file", "/data/secret"] docker run -d --name tglock -p 1080:1080 -v tglock-data:/data tglock ``` -Образу не нужны ни Node.js, ни `libwebkit2gtk` — только `ca-certificates` для проверки сертификата Telegram. +Образу не нужно вообще ничего: ни Node.js, ни `libwebkit2gtk`, ни даже `ca-certificates` — TLS работает на rustls, а корневые сертификаты вшиты в бинарь. --- @@ -411,7 +409,7 @@ TGLock определяет Telegram по IP получателя и завор | **Tauri 2** | Нативная оболочка для GUI. Опциональна: за фичей `gui`, в CLI не входит | | **TypeScript + Vite** | Интерфейс, внутренняя навигация и строгая типизация | | **tokio** | Async I/O, обработка сигналов для корректной остановки сервиса | -| **tokio-tungstenite** | WebSocket-клиент с TLS поверх `native-tls` | +| **tokio-tungstenite** + **rustls** | WebSocket-клиент с TLS на чистом Rust: без системного OpenSSL, кросскомпилируется под Android, корневые сертификаты вшиты | | **aes** + **ctr** | Расшифровка MTProto `obfuscated2` init-пакета | | **clap** | Разбор аргументов `tglock-cli` | diff --git a/docs/ARCHITECTURE_V2.md b/docs/ARCHITECTURE_V2.md index 8af9adb..e529a75 100644 --- a/docs/ARCHITECTURE_V2.md +++ b/docs/ARCHITECTURE_V2.md @@ -91,6 +91,19 @@ LAN-режим превращал бы машину в открытый прок заданному Telegram IP TCP destination отделён от URI host: TLS продолжает проверять сертификат настоящего `kws*.web.telegram.org`. +Стек — **rustls** с корневыми сертификатами webpki, вшитыми в бинарь. Выбран не +из вкуса: `native-tls` на Linux и Android тянет OpenSSL, а `openssl-sys` не +кросскомпилируется под `aarch64-linux-android` без сборки OpenSSL вручную. +Побочные выгоды: на Linux исчезла зависимость от системного `libssl`, а образу +Docker больше не нужен даже `ca-certificates`. + +Криптопровайдер rustls выбирается явно (`ring`). Без этого rustls 0.23 +отказывается угадывать и **паникует на первом TLS-рукопожатии** — это не +ошибка компиляции и её не видят офлайновые тесты, потому что они ходят через +локальный маршрут без TLS. Провайдер устанавливается в `ensure_crypto_provider` +перед подключением, а тест `a_crypto_provider_is_available_for_tls` не даёт +регрессии вернуться. + Cloudflare Worker принимается только как пользовательская настройка. TGLock не загружает и не скрывает публичные списки чужих доменов. diff --git a/gen/android/.editorconfig b/gen/android/.editorconfig new file mode 100644 index 0000000..ebe51d3 --- /dev/null +++ b/gen/android/.editorconfig @@ -0,0 +1,12 @@ +# EditorConfig is awesome: https://EditorConfig.org + +# top-most EditorConfig file +root = true + +[*] +indent_style = space +indent_size = 2 +end_of_line = lf +charset = utf-8 +trim_trailing_whitespace = false +insert_final_newline = false \ No newline at end of file diff --git a/gen/android/.gitignore b/gen/android/.gitignore new file mode 100644 index 0000000..1c636c3 --- /dev/null +++ b/gen/android/.gitignore @@ -0,0 +1,20 @@ +*.iml +.gradle +/local.properties +/.idea/caches +/.idea/libraries +/.idea/modules.xml +/.idea/workspace.xml +/.idea/navEditor.xml +/.idea/assetWizardSettings.xml +.DS_Store +build +/captures +.externalNativeBuild +.cxx +local.properties +key.properties +keystore.properties + +/.tauri +/tauri.settings.gradle \ No newline at end of file diff --git a/gen/android/app/.gitignore b/gen/android/app/.gitignore new file mode 100644 index 0000000..6c4d56b --- /dev/null +++ b/gen/android/app/.gitignore @@ -0,0 +1,6 @@ +/src/main/**/generated +/src/main/jniLibs/**/*.so +/src/main/assets/tauri.conf.json +/tauri.build.gradle.kts +/proguard-tauri.pro +/tauri.properties \ No newline at end of file diff --git a/gen/android/app/build.gradle.kts b/gen/android/app/build.gradle.kts new file mode 100644 index 0000000..6b5970d --- /dev/null +++ b/gen/android/app/build.gradle.kts @@ -0,0 +1,71 @@ +import java.util.Properties + +plugins { + id("com.android.application") + id("org.jetbrains.kotlin.android") + id("rust") +} + +val tauriProperties = Properties().apply { + val propFile = file("tauri.properties") + if (propFile.exists()) { + propFile.inputStream().use { load(it) } + } +} + +android { + compileSdk = 36 + namespace = "com.bysonic.tglock" + defaultConfig { + manifestPlaceholders["usesCleartextTraffic"] = "false" + applicationId = "com.bysonic.tglock" + minSdk = 24 + targetSdk = 36 + versionCode = tauriProperties.getProperty("tauri.android.versionCode", "1").toInt() + versionName = tauriProperties.getProperty("tauri.android.versionName", "1.0") + } + buildTypes { + getByName("debug") { + manifestPlaceholders["usesCleartextTraffic"] = "true" + isDebuggable = true + isJniDebuggable = true + isMinifyEnabled = false + packaging { jniLibs.keepDebugSymbols.add("*/arm64-v8a/*.so") + jniLibs.keepDebugSymbols.add("*/armeabi-v7a/*.so") + jniLibs.keepDebugSymbols.add("*/x86/*.so") + jniLibs.keepDebugSymbols.add("*/x86_64/*.so") + } + } + getByName("release") { + isMinifyEnabled = true + proguardFiles( + *fileTree(".") { include("**/*.pro") } + .plus(getDefaultProguardFile("proguard-android-optimize.txt")) + .toList().toTypedArray() + ) + } + } + kotlinOptions { + jvmTarget = "1.8" + } + buildFeatures { + buildConfig = true + } +} + +rust { + rootDirRel = "../../../" +} + +dependencies { + implementation("androidx.webkit:webkit:1.14.0") + implementation("androidx.appcompat:appcompat:1.7.1") + implementation("androidx.activity:activity-ktx:1.10.1") + implementation("com.google.android.material:material:1.12.0") + implementation("androidx.lifecycle:lifecycle-process:2.10.0") + testImplementation("junit:junit:4.13.2") + androidTestImplementation("androidx.test.ext:junit:1.1.4") + androidTestImplementation("androidx.test.espresso:espresso-core:3.5.0") +} + +apply(from = "tauri.build.gradle.kts") \ No newline at end of file diff --git a/gen/android/app/proguard-rules.pro b/gen/android/app/proguard-rules.pro new file mode 100644 index 0000000..481bb43 --- /dev/null +++ b/gen/android/app/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile \ No newline at end of file diff --git a/gen/android/app/src/main/AndroidManifest.xml b/gen/android/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..5125f2e --- /dev/null +++ b/gen/android/app/src/main/AndroidManifest.xml @@ -0,0 +1,52 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/gen/android/app/src/main/java/com/bysonic/tglock/MainActivity.kt b/gen/android/app/src/main/java/com/bysonic/tglock/MainActivity.kt new file mode 100644 index 0000000..643976a --- /dev/null +++ b/gen/android/app/src/main/java/com/bysonic/tglock/MainActivity.kt @@ -0,0 +1,42 @@ +package com.bysonic.tglock + +import android.Manifest +import android.content.pm.PackageManager +import android.os.Build +import android.os.Bundle +import androidx.activity.enableEdgeToEdge +import androidx.activity.result.contract.ActivityResultContracts +import androidx.core.content.ContextCompat + +class MainActivity : TauriActivity() { + private val requestNotifications = + registerForActivityResult(ActivityResultContracts.RequestPermission()) { + // The tunnel works either way: without the permission the system simply + // hides the notification, while the foreground service still keeps the + // process alive. + } + + override fun onCreate(savedInstanceState: Bundle?) { + enableEdgeToEdge() + super.onCreate(savedInstanceState) + askForNotificationPermission() + // Started here rather than when the proxy turns on: the proxy is a thread in + // this same process, so what has to survive backgrounding is the process. + TunnelService.start(this) + } + + override fun onDestroy() { + TunnelService.stop(this) + super.onDestroy() + } + + private fun askForNotificationPermission() { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) return + val granted = + ContextCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS) == + PackageManager.PERMISSION_GRANTED + if (!granted) { + requestNotifications.launch(Manifest.permission.POST_NOTIFICATIONS) + } + } +} diff --git a/gen/android/app/src/main/java/com/bysonic/tglock/TunnelService.kt b/gen/android/app/src/main/java/com/bysonic/tglock/TunnelService.kt new file mode 100644 index 0000000..8e4e93f --- /dev/null +++ b/gen/android/app/src/main/java/com/bysonic/tglock/TunnelService.kt @@ -0,0 +1,98 @@ +package com.bysonic.tglock + +import android.app.Notification +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.PendingIntent +import android.app.Service +import android.content.Context +import android.content.Intent +import android.content.pm.ServiceInfo +import android.os.Build +import android.os.IBinder +import androidx.core.app.NotificationCompat + +/** + * Keeps the application process alive while the tunnel is in use. + * + * The proxy itself runs on a Rust thread inside this process, so nothing here + * touches networking. The only job is to stop Android from killing the process + * as soon as the user switches away from the app — without this, Telegram loses + * its proxy within minutes of the app going to the background. + */ +class TunnelService : Service() { + override fun onBind(intent: Intent?): IBinder? = null + + override fun onCreate() { + super.onCreate() + createChannel() + } + + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + val notification = buildNotification() + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { + startForeground( + NOTIFICATION_ID, + notification, + ServiceInfo.FOREGROUND_SERVICE_TYPE_SPECIAL_USE, + ) + } else { + startForeground(NOTIFICATION_ID, notification) + } + // The process must come back if the system reclaims it while the user + // still has the app open. + return START_STICKY + } + + private fun createChannel() { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return + val channel = NotificationChannel( + CHANNEL_ID, + getString(R.string.tunnel_channel_name), + // Low importance: the notification is required by the platform, not + // something the user needs to be interrupted by. + NotificationManager.IMPORTANCE_LOW, + ).apply { + setShowBadge(false) + description = getString(R.string.tunnel_channel_description) + } + val manager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + manager.createNotificationChannel(channel) + } + + private fun buildNotification(): Notification { + val open = PendingIntent.getActivity( + this, + 0, + Intent(this, MainActivity::class.java), + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE, + ) + return NotificationCompat.Builder(this, CHANNEL_ID) + .setContentTitle(getString(R.string.tunnel_notification_title)) + .setContentText(getString(R.string.tunnel_notification_text)) + .setSmallIcon(R.mipmap.ic_launcher) + .setContentIntent(open) + .setOngoing(true) + .setShowWhen(false) + .setPriority(NotificationCompat.PRIORITY_LOW) + .build() + } + + companion object { + private const val CHANNEL_ID = "tglock-tunnel" + private const val NOTIFICATION_ID = 1 + + fun start(context: Context) { + val intent = Intent(context, TunnelService::class.java) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + context.startForegroundService(intent) + } else { + context.startService(intent) + } + } + + fun stop(context: Context) { + context.stopService(Intent(context, TunnelService::class.java)) + } + } +} diff --git a/gen/android/app/src/main/res/drawable-v24/ic_launcher_foreground.xml b/gen/android/app/src/main/res/drawable-v24/ic_launcher_foreground.xml new file mode 100644 index 0000000..2b068d1 --- /dev/null +++ b/gen/android/app/src/main/res/drawable-v24/ic_launcher_foreground.xml @@ -0,0 +1,30 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/gen/android/app/src/main/res/drawable/ic_launcher_background.xml b/gen/android/app/src/main/res/drawable/ic_launcher_background.xml new file mode 100644 index 0000000..07d5da9 --- /dev/null +++ b/gen/android/app/src/main/res/drawable/ic_launcher_background.xml @@ -0,0 +1,170 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/gen/android/app/src/main/res/layout/activity_main.xml b/gen/android/app/src/main/res/layout/activity_main.xml new file mode 100644 index 0000000..4fc2444 --- /dev/null +++ b/gen/android/app/src/main/res/layout/activity_main.xml @@ -0,0 +1,18 @@ + + + + + + \ No newline at end of file diff --git a/gen/android/app/src/main/res/mipmap-hdpi/ic_launcher.png b/gen/android/app/src/main/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 0000000..28f1aa1 Binary files /dev/null and b/gen/android/app/src/main/res/mipmap-hdpi/ic_launcher.png differ diff --git a/gen/android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png b/gen/android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..85d0c88 Binary files /dev/null and b/gen/android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png differ diff --git a/gen/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png b/gen/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png new file mode 100644 index 0000000..28f1aa1 Binary files /dev/null and b/gen/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png differ diff --git a/gen/android/app/src/main/res/mipmap-mdpi/ic_launcher.png b/gen/android/app/src/main/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 0000000..73e48db Binary files /dev/null and b/gen/android/app/src/main/res/mipmap-mdpi/ic_launcher.png differ diff --git a/gen/android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png b/gen/android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..13dd214 Binary files /dev/null and b/gen/android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png differ diff --git a/gen/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png b/gen/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png new file mode 100644 index 0000000..73e48db Binary files /dev/null and b/gen/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png differ diff --git a/gen/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/gen/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 0000000..1d98044 Binary files /dev/null and b/gen/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/gen/android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png b/gen/android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..a888b33 Binary files /dev/null and b/gen/android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png differ diff --git a/gen/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png b/gen/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png new file mode 100644 index 0000000..1d98044 Binary files /dev/null and b/gen/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png differ diff --git a/gen/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/gen/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 0000000..0818324 Binary files /dev/null and b/gen/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/gen/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png b/gen/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..a2a838e Binary files /dev/null and b/gen/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png differ diff --git a/gen/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png b/gen/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png new file mode 100644 index 0000000..0818324 Binary files /dev/null and b/gen/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png differ diff --git a/gen/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/gen/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 0000000..b18bceb Binary files /dev/null and b/gen/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/gen/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png b/gen/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..3f8a57f Binary files /dev/null and b/gen/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png differ diff --git a/gen/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png b/gen/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png new file mode 100644 index 0000000..b18bceb Binary files /dev/null and b/gen/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png differ diff --git a/gen/android/app/src/main/res/values-night/themes.xml b/gen/android/app/src/main/res/values-night/themes.xml new file mode 100644 index 0000000..e82ff72 --- /dev/null +++ b/gen/android/app/src/main/res/values-night/themes.xml @@ -0,0 +1,6 @@ + + + + diff --git a/gen/android/app/src/main/res/values/colors.xml b/gen/android/app/src/main/res/values/colors.xml new file mode 100644 index 0000000..f8c6127 --- /dev/null +++ b/gen/android/app/src/main/res/values/colors.xml @@ -0,0 +1,10 @@ + + + #FFBB86FC + #FF6200EE + #FF3700B3 + #FF03DAC5 + #FF018786 + #FF000000 + #FFFFFFFF + \ No newline at end of file diff --git a/gen/android/app/src/main/res/values/strings.xml b/gen/android/app/src/main/res/values/strings.xml new file mode 100644 index 0000000..a5a45a2 --- /dev/null +++ b/gen/android/app/src/main/res/values/strings.xml @@ -0,0 +1,8 @@ + + "TGLock" + "TGLock" + Туннель + Уведомление, которое не даёт системе выгрузить туннель, пока приложение открыто + TGLock активен + Пока это уведомление на месте, система не выгрузит туннель + \ No newline at end of file diff --git a/gen/android/app/src/main/res/values/themes.xml b/gen/android/app/src/main/res/values/themes.xml new file mode 100644 index 0000000..e82ff72 --- /dev/null +++ b/gen/android/app/src/main/res/values/themes.xml @@ -0,0 +1,6 @@ + + + + diff --git a/gen/android/app/src/main/res/xml/file_paths.xml b/gen/android/app/src/main/res/xml/file_paths.xml new file mode 100644 index 0000000..782d63b --- /dev/null +++ b/gen/android/app/src/main/res/xml/file_paths.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/gen/android/build.gradle.kts b/gen/android/build.gradle.kts new file mode 100644 index 0000000..607240b --- /dev/null +++ b/gen/android/build.gradle.kts @@ -0,0 +1,22 @@ +buildscript { + repositories { + google() + mavenCentral() + } + dependencies { + classpath("com.android.tools.build:gradle:8.11.0") + classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:1.9.25") + } +} + +allprojects { + repositories { + google() + mavenCentral() + } +} + +tasks.register("clean").configure { + delete("build") +} + diff --git a/gen/android/buildSrc/build.gradle.kts b/gen/android/buildSrc/build.gradle.kts new file mode 100644 index 0000000..5c55bba --- /dev/null +++ b/gen/android/buildSrc/build.gradle.kts @@ -0,0 +1,23 @@ +plugins { + `kotlin-dsl` +} + +gradlePlugin { + plugins { + create("pluginsForCoolKids") { + id = "rust" + implementationClass = "RustPlugin" + } + } +} + +repositories { + google() + mavenCentral() +} + +dependencies { + compileOnly(gradleApi()) + implementation("com.android.tools.build:gradle:8.11.0") +} + diff --git a/gen/android/buildSrc/src/main/java/com/bysonic/tglock/kotlin/BuildTask.kt b/gen/android/buildSrc/src/main/java/com/bysonic/tglock/kotlin/BuildTask.kt new file mode 100644 index 0000000..a3de125 --- /dev/null +++ b/gen/android/buildSrc/src/main/java/com/bysonic/tglock/kotlin/BuildTask.kt @@ -0,0 +1,68 @@ +import java.io.File +import org.apache.tools.ant.taskdefs.condition.Os +import org.gradle.api.DefaultTask +import org.gradle.api.GradleException +import org.gradle.api.logging.LogLevel +import org.gradle.api.tasks.Input +import org.gradle.api.tasks.TaskAction + +open class BuildTask : DefaultTask() { + @Input + var rootDirRel: String? = null + @Input + var target: String? = null + @Input + var release: Boolean? = null + + @TaskAction + fun assemble() { + val executable = """npm"""; + try { + runTauriCli(executable) + } catch (e: Exception) { + if (Os.isFamily(Os.FAMILY_WINDOWS)) { + // Try different Windows-specific extensions + val fallbacks = listOf( + "$executable.exe", + "$executable.cmd", + "$executable.bat", + ) + + var lastException: Exception = e + for (fallback in fallbacks) { + try { + runTauriCli(fallback) + return + } catch (fallbackException: Exception) { + lastException = fallbackException + } + } + throw lastException + } else { + throw e; + } + } + } + + fun runTauriCli(executable: String) { + val rootDirRel = rootDirRel ?: throw GradleException("rootDirRel cannot be null") + val target = target ?: throw GradleException("target cannot be null") + val release = release ?: throw GradleException("release cannot be null") + val args = listOf("run", "--", "tauri", "android", "android-studio-script"); + + project.exec { + workingDir(File(project.projectDir, rootDirRel)) + executable(executable) + args(args) + if (project.logger.isEnabled(LogLevel.DEBUG)) { + args("-vv") + } else if (project.logger.isEnabled(LogLevel.INFO)) { + args("-v") + } + if (release) { + args("--release") + } + args(listOf("--target", target)) + }.assertNormalExitValue() + } +} \ No newline at end of file diff --git a/gen/android/buildSrc/src/main/java/com/bysonic/tglock/kotlin/RustPlugin.kt b/gen/android/buildSrc/src/main/java/com/bysonic/tglock/kotlin/RustPlugin.kt new file mode 100644 index 0000000..4aa7fca --- /dev/null +++ b/gen/android/buildSrc/src/main/java/com/bysonic/tglock/kotlin/RustPlugin.kt @@ -0,0 +1,85 @@ +import com.android.build.api.dsl.ApplicationExtension +import org.gradle.api.DefaultTask +import org.gradle.api.Plugin +import org.gradle.api.Project +import org.gradle.kotlin.dsl.configure +import org.gradle.kotlin.dsl.get + +const val TASK_GROUP = "rust" + +open class Config { + lateinit var rootDirRel: String +} + +open class RustPlugin : Plugin { + private lateinit var config: Config + + override fun apply(project: Project) = with(project) { + config = extensions.create("rust", Config::class.java) + + val defaultAbiList = listOf("arm64-v8a", "armeabi-v7a", "x86", "x86_64"); + val abiList = (findProperty("abiList") as? String)?.split(',') ?: defaultAbiList + + val defaultArchList = listOf("arm64", "arm", "x86", "x86_64"); + val archList = (findProperty("archList") as? String)?.split(',') ?: defaultArchList + + val targetsList = (findProperty("targetList") as? String)?.split(',') ?: listOf("aarch64", "armv7", "i686", "x86_64") + + extensions.configure { + @Suppress("UnstableApiUsage") + flavorDimensions.add("abi") + productFlavors { + create("universal") { + dimension = "abi" + ndk { + abiFilters += abiList + } + } + defaultArchList.forEachIndexed { index, arch -> + create(arch) { + dimension = "abi" + ndk { + abiFilters.add(defaultAbiList[index]) + } + } + } + } + } + + afterEvaluate { + for (profile in listOf("debug", "release")) { + val profileCapitalized = profile.replaceFirstChar { it.uppercase() } + val buildTask = tasks.maybeCreate( + "rustBuildUniversal$profileCapitalized", + DefaultTask::class.java + ).apply { + group = TASK_GROUP + description = "Build dynamic library in $profile mode for all targets" + } + + tasks["mergeUniversal${profileCapitalized}JniLibFolders"].dependsOn(buildTask) + + for (targetPair in targetsList.withIndex()) { + val targetName = targetPair.value + val targetArch = archList[targetPair.index] + val targetArchCapitalized = targetArch.replaceFirstChar { it.uppercase() } + val targetBuildTask = project.tasks.maybeCreate( + "rustBuild$targetArchCapitalized$profileCapitalized", + BuildTask::class.java + ).apply { + group = TASK_GROUP + description = "Build dynamic library in $profile mode for $targetArch" + rootDirRel = config.rootDirRel + target = targetName + release = profile == "release" + } + + buildTask.dependsOn(targetBuildTask) + tasks["merge$targetArchCapitalized${profileCapitalized}JniLibFolders"].dependsOn( + targetBuildTask + ) + } + } + } + } +} \ No newline at end of file diff --git a/gen/android/gradle.properties b/gen/android/gradle.properties new file mode 100644 index 0000000..2a7ec69 --- /dev/null +++ b/gen/android/gradle.properties @@ -0,0 +1,24 @@ +# Project-wide Gradle settings. +# IDE (e.g. Android Studio) users: +# Gradle settings configured through the IDE *will override* +# any settings specified in this file. +# For more details on how to configure your build environment visit +# http://www.gradle.org/docs/current/userguide/build_environment.html +# Specifies the JVM arguments used for the daemon process. +# The setting is particularly useful for tweaking memory settings. +org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8 +# When configured, Gradle will run in incubating parallel mode. +# This option should only be used with decoupled projects. More details, visit +# http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects +# org.gradle.parallel=true +# AndroidX package structure to make it clearer which packages are bundled with the +# Android operating system, and which are packaged with your app"s APK +# https://developer.android.com/topic/libraries/support-library/androidx-rn +android.useAndroidX=true +# Kotlin code style for this project: "official" or "obsolete": +kotlin.code.style=official +# Enables namespacing of each library's R class so that its R class includes only the +# resources declared in the library itself and none from the library's dependencies, +# thereby reducing the size of the R class for that library +android.nonTransitiveRClass=true +android.nonFinalResIds=false \ No newline at end of file diff --git a/gen/android/gradle/wrapper/gradle-wrapper.jar b/gen/android/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..e708b1c Binary files /dev/null and b/gen/android/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gen/android/gradle/wrapper/gradle-wrapper.properties b/gen/android/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..c5f9a53 --- /dev/null +++ b/gen/android/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,6 @@ +#Tue May 10 19:22:52 CST 2022 +distributionBase=GRADLE_USER_HOME +distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.3-bin.zip +distributionPath=wrapper/dists +zipStorePath=wrapper/dists +zipStoreBase=GRADLE_USER_HOME diff --git a/gen/android/gradlew b/gen/android/gradlew new file mode 100644 index 0000000..4f906e0 --- /dev/null +++ b/gen/android/gradlew @@ -0,0 +1,185 @@ +#!/usr/bin/env sh + +# +# Copyright 2015 the original author or authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +############################################################################## +## +## Gradle start up script for UN*X +## +############################################################################## + +# Attempt to set APP_HOME +# Resolve links: $0 may be a link +PRG="$0" +# Need this for relative symlinks. +while [ -h "$PRG" ] ; do + ls=`ls -ld "$PRG"` + link=`expr "$ls" : '.*-> \(.*\)$'` + if expr "$link" : '/.*' > /dev/null; then + PRG="$link" + else + PRG=`dirname "$PRG"`"/$link" + fi +done +SAVED="`pwd`" +cd "`dirname \"$PRG\"`/" >/dev/null +APP_HOME="`pwd -P`" +cd "$SAVED" >/dev/null + +APP_NAME="Gradle" +APP_BASE_NAME=`basename "$0"` + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD="maximum" + +warn () { + echo "$*" +} + +die () { + echo + echo "$*" + echo + exit 1 +} + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "`uname`" in + CYGWIN* ) + cygwin=true + ;; + Darwin* ) + darwin=true + ;; + MINGW* ) + msys=true + ;; + NONSTOP* ) + nonstop=true + ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD="$JAVA_HOME/jre/sh/java" + else + JAVACMD="$JAVA_HOME/bin/java" + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD="java" + which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." +fi + +# Increase the maximum file descriptors if we can. +if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then + MAX_FD_LIMIT=`ulimit -H -n` + if [ $? -eq 0 ] ; then + if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then + MAX_FD="$MAX_FD_LIMIT" + fi + ulimit -n $MAX_FD + if [ $? -ne 0 ] ; then + warn "Could not set maximum file descriptor limit: $MAX_FD" + fi + else + warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" + fi +fi + +# For Darwin, add options to specify how the application appears in the dock +if $darwin; then + GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" +fi + +# For Cygwin or MSYS, switch paths to Windows format before running java +if [ "$cygwin" = "true" -o "$msys" = "true" ] ; then + APP_HOME=`cygpath --path --mixed "$APP_HOME"` + CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` + + JAVACMD=`cygpath --unix "$JAVACMD"` + + # We build the pattern for arguments to be converted via cygpath + ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null` + SEP="" + for dir in $ROOTDIRSRAW ; do + ROOTDIRS="$ROOTDIRS$SEP$dir" + SEP="|" + done + OURCYGPATTERN="(^($ROOTDIRS))" + # Add a user-defined pattern to the cygpath arguments + if [ "$GRADLE_CYGPATTERN" != "" ] ; then + OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)" + fi + # Now convert the arguments - kludge to limit ourselves to /bin/sh + i=0 + for arg in "$@" ; do + CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -` + CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option + + if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition + eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"` + else + eval `echo args$i`="\"$arg\"" + fi + i=`expr $i + 1` + done + case $i in + 0) set -- ;; + 1) set -- "$args0" ;; + 2) set -- "$args0" "$args1" ;; + 3) set -- "$args0" "$args1" "$args2" ;; + 4) set -- "$args0" "$args1" "$args2" "$args3" ;; + 5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; + 6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; + 7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; + 8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; + 9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; + esac +fi + +# Escape application args +save () { + for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done + echo " " +} +APP_ARGS=`save "$@"` + +# Collect all arguments for the java command, following the shell quoting and substitution rules +eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS" + +exec "$JAVACMD" "$@" diff --git a/gen/android/gradlew.bat b/gen/android/gradlew.bat new file mode 100644 index 0000000..107acd3 --- /dev/null +++ b/gen/android/gradlew.bat @@ -0,0 +1,89 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem + +@if "%DEBUG%" == "" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%" == "" set DIRNAME=. +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if "%ERRORLEVEL%" == "0" goto execute + +echo. +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* + +:end +@rem End local scope for the variables with windows NT shell +if "%ERRORLEVEL%"=="0" goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 +exit /b 1 + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/gen/android/settings.gradle b/gen/android/settings.gradle new file mode 100644 index 0000000..3939116 --- /dev/null +++ b/gen/android/settings.gradle @@ -0,0 +1,3 @@ +include ':app' + +apply from: 'tauri.settings.gradle' diff --git a/src/bin/cli.rs b/src/bin/cli.rs index 94d6038..99a9d07 100644 --- a/src/bin/cli.rs +++ b/src/bin/cli.rs @@ -12,8 +12,8 @@ use std::process::ExitCode; use std::sync::atomic::Ordering; use std::sync::Arc; use std::time::Duration; -use tglock::config::ListenConfig; -use tglock::{mtproto, proxy, transport}; +use tglock_lib::config::ListenConfig; +use tglock_lib::{mtproto, proxy, transport}; const STATUS_POLL: Duration = Duration::from_secs(1); diff --git a/src/gui.rs b/src/gui.rs new file mode 100644 index 0000000..f9acc05 --- /dev/null +++ b/src/gui.rs @@ -0,0 +1,358 @@ +use crate::config::ListenConfig; +use crate::{proxy, transport}; +use serde::{Deserialize, Serialize}; +use std::path::PathBuf; +use std::sync::atomic::Ordering; +use std::sync::{Arc, Mutex}; +use std::time::{Instant, SystemTime, UNIX_EPOCH}; +use tauri::{Manager, State}; +use tauri_plugin_opener::OpenerExt; + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +struct Settings { + lan_mode: bool, + port: u16, + worker_domain: String, +} + +impl Default for Settings { + fn default() -> Self { + Self { + lan_mode: false, + port: proxy::DEFAULT_PORT, + worker_domain: String::new(), + } + } +} + +#[derive(Clone, Serialize)] +#[serde(rename_all = "camelCase")] +struct LogLine { + timestamp: String, + message: String, + error: bool, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct StatusSnapshot { + running: bool, + active_connections: u32, + tunnels: u32, + data_center: Option, + route: String, + failures: u32, + uptime_seconds: u64, + port: u16, + logs: Vec, +} + +struct AppState { + stats: Arc, + settings: Mutex, + active_port: Mutex, + started_at: Mutex>, + logs: Arc>>, + settings_path: PathBuf, +} + +impl AppState { + fn new(settings_path: PathBuf) -> Self { + let settings = std::fs::read(&settings_path) + .ok() + .and_then(|contents| serde_json::from_slice(&contents).ok()) + .unwrap_or_default(); + Self { + stats: proxy::Stats::new(), + settings: Mutex::new(settings), + active_port: Mutex::new(proxy::DEFAULT_PORT), + started_at: Mutex::new(None), + logs: Arc::new(Mutex::new(Vec::new())), + settings_path, + } + } + + fn log(&self, message: impl Into, error: bool) { + let mut logs = self.logs.lock().unwrap(); + logs.push(LogLine { + timestamp: current_time(), + message: message.into(), + error, + }); + if logs.len() > 100 { + logs.remove(0); + } + } + + fn snapshot(&self) -> StatusSnapshot { + let data_center = self.stats.last_dc.load(Ordering::Relaxed); + let route = transport::route_label(self.stats.last_route.load(Ordering::Relaxed)); + StatusSnapshot { + running: self.stats.running.load(Ordering::SeqCst), + active_connections: self.stats.active.load(Ordering::Relaxed), + tunnels: self.stats.ws.load(Ordering::Relaxed), + data_center: (data_center > 0).then_some(data_center), + route: route.to_owned(), + failures: self.stats.ws_failures.load(Ordering::Relaxed), + uptime_seconds: self + .started_at + .lock() + .unwrap() + .map_or(0, |started| started.elapsed().as_secs()), + port: *self.active_port.lock().unwrap(), + logs: self.logs.lock().unwrap().clone(), + } + } + + fn persist_settings(&self, settings: &Settings) -> Result<(), String> { + if let Some(parent) = self.settings_path.parent() { + std::fs::create_dir_all(parent) + .map_err(|error| format!("Не удалось создать папку настроек: {error}"))?; + } + let contents = serde_json::to_vec_pretty(settings) + .map_err(|error| format!("Не удалось сохранить настройки: {error}"))?; + std::fs::write(&self.settings_path, contents) + .map_err(|error| format!("Не удалось сохранить настройки: {error}")) + } +} + +fn current_time() -> String { + let seconds = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs(); + format!( + "{:02}:{:02}:{:02}", + (seconds / 3600) % 24, + (seconds / 60) % 60, + seconds % 60 + ) +} + +#[tauri::command] +fn get_status(state: State<'_, AppState>) -> StatusSnapshot { + state.snapshot() +} + +#[tauri::command] +fn get_settings(state: State<'_, AppState>) -> Settings { + state.settings.lock().unwrap().clone() +} + +#[tauri::command] +fn save_settings(settings: Settings, state: State<'_, AppState>) -> Result { + if state.stats.running.load(Ordering::SeqCst) { + return Err("Сначала выключите защиту".into()); + } + if settings.port == 0 { + return Err("Порт должен быть от 1 до 65535".into()); + } + state.persist_settings(&settings)?; + *state.settings.lock().unwrap() = settings.clone(); + state.log("Настройки сохранены", false); + Ok(settings) +} + +#[tauri::command] +fn start_proxy( + app: tauri::AppHandle, + state: State<'_, AppState>, +) -> Result { + if state.stats.running.load(Ordering::SeqCst) { + return Ok(state.snapshot()); + } + + let settings = state.settings.lock().unwrap().clone(); + state.stats.set_worker_domain(&settings.worker_domain); + *state.active_port.lock().unwrap() = settings.port; + *state.started_at.lock().unwrap() = Some(Instant::now()); + state.log("Запускаю защищённый маршрут…", false); + + let listen = if settings.lan_mode { + ListenConfig::lan(settings.port) + } else { + ListenConfig::loopback(settings.port) + }; + + let stats = state.stats.clone(); + let logs = state.logs.clone(); + std::thread::spawn(move || { + let runtime = match tokio::runtime::Runtime::new() { + Ok(runtime) => runtime, + Err(error) => { + push_log(&logs, format!("Не удалось запустить сервис: {error}"), true); + return; + } + }; + if let Err(error) = runtime.block_on(proxy::run(stats, listen)) { + push_log(&logs, format!("Ошибка подключения: {error}"), true); + } + }); + + std::thread::sleep(std::time::Duration::from_millis(220)); + if !state.stats.running.load(Ordering::SeqCst) { + *state.started_at.lock().unwrap() = None; + return Err(state + .logs + .lock() + .unwrap() + .last() + .map(|line| line.message.clone()) + .unwrap_or_else(|| "Не удалось запустить прокси".into())); + } + + state.log(format!("Прокси запущен на {}", listen.addr), false); + // The opener plugin works on desktop and on Android alike; the `open` + // crate has no Android implementation. + let _ = app.opener().open_url( + listen.telegram_link(&state.stats.telegram_secret()), + None::<&str>, + ); + state.log("Открываю подключение в Telegram…", false); + Ok(state.snapshot()) +} + +#[tauri::command] +fn stop_proxy(state: State<'_, AppState>) -> StatusSnapshot { + state.stats.stop(); + *state.started_at.lock().unwrap() = None; + state.log("Защита выключена", false); + state.snapshot() +} + +fn push_log(logs: &Arc>>, message: String, error: bool) { + logs.lock().unwrap().push(LogLine { + timestamp: current_time(), + message, + error, + }); +} + +/// Environment variables that make the WebView render without a GPU. +/// +/// The window is never created when 3D acceleration is unavailable: no +/// monitor, the default Microsoft display driver, a virtual machine without +/// 3D enabled (by-sonic/tglock#10, by-sonic/tglock#17). For a small status +/// panel software rendering costs nothing noticeable, so preferring it is the +/// safer default. +/// +/// Values already present in the environment are never overwritten, and +/// `TGLOCK_FORCE_GPU` disables the whole mechanism. +fn software_rendering_vars( + force_gpu: bool, + is_set: impl Fn(&str) -> bool, +) -> Vec<(&'static str, &'static str)> { + if force_gpu { + return Vec::new(); + } + + let candidates: &[(&str, &str)] = if cfg!(target_os = "windows") { + &[( + "WEBVIEW2_ADDITIONAL_BROWSER_ARGUMENTS", + "--disable-gpu --disable-gpu-compositing", + )] + } else if cfg!(target_os = "macos") || cfg!(target_os = "android") { + // WebKit on macOS falls back to software rendering on its own, and on a + // phone the WebView always has a GPU — forcing software rendering there + // would only make the interface slower. + &[] + } else { + &[ + ("WEBKIT_DISABLE_COMPOSITING_MODE", "1"), + ("WEBKIT_DISABLE_DMABUF_RENDERER", "1"), + ] + }; + + candidates + .iter() + .filter(|(key, _)| !is_set(key)) + .copied() + .collect() +} + +fn prefer_software_rendering() { + let force_gpu = std::env::var_os("TGLOCK_FORCE_GPU").is_some(); + for (key, value) in software_rendering_vars(force_gpu, |key| std::env::var_os(key).is_some()) { + std::env::set_var(key, value); + } +} + +/// Entry point of the graphical application. +/// +/// On desktop this is called from `main`. On Android the platform loads the +/// crate as a shared library and calls this through the JNI entry point that +/// `tauri::mobile_entry_point` generates, which is why the app lives in the +/// library rather than in `main.rs`. +#[cfg_attr(mobile, tauri::mobile_entry_point)] +pub fn run() { + prefer_software_rendering(); + + tauri::Builder::default() + .plugin(tauri_plugin_opener::init()) + .setup(|app| { + let settings_path = app + .path() + .app_config_dir() + .map_err(|error| error.to_string())? + .join("settings.json"); + app.manage(AppState::new(settings_path)); + Ok(()) + }) + .invoke_handler(tauri::generate_handler![ + get_status, + get_settings, + save_settings, + start_proxy, + stop_proxy + ]) + .run(tauri::generate_context!()) + .expect("failed to run TGLock"); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn software_rendering_is_requested_by_default() { + let vars = software_rendering_vars(false, |_| false); + if cfg!(target_os = "macos") { + assert!(vars.is_empty(), "macOS needs no override"); + } else { + assert!( + !vars.is_empty(), + "a machine without 3D acceleration must still get a window" + ); + } + } + + #[test] + fn force_gpu_disables_the_override() { + assert!(software_rendering_vars(true, |_| false).is_empty()); + } + + #[test] + fn an_operators_own_value_is_never_overwritten() { + assert!(software_rendering_vars(false, |_| true).is_empty()); + } + + #[test] + fn windows_uses_webview2_arguments_and_linux_uses_webkit_ones() { + let keys: Vec<_> = software_rendering_vars(false, |_| false) + .into_iter() + .map(|(key, _)| key) + .collect(); + if cfg!(target_os = "windows") { + assert_eq!(keys, ["WEBVIEW2_ADDITIONAL_BROWSER_ARGUMENTS"]); + } else if cfg!(target_os = "linux") { + assert_eq!( + keys, + [ + "WEBKIT_DISABLE_COMPOSITING_MODE", + "WEBKIT_DISABLE_DMABUF_RENDERER" + ] + ); + } + } +} diff --git a/src/lib.rs b/src/lib.rs index 0cd939a..2c78527 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -9,3 +9,9 @@ pub mod config; pub mod mtproto; pub mod proxy; pub mod transport; + +/// The graphical application, shared by the desktop binary and the Android +/// package. Compiled only with the `gui` feature, so a headless build never +/// pulls in Tauri or a WebView. +#[cfg(feature = "gui")] +pub mod gui; diff --git a/src/main.rs b/src/main.rs index 2938e3b..eedf0e1 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,341 +1,10 @@ #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] -use serde::{Deserialize, Serialize}; -use std::path::PathBuf; -use std::sync::atomic::Ordering; -use std::sync::{Arc, Mutex}; -use std::time::{Instant, SystemTime, UNIX_EPOCH}; -use tauri::{Manager, State}; -use tglock::config::ListenConfig; -use tglock::{proxy, transport}; - -#[derive(Clone, Debug, Deserialize, Serialize)] -#[serde(rename_all = "camelCase")] -struct Settings { - lan_mode: bool, - port: u16, - worker_domain: String, -} - -impl Default for Settings { - fn default() -> Self { - Self { - lan_mode: false, - port: proxy::DEFAULT_PORT, - worker_domain: String::new(), - } - } -} - -#[derive(Clone, Serialize)] -#[serde(rename_all = "camelCase")] -struct LogLine { - timestamp: String, - message: String, - error: bool, -} - -#[derive(Serialize)] -#[serde(rename_all = "camelCase")] -struct StatusSnapshot { - running: bool, - active_connections: u32, - tunnels: u32, - data_center: Option, - route: String, - failures: u32, - uptime_seconds: u64, - port: u16, - logs: Vec, -} - -struct AppState { - stats: Arc, - settings: Mutex, - active_port: Mutex, - started_at: Mutex>, - logs: Arc>>, - settings_path: PathBuf, -} - -impl AppState { - fn new(settings_path: PathBuf) -> Self { - let settings = std::fs::read(&settings_path) - .ok() - .and_then(|contents| serde_json::from_slice(&contents).ok()) - .unwrap_or_default(); - Self { - stats: proxy::Stats::new(), - settings: Mutex::new(settings), - active_port: Mutex::new(proxy::DEFAULT_PORT), - started_at: Mutex::new(None), - logs: Arc::new(Mutex::new(Vec::new())), - settings_path, - } - } - - fn log(&self, message: impl Into, error: bool) { - let mut logs = self.logs.lock().unwrap(); - logs.push(LogLine { - timestamp: current_time(), - message: message.into(), - error, - }); - if logs.len() > 100 { - logs.remove(0); - } - } - - fn snapshot(&self) -> StatusSnapshot { - let data_center = self.stats.last_dc.load(Ordering::Relaxed); - let route = transport::route_label(self.stats.last_route.load(Ordering::Relaxed)); - StatusSnapshot { - running: self.stats.running.load(Ordering::SeqCst), - active_connections: self.stats.active.load(Ordering::Relaxed), - tunnels: self.stats.ws.load(Ordering::Relaxed), - data_center: (data_center > 0).then_some(data_center), - route: route.to_owned(), - failures: self.stats.ws_failures.load(Ordering::Relaxed), - uptime_seconds: self - .started_at - .lock() - .unwrap() - .map_or(0, |started| started.elapsed().as_secs()), - port: *self.active_port.lock().unwrap(), - logs: self.logs.lock().unwrap().clone(), - } - } - - fn persist_settings(&self, settings: &Settings) -> Result<(), String> { - if let Some(parent) = self.settings_path.parent() { - std::fs::create_dir_all(parent) - .map_err(|error| format!("Не удалось создать папку настроек: {error}"))?; - } - let contents = serde_json::to_vec_pretty(settings) - .map_err(|error| format!("Не удалось сохранить настройки: {error}"))?; - std::fs::write(&self.settings_path, contents) - .map_err(|error| format!("Не удалось сохранить настройки: {error}")) - } -} - -fn current_time() -> String { - let seconds = SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap_or_default() - .as_secs(); - format!( - "{:02}:{:02}:{:02}", - (seconds / 3600) % 24, - (seconds / 60) % 60, - seconds % 60 - ) -} - -#[tauri::command] -fn get_status(state: State<'_, AppState>) -> StatusSnapshot { - state.snapshot() -} - -#[tauri::command] -fn get_settings(state: State<'_, AppState>) -> Settings { - state.settings.lock().unwrap().clone() -} - -#[tauri::command] -fn save_settings(settings: Settings, state: State<'_, AppState>) -> Result { - if state.stats.running.load(Ordering::SeqCst) { - return Err("Сначала выключите защиту".into()); - } - if settings.port == 0 { - return Err("Порт должен быть от 1 до 65535".into()); - } - state.persist_settings(&settings)?; - *state.settings.lock().unwrap() = settings.clone(); - state.log("Настройки сохранены", false); - Ok(settings) -} - -#[tauri::command] -fn start_proxy(state: State<'_, AppState>) -> Result { - if state.stats.running.load(Ordering::SeqCst) { - return Ok(state.snapshot()); - } - - let settings = state.settings.lock().unwrap().clone(); - state.stats.set_worker_domain(&settings.worker_domain); - *state.active_port.lock().unwrap() = settings.port; - *state.started_at.lock().unwrap() = Some(Instant::now()); - state.log("Запускаю защищённый маршрут…", false); - - let listen = if settings.lan_mode { - ListenConfig::lan(settings.port) - } else { - ListenConfig::loopback(settings.port) - }; - - let stats = state.stats.clone(); - let logs = state.logs.clone(); - std::thread::spawn(move || { - let runtime = match tokio::runtime::Runtime::new() { - Ok(runtime) => runtime, - Err(error) => { - push_log(&logs, format!("Не удалось запустить сервис: {error}"), true); - return; - } - }; - if let Err(error) = runtime.block_on(proxy::run(stats, listen)) { - push_log(&logs, format!("Ошибка подключения: {error}"), true); - } - }); - - std::thread::sleep(std::time::Duration::from_millis(220)); - if !state.stats.running.load(Ordering::SeqCst) { - *state.started_at.lock().unwrap() = None; - return Err(state - .logs - .lock() - .unwrap() - .last() - .map(|line| line.message.clone()) - .unwrap_or_else(|| "Не удалось запустить прокси".into())); - } - - state.log(format!("Прокси запущен на {}", listen.addr), false); - let _ = open::that(listen.telegram_link(&state.stats.telegram_secret())); - state.log("Открываю подключение в Telegram…", false); - Ok(state.snapshot()) -} - -#[tauri::command] -fn stop_proxy(state: State<'_, AppState>) -> StatusSnapshot { - state.stats.stop(); - *state.started_at.lock().unwrap() = None; - state.log("Защита выключена", false); - state.snapshot() -} - -fn push_log(logs: &Arc>>, message: String, error: bool) { - logs.lock().unwrap().push(LogLine { - timestamp: current_time(), - message, - error, - }); -} - -/// Environment variables that make the WebView render without a GPU. -/// -/// The window is never created when 3D acceleration is unavailable: no -/// monitor, the default Microsoft display driver, a virtual machine without -/// 3D enabled (by-sonic/tglock#10, by-sonic/tglock#17). For a small status -/// panel software rendering costs nothing noticeable, so preferring it is the -/// safer default. -/// -/// Values already present in the environment are never overwritten, and -/// `TGLOCK_FORCE_GPU` disables the whole mechanism. -fn software_rendering_vars( - force_gpu: bool, - is_set: impl Fn(&str) -> bool, -) -> Vec<(&'static str, &'static str)> { - if force_gpu { - return Vec::new(); - } - - let candidates: &[(&str, &str)] = if cfg!(target_os = "windows") { - &[( - "WEBVIEW2_ADDITIONAL_BROWSER_ARGUMENTS", - "--disable-gpu --disable-gpu-compositing", - )] - } else if cfg!(target_os = "macos") { - // WebKit on macOS falls back to software rendering on its own. - &[] - } else { - &[ - ("WEBKIT_DISABLE_COMPOSITING_MODE", "1"), - ("WEBKIT_DISABLE_DMABUF_RENDERER", "1"), - ] - }; - - candidates - .iter() - .filter(|(key, _)| !is_set(key)) - .copied() - .collect() -} - -fn prefer_software_rendering() { - let force_gpu = std::env::var_os("TGLOCK_FORCE_GPU").is_some(); - for (key, value) in software_rendering_vars(force_gpu, |key| std::env::var_os(key).is_some()) { - std::env::set_var(key, value); - } -} +// The application itself lives in `tglock_lib::gui` because Android loads the crate +// as a shared library and enters through a JNI symbol rather than through +// `main`. Keeping one implementation for both platforms means the desktop +// binary is just this shim. fn main() { - prefer_software_rendering(); - - tauri::Builder::default() - .setup(|app| { - let settings_path = app - .path() - .app_config_dir() - .map_err(|error| error.to_string())? - .join("settings.json"); - app.manage(AppState::new(settings_path)); - Ok(()) - }) - .invoke_handler(tauri::generate_handler![ - get_status, - get_settings, - save_settings, - start_proxy, - stop_proxy - ]) - .run(tauri::generate_context!()) - .expect("failed to run TGLock"); -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn software_rendering_is_requested_by_default() { - let vars = software_rendering_vars(false, |_| false); - if cfg!(target_os = "macos") { - assert!(vars.is_empty(), "macOS needs no override"); - } else { - assert!( - !vars.is_empty(), - "a machine without 3D acceleration must still get a window" - ); - } - } - - #[test] - fn force_gpu_disables_the_override() { - assert!(software_rendering_vars(true, |_| false).is_empty()); - } - - #[test] - fn an_operators_own_value_is_never_overwritten() { - assert!(software_rendering_vars(false, |_| true).is_empty()); - } - - #[test] - fn windows_uses_webview2_arguments_and_linux_uses_webkit_ones() { - let keys: Vec<_> = software_rendering_vars(false, |_| false) - .into_iter() - .map(|(key, _)| key) - .collect(); - if cfg!(target_os = "windows") { - assert_eq!(keys, ["WEBVIEW2_ADDITIONAL_BROWSER_ARGUMENTS"]); - } else if cfg!(target_os = "linux") { - assert_eq!( - keys, - [ - "WEBKIT_DISABLE_COMPOSITING_MODE", - "WEBKIT_DISABLE_DMABUF_RENDERER" - ] - ); - } - } + tglock_lib::gui::run() } diff --git a/src/transport.rs b/src/transport.rs index 481581e..4d6cd41 100644 --- a/src/transport.rs +++ b/src/transport.rs @@ -355,6 +355,21 @@ pub fn routes_for_dc(dc: u16, media: bool) -> Vec { routes } +/// Select the rustls cryptographic provider. +/// +/// rustls 0.23 refuses to guess when the enabled features do not name exactly +/// one provider, and the failure is a panic on the first TLS handshake — not a +/// compile error and not something the offline tests can reach, because they use +/// a plaintext local route. Installing it at the point of use means both +/// frontends are covered without a startup hook. +fn ensure_crypto_provider() { + static ONCE: std::sync::Once = std::sync::Once::new(); + ONCE.call_once(|| { + // An error here means a provider is already installed, which is fine. + let _ = rustls::crypto::ring::default_provider().install_default(); + }); +} + async fn connect_route(route: &Route) -> Result { let tcp = tokio::time::timeout( CONNECT_TIMEOUT, @@ -392,14 +407,16 @@ async fn connect_route(route: &Route) -> Result { .map_err(|error| format!("WebSocket handshake: {}", error)); } + ensure_crypto_provider(); + // The URI host remains the real Telegram hostname even when the TCP socket - // is opened to a pinned IP. Native TLS therefore validates Telegram's - // certificate and sends the correct SNI. - let tls = native_tls::TlsConnector::new().map_err(|error| format!("TLS setup: {}", error))?; - let connector = tokio_tungstenite::Connector::NativeTls(tls); + // is opened to a pinned IP, so TLS validates Telegram's certificate and + // sends the correct SNI. Passing no connector makes tokio-tungstenite build + // the default rustls one, with the bundled webpki root store; certificate + // and hostname verification are never disabled. tokio::time::timeout( CONNECT_TIMEOUT, - tokio_tungstenite::client_async_tls_with_config(request, tcp, None, Some(connector)), + tokio_tungstenite::client_async_tls_with_config(request, tcp, None, None), ) .await .map_err(|_| "TLS/WebSocket timeout".to_owned())? @@ -656,6 +673,17 @@ mod tests { ); } + #[test] + fn a_crypto_provider_is_available_for_tls() { + // Guards against the failure that compiles cleanly and passes every + // offline test, then panics on the first real connection to Telegram. + ensure_crypto_provider(); + assert!( + rustls::crypto::CryptoProvider::get_default().is_some(), + "without an installed provider every TLS handshake panics" + ); + } + #[test] fn documented_worker_contract_matches_the_requested_path() { // docs/CLOUDFLARE_WORKER.md promises exactly this shape.