diff --git a/docs/provider-channel-selection.md b/docs/provider-channel-selection.md index b8da20b87..96752c11e 100644 --- a/docs/provider-channel-selection.md +++ b/docs/provider-channel-selection.md @@ -27,3 +27,29 @@ Restart reconciliation checks local selection metadata. It does not send a provider validation request or automatically choose another enabled provider after a network failure. An ongoing recording retains the channel identity, protocol and model selected at the start of that recording. + + +## Use your ChatGPT plan for text polishing + +Add a **ChatGPT Plan** LLM channel and choose **Continue with ChatGPT**. Complete +sign-in and plan-usage consent in the system browser, return to OpenLess, and +select a model from the account's catalog. Enable the channel and move it first +to use it for polishing and translation. Codex and an OpenAI API key are not +required. Speech recognition keeps its existing ASR provider. + +Model discovery does not run inference. **Test connection** consumes a small +amount of ChatGPT plan usage. **Manage usage** opens ChatGPT's usage settings; +limits may apply to the plan or this app. OpenLess never switches a failed +ChatGPT Plan request to an API-key billing path automatically. + +Each channel selects a saved account registration or creates a new one. Matching +email addresses do not merge registrations or workspaces. **Disconnect** stops +that registration's requests, attempts remote session revocation, and removes +its local tokens without changing channel order. If remote revocation cannot be +confirmed, disconnect the app in ChatGPT settings. Reauthorization reuses the +saved registration. OAuth tokens and host identifiers are local-only and are +excluded from provider synchronization, backup, and migration; a channel copied +to another device requires a new local sign-in. + +On mobile, keep the authorization within the system's background-execution +window. If the process is terminated or the attempt expires, start sign-in again. diff --git a/openless-all/app/Cargo.lock b/openless-all/app/Cargo.lock index f7ced779a..68d03e392 100644 --- a/openless-all/app/Cargo.lock +++ b/openless-all/app/Cargo.lock @@ -606,6 +606,12 @@ dependencies = [ "tower-service", ] +[[package]] +name = "base16ct" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf" + [[package]] name = "base64" version = "0.22.1" @@ -1022,6 +1028,12 @@ dependencies = [ "crossbeam-utils", ] +[[package]] +name = "const-oid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" + [[package]] name = "const-oid" version = "0.10.2" @@ -1186,6 +1198,18 @@ version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" +[[package]] +name = "crypto-bigint" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76" +dependencies = [ + "generic-array", + "rand_core 0.6.4", + "subtle", + "zeroize", +] + [[package]] name = "crypto-common" version = "0.1.7" @@ -1222,6 +1246,33 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f27ae1dd37df86211c42e150270f82743308803d90a6f6e6651cd730d5e1732f" +[[package]] +name = "curve25519-dalek" +version = "4.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "curve25519-dalek-derive", + "digest 0.10.7", + "fiat-crypto", + "rustc_version", + "subtle", + "zeroize", +] + +[[package]] +name = "curve25519-dalek-derive" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "darling" version = "0.20.11" @@ -1292,6 +1343,17 @@ version = "0.1.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ac6b926516df9c60bfa16e107b21086399f8285a44ca9711344b9e553c5146e2" +[[package]] +name = "der" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" +dependencies = [ + "const-oid 0.9.6", + "pem-rfc7468", + "zeroize", +] + [[package]] name = "der-parser" version = "10.0.0" @@ -1350,7 +1412,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ "block-buffer 0.10.4", + "const-oid 0.9.6", "crypto-common 0.1.7", + "subtle", ] [[package]] @@ -1360,7 +1424,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" dependencies = [ "block-buffer 0.12.1", - "const-oid", + "const-oid 0.10.2", "crypto-common 0.2.2", "ctutils", "zeroize", @@ -1431,6 +1495,20 @@ version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" +[[package]] +name = "ecdsa" +version = "0.16.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca" +dependencies = [ + "der", + "digest 0.10.7", + "elliptic-curve", + "rfc6979", + "signature", + "spki", +] + [[package]] name = "ecolor" version = "0.37.0" @@ -1441,6 +1519,30 @@ dependencies = [ "emath", ] +[[package]] +name = "ed25519" +version = "2.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53" +dependencies = [ + "pkcs8", + "signature", +] + +[[package]] +name = "ed25519-dalek" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9" +dependencies = [ + "curve25519-dalek", + "ed25519", + "serde", + "sha2 0.10.9", + "subtle", + "zeroize", +] + [[package]] name = "eframe" version = "0.37.0" @@ -1561,6 +1663,27 @@ version = "1.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0e9c71c2167ca323c882b99918929403426e2373ea17242ff5653e0d5e1058be" +[[package]] +name = "elliptic-curve" +version = "0.13.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47" +dependencies = [ + "base16ct", + "crypto-bigint", + "digest 0.10.7", + "ff", + "generic-array", + "group", + "hkdf 0.12.4", + "pem-rfc7468", + "pkcs8", + "rand_core 0.6.4", + "sec1", + "subtle", + "zeroize", +] + [[package]] name = "emath" version = "0.37.0" @@ -1747,6 +1870,22 @@ dependencies = [ "rkyv", ] +[[package]] +name = "ff" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0b50bfb653653f9ca9095b427bed08ab8d75a137839d9ad64eb11810d5b6393" +dependencies = [ + "rand_core 0.6.4", + "subtle", +] + +[[package]] +name = "fiat-crypto" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" + [[package]] name = "filetime" version = "0.2.29" @@ -2001,6 +2140,7 @@ checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" dependencies = [ "typenum", "version_check", + "zeroize", ] [[package]] @@ -2187,6 +2327,17 @@ dependencies = [ "thiserror 2.0.21", ] +[[package]] +name = "group" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63" +dependencies = [ + "ff", + "rand_core 0.6.4", + "subtle", +] + [[package]] name = "guillotiere" version = "0.7.0" @@ -2286,13 +2437,31 @@ version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" +[[package]] +name = "hkdf" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" +dependencies = [ + "hmac 0.12.1", +] + [[package]] name = "hkdf" version = "0.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4aaa26c720c68b866f2c96ef5c1264b3e6f473fe5d4ce61cd44bbe913e553018" dependencies = [ - "hmac", + "hmac 0.13.0", +] + +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest 0.10.7", ] [[package]] @@ -2704,6 +2873,28 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "jsonwebtoken" +version = "11.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e75fe14a82d81e5f5af639997db37d8b96045938a7ac6ab18cdbe1c7467e05e1" +dependencies = [ + "base64 0.22.1", + "ed25519-dalek", + "getrandom 0.2.17", + "hmac 0.12.1", + "js-sys", + "p256", + "p384", + "rand 0.8.8", + "rsa", + "serde", + "serde_json", + "sha2 0.10.9", + "signature", + "zeroize", +] + [[package]] name = "keyring" version = "4.2.0" @@ -2748,6 +2939,9 @@ name = "lazy_static" version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "20870f649af7073d53e38067b2a84312175d56ea15217e1b15bc83506ec50afb" +dependencies = [ + "spin", +] [[package]] name = "libbz2-rs-sys" @@ -2893,7 +3087,7 @@ version = "0.22.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7277c9742229dc25243236d578b6471a798c554512c6f6a04365ec2e87c2a3bb" dependencies = [ - "sha2", + "sha2 0.11.1", ] [[package]] @@ -3184,6 +3378,22 @@ dependencies = [ "num-traits", ] +[[package]] +name = "num-bigint-dig" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e661dda6640fad38e827a6d4a310ff4763082116fe217f279885c97f511bb0b7" +dependencies = [ + "lazy_static", + "libm", + "num-integer", + "num-iter", + "num-traits", + "rand 0.8.8", + "smallvec", + "zeroize", +] + [[package]] name = "num-complex" version = "0.4.6" @@ -3683,17 +3893,20 @@ dependencies = [ "futures-util", "getrandom 0.4.3", "hex", - "hmac", + "hmac 0.13.0", + "jsonwebtoken", "log", "md-5", "once_cell", "parking_lot", + "rand 0.8.8", "regex", "reqwest", + "rsa", "serde", "serde_json", "sha1 0.11.0", - "sha2", + "sha2 0.11.1", "tar", "thiserror 2.0.21", "tokio", @@ -3746,7 +3959,7 @@ dependencies = [ "semver", "serde", "serde_json", - "sha2", + "sha2 0.11.1", "simplelog", "tempfile", "time", @@ -3820,6 +4033,30 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "p256" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9863ad85fa8f4460f9c48cb909d38a0d689dba1f6f6988a5e3e0d31071bcd4b" +dependencies = [ + "ecdsa", + "elliptic-curve", + "primeorder", + "sha2 0.10.9", +] + +[[package]] +name = "p384" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe42f1670a52a47d448f14b6a5c61dd78fce51856e68edaa38f7ae3a46b8d6b6" +dependencies = [ + "ecdsa", + "elliptic-curve", + "primeorder", + "sha2 0.10.9", +] + [[package]] name = "parking" version = "2.2.1" @@ -3866,7 +4103,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "112d82ceb8c5bf524d9af484d4e4970c9fd5a0cc15ba14ad93dccd28873b0629" dependencies = [ "digest 0.11.3", - "hmac", + "hmac 0.13.0", ] [[package]] @@ -3879,6 +4116,15 @@ dependencies = [ "serde_core", ] +[[package]] +name = "pem-rfc7468" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412" +dependencies = [ + "base64ct", +] + [[package]] name = "peniko" version = "0.6.1" @@ -4084,6 +4330,27 @@ dependencies = [ "system-deps", ] +[[package]] +name = "pkcs1" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8ffb9f10fa047879315e6625af03c164b16962a5368d724ed16323b68ace47f" +dependencies = [ + "der", + "pkcs8", + "spki", +] + +[[package]] +name = "pkcs8" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" +dependencies = [ + "der", + "spki", +] + [[package]] name = "pkg-config" version = "0.3.34" @@ -4205,6 +4472,15 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e8cf8e6a8aa66ce33f63993ffc4ea4271eb5b0530a9002db8455ea6050c77bfa" +[[package]] +name = "primeorder" +version = "0.13.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "353e1ca18966c16d9deb1c69278edbc5f194139612772bd9537af60ac231e1e6" +dependencies = [ + "elliptic-curve", +] + [[package]] name = "proc-macro-crate" version = "3.5.0" @@ -4373,13 +4649,24 @@ dependencies = [ "ptr_meta", ] +[[package]] +name = "rand" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e058c7de0b26af77780c769414d6257830bb240f3c38477dbc2c16e5f54d6d4c" +dependencies = [ + "libc", + "rand_chacha 0.3.1", + "rand_core 0.6.4", +] + [[package]] name = "rand" version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41" dependencies = [ - "rand_chacha", + "rand_chacha 0.9.0", "rand_core 0.9.5", ] @@ -4394,6 +4681,16 @@ dependencies = [ "rand_core 0.10.1", ] +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core 0.6.4", +] + [[package]] name = "rand_chacha" version = "0.9.0" @@ -4404,6 +4701,15 @@ dependencies = [ "rand_core 0.9.5", ] +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", +] + [[package]] name = "rand_core" version = "0.9.5" @@ -4599,6 +4905,16 @@ dependencies = [ "usvg", ] +[[package]] +name = "rfc6979" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dd2a808d456c4a54e300a23e9f5a67e122c3024119acbfd73e3bf664491cb2" +dependencies = [ + "hmac 0.12.1", + "subtle", +] + [[package]] name = "rfd" version = "0.17.2" @@ -4691,6 +5007,26 @@ dependencies = [ "memchr", ] +[[package]] +name = "rsa" +version = "0.9.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8573f03f5883dcaebdfcf4725caa1ecb9c15b2ef50c43a07b816e06799bb12d" +dependencies = [ + "const-oid 0.9.6", + "digest 0.10.7", + "num-bigint-dig", + "num-integer", + "num-traits", + "pkcs1", + "pkcs8", + "rand_core 0.6.4", + "signature", + "spki", + "subtle", + "zeroize", +] + [[package]] name = "rustc-hash" version = "1.1.0" @@ -4866,6 +5202,20 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "sec1" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc" +dependencies = [ + "base16ct", + "der", + "generic-array", + "pkcs8", + "subtle", + "zeroize", +] + [[package]] name = "secret-service" version = "5.2.0" @@ -4876,12 +5226,12 @@ dependencies = [ "cbc", "futures-util", "getrandom 0.4.3", - "hkdf", + "hkdf 0.13.0", "hybrid-array", "num", "once_cell", "serde", - "sha2", + "sha2 0.11.1", "zbus", ] @@ -5023,6 +5373,17 @@ dependencies = [ "digest 0.11.3", ] +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest 0.10.7", +] + [[package]] name = "sha2" version = "0.11.1" @@ -5056,6 +5417,16 @@ dependencies = [ "libc", ] +[[package]] +name = "signature" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" +dependencies = [ + "digest 0.10.7", + "rand_core 0.6.4", +] + [[package]] name = "simd-adler32" version = "0.3.10" @@ -5179,6 +5550,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "spin" +version = "0.9.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3763264f6b73151db08c50ff20d7d8a0b8796e021cdea7ceedad07b80155fa0e" + [[package]] name = "spirv" version = "0.4.0+sdk-1.4.341.0" @@ -5188,6 +5565,16 @@ dependencies = [ "bitflags 2.13.2", ] +[[package]] +name = "spki" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" +dependencies = [ + "base64ct", + "der", +] + [[package]] name = "stable_deref_trait" version = "1.2.1" @@ -7128,7 +7515,7 @@ dependencies = [ "deflate64", "flate2", "getrandom 0.4.3", - "hmac", + "hmac 0.13.0", "indexmap", "lzma-rust2", "memchr", diff --git a/openless-all/app/android/kotlin/OpenLessImeService.kt b/openless-all/app/android/kotlin/OpenLessImeService.kt index 3eb130dd0..8787b73a6 100644 --- a/openless-all/app/android/kotlin/OpenLessImeService.kt +++ b/openless-all/app/android/kotlin/OpenLessImeService.kt @@ -4629,6 +4629,7 @@ class OpenLessImeService : InputMethodService(), OpenLessOverlayBridge.OverlaySt } private fun startRuntimeService() { + OpenLessRuntimeService.imeRequested = true try { val intent = android.content.Intent(this, OpenLessRuntimeService::class.java) if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.O) { @@ -4695,6 +4696,8 @@ class OpenLessImeService : InputMethodService(), OpenLessOverlayBridge.OverlaySt private fun ensureBackendReady() = OpenLessBackendWarmupActivity.ensureBackendReady(this) private fun stopRuntimeService() { + OpenLessRuntimeService.imeRequested = false + if (OpenLessRuntimeService.authorizationActive()) return runCatching { stopService(android.content.Intent(this, OpenLessRuntimeService::class.java)) } } diff --git a/openless-all/app/android/kotlin/OpenLessRuntimeService.kt b/openless-all/app/android/kotlin/OpenLessRuntimeService.kt index 7c003c94e..a5c772c0c 100644 --- a/openless-all/app/android/kotlin/OpenLessRuntimeService.kt +++ b/openless-all/app/android/kotlin/OpenLessRuntimeService.kt @@ -111,7 +111,7 @@ class OpenLessRuntimeService : Service() { // old WebView, can create an empty black Activity. UI warmup remains // explicitly initiated by the launcher/settings or the IME path when // an input interaction actually requires it. - return START_STICKY + return if (authorizationActive() && !imeRequested) START_NOT_STICKY else START_STICKY } private fun buildNotification(): Notification { @@ -133,16 +133,16 @@ class OpenLessRuntimeService : Service() { } return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { Notification.Builder(this, channelId) - .setContentTitle("OpenLess 输入法") - .setContentText("输入法面板已就绪") + .setContentTitle(if (imeRequested) "OpenLess 输入法" else "OpenLess") + .setContentText(if (imeRequested) "输入法面板已就绪" else "等待 ChatGPT 授权") .setSmallIcon(R.mipmap.ic_launcher) .setOngoing(true) .build() } else { @Suppress("DEPRECATION") Notification.Builder(this) - .setContentTitle("OpenLess 输入法") - .setContentText("输入法面板已就绪") + .setContentTitle(if (imeRequested) "OpenLess 输入法" else "OpenLess") + .setContentText(if (imeRequested) "输入法面板已就绪" else "等待 ChatGPT 授权") .setSmallIcon(R.mipmap.ic_launcher) .setOngoing(true) .build() @@ -150,6 +150,29 @@ class OpenLessRuntimeService : Service() { } companion object { + @Volatile var imeRequested = false + private val authorizationUsers = java.util.concurrent.atomic.AtomicInteger(0) + fun authorizationActive(): Boolean = authorizationUsers.get() > 0 + + @androidx.annotation.Keep + @JvmStatic fun acquireAuthorization(context: android.content.Context) { + authorizationUsers.incrementAndGet() + try { + val intent = Intent(context, OpenLessRuntimeService::class.java) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) context.startForegroundService(intent) + else context.startService(intent) + } catch (error: Throwable) { + authorizationUsers.decrementAndGet() + throw error + } + } + + @androidx.annotation.Keep + @JvmStatic fun releaseAuthorization(context: android.content.Context) { + val remaining = authorizationUsers.updateAndGet { maxOf(0, it - 1) } + if (remaining == 0 && !imeRequested) context.stopService(Intent(context, OpenLessRuntimeService::class.java)) + } + private const val NOTIFICATION_ID = 42002 private const val TAG = "OpenLessRuntimeService" private const val ACTION_RUNTIME_ACTIVITY_DESTROYED = "com.openless.app.action.RUNTIME_ACTIVITY_DESTROYED" diff --git a/openless-all/app/crates/openless-core/Cargo.toml b/openless-all/app/crates/openless-core/Cargo.toml index 1c55fc063..80b76bc93 100644 --- a/openless-all/app/crates/openless-core/Cargo.toml +++ b/openless-all/app/crates/openless-core/Cargo.toml @@ -20,6 +20,7 @@ ferrous-opencc = "0.4" hmac = "0.13.0" hex = "0.4.3" log = "0.4" +jsonwebtoken = { version = "11.1", default-features = false, features = ["rust_crypto"] } md-5 = "0.11.0" once_cell = "1" parking_lot = "0.12" @@ -42,3 +43,7 @@ uuid = { version = "1", features = ["v4", "serde"] } unicode-normalization = "0.1" zeroize = { version = "1", features = ["derive"] } zip = "9.0.0" + +[dev-dependencies] +rsa = "0.9.10" +rand = "0.8.8" diff --git a/openless-all/app/crates/openless-core/src/api.rs b/openless-all/app/crates/openless-core/src/api.rs index 670275f71..4256e0983 100644 --- a/openless-all/app/crates/openless-core/src/api.rs +++ b/openless-all/app/crates/openless-core/src/api.rs @@ -3942,8 +3942,8 @@ impl OpenLessBackend { *self .voice_activation_session .lock() - .expect("voice activation session lock poisoned") = Some( - crate::voice_activation::VoiceActivationSession { + .expect("voice activation session lock poisoned") = + Some(crate::voice_activation::VoiceActivationSession { generation: crate::voice_activation::VoiceActivationGeneration(generation), session_id, timeout: activation_config.timeout(), @@ -4941,7 +4941,8 @@ impl OpenLessBackend { pub async fn start(&self) -> Result { let _lifecycle_gate = self.backend_lifecycle_gate.lock().await; - self.backend_shutdown_requested.store(false, Ordering::Release); + self.backend_shutdown_requested + .store(false, Ordering::Release); if let Some(error) = &self.startup_error { return Err(error.clone()); } @@ -5029,7 +5030,8 @@ impl OpenLessBackend { pub async fn shutdown(&self) -> Result<(), BackendError> { let _lifecycle_gate = self.backend_lifecycle_gate.lock().await; - self.backend_shutdown_requested.store(true, Ordering::Release); + self.backend_shutdown_requested + .store(true, Ordering::Release); let _ = self .stop_voice_activation(crate::voice_activation::VoiceActivationStopReason::Disabled) .await; @@ -5637,12 +5639,82 @@ impl OpenLessBackend { Ok(()) } + pub fn siwc_service(&self) -> Result, BackendError> { + self.deps + .credential_store + .siwc() + .ok_or_else(|| crate::siwc::error("unavailable").into_backend()) + } + + pub async fn siwc_for_channel( + &self, + channel: &str, + ) -> Result, BackendError> { + if !self + .list_channels(ChannelKind::Llm) + .await? + .iter() + .any(|c| c.id == channel && c.provider_type == crate::siwc::PROVIDER_ID) + { + return Err(crate::siwc::error("channel_missing").into_backend()); + } + self.siwc_service() + } + + pub async fn begin_siwc_sign_in(&self, channel: String, new_account: bool, enable_usage: bool) -> Result { + let service = self.siwc_for_channel(&channel).await?; + let launch = service.begin_sign_in(channel.clone(), new_account, enable_usage).await.map_err(crate::siwc::SiwcError::into_backend)?; + // Storage/browser preparation can race channel deletion or a provider switch. + let current = self.siwc_for_channel(&channel).await; + if current.is_ok() && service.authorizing(&channel, &launch.attempt_id) { return Ok(launch); } + service.cancel_attempt(&channel, &launch.attempt_id).await.map_err(crate::siwc::SiwcError::into_backend)?; + current?; + Err(crate::siwc::error("cancelled").into_backend()) + } + + async fn with_siwc_status( + &self, + mut status: CredentialsStatus, + ) -> Result { + if let Some(service) = self.deps.credential_store.siwc() { + let channel_id = self + .deps + .credential_store + .active_provider(crate::ProviderSlot::Llm) + .await?; + let channel = &channel_id; + if self + .list_channels(ChannelKind::Llm) + .await? + .iter() + .any(|c| &c.id == channel && c.provider_type == crate::siwc::PROVIDER_ID) + { + let session = service.status(channel).await.map_err(crate::siwc::SiwcError::into_backend)?; + let model = self + .deps + .credential_store + .read(CredentialKey::new( + crate::CredentialNamespace::Llm, + Some(channel.clone()), + crate::credentials::LLM_MODEL_ACCOUNT, + )?) + .await?; + status.llm_configured = + matches!(session.state.as_str(), "connected" | "usage_limited") + && model.is_some_and(|m| !m.expose_secret().trim().is_empty()); + status.ark_configured = status.llm_configured; + } + } + Ok(status) + } + pub async fn get_credentials_status(&self) -> Result { let status = self .deps .credential_store .status(self.get_preferences()) .await?; + let status = self.with_siwc_status(status).await?; self.state .write() .expect("backend state lock poisoned") @@ -5777,6 +5849,15 @@ impl OpenLessBackend { Some(id.clone()), crate::llm_protocol::SERVICE_TIER_ACCOUNT, )?; + let leaving_siwc = previous.provider_type == crate::siwc::PROVIDER_ID + && previous.provider_type != provider_type; + if leaving_siwc { + self.siwc_service()? + .cancel_sign_in(&id) + .await + .map_err(crate::siwc::SiwcError::into_backend)?; + } + let siwc_channel = id.clone(); let reset = kind == ChannelKind::Llm && previous.provider_type != provider_type; let (old_format, old_service_tier) = if reset { let old_format = self.deps.credential_store.read(key.clone()).await?; @@ -5823,6 +5904,12 @@ impl OpenLessBackend { } } result?; + if leaving_siwc { + self.siwc_service()? + .detach(siwc_channel) + .await + .map_err(crate::siwc::SiwcError::into_backend)?; + } self.refresh_and_publish_credentials().await.map(|_| ()) } @@ -5831,6 +5918,19 @@ impl OpenLessBackend { kind: ChannelKind, id: String, ) -> Result { + if kind == ChannelKind::Llm + && self + .list_channels(kind) + .await? + .iter() + .any(|c| c.id == id && c.provider_type == crate::siwc::PROVIDER_ID) + { + if let Some(service) = self.deps.credential_store.siwc() { + if service.has_binding(&id).await.map_err(crate::siwc::SiwcError::into_backend)? { + return Ok(false); + } + } + } match self .apply_channel_mutation(ChannelMutation::DeleteIfBlank { kind, id }) .await? @@ -5855,9 +5955,28 @@ impl OpenLessBackend { } pub async fn delete_channel(&self, kind: ChannelKind, id: String) -> Result<(), BackendError> { - self.apply_channel_mutation(ChannelMutation::Delete { kind, id }) - .await - .map(|_| ()) + let is_siwc = kind == ChannelKind::Llm + && self + .list_channels(kind) + .await? + .iter() + .any(|c| c.id == id && c.provider_type == crate::siwc::PROVIDER_ID); + let service = self.deps.credential_store.siwc().filter(|_| is_siwc); + if let Some(service) = &service { + service + .cancel_sign_in(&id) + .await + .map_err(crate::siwc::SiwcError::into_backend)?; + } + self.apply_channel_mutation(ChannelMutation::Delete { + kind, + id: id.clone(), + }) + .await?; + if let Some(service) = service { + service.detach(id).await.map_err(crate::siwc::SiwcError::into_backend)?; + } + Ok(()) } pub async fn set_channel_enabled( @@ -5905,6 +6024,10 @@ impl OpenLessBackend { .map(|_| ()) } + pub async fn invalidate_channel_test(&self, kind: ChannelKind, id: String) -> Result<(), BackendError> { + self.apply_channel_mutation(ChannelMutation::InvalidateTest { kind, id }).await.map(|_| ()) + } + pub async fn invalidate_channel_tests(&self, kind: ChannelKind) -> Result<(), BackendError> { self.apply_channel_mutation(ChannelMutation::InvalidateTests { kind }) .await @@ -6196,13 +6319,7 @@ impl OpenLessBackend { // native model receipt is missing or a download/storage // operation requires a process restart. Do not advertise a // route that the next prepare call will reject. - let status = match self - .deps - .services - .local_asr - .runtime_status(runtime) - .await - { + let status = match self.deps.services.local_asr.runtime_status(runtime).await { Ok(status) => status, Err(_) => return AsrRoute::Unavailable, }; @@ -6283,7 +6400,10 @@ impl OpenLessBackend { } else { usage.cloud += 1; } - if latest.as_ref().is_none_or(|(created_at, _)| entry.created_at > *created_at) { + if latest + .as_ref() + .is_none_or(|(created_at, _)| entry.created_at > *created_at) + { latest = Some((entry.created_at, local)); } } @@ -6326,6 +6446,7 @@ impl OpenLessBackend { .credential_store .status(self.get_preferences()) .await?; + let status = self.with_siwc_status(status).await?; self.state .write() .expect("backend state lock poisoned") @@ -11574,7 +11695,11 @@ mod tests { assert_eq!( backend.asr_usage().unwrap(), - AsrUsage { local: 2, cloud: 2, last_local: Some(true) } + AsrUsage { + local: 2, + cloud: 2, + last_local: Some(true) + } ); let _ = std::fs::remove_dir_all(data_dir); } @@ -11647,7 +11772,11 @@ mod tests { assert_eq!( backend.asr_usage().unwrap(), - AsrUsage { local: 2, cloud: 1, last_local: Some(true) } + AsrUsage { + local: 2, + cloud: 1, + last_local: Some(true) + } ); let _ = std::fs::remove_dir_all(data_dir); } diff --git a/openless-all/app/crates/openless-core/src/cloud_providers.rs b/openless-all/app/crates/openless-core/src/cloud_providers.rs index c5e0d3c3c..4c38be0f5 100644 --- a/openless-all/app/crates/openless-core/src/cloud_providers.rs +++ b/openless-all/app/crates/openless-core/src/cloud_providers.rs @@ -77,6 +77,7 @@ pub const SHARED_CLOUD_LLM_PROVIDER_TYPES: &[&str] = &[ "openai", "gemini", crate::polish::CODEX_OAUTH_PROVIDER_ID, + crate::siwc::PROVIDER_ID, "mimo", "cometapi", "openrouterFree", @@ -1405,7 +1406,10 @@ pub(crate) async fn llm_channel_configured( if !has_model { return false; } - if provider_type == crate::polish::CODEX_OAUTH_PROVIDER_ID { + if matches!( + provider_type, + crate::polish::CODEX_OAUTH_PROVIDER_ID | crate::siwc::PROVIDER_ID + ) { return true; } let Ok(api_key) = read_channel_credential( @@ -1465,6 +1469,28 @@ async fn build_cloud_polisher_provider( .ok_or_else(|| { BackendError::new(BackendErrorCode::Provider, "LLM model is not configured") })?; + if provider_type == crate::siwc::PROVIDER_ID { + let service = credentials + .siwc() + .ok_or_else(|| crate::siwc::error("unavailable").into_backend())?; + let access = service + .access(channel_id) + .await + .map_err(crate::siwc::SiwcError::into_backend)?; + let config = crate::polish::OpenAICompatibleConfig::new( + crate::siwc::PROVIDER_ID, + "ChatGPT Plan", + crate::siwc::API_BASE, + "", + model, + ) + .with_temperature(None); + let provider = + crate::polish::OpenAICompatibleLLMProvider::new(config).with_siwc_access(access); + return Ok(CloudPolisherProvider::OpenAi( + crate::polish::ActiveLLMProvider::OpenAI(provider), + )); + } if provider_type == crate::polish::CODEX_OAUTH_PROVIDER_ID { let provider = crate::polish::CodexOAuthLLMProvider::new( crate::polish::CodexOAuthConfig::new(model) @@ -1595,6 +1621,9 @@ fn map_llm_error(error: impl std::fmt::Display) -> BackendError { /// malformed reply. The dictation pipeline uses it to tell "offline" apart /// when the transcript came from a local model. fn map_polish_error(error: crate::polish::LLMError) -> BackendError { + if let crate::polish::LLMError::Siwc(error) = error { + return error.into_backend(); + } let transient = matches!( error, crate::polish::LLMError::Network(_) | crate::polish::LLMError::Timeout diff --git a/openless-all/app/crates/openless-core/src/credentials.rs b/openless-all/app/crates/openless-core/src/credentials.rs index 0e0e097a7..7a82033d2 100644 --- a/openless-all/app/crates/openless-core/src/credentials.rs +++ b/openless-all/app/crates/openless-core/src/credentials.rs @@ -359,6 +359,10 @@ impl fmt::Debug for SecretValue { } pub trait CredentialStore: Send + Sync { + fn siwc(&self) -> Option> { + None + } + fn bind_sync_gate(&self, _gate: std::sync::Arc) -> Result<(), BackendError> { Ok(()) } diff --git a/openless-all/app/crates/openless-core/src/lib.rs b/openless-all/app/crates/openless-core/src/lib.rs index 92964d687..827099da0 100644 --- a/openless-all/app/crates/openless-core/src/lib.rs +++ b/openless-all/app/crates/openless-core/src/lib.rs @@ -69,6 +69,7 @@ mod selection_voice_service; pub mod settings; pub mod shared_types; pub mod silence_auto_stop; +pub mod siwc; pub mod streaming_insert; mod style_pack_archive; pub mod style_pack_store; diff --git a/openless-all/app/crates/openless-core/src/llm_protocol.rs b/openless-all/app/crates/openless-core/src/llm_protocol.rs index 3490807b5..41d6ad20a 100644 --- a/openless-all/app/crates/openless-core/src/llm_protocol.rs +++ b/openless-all/app/crates/openless-core/src/llm_protocol.rs @@ -35,7 +35,7 @@ impl LlmRequestFormat { pub fn default_for(provider: &str) -> Self { match provider { - "custom_responses" => Self::Responses, + "custom_responses" | crate::siwc::PROVIDER_ID => Self::Responses, "custom_messages" => Self::Messages, _ => Self::ChatCompletions, } @@ -44,7 +44,7 @@ impl LlmRequestFormat { pub fn selectable(provider: &str) -> bool { !matches!( provider, - "gemini" | "codex_oauth" | "tencentTokenHub" | "lmstudio" + "gemini" | "codex_oauth" | crate::siwc::PROVIDER_ID | "tencentTokenHub" | "lmstudio" ) } @@ -252,6 +252,9 @@ pub(crate) fn request_body( stream: bool, messages: Vec, ) -> Value { + if config.provider_id == crate::siwc::PROVIDER_ID { + return crate::siwc::inference::request_body(&config.model, messages); + } let mut body = match config.protocol.format { LlmRequestFormat::ChatCompletions => { unreachable!("Chat Completions retains its provider rules") @@ -405,6 +408,8 @@ pub(crate) struct TextEventStream { pending: Vec, messages_complete: bool, pub done: bool, + siwc: bool, + request_id: Option, } impl TextEventStream { @@ -415,9 +420,17 @@ impl TextEventStream { pending: Vec::new(), messages_complete: false, done: false, + siwc: false, + request_id: None, } } + pub(crate) fn for_siwc(mut self, request_id: Option) -> Self { + self.siwc = true; + self.request_id = request_id; + self + } + pub fn push(&mut self, chunk: &[u8]) -> Result<(), LLMError> { crate::polish::append_utf8_sse_chunk(&mut self.buffer, &mut self.pending, chunk) } @@ -458,6 +471,19 @@ impl TextEventStream { .find_map(|line| line.strip_prefix("event:").map(str::trim)) }) .unwrap_or(""); + if self.siwc && (kind == "error" || kind == "response.failed" || !value["error"].is_null()) + { + let body = if kind == "response.failed" { + &value["response"] + } else { + &value + }; + return Err(LLMError::Siwc(crate::siwc::response_error( + 200, + body, + self.request_id.as_deref(), + ))); + } if kind == "error" || !value["error"].is_null() { return Err(response_error("llmStreamError")); } @@ -476,6 +502,7 @@ impl TextEventStream { None } "response.failed" | "response.incomplete" => { + if self.siwc { return Err(LLMError::Siwc(crate::siwc::error("response_incomplete"))); } return Err(response_error("llmResponseIncomplete")) } _ => None, diff --git a/openless-all/app/crates/openless-core/src/polish.rs b/openless-all/app/crates/openless-core/src/polish.rs index 7931a28da..4ff06a1f5 100644 --- a/openless-all/app/crates/openless-core/src/polish.rs +++ b/openless-all/app/crates/openless-core/src/polish.rs @@ -212,6 +212,8 @@ fn is_builtin_llm_provider(provider_id: &str) -> bool { #[derive(Debug, Error)] pub enum LLMError { + #[error("{0}")] + Siwc(crate::siwc::SiwcError), #[error("missing credentials")] MissingCredentials, #[error("network error: {0}")] @@ -518,6 +520,7 @@ impl ActiveLLMProvider { pub struct OpenAICompatibleLLMProvider { config: OpenAICompatibleConfig, + siwc_access: Option>, dictation_session_id: Option, /// Polish-dedicated client: no input-length-dependent whole-request timeout, only a /// connection-leak hard cap. The real criteria live at call sites (streaming two @@ -548,10 +551,16 @@ impl OpenAICompatibleLLMProvider { Self { config, dictation_session_id: None, + siwc_access: None, polish_client, } } + pub(crate) fn with_siwc_access(mut self, access: crate::siwc::SiwcAccess) -> Self { + self.siwc_access = Some(std::sync::Arc::new(access)); + self + } + pub(crate) fn set_dictation_session(&mut self, session_id: crate::types::SessionId) { self.dictation_session_id = Some(session_id); } @@ -859,6 +868,23 @@ impl OpenAICompatibleLLMProvider { body: &serde_json::Value, budget: Duration, ) -> Result { + if let Some(access) = &self.siwc_access { + return tokio::time::timeout( + budget, + access.infer( + body, + StreamingTimeouts { + first_token: budget, + idle: budget, + }, + |_| {}, + || false, + ), + ) + .await + .map_err(|_| LLMError::Timeout)? + .map(|text| clean_polish_output(&text)); + } let request_id = uuid::Uuid::new_v4(); log::info!( "[llm] request_id={} session_id={} stream=false", @@ -982,6 +1008,16 @@ impl OpenAICompatibleLLMProvider { F: Fn(&str) + Send + Sync, C: Fn() -> bool + Send + Sync, { + if let Some(access) = &self.siwc_access { + return access + .infer( + &self.chat_body(true, messages), + timeouts, + on_delta, + should_cancel, + ) + .await; + } if should_cancel() { return Err(LLMError::Network("cancelled".into())); } diff --git a/openless-all/app/crates/openless-core/src/provider_rules.rs b/openless-all/app/crates/openless-core/src/provider_rules.rs index 27a7e3df6..968113506 100644 --- a/openless-all/app/crates/openless-core/src/provider_rules.rs +++ b/openless-all/app/crates/openless-core/src/provider_rules.rs @@ -64,6 +64,7 @@ const LLM_PROVIDER_TYPES: &[(&str, &str)] = &[ ("openai", "openai"), ("gemini", "gemini"), (crate::polish::CODEX_OAUTH_PROVIDER_ID, "codexOAuth"), + (crate::siwc::PROVIDER_ID, "chatgptSiwc"), ("mimo", "mimo"), ("cometapi", "cometapi"), ("openrouterFree", "openrouterFree"), @@ -122,6 +123,7 @@ const GEMINI_MODELS: &[&str] = &[ #[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)] #[serde(rename_all = "snake_case")] pub enum AuthRequirement { + Siwc, None, ApiKey, EndpointModelOptionalApiKey, @@ -271,6 +273,7 @@ fn provider_descriptor_with_label( default_llm_model(&id), match id.as_str() { crate::polish::CODEX_OAUTH_PROVIDER_ID => AuthRequirement::OAuth, + crate::siwc::PROVIDER_ID => AuthRequirement::Siwc, "gemini" => AuthRequirement::ApiKey, "lmstudio" => AuthRequirement::EndpointModelOptionalApiKey, _ => AuthRequirement::ApiKeyUnlessCustomEndpoint, @@ -520,6 +523,7 @@ pub fn auth_requirement_satisfied( && configuration.tencent_cloud_secret_key } AuthRequirement::OAuth => configuration.codex_oauth && model, + AuthRequirement::Siwc => false, // Backend combines the local-only SIWC session with the selected model. } } @@ -535,6 +539,7 @@ pub fn api_key_required( AuthRequirement::None | AuthRequirement::EndpointModelOptionalApiKey | AuthRequirement::OAuth + | AuthRequirement::Siwc | AuthRequirement::TencentCloud => false, AuthRequirement::ApiKeyUnlessCustomEndpoint => { let Some(endpoint) = configured_endpoint.filter(|value| !value.trim().is_empty()) @@ -616,6 +621,7 @@ pub fn default_asr_model(provider_type: &str) -> Option<&'static str> { pub fn default_llm_endpoint(provider_type: &str) -> Option<&'static str> { match provider_type { + crate::siwc::PROVIDER_ID => Some(crate::siwc::API_BASE), "opencode" => Some("https://opencode.ai/zen/v1"), "ark" => Some("https://ark.cn-beijing.volces.com/api/v3"), "deepseek" => Some("https://api.deepseek.com/v1"), diff --git a/openless-all/app/crates/openless-core/src/provider_service.rs b/openless-all/app/crates/openless-core/src/provider_service.rs index e5f135a0d..8212879bb 100644 --- a/openless-all/app/crates/openless-core/src/provider_service.rs +++ b/openless-all/app/crates/openless-core/src/provider_service.rs @@ -337,6 +337,16 @@ impl ProviderService { ) -> Result { let resolved = self.resolve(request).await?; ensure_supported_kind(&resolved)?; + if resolved.provider_type == crate::siwc::PROVIDER_ID { + let service = self + .credentials + .siwc() + .ok_or_else(|| crate::siwc::error("unavailable").into_backend())?; + return tokio::select! { + _ = wait_for_cancellation(cancellation) => Err(cancelled_request()), + result = service.list_models(&resolved.provider_id) => result.map(|models| ProviderModelsResult { models: models.into_iter().map(|m| m.slug).collect() }).map_err(crate::siwc::SiwcError::into_backend), + }; + } if let Some(models) = static_models(&resolved) { if cancellation.is_cancelled() { return Err(cancelled_request()); @@ -514,7 +524,10 @@ fn validate_configuration( { return Err(invalid_request("provider model is not configured")); } - if !matches!(descriptor.auth_requirement, AuthRequirement::OAuth) { + if !matches!( + descriptor.auth_requirement, + AuthRequirement::OAuth | AuthRequirement::Siwc + ) { let endpoint = resolved .endpoint .as_deref() @@ -676,7 +689,7 @@ fn provider_no_final_is_valid(error: &BackendError) -> bool { } fn sanitize_validation_error(error: BackendError) -> BackendError { - if error.code != BackendErrorCode::Provider { + if error.code != BackendErrorCode::Provider || error.details.as_ref().is_some_and(|d| d.get("siwc").is_some()) { return error; } let message = error.message.as_str(); diff --git a/openless-all/app/crates/openless-core/src/siwc/inference.rs b/openless-all/app/crates/openless-core/src/siwc/inference.rs new file mode 100644 index 000000000..49e198412 --- /dev/null +++ b/openless-all/app/crates/openless-core/src/siwc/inference.rs @@ -0,0 +1,132 @@ +use super::*; +use crate::{ + llm_protocol::{LlmRequestFormat, StreamEvent, TextEventStream}, + polish::{LLMError, StreamingTimeouts}, +}; + +/// A separate admission policy over the shared prompt and SSE machinery. +pub(crate) fn request_body(model: &str, messages: Vec) -> Value { + let input: Vec = messages + .into_iter() + .map(|mut message| { + if message["role"] == "system" { + message["role"] = json!("developer"); + } + message + }) + .collect(); + json!({"model": model, "input": input, "stream": true, "store": false}) +} +impl SiwcAccess { + pub(crate) async fn infer( + &self, + body: &Value, + timeouts: StreamingTimeouts, + on_delta: F, + should_cancel: C, + ) -> Result + where + F: Fn(&str) + Send + Sync, + C: Fn() -> bool + Send + Sync, + { + let call = async { + let token = self.token().await.map_err(LLMError::Siwc)?; + let url = self.endpoint(); + let started = std::time::Instant::now(); + let request = crate::net::credential_http_for_url(&url) + .post(&url) + .bearer_auth(token.expose_secret()) + .header("Accept", "text/event-stream") + .json(body); + // Do not replay a possibly accepted inference or switch its billing source. + let mut response = tokio::time::timeout(timeouts.first_token, request.send()) + .await + .map_err(|_| LLMError::Timeout)? + .map_err(|_| LLMError::Siwc(error("network_failed")))?; + let status = response.status().as_u16(); + let request_id = response + .headers() + .get("x-request-id") + .or_else(|| response.headers().get("openai-request-id")) + .and_then(|v| v.to_str().ok()) + .map(str::to_owned); + if !(200..300).contains(&status) { + let failure = match tokio::time::timeout( + timeouts.first_token.saturating_sub(started.elapsed()), + json_response(Ok(response)), + ) + .await + { + Ok(Err(failure)) => failure, + Ok(Ok(_)) => error("request_failed"), + Err(_) => error("network_failed"), + }; + self.record_error(failure.clone()) + .await + .map_err(LLMError::Siwc)?; + return Err(LLMError::Siwc(failure)); + } + let mut events = + TextEventStream::new(LlmRequestFormat::Responses).for_siwc(request_id.clone()); + let mut text = String::new(); + while !events.done { + let budget = if text.is_empty() { + timeouts.first_token.saturating_sub(started.elapsed()) + } else { + timeouts.idle + }; + let chunk = tokio::time::timeout(budget, response.chunk()) + .await + .map_err(|_| LLMError::Timeout)? + .map_err(|_| LLMError::Siwc(error("network_failed")))?; + let Some(chunk) = chunk else { + break; + }; + events.push(&chunk)?; + loop { + let event = match events.next() { + Err(LLMError::Siwc(failure)) => { + let failure = failure.with_http_context(status, request_id.as_deref()); + self.record_error(failure.clone()) + .await + .map_err(LLMError::Siwc)?; + return Err(LLMError::Siwc(failure)); + } + other => other?, + }; + let Some(event) = event else { + break; + }; + if should_cancel() { + return Err(LLMError::Siwc(error("cancelled"))); + } + if let StreamEvent::Text(delta) = event { + text.push_str(&delta); + on_delta(&delta); + } + } + } + events.finish().map_err(|_| { + LLMError::Siwc( + error("response_incomplete").with_http_context(status, request_id.as_deref()), + ) + })?; + self.check().await.map_err(LLMError::Siwc)?; + if text.trim().is_empty() { + return Err(LLMError::Siwc(error("empty_response"))); + } + Ok(text) + }; + let cancelled = async { + while !should_cancel() { + tokio::time::sleep(Duration::from_millis(25)).await; + } + }; + tokio::select! { + biased; + _ = cancelled => Err(LLMError::Siwc(error("cancelled"))), + failure = self.cancelled() => Err(LLMError::Siwc(failure)), + result = call => result, + } + } +} diff --git a/openless-all/app/crates/openless-core/src/siwc/mod.rs b/openless-all/app/crates/openless-core/src/siwc/mod.rs new file mode 100644 index 000000000..66ebf0da3 --- /dev/null +++ b/openless-all/app/crates/openless-core/src/siwc/mod.rs @@ -0,0 +1,1386 @@ +//! Official Sign in with ChatGPT. OAuth secrets never enter the provider credential map. +pub(crate) mod inference; +mod store; +pub use store::{locked_head, SiwcVault}; +#[cfg(test)] +mod tests; + +use crate::{BackendError, BackendErrorCode, SecretValue}; +use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine}; +use serde::{Deserialize, Serialize}; +use serde_json::{json, Value}; +use sha2::{Digest, Sha256}; +use std::{ + collections::BTreeMap, + sync::Arc, + time::{Duration, SystemTime, UNIX_EPOCH}, +}; +use tokio::io::{AsyncReadExt, AsyncWriteExt}; + +pub const PROVIDER_ID: &str = "chatgpt_siwc"; +pub const API_BASE: &str = "https://api.openai.com/v1"; +pub const USAGE_URL: &str = "https://chatgpt.com/settings/usage"; +const ISSUER: &str = "https://auth.openai.com"; +const SCOPES: &str = + "openid profile email offline_access resource.invoke chatgpt.tokens.use.direct"; +const DIRECT: &str = "chatgpt.tokens.use.direct"; +const AUTH_TIMEOUT: Duration = Duration::from_secs(600); +const HTTP_TIMEOUT: Duration = Duration::from_secs(30); + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct SiwcError { + pub code: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub http_status: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub param: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub request_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub body_shape: Option, +} +pub fn error(code: &str) -> SiwcError { + SiwcError { + code: code.into(), + http_status: None, + param: None, + request_id: None, + body_shape: None, + } +} +impl std::fmt::Display for SiwcError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!( + f, + "siwc:{}", + serde_json::to_string(self).unwrap_or_else(|_| "{}".into()) + ) + } +} +impl std::error::Error for SiwcError {} +impl SiwcError { + fn with_http_context(mut self, status: u16, request_id: Option<&str>) -> Self { + self.http_status = Some(status); + self.request_id = safe_identifier(request_id); + self + } + + /// Keep this conversion explicit so unrelated host Result inference remains unchanged. + pub fn into_backend(self) -> BackendError { + let code = if self.code == "cancelled" { + BackendErrorCode::Cancelled + } else { + BackendErrorCode::Provider + }; + let message = match self.code.as_str() { + "subscription_sharing_usage_limit_exceeded" | "rate_limited" => "ChatGPT plan or app usage limit reached. Open Manage usage in the ChatGPT Plan channel.", + "subscription_sharing_user_not_eligible" | "forbidden" => "This ChatGPT account, workspace or region is not eligible for plan usage.", + "usage_disabled" => "Enable ChatGPT plan usage in this channel's settings.", + "sign_in_required" | "reauthorization_required" | "refresh_interrupted" | "session_changed" => "Sign in to ChatGPT again in the ChatGPT Plan channel.", + "subscription_sharing_usage_unavailable" | "subscription_sharing_user_unavailable" | "unavailable" => "ChatGPT plan usage is temporarily unavailable. Retry later.", + "subscription_sharing_unsupported_capability" => "This ChatGPT model or capability is unavailable. Refresh the model list.", + "response_incomplete" => "No completed response was received from ChatGPT.", + "cancelled" => "ChatGPT request cancelled.", + _ => "ChatGPT Plan request failed. Check this channel's connection status.", + }; + let mut result = BackendError::new(code, message); + result.details = Some(json!({"siwc": self})); + result + } +} +fn now() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs() +} +fn random() -> String { + let mut bytes = [0u8; 32]; + getrandom::fill(&mut bytes).expect("OS random source unavailable"); + URL_SAFE_NO_PAD.encode(bytes) +} +fn safe_identifier(value: Option<&str>) -> Option { + value + .filter(|s| { + !s.is_empty() + && s.len() <= 160 + && s.bytes() + .all(|b| b.is_ascii_alphanumeric() || b"._-[]".contains(&b)) + }) + .map(str::to_owned) +} +pub(crate) fn response_error(status: u16, body: &Value, request_id: Option<&str>) -> SiwcError { + let detail = body.get("error").unwrap_or(body); + let mut failure = error( + &safe_identifier(detail["code"].as_str()).unwrap_or_else(|| { + match status { + 401 => "unauthorized", + 403 => "forbidden", + 429 => "rate_limited", + 503 => "unavailable", + _ => "request_failed", + } + .into() + }), + ); + failure.http_status = Some(status); + failure.param = safe_identifier(detail["param"].as_str()); + failure.request_id = safe_identifier(request_id); + failure.body_shape = Some( + if body.get("error").is_some() { + "error" + } else if body.get("detail").is_some() { + "detail" + } else { + "other" + } + .into(), + ); + failure +} + +#[derive(Clone, Serialize, Deserialize, zeroize::Zeroize, zeroize::ZeroizeOnDrop)] +struct Tokens { + access: String, + refresh: String, + id: String, + nonce: String, + expires_at: u64, + scopes: Vec, +} +impl Tokens { + fn plan(&self) -> bool { + self.scopes.iter().any(|s| s == DIRECT) + && self.scopes.iter().any(|s| s == "resource.invoke") + } +} +#[derive(Clone, Serialize, Deserialize)] +struct Registration { + id: String, + issuer: String, + subject: String, + client_id: String, + email: Option, + epoch: u64, + tokens: Option, + refreshing: Option<(String, u64)>, + failure: Option, + welcomed: bool, + #[serde(default)] + revocation_confirmed: Option, +} +#[derive(Clone, Default, Serialize, Deserialize)] +struct Binding { + registration: Option, + attempt: Option, +} +#[derive(Clone, Serialize, Deserialize)] +struct Data { + host_id: String, + registrations: BTreeMap, + channels: BTreeMap, + #[serde(default)] + pending_clients: BTreeMap, +} +impl Default for Data { + fn default() -> Self { + Self { + host_id: format!("urn:uuid:{}", uuid::Uuid::new_v4()), + registrations: BTreeMap::new(), + channels: BTreeMap::new(), + pending_clients: BTreeMap::new(), + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SiwcAccount { + pub id: String, + pub label: String, + pub email: Option, +} +impl Registration { + fn account(&self) -> SiwcAccount { + let suffix = &self.client_id[self.client_id.len().saturating_sub(6)..]; + SiwcAccount { + id: self.id.clone(), + email: self.email.clone(), + label: format!( + "{} · {}", + self.email.as_deref().unwrap_or("ChatGPT"), + suffix + ), + } + } +} +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SiwcStatus { + pub state: String, + pub account: Option, + pub accounts: Vec, + pub attempt_id: Option, + pub error: Option, + pub welcome_pending: bool, +} +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct SiwcModel { + pub slug: String, + pub display_name: String, +} +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SiwcDisconnect { + pub remote_revoked: bool, +} + +/// Host-only launch data: never serialize, log, or send the authorization URL to WebView. +pub struct SiwcLaunch { + pub attempt_id: String, + url: SecretValue, +} +impl SiwcLaunch { + pub fn authorization_url(&self) -> &str { + self.url.expose_secret() + } +} +struct Pending { + id: String, + cancellation: crate::provider_transport::ProviderCancellation, +} +#[derive(Clone, Deserialize)] +struct Discovery { + issuer: String, + authorization_endpoint: String, + token_endpoint: String, + jwks_uri: String, + revocation_endpoint: String, +} +#[derive(Deserialize)] +struct Claims { + iss: String, + sub: String, + exp: u64, + iat: u64, + nonce: Option, + email: Option, +} +#[derive(Clone)] +struct Endpoints { + issuer: String, + discovery: String, + api: String, +} +impl Default for Endpoints { + fn default() -> Self { + Self { + issuer: ISSUER.into(), + discovery: format!("{ISSUER}/.well-known/openid-configuration"), + api: API_BASE.into(), + } + } +} +pub struct SiwcService { + store: Arc, + endpoints: Endpoints, + pending: parking_lot::Mutex>, + errors: parking_lot::Mutex>, + jwks: tokio::sync::Mutex>, +} +impl SiwcService { + pub fn new(vault: Arc) -> Arc { + Arc::new(Self { + store: Arc::new(store::Store(vault)), + endpoints: Endpoints::default(), + pending: Default::default(), + errors: Default::default(), + jwks: Default::default(), + }) + } + async fn load(&self) -> Result { + let store = self.store.clone(); + tokio::task::spawn_blocking(move || store.load()) + .await + .map_err(|_| error("storage_failed"))? + } + async fn change( + &self, + mut f: impl FnMut(&mut Data) -> Result + Send + 'static, + ) -> Result { + let store = self.store.clone(); + tokio::task::spawn_blocking(move || { + for _ in 0..16 { + let before = store.load()?; + let mut next = before.data.clone(); + let result = f(&mut next)?; + if store.commit(&before, &next)? { + return Ok(result); + } + } + Err(error("busy")) + }) + .await + .map_err(|_| error("storage_failed"))? + } + pub async fn status(&self, channel: &str) -> Result { + let data = self.load().await?.data; + let binding = data.channels.get(channel); + let registration = binding + .and_then(|b| b.registration.as_ref()) + .and_then(|id| data.registrations.get(id)); + let attempt_id = self.pending.lock().get(channel).map(|p| p.id.clone()); + let mut state = "signed_out"; + let mut failure = self.errors.lock().get(channel).cloned(); + if let Some(r) = registration { + failure = failure.or_else(|| r.failure.clone()); + if r.tokens.is_none() && r.revocation_confirmed == Some(false) { + failure = Some(error("remote_revocation_unconfirmed")); + } + state = match &r.tokens { + None => "reauthorization_required", + Some(t) if !t.plan() => "usage_disabled", + Some(_) + if r.failure + .as_ref() + .is_some_and(|e| e.code == "subscription_sharing_usage_limit_exceeded") => + { + "usage_limited" + } + Some(_) + if r.refreshing + .as_ref() + .is_some_and(|(_, since)| now().saturating_sub(*since) >= 120) => + { + "reauthorization_required" + } + Some(_) => "connected", + }; + } + if attempt_id.is_some() { + state = "authorizing"; + } + Ok(SiwcStatus { + state: state.into(), + account: registration.map(Registration::account), + accounts: data + .registrations + .values() + .map(Registration::account) + .collect(), + attempt_id, + error: failure, + welcome_pending: registration + .is_some_and(|r| !r.welcomed && r.tokens.as_ref().is_some_and(Tokens::plan)), + }) + } + pub fn authorizing(&self, channel: &str, attempt: &str) -> bool { + self.pending + .lock() + .get(channel) + .is_some_and(|p| p.id == attempt) + } + pub async fn has_binding(&self, channel: &str) -> Result { + Ok(self + .load() + .await? + .data + .channels + .get(channel) + .is_some_and(|b| b.registration.is_some() || b.attempt.is_some())) + } + pub async fn select_account( + &self, + channel: String, + registration: String, + ) -> Result<(), SiwcError> { + self.cancel_sign_in(&channel).await?; + self.errors.lock().remove(&channel); + self.change(move |data| { + if !data.registrations.contains_key(®istration) { + return Err(error("account_missing")); + } + data.channels.insert( + channel.clone(), + Binding { + registration: Some(registration.clone()), + attempt: None, + }, + ); + Ok(()) + }) + .await + } + pub async fn acknowledge_welcome(&self, channel: String) -> Result<(), SiwcError> { + self.change(move |data| { + selected_mut(data, &channel)?.welcomed = true; + Ok(()) + }) + .await + } + pub async fn retry_usage(&self, channel: String) -> Result<(), SiwcError> { + self.change(move |data| { + selected_mut(data, &channel)?.failure = None; + Ok(()) + }) + .await + } + pub async fn cancel_sign_in(&self, channel: &str) -> Result<(), SiwcError> { + let local = self + .pending + .lock() + .get(channel) + .map(|pending| pending.id.clone()); + let attempt = match local { + Some(id) => Some(id), + None => self + .load() + .await? + .data + .channels + .get(channel) + .and_then(|b| b.attempt.clone()), + }; + if let Some(attempt) = attempt { + self.cancel_attempt(channel, &attempt).await?; + } + Ok(()) + } + pub(crate) async fn cancel_attempt( + &self, + channel: &str, + attempt: &str, + ) -> Result<(), SiwcError> { + { + let mut pending = self.pending.lock(); + if pending.get(channel).is_some_and(|p| p.id == attempt) { + if let Some(pending) = pending.remove(channel) { + pending.cancellation.cancel(); + } + } + } + let snapshot = self.load().await?; + if snapshot + .data + .channels + .get(channel) + .and_then(|b| b.attempt.as_deref()) + != Some(attempt) + { + return Ok(()); + } + let channel = channel.to_owned(); + let attempt = attempt.to_owned(); + self.change(move |data| { + if let Some(binding) = data.channels.get_mut(&channel) { + if binding.attempt.as_deref() == Some(&attempt) { + binding.attempt = None; + } + } + Ok(()) + }) + .await + } + pub async fn detach(&self, channel: String) -> Result<(), SiwcError> { + self.cancel_sign_in(&channel).await?; + let snapshot = self.load().await?; + if !snapshot.data.channels.contains_key(&channel) + && !snapshot.data.pending_clients.contains_key(&channel) + { + return Ok(()); + } + self.change(move |data| { + data.channels.remove(&channel); + data.pending_clients.remove(&channel); + Ok(()) + }) + .await + } + async fn discovery(&self) -> Result { + let value = json_response( + self.client() + .get(&self.endpoints.discovery) + .timeout(HTTP_TIMEOUT) + .send() + .await, + ) + .await?; + let discovery: Discovery = + serde_json::from_value(value).map_err(|_| error("discovery_invalid"))?; + if discovery.issuer != self.endpoints.issuer { + return Err(error("issuer_mismatch")); + } + let issuer = + url::Url::parse(&self.endpoints.issuer).map_err(|_| error("discovery_invalid"))?; + for endpoint in [ + &discovery.authorization_endpoint, + &discovery.token_endpoint, + &discovery.jwks_uri, + &discovery.revocation_endpoint, + ] { + let parsed = url::Url::parse(endpoint).map_err(|_| error("discovery_invalid"))?; + if parsed.origin() != issuer.origin() + || !parsed.username().is_empty() + || parsed.password().is_some() + { + return Err(error("discovery_invalid")); + } + } + Ok(discovery) + } + fn client(&self) -> reqwest::Client { + crate::net::credential_http_for_url(&self.endpoints.issuer) + } + pub async fn begin_sign_in( + self: &Arc, + channel: String, + new_account: bool, + enable_usage: bool, + ) -> Result { + self.cancel_sign_in(&channel).await?; + self.errors.lock().remove(&channel); + let listener = tokio::net::TcpListener::bind((std::net::Ipv4Addr::LOCALHOST, 0)) + .await + .map_err(|_| error("callback_unavailable"))?; + let redirect = format!( + "http://127.0.0.1:{}/auth/callback", + listener + .local_addr() + .map_err(|_| error("callback_unavailable"))? + .port() + ); + let id = uuid::Uuid::new_v4().to_string(); + let attempt = id.clone(); + let key = channel.clone(); + let (host_id, account, pending_client) = self + .change(move |data| { + let binding = data.channels.entry(key.clone()).or_default(); + binding.attempt = Some(attempt.clone()); + let account = if new_account { + None + } else { + binding + .registration + .as_ref() + .and_then(|id| data.registrations.get(id)) + .cloned() + }; + if new_account { + data.pending_clients.remove(&key); + } + Ok(( + data.host_id.clone(), + account, + data.pending_clients.get(&key).cloned(), + )) + }) + .await?; + let client_id = account + .as_ref() + .map(|r| r.client_id.clone()) + .or(pending_client); + let state = random(); + let nonce = random(); + let verifier = random(); + let challenge = URL_SAFE_NO_PAD.encode(Sha256::digest(verifier.as_bytes())); + let mut url = url::Url::parse(&format!("{}/api/accounts/authorize", self.endpoints.issuer)) + .map_err(|_| error("discovery_invalid"))?; + { + let mut query = url.query_pairs_mut(); + query + .append_pair( + "client_id", + client_id.as_deref().unwrap_or("dynamic_agent_client"), + ) + .append_pair("ext_agent_host_id", &host_id) + .append_pair("response_type", "code") + .append_pair("redirect_uri", &redirect) + .append_pair("scope", SCOPES) + .append_pair("resource", API_BASE) + .append_pair("state", &state) + .append_pair("nonce", &nonce) + .append_pair("code_challenge_method", "S256") + .append_pair("code_challenge", &challenge); + if let Some(r) = &account { + if let Some(t) = &r.tokens { + if !t.id.is_empty() { + query.append_pair("id_token_hint", &t.id); + } + } + if let Some(email) = &r.email { + query.append_pair("login_hint", email); + } + } + if client_id.is_none() { + query.append_pair("agent_name_hint", "OpenLess"); + } + if enable_usage { + query.append_pair("prompt", "consent"); + } + } + let cancellation = crate::provider_transport::ProviderCancellation::new(); + self.pending.lock().insert( + channel.clone(), + Pending { + id: id.clone(), + cancellation: cancellation.clone(), + }, + ); + let service = self.clone(); + let task_id = id.clone(); + tokio::spawn(async move { + let task = service.finish_sign_in( + &channel, &task_id, listener, account, client_id, redirect, state, nonce, verifier, + ); + let result = tokio::select! { + _ = wait_cancel(&cancellation) => Err(error("cancelled")), + result = tokio::time::timeout(AUTH_TIMEOUT, task) => result.unwrap_or_else(|_| Err(error("authorization_timeout"))), + }; + let current = { + let mut pending = service.pending.lock(); + let current = pending.get(&channel).is_some_and(|p| p.id == task_id); + if current { + pending.remove(&channel); + if let Err(e) = result { + service.errors.lock().insert(channel.clone(), e); + } + } + current + }; + if current { + let _ = service + .change(move |data| { + if let Some(b) = data.channels.get_mut(&channel) { + if b.attempt.as_deref() == Some(&task_id) { + b.attempt = None; + } + } + Ok(()) + }) + .await; + } + }); + Ok(SiwcLaunch { + attempt_id: id, + url: SecretValue::new(url.to_string()), + }) + } + #[allow(clippy::too_many_arguments)] + async fn finish_sign_in( + &self, + channel: &str, + attempt: &str, + listener: tokio::net::TcpListener, + account: Option, + expected_client: Option, + redirect: String, + state: String, + nonce: String, + verifier: String, + ) -> Result<(), SiwcError> { + let (mut socket, params) = callback(&listener, &state).await?; + let result = async { + if params.contains_key("error") { + return Err(error("access_denied")); + } + let code = params + .get("code") + .filter(|s| !s.is_empty()) + .ok_or_else(|| error("callback_invalid"))?; + let client = match expected_client { + Some(client) => { + if params.get("client_id").is_some_and(|c| c != &client) { + return Err(error("client_mismatch")); + } + client + } + None => params + .get("client_id") + .filter(|s| { + s.as_str() != "dynamic_agent_client" && safe_identifier(Some(s)).is_some() + }) + .cloned() + .ok_or_else(|| error("registration_incomplete"))?, + }; + let pending_channel = channel.to_owned(); + let pending_attempt = attempt.to_owned(); + let pending_client = client.clone(); + self.change(move |data| { + if data + .channels + .get(&pending_channel) + .and_then(|b| b.attempt.as_deref()) + != Some(&pending_attempt) + { + return Err(error("cancelled")); + } + data.pending_clients + .insert(pending_channel.clone(), pending_client.clone()); + Ok(()) + }) + .await?; + let discovery = self.discovery().await?; + let value = json_response( + self.client() + .post(&discovery.token_endpoint) + .timeout(HTTP_TIMEOUT) + .form(&[ + ("grant_type", "authorization_code"), + ("client_id", &client), + ("code", code), + ("code_verifier", &verifier), + ("redirect_uri", &redirect), + ("resource", API_BASE), + ]) + .send() + .await, + ) + .await?; + let id_token = value["id_token"] + .as_str() + .ok_or_else(|| error("id_token_missing"))?; + let claims = self + .verify_identity(id_token, &client, Some(&nonce), &discovery) + .await?; + if account + .as_ref() + .is_some_and(|r| r.subject != claims.sub || r.issuer != claims.iss) + { + return Err(error("identity_mismatch")); + } + let tokens = parse_tokens(&value, None, &nonce)?; + let channel = channel.to_string(); + let attempt = attempt.to_string(); + self.change(move |data| { + if data + .channels + .get(&channel) + .and_then(|b| b.attempt.as_deref()) + != Some(&attempt) + { + return Err(error("cancelled")); + } + if let Some(old) = &account { + if data + .registrations + .get(&old.id) + .is_none_or(|r| r.epoch != old.epoch) + { + return Err(error("cancelled")); + } + } + let existing = data + .registrations + .values() + .find(|r| { + r.client_id == client && r.subject == claims.sub && r.issuer == claims.iss + }) + .cloned(); + let id = existing + .as_ref() + .map(|r| r.id.clone()) + .unwrap_or_else(|| uuid::Uuid::new_v4().to_string()); + data.registrations.insert( + id.clone(), + Registration { + id: id.clone(), + issuer: claims.iss.clone(), + subject: claims.sub.clone(), + client_id: client.clone(), + email: claims.email.clone(), + epoch: existing.as_ref().map_or(1, |r| r.epoch + 1), + tokens: Some(tokens.clone()), + refreshing: None, + failure: None, + welcomed: existing.is_some_and(|r| r.welcomed), + revocation_confirmed: None, + }, + ); + data.channels.insert( + channel.clone(), + Binding { + registration: Some(id), + attempt: None, + }, + ); + data.pending_clients.remove(&channel); + Ok(()) + }) + .await + } + .await; + let text = if result.is_ok() { + "ChatGPT authorization complete. You can return to OpenLess." + } else { + "Authorization was not completed. Return to OpenLess to retry." + }; + let response = format!("HTTP/1.1 200 OK\r\nContent-Type: text/plain; charset=utf-8\r\nCache-Control: no-store\r\nReferrer-Policy: no-referrer\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", text.len(), text); + let _ = tokio::time::timeout( + Duration::from_secs(2), + socket.write_all(response.as_bytes()), + ) + .await; + result + } + async fn verify_identity( + &self, + token: &str, + client: &str, + nonce: Option<&str>, + discovery: &Discovery, + ) -> Result { + use jsonwebtoken::{Algorithm, DecodingKey, Validation}; + let header = jsonwebtoken::decode_header(token).map_err(|_| error("id_token_invalid"))?; + if header.alg != Algorithm::RS256 { + return Err(error("id_token_invalid")); + } + let kid = header.kid.ok_or_else(|| error("id_token_invalid"))?; + let mut cache = self.jwks.lock().await; + if cache + .as_ref() + .is_none_or(|(at, set)| now().saturating_sub(*at) > 3600 || set.find(&kid).is_none()) + { + let value = json_response( + self.client() + .get(&discovery.jwks_uri) + .timeout(HTTP_TIMEOUT) + .send() + .await, + ) + .await?; + *cache = Some(( + now(), + serde_json::from_value(value).map_err(|_| error("jwks_invalid"))?, + )); + } + let jwk = cache + .as_ref() + .and_then(|(_, set)| set.find(&kid)) + .ok_or_else(|| error("id_token_invalid"))?; + let key = DecodingKey::from_jwk(jwk).map_err(|_| error("id_token_invalid"))?; + let mut validation = Validation::new(Algorithm::RS256); + validation.set_issuer(&[&discovery.issuer]); + validation.set_audience(&[client]); + validation.set_required_spec_claims(&["iss", "sub", "aud", "exp", "iat"]); + validation.leeway = 5; + validation.validate_nbf = true; + let claims = jsonwebtoken::decode::(token, &key, &validation) + .map_err(|_| error("id_token_invalid"))? + .claims; + if claims.sub.is_empty() + || claims.exp == 0 + || claims.iat > now() + 5 + || nonce.is_some_and(|n| claims.nonce.as_deref() != Some(n)) + { + return Err(error("id_token_invalid")); + } + Ok(claims) + } + pub(crate) async fn access(self: &Arc, channel: &str) -> Result { + let data = self.load().await?.data; + let r = selected(&data, channel)?; + Ok(SiwcAccess { + service: self.clone(), + registration: r.id.clone(), + epoch: r.epoch, + }) + } + async fn token(self: &Arc, id: &str, epoch: u64) -> Result { + let mut waited = false; + loop { + let data = self.load().await?.data; + let r = data + .registrations + .get(id) + .filter(|r| r.epoch == epoch) + .ok_or_else(|| error("session_changed"))?; + let tokens = r + .tokens + .as_ref() + .ok_or_else(|| error("reauthorization_required"))?; + if !tokens.plan() { + return Err(error("usage_disabled")); + } + if let Some(failure) = r.failure.as_ref().filter(|e| { + waited + || matches!( + e.code.as_str(), + "subscription_sharing_usage_limit_exceeded" + | "subscription_sharing_user_not_eligible" + ) + }) { + return Err(failure.clone()); + } + if let Some((_, since)) = &r.refreshing { + if now().saturating_sub(*since) >= 120 { + return Err(error("refresh_interrupted")); + } + waited = true; + tokio::time::sleep(Duration::from_millis(100)).await; + continue; + } + if tokens.expires_at > now() + if waited { 0 } else { 60 } { + return Ok(SecretValue::new(tokens.access.clone())); + } + if tokens.refresh.is_empty() { + return Err(error("reauthorization_required")); + } + // A cancelled inference must not abandon a rotating-token transaction. + let service = self.clone(); + let id = id.to_owned(); + tokio::spawn(async move { service.perform_refresh(id, epoch).await }) + .await + .map_err(|_| error("refresh_interrupted"))??; + waited = true; + } + } + async fn perform_refresh(&self, id: String, epoch: u64) -> Result<(), SiwcError> { + // Discovery cannot rotate tokens; complete it before claiming the refresh transaction. + let discovery = self.discovery().await?; + let operation = uuid::Uuid::new_v4().to_string(); + let key = id.clone(); + let op = operation.clone(); + let original = self + .change(move |data| { + let r = data + .registrations + .get_mut(&key) + .filter(|r| r.epoch == epoch) + .ok_or_else(|| error("session_changed"))?; + if r.refreshing.is_some() + || r.tokens.as_ref().is_some_and(|t| t.expires_at > now() + 60) + { + return Ok(None); + } + r.refreshing = Some((op.clone(), now())); + r.failure = None; + Ok(Some(r.clone())) + }) + .await?; + let Some(original) = original else { + return Ok(()); + }; + match self.refresh_tokens(&original, &discovery).await { + Ok(tokens) => { + self.change(move |data| { + let r = data + .registrations + .get_mut(&id) + .filter(|r| { + r.epoch == epoch + && r.refreshing + .as_ref() + .is_some_and(|(id, _)| id == &operation) + }) + .ok_or_else(|| error("session_changed"))?; + r.tokens = Some(tokens.clone()); + r.refreshing = None; + r.failure = None; + Ok(()) + }) + .await + } + Err(failure) => { + let returned = failure.clone(); + self.change(move |data| { + if let Some(r) = data.registrations.get_mut(&id).filter(|r| { + r.epoch == epoch + && r.refreshing + .as_ref() + .is_some_and(|(id, _)| id == &operation) + }) { + if terminal_refresh(&failure.code) { + r.tokens = None; + r.epoch += 1; + r.refreshing = None; + } else if failure.http_status.is_some() { + r.refreshing = None; + } + // Ambiguous network failure can mean the token already rotated; never replay it. + r.failure = Some(failure.clone()); + } + Ok(()) + }) + .await?; + Err(returned) + } + } + } + async fn refresh_tokens( + &self, + registration: &Registration, + discovery: &Discovery, + ) -> Result { + let old = registration + .tokens + .as_ref() + .ok_or_else(|| error("reauthorization_required"))?; + let value = json_response( + self.client() + .post(&discovery.token_endpoint) + .timeout(HTTP_TIMEOUT) + .form(&[ + ("grant_type", "refresh_token"), + ("client_id", ®istration.client_id), + ("refresh_token", &old.refresh), + ("resource", API_BASE), + ]) + .send() + .await, + ) + .await?; + if let Some(token) = value["id_token"].as_str() { + let claims = self + .verify_identity(token, ®istration.client_id, None, discovery) + .await?; + if claims.sub != registration.subject + || claims.iss != registration.issuer + || claims + .nonce + .as_ref() + .is_some_and(|nonce| nonce != &old.nonce) + { + return Err(error("identity_mismatch")); + } + } + parse_tokens(&value, Some(old), &old.nonce) + } + pub async fn list_models(self: &Arc, channel: &str) -> Result, SiwcError> { + let access = self.access(channel).await?; + let token = access.token().await?; + let response = crate::net::credential_http_for_url(&self.endpoints.api) + .get(format!("{}/models", self.endpoints.api)) + .bearer_auth(token.expose_secret()) + .timeout(HTTP_TIMEOUT) + .send() + .await; + let result = json_response(response).await; + if let Err(e) = &result { + access.record_error(e.clone()).await?; + } + let value = result?; + access.check().await?; + let models = value["models"] + .as_array() + .ok_or_else(|| error("models_invalid"))?; + let mut seen = std::collections::HashSet::new(); + Ok(models + .iter() + .filter(|m| m["visibility"] == "list") + .filter_map(|m| { + let slug = m["slug"].as_str().filter(|s| !s.is_empty())?; + if !seen.insert(slug.to_string()) { + return None; + } + Some(SiwcModel { + slug: slug.into(), + display_name: m["display_name"] + .as_str() + .filter(|s| !s.is_empty()) + .unwrap_or(slug) + .into(), + }) + }) + .collect()) + } + pub async fn disconnect(&self, channel: &str) -> Result { + self.cancel_sign_in(channel).await?; + let channel = channel.to_string(); + let (registration, channels) = self + .change(move |data| { + let r = selected_mut(data, &channel)?; + let old = r.clone(); + r.epoch += 1; + r.tokens = None; + r.refreshing = None; + r.failure = None; + r.revocation_confirmed = Some(false); + let id = r.id.clone(); + let channels: Vec = data + .channels + .iter_mut() + .filter(|(_, b)| b.registration.as_deref() == Some(&id)) + .map(|(channel, b)| { + b.attempt = None; + channel.clone() + }) + .collect(); + Ok((old, channels)) + }) + .await?; + for channel in channels { + if let Some(pending) = self.pending.lock().remove(&channel) { + pending.cancellation.cancel(); + } + } + let remote_revoked = if let Some(tokens) = ®istration.tokens { + if tokens.refresh.is_empty() { + true + } else if let Ok(discovery) = self.discovery().await { + let mut success = false; + for retry in 0..2 { + let response = self + .client() + .post(&discovery.revocation_endpoint) + .timeout(Duration::from_secs(10)) + .form(&[ + ("token", tokens.refresh.as_str()), + ("token_type_hint", "refresh_token"), + ("client_id", ®istration.client_id), + ]) + .send() + .await; + match response { + Ok(r) if r.status().as_u16() == 200 => { + success = true; + break; + } + Ok(r) if !r.status().is_server_error() => break, + _ => {} + } + if retry == 0 { + tokio::time::sleep(Duration::from_millis(300)).await; + } + } + success + } else { + false + } + } else { + registration.revocation_confirmed.unwrap_or(false) + }; + // A racing rotation can supersede the revoked token; an HTTP 200 is then insufficient proof. + let remote_revoked = remote_revoked && registration.refreshing.is_none(); + let id = registration.id.clone(); + let signed_out_epoch = registration.epoch + 1; + self.change(move |data| { + if let Some(current) = data + .registrations + .get_mut(&id) + .filter(|r| r.epoch == signed_out_epoch && r.tokens.is_none()) + { + current.revocation_confirmed = Some(remote_revoked); + } + Ok(()) + }) + .await?; + Ok(SiwcDisconnect { remote_revoked }) + } +} +fn selected<'a>(data: &'a Data, channel: &str) -> Result<&'a Registration, SiwcError> { + data.channels + .get(channel) + .and_then(|b| b.registration.as_ref()) + .and_then(|id| data.registrations.get(id)) + .ok_or_else(|| error("sign_in_required")) +} +fn selected_mut<'a>(data: &'a mut Data, channel: &str) -> Result<&'a mut Registration, SiwcError> { + let id = data + .channels + .get(channel) + .and_then(|b| b.registration.clone()) + .ok_or_else(|| error("sign_in_required"))?; + data.registrations + .get_mut(&id) + .ok_or_else(|| error("sign_in_required")) +} +fn terminal_refresh(code: &str) -> bool { + matches!( + code, + "invalid_grant" + | "invalid_refresh_token" + | "token_expired" + | "refresh_token_expired" + | "refresh_token_invalidated" + | "refresh_token_reused" + | "identity_mismatch" + | "id_token_invalid" + ) +} +fn parse_tokens(value: &Value, old: Option<&Tokens>, nonce: &str) -> Result { + let scopes = match value["scope"].as_str() { + Some(s) => s.split_whitespace().map(str::to_owned).collect(), + None => old.map(|t| t.scopes.clone()).unwrap_or_default(), + }; + let tokens = Tokens { + access: value["access_token"].as_str().unwrap_or_default().into(), + refresh: value["refresh_token"] + .as_str() + .map(str::to_owned) + .or_else(|| old.map(|t| t.refresh.clone())) + .unwrap_or_default(), + id: value["id_token"] + .as_str() + .map(str::to_owned) + .or_else(|| old.map(|t| t.id.clone())) + .unwrap_or_default(), + nonce: nonce.into(), + expires_at: now().saturating_add(value["expires_in"].as_u64().unwrap_or(0)), + scopes, + }; + if tokens.plan() + && (tokens.access.is_empty() + || tokens.refresh.is_empty() + || tokens.expires_at <= now() + || !value["token_type"] + .as_str() + .is_some_and(|s| s.eq_ignore_ascii_case("bearer"))) + { + return Err(error("token_response_invalid")); + } + Ok(tokens) +} +async fn json_response( + response: Result, +) -> Result { + let mut response = response.map_err(|_| error("network_failed"))?; + let status = response.status().as_u16(); + let request_id = response + .headers() + .get("x-request-id") + .or_else(|| response.headers().get("openai-request-id")) + .and_then(|h| h.to_str().ok()) + .map(str::to_owned); + let mut bytes = zeroize::Zeroizing::new(Vec::new()); + while let Some(part) = response + .chunk() + .await + .map_err(|_| error("network_failed"))? + { + if bytes.len() + part.len() > 2 * 1024 * 1024 { + return Err(error("response_too_large")); + } + bytes.extend_from_slice(&part); + } + let value: Value = match serde_json::from_slice(&bytes) { + Ok(value) => value, + Err(_) if !(200..300).contains(&status) => Value::Null, + Err(_) => return Err(error("response_invalid")), + }; + if !(200..300).contains(&status) { + let mut failure = response_error(status, &value, request_id.as_deref()); + if let Some(code) = value["error"] + .as_str() + .and_then(|s| safe_identifier(Some(s))) + { + failure.code = code; + } + return Err(failure); + } + Ok(value) +} +async fn wait_cancel(cancellation: &crate::provider_transport::ProviderCancellation) { + while !cancellation.is_cancelled() { + tokio::time::sleep(Duration::from_millis(25)).await; + } +} +async fn callback( + listener: &tokio::net::TcpListener, + expected: &str, +) -> Result<(tokio::net::TcpStream, BTreeMap), SiwcError> { + loop { + let (mut socket, _) = listener + .accept() + .await + .map_err(|_| error("callback_unavailable"))?; + let read = async { + let mut bytes = Vec::new(); + loop { + let mut part = [0u8; 1024]; + let count = socket + .read(&mut part) + .await + .map_err(|_| error("callback_invalid"))?; + if count == 0 || bytes.len() + count > 16 * 1024 { + return Err(error("callback_invalid")); + } + bytes.extend_from_slice(&part[..count]); + if bytes.windows(4).any(|w| w == b"\r\n\r\n") { + break; + } + } + let request = std::str::from_utf8(&bytes).map_err(|_| error("callback_invalid"))?; + let mut line = request + .lines() + .next() + .unwrap_or_default() + .split_whitespace(); + if line.next() != Some("GET") { + return Err(error("callback_invalid")); + } + let target = line.next().ok_or_else(|| error("callback_invalid"))?; + let url = url::Url::parse(&format!("http://127.0.0.1{target}")) + .map_err(|_| error("callback_invalid"))?; + if url.path() != "/auth/callback" { + return Err(error("callback_invalid")); + } + let mut params = BTreeMap::new(); + for (key, value) in url.query_pairs() { + if params + .insert(key.into_owned(), value.into_owned()) + .is_some() + { + return Err(error("callback_invalid")); + } + } + if params.get("state").map(String::as_str) != Some(expected) { + return Err(error("state_mismatch")); + } + Ok(params) + }; + match tokio::time::timeout(Duration::from_secs(2), read).await { + Ok(Ok(params)) => return Ok((socket, params)), + _ => { + let _ = socket.write_all(b"HTTP/1.1 400 Bad Request\r\nConnection: close\r\nContent-Length: 0\r\n\r\n").await; + } + } + } +} + +pub(crate) struct SiwcAccess { + service: Arc, + registration: String, + epoch: u64, +} +impl SiwcAccess { + pub async fn token(&self) -> Result { + self.service.token(&self.registration, self.epoch).await + } + pub async fn check(&self) -> Result<(), SiwcError> { + let data = self.service.load().await?.data; + if data + .registrations + .get(&self.registration) + .is_some_and(|r| r.epoch == self.epoch && r.tokens.is_some()) + { + Ok(()) + } else { + Err(error("session_changed")) + } + } + pub async fn cancelled(&self) -> SiwcError { + loop { + if let Err(error) = self.check().await { + return error; + } + tokio::time::sleep(Duration::from_secs(1)).await; + } + } + pub async fn record_error(&self, failure: SiwcError) -> Result<(), SiwcError> { + let id = self.registration.clone(); + let epoch = self.epoch; + self.service + .change(move |data| { + if let Some(r) = data.registrations.get_mut(&id).filter(|r| r.epoch == epoch) { + r.failure = Some(failure.clone()); + } + Ok(()) + }) + .await + } + pub fn endpoint(&self) -> String { + format!("{}/responses", self.service.endpoints.api) + } +} diff --git a/openless-all/app/crates/openless-core/src/siwc/store.rs b/openless-all/app/crates/openless-core/src/siwc/store.rs new file mode 100644 index 000000000..e1ffe71da --- /dev/null +++ b/openless-all/app/crates/openless-core/src/siwc/store.rs @@ -0,0 +1,157 @@ +//! Local-only, versioned secret storage. Hosts supply atomic head replacement. +use super::{error, SiwcError}; +use crate::SecretValue; +use std::sync::Arc; + +/// Never expose these operations through the general credential/settings IPC. +/// compare_head must compare and replace atomically across host processes. +pub trait SiwcVault: Send + Sync { + fn read(&self, key: &str) -> Result, SiwcError>; + fn write(&self, key: &str, value: &str) -> Result<(), SiwcError>; + fn remove(&self, key: &str) -> Result<(), SiwcError>; + fn compare_head(&self, expected: Option<&str>, next: &str) -> Result; +} + +#[derive(Clone, serde::Serialize, serde::Deserialize)] +struct Head { + generation: String, + chunks: usize, +} + +pub(super) struct Snapshot { + pub head: Option, + pub data: super::Data, +} + +pub(super) struct Store(pub Arc); +impl Store { + pub fn load(&self) -> Result { + for _ in 0..4 { + let head = self.0.read("head")?.map(SecretValue::into_exposed); + let Some(raw) = head.as_deref() else { + return Ok(Snapshot { + head, + data: super::Data::default(), + }); + }; + let manifest: Head = serde_json::from_str(raw).map_err(|_| error("storage_corrupt"))?; + if uuid::Uuid::parse_str(&manifest.generation).is_err() + || manifest.chunks == 0 + || manifest.chunks > 4096 + { + return Err(error("storage_corrupt")); + } + let mut payload = zeroize::Zeroizing::new(String::new()); + let mut missing = false; + for index in 0..manifest.chunks { + match self.0.read(&format!("{}.{}", manifest.generation, index))? { + Some(part) => payload.push_str(part.expose_secret()), + None => { + missing = true; + break; + } + } + } + if self + .0 + .read("head")? + .as_ref() + .map(SecretValue::expose_secret) + != Some(raw) + { + continue; + } + if missing { + return Err(error("storage_corrupt")); + } + let data = serde_json::from_str(&payload).map_err(|_| error("storage_corrupt"))?; + return Ok(Snapshot { head, data }); + } + Err(error("busy")) + } + + pub fn commit(&self, previous: &Snapshot, data: &super::Data) -> Result { + let payload = zeroize::Zeroizing::new( + serde_json::to_string(data).map_err(|_| error("storage_failed"))?, + ); + if payload.len() > 3_600_000 { + return Err(error("storage_full")); + } + let generation = uuid::Uuid::new_v4().to_string(); + // Asset Store caps a secret at 1024 bytes; Windows also counts UTF-16 units. + let mut chunks = Vec::new(); + let mut start = 0; + while start < payload.len() { + let mut end = (start + 900).min(payload.len()); + while !payload.is_char_boundary(end) { + end -= 1; + } + chunks.push(&payload[start..end]); + start = end; + } + let head = serde_json::to_string(&Head { + generation: generation.clone(), + chunks: chunks.len(), + }) + .map_err(|_| error("storage_failed"))?; + let prepare = (|| { + for (index, part) in chunks.iter().enumerate() { + let key = format!("{generation}.{index}"); + self.0.write(&key, part)?; + if self.0.read(&key)?.as_ref().map(SecretValue::expose_secret) != Some(*part) { + return Err(error("storage_failed")); + } + } + self.0.compare_head(previous.head.as_deref(), &head) + })(); + // A native error can occur after publication. Read back before deleting anything. + let committed = match prepare { + Ok(value) => value, + Err(failure) => match self.0.read("head") { + Ok(Some(current)) if current.expose_secret() == head => true, + Ok(_) => { + for index in 0..chunks.len() { + let _ = self.0.remove(&format!("{generation}.{index}")); + } + return Err(failure); + } + Err(_) => return Err(failure), + }, + }; + if committed { + if let Some(old) = previous + .head + .as_deref() + .and_then(|s| serde_json::from_str::(s).ok()) + { + for index in 0..old.chunks { + let _ = self.0.remove(&format!("{}.{}", old.generation, index)); + } + } + } else { + for index in 0..chunks.len() { + let _ = self.0.remove(&format!("{generation}.{index}")); + } + } + Ok(committed) + } +} + +/// Used by file-backed native hosts. The lock contains no credentials. +pub fn locked_head( + path: &std::path::Path, + action: impl FnOnce() -> Result, +) -> Result { + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent).map_err(|_| error("storage_failed"))?; + } + let file = std::fs::OpenOptions::new() + .read(true) + .write(true) + .create(true) + .truncate(false) + .open(path) + .map_err(|_| error("storage_failed"))?; + file.lock().map_err(|_| error("storage_failed"))?; + action() +} diff --git a/openless-all/app/crates/openless-core/src/siwc/tests.rs b/openless-all/app/crates/openless-core/src/siwc/tests.rs new file mode 100644 index 000000000..c5893b0c3 --- /dev/null +++ b/openless-all/app/crates/openless-core/src/siwc/tests.rs @@ -0,0 +1,1168 @@ +use super::*; +use std::sync::Mutex; +#[derive(Default)] +struct MemoryVault(Mutex>); +impl SiwcVault for MemoryVault { + fn read(&self, key: &str) -> Result, SiwcError> { + Ok(self + .0 + .lock() + .unwrap() + .get(key) + .cloned() + .map(SecretValue::new)) + } + fn write(&self, key: &str, value: &str) -> Result<(), SiwcError> { + assert!(value.len() <= 1024); + self.0.lock().unwrap().insert(key.into(), value.into()); + Ok(()) + } + fn remove(&self, key: &str) -> Result<(), SiwcError> { + self.0.lock().unwrap().remove(key); + Ok(()) + } + fn compare_head(&self, expected: Option<&str>, next: &str) -> Result { + let mut values = self.0.lock().unwrap(); + if values.get("head").map(String::as_str) != expected { + return Ok(false); + } + values.insert("head".into(), next.into()); + Ok(true) + } +} +fn registration(id: &str) -> Registration { + Registration { + id: id.into(), + issuer: ISSUER.into(), + subject: "subject".into(), + client_id: format!("oaiapp_{id}"), + email: Some("same@example.test".into()), + epoch: 1, + tokens: Some(Tokens { + access: "access-secret".repeat(900), + refresh: "refresh-secret".into(), + id: "id-secret".into(), + nonce: "nonce".into(), + expires_at: now() + 3600, + scopes: vec![DIRECT.into(), "resource.invoke".into()], + }), + refreshing: None, + failure: None, + welcomed: false, + revocation_confirmed: None, + } +} +async fn connected(vault: Arc) -> Arc { + let service = SiwcService::new(vault); + service + .change(|data| { + data.registrations + .insert("account".into(), registration("account")); + data.channels.insert( + "channel".into(), + Binding { + registration: Some("account".into()), + attempt: None, + }, + ); + Ok(()) + }) + .await + .unwrap(); + service +} +#[tokio::test] +async fn siwc_vault_chunks_long_tokens_and_never_serializes_secrets_to_status() { + let vault = Arc::new(MemoryVault::default()); + let service = connected(vault.clone()).await; + let host = service.load().await.unwrap().data.host_id; + let status = serde_json::to_string(&service.status("channel").await.unwrap()).unwrap(); + for secret in ["access-secret", "refresh-secret", "id-secret", &host] { + assert!(!status.contains(secret)); + } + assert_eq!( + SiwcService::new(vault).load().await.unwrap().data.host_id, + host + ); + assert_eq!(service.status("channel").await.unwrap().state, "connected"); +} +#[tokio::test] +async fn siwc_registration_identity_is_not_email_and_account_switch_preserves_other_session() { + let service = connected(Arc::new(MemoryVault::default())).await; + service + .change(|data| { + data.registrations + .insert("other".into(), registration("other")); + Ok(()) + }) + .await + .unwrap(); + let old = service.access("channel").await.unwrap(); + service + .select_account("channel".into(), "other".into()) + .await + .unwrap(); + assert_eq!(service.status("channel").await.unwrap().accounts.len(), 2); + old.check().await.unwrap(); + assert_eq!( + service.status("channel").await.unwrap().account.unwrap().id, + "other" + ); +} +#[tokio::test] +async fn siwc_vault_cas_does_not_overwrite_concurrent_writes() { + let vault = Arc::new(MemoryVault::default()); + let store = store::Store(vault); + let first = store.load().unwrap(); + let second = store.load().unwrap(); + assert!(store.commit(&first, &first.data).unwrap()); + assert!(!store.commit(&second, &second.data).unwrap()); + assert_eq!(store.load().unwrap().data.host_id, first.data.host_id); +} +#[tokio::test] +async fn siwc_rotated_or_revoked_session_invalidates_inflight_access() { + let service = connected(Arc::new(MemoryVault::default())).await; + let access = service.access("channel").await.unwrap(); + access.check().await.unwrap(); + service + .change(|data| { + let r = data.registrations.get_mut("account").unwrap(); + r.epoch += 1; + r.tokens = None; + Ok(()) + }) + .await + .unwrap(); + assert_eq!(access.check().await.unwrap_err().code, "session_changed"); + assert_eq!( + service.status("channel").await.unwrap().state, + "reauthorization_required" + ); + assert!(service.has_binding("channel").await.unwrap()); +} +#[tokio::test] +async fn siwc_missing_scope_keeps_identity_without_allowing_inference() { + let service = connected(Arc::new(MemoryVault::default())).await; + service + .change(|data| { + data.registrations + .get_mut("account") + .unwrap() + .tokens + .as_mut() + .unwrap() + .scopes + .clear(); + Ok(()) + }) + .await + .unwrap(); + assert_eq!( + service.status("channel").await.unwrap().state, + "usage_disabled" + ); + assert_eq!( + service + .access("channel") + .await + .unwrap() + .token() + .await + .unwrap_err() + .code, + "usage_disabled" + ); +} +#[test] +fn siwc_request_whitelist_and_terminal_events() { + use crate::llm_protocol::{LlmRequestFormat, TextEventStream}; + let body = inference::request_body( + "available-model", + vec![ + json!({"role":"system","content":"polish"}), + json!({"role":"user","content":"hello"}), + ], + ); + assert_eq!(body["stream"], true); + assert_eq!(body["store"], false); + assert_eq!(body["input"][0]["role"], "developer"); + assert_eq!(body.as_object().unwrap().len(), 4); + for terminal in ["response.failed", "response.incomplete"] { + let mut stream = + TextEventStream::new(LlmRequestFormat::Responses).for_siwc(Some("req_123".into())); + stream.push(format!("data: {{\"type\":\"{terminal}\",\"response\":{{\"error\":{{\"code\":\"subscription_sharing_usage_limit_exceeded\"}}}}}}\n\n").as_bytes()).unwrap(); + assert!(stream.next().is_err()); + } + let mut stream = TextEventStream::new(LlmRequestFormat::Responses); + stream + .push(b"data: {\"type\":\"response.output_text.delta\",\"delta\":\"partial\"}\r\n\r\n") + .unwrap(); + stream.next().unwrap(); + assert!(stream.finish().is_err()); +} + +#[derive(Default)] +struct HttpState { + nonce: String, + subject: String, + scope: String, + refreshes: usize, + revocations: usize, + forms: Vec>, + refresh_error: Option, + authorization_error: Option, + revoke_status: u16, + refresh_delay: u64, + refresh_gate: Option>, + inference_error: bool, + incomplete: bool, + requests: Vec, +} +struct Fixture { + service: Arc, + vault: Arc, + state: Arc>, + server: tokio::task::JoinHandle<()>, +} +impl Drop for Fixture { + fn drop(&mut self) { + self.server.abort(); + } +} +fn signing_key() -> &'static (jsonwebtoken::EncodingKey, Value) { + static KEY: std::sync::OnceLock<(jsonwebtoken::EncodingKey, Value)> = + std::sync::OnceLock::new(); + KEY.get_or_init(|| { + use rsa::{pkcs1::EncodeRsaPrivateKey, traits::PublicKeyParts}; + let key = rsa::RsaPrivateKey::new(&mut rand::rngs::OsRng, 2048).unwrap(); + let public = key.to_public_key(); + let jwk = json!({"kty":"RSA","kid":"test-key","alg":"RS256","use":"sig","n":URL_SAFE_NO_PAD.encode(public.n().to_bytes_be()),"e":URL_SAFE_NO_PAD.encode(public.e().to_bytes_be())}); + (jsonwebtoken::EncodingKey::from_rsa_der(key.to_pkcs1_der().unwrap().as_bytes()), jwk) + }) +} +fn signed(claims: &Value) -> String { + let mut header = jsonwebtoken::Header::new(jsonwebtoken::Algorithm::RS256); + header.kid = Some("test-key".into()); + jsonwebtoken::encode(&header, claims, &signing_key().0).unwrap() +} +impl Fixture { + async fn new() -> Self { + let _ = signing_key(); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let base = format!("http://{}", listener.local_addr().unwrap()); + let vault = Arc::new(MemoryVault::default()); + let mut service = SiwcService::new(vault.clone()); + Arc::get_mut(&mut service).unwrap().endpoints = Endpoints { + issuer: base.clone(), + discovery: format!("{base}/discovery"), + api: format!("{base}/v1"), + }; + let state = Arc::new(Mutex::new(HttpState { + subject: "subject".into(), + scope: SCOPES.into(), + ..Default::default() + })); + let server_state = state.clone(); + let server = tokio::spawn(async move { + while let Ok((mut socket, _)) = listener.accept().await { + let base = base.clone(); + let state = server_state.clone(); + tokio::spawn(async move { + let mut data = Vec::new(); + let mut part = [0u8; 4096]; + let end = loop { + let count = socket.read(&mut part).await.unwrap(); + if count == 0 { + return; + } + data.extend_from_slice(&part[..count]); + if let Some(end) = data.windows(4).position(|p| p == b"\r\n\r\n") { + break end + 4; + } + }; + let headers = String::from_utf8_lossy(&data[..end]).to_string(); + let length: usize = headers + .lines() + .find_map(|line| { + line.to_lowercase() + .strip_prefix("content-length:") + .map(|v| v.trim().parse().unwrap()) + }) + .unwrap_or(0); + while data.len() < end + length { + let count = socket.read(&mut part).await.unwrap(); + if count == 0 { + return; + } + data.extend_from_slice(&part[..count]); + } + let route = headers.split_whitespace().nth(1).unwrap(); + let mut status = 200; + let mut mime = "application/json"; + let mut delay = 0; + let mut refresh_gate = None; + if route.starts_with("/v1/") { + assert!(headers.to_ascii_lowercase().contains("authorization: bearer access-")); + } + let body = match route { + "/discovery" => json!({"issuer":base,"authorization_endpoint":format!("{base}/authorize"),"token_endpoint":format!("{base}/token"),"jwks_uri":format!("{base}/jwks"),"revocation_endpoint":format!("{base}/revoke")}).to_string(), + "/jwks" => json!({"keys":[signing_key().1.clone()]}).to_string(), + "/token" => { + let form: BTreeMap = url::form_urlencoded::parse(&data[end..end+length]).into_owned().collect(); + let mut state = state.lock().unwrap(); state.forms.push(form.clone()); + assert_eq!(form["client_id"], "oaiapp_fixture_0001"); assert_eq!(form["resource"], API_BASE); + let refresh = form["grant_type"] == "refresh_token"; + if refresh { state.refreshes += 1; delay = state.refresh_delay; refresh_gate = state.refresh_gate.clone(); assert!(!form.contains_key("scope")); } + if !refresh && state.authorization_error.is_some() { + status = 400; json!({"error":state.authorization_error}).to_string() + } else if refresh && state.refresh_error.is_some() { + status = 400; json!({"error":state.refresh_error}).to_string() + } else { + let claims = json!({"iss":base,"sub":state.subject,"aud":"oaiapp_fixture_0001","iat":now(),"exp":now()+3600,"nonce":state.nonce,"email":"same@example.test"}); + json!({"access_token":format!("access-{}",state.refreshes),"refresh_token":format!("refresh-{}",state.refreshes),"id_token":signed(&claims),"expires_in":3600,"token_type":"Bearer","scope":state.scope}).to_string() + } + } + "/revoke" => { let mut state = state.lock().unwrap(); state.revocations += 1; if state.revoke_status > 0 { status = state.revoke_status; } String::new() } + "/v1/models" => json!({"models":[{"slug":"z","display_name":"Preferred model","visibility":"list"},{"slug":"hidden","visibility":"hide"},{"slug":"a","display_name":"Another model","visibility":"list"}]}).to_string(), + "/v1/responses" => { + mime = "text/event-stream"; + let request: Value = serde_json::from_slice(&data[end..end+length]).unwrap(); + let mut state = state.lock().unwrap(); state.requests.push(request); + let terminal = if state.inference_error { "data: {\"type\":\"response.failed\",\"response\":{\"error\":{\"code\":\"subscription_sharing_usage_limit_exceeded\",\"param\":\"model\"}}}\r\n\r\n" } + else if state.incomplete { "" } else { "data: {\"type\":\"response.completed\",\"response\":{\"status\":\"completed\"}}\r\n\r\n" }; + format!("data: {{\"type\":\"response.output_text.delta\",\"delta\":\"你好🙂\"}}\r\n\r\n{terminal}") + } + _ => { status = 404; "{}".into() } + }; + if let Some(gate) = refresh_gate { + gate.notified().await; + } + if delay > 0 { + tokio::time::sleep(Duration::from_millis(delay)).await; + } + let reply = format!("HTTP/1.1 {status} OK\r\nContent-Type: {mime}\r\nContent-Length: {}\r\nX-Request-Id: req_fixture\r\nConnection: close\r\n\r\n{body}", body.len()); + let _ = socket.write_all(reply.as_bytes()).await; + }); + } + }); + Self { + service, + vault, + state, + server, + } + } + async fn authorize(&self) -> url::Url { + let launch = self + .service + .begin_sign_in("channel".into(), false, false) + .await + .unwrap(); + let url = url::Url::parse(launch.authorization_url()).unwrap(); + let params: BTreeMap<_, _> = url.query_pairs().into_owned().collect(); + self.state.lock().unwrap().nonce = params["nonce"].clone(); + let mut callback = url::Url::parse(¶ms["redirect_uri"]).unwrap(); + callback + .query_pairs_mut() + .append_pair("state", ¶ms["state"]) + .append_pair("code", "fixture-code") + .append_pair("client_id", "oaiapp_fixture_0001"); + reqwest::Client::builder() + .no_proxy() + .build() + .unwrap() + .get(callback) + .send() + .await + .unwrap() + .text() + .await + .unwrap(); + for _ in 0..100 { + if !self.service.authorizing("channel", &launch.attempt_id) { + return url; + } + tokio::time::sleep(Duration::from_millis(5)).await; + } + panic!("authorization did not finish") + } + async fn expire(&self) { + self.service + .change(|data| { + selected_mut(data, "channel")? + .tokens + .as_mut() + .unwrap() + .expires_at = now(); + Ok(()) + }) + .await + .unwrap(); + } +} +#[tokio::test] +async fn siwc_oauth_real_loopback_pkce_jwks_and_returning_registration() { + let f = Fixture::new().await; + let first = f.authorize().await; + assert_eq!( + f.service.status("channel").await.unwrap().state, + "connected" + ); + let params: BTreeMap<_, _> = first.query_pairs().into_owned().collect(); + assert_eq!(params["client_id"], "dynamic_agent_client"); + assert_eq!(params["agent_name_hint"], "OpenLess"); + assert!(params["redirect_uri"].starts_with("http://127.0.0.1:")); + assert!(params["redirect_uri"].ends_with("/auth/callback")); + let form = f.state.lock().unwrap().forms[0].clone(); + assert_eq!( + params["code_challenge"], + URL_SAFE_NO_PAD.encode(Sha256::digest(form["code_verifier"].as_bytes())) + ); + assert_eq!(params["redirect_uri"], form["redirect_uri"]); + let second = f.authorize().await; + let returning: BTreeMap<_, _> = second.query_pairs().into_owned().collect(); + assert_eq!(returning["client_id"], "oaiapp_fixture_0001"); + assert!(!returning.contains_key("agent_name_hint")); + assert_eq!(params["ext_agent_host_id"], returning["ext_agent_host_id"]); + assert_ne!(params["nonce"], returning["nonce"]); + assert!(returning.contains_key("id_token_hint")); + assert_eq!(f.service.status("channel").await.unwrap().accounts.len(), 1); +} +#[tokio::test] +async fn siwc_jwt_signature_issuer_audience_nonce_and_expiry_are_verified() { + let f = Fixture::new().await; + let discovery = f.service.discovery().await.unwrap(); + let valid = json!({"iss":f.service.endpoints.issuer,"sub":"subject","aud":"oaiapp_fixture_0001","iat":now(),"exp":now()+3600,"nonce":"correct"}); + f.service + .verify_identity( + &signed(&valid), + "oaiapp_fixture_0001", + Some("correct"), + &discovery, + ) + .await + .unwrap(); + for (key, value) in [ + ("iss", json!("https://attacker.test")), + ("aud", json!("other-client")), + ("nonce", json!("wrong")), + ("exp", json!(1)), + ("sub", json!("")), + ] { + let mut invalid = valid.clone(); + invalid[key] = value; + assert!( + f.service + .verify_identity( + &signed(&invalid), + "oaiapp_fixture_0001", + Some("correct"), + &discovery + ) + .await + .is_err(), + "{key}" + ); + } + let mut forged = signed(&valid); + let last = forged.len() - 20; + forged.replace_range(last..last + 1, "!"); + assert!(f + .service + .verify_identity(&forged, "oaiapp_fixture_0001", Some("correct"), &discovery) + .await + .is_err()); +} +#[tokio::test] +async fn siwc_refresh_is_serial_across_service_instances_and_rotates_atomically() { + let f = Fixture::new().await; + f.authorize().await; + f.expire().await; + f.state.lock().unwrap().refresh_delay = 100; + let mut second = SiwcService::new(f.vault.clone()); + Arc::get_mut(&mut second).unwrap().endpoints = f.service.endpoints.clone(); + let first_access = f.service.access("channel").await.unwrap(); + let second_access = second.access("channel").await.unwrap(); + let (a, b) = tokio::join!(first_access.token(), second_access.token()); + assert_eq!(a.unwrap().expose_secret(), "access-1"); + assert_eq!(b.unwrap().expose_secret(), "access-1"); + assert_eq!(f.state.lock().unwrap().refreshes, 1); + let data = f.service.load().await.unwrap().data; + let tokens = selected(&data, "channel").unwrap().tokens.as_ref().unwrap(); + assert_eq!(tokens.refresh, "refresh-1"); + assert!(tokens.expires_at > now() + 60); +} +#[tokio::test] +async fn siwc_terminal_refresh_keeps_registration_and_temporary_errors_keep_tokens() { + for code in ["invalid_grant", "temporarily_unavailable"] { + let f = Fixture::new().await; + f.authorize().await; + f.expire().await; + f.state.lock().unwrap().refresh_error = Some(code.into()); + let failure = f + .service + .access("channel") + .await + .unwrap() + .token() + .await + .unwrap_err(); + assert_eq!(failure.code, code); + let data = f.service.load().await.unwrap().data; + let r = selected(&data, "channel").unwrap(); + assert_eq!(r.client_id, "oaiapp_fixture_0001"); + assert_eq!(r.tokens.is_some(), code != "invalid_grant"); + } +} +#[tokio::test] +async fn siwc_wrong_identity_never_overwrites_an_existing_registration() { + let f = Fixture::new().await; + f.authorize().await; + f.state.lock().unwrap().subject = "another-subject".into(); + f.authorize().await; + let status = f.service.status("channel").await.unwrap(); + assert_eq!(status.error.unwrap().code, "identity_mismatch"); + let data = f.service.load().await.unwrap().data; + assert_eq!(selected(&data, "channel").unwrap().subject, "subject"); +} +#[tokio::test] +async fn siwc_cancelled_callback_cannot_reconnect_and_signout_reuses_registration() { + let f = Fixture::new().await; + f.authorize().await; + let lease = f.service.access("channel").await.unwrap(); + let launch = f + .service + .begin_sign_in("channel".into(), false, false) + .await + .unwrap(); + f.service.cancel_sign_in("channel").await.unwrap(); + assert!(!f.service.authorizing("channel", &launch.attempt_id)); + assert!( + f.service + .disconnect("channel") + .await + .unwrap() + .remote_revoked + ); + assert_eq!(f.state.lock().unwrap().revocations, 1); + assert!(lease.check().await.is_err()); + let launch = f + .service + .begin_sign_in("channel".into(), false, false) + .await + .unwrap(); + let params: BTreeMap<_, _> = url::Url::parse(launch.authorization_url()) + .unwrap() + .query_pairs() + .into_owned() + .collect(); + assert_eq!(params["client_id"], "oaiapp_fixture_0001"); + assert!(!params.contains_key("id_token_hint")); + f.service.cancel_sign_in("channel").await.unwrap(); +} +#[tokio::test] +async fn siwc_catalog_and_full_sse_lifecycle_use_the_account_token() { + use crate::polish::StreamingTimeouts; + let f = Fixture::new().await; + f.authorize().await; + let models = f.service.list_models("channel").await.unwrap(); + assert_eq!( + models.iter().map(|m| m.slug.as_str()).collect::>(), + vec!["z", "a"] + ); + assert_eq!(models[0].display_name, "Preferred model"); + let access = f.service.access("channel").await.unwrap(); + let body = inference::request_body( + "z", + vec![ + json!({"role":"system","content":"polish"}), + json!({"role":"user","content":"text"}), + ], + ); + let timeouts = StreamingTimeouts { + first_token: Duration::from_secs(3), + idle: Duration::from_secs(3), + }; + assert_eq!( + access + .infer(&body, timeouts, |_| {}, || false) + .await + .unwrap(), + "你好🙂" + ); + f.state.lock().unwrap().incomplete = true; + let incomplete = access + .infer(&body, timeouts, |_| {}, || false) + .await + .unwrap_err(); + match incomplete { + crate::polish::LLMError::Siwc(error) => { + assert_eq!(error.code, "response_incomplete"); + assert_eq!(error.http_status, Some(200)); + assert_eq!(error.request_id.as_deref(), Some("req_fixture")); + } + other => panic!("{other}"), + } + f.state.lock().unwrap().inference_error = true; + let failure = access + .infer(&body, timeouts, |_| {}, || false) + .await + .unwrap_err(); + match failure { + crate::polish::LLMError::Siwc(e) => { + assert_eq!(e.code, "subscription_sharing_usage_limit_exceeded"); + assert_eq!(e.request_id.as_deref(), Some("req_fixture")); + } + other => panic!("{other}"), + } + assert_eq!( + f.service.status("channel").await.unwrap().state, + "usage_limited" + ); + let before = f.state.lock().unwrap().requests.len(); + assert!(access + .infer(&body, timeouts, |_| {}, || false) + .await + .is_err()); + assert_eq!(f.state.lock().unwrap().requests.len(), before); +} + +#[tokio::test] +async fn siwc_cancelled_inference_does_not_abandon_refresh_rotation() { + let f = Fixture::new().await; + f.authorize().await; + f.expire().await; + let gate = Arc::new(tokio::sync::Notify::new()); + f.state.lock().unwrap().refresh_gate = Some(gate.clone()); + let access = f.service.access("channel").await.unwrap(); + let task = tokio::spawn(async move { access.token().await }); + while f.state.lock().unwrap().refreshes == 0 { + tokio::time::sleep(Duration::from_millis(5)).await; + } + task.abort(); + let _ = task.await; + gate.notify_one(); + let token = f + .service + .access("channel") + .await + .unwrap() + .token() + .await + .unwrap(); + assert_eq!(token.expose_secret(), "access-1"); + assert_eq!(f.state.lock().unwrap().refreshes, 1); +} +#[tokio::test] +async fn siwc_logout_wins_over_a_late_refresh_and_remote_failure_is_explicit() { + let f = Fixture::new().await; + f.authorize().await; + f.expire().await; + let gate = Arc::new(tokio::sync::Notify::new()); + { + let mut s = f.state.lock().unwrap(); + s.refresh_gate = Some(gate.clone()); + s.revoke_status = 503; + } + let access = f.service.access("channel").await.unwrap(); + let task = tokio::spawn(async move { access.token().await }); + while f.state.lock().unwrap().refreshes == 0 { + tokio::time::sleep(Duration::from_millis(5)).await; + } + assert!( + !f.service + .disconnect("channel") + .await + .unwrap() + .remote_revoked + ); + gate.notify_one(); + assert!(task.await.unwrap().is_err()); + assert_eq!( + f.service.status("channel").await.unwrap().state, + "reauthorization_required" + ); + assert_eq!(f.state.lock().unwrap().revocations, 2); +} +struct FailingCommit { + inner: Arc, + heads: std::sync::atomic::AtomicUsize, +} +impl SiwcVault for FailingCommit { + fn read(&self, key: &str) -> Result, SiwcError> { + self.inner.read(key) + } + fn write(&self, key: &str, value: &str) -> Result<(), SiwcError> { + self.inner.write(key, value) + } + fn remove(&self, key: &str) -> Result<(), SiwcError> { + self.inner.remove(key) + } + fn compare_head(&self, expected: Option<&str>, next: &str) -> Result { + if self.heads.fetch_add(1, std::sync::atomic::Ordering::SeqCst) == 1 { + return Err(error("storage_failed")); + } + self.inner.compare_head(expected, next) + } +} +#[tokio::test] +async fn siwc_failed_rotated_token_commit_never_replays_the_old_refresh_token() { + let f = Fixture::new().await; + f.authorize().await; + f.expire().await; + let mut service = SiwcService::new(Arc::new(FailingCommit { + inner: f.vault.clone(), + heads: Default::default(), + })); + Arc::get_mut(&mut service).unwrap().endpoints = f.service.endpoints.clone(); + let access = service.access("channel").await.unwrap(); + assert_eq!(access.token().await.unwrap_err().code, "storage_failed"); + assert_eq!(f.state.lock().unwrap().refreshes, 1); + assert!( + tokio::time::timeout(Duration::from_millis(30), access.token()) + .await + .is_err() + ); + f.service + .change(|data| { + selected_mut(data, "channel")? + .refreshing + .as_mut() + .unwrap() + .1 = now() - 121; + Ok(()) + }) + .await + .unwrap(); + assert_eq!( + access.token().await.unwrap_err().code, + "refresh_interrupted" + ); + assert_eq!(f.state.lock().unwrap().refreshes, 1); +} +#[tokio::test] +async fn siwc_invalid_code_retains_issued_client_without_trusting_identity() { + let f = Fixture::new().await; + f.state.lock().unwrap().authorization_error = Some("invalid_grant".into()); + f.authorize().await; + assert!(f.service.status("channel").await.unwrap().account.is_none()); + f.state.lock().unwrap().authorization_error = None; + let returning = f.authorize().await; + let params: BTreeMap<_, _> = returning.query_pairs().into_owned().collect(); + assert_eq!(params["client_id"], "oaiapp_fixture_0001"); + assert!(!params.contains_key("agent_name_hint")); + assert_eq!( + f.service.status("channel").await.unwrap().state, + "connected" + ); +} +#[tokio::test] +async fn siwc_identity_only_authorization_does_not_run_models_or_inference() { + let f = Fixture::new().await; + f.state.lock().unwrap().scope = "openid profile email".into(); + f.authorize().await; + let status = f.service.status("channel").await.unwrap(); + assert!(status.account.is_some()); + assert_eq!(status.state, "usage_disabled"); + assert_eq!( + f.service.list_models("channel").await.unwrap_err().code, + "usage_disabled" + ); + assert!(f.state.lock().unwrap().requests.is_empty()); +} + +#[tokio::test] +async fn siwc_start_does_not_block_on_discovery_and_cancel_is_immediate() { + let mut f = Fixture::new().await; + Arc::get_mut(&mut f.service).unwrap().endpoints.discovery = + "http://127.0.0.1:1/unreachable".into(); + let launch = f + .service + .begin_sign_in("channel".into(), false, false) + .await + .unwrap(); + assert!(f.service.authorizing("channel", &launch.attempt_id)); + f.service.cancel_sign_in("channel").await.unwrap(); + assert!(!f.service.authorizing("channel", &launch.attempt_id)); + assert!(f.state.lock().unwrap().forms.is_empty()); +} +#[tokio::test] +async fn siwc_state_and_declined_consent_never_exchange_a_code() { + let f = Fixture::new().await; + let launch = f + .service + .begin_sign_in("channel".into(), false, false) + .await + .unwrap(); + let params: BTreeMap<_, _> = url::Url::parse(launch.authorization_url()) + .unwrap() + .query_pairs() + .into_owned() + .collect(); + let mut callback = url::Url::parse(¶ms["redirect_uri"]).unwrap(); + callback + .query_pairs_mut() + .append_pair("state", "incorrect") + .append_pair("code", "untrusted"); + let client = reqwest::Client::builder().no_proxy().build().unwrap(); + assert_eq!( + client + .get(callback.clone()) + .send() + .await + .unwrap() + .status() + .as_u16(), + 400 + ); + assert!(f.state.lock().unwrap().forms.is_empty()); + callback.set_query(None); + callback + .query_pairs_mut() + .append_pair("state", ¶ms["state"]) + .append_pair("error", "access_denied"); + client + .get(callback) + .send() + .await + .unwrap() + .text() + .await + .unwrap(); + for _ in 0..100 { + if !f.service.authorizing("channel", &launch.attempt_id) { + break; + } + tokio::time::sleep(Duration::from_millis(5)).await; + } + assert_eq!( + f.service + .status("channel") + .await + .unwrap() + .error + .unwrap() + .code, + "access_denied" + ); + assert!(f.state.lock().unwrap().forms.is_empty()); +} + +struct ChannelCredentials(Arc); +impl crate::CredentialStore for ChannelCredentials { + fn siwc(&self) -> Option> { + Some(self.0.clone()) + } + fn status( + &self, + _: crate::UserPreferences, + ) -> futures_util::future::BoxFuture<'static, Result> + { + Box::pin(async { + Ok(crate::CredentialsStatus { + active_llm_provider: PROVIDER_ID.into(), + ..Default::default() + }) + }) + } + fn read( + &self, + key: crate::CredentialKey, + ) -> futures_util::future::BoxFuture<'static, Result, BackendError>> { + Box::pin(async move { + Ok(match key.account.as_str() { + crate::credentials::LLM_MODEL_ACCOUNT => Some(SecretValue::new("z")), + crate::credentials::LLM_API_KEY_ACCOUNT => { + Some(SecretValue::new("legacy-secret-must-not-be-sent")) + } + crate::credentials::LLM_ENDPOINT_ACCOUNT => { + Some(SecretValue::new("https://invalid.example/never-use-this")) + } + crate::credentials::LLM_EXTRA_HEADERS_ACCOUNT => Some(SecretValue::new("not-json")), + _ => None, + }) + }) + } + fn write( + &self, + _: crate::CredentialKey, + _: SecretValue, + ) -> futures_util::future::BoxFuture<'static, Result<(), BackendError>> { + Box::pin(async { Ok(()) }) + } + fn remove( + &self, + _: crate::CredentialKey, + ) -> futures_util::future::BoxFuture<'static, Result<(), BackendError>> { + Box::pin(async { Ok(()) }) + } + fn list_channels( + &self, + _: crate::ChannelKind, + ) -> futures_util::future::BoxFuture<'static, Result, BackendError>> + { + Box::pin(async { + Ok(vec![crate::ChannelSummary { + id: "channel".into(), + name: "ChatGPT Plan".into(), + provider_type: PROVIDER_ID.into(), + enabled: true, + order: 0, + last_test: None, + }]) + }) + } + fn active_provider( + &self, + _: crate::ProviderSlot, + ) -> futures_util::future::BoxFuture<'static, Result> { + Box::pin(async { Ok("channel".into()) }) + } +} +#[derive(Default)] +struct TextSink(Mutex); +impl crate::TextStreamSink for TextSink { + fn publish(&self, chunk: crate::TextStreamChunk) -> Result<(), BackendError> { + self.0.lock().unwrap().push_str(&chunk.text); + Ok(()) + } +} +#[tokio::test] +async fn siwc_shared_polish_translation_and_provider_validation_ignore_legacy_api_credentials() { + use crate::{ProviderApi, TextPolisher}; + let f = Fixture::new().await; + f.authorize().await; + let credentials: Arc = + Arc::new(ChannelCredentials(f.service.clone())); + let provider = + crate::ProviderService::new(credentials.clone(), Arc::new(crate::TokioTaskSpawner)); + let request = crate::ProviderRequest { + kind: crate::ProviderKind::Llm, + channel_id: Some("channel".into()), + thinking_enabled: false, + }; + assert_eq!( + provider.list_models(request.clone()).await.unwrap().models, + vec!["z", "a"] + ); + provider.validate(request.clone()).await.unwrap(); + let polisher = crate::SharedCloudTextPolisher::new(credentials); + let mut context = crate::dictation_context::DictationContext::default(); + context.llm.provider_id = "channel".into(); + context.llm.provider_type = PROVIDER_ID.into(); + context.llm.model = Some("z".into()); + context.polish.style_system_prompt = "style-fixture".into(); + context.polish.hotwords = vec!["term-fixture".into()]; + let sink = Arc::new(TextSink::default()); + let output = polisher + .polish( + crate::SessionId::new(), + Arc::new(context.clone()), + "source text".into(), + sink.clone(), + ) + .await + .unwrap(); + assert_eq!(output.text, "你好🙂"); + assert_eq!(*sink.0.lock().unwrap(), "你好🙂"); + context.polish.translation_active = true; + context.polish.translation_target_language = "English".into(); + assert_eq!( + polisher + .polish( + crate::SessionId::new(), + Arc::new(context), + "source text".into(), + Arc::new(TextSink::default()) + ) + .await + .unwrap() + .text, + "你好🙂" + ); + let requests = f.state.lock().unwrap().requests.clone(); + assert!(requests + .iter() + .any(|body| body.to_string().contains("style-fixture"))); + for body in &requests { + assert_eq!(body["stream"], true); + assert_eq!(body["store"], false); + assert_eq!(body.as_object().unwrap().len(), 4); + assert_eq!(body["input"][0]["role"], "developer"); + } + f.state.lock().unwrap().inference_error = true; + let error = provider.validate(request).await.unwrap_err(); + assert_eq!( + error.details.unwrap()["siwc"]["code"], + "subscription_sharing_usage_limit_exceeded" + ); + assert!(error.message.contains("ChatGPT plan")); +} +#[tokio::test] +async fn siwc_nonstreaming_callers_still_consume_responses_sse() { + let f = Fixture::new().await; + f.authorize().await; + let config = + crate::polish::OpenAICompatibleConfig::new(PROVIDER_ID, "ChatGPT Plan", API_BASE, "", "z"); + let provider = crate::polish::OpenAICompatibleLLMProvider::new(config) + .with_siwc_access(f.service.access("channel").await.unwrap()); + assert_eq!( + provider + .translate_to( + "text", + "English", + &[], + Default::default(), + Default::default(), + None + ) + .await + .unwrap(), + "你好🙂" + ); + assert!(f + .state + .lock() + .unwrap() + .requests + .iter() + .all(|body| body["stream"] == true)); +} +#[test] +fn siwc_structured_diagnostics_never_copy_an_error_body_or_token_to_the_ui() { + let body = json!({"error":{"code":"subscription_sharing_usage_limit_exceeded","param":"model","message":"access-secret refresh-secret user transcript"}}); + let error: BackendError = response_error(429, &body, Some("req_safe")).into_backend(); + let wire = serde_json::to_string(&error).unwrap(); + assert!(!wire.contains("access-secret")); + assert!(!wire.contains("refresh-secret")); + assert!(!wire.contains("user transcript")); + assert_eq!(error.details.unwrap()["siwc"]["requestId"], "req_safe"); +} + +#[tokio::test] +async fn siwc_logout_during_rotation_never_claims_remote_revocation_was_confirmed() { + let f = Fixture::new().await; + f.authorize().await; + f.expire().await; + let gate = Arc::new(tokio::sync::Notify::new()); + f.state.lock().unwrap().refresh_gate = Some(gate.clone()); + let access = f.service.access("channel").await.unwrap(); + let task = tokio::spawn(async move { access.token().await }); + while f.state.lock().unwrap().refreshes == 0 { + tokio::time::sleep(Duration::from_millis(5)).await; + } + assert!( + !f.service + .disconnect("channel") + .await + .unwrap() + .remote_revoked + ); + gate.notify_one(); + assert!(task.await.unwrap().is_err()); + assert!( + !f.service + .disconnect("channel") + .await + .unwrap() + .remote_revoked + ); + assert_eq!( + f.service + .status("channel") + .await + .unwrap() + .error + .unwrap() + .code, + "remote_revocation_unconfirmed" + ); +} + +#[tokio::test] +async fn siwc_cleanup_of_an_untouched_draft_does_not_create_a_secret_vault() { + let vault = Arc::new(MemoryVault::default()); + let service = SiwcService::new(vault.clone()); + service.cancel_sign_in("draft").await.unwrap(); + service.detach("draft".into()).await.unwrap(); + assert!(vault.0.lock().unwrap().is_empty()); +} +#[tokio::test] +async fn siwc_cancelling_an_old_attempt_preserves_its_successor() { + let f = Fixture::new().await; + let first = f + .service + .begin_sign_in("channel".into(), false, false) + .await + .unwrap(); + let second = f + .service + .begin_sign_in("channel".into(), false, false) + .await + .unwrap(); + f.service + .cancel_attempt("channel", &first.attempt_id) + .await + .unwrap(); + assert!(f.service.authorizing("channel", &second.attempt_id)); + assert_eq!( + f.service + .status("channel") + .await + .unwrap() + .attempt_id + .as_deref(), + Some(second.attempt_id.as_str()) + ); + f.service.cancel_sign_in("channel").await.unwrap(); +} + +#[tokio::test] +async fn siwc_refresh_discovery_failure_can_retry_after_network_recovers() { + let f = Fixture::new().await; + f.authorize().await; + f.expire().await; + let mut service = SiwcService::new(f.vault.clone()); + Arc::get_mut(&mut service).unwrap().endpoints = f.service.endpoints.clone(); + Arc::get_mut(&mut service).unwrap().endpoints.discovery = + "http://127.0.0.1:1/unreachable".into(); + let access = service.access("channel").await.unwrap(); + assert_eq!(access.token().await.unwrap_err().code, "network_failed"); + assert_eq!(f.state.lock().unwrap().refreshes, 0); + drop(access); + Arc::get_mut(&mut service).unwrap().endpoints.discovery = f.service.endpoints.discovery.clone(); + let access = service.access("channel").await.unwrap(); + let token = tokio::time::timeout(Duration::from_secs(3), access.token()) + .await + .expect("discovery recovery must not wait for an abandoned refresh") + .expect("discovery failed before sending a rotating-token request"); + assert_eq!(token.expose_secret(), "access-1"); + assert_eq!(f.state.lock().unwrap().refreshes, 1); +} + +#[tokio::test] +async fn siwc_interrupted_disconnect_preserves_remote_revocation_warning() { + let f = Fixture::new().await; + f.authorize().await; + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let mut service = SiwcService::new(f.vault.clone()); + Arc::get_mut(&mut service).unwrap().endpoints = f.service.endpoints.clone(); + Arc::get_mut(&mut service).unwrap().endpoints.discovery = + format!("http://{}/discovery", listener.local_addr().unwrap()); + let task = tokio::spawn(async move { service.disconnect("channel").await }); + let (_blocked_request, _) = tokio::time::timeout(Duration::from_secs(3), listener.accept()) + .await + .unwrap() + .unwrap(); + // Local sign-out is committed, but the remote revocation has not started. + task.abort(); + let _ = task.await; + assert_eq!(f.state.lock().unwrap().revocations, 0); + let restarted = SiwcService::new(f.vault.clone()); + let status = restarted.status("channel").await.unwrap(); + assert_eq!(status.state, "reauthorization_required"); + assert_eq!( + status.error.as_ref().map(|e| e.code.as_str()), + Some("remote_revocation_unconfirmed") + ); +} diff --git a/openless-all/app/e2e/fixtures/siwc.html b/openless-all/app/e2e/fixtures/siwc.html new file mode 100644 index 000000000..79ef8fef1 --- /dev/null +++ b/openless-all/app/e2e/fixtures/siwc.html @@ -0,0 +1,11 @@ + + + + + SIWC channel tests + + +
+ + + diff --git a/openless-all/app/e2e/fixtures/siwc.tsx b/openless-all/app/e2e/fixtures/siwc.tsx new file mode 100644 index 000000000..fb7dfaf7b --- /dev/null +++ b/openless-all/app/e2e/fixtures/siwc.tsx @@ -0,0 +1,115 @@ +import React from 'react'; +import { createRoot } from 'react-dom/client'; +import { i18nReady } from '../../src/i18n'; +import { SiwcChannelFields } from '../../src/pages/settings/SiwcChannelFields'; +import type { SiwcStatus } from '../../src/lib/ipc/siwc'; +import '../../src/styles/tokens.css'; +import '../../src/styles/global.css'; +import '../../src/styles/refinements.css'; +const account = { + id: 'fixture-account', + label: 'tester@example.test · 000001', + email: 'tester@example.test', +}; +const otherAccount = { + id: 'fixture-other-account', + label: 'other@example.test · 000002', + email: 'other@example.test', +}; +const state: SiwcStatus = { + state: 'signed_out', + account: null, + accounts: [], + attemptId: null, + error: null, + welcomePending: false, +}; +let model = ''; +let quota = false; +const calls: { command: string; args: Record }[] = []; +const invoke = async (command: string, args: Record = {}) => { + calls.push({ command, args }); + if (command === 'get_startup_snapshot') + return { contractVersion: '2.0.0', backend: { running: true } }; + if (command === 'siwc_status') return structuredClone(state); + if (command === 'siwc_models') return [{ slug: 'fixture-model', displayName: '账号可用模型' }]; + if (command === 'read_credential') return model; + if (command === 'set_credential') { + model = String(args.value); + return; + } + if (command === 'validate_provider_credentials') { + if (quota) { + state.state = 'usage_limited'; + state.error = { code: 'subscription_sharing_usage_limit_exceeded' }; + throw new Error('siwc:' + JSON.stringify(state.error)); + } + return { ok: true }; + } + if (command === 'record_channel_test' || command === 'open_external_url') return; + if (command === 'siwc_action') { + switch (args.action) { + case 'sign_in': + case 'add_account': + case 'enable_usage': + state.state = 'authorizing'; + state.attemptId = 'fixture-attempt'; + return { attemptId: state.attemptId }; + case 'cancel': + state.state = state.account ? 'connected' : 'signed_out'; + state.attemptId = null; + break; + case 'select': + state.account = state.accounts.find((saved) => saved.id === args.registrationId) ?? null; + state.state = state.account ? 'connected' : 'signed_out'; + break; + case 'acknowledge': + state.welcomePending = false; + break; + case 'retry': + quota = false; + state.state = 'connected'; + state.error = null; + break; + case 'disconnect': + state.state = 'reauthorization_required'; + return { remoteRevoked: false }; + } + return null; + } + throw Error('Unexpected command: ' + command); +}; +Object.assign(window, { + __TAURI_INTERNALS__: { invoke }, + siwcFixture: { + calls, + complete: () => { + Object.assign(state, { + state: 'connected', + account, + accounts: [account, otherAccount], + attemptId: null, + welcomePending: true, + }); + window.dispatchEvent(new Event('focus')); + }, + quota: () => { + quota = true; + }, + }, +}); +await i18nReady; +createRoot(document.getElementById('root')!).render( +
+

ChatGPT Plan

+ +
, +); diff --git a/openless-all/app/e2e/siwc.spec.ts b/openless-all/app/e2e/siwc.spec.ts new file mode 100644 index 000000000..4ac271ede --- /dev/null +++ b/openless-all/app/e2e/siwc.spec.ts @@ -0,0 +1,85 @@ +import { test, expect } from '@playwright/test'; +const fixture = 'http://127.0.0.1:5326/e2e/fixtures/siwc.html'; +test('SIWC sign-in, cancellation, model selection, quota and disconnect', async ({ page }) => { + await page.goto(fixture); + await expect(page.getByText('尚未连接 ChatGPT', { exact: true })).toBeVisible(); + await expect(page.getByText('API Key', { exact: true })).toHaveCount(0); + const primaryBackground = await page + .getByRole('button', { name: 'Continue with ChatGPT', exact: true }) + .evaluate((el) => getComputedStyle(el).backgroundColor); + await page.getByRole('button', { name: 'Continue with ChatGPT', exact: true }).click(); + await expect(page.getByText('请在系统浏览器中完成授权', { exact: true })).toBeVisible(); + await page.getByRole('button', { name: '取消登录', exact: true }).click(); + await expect(page.getByText('尚未连接 ChatGPT', { exact: true })).toBeVisible(); + await page.getByRole('button', { name: 'Continue with ChatGPT', exact: true }).click(); + await expect(page.getByText('请在系统浏览器中完成授权', { exact: true })).toBeVisible(); + await page.evaluate(() => (window as any).siwcFixture.complete()); + await expect(page.getByRole('dialog')).toBeVisible(); + await page.getByRole('button', { name: '知道了', exact: true }).click(); + await expect(page.getByRole('dialog')).toHaveCount(0); + await expect(page.getByRole('button', { name: '测试连接', exact: true })).toBeDisabled(); + await page.getByRole('combobox', { name: '润色模型', exact: true }).click(); + await page.getByRole('option', { name: '账号可用模型', exact: true }).click(); + await expect(page.getByRole('button', { name: '测试连接', exact: true })).toBeEnabled(); + const inferenceBefore = await page.evaluate( + () => + (window as any).siwcFixture.calls.filter( + (c: any) => c.command === 'validate_provider_credentials', + ).length, + ); + expect(inferenceBefore).toBe(0); + await expect(page.locator('[role="listbox"]')).toHaveCount(0); + await page.screenshot({ path: 'test-results/siwc-connected.png', fullPage: true }); + await page.getByRole('button', { name: '测试连接', exact: true }).click(); + await expect(page.getByText('连接测试成功', { exact: true })).toBeVisible(); + await page.evaluate(() => (window as any).siwcFixture.quota()); + await page.getByRole('button', { name: '测试连接', exact: true }).click(); + await expect(page.getByText('已达到计划或此应用的用量限制', { exact: true })).toBeVisible(); + await expect(page.getByRole('button', { name: '管理用量', exact: true })).toBeVisible(); + await expect(page.getByRole('button', { name: '管理用量', exact: true })).toHaveCSS( + 'background-color', + primaryBackground, + ); + await page.getByRole('button', { name: '断开连接', exact: true }).click(); + await expect(page.getByText(/已在本机断开连接,但未确认远端撤销/)).toBeVisible(); + const calls = await page.evaluate(() => (window as any).siwcFixture.calls); + expect(calls.some((c: any) => /set_active|reorder|set_channel_enabled/.test(c.command))).toBe( + false, + ); +}); +test('SIWC account form fits a narrow screen and keyboard navigation', async ({ page }) => { + await page.setViewportSize({ width: 390, height: 844 }); + await page.goto(fixture); + await page.getByRole('button', { name: 'Continue with ChatGPT', exact: true }).focus(); + await page.keyboard.press('Enter'); + await expect(page.getByRole('button', { name: '取消登录', exact: true })).toBeVisible(); + await page.evaluate(() => (window as any).siwcFixture.complete()); + await page.getByRole('button', { name: '知道了', exact: true }).click(); + await expect(page.getByRole('combobox', { name: '润色模型', exact: true })).toBeVisible(); + expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual(390); + await page.screenshot({ path: 'test-results/siwc-mobile.png', fullPage: true }); +}); + +test('SIWC account selection and quota retry refresh after native null results', async ({ + page, +}) => { + await page.goto(fixture); + await page.getByRole('button', { name: 'Continue with ChatGPT', exact: true }).waitFor(); + await page.evaluate(() => (window as any).siwcFixture.complete()); + await page.getByRole('button', { name: '知道了', exact: true }).click(); + await expect(page.getByRole('dialog')).toHaveCount(0); + await page.getByRole('combobox', { name: '润色模型', exact: true }).click(); + await page.getByRole('option', { name: '账号可用模型', exact: true }).click(); + const accountPicker = page.getByRole('combobox', { name: 'ChatGPT 账号', exact: true }); + await accountPicker.click(); + await page.getByRole('option', { name: 'other@example.test · 000002', exact: true }).click(); + await expect(accountPicker).toContainText('other@example.test'); + const testConnection = page.getByRole('button', { name: '测试连接', exact: true }); + await expect(testConnection).toBeEnabled(); + await page.evaluate(() => (window as any).siwcFixture.quota()); + await testConnection.click(); + await expect(page.getByText('已达到计划或此应用的用量限制', { exact: true })).toBeVisible(); + await page.getByRole('button', { name: '重试计划用量', exact: true }).click(); + await expect(testConnection).toBeEnabled(); + await expect(page.getByText('已达到计划或此应用的用量限制', { exact: true })).toHaveCount(0); +}); diff --git a/openless-all/app/harmony/entry/src/main/ets/HarmonyApi.ets b/openless-all/app/harmony/entry/src/main/ets/HarmonyApi.ets index 11dd9eb05..223e3d693 100644 --- a/openless-all/app/harmony/entry/src/main/ets/HarmonyApi.ets +++ b/openless-all/app/harmony/entry/src/main/ets/HarmonyApi.ets @@ -1,3 +1,4 @@ +import { backgroundTaskManager } from '@kit.BackgroundTasksKit'; import { common, abilityAccessCtrl } from '@kit.AbilityKit'; import { audio } from '@kit.AudioKit'; import { fileIo as fs, picker } from '@kit.CoreFileKit'; @@ -12,6 +13,12 @@ import { VoiceClient, VoiceResult } from './inputmethod/VoiceClient'; import { publishVoiceConfiguration } from './VoiceConfiguration'; interface Revisions { preferencesRevision: number; stylePackRevision: number; historyRevision: number; vocabularyRevision: number; } +interface SiwcLaunch { attemptId: string; url: string; } +interface SiwcLaunchReply { ok: boolean; value: SiwcLaunch; error?: string; } +interface SiwcState { state: string; } +interface SiwcStateReply { ok: boolean; value: SiwcState; } +interface SiwcPublicLaunch { attemptId: string; } +interface SiwcCancelArgs { channelId: string; action: string; } interface RevisionReply { ok: boolean; value: Revisions; } interface CredentialChange { account: string; provider?: string | null; value: string | null; } interface CredentialChangesReply { ok: boolean; value: CredentialChange[]; } @@ -24,10 +31,60 @@ interface DeviceInfo { name: string; isDefault: boolean; } interface DictationEvent { phase: string; level: number; error: string; } interface VoiceCredentialStatus { pipelineMode: string; asrConfigured: boolean; omniConfigured: boolean; } interface VoiceCredentialReply { ok: boolean; value: VoiceCredentialStatus; } -class CommandArgs { deviceName: string = ''; pane: string = ''; account: string = ''; provider?: string; value: string = ''; fileName: string = ''; message: string = ''; url: string = ''; id: string = ''; zipPath: string = ''; targetPath: string = ''; text: string = ''; } +class CommandArgs { channelId: string = ''; action: string = ''; registrationId: string = ''; deviceName: string = ''; pane: string = ''; account: string = ''; provider?: string; value: string = ''; fileName: string = ''; message: string = ''; url: string = ''; id: string = ''; zipPath: string = ''; targetPath: string = ''; text: string = ''; } export class HarmonyApi { private logs: string[] = []; + private siwcDelay?: number; + private siwcEpoch: number = 0; + private siwcChannel: string = ''; + private endSiwcDelay(): void { + this.siwcEpoch++; + const delay = this.siwcDelay; this.siwcDelay = undefined; + if (delay !== undefined) { try { backgroundTaskManager.cancelSuspendDelay(delay); } catch (_) {} } + } + private async cancelSiwc(channel: string): Promise { + const args: SiwcCancelArgs = {channelId: channel, action: 'cancel'}; + await nativeHost.invokeAsync('siwc_action', JSON.stringify(args)); + } + private async beginSiwc(args: CommandArgs, argsJson: string): Promise { + if (this.siwcChannel) { + await this.cancelSiwc(this.siwcChannel); + } + this.endSiwcDelay(); + this.siwcChannel = args.channelId; + const channel = args.channelId; + const epoch = this.siwcEpoch; + const delay = backgroundTaskManager.requestSuspendDelay('ChatGPT authorization', () => { + if (epoch !== this.siwcEpoch) return; + this.cancelSiwc(channel).catch(() => {}); + this.endSiwcDelay(); + }); + this.siwcDelay = delay.requestId; + try { + const launch: SiwcLaunchReply = JSON.parse(await nativeHost.invokeAsync('__siwc_begin', argsJson)); + if (epoch !== this.siwcEpoch) { + const reply: Reply = {ok: false, error: 'siwc: {"code":"cancelled"}'}; + return JSON.stringify(reply); + } + if (!launch.ok) { + this.endSiwcDelay(); + const reply: Reply = {ok: false, error: launch.error || 'SIWC authorization failed'}; + return JSON.stringify(reply); + } + try { await this.openExternal(launch.value.url); } + catch (_) { throw new Error('siwc: {"code":"browser_failed"}'); } + const value: SiwcPublicLaunch = {attemptId: launch.value.attemptId}; + const reply: Reply = {ok: true, value: value}; + return JSON.stringify(reply); + } catch (_) { + if (epoch === this.siwcEpoch) { + this.endSiwcDelay(); + await this.cancelSiwc(channel); + } + throw new Error('siwc: {"code":"browser_failed"}'); + } + } appendLog(message: string): void { this.logs.push(new Date().toISOString() + ' ' + message); @@ -55,6 +112,22 @@ export class HarmonyApi { private revisions?: Revisions; private nativeQueue: Promise = Promise.resolve(); + private async notifyBackendChange(): Promise { + const snapshot: RevisionReply = JSON.parse(await nativeHost.invokeAsync('__revision_snapshot', '{}')); + if (snapshot.ok) { + if (this.revisions && JSON.stringify(this.revisions) !== JSON.stringify(snapshot.value)) { + this.controller?.runJavaScript("window.dispatchEvent(new CustomEvent('openless:harmony-backend-change'))").catch(() => {}); + } + this.revisions = snapshot.value; + } + } + private async siwcNative(command: string, args: string): Promise { + // OAuth has its own durable native vault; never wait behind ordinary settings writes. + const wire = await nativeHost.invokeAsync(command, args); + await this.notifyBackendChange(); + return wire; + } + private async native(command: string, args: string): Promise { const task = this.nativeQueue.then(async (): Promise => { const wire = await nativeHost.invokeAsync(command, args); @@ -68,13 +141,7 @@ export class HarmonyApi { await this.persistCredential(entry); } if (changes.value.length) await nativeHost.invokeAsync('__ack_credential_writes', '{}'); - const snapshot: RevisionReply = JSON.parse(await nativeHost.invokeAsync('__revision_snapshot', '{}')); - if (snapshot.ok) { - if (this.revisions && JSON.stringify(this.revisions) !== JSON.stringify(snapshot.value)) { - this.controller?.runJavaScript("window.dispatchEvent(new CustomEvent('openless:harmony-backend-change'))").catch(() => {}); - } - this.revisions = snapshot.value; - } + await this.notifyBackendChange(); await publishVoiceConfiguration(); return wire; }); @@ -255,7 +322,13 @@ export class HarmonyApi { } - invoke(command: string, args: string): string { return nativeHost.invoke(command, args); } + invoke(command: string, args: string): string { + if (command.startsWith('__siwc')) { + const reply: Reply = {ok: false, error: 'Native-only SIWC operation'}; + return JSON.stringify(reply); + } + return nativeHost.invoke(command, args); + } private microphoneStatus(): string { if (!this.context) throw new Error('应用上下文未就绪'); @@ -325,9 +398,23 @@ export class HarmonyApi { async invokeAsync(command: string, argsJson: string): Promise { try { + if (command.startsWith('__siwc')) throw new Error('Native-only SIWC operation'); const args: CommandArgs = JSON.parse(argsJson); let value: Object | null = null; switch (command) { + case 'siwc_models': return await this.siwcNative(command, argsJson); + case 'siwc_action': { + if (['sign_in', 'add_account', 'enable_usage'].includes(args.action)) return await this.beginSiwc(args, argsJson); + const wire = await this.siwcNative(command, argsJson); + if (args.action === 'cancel' || args.action === 'disconnect') this.endSiwcDelay(); + return wire; + } + case 'siwc_status': { + const wire = await this.siwcNative(command, argsJson); + const state: SiwcStateReply = JSON.parse(wire); + if (state.ok && state.value.state !== 'authorizing' && args.channelId === this.siwcChannel) this.endSiwcDelay(); + return wire; + } case 'start_dictation': await this.startDictation(); break; case 'stop_dictation': value = await this.stopDictation(); break; case 'cancel_dictation': await this.cancelDictation(); break; diff --git a/openless-all/app/harmony/ipc-audit.json b/openless-all/app/harmony/ipc-audit.json index b8b4e77b1..f08df1f01 100644 --- a/openless-all/app/harmony/ipc-audit.json +++ b/openless-all/app/harmony/ipc-audit.json @@ -1,7 +1,7 @@ { "scope": "Static shared IPC command coverage; excludes event subscriptions, dynamic commands and desktop-only calls outside src/lib/ipc.", - "total": 192, - "wired": 100, + "total": 195, + "wired": 103, "missing": 92, "commands": [ { @@ -1236,6 +1236,27 @@ ], "implementation": "rust" }, + { + "name": "siwc_action", + "calledFrom": [ + "src/lib/ipc/siwc.ts" + ], + "implementation": "arkts" + }, + { + "name": "siwc_models", + "calledFrom": [ + "src/lib/ipc/siwc.ts" + ], + "implementation": "arkts" + }, + { + "name": "siwc_status", + "calledFrom": [ + "src/lib/ipc/siwc.ts" + ], + "implementation": "arkts" + }, { "name": "start_dictation", "calledFrom": [ diff --git a/openless-all/app/harmony/rust/src/credentials.rs b/openless-all/app/harmony/rust/src/credentials.rs index cf5298dfc..08d4eab6d 100644 --- a/openless-all/app/harmony/rust/src/credentials.rs +++ b/openless-all/app/harmony/rust/src/credentials.rs @@ -55,6 +55,7 @@ impl CredentialMetadataStore for DiskMetadata { } } pub struct HarmonyCredentials { + siwc: Arc, directory: CredentialDirectory, metadata: Arc, values: Values, @@ -68,6 +69,7 @@ impl HarmonyCredentials { values: values.clone(), }); Self { + siwc: crate::siwc::service(), directory: CredentialDirectory::new(metadata.clone()), metadata, values, @@ -101,6 +103,10 @@ impl HarmonyCredentials { } } impl CredentialStore for HarmonyCredentials { + fn siwc(&self) -> Option> { + Some(self.siwc.clone()) + } + fn status( &self, prefs: UserPreferences, diff --git a/openless-all/app/harmony/rust/src/lib.rs b/openless-all/app/harmony/rust/src/lib.rs index 503b3176e..77475ccb9 100644 --- a/openless-all/app/harmony/rust/src/lib.rs +++ b/openless-all/app/harmony/rust/src/lib.rs @@ -1,6 +1,7 @@ mod credentials; mod history; mod self_test; +mod siwc; mod voice_configuration; use openless_core::{BackendConfig, BackendDependencies, OpenLessBackend}; use serde_json::{json, Value}; @@ -356,6 +357,86 @@ fn dispatch(host: &Host, command: &str, args: Value) -> Result { Err(_) => json!({"online":false,"latencyMs":null}), } }), + "siwc_status" | "siwc_models" | "siwc_action" | "__siwc_begin" => { + host.runtime.block_on(async { + let channel = required(&args, "channelId")?.to_string(); + let service = b + .siwc_for_channel(&channel) + .await + .map_err(|e| e.to_string())?; + if command == "__siwc_begin" + || (command == "siwc_action" + && matches!(args["action"].as_str(), Some("select" | "disconnect"))) + { + b.invalidate_channel_test(openless_core::ChannelKind::Llm, channel.clone()) + .await + .map_err(|e| e.to_string())?; + } + let result = match command { + "siwc_status" => { + json!(service.status(&channel).await.map_err(|e| e.to_string())?) + } + "siwc_models" => json!(service + .list_models(&channel) + .await + .map_err(|e| e.to_string())?), + "__siwc_begin" => { + let action = required(&args, "action")?; + let launch = b + .begin_siwc_sign_in( + channel, + action == "add_account", + action == "enable_usage", + ) + .await + .map_err(|e| e.to_string())?; + json!({"attemptId": launch.attempt_id, "url": launch.authorization_url()}) + } + _ => match required(&args, "action")? { + "cancel" => { + service + .cancel_sign_in(&channel) + .await + .map_err(|e| e.to_string())?; + Value::Null + } + "select" => { + service + .select_account( + channel, + required(&args, "registrationId")?.to_string(), + ) + .await + .map_err(|e| e.to_string())?; + Value::Null + } + "disconnect" => json!(service + .disconnect(&channel) + .await + .map_err(|e| e.to_string())?), + "acknowledge" => { + service + .acknowledge_welcome(channel) + .await + .map_err(|e| e.to_string())?; + Value::Null + } + "retry" => { + service + .retry_usage(channel) + .await + .map_err(|e| e.to_string())?; + Value::Null + } + _ => return Err("unsupported SIWC action".into()), + }, + }; + b.refresh_and_publish_credentials() + .await + .map_err(|e| e.to_string())?; + Ok::<_, String>(result) + })? + } "get_settings" => json!(b.get_preferences()), "get_credentials" => json!(host .runtime diff --git a/openless-all/app/harmony/rust/src/siwc.rs b/openless-all/app/harmony/rust/src/siwc.rs new file mode 100644 index 000000000..32ff53d29 --- /dev/null +++ b/openless-all/app/harmony/rust/src/siwc.rs @@ -0,0 +1,196 @@ +//! Native Asset Store access is synchronous and shared by the app and IME processes. +//! It bypasses the WebView credential write queue so rotated tokens are durable before use. +use openless_core::{ + siwc::{error, SiwcError, SiwcService, SiwcVault}, + SecretValue, +}; +use std::sync::Arc; +pub fn service() -> Arc { + SiwcService::new(Arc::new(AssetVault)) +} +struct AssetVault; +#[cfg(not(target_env = "ohos"))] +impl SiwcVault for AssetVault { + fn read(&self, _: &str) -> Result, SiwcError> { + Err(error("storage_unavailable")) + } + fn write(&self, _: &str, _: &str) -> Result<(), SiwcError> { + Err(error("storage_unavailable")) + } + fn remove(&self, _: &str) -> Result<(), SiwcError> { + Err(error("storage_unavailable")) + } + fn compare_head(&self, _: Option<&str>, _: &str) -> Result { + Err(error("storage_unavailable")) + } +} +#[cfg(target_env = "ohos")] +mod native { + use super::*; + #[repr(C)] + #[derive(Clone, Copy)] + struct Blob { + size: u32, + data: *mut u8, + } + #[repr(C)] + #[derive(Clone, Copy)] + union Value { + boolean: bool, + number: u32, + blob: Blob, + } + #[repr(C)] + #[derive(Clone, Copy)] + struct Attr { + tag: u32, + value: Value, + } + #[repr(C)] + struct ResultRow { + count: u32, + attrs: *mut Attr, + } + #[repr(C)] + struct ResultSet { + count: u32, + results: *mut ResultRow, + } + // AssetStoreKit API 11/12 declarations; no dependency on ArkTS or an active WebView. + #[link(name = "asset_ndk.z")] + extern "C" { + fn OH_Asset_Add(attrs: *const Attr, count: u32) -> i32; + fn OH_Asset_Update(query: *const Attr, count: u32, attrs: *const Attr, updates: u32) + -> i32; + fn OH_Asset_Remove(query: *const Attr, count: u32) -> i32; + fn OH_Asset_Query(query: *const Attr, count: u32, result: *mut ResultSet) -> i32; + fn OH_Asset_ParseAttr(row: *const ResultRow, tag: u32) -> *mut Attr; + fn OH_Asset_FreeResultSet(result: *mut ResultSet); + } + const SECRET: u32 = 0x30000001; + const ALIAS: u32 = 0x30000002; + const ACCESSIBILITY: u32 = 0x20000003; + const SYNC: u32 = 0x20000010; + const LABEL: u32 = 0x30000030; + const VERSION: u32 = 0x30000034; + const RETURN: u32 = 0x20000040; + const CONFLICT: u32 = 0x20000044; + const MISSING: i32 = 24000002; + const DUPLICATE: i32 = 24000003; + fn blob(tag: u32, value: &str) -> Attr { + Attr { + tag, + value: Value { + blob: Blob { + size: value.len() as u32, + data: value.as_ptr() as *mut u8, + }, + }, + } + } + fn number(tag: u32, value: u32) -> Attr { + Attr { + tag, + value: Value { number: value }, + } + } + fn alias(key: &str) -> Result { + if key.is_empty() + || key.len() > 100 + || !key + .bytes() + .all(|b| b.is_ascii_alphanumeric() || b".-".contains(&b)) + { + return Err(error("storage_failed")); + } + Ok(format!("openless.siwc.{key}")) + } + impl Drop for ResultSet { + fn drop(&mut self) { + unsafe { + OH_Asset_FreeResultSet(self); + } + } + } + impl SiwcVault for AssetVault { + fn read(&self, key: &str) -> Result, SiwcError> { + let alias = alias(key)?; + let query = [blob(ALIAS, &alias), number(RETURN, 0)]; + let mut result = ResultSet { + count: 0, + results: std::ptr::null_mut(), + }; + let code = unsafe { OH_Asset_Query(query.as_ptr(), query.len() as u32, &mut result) }; + if code == MISSING { + return Ok(None); + } + if code != 0 { + return Err(error("storage_unavailable")); + } + if result.count != 1 || result.results.is_null() { + return Err(error("storage_corrupt")); + } + let secret = unsafe { OH_Asset_ParseAttr(result.results, SECRET).as_ref() } + .ok_or_else(|| error("storage_corrupt"))?; + let value = unsafe { secret.value.blob }; + if value.size > 1024 || value.data.is_null() { + return Err(error("storage_corrupt")); + } + let bytes = unsafe { std::slice::from_raw_parts(value.data, value.size as usize) }; + Ok(Some(SecretValue::new( + std::str::from_utf8(bytes).map_err(|_| error("storage_corrupt"))?, + ))) + } + fn write(&self, key: &str, value: &str) -> Result<(), SiwcError> { + if value.len() > 1024 { + return Err(error("storage_full")); + } + let alias = alias(key)?; + let attrs = [ + blob(ALIAS, &alias), + blob(SECRET, value), + blob(LABEL, "openless.siwc"), + number(ACCESSIBILITY, 2), + number(SYNC, 0), + number(CONFLICT, 1), + ]; + if unsafe { OH_Asset_Add(attrs.as_ptr(), attrs.len() as u32) } != 0 { + return Err(error("storage_failed")); + } + Ok(()) + } + fn remove(&self, key: &str) -> Result<(), SiwcError> { + let alias = alias(key)?; + let query = [blob(ALIAS, &alias)]; + match unsafe { OH_Asset_Remove(query.as_ptr(), 1) } { + 0 | MISSING => Ok(()), + _ => Err(error("storage_failed")), + } + } + fn compare_head(&self, expected: Option<&str>, next: &str) -> Result { + let alias = alias("head")?; + // The revision is a non-secret manifest. The DB condition and head update are one operation. + let code = if let Some(previous) = expected { + let query = [blob(ALIAS, &alias), blob(VERSION, previous)]; + let attrs = [blob(SECRET, next), blob(VERSION, next)]; + unsafe { OH_Asset_Update(query.as_ptr(), 2, attrs.as_ptr(), 2) } + } else { + let attrs = [ + blob(ALIAS, &alias), + blob(SECRET, next), + blob(VERSION, next), + blob(LABEL, "openless.siwc"), + number(ACCESSIBILITY, 2), + number(SYNC, 0), + number(CONFLICT, 1), + ]; + unsafe { OH_Asset_Add(attrs.as_ptr(), attrs.len() as u32) } + }; + match code { + 0 => Ok(true), + MISSING | DUPLICATE => Ok(false), + _ => Err(error("storage_failed")), + } + } + } +} diff --git a/openless-all/app/linux-egui/src/credentials.rs b/openless-all/app/linux-egui/src/credentials.rs index 94da6b9d1..adb0d607f 100644 --- a/openless-all/app/linux-egui/src/credentials.rs +++ b/openless-all/app/linux-egui/src/credentials.rs @@ -40,6 +40,7 @@ struct PersistedCredentialMetadata { /// data directory. Secret values are never serialized to the metadata file. #[derive(Clone)] pub struct LinuxCredentialStore { + siwc: Arc, metadata_path: PathBuf, state: Arc>, } @@ -55,6 +56,7 @@ impl LinuxCredentialStore { let metadata_path = data_dir.join("credential-metadata.json"); let state = read_metadata(&metadata_path)?; Ok(Self { + siwc: crate::siwc::service(data_dir.to_path_buf()), metadata_path, state: Arc::new(Mutex::new(state)), }) @@ -263,6 +265,10 @@ impl LinuxCredentialStore { } impl CredentialStore for LinuxCredentialStore { + fn siwc(&self) -> Option> { + Some(self.siwc.clone()) + } + fn status( &self, preferences: UserPreferences, diff --git a/openless-all/app/linux-egui/src/desktop.rs b/openless-all/app/linux-egui/src/desktop.rs index 3f7207f68..3a743a8ec 100644 --- a/openless-all/app/linux-egui/src/desktop.rs +++ b/openless-all/app/linux-egui/src/desktop.rs @@ -284,7 +284,9 @@ fn validate_external_url(url: &str) -> Result<(), DesktopError> { fn open_external_with(url: &str, launchers: &[(&str, &[&str])]) -> Result<(), DesktopError> { let mut unavailable = Vec::new(); for (program, prefix) in launchers { - match Command::new(program).args(*prefix).arg(url).status() { + // Desktop launchers may echo a failed OAuth URL, including id_token_hint. + match Command::new(program).args(*prefix).arg(url) + .stdout(std::process::Stdio::null()).stderr(std::process::Stdio::null()).status() { Ok(status) if status.success() => return Ok(()), Ok(status) => { return Err(DesktopError::LauncherFailed { diff --git a/openless-all/app/linux-egui/src/i18n.rs b/openless-all/app/linux-egui/src/i18n.rs index cb7d5b198..66b044a7d 100644 --- a/openless-all/app/linux-egui/src/i18n.rs +++ b/openless-all/app/linux-egui/src/i18n.rs @@ -149,6 +149,34 @@ const fn row( // that the zh-CN column is fully populated and that every key actually // referenced resolves. pub const CATALOG: &[Msg] = &[ + Msg { key: "settings.providers.presets.chatgptSiwc", text: row("ChatGPT Plan", "ChatGPT Plan", "ChatGPT Plan", "ChatGPT Plan", "ChatGPT Plan") }, + Msg { key: "siwc.description", text: row("符合条件的文本润色和翻译使用你的 ChatGPT 计划或积分余额。ASR 仍使用原渠道;额度不足时不会自动切换到 API Key。", "符合條件的文字潤飾和翻譯使用你的 ChatGPT 方案或點數。ASR 仍使用原管道;額度不足時不會自動切換至 API Key。", "Eligible text polishing and translation use your ChatGPT plan or credits. ASR keeps its existing provider. Usage limits never switch you to an API key automatically.", "対象の文章校正と翻訳には ChatGPT プランまたはクレジットを使用します。音声認識は従来のプロバイダーを使い、上限到達時に API キーへ自動切り替えしません。", "지원되는 텍스트 다듬기와 번역에 ChatGPT 요금제 또는 크레딧을 사용합니다. 음성 인식은 기존 서비스를 사용하며 한도에 도달해도 API 키로 자동 전환하지 않습니다.") }, + Msg { key: "siwc.account", text: row("ChatGPT 账号", "ChatGPT 帳號", "ChatGPT account", "ChatGPT アカウント", "ChatGPT 계정") }, + Msg { key: "siwc.cancel", text: row("取消登录", "取消登入", "Cancel sign-in", "ログインをキャンセル", "로그인 취소") }, + Msg { key: "siwc.enableUsage", text: row("授权计划用量", "授權方案用量", "Enable plan usage", "プラン利用を許可", "요금제 사용 허용") }, + Msg { key: "siwc.reauthorize", text: row("重新授权", "重新授權", "Sign in again", "再ログイン", "다시 로그인") }, + Msg { key: "siwc.addAccount", text: row("添加账号或工作区", "新增帳號或工作區", "Add account or workspace", "アカウント・ワークスペースを追加", "계정 또는 워크스페이스 추가") }, + Msg { key: "siwc.disconnect", text: row("断开连接", "中斷連線", "Disconnect", "接続を解除", "연결 해제") }, + Msg { key: "siwc.manageUsage", text: row("管理用量", "管理用量", "Manage usage", "使用量を管理", "사용량 관리") }, + Msg { key: "siwc.retry", text: row("重试计划用量", "重試方案用量", "Retry plan usage", "プラン利用を再試行", "요금제 사용 재시도") }, + Msg { key: "siwc.model", text: row("润色模型", "潤飾模型", "Polishing model", "校正モデル", "다듬기 모델") }, + Msg { key: "siwc.selectModel", text: row("选择账号可用模型", "選擇帳號可用模型", "Select an available model", "利用可能なモデルを選択", "사용 가능한 모델 선택") }, + Msg { key: "siwc.modelUnavailable", text: row("模型 {0} 当前不可用,请重新选择。", "模型 {0} 目前無法使用,請重新選擇。", "{0} is no longer available. Select another model.", "{0} は現在利用できません。モデルを選び直してください。", "{0} 모델을 사용할 수 없습니다. 다른 모델을 선택하세요.") }, + Msg { key: "siwc.noModels", text: row("该账号暂时没有可显示的模型。", "此帳號目前沒有可顯示的模型。", "No displayable models are available for this account.", "このアカウントには表示可能なモデルがありません。", "이 계정에서 표시할 수 있는 모델이 없습니다.") }, + Msg { key: "siwc.refreshModels", text: row("刷新模型", "重新整理模型", "Refresh models", "モデルを更新", "모델 새로고침") }, + Msg { key: "siwc.test", text: row("测试连接", "測試連線", "Test connection", "接続をテスト", "연결 테스트") }, + Msg { key: "siwc.testHint", text: row("获取模型列表不执行推理;测试连接会使用少量 ChatGPT 计划用量。", "取得模型清單不會執行推理;測試連線會使用少量 ChatGPT 方案用量。", "Listing models does not run inference. Testing uses a small amount of your ChatGPT plan.", "モデル一覧の取得では推論を実行しません。接続テストはプランの使用量を少量消費します。", "모델 목록 조회는 추론을 실행하지 않습니다. 연결 테스트는 요금제 사용량을 소량 소모합니다.") }, + Msg { key: "siwc.testSuccess", text: row("连接测试成功", "連線測試成功", "Connection test succeeded", "接続テストに成功しました", "연결 테스트 성공") }, + Msg { key: "siwc.remoteUnconfirmed", text: row("已在本机断开连接,但未确认远端撤销。请到 ChatGPT 设置中断开此应用。", "已在本機登出,但未確認遠端撤銷。請至 ChatGPT 設定中中斷此應用程式。", "Signed out locally, but remote revocation was not confirmed. Disconnect this app in ChatGPT settings.", "端末ではログアウトしましたが、サーバー側の失効を確認できません。ChatGPT 設定でこのアプリの接続を解除してください。", "이 기기에서는 로그아웃했지만 원격 취소는 확인되지 않았습니다. ChatGPT 설정에서 이 앱의 연결을 해제하세요.") }, + Msg { key: "siwc.welcome", text: row("正在使用你的 ChatGPT 计划", "正在使用你的 ChatGPT 方案", "You’re using your ChatGPT plan", "ChatGPT プランを使用しています", "ChatGPT 요금제를 사용하고 있습니다") }, + Msg { key: "siwc.gotIt", text: row("知道了", "知道了", "Got it", "了解", "확인") }, + Msg { key: "siwc.failed", text: row("请求未完成,请检查连接或重新授权。", "請求未完成,請檢查連線或重新授權。", "The request did not complete. Check your connection or sign in again.", "リクエストを完了できませんでした。接続を確認するか再ログインしてください。", "요청이 완료되지 않았습니다. 연결을 확인하거나 다시 로그인하세요.") }, + Msg { key: "siwc.states.signed_out", text: row("尚未连接 ChatGPT", "尚未連接 ChatGPT", "Not connected to ChatGPT", "ChatGPT に未接続", "ChatGPT에 연결되지 않음") }, + Msg { key: "siwc.states.authorizing", text: row("请在系统浏览器中完成授权", "請在系統瀏覽器完成授權", "Complete authorization in your system browser", "システムブラウザーで認証を完了してください", "시스템 브라우저에서 인증을 완료하세요") }, + Msg { key: "siwc.states.connected", text: row("正在使用 ChatGPT 计划", "正在使用 ChatGPT 方案", "Using ChatGPT plan", "ChatGPT プランを使用中", "ChatGPT 요금제 사용 중") }, + Msg { key: "siwc.states.usage_disabled", text: row("已登录,尚未授权计划用量", "已登入,尚未授權方案用量", "Signed in; plan usage is not enabled", "ログイン済み・プラン利用は未許可", "로그인됨 · 요금제 사용 허용 필요") }, + Msg { key: "siwc.states.usage_limited", text: row("已达到计划或此应用的用量限制", "已達方案或此應用程式的用量限制", "Plan or app usage limit reached", "プランまたはアプリの利用上限に達しました", "요금제 또는 앱의 사용 한도에 도달함") }, + Msg { key: "siwc.states.reauthorization_required", text: row("需要重新登录 ChatGPT", "需要重新登入 ChatGPT", "Sign in to ChatGPT again", "ChatGPT に再ログインしてください", "ChatGPT에 다시 로그인하세요") }, Msg { key: "providers.extra_headers_hint", text: row( diff --git a/openless-all/app/linux-egui/src/lib.rs b/openless-all/app/linux-egui/src/lib.rs index 4924cf687..d0bdb47e3 100644 --- a/openless-all/app/linux-egui/src/lib.rs +++ b/openless-all/app/linux-egui/src/lib.rs @@ -38,6 +38,7 @@ mod runtime; mod selection; mod settings; mod single_instance; +mod siwc; mod tray; pub mod typography; mod ui_state; diff --git a/openless-all/app/linux-egui/src/main.rs b/openless-all/app/linux-egui/src/main.rs index bb6022bc0..8b691ab6b 100644 --- a/openless-all/app/linux-egui/src/main.rs +++ b/openless-all/app/linux-egui/src/main.rs @@ -47,6 +47,11 @@ mod linux_app { use window::*; enum UiResult { + Siwc { + channel_id: String, + request: u64, + state: Result, + }, Message(String), /// 选区助手动作的异步结果:`generation` 是发起时面板进程的代次, /// 换过窗口后的迟到结果不得回写到新面板。 @@ -442,6 +447,8 @@ mod linux_app { /// mirror writes it out each frame, so re-reading the channel list never /// clobbers what the user is typing. struct ProviderEditorForm { + siwc: Option, + siwc_request: u64, channel_id: String, /// 渠道种类(LLM / ASR):弹窗副标题「文字处理渠道 / 语音识别渠道」用它。 kind: openless_core::ChannelKind, @@ -471,6 +478,8 @@ mod linux_app { impl ProviderEditorForm { fn from_editor(editor: &ProviderEditor, lang: Lang) -> Self { Self { + siwc: None, + siwc_request: 0, channel_id: editor.channel.id.clone(), kind: editor.kind, provider_type: editor.descriptor.provider_type.as_str().to_string(), @@ -3475,6 +3484,161 @@ mod linux_app { self.editor_requested = false; } + fn request_siwc(&mut self, action: String, registration_id: Option) { + let Some(backend) = self.backend() else { + return; + }; + let Some(form) = self + .provider_editor_form + .as_mut() + .filter(|f| f.provider_type == openless_core::siwc::PROVIDER_ID) + else { + return; + }; + static SIWC_REQUEST: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1); + form.siwc_request = SIWC_REQUEST.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + let request = form.siwc_request; + let channel = form.channel_id.clone(); + let tx = self.tx.clone(); + let wake = std::thread::current(); + self.tokio.spawn(async move { + let result = async { + let service = backend + .siwc_for_channel(&channel) + .await + .map_err(|e| e.to_string())?; + let mut message = None; + if matches!(action.as_str(), "sign_in" | "add_account" | "enable_usage" | "select" | "disconnect") { + backend.invalidate_channel_test(openless_core::ChannelKind::Llm, channel.clone()).await.map_err(|e| e.to_string())?; + } + match action.as_str() { + "sign_in" | "add_account" | "enable_usage" => { + let launch = backend + .begin_siwc_sign_in( + channel.clone(), + action == "add_account", + action == "enable_usage", + ) + .await + .map_err(|e| e.to_string())?; + if open_external(launch.authorization_url()).is_err() { + let _ = service.cancel_sign_in(&channel).await; + return Err( + openless_core::siwc::error("browser_failed").to_string() + ); + } + } + "cancel" => service + .cancel_sign_in(&channel) + .await + .map_err(|e| e.to_string())?, + "select" => service + .select_account( + channel.clone(), + registration_id.ok_or("account required")?, + ) + .await + .map_err(|e| e.to_string())?, + "disconnect" => { + if !service + .disconnect(&channel) + .await + .map_err(|e| e.to_string())? + .remote_revoked + { + message = Some("siwc.remoteUnconfirmed".into()); + } + } + "acknowledge" => service + .acknowledge_welcome(channel.clone()) + .await + .map_err(|e| e.to_string())?, + "retry" => service + .retry_usage(channel.clone()) + .await + .map_err(|e| e.to_string())?, + "test" => { + let started = std::time::Instant::now(); + let result = backend + .services() + .provider + .validate(openless_core::ProviderRequest { + kind: openless_core::ProviderKind::Llm, + channel_id: Some(channel.clone()), + thinking_enabled: false, + }) + .await; + backend + .record_channel_test( + openless_core::ChannelKind::Llm, + channel.clone(), + result.is_ok(), + result + .as_ref() + .ok() + .map(|_| started.elapsed().as_millis() as u32), + result.as_ref().err().map(|e| e.to_string()), + ) + .await + .map_err(|e| e.to_string())?; + message = Some(match result { + Ok(_) => "siwc.testSuccess".into(), + Err(e) => e.to_string(), + }); + } + "refresh" => {} + _ => return Err("unknown SIWC action".into()), + } + loop { + let status = service.status(&channel).await.map_err(|e| e.to_string())?; + let authorizing = status.state == "authorizing"; + let models = if status.state == "connected" { + match service.list_models(&channel).await { + Ok(models) => models, + Err(e) => { + message = Some(e.to_string()); + Vec::new() + } + } + } else { + Vec::new() + }; + let state = frontend::view_model::SettingsSiwcState { + status, + models, + message: message.clone(), + }; + let _ = backend.refresh_and_publish_credentials().await; + if tx + .send(UiResult::Siwc { + channel_id: channel.clone(), + request, + state: Ok(state), + }) + .is_err() + { + break; + } + wake.unpark(); + if !authorizing { + break; + } + tokio::time::sleep(Duration::from_secs(1)).await; + } + Ok::<(), String>(()) + } + .await; + if let Err(error) = result { + let _ = tx.send(UiResult::Siwc { + channel_id: channel, + request, + state: Err(error), + }); + wake.unpark(); + } + }); + } + /// Core owns the model catalog; the host only forwards the request. fn request_provider_models(&self, kind: openless_core::ChannelKind, channel_id: String) { if self.provider_switch_pending { @@ -4869,6 +5033,22 @@ mod linux_app { }; self.load_service_configured(); } + UiResult::Siwc { + channel_id, + request, + state, + } => { + if let Some(form) = self.provider_editor_form.as_mut().filter(|f| { + f.channel_id == channel_id + && f.provider_type == openless_core::siwc::PROVIDER_ID + && f.siwc_request == request + }) { + match state { + Ok(state) => form.siwc = Some(state), + Err(error) => self.frontend_vm.settings_notice = Some(error), + } + } + } UiResult::ProviderEditor { kind, channel_id, @@ -4889,6 +5069,7 @@ mod linux_app { Some(ProviderEditorForm::from_editor(&editor, self.lang)); self.provider_editor = ProviderEditorState::Loaded(Box::new(editor)); + self.request_siwc("refresh".into(), None); // Tauri `CatalogModelField` 在挂载时就拉一次目录(只有 // OrcaRouter 这种没有固定清单的服务商才需要),否则用户 // 得自己找到「拉取模型」才知道可选什么。 @@ -5221,6 +5402,7 @@ mod linux_app { Some(ProviderEditorForm::from_editor(&editor, self.lang)); self.provider_editor = ProviderEditorState::Loaded(Box::new(editor)); + self.request_siwc("refresh".into(), None); if self .provider_editor_form .as_ref() @@ -5824,6 +6006,7 @@ mod linux_app { // stays a pure renderer; Core still owns the credential schema. vm.provider_editor = self.provider_editor_form.as_ref().map(|form| { frontend::view_model::SettingsProviderEditor { + siwc: form.siwc.clone(), channel_id: form.channel_id.clone(), is_asr: matches!(form.kind, openless_core::ChannelKind::Asr), is_draft: self.editor_draft, @@ -7601,6 +7784,16 @@ mod linux_app { } } } + frontend::view_model::FrontendAction::SettingsSiwc { + action, + registration_id, + } => { + if self.provider_switch_pending { + continue; + } + self.draft_touched = true; + self.request_siwc(action, registration_id); + } frontend::view_model::FrontendAction::SettingsProviderSave => { self.queue_provider_save(); } @@ -8070,6 +8263,7 @@ mod linux_app { Core::Volcengine => Ui::Volcengine, Core::Xfyun => Ui::Xfyun, Core::OAuth => Ui::OAuth, + Core::Siwc => Ui::Siwc, Core::TencentCloud => Ui::TencentCloud, Core::ApiKey | Core::EndpointModelOptionalApiKey | Core::ApiKeyUnlessCustomEndpoint => { Ui::ApiKey @@ -8185,7 +8379,9 @@ mod linux_app { .rename_channel(editor.kind, channel_id.to_string(), editor.name) .await?; match editor.descriptor.auth_requirement { - openless_core::AuthRequirement::None | openless_core::AuthRequirement::OAuth => {} + openless_core::AuthRequirement::None + | openless_core::AuthRequirement::OAuth + | openless_core::AuthRequirement::Siwc => {} openless_core::AuthRequirement::Volcengine => { write_or_remove_provider_value( &backend, @@ -8362,7 +8558,9 @@ mod linux_app { editor: &ProviderEditor, ) -> Result<(), BackendError> { let accounts: &[&str] = match editor.descriptor.auth_requirement { - openless_core::AuthRequirement::None | openless_core::AuthRequirement::OAuth => &[], + openless_core::AuthRequirement::None + | openless_core::AuthRequirement::OAuth + | openless_core::AuthRequirement::Siwc => &[], openless_core::AuthRequirement::Volcengine => &[ openless_core::credentials::VOLCENGINE_APP_KEY_ACCOUNT, openless_core::credentials::VOLCENGINE_ACCESS_KEY_ACCOUNT, @@ -8572,9 +8770,9 @@ mod linux_app { // 用户能看到「这里有东西」。之前这里写死成空串,编辑已有渠道时密钥框永远空白, // 看起来像没保存。 let (primary_account, secondary_account) = match descriptor.auth_requirement { - openless_core::AuthRequirement::None | openless_core::AuthRequirement::OAuth => { - (None, None) - } + openless_core::AuthRequirement::None + | openless_core::AuthRequirement::OAuth + | openless_core::AuthRequirement::Siwc => (None, None), openless_core::AuthRequirement::Volcengine => { if auth_mode == "api_key" { ( diff --git a/openless-all/app/linux-egui/src/siwc.rs b/openless-all/app/linux-egui/src/siwc.rs new file mode 100644 index 000000000..d84505451 --- /dev/null +++ b/openless-all/app/linux-egui/src/siwc.rs @@ -0,0 +1,66 @@ +use openless_core::{ + siwc::{error, locked_head, SiwcError, SiwcService, SiwcVault}, + SecretValue, +}; +use std::{path::PathBuf, sync::Arc}; +pub(crate) fn service(directory: PathBuf) -> Arc { + SiwcService::new(Arc::new(Vault(directory))) +} +struct Vault(PathBuf); +#[cfg(target_os = "linux")] +fn entry(key: &str) -> Result { + keyring::Entry::new("top.openless.linux.siwc", key).map_err(|_| error("storage_failed")) +} +impl SiwcVault for Vault { + fn read(&self, key: &str) -> Result, SiwcError> { + #[cfg(target_os = "linux")] + { + match entry(key)?.get_password() { + Ok(s) => Ok(Some(SecretValue::new(s))), + Err(keyring::Error::NoEntry) => Ok(None), + Err(_) => Err(error("storage_unavailable")), + } + } + #[cfg(not(target_os = "linux"))] + { + let _ = key; + Err(error("storage_unavailable")) + } + } + fn write(&self, key: &str, value: &str) -> Result<(), SiwcError> { + #[cfg(target_os = "linux")] + { + entry(key)? + .set_password(value) + .map_err(|_| error("storage_failed")) + } + #[cfg(not(target_os = "linux"))] + { + let _ = (key, value); + Err(error("storage_unavailable")) + } + } + fn remove(&self, key: &str) -> Result<(), SiwcError> { + #[cfg(target_os = "linux")] + { + match entry(key)?.delete_credential() { + Ok(()) | Err(keyring::Error::NoEntry) => Ok(()), + Err(_) => Err(error("storage_failed")), + } + } + #[cfg(not(target_os = "linux"))] + { + let _ = key; + Err(error("storage_unavailable")) + } + } + fn compare_head(&self, expected: Option<&str>, next: &str) -> Result { + locked_head(&self.0.join("siwc-transaction.lock"), || { + if self.read("head")?.as_ref().map(SecretValue::expose_secret) != expected { + return Ok(false); + } + self.write("head", next)?; + Ok(true) + }) + } +} diff --git a/openless-all/app/linux-egui/src/ui/frontend/mod.rs b/openless-all/app/linux-egui/src/ui/frontend/mod.rs index a7a3906bc..132271c89 100644 --- a/openless-all/app/linux-egui/src/ui/frontend/mod.rs +++ b/openless-all/app/linux-egui/src/ui/frontend/mod.rs @@ -735,6 +735,7 @@ mod tests { settings_section: super::view_model::SettingsSection::Services, services_view: 1, provider_editor: Some(super::view_model::SettingsProviderEditor { + siwc: None, channel_id: "channel".to_string(), is_asr: false, is_draft: true, @@ -823,6 +824,7 @@ mod tests { let zh = openless_linux_egui::Lang::ZhCn; let editor = || { Some(super::view_model::SettingsProviderEditor { + siwc: None, channel_id: "channel".to_string(), is_asr: false, is_draft: false, @@ -1378,6 +1380,7 @@ mod tests { }; let editor = |channel_id: &str, endpoint: &str, model: &str| { super::view_model::SettingsProviderEditor { + siwc: None, channel_id: channel_id.to_string(), is_asr: false, is_draft: false, diff --git a/openless-all/app/linux-egui/src/ui/frontend/settings.rs b/openless-all/app/linux-egui/src/ui/frontend/settings.rs index e3266df04..8a8599511 100644 --- a/openless-all/app/linux-egui/src/ui/frontend/settings.rs +++ b/openless-all/app/linux-egui/src/ui/frontend/settings.rs @@ -4636,6 +4636,10 @@ fn provider_editor_body( .color(theme::INK_3), ); ui.add_space(4.0); + if editor.auth == SettingsProviderAuth::Siwc { + siwc_editor(ui, editor, lang, actions); + return; + } provider_protocol_block(ui, editor, lang, actions); ui.label( egui::RichText::new(tr_l10n(lang, "providers.credentials")) @@ -4650,6 +4654,7 @@ fn provider_editor_body( .color(theme::INK_4), ); } + SettingsProviderAuth::Siwc => unreachable!("SIWC has its own account form"), SettingsProviderAuth::OAuth => { ui.label( egui::RichText::new(tr_l10n(lang, "providers.oauth_note")) @@ -5870,6 +5875,170 @@ pub(crate) fn test_render_shortcuts( shortcuts(ui, vm, actions); } +fn siwc_editor( + ui: &mut egui::Ui, + editor: &SettingsProviderEditor, + lang: Lang, + actions: &mut Vec, +) { + let emit = |actions: &mut Vec, action: &str, id: Option| { + actions.push(FrontendAction::SettingsSiwc { + action: action.into(), + registration_id: id, + }) + }; + ui.label(tr_l10n(lang, "siwc.description")); + let Some(state) = editor.siwc.as_ref() else { + ui.label(tr_l10n(lang, "common.loading")); + if ui.button(tr_l10n(lang, "siwc.refreshModels")).clicked() { + emit(actions, "refresh", None); + } + return; + }; + let status = &state.status; + let label = match status.state.as_str() { + "connected" => "siwc.states.connected", + "authorizing" => "siwc.states.authorizing", + "usage_disabled" => "siwc.states.usage_disabled", + "usage_limited" => "siwc.states.usage_limited", + "reauthorization_required" => "siwc.states.reauthorization_required", + _ => "siwc.states.signed_out", + }; + ui.strong(tr_l10n(lang, label)); + egui::ComboBox::from_id_salt(("siwc-account", &editor.channel_id)) + .selected_text( + status + .account + .as_ref() + .map(|a| a.label.as_str()) + .unwrap_or(tr_l10n(lang, "siwc.account")), + ) + .show_ui(ui, |ui| { + for account in &status.accounts { + if ui + .selectable_label( + status.account.as_ref().is_some_and(|a| a.id == account.id), + &account.label, + ) + .clicked() + { + emit(actions, "select", Some(account.id.clone())); + } + } + }); + ui.horizontal_wrapped(|ui| { + if status.state == "authorizing" { + if ui.button(tr_l10n(lang, "siwc.cancel")).clicked() { + emit(actions, "cancel", None); + } + } else { + let (action, label) = if status.state == "usage_disabled" { + ("enable_usage", tr_l10n(lang, "siwc.enableUsage")) + } else if status.account.is_some() { + ("sign_in", tr_l10n(lang, "siwc.reauthorize")) + } else { + ("sign_in", "Continue with ChatGPT") + }; + if ui.button(label).clicked() { + emit(actions, action, None); + } + if !status.accounts.is_empty() && ui.button(tr_l10n(lang, "siwc.addAccount")).clicked() + { + emit(actions, "add_account", None); + } + if status.account.is_some() && ui.button(tr_l10n(lang, "siwc.disconnect")).clicked() { + emit(actions, "disconnect", None); + } + } + if provider_small_button( + ui, + lang, + "siwc.manageUsage", + status.state == "usage_limited", + ) { + ui.ctx() + .open_url(egui::OpenUrl::new_tab(openless_core::siwc::USAGE_URL)); + } + if status.state == "usage_limited" && ui.button(tr_l10n(lang, "siwc.retry")).clicked() { + emit(actions, "retry", None); + } + }); + if let Some(error) = &status.error { + if error.code == "remote_revocation_unconfirmed" { + ui.label(tr_l10n(lang, "siwc.remoteUnconfirmed")); + } else { + ui.colored_label( + theme::INK_3, + format!("{} ({})", tr_l10n(lang, "siwc.failed"), error.code), + ); + } + } + if let Some(message) = &state.message { + if message != "siwc.remoteUnconfirmed" + || !status + .error + .as_ref() + .is_some_and(|e| e.code == "remote_revocation_unconfirmed") + { + ui.label(match message.as_str() { + "siwc.remoteUnconfirmed" => tr_l10n(lang, "siwc.remoteUnconfirmed"), + "siwc.testSuccess" => tr_l10n(lang, "siwc.testSuccess"), + _ => message, + }); + } + } + if status.welcome_pending { + ui.group(|ui| { + ui.strong(tr_l10n(lang, "siwc.welcome")); + if ui.button(tr_l10n(lang, "siwc.gotIt")).clicked() { + emit(actions, "acknowledge", None); + } + }); + } + if status.state == "connected" { + let selected = state.models.iter().find(|m| m.slug == editor.model); + if !editor.model.is_empty() && selected.is_none() { + ui.label(fmt_l10n(lang, "siwc.modelUnavailable", &[&editor.model])); + } + if state.models.is_empty() { + ui.label(tr_l10n(lang, "siwc.noModels")); + } + egui::ComboBox::from_id_salt(("siwc-model", &editor.channel_id)) + .selected_text( + selected + .map(|m| m.display_name.as_str()) + .unwrap_or(tr_l10n(lang, "siwc.selectModel")), + ) + .show_ui(ui, |ui| { + for model in &state.models { + if ui + .selectable_label(editor.model == model.slug, &model.display_name) + .clicked() + { + actions.push(FrontendAction::SettingsProviderModelSelected( + model.slug.clone(), + )); + } + } + }); + ui.horizontal_wrapped(|ui| { + if ui.button(tr_l10n(lang, "siwc.refreshModels")).clicked() { + emit(actions, "refresh", None); + } + if ui + .add_enabled( + selected.is_some(), + egui::Button::new(tr_l10n(lang, "siwc.test")), + ) + .clicked() + { + emit(actions, "test", None); + } + }); + ui.label(tr_l10n(lang, "siwc.testHint")); + } +} + #[cfg(test)] mod tests { /// 点「验证」后按钮要立刻变成「正在验证…」并禁用,直到结果回包或超时。 @@ -6347,6 +6516,7 @@ mod tests { let shapes = [ SettingsProviderAuth::None, SettingsProviderAuth::OAuth, + SettingsProviderAuth::Siwc, SettingsProviderAuth::Volcengine, SettingsProviderAuth::Xfyun, SettingsProviderAuth::Other, @@ -6354,6 +6524,7 @@ mod tests { ]; for auth in shapes { let editor = SettingsProviderEditor { + siwc: None, channel_id: "channel".to_string(), is_asr: false, is_draft: false, @@ -6397,6 +6568,7 @@ mod tests { fn provider_model_block_follows_the_descriptor() { let editor = |static_models: Vec, has_models_url: bool, custom_model: bool| { SettingsProviderEditor { + siwc: None, channel_id: "channel".to_string(), is_asr: false, is_draft: false, @@ -6533,6 +6705,7 @@ mod tests { let vm = crate::ui::frontend::FrontendViewModel { lang: Lang::ZhCn, provider_editor: Some(SettingsProviderEditor { + siwc: None, channel_id: "channel".to_string(), is_asr: false, is_draft: false, diff --git a/openless-all/app/linux-egui/src/ui/frontend/view_model.rs b/openless-all/app/linux-egui/src/ui/frontend/view_model.rs index 54da3696e..8757b5fb9 100644 --- a/openless-all/app/linux-egui/src/ui/frontend/view_model.rs +++ b/openless-all/app/linux-egui/src/ui/frontend/view_model.rs @@ -236,6 +236,10 @@ pub enum FrontendAction { SettingsProviderField(SettingsProviderField, String), /// Save the editor: rename + endpoint/model + credentials through Core. SettingsProviderSave, + SettingsSiwc { + action: String, + registration_id: Option, + }, SettingsOmni(openless_linux_egui::omni_settings::OmniAction), /// Encrypted cloud sync card (enable/unlock/restore/delete/custom server). SettingsCloudSync(openless_linux_egui::cloud_sync_ui::CloudSyncAction), @@ -436,6 +440,7 @@ pub enum SettingsProviderAuth { None, /// OAuth is driven by Core; the editor only explains it. OAuth, + Siwc, /// Volcengine: APP ID + Access Token, or API Key + Resource ID. Volcengine, /// Xfyun: AppID + API Key. @@ -448,10 +453,19 @@ pub enum SettingsProviderAuth { ApiKey, } +#[derive(Clone, serde::Serialize, serde::Deserialize, Debug)] +pub struct SettingsSiwcState { + pub status: openless_core::siwc::SiwcStatus, + pub models: Vec, + pub message: Option, +} + /// The open channel editor. Hydrated once per load from Core, then driven by /// the host-side draft so typing is never clobbered by a re-read. #[derive(Clone, serde::Serialize, serde::Deserialize, Debug)] pub struct SettingsProviderEditor { + #[serde(default)] + pub siwc: Option, pub channel_id: String, /// 渠道种类:弹窗副标题在「文字处理渠道 / 语音识别渠道」之间切换。 pub is_asr: bool, diff --git a/openless-all/app/package.json b/openless-all/app/package.json index a3aae3c75..7d4cfc0e1 100644 --- a/openless-all/app/package.json +++ b/openless-all/app/package.json @@ -7,7 +7,7 @@ "pretest": "npm run build", "test": "node scripts/frontend-test-runner.mjs", "dev": "vite", - "prebuild": "node scripts/android-ipc-import-boundary.test.mjs && node scripts/harmony-entry-startup.test.mjs && node scripts/harmony-app-dictation.test.mjs && node scripts/harmony-voice-configuration.test.mjs && node scripts/harmony-panel-layout.test.mjs && node scripts/harmony-editor-compat.test.mjs && node scripts/harmony-clipboard-history.test.mjs && node scripts/harmony-symbol-gesture.test.mjs && node scripts/harmony-rime-startup.test.mjs", + "prebuild": "node scripts/android-ipc-import-boundary.test.mjs && node scripts/harmony-entry-startup.test.mjs && node scripts/harmony-app-dictation.test.mjs && node scripts/harmony-siwc.test.mjs && node scripts/harmony-voice-configuration.test.mjs && node scripts/harmony-panel-layout.test.mjs && node scripts/harmony-editor-compat.test.mjs && node scripts/harmony-clipboard-history.test.mjs && node scripts/harmony-symbol-gesture.test.mjs && node scripts/harmony-rime-startup.test.mjs", "build": "tsc && vite build", "preview": "vite preview", "tauri": "tauri", diff --git a/openless-all/app/scripts/harmony-siwc.test.mjs b/openless-all/app/scripts/harmony-siwc.test.mjs new file mode 100644 index 000000000..e40481d9f --- /dev/null +++ b/openless-all/app/scripts/harmony-siwc.test.mjs @@ -0,0 +1,106 @@ +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import vm from 'node:vm'; +import ts from 'typescript'; +const source = readFileSync( + new URL('../harmony/entry/src/main/ets/HarmonyApi.ets', import.meta.url), + 'utf8', +) + .replace(/\r\n/g, '\n') + .replace(/^import .*;\n/gm, ''); +const compiled = ts.transpileModule(source, { + compilerOptions: { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.CommonJS }, +}).outputText; +const calls = []; +const opened = []; +let expire; +let release = 0; +let browserFails = false; +const exports = {}; +let finishSlow; +const nativeHost = { + invokeAsync: async (command, raw) => { + const args = JSON.parse(raw); + calls.push({ command, args }); + if (command === 'slow_settings') + return new Promise((resolve) => { + finishSlow = resolve; + }); + if (command === '__siwc_begin') + return JSON.stringify({ + ok: true, + value: { + attemptId: 'attempt', + url: 'https://auth.openai.com/api/accounts/authorize?id_token_hint=private-id-token', + }, + }); + if (command === '__credential_writes') return JSON.stringify({ ok: true, value: [] }); + if (command === '__revision_snapshot') + return JSON.stringify({ ok: true, value: { preferencesRevision: 0 } }); + return JSON.stringify({ ok: true, value: { state: 'signed_out' } }); + }, +}; +vm.runInNewContext(compiled, { + exports, + console, + VoiceClient: class {}, + nativeHost, + publishVoiceConfiguration: async () => {}, + backgroundTaskManager: { + requestSuspendDelay: (_reason, callback) => { + expire = callback; + return { requestId: 1, actualDelayTime: 60000 }; + }, + cancelSuspendDelay: () => release++, + }, +}); +const api = new exports.HarmonyApi(); +api.configure( + { + startAbility: async (want) => { + opened.push(want.uri); + if (browserFails) throw Error(want.uri); + }, + }, + {}, +); +assert.equal(JSON.parse(api.invoke('__siwc_begin', '{}')).ok, false); +const blocked = await api.invokeAsync('__siwc_begin', '{}'); +assert.equal(JSON.parse(blocked).ok, false); +assert.equal(calls.length, 0); +const result = await api.invokeAsync( + 'siwc_action', + JSON.stringify({ channelId: 'channel', action: 'sign_in' }), +); +assert.equal(JSON.parse(result).ok, true); +assert(!result.includes('private-id-token')); +assert.equal(opened.length, 1); +expire(); +await Promise.resolve(); +assert(calls.some((c) => c.command === 'siwc_action' && c.args.action === 'cancel')); +assert(release > 0); +browserFails = true; +const failed = await api.invokeAsync( + 'siwc_action', + JSON.stringify({ channelId: 'channel', action: 'sign_in' }), +); +assert.equal(JSON.parse(failed).ok, false); +assert(!failed.includes('private-id-token')); +console.log( + 'Harmony SIWC: native-only launch, URL redaction, background expiry and cancellation passed', +); + +// Authorization control must not queue behind unrelated settings I/O. +const pendingSettings = api.invokeAsync('slow_settings', '{}'); +for (let i = 0; i < 20 && !finishSlow; i++) await new Promise((resolve) => setImmediate(resolve)); +assert(finishSlow); +const immediateCancel = await Promise.race([ + api.invokeAsync('siwc_action', JSON.stringify({ channelId: 'channel', action: 'cancel' })), + new Promise((_, reject) => + setTimeout(() => reject(new Error('SIWC cancellation was queued')), 500), + ), +]); +assert.equal(JSON.parse(immediateCancel).ok, true); +finishSlow(JSON.stringify({ ok: true, value: null })); +await pendingSettings; +console.log('Harmony SIWC: cancellation remains responsive while settings persistence is blocked'); diff --git a/openless-all/app/scripts/merge-android-overlay-manifest.mjs b/openless-all/app/scripts/merge-android-overlay-manifest.mjs index 9471920ea..673bd4eb7 100644 --- a/openless-all/app/scripts/merge-android-overlay-manifest.mjs +++ b/openless-all/app/scripts/merge-android-overlay-manifest.mjs @@ -34,7 +34,7 @@ const SERVICE_SNIPPETS = [ android:foregroundServiceType="specialUse"> + android:value="Keeps the IME runtime and user-initiated ChatGPT authorization callback available while the user is in another app." /> `, `]*android:name="\.OpenLessRuntimeService"[\s\S]*?<\/service>/g, + (block) => + block.replace( + 'android:value="ime_runtime"', + 'android:value="Keeps the IME runtime and user-initiated ChatGPT authorization callback available while the user is in another app."', + ), + ); + return { content, changed: content !== manifestXml }; +} + /** Move only the launcher filter, including repair of previously generated manifests. */ function moveLauncherIntentFilterToWarmupActivity(manifestXml) { const activities = /]*?)(?:\/>|>([\s\S]*?)<\/activity>)/g; @@ -229,6 +241,7 @@ function main() { mergePermissions, ensureApplicationName, mergeApplicationChildren, + updateRuntimeServicePurpose, moveLauncherIntentFilterToWarmupActivity, ]) { const result = step(content); diff --git a/openless-all/app/scripts/siwc-host-contract.test.mjs b/openless-all/app/scripts/siwc-host-contract.test.mjs new file mode 100644 index 000000000..567a4d4e5 --- /dev/null +++ b/openless-all/app/scripts/siwc-host-contract.test.mjs @@ -0,0 +1,68 @@ +import vm from 'node:vm'; +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +const read = (path) => readFileSync(new URL('../' + path, import.meta.url), 'utf8'); +const commands = read('src-tauri/src/commands/siwc.rs'); +assert(/app\s*\.shell\(\)\s*\.open\(launch\.authorization_url\(\),\s*None\)/.test(commands)); +assert(commands.includes('siwc_authorization_lease(true)')); +assert(commands.includes('siwc_authorization_lease(false)')); +assert.equal((commands.match(/ensure_main_window/g) || []).length, 3); +const registry = read('src-tauri/src/lib.rs'); +for (const name of ['siwc_status', 'siwc_models', 'siwc_action']) + assert.equal((registry.match(new RegExp('commands::' + name + ',', 'g')) || []).length, 2); +const runtime = read('android/kotlin/OpenLessRuntimeService.kt'); +for (const method of ['acquireAuthorization', 'releaseAuthorization']) + assert(new RegExp('@androidx.annotation.Keep\\s+@JvmStatic fun ' + method).test(runtime)); +assert(runtime.includes('remaining == 0 && !imeRequested')); +assert( + read('android/kotlin/OpenLessImeService.kt').includes( + 'if (OpenLessRuntimeService.authorizationActive()) return', + ), +); +const assets = read('harmony/rust/src/siwc.rs'); +assert(assets.includes('OH_Asset_Update')); +assert(assets.includes('blob(VERSION, previous)')); +assert(assets.includes('number(SYNC, 0)')); +assert(!assets.includes('__credential_writes')); +const vault = read('src-tauri/src/persistence/siwc.rs'); +assert(vault.includes('com.openless.app.siwc')); +assert(vault.includes('entry("vault.v1")')); +assert(vault.includes('write_verified')); +assert(!read('src/lib/ipc/siwc.ts').includes('authorizationUrl')); +console.log( + 'SIWC host contracts: desktop/mobile commands, Android lease, local-only vaults and native CAS passed', +); + +// Exercise the actual manifest merger on an existing generated service, without disk writes. +const mergeSource = read('scripts/merge-android-overlay-manifest.mjs') + .replace(/^#!.*\n/, '') + .replace(/^import .*;\n/gm, '') + .replaceAll('import.meta.url', JSON.stringify('file:///fixture/merge.mjs')); +let manifest = + ''; +const merge = () => + vm.runInNewContext(mergeSource, { + URL, + fileURLToPath: () => '/fixture/AndroidManifest.xml', + existsSync: () => true, + readFileSync: () => manifest, + writeFileSync: (_path, value) => { + manifest = value; + }, + console: { log() {}, error() {} }, + process: { + argv: ['node', 'merge.mjs'], + exit(code) { + throw Error('merge exit ' + code); + }, + }, + }); +merge(); +assert(manifest.includes('user-initiated ChatGPT authorization callback')); +assert(!manifest.includes('android:value="ime_runtime"')); +const updated = manifest; +merge(); +assert.equal(manifest, updated); +console.log( + 'SIWC Android manifest: existing service purpose updated idempotently without microphone gating', +); diff --git a/openless-all/app/src-tauri/Cargo.lock b/openless-all/app/src-tauri/Cargo.lock index 5e1709cdb..3223fda2e 100644 --- a/openless-all/app/src-tauri/Cargo.lock +++ b/openless-all/app/src-tauri/Cargo.lock @@ -502,6 +502,12 @@ dependencies = [ "tower-service", ] +[[package]] +name = "base16ct" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf" + [[package]] name = "base64" version = "0.21.7" @@ -1039,6 +1045,12 @@ dependencies = [ "crossbeam-utils", ] +[[package]] +name = "const-oid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" + [[package]] name = "const-oid" version = "0.10.2" @@ -1251,6 +1263,18 @@ version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" +[[package]] +name = "crypto-bigint" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76" +dependencies = [ + "generic-array", + "rand_core 0.6.4", + "subtle", + "zeroize", +] + [[package]] name = "crypto-common" version = "0.1.7" @@ -1310,6 +1334,33 @@ dependencies = [ "cmov", ] +[[package]] +name = "curve25519-dalek" +version = "4.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "curve25519-dalek-derive", + "digest 0.10.7", + "fiat-crypto", + "rustc_version", + "subtle", + "zeroize", +] + +[[package]] +name = "curve25519-dalek-derive" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "darling" version = "0.20.11" @@ -1439,6 +1490,17 @@ dependencies = [ "thiserror 2.0.21", ] +[[package]] +name = "der" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" +dependencies = [ + "const-oid 0.9.6", + "pem-rfc7468", + "zeroize", +] + [[package]] name = "der-parser" version = "9.0.0" @@ -1532,6 +1594,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ "block-buffer 0.10.4", + "const-oid 0.9.6", "crypto-common 0.1.7", "subtle", ] @@ -1543,7 +1606,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" dependencies = [ "block-buffer 0.12.1", - "const-oid", + "const-oid 0.10.2", "crypto-common 0.2.2", "ctutils", "zeroize", @@ -1676,12 +1739,71 @@ version = "1.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" +[[package]] +name = "ecdsa" +version = "0.16.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca" +dependencies = [ + "der", + "digest 0.10.7", + "elliptic-curve", + "rfc6979", + "signature", + "spki", +] + +[[package]] +name = "ed25519" +version = "2.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53" +dependencies = [ + "pkcs8", + "signature", +] + +[[package]] +name = "ed25519-dalek" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9" +dependencies = [ + "curve25519-dalek", + "ed25519", + "serde", + "sha2 0.10.9", + "subtle", + "zeroize", +] + [[package]] name = "either" version = "1.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0e9c71c2167ca323c882b99918929403426e2373ea17242ff5653e0d5e1058be" +[[package]] +name = "elliptic-curve" +version = "0.13.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47" +dependencies = [ + "base16ct", + "crypto-bigint", + "digest 0.10.7", + "ff", + "generic-array", + "group", + "hkdf", + "pem-rfc7468", + "pkcs8", + "rand_core 0.6.4", + "sec1", + "subtle", + "zeroize", +] + [[package]] name = "embed-resource" version = "3.0.12" @@ -1888,6 +2010,22 @@ dependencies = [ "rkyv", ] +[[package]] +name = "ff" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0b50bfb653653f9ca9095b427bed08ab8d75a137839d9ad64eb11810d5b6393" +dependencies = [ + "rand_core 0.6.4", + "subtle", +] + +[[package]] +name = "fiat-crypto" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" + [[package]] name = "field-offset" version = "0.3.6" @@ -2212,6 +2350,7 @@ checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" dependencies = [ "typenum", "version_check", + "zeroize", ] [[package]] @@ -2388,6 +2527,17 @@ dependencies = [ "system-deps", ] +[[package]] +name = "group" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63" +dependencies = [ + "ff", + "rand_core 0.6.4", + "subtle", +] + [[package]] name = "gtk" version = "0.18.2" @@ -2506,6 +2656,15 @@ version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" +[[package]] +name = "hkdf" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" +dependencies = [ + "hmac 0.12.1", +] + [[package]] name = "hmac" version = "0.12.1" @@ -3116,6 +3275,28 @@ dependencies = [ "serde_json", ] +[[package]] +name = "jsonwebtoken" +version = "11.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e75fe14a82d81e5f5af639997db37d8b96045938a7ac6ab18cdbe1c7467e05e1" +dependencies = [ + "base64 0.22.1", + "ed25519-dalek", + "getrandom 0.2.17", + "hmac 0.12.1", + "js-sys", + "p256", + "p384", + "rand 0.8.8", + "rsa", + "serde", + "serde_json", + "sha2 0.10.9", + "signature", + "zeroize", +] + [[package]] name = "keyboard-types" version = "0.7.0" @@ -3156,6 +3337,9 @@ name = "lazy_static" version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "20870f649af7073d53e38067b2a84312175d56ea15217e1b15bc83506ec50afb" +dependencies = [ + "spin", +] [[package]] name = "libappindicator" @@ -3222,6 +3406,12 @@ dependencies = [ "windows-link 0.2.1", ] +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + [[package]] name = "libredox" version = "0.1.25" @@ -3643,6 +3833,22 @@ dependencies = [ "num-traits", ] +[[package]] +name = "num-bigint-dig" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e661dda6640fad38e827a6d4a310ff4763082116fe217f279885c97f511bb0b7" +dependencies = [ + "lazy_static", + "libm", + "num-integer", + "num-iter", + "num-traits", + "rand 0.8.8", + "smallvec", + "zeroize", +] + [[package]] name = "num-conv" version = "0.2.2" @@ -3669,6 +3875,16 @@ dependencies = [ "num-traits", ] +[[package]] +name = "num-iter" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c92800bd69a1eac91786bcfe9da64a897eb72911b8dc3095decbd07429e8048b" +dependencies = [ + "num-integer", + "num-traits", +] + [[package]] name = "num-traits" version = "0.2.19" @@ -3676,6 +3892,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" dependencies = [ "autocfg", + "libm", ] [[package]] @@ -4237,6 +4454,7 @@ dependencies = [ "getrandom 0.4.3", "hex", "hmac 0.13.0", + "jsonwebtoken", "log", "md-5 0.11.0", "once_cell", @@ -4356,6 +4574,30 @@ dependencies = [ "thiserror 2.0.21", ] +[[package]] +name = "p256" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9863ad85fa8f4460f9c48cb909d38a0d689dba1f6f6988a5e3e0d31071bcd4b" +dependencies = [ + "ecdsa", + "elliptic-curve", + "primeorder", + "sha2 0.10.9", +] + +[[package]] +name = "p384" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe42f1670a52a47d448f14b6a5c61dd78fce51856e68edaa38f7ae3a46b8d6b6" +dependencies = [ + "ecdsa", + "elliptic-curve", + "primeorder", + "sha2 0.10.9", +] + [[package]] name = "pango" version = "0.18.3" @@ -4450,6 +4692,15 @@ dependencies = [ "serde_core", ] +[[package]] +name = "pem-rfc7468" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412" +dependencies = [ + "base64ct", +] + [[package]] name = "percent-encoding" version = "2.3.2" @@ -4537,6 +4788,27 @@ dependencies = [ "futures-io", ] +[[package]] +name = "pkcs1" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8ffb9f10fa047879315e6625af03c164b16962a5368d724ed16323b68ace47f" +dependencies = [ + "der", + "pkcs8", + "spki", +] + +[[package]] +name = "pkcs8" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" +dependencies = [ + "der", + "spki", +] + [[package]] name = "pkg-config" version = "0.3.34" @@ -4657,6 +4929,15 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c" +[[package]] +name = "primeorder" +version = "0.13.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "353e1ca18966c16d9deb1c69278edbc5f194139612772bd9537af60ac231e1e6" +dependencies = [ + "elliptic-curve", +] + [[package]] name = "proc-macro-crate" version = "1.3.1" @@ -5090,6 +5371,16 @@ dependencies = [ "web-sys", ] +[[package]] +name = "rfc6979" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dd2a808d456c4a54e300a23e9f5a67e122c3024119acbfd73e3bf664491cb2" +dependencies = [ + "hmac 0.12.1", + "subtle", +] + [[package]] name = "rfd" version = "0.16.0" @@ -5158,6 +5449,26 @@ dependencies = [ "syn 3.0.9", ] +[[package]] +name = "rsa" +version = "0.9.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8573f03f5883dcaebdfcf4725caa1ecb9c15b2ef50c43a07b816e06799bb12d" +dependencies = [ + "const-oid 0.9.6", + "digest 0.10.7", + "num-bigint-dig", + "num-integer", + "num-traits", + "pkcs1", + "pkcs8", + "rand_core 0.6.4", + "signature", + "spki", + "subtle", + "zeroize", +] + [[package]] name = "rustc-hash" version = "2.1.3" @@ -5359,6 +5670,20 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "sec1" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc" +dependencies = [ + "base16ct", + "der", + "generic-array", + "pkcs8", + "subtle", + "zeroize", +] + [[package]] name = "security-framework" version = "2.11.1" @@ -5694,6 +6019,16 @@ dependencies = [ "libc", ] +[[package]] +name = "signature" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" +dependencies = [ + "digest 0.10.7", + "rand_core 0.6.4", +] + [[package]] name = "simd-adler32" version = "0.3.10" @@ -5815,6 +6150,22 @@ dependencies = [ "system-deps", ] +[[package]] +name = "spin" +version = "0.9.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3763264f6b73151db08c50ff20d7d8a0b8796e021cdea7ceedad07b80155fa0e" + +[[package]] +name = "spki" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" +dependencies = [ + "base64ct", + "der", +] + [[package]] name = "stable_deref_trait" version = "1.2.1" diff --git a/openless-all/app/src-tauri/backend-tests/Cargo.lock b/openless-all/app/src-tauri/backend-tests/Cargo.lock index 2a3260d62..fb37a2b57 100644 --- a/openless-all/app/src-tauri/backend-tests/Cargo.lock +++ b/openless-all/app/src-tauri/backend-tests/Cargo.lock @@ -134,6 +134,12 @@ dependencies = [ "pkg-config", ] +[[package]] +name = "base16ct" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf" + [[package]] name = "base64" version = "0.22.1" @@ -272,7 +278,7 @@ dependencies = [ "cfg-if", "cipher", "cpufeatures 0.3.1", - "rand_core", + "rand_core 0.10.1", ] [[package]] @@ -334,6 +340,12 @@ dependencies = [ "memchr", ] +[[package]] +name = "const-oid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" + [[package]] name = "const-oid" version = "0.10.2" @@ -408,6 +420,18 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "crypto-bigint" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76" +dependencies = [ + "generic-array", + "rand_core 0.6.4", + "subtle", + "zeroize", +] + [[package]] name = "crypto-common" version = "0.1.7" @@ -426,7 +450,7 @@ checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" dependencies = [ "getrandom 0.4.3", "hybrid-array", - "rand_core", + "rand_core 0.10.1", ] [[package]] @@ -438,6 +462,33 @@ dependencies = [ "cmov", ] +[[package]] +name = "curve25519-dalek" +version = "4.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "curve25519-dalek-derive", + "digest 0.10.7", + "fiat-crypto", + "rustc_version", + "subtle", + "zeroize", +] + +[[package]] +name = "curve25519-dalek-derive" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "data-encoding" version = "2.11.1" @@ -450,6 +501,17 @@ version = "0.1.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ac6b926516df9c60bfa16e107b21086399f8285a44ca9711344b9e553c5146e2" +[[package]] +name = "der" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" +dependencies = [ + "const-oid 0.9.6", + "pem-rfc7468", + "zeroize", +] + [[package]] name = "der-parser" version = "9.0.0" @@ -477,7 +539,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ "block-buffer 0.10.4", + "const-oid 0.9.6", "crypto-common 0.1.7", + "subtle", ] [[package]] @@ -487,7 +551,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" dependencies = [ "block-buffer 0.12.1", - "const-oid", + "const-oid 0.10.2", "crypto-common 0.2.2", "ctutils", "zeroize", @@ -510,6 +574,65 @@ version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" +[[package]] +name = "ecdsa" +version = "0.16.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca" +dependencies = [ + "der", + "digest 0.10.7", + "elliptic-curve", + "rfc6979", + "signature", + "spki", +] + +[[package]] +name = "ed25519" +version = "2.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53" +dependencies = [ + "pkcs8", + "signature", +] + +[[package]] +name = "ed25519-dalek" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9" +dependencies = [ + "curve25519-dalek", + "ed25519", + "serde", + "sha2 0.10.9", + "subtle", + "zeroize", +] + +[[package]] +name = "elliptic-curve" +version = "0.13.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47" +dependencies = [ + "base16ct", + "crypto-bigint", + "digest 0.10.7", + "ff", + "generic-array", + "group", + "hkdf", + "pem-rfc7468", + "pkcs8", + "rand_core 0.6.4", + "sec1", + "subtle", + "zeroize", +] + [[package]] name = "equivalent" version = "1.0.3" @@ -561,6 +684,22 @@ dependencies = [ "rkyv", ] +[[package]] +name = "ff" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0b50bfb653653f9ca9095b427bed08ab8d75a137839d9ad64eb11810d5b6393" +dependencies = [ + "rand_core 0.6.4", + "subtle", +] + +[[package]] +name = "fiat-crypto" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" + [[package]] name = "filetime" version = "0.2.29" @@ -675,6 +814,7 @@ checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" dependencies = [ "typenum", "version_check", + "zeroize", ] [[package]] @@ -700,10 +840,21 @@ dependencies = [ "js-sys", "libc", "r-efi", - "rand_core", + "rand_core 0.10.1", "wasm-bindgen", ] +[[package]] +name = "group" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63" +dependencies = [ + "ff", + "rand_core 0.6.4", + "subtle", +] + [[package]] name = "hashbrown" version = "0.17.1" @@ -716,6 +867,24 @@ version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" +[[package]] +name = "hkdf" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" +dependencies = [ + "hmac 0.12.1", +] + +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest 0.10.7", +] + [[package]] name = "hmac" version = "0.13.0" @@ -1062,11 +1231,36 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "jsonwebtoken" +version = "11.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e75fe14a82d81e5f5af639997db37d8b96045938a7ac6ab18cdbe1c7467e05e1" +dependencies = [ + "base64 0.22.1", + "ed25519-dalek", + "getrandom 0.2.17", + "hmac 0.12.1", + "js-sys", + "p256", + "p384", + "rand 0.8.8", + "rsa", + "serde", + "serde_json", + "sha2 0.10.9", + "signature", + "zeroize", +] + [[package]] name = "lazy_static" version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "20870f649af7073d53e38067b2a84312175d56ea15217e1b15bc83506ec50afb" +dependencies = [ + "spin", +] [[package]] name = "libbz2-rs-sys" @@ -1080,6 +1274,12 @@ version = "0.2.191" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "65cd87c9a6d60536580b316ad985726104730a5151c99845f2490428ef91240a" +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + [[package]] name = "linux-raw-sys" version = "0.12.1" @@ -1223,6 +1423,22 @@ dependencies = [ "num-traits", ] +[[package]] +name = "num-bigint-dig" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e661dda6640fad38e827a6d4a310ff4763082116fe217f279885c97f511bb0b7" +dependencies = [ + "lazy_static", + "libm", + "num-integer", + "num-iter", + "num-traits", + "rand 0.8.8", + "smallvec", + "zeroize", +] + [[package]] name = "num-conv" version = "0.2.2" @@ -1238,6 +1454,16 @@ dependencies = [ "num-traits", ] +[[package]] +name = "num-iter" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c92800bd69a1eac91786bcfe9da64a897eb72911b8dc3095decbd07429e8048b" +dependencies = [ + "num-integer", + "num-traits", +] + [[package]] name = "num-traits" version = "0.2.19" @@ -1245,6 +1471,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" dependencies = [ "autocfg", + "libm", ] [[package]] @@ -1328,7 +1555,8 @@ dependencies = [ "futures-util", "getrandom 0.4.3", "hex", - "hmac", + "hmac 0.13.0", + "jsonwebtoken", "log", "md-5", "once_cell", @@ -1356,6 +1584,30 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" +[[package]] +name = "p256" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9863ad85fa8f4460f9c48cb909d38a0d689dba1f6f6988a5e3e0d31071bcd4b" +dependencies = [ + "ecdsa", + "elliptic-curve", + "primeorder", + "sha2 0.10.9", +] + +[[package]] +name = "p384" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe42f1670a52a47d448f14b6a5c61dd78fce51856e68edaa38f7ae3a46b8d6b6" +dependencies = [ + "ecdsa", + "elliptic-curve", + "primeorder", + "sha2 0.10.9", +] + [[package]] name = "parking_lot" version = "0.12.5" @@ -1396,7 +1648,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "112d82ceb8c5bf524d9af484d4e4970c9fd5a0cc15ba14ad93dccd28873b0629" dependencies = [ "digest 0.11.3", - "hmac", + "hmac 0.13.0", ] [[package]] @@ -1409,6 +1661,15 @@ dependencies = [ "serde_core", ] +[[package]] +name = "pem-rfc7468" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412" +dependencies = [ + "base64ct", +] + [[package]] name = "percent-encoding" version = "2.3.2" @@ -1485,6 +1746,27 @@ version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" +[[package]] +name = "pkcs1" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8ffb9f10fa047879315e6625af03c164b16962a5368d724ed16323b68ace47f" +dependencies = [ + "der", + "pkcs8", + "spki", +] + +[[package]] +name = "pkcs8" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" +dependencies = [ + "der", + "spki", +] + [[package]] name = "pkg-config" version = "0.3.34" @@ -1522,6 +1804,24 @@ version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "494b4379484fc92217cc7d9e3a532375b1d01a61889ec8333025dc3b0377af56" +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "primeorder" +version = "0.13.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "353e1ca18966c16d9deb1c69278edbc5f194139612772bd9537af60ac231e1e6" +dependencies = [ + "elliptic-curve", +] + [[package]] name = "proc-macro2" version = "1.0.107" @@ -1581,7 +1881,7 @@ dependencies = [ "bytes", "getrandom 0.4.3", "lru-slab", - "rand", + "rand 0.10.3", "rand_pcg", "ring", "rustc-hash", @@ -1632,6 +1932,17 @@ dependencies = [ "ptr_meta", ] +[[package]] +name = "rand" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e058c7de0b26af77780c769414d6257830bb240f3c38477dbc2c16e5f54d6d4c" +dependencies = [ + "libc", + "rand_chacha", + "rand_core 0.6.4", +] + [[package]] name = "rand" version = "0.10.3" @@ -1640,7 +1951,26 @@ checksum = "65c9fb96cbc91e3478eaae79a69fcd3f1ae4ad052e471fe6732fff548984b4af" dependencies = [ "chacha20", "getrandom 0.4.3", - "rand_core", + "rand_core 0.10.1", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core 0.6.4", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", ] [[package]] @@ -1655,7 +1985,7 @@ version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a" dependencies = [ - "rand_core", + "rand_core 0.10.1", ] [[package]] @@ -1761,6 +2091,16 @@ dependencies = [ "web-sys", ] +[[package]] +name = "rfc6979" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dd2a808d456c4a54e300a23e9f5a67e122c3024119acbfd73e3bf664491cb2" +dependencies = [ + "hmac 0.12.1", + "subtle", +] + [[package]] name = "ring" version = "0.17.14" @@ -1805,6 +2145,26 @@ dependencies = [ "syn 3.0.9", ] +[[package]] +name = "rsa" +version = "0.9.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8573f03f5883dcaebdfcf4725caa1ecb9c15b2ef50c43a07b816e06799bb12d" +dependencies = [ + "const-oid 0.9.6", + "digest 0.10.7", + "num-bigint-dig", + "num-integer", + "num-traits", + "pkcs1", + "pkcs8", + "rand_core 0.6.4", + "signature", + "spki", + "subtle", + "zeroize", +] + [[package]] name = "rustc-hash" version = "2.1.3" @@ -1954,6 +2314,20 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "sec1" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc" +dependencies = [ + "base16ct", + "der", + "generic-array", + "pkcs8", + "subtle", + "zeroize", +] + [[package]] name = "security-framework" version = "3.7.0" @@ -2077,6 +2451,16 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" +[[package]] +name = "signature" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" +dependencies = [ + "digest 0.10.7", + "rand_core 0.6.4", +] + [[package]] name = "simd-adler32" version = "0.3.10" @@ -2127,6 +2511,22 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "spin" +version = "0.9.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3763264f6b73151db08c50ff20d7d8a0b8796e021cdea7ceedad07b80155fa0e" + +[[package]] +name = "spki" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" +dependencies = [ + "base64ct", + "der", +] + [[package]] name = "stable_deref_trait" version = "1.2.1" @@ -2470,7 +2870,7 @@ dependencies = [ "http", "httparse", "log", - "rand", + "rand 0.10.3", "rustls", "rustls-pki-types", "sha1", @@ -2995,6 +3395,26 @@ dependencies = [ "synstructure 0.14.0", ] +[[package]] +name = "zerocopy" +version = "0.8.63" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5fe1f8f1b06191a00962174c61aa5005e0bb391a6d80d07e24d115c01a92ed8" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.63" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "863ad3ac83293fb4d740aedbfdc9240dd8d1a50c1099acd76ce80ce7c7230c7f" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "zerofrom" version = "0.1.8" @@ -3082,7 +3502,7 @@ dependencies = [ "deflate64", "flate2", "getrandom 0.4.3", - "hmac", + "hmac 0.13.0", "indexmap", "lzma-rust2", "memchr", diff --git a/openless-all/app/src-tauri/src/android/mod.rs b/openless-all/app/src-tauri/src/android/mod.rs index eedb949bf..d5da8bbcb 100644 --- a/openless-all/app/src-tauri/src/android/mod.rs +++ b/openless-all/app/src-tauri/src/android/mod.rs @@ -41,3 +41,25 @@ pub use shizuku::{ recover_android_accessibility, request_android_shizuku_permission, AndroidShizukuOpenResult, AndroidShizukuPermissionResult, }; + +#[cfg(target_os = "android")] +pub fn siwc_authorization_lease(acquire: bool) -> Result<(), String> { + jni::android::with_android_env(|env, context| { + let result = jni::android::call_static_void_with_context_class( + env, + context, + "com.openless.app.OpenLessRuntimeService", + if acquire { + "acquireAuthorization" + } else { + "releaseAuthorization" + }, + "(Landroid/content/Context;)V", + &[::jni::objects::JValue::Object(context)], + ); + if result.is_err() { + let _ = env.exception_clear(); + } + result + }) +} diff --git a/openless-all/app/src-tauri/src/commands/credentials.rs b/openless-all/app/src-tauri/src/commands/credentials.rs index dc4a7bc54..2d073e7b9 100644 --- a/openless-all/app/src-tauri/src/commands/credentials.rs +++ b/openless-all/app/src-tauri/src/commands/credentials.rs @@ -16,6 +16,7 @@ const CLOUD_SYNC_CUSTOM_TOKEN_ACCOUNT: &str = pub(crate) struct SystemCredentialStore { model_store: Option>, directory: openless_core::credentials::CredentialDirectory, + siwc: std::sync::Arc, } impl SystemCredentialStore { @@ -25,6 +26,7 @@ impl SystemCredentialStore { > = std::sync::Arc::new(SystemCredentialMetadataStore); Self { model_store, + siwc: crate::persistence::siwc::siwc_service(), directory: openless_core::credentials::CredentialDirectory::new(metadata_store), } } @@ -65,7 +67,9 @@ impl openless_core::credentials::CredentialMetadataStore for SystemCredentialMet pub(crate) fn sync_active_asr_provider_to_vault(provider: &str) -> Result<(), String> { #[cfg(target_os = "macos")] if openless_core::credentials::is_retired_macos_asr_provider(provider) { - return Err("Legacy macOS local ASR is retired; use Experimental local speech models".into()); + return Err( + "Legacy macOS local ASR is retired; use Experimental local speech models".into(), + ); } if CredentialsVault::get_active_asr() == provider { return Ok(()); @@ -90,6 +94,10 @@ pub(crate) fn active_sherpa_asr_is_supported(provider: &str) -> bool { } impl openless_core::CredentialStore for SystemCredentialStore { + fn siwc(&self) -> Option> { + Some(self.siwc.clone()) + } + fn bind_sync_gate( &self, gate: std::sync::Arc, @@ -232,17 +240,39 @@ impl openless_core::CredentialStore for SystemCredentialStore { { use openless_core::{ChannelKind, ChannelMutation}; let provider = match &mutation { - ChannelMutation::Create { kind: ChannelKind::Asr, provider_type, .. } - | ChannelMutation::SetProviderType { kind: ChannelKind::Asr, provider_type, .. } - | ChannelMutation::ActivateLocalAsr { provider_type, .. } => Some(provider_type.clone()), - ChannelMutation::SetEnabled { kind: ChannelKind::Asr, id, enabled: true } => - directory.list_channels(ChannelKind::Asr).await?.into_iter() - .find(|c| &c.id == id).map(|c| c.provider_type), + ChannelMutation::Create { + kind: ChannelKind::Asr, + provider_type, + .. + } + | ChannelMutation::SetProviderType { + kind: ChannelKind::Asr, + provider_type, + .. + } + | ChannelMutation::ActivateLocalAsr { provider_type, .. } => { + Some(provider_type.clone()) + } + ChannelMutation::SetEnabled { + kind: ChannelKind::Asr, + id, + enabled: true, + } => directory + .list_channels(ChannelKind::Asr) + .await? + .into_iter() + .find(|c| &c.id == id) + .map(|c| c.provider_type), _ => None, }; - if provider.as_deref().is_some_and(openless_core::credentials::is_retired_macos_asr_provider) { - return Err(openless_core::BackendError::new(openless_core::BackendErrorCode::Unsupported, - "Legacy macOS local ASR is retired; use Experimental local speech models")); + if provider + .as_deref() + .is_some_and(openless_core::credentials::is_retired_macos_asr_provider) + { + return Err(openless_core::BackendError::new( + openless_core::BackendErrorCode::Unsupported, + "Legacy macOS local ASR is retired; use Experimental local speech models", + )); } } directory.mutate_channel(mutation).await @@ -266,12 +296,18 @@ impl openless_core::CredentialStore for SystemCredentialStore { Box::pin(async move { #[cfg(target_os = "macos")] if slot == openless_core::ProviderSlot::Asr { - let provider = directory.list_channels(openless_core::ChannelKind::Asr).await? - .into_iter().find(|c| c.id == provider_id) - .map(|c| c.provider_type).unwrap_or_else(|| provider_id.clone()); + let provider = directory + .list_channels(openless_core::ChannelKind::Asr) + .await? + .into_iter() + .find(|c| c.id == provider_id) + .map(|c| c.provider_type) + .unwrap_or_else(|| provider_id.clone()); if openless_core::credentials::is_retired_macos_asr_provider(&provider) { - return Err(openless_core::BackendError::new(openless_core::BackendErrorCode::Unsupported, - "Legacy macOS local ASR is retired; use Experimental local speech models")); + return Err(openless_core::BackendError::new( + openless_core::BackendErrorCode::Unsupported, + "Legacy macOS local ASR is retired; use Experimental local speech models", + )); } } directory.set_active_provider(slot, provider_id).await @@ -312,7 +348,8 @@ fn credentials_status( let configuration = credential_configuration( &snap, &active_llm_provider, - CodexOAuthCredentials::load_default().is_ok(), + active_llm_provider == openless_core::polish::CODEX_OAUTH_PROVIDER_ID + && CodexOAuthCredentials::load_default().is_ok(), ); let volcengine_configured = openless_core::provider_rules::volcengine_configured(&configuration); @@ -599,7 +636,9 @@ fn local_asr_configured( if provider == "apple-speech" { return Some(cfg!(target_os = "macos")); } - if openless_core::credentials::is_retired_macos_asr_provider(provider) { return Some(false); } + if openless_core::credentials::is_retired_macos_asr_provider(provider) { + return Some(false); + } if let Some(model) = openless_core::experimental_asr::ExperimentalAsrModel::from_provider(provider) { @@ -632,7 +671,8 @@ pub(crate) fn llm_configured_for_provider(provider: &str, snap: &CredentialsSnap &credential_configuration( snap, provider, - CodexOAuthCredentials::load_default().is_ok(), + provider == openless_core::polish::CODEX_OAUTH_PROVIDER_ID + && CodexOAuthCredentials::load_default().is_ok(), ), ) } @@ -1058,11 +1098,9 @@ mod tests { local_asr_configured(provider, Some(&store), Some(model)), Some(false) ); - let target = openless_core::LocalAsrTarget::parse( - openless_core::LocalAsrRuntime::SherpaOnnx, - model, - ) - .unwrap(); + let target = + openless_core::LocalAsrTarget::parse(openless_core::LocalAsrRuntime::SherpaOnnx, model) + .unwrap(); let model_dir = store.model_dir(&target).unwrap(); std::fs::create_dir_all(&model_dir).unwrap(); std::fs::write( diff --git a/openless-all/app/src-tauri/src/commands/mod.rs b/openless-all/app/src-tauri/src/commands/mod.rs index 6c060e01f..b0ba7db77 100644 --- a/openless-all/app/src-tauri/src/commands/mod.rs +++ b/openless-all/app/src-tauri/src/commands/mod.rs @@ -81,6 +81,7 @@ mod selection_voice; mod settings; #[cfg(any(not(mobile), local_asr_sherpa))] mod sherpa_asr; +mod siwc; mod style_packs; mod voice_edit; @@ -112,13 +113,14 @@ pub use selection_polish::*; pub use selection_polish_preview::*; #[cfg(all(not(mobile), target_os = "windows"))] pub use selection_voice::*; -pub use settings::*; pub(crate) use settings::sync_low_latency_capture_preferences; +pub use settings::*; #[cfg(any(not(mobile), local_asr_sherpa))] // Sherpa commands are registered on Windows and arm64 Android; other desktop // targets keep an empty re-export. #[allow(unused_imports)] pub use sherpa_asr::*; +pub use siwc::*; pub use style_packs::*; pub use voice_edit::*; diff --git a/openless-all/app/src-tauri/src/commands/providers.rs b/openless-all/app/src-tauri/src/commands/providers.rs index f475ff78a..4708435c7 100644 --- a/openless-all/app/src-tauri/src/commands/providers.rs +++ b/openless-all/app/src-tauri/src/commands/providers.rs @@ -35,7 +35,14 @@ pub async fn validate_provider_credentials( thinking_enabled: core.get_preferences().llm_thinking_enabled, }) .await - .map_err(|error| error.message) + .map_err(|error| { + error + .details + .as_ref() + .and_then(|details| details.get("siwc")) + .map(|details| format!("siwc:{details}")) + .unwrap_or(error.message) + }) } #[tauri::command] @@ -53,7 +60,14 @@ pub async fn list_provider_models( thinking_enabled: core.get_preferences().llm_thinking_enabled, }) .await - .map_err(|error| error.message) + .map_err(|error| { + error + .details + .as_ref() + .and_then(|details| details.get("siwc")) + .map(|details| format!("siwc:{details}")) + .unwrap_or(error.message) + }) } #[cfg(test)] diff --git a/openless-all/app/src-tauri/src/commands/siwc.rs b/openless-all/app/src-tauri/src/commands/siwc.rs new file mode 100644 index 000000000..9bb9cfba5 --- /dev/null +++ b/openless-all/app/src-tauri/src/commands/siwc.rs @@ -0,0 +1,147 @@ +use super::*; + +#[tauri::command] +pub async fn siwc_status( + core: CoreState<'_>, + window: Window, + channel_id: String, +) -> Result { + credentials::ensure_main_window(&window)?; + let service = core + .siwc_for_channel(&channel_id) + .await + .map_err(|e| e.to_string())?; + let status = service + .status(&channel_id) + .await + .map_err(|e| e.to_string())?; + core.refresh_and_publish_credentials() + .await + .map_err(|e| e.to_string())?; + Ok(status) +} +#[tauri::command] +pub async fn siwc_models( + core: CoreState<'_>, + window: Window, + channel_id: String, +) -> Result, String> { + credentials::ensure_main_window(&window)?; + core.siwc_for_channel(&channel_id) + .await + .map_err(|e| e.to_string())? + .list_models(&channel_id) + .await + .map_err(|e| e.to_string()) +} +#[tauri::command] +pub async fn siwc_action( + core: CoreState<'_>, + window: Window, + app: AppHandle, + channel_id: String, + action: String, + registration_id: Option, +) -> Result { + credentials::ensure_main_window(&window)?; + let service = core + .siwc_for_channel(&channel_id) + .await + .map_err(|e| e.to_string())?; + if matches!( + action.as_str(), + "sign_in" | "add_account" | "enable_usage" | "select" | "disconnect" + ) { + core.invalidate_channel_test(openless_core::ChannelKind::Llm, channel_id.clone()) + .await + .map_err(|e| e.to_string())?; + } + let result = match action.as_str() { + "sign_in" | "add_account" | "enable_usage" => { + let launch = core + .begin_siwc_sign_in( + channel_id.clone(), + action == "add_account", + action == "enable_usage", + ) + .await + .map_err(|e| e.to_string())?; + #[cfg(target_os = "android")] + { + if crate::android::siwc_authorization_lease(true).is_err() { + let _ = service.cancel_sign_in(&channel_id).await; + return Err(openless_core::siwc::error("browser_failed").to_string()); + } + let service = service.clone(); + let channel = channel_id.clone(); + let attempt = launch.attempt_id.clone(); + tokio::spawn(async move { + while service.authorizing(&channel, &attempt) { + tokio::time::sleep(std::time::Duration::from_millis(250)).await; + } + let _ = crate::android::siwc_authorization_lease(false); + }); + } + #[cfg(not(target_os = "android"))] + let opened = { + use tauri_plugin_shell::ShellExt; + app.shell() + .open(launch.authorization_url(), None) + .map_err(|_| ()) + }; + #[cfg(target_os = "android")] + let opened = { + let _ = app; + crate::external_url::open_external_url(launch.authorization_url()).map_err(|_| ()) + }; + if opened.is_err() { + let _ = service.cancel_sign_in(&channel_id).await; + return Err(openless_core::siwc::error("browser_failed").to_string()); + } + serde_json::json!({"attemptId": launch.attempt_id}) + } + "cancel" => { + service + .cancel_sign_in(&channel_id) + .await + .map_err(|e| e.to_string())?; + serde_json::Value::Null + } + "select" => { + service + .select_account( + channel_id.clone(), + registration_id.ok_or("registrationId required")?, + ) + .await + .map_err(|e| e.to_string())?; + serde_json::Value::Null + } + "disconnect" => serde_json::to_value( + service + .disconnect(&channel_id) + .await + .map_err(|e| e.to_string())?, + ) + .map_err(|_| "siwc result invalid")?, + "acknowledge" => { + service + .acknowledge_welcome(channel_id.clone()) + .await + .map_err(|e| e.to_string())?; + serde_json::Value::Null + } + "retry" => { + service + .retry_usage(channel_id.clone()) + .await + .map_err(|e| e.to_string())?; + serde_json::Value::Null + } + _ => return Err("unknown SIWC action".into()), + }; + core.refresh_and_publish_credentials() + .await + .map_err(|e| e.to_string())?; + Ok(result) +} diff --git a/openless-all/app/src-tauri/src/external_url.rs b/openless-all/app/src-tauri/src/external_url.rs index aa492a2c8..85e0798e0 100644 --- a/openless-all/app/src-tauri/src/external_url.rs +++ b/openless-all/app/src-tauri/src/external_url.rs @@ -57,7 +57,11 @@ fn platform_open_external_url(url: &str) -> Result<(), String> { "(Landroid/content/Intent;)V", &[JValue::Object(&intent)], ) - .map_err(|error| format!("start Android URL activity: {error}"))?; + .map_err(|error| { + // A failed browser Intent must not poison the pooled JNI thread or log its URL. + let _ = env.exception_clear(); + format!("start Android URL activity: {error}") + })?; Ok(()) }) diff --git a/openless-all/app/src-tauri/src/lib.rs b/openless-all/app/src-tauri/src/lib.rs index e86153098..ec0911225 100644 --- a/openless-all/app/src-tauri/src/lib.rs +++ b/openless-all/app/src-tauri/src/lib.rs @@ -206,22 +206,33 @@ pub fn run() { // system credential vault, but exercises the actual shared/native engines. // This entry point does not exist in production release builds. #[cfg(all(debug_assertions, target_os = "macos", target_arch = "aarch64"))] - if std::env::args_os().nth(1).as_deref() == Some(std::ffi::OsStr::new("--openless-local-asr-check")) { + if std::env::args_os().nth(1).as_deref() + == Some(std::ffi::OsStr::new("--openless-local-asr-check")) + { let args: Vec<_> = std::env::args_os().skip(2).collect(); if !(args.len() == 2 || (args.len() == 3 && args[2] == "--download")) { eprintln!("usage: openless --openless-local-asr-check <16kHz mono WAV> [--download]"); std::process::exit(2); } - let runtime = tokio::runtime::Builder::new_multi_thread().enable_all().build().expect("acceptance runtime"); + let runtime = tokio::runtime::Builder::new_multi_thread() + .enable_all() + .build() + .expect("acceptance runtime"); match runtime.block_on(crate::asr::local::experimental::acceptance::run( - args[0].clone().into(), args[1].clone().into(), args.len() == 3, + args[0].clone().into(), + args[1].clone().into(), + args.len() == 3, )) { Ok(()) => return, - Err(error) => { eprintln!("local ASR acceptance failed: {error:#}"); std::process::exit(1); } + Err(error) => { + eprintln!("local ASR acceptance failed: {error:#}"); + std::process::exit(1); + } } } // Old installations/launchers must not revive the retired native worker. - if std::env::args_os().nth(1).as_deref() == Some(std::ffi::OsStr::new("--openless-mlx-worker")) { + if std::env::args_os().nth(1).as_deref() == Some(std::ffi::OsStr::new("--openless-mlx-worker")) + { eprintln!("Legacy local ASR worker has been retired"); return; } @@ -450,6 +461,9 @@ macro_rules! app_invoke_handler_desktop { commands::list_provider_descriptors, commands::validate_provider_credentials, commands::list_provider_models, + commands::siwc_status, + commands::siwc_models, + commands::siwc_action, commands::experimental_asr_status, commands::experimental_asr_set_enabled, commands::experimental_asr_delete, @@ -580,6 +594,9 @@ macro_rules! app_invoke_handler_mobile { $crate::commands::list_provider_descriptors, $crate::commands::validate_provider_credentials, $crate::commands::list_provider_models, + $crate::commands::siwc_status, + $crate::commands::siwc_models, + $crate::commands::siwc_action, $crate::commands::list_history, $crate::commands::delete_history_entry, $crate::commands::clear_history, @@ -3443,7 +3460,11 @@ pub(crate) fn show_less_computer_window( let result = app_for_show .try_state::>() .ok_or_else(|| "Backend is not initialized".to_string()) - .and_then(|backend| backend.ensure_testing_features_enabled().map_err(|error| error.message)) + .and_then(|backend| { + backend + .ensure_testing_features_enabled() + .map_err(|error| error.message) + }) .and_then(|()| { window.show().map_err(|error| { log::warn!("[less-computer] show failed: {error}"); @@ -3452,7 +3473,8 @@ pub(crate) fn show_less_computer_window( }); if result.is_ok() { LESS_COMPUTER_PANEL_EPOCH.fetch_add(1, Ordering::SeqCst); - let _ = app_for_show.emit_to("less-computer", "chat-panel:shown", serde_json::json!({})); + let _ = + app_for_show.emit_to("less-computer", "chat-panel:shown", serde_json::json!({})); } let _ = result_tx.send(result); }) diff --git a/openless-all/app/src-tauri/src/persistence/mod.rs b/openless-all/app/src-tauri/src/persistence/mod.rs index c274ee018..a411a3bda 100644 --- a/openless-all/app/src-tauri/src/persistence/mod.rs +++ b/openless-all/app/src-tauri/src/persistence/mod.rs @@ -34,6 +34,7 @@ mod dictionary; mod history; mod paths; mod preferences; +pub(crate) mod siwc; mod style_pack; pub use activity::*; diff --git a/openless-all/app/src-tauri/src/persistence/siwc.rs b/openless-all/app/src-tauri/src/persistence/siwc.rs new file mode 100644 index 000000000..21fd380e7 --- /dev/null +++ b/openless-all/app/src-tauri/src/persistence/siwc.rs @@ -0,0 +1,178 @@ +//! Separate vault namespace: SIWC sessions are never part of provider exports or backups. +use openless_core::{ + siwc::{error, locked_head, SiwcError, SiwcService, SiwcVault}, + SecretValue, +}; +use std::sync::Arc; +struct NativeSiwcVault; +pub(crate) fn siwc_service() -> Arc { + SiwcService::new(Arc::new(NativeSiwcVault)) +} +fn check_key(key: &str) -> Result<(), SiwcError> { + if key.len() <= 100 + && !key.is_empty() + && key + .bytes() + .all(|b| b.is_ascii_alphanumeric() || b".-".contains(&b)) + { + Ok(()) + } else { + Err(error("storage_failed")) + } +} +#[cfg(not(target_os = "android"))] +fn entry(key: &str) -> Result { + check_key(key)?; + keyring::Entry::new("com.openless.app.siwc", key).map_err(|_| error("storage_failed")) +} +#[cfg(target_os = "android")] +fn item_path(key: &str) -> Result { + check_key(key)?; + let directory = super::data_dir() + .map_err(|_| error("storage_failed"))? + .join("siwc-local"); + std::fs::create_dir_all(&directory).map_err(|_| error("storage_failed"))?; + Ok(directory.join(format!("{key}.enc.json"))) +} +impl SiwcVault for NativeSiwcVault { + fn read(&self, key: &str) -> Result, SiwcError> { + #[cfg(target_os = "macos")] + { + Ok(macos_values()?.remove(key).map(SecretValue::new)) + } + #[cfg(not(any(target_os = "android", target_os = "macos")))] + { + match entry(key)?.get_password() { + Ok(value) => Ok(Some(SecretValue::new(value))), + Err(keyring::Error::NoEntry) => Ok(None), + Err(_) => Err(error("storage_unavailable")), + } + } + #[cfg(target_os = "android")] + { + use super::android_credentials::{read_only, AndroidKeystoreCrypto, ReadOutcome}; + let bytes = match read_only(&item_path(key)?, &mut AndroidKeystoreCrypto) + .map_err(|_| error("storage_unavailable"))? + { + ReadOutcome::Missing => return Ok(None), + ReadOutcome::Plaintext(bytes) => zeroize::Zeroizing::new(bytes), + ReadOutcome::Legacy(_) => return Err(error("storage_corrupt")), + }; + let record: serde_json::Value = + serde_json::from_slice(&bytes).map_err(|_| error("storage_corrupt"))?; + if record["key"] != key { + return Err(error("storage_corrupt")); + } + Ok(Some(SecretValue::new( + record["value"] + .as_str() + .ok_or_else(|| error("storage_corrupt"))?, + ))) + } + } + fn write(&self, key: &str, value: &str) -> Result<(), SiwcError> { + #[cfg(target_os = "macos")] + { + macos_update(|values| { + values.insert(key.into(), value.into()); + Ok(()) + }) + } + #[cfg(not(any(target_os = "android", target_os = "macos")))] + { + entry(key)? + .set_password(value) + .map_err(|_| error("storage_failed")) + } + #[cfg(target_os = "android")] + { + let bytes = zeroize::Zeroizing::new( + serde_json::to_vec(&serde_json::json!({"key":key,"value":value})) + .map_err(|_| error("storage_failed"))?, + ); + super::android_credentials::write_verified( + &item_path(key)?, + &bytes, + &mut super::android_credentials::AndroidKeystoreCrypto, + ) + .map_err(|_| error("storage_failed")) + } + } + fn remove(&self, key: &str) -> Result<(), SiwcError> { + #[cfg(target_os = "macos")] + { + macos_update(|values| { + values.remove(key); + Ok(()) + }) + } + #[cfg(not(any(target_os = "android", target_os = "macos")))] + { + match entry(key)?.delete_credential() { + Ok(()) | Err(keyring::Error::NoEntry) => Ok(()), + Err(_) => Err(error("storage_failed")), + } + } + #[cfg(target_os = "android")] + { + super::android_credentials::secure_remove(&item_path(key)?) + .map_err(|_| error("storage_failed")) + } + } + fn compare_head(&self, expected: Option<&str>, next: &str) -> Result { + #[cfg(target_os = "macos")] + { + macos_update(|values| { + if values.get("head").map(String::as_str) != expected { + return Ok(false); + } + values.insert("head".into(), next.into()); + Ok(true) + }) + } + #[cfg(not(target_os = "macos"))] + { + let lock = super::data_dir() + .map_err(|_| error("storage_failed"))? + .join("siwc-local") + .join("transaction.lock"); + locked_head(&lock, || { + if self.read("head")?.as_ref().map(SecretValue::expose_secret) != expected { + return Ok(false); + } + self.write("head", next)?; + Ok(true) + }) + } + } +} + +#[cfg(target_os = "macos")] +fn macos_values() -> Result, SiwcError> { + let raw = match entry("vault.v1")?.get_password() { + Ok(raw) => zeroize::Zeroizing::new(raw), + Err(keyring::Error::NoEntry) => return Ok(Default::default()), + Err(_) => return Err(error("storage_unavailable")), + }; + serde_json::from_str(&raw).map_err(|_| error("storage_corrupt")) +} +#[cfg(target_os = "macos")] +fn macos_update( + update: impl FnOnce(&mut std::collections::BTreeMap) -> Result, +) -> Result { + let lock = super::data_dir() + .map_err(|_| error("storage_failed"))? + .join("siwc-local/transaction.lock"); + locked_head(&lock, || { + let mut values = macos_values()?; + let result = update(&mut values)?; + let payload = zeroize::Zeroizing::new( + serde_json::to_string(&values).map_err(|_| error("storage_failed"))?, + ); + // Keep one stable Keychain item instead of prompting for every generation's chunks. + entry("vault.v1")? + .set_password(&payload) + .map_err(|_| error("storage_failed"))?; + Ok(result) + }) +} diff --git a/openless-all/app/src/i18n/de.ts b/openless-all/app/src/i18n/de.ts index ac3e6e9ac..e05aff4fa 100644 --- a/openless-all/app/src/i18n/de.ts +++ b/openless-all/app/src/i18n/de.ts @@ -1,9 +1,11 @@ +import { siwcDe as siwc } from './siwc'; import type { zhCN } from './zh-CN'; import { uiRefinementEn as uiRefinement } from './uiRefinement'; import { experimentalAsrEn as experimentalAsr } from './experimentalAsr'; export const de: typeof zhCN = { + siwc, experimentalAsr, uiRefinement, voiceEdit: { @@ -1788,6 +1790,7 @@ export const de: typeof zhCN = { openai: 'OpenAI', gemini: 'Google Gemini', codexOAuth: 'Codex OAuth', + chatgptSiwc: 'ChatGPT Plan', mimo: 'Xiaomi MiMo', cometapi: 'CometAPI', openrouterFree: 'OpenRouter (kostenlose Modelle)', diff --git a/openless-all/app/src/i18n/en.ts b/openless-all/app/src/i18n/en.ts index 304d39dec..d876c5440 100644 --- a/openless-all/app/src/i18n/en.ts +++ b/openless-all/app/src/i18n/en.ts @@ -1,3 +1,4 @@ +import { siwcEn as siwc } from './siwc'; // English resources — translated from zh-CN.ts. Keep keys in sync. import type { zhCN } from './zh-CN'; @@ -7,6 +8,7 @@ import { uiRefinementEn as uiRefinement } from './uiRefinement'; import { experimentalAsrEn as experimentalAsr } from './experimentalAsr'; export const en: typeof zhCN = { + siwc, experimentalAsr, uiRefinement, voiceEdit: { @@ -1743,6 +1745,7 @@ export const en: typeof zhCN = { openai: 'OpenAI', gemini: 'Google Gemini', codexOAuth: 'Codex OAuth', + chatgptSiwc: 'ChatGPT Plan', mimo: 'Xiaomi MiMo', cometapi: 'CometAPI', openrouterFree: 'OpenRouter (free models)', diff --git a/openless-all/app/src/i18n/es.ts b/openless-all/app/src/i18n/es.ts index 831db88b6..50c5639fb 100644 --- a/openless-all/app/src/i18n/es.ts +++ b/openless-all/app/src/i18n/es.ts @@ -1,9 +1,11 @@ +import { siwcEs as siwc } from './siwc'; import type { zhCN } from './zh-CN'; import { uiRefinementEn as uiRefinement } from './uiRefinement'; import { experimentalAsrEn as experimentalAsr } from './experimentalAsr'; export const es: typeof zhCN = { + siwc, experimentalAsr, uiRefinement, voiceEdit: { @@ -1776,6 +1778,7 @@ export const es: typeof zhCN = { openai: 'OpenAI', gemini: 'Google Gemini', codexOAuth: 'Codex OAuth', + chatgptSiwc: 'ChatGPT Plan', mimo: 'Xiaomi MiMo', cometapi: 'CometAPI', openrouterFree: 'OpenRouter (modelos gratuitos)', diff --git a/openless-all/app/src/i18n/fr.ts b/openless-all/app/src/i18n/fr.ts index 5a49334dc..c75fd0462 100644 --- a/openless-all/app/src/i18n/fr.ts +++ b/openless-all/app/src/i18n/fr.ts @@ -1,9 +1,11 @@ +import { siwcFr as siwc } from './siwc'; import type { zhCN } from './zh-CN'; import { uiRefinementEn as uiRefinement } from './uiRefinement'; import { experimentalAsrEn as experimentalAsr } from './experimentalAsr'; export const fr: typeof zhCN = { + siwc, experimentalAsr, uiRefinement, voiceEdit: { @@ -1801,6 +1803,7 @@ export const fr: typeof zhCN = { openai: 'OpenAI', gemini: 'Google Gemini', codexOAuth: 'Codex OAuth', + chatgptSiwc: 'ChatGPT Plan', mimo: 'Xiaomi MiMo', cometapi: 'CometAPI', openrouterFree: 'OpenRouter (modèles gratuits)', diff --git a/openless-all/app/src/i18n/ja.ts b/openless-all/app/src/i18n/ja.ts index c894554ed..42b5276b4 100644 --- a/openless-all/app/src/i18n/ja.ts +++ b/openless-all/app/src/i18n/ja.ts @@ -1,3 +1,4 @@ +import { siwcJa as siwc } from './siwc'; import type { zhCN } from './zh-CN'; import { en } from './en'; @@ -9,6 +10,7 @@ import { experimentalAsrEn as experimentalAsr } from './experimentalAsr'; export const ja: typeof zhCN = { ...en, + siwc, experimentalAsr, uiRefinement, voiceEdit: { @@ -1731,6 +1733,7 @@ export const ja: typeof zhCN = { openai: 'OpenAI', gemini: 'Google Gemini', codexOAuth: 'Codex OAuth', + chatgptSiwc: 'ChatGPT Plan', mimo: 'Xiaomi MiMo', cometapi: 'CometAPI', openrouterFree: 'OpenRouter(無料モデル)', diff --git a/openless-all/app/src/i18n/ko.ts b/openless-all/app/src/i18n/ko.ts index 59897bc5a..918e39e27 100644 --- a/openless-all/app/src/i18n/ko.ts +++ b/openless-all/app/src/i18n/ko.ts @@ -1,3 +1,4 @@ +import { siwcKo as siwc } from './siwc'; import type { zhCN } from './zh-CN'; import { en } from './en'; @@ -9,6 +10,7 @@ import { experimentalAsrEn as experimentalAsr } from './experimentalAsr'; export const ko: typeof zhCN = { ...en, + siwc, experimentalAsr, uiRefinement, voiceEdit: { @@ -1719,6 +1721,7 @@ export const ko: typeof zhCN = { openai: 'OpenAI', gemini: 'Google Gemini', codexOAuth: 'Codex OAuth', + chatgptSiwc: 'ChatGPT Plan', mimo: 'Xiaomi MiMo', cometapi: 'CometAPI', openrouterFree: 'OpenRouter(무료 모델)', diff --git a/openless-all/app/src/i18n/siwc.ts b/openless-all/app/src/i18n/siwc.ts new file mode 100644 index 000000000..ab7589924 --- /dev/null +++ b/openless-all/app/src/i18n/siwc.ts @@ -0,0 +1,776 @@ +// Shared SIWC copy; backend errors contain only safe diagnostic codes. +export const siwcZhCN = { + description: + '符合条件的文本润色和翻译使用你的 ChatGPT 计划或积分余额。ASR 仍使用原渠道;额度不足时不会自动切换到 API Key。', + account: 'ChatGPT 账号', + cancel: '取消登录', + enableUsage: '授权计划用量', + reauthorize: '重新授权', + addAccount: '添加账号或工作区', + disconnect: '断开连接', + manageUsage: '管理用量', + retry: '重试计划用量', + model: '润色模型', + selectModel: '选择账号可用模型', + modelUnavailable: '模型 {{model}} 当前不可用,请重新选择。', + noModels: '该账号暂时没有可显示的模型。', + refreshModels: '刷新模型', + test: '测试连接', + testHint: '获取模型列表不执行推理;测试连接会使用少量 ChatGPT 计划用量。', + testSuccess: '连接测试成功', + remoteUnconfirmed: '已在本机断开连接,但未确认远端撤销。请到 ChatGPT 设置中断开此应用。', + welcome: '正在使用你的 ChatGPT 计划', + gotIt: '知道了', + failed: '请求未完成,请检查连接或重新授权。', + states: { + signed_out: '尚未连接 ChatGPT', + authorizing: '请在系统浏览器中完成授权', + connected: '正在使用 ChatGPT 计划', + usage_disabled: '已登录,尚未授权计划用量', + usage_limited: '已达到计划或此应用的用量限制', + reauthorization_required: '需要重新登录 ChatGPT', + }, + errors: { + sign_in_required: '请重新登录 ChatGPT。', + reauthorization_required: '请重新登录 ChatGPT。', + session_changed: '请重新登录 ChatGPT。', + refresh_interrupted: '请重新登录 ChatGPT。', + invalid_grant: '请重新登录 ChatGPT。', + invalid_refresh_token: '请重新登录 ChatGPT。', + token_expired: '请重新登录 ChatGPT。', + refresh_token_expired: '请重新登录 ChatGPT。', + refresh_token_invalidated: '请重新登录 ChatGPT。', + refresh_token_reused: '请重新登录 ChatGPT。', + unauthorized: '请重新登录 ChatGPT。', + subscription_sharing_invalid_user: '请重新登录 ChatGPT。', + usage_disabled: '请授权 OpenLess 使用 ChatGPT 计划用量。', + access_denied: '请授权 OpenLess 使用 ChatGPT 计划用量。', + chatpass_v2_scope_not_authorized: '请授权 OpenLess 使用 ChatGPT 计划用量。', + chatpass_v2_invalid_authorization_context: '请授权 OpenLess 使用 ChatGPT 计划用量。', + subscription_sharing_usage_limit_exceeded: '已达到计划或此应用的用量限制,请前往“管理用量”。', + rate_limited: '已达到计划或此应用的用量限制,请前往“管理用量”。', + subscription_sharing_user_not_eligible: '当前账号、工作区或地区不符合计划用量要求。', + forbidden: '当前账号、工作区或地区不符合计划用量要求。', + network_failed: '暂时无法连接服务,请稍后重试。你的账号信息已保留。', + unavailable: '暂时无法连接服务,请稍后重试。你的账号信息已保留。', + subscription_sharing_usage_unavailable: '暂时无法连接服务,请稍后重试。你的账号信息已保留。', + subscription_sharing_user_unavailable: '暂时无法连接服务,请稍后重试。你的账号信息已保留。', + id_token_invalid: '无法验证本次授权,请重新发起登录。', + id_token_missing: '无法验证本次授权,请重新发起登录。', + issuer_mismatch: '无法验证本次授权,请重新发起登录。', + client_mismatch: '无法验证本次授权,请重新发起登录。', + identity_mismatch: '无法验证本次授权,请重新发起登录。', + callback_invalid: '无法验证本次授权,请重新发起登录。', + state_mismatch: '无法验证本次授权,请重新发起登录。', + registration_incomplete: '无法验证本次授权,请重新发起登录。', + discovery_invalid: '无法验证本次授权,请重新发起登录。', + jwks_invalid: '无法验证本次授权,请重新发起登录。', + token_response_invalid: '无法验证本次授权,请重新发起登录。', + storage_failed: '无法安全读取或保存登录凭据,请检查系统安全存储后重试。', + storage_corrupt: '无法安全读取或保存登录凭据,请检查系统安全存储后重试。', + storage_unavailable: '无法安全读取或保存登录凭据,请检查系统安全存储后重试。', + storage_full: '无法安全读取或保存登录凭据,请检查系统安全存储后重试。', + cancelled: '本次操作已取消。', + authorization_timeout: '授权已超时,请重新登录。', + subscription_sharing_unsupported_capability: '所选模型或请求能力不可用,请刷新模型并重新选择。', + subscription_sharing_route_not_supported: '所选模型或请求能力不可用,请刷新模型并重新选择。', + models_invalid: '所选模型或请求能力不可用,请刷新模型并重新选择。', + empty_response: '所选模型或请求能力不可用,请刷新模型并重新选择。', + busy: '另一个账号操作正在进行,请稍后重试。', + browser_failed: '无法打开系统浏览器,请重试。', + callback_unavailable: '无法打开系统浏览器,请重试。', + }, +}; + +export const siwcEn = { + description: + 'Eligible text polishing and translation use your ChatGPT plan or credits. ASR keeps its existing provider. Usage limits never switch you to an API key automatically.', + account: 'ChatGPT account', + cancel: 'Cancel sign-in', + enableUsage: 'Enable plan usage', + reauthorize: 'Sign in again', + addAccount: 'Add account or workspace', + disconnect: 'Disconnect', + manageUsage: 'Manage usage', + retry: 'Retry plan usage', + model: 'Polishing model', + selectModel: 'Select an available model', + modelUnavailable: '{{model}} is no longer available. Select another model.', + noModels: 'No displayable models are available for this account.', + refreshModels: 'Refresh models', + test: 'Test connection', + testHint: + 'Listing models does not run inference. Testing uses a small amount of your ChatGPT plan.', + testSuccess: 'Connection test succeeded', + remoteUnconfirmed: + 'Signed out locally, but remote revocation was not confirmed. Disconnect this app in ChatGPT settings.', + welcome: 'You’re using your ChatGPT plan', + gotIt: 'Got it', + failed: 'The request did not complete. Check your connection or sign in again.', + states: { + signed_out: 'Not connected to ChatGPT', + authorizing: 'Complete authorization in your system browser', + connected: 'Using ChatGPT plan', + usage_disabled: 'Signed in; plan usage is not enabled', + usage_limited: 'Plan or app usage limit reached', + reauthorization_required: 'Sign in to ChatGPT again', + }, + errors: { + sign_in_required: 'Sign in to ChatGPT again.', + reauthorization_required: 'Sign in to ChatGPT again.', + session_changed: 'Sign in to ChatGPT again.', + refresh_interrupted: 'Sign in to ChatGPT again.', + invalid_grant: 'Sign in to ChatGPT again.', + invalid_refresh_token: 'Sign in to ChatGPT again.', + token_expired: 'Sign in to ChatGPT again.', + refresh_token_expired: 'Sign in to ChatGPT again.', + refresh_token_invalidated: 'Sign in to ChatGPT again.', + refresh_token_reused: 'Sign in to ChatGPT again.', + unauthorized: 'Sign in to ChatGPT again.', + subscription_sharing_invalid_user: 'Sign in to ChatGPT again.', + usage_disabled: 'Allow OpenLess to use your ChatGPT plan.', + access_denied: 'Allow OpenLess to use your ChatGPT plan.', + chatpass_v2_scope_not_authorized: 'Allow OpenLess to use your ChatGPT plan.', + chatpass_v2_invalid_authorization_context: 'Allow OpenLess to use your ChatGPT plan.', + subscription_sharing_usage_limit_exceeded: + 'Your plan or this app has reached its limit. Open Manage usage.', + rate_limited: 'Your plan or this app has reached its limit. Open Manage usage.', + subscription_sharing_user_not_eligible: + 'This account, workspace, or region is not eligible for plan usage.', + forbidden: 'This account, workspace, or region is not eligible for plan usage.', + network_failed: + 'The service is temporarily unavailable. Your account information has been kept. Try again later.', + unavailable: + 'The service is temporarily unavailable. Your account information has been kept. Try again later.', + subscription_sharing_usage_unavailable: + 'The service is temporarily unavailable. Your account information has been kept. Try again later.', + subscription_sharing_user_unavailable: + 'The service is temporarily unavailable. Your account information has been kept. Try again later.', + id_token_invalid: 'This authorization could not be verified. Start a new sign-in.', + id_token_missing: 'This authorization could not be verified. Start a new sign-in.', + issuer_mismatch: 'This authorization could not be verified. Start a new sign-in.', + client_mismatch: 'This authorization could not be verified. Start a new sign-in.', + identity_mismatch: 'This authorization could not be verified. Start a new sign-in.', + callback_invalid: 'This authorization could not be verified. Start a new sign-in.', + state_mismatch: 'This authorization could not be verified. Start a new sign-in.', + registration_incomplete: 'This authorization could not be verified. Start a new sign-in.', + discovery_invalid: 'This authorization could not be verified. Start a new sign-in.', + jwks_invalid: 'This authorization could not be verified. Start a new sign-in.', + token_response_invalid: 'This authorization could not be verified. Start a new sign-in.', + storage_failed: + 'Credentials could not be read or saved securely. Check your system credential store.', + storage_corrupt: + 'Credentials could not be read or saved securely. Check your system credential store.', + storage_unavailable: + 'Credentials could not be read or saved securely. Check your system credential store.', + storage_full: + 'Credentials could not be read or saved securely. Check your system credential store.', + cancelled: 'The operation was cancelled.', + authorization_timeout: 'Authorization timed out. Start a new sign-in.', + subscription_sharing_unsupported_capability: + 'This model or request capability is unavailable. Refresh and select another model.', + subscription_sharing_route_not_supported: + 'This model or request capability is unavailable. Refresh and select another model.', + models_invalid: + 'This model or request capability is unavailable. Refresh and select another model.', + empty_response: + 'This model or request capability is unavailable. Refresh and select another model.', + busy: 'Another account operation is in progress. Try again shortly.', + browser_failed: 'Could not open the system browser. Please retry.', + callback_unavailable: 'Could not open the system browser. Please retry.', + }, +}; + +export const siwcZhTW = { + description: + '符合條件的文字潤飾和翻譯使用你的 ChatGPT 方案或點數。ASR 仍使用原管道;額度不足時不會自動切換至 API Key。', + account: 'ChatGPT 帳號', + cancel: '取消登入', + enableUsage: '授權方案用量', + reauthorize: '重新授權', + addAccount: '新增帳號或工作區', + disconnect: '中斷連線', + manageUsage: '管理用量', + retry: '重試方案用量', + model: '潤飾模型', + selectModel: '選擇帳號可用模型', + modelUnavailable: '模型 {{model}} 目前無法使用,請重新選擇。', + noModels: '此帳號目前沒有可顯示的模型。', + refreshModels: '重新整理模型', + test: '測試連線', + testHint: '取得模型清單不會執行推理;測試連線會使用少量 ChatGPT 方案用量。', + testSuccess: '連線測試成功', + remoteUnconfirmed: '已在本機登出,但未確認遠端撤銷。請至 ChatGPT 設定中中斷此應用程式。', + welcome: '正在使用你的 ChatGPT 方案', + gotIt: '知道了', + failed: '請求未完成,請檢查連線或重新授權。', + states: { + signed_out: '尚未連接 ChatGPT', + authorizing: '請在系統瀏覽器完成授權', + connected: '正在使用 ChatGPT 方案', + usage_disabled: '已登入,尚未授權方案用量', + usage_limited: '已達方案或此應用程式的用量限制', + reauthorization_required: '需要重新登入 ChatGPT', + }, + errors: { + sign_in_required: '請重新登入 ChatGPT。', + reauthorization_required: '請重新登入 ChatGPT。', + session_changed: '請重新登入 ChatGPT。', + refresh_interrupted: '請重新登入 ChatGPT。', + invalid_grant: '請重新登入 ChatGPT。', + invalid_refresh_token: '請重新登入 ChatGPT。', + token_expired: '請重新登入 ChatGPT。', + refresh_token_expired: '請重新登入 ChatGPT。', + refresh_token_invalidated: '請重新登入 ChatGPT。', + refresh_token_reused: '請重新登入 ChatGPT。', + unauthorized: '請重新登入 ChatGPT。', + subscription_sharing_invalid_user: '請重新登入 ChatGPT。', + usage_disabled: '請授權 OpenLess 使用 ChatGPT 方案用量。', + access_denied: '請授權 OpenLess 使用 ChatGPT 方案用量。', + chatpass_v2_scope_not_authorized: '請授權 OpenLess 使用 ChatGPT 方案用量。', + chatpass_v2_invalid_authorization_context: '請授權 OpenLess 使用 ChatGPT 方案用量。', + subscription_sharing_usage_limit_exceeded: + '已達方案或此應用程式的用量限制,請開啟「管理用量」。', + rate_limited: '已達方案或此應用程式的用量限制,請開啟「管理用量」。', + subscription_sharing_user_not_eligible: '目前帳號、工作區或地區不符合方案用量要求。', + forbidden: '目前帳號、工作區或地區不符合方案用量要求。', + network_failed: '服務暫時無法連線,帳號資料已保留,請稍後重試。', + unavailable: '服務暫時無法連線,帳號資料已保留,請稍後重試。', + subscription_sharing_usage_unavailable: '服務暫時無法連線,帳號資料已保留,請稍後重試。', + subscription_sharing_user_unavailable: '服務暫時無法連線,帳號資料已保留,請稍後重試。', + id_token_invalid: '無法驗證本次授權,請重新登入。', + id_token_missing: '無法驗證本次授權,請重新登入。', + issuer_mismatch: '無法驗證本次授權,請重新登入。', + client_mismatch: '無法驗證本次授權,請重新登入。', + identity_mismatch: '無法驗證本次授權,請重新登入。', + callback_invalid: '無法驗證本次授權,請重新登入。', + state_mismatch: '無法驗證本次授權,請重新登入。', + registration_incomplete: '無法驗證本次授權,請重新登入。', + discovery_invalid: '無法驗證本次授權,請重新登入。', + jwks_invalid: '無法驗證本次授權,請重新登入。', + token_response_invalid: '無法驗證本次授權,請重新登入。', + storage_failed: '無法安全讀取或儲存憑據,請檢查系統安全儲存。', + storage_corrupt: '無法安全讀取或儲存憑據,請檢查系統安全儲存。', + storage_unavailable: '無法安全讀取或儲存憑據,請檢查系統安全儲存。', + storage_full: '無法安全讀取或儲存憑據,請檢查系統安全儲存。', + cancelled: '操作已取消。', + authorization_timeout: '授權已逾時,請重新登入。', + subscription_sharing_unsupported_capability: '此模型或請求能力不可用,請重新整理並選擇模型。', + subscription_sharing_route_not_supported: '此模型或請求能力不可用,請重新整理並選擇模型。', + models_invalid: '此模型或請求能力不可用,請重新整理並選擇模型。', + empty_response: '此模型或請求能力不可用,請重新整理並選擇模型。', + busy: '另一個帳號操作正在進行,請稍後重試。', + browser_failed: '無法開啟系統瀏覽器,請重試。', + callback_unavailable: '無法開啟系統瀏覽器,請重試。', + }, +}; + +export const siwcJa = { + description: + '対象の文章校正と翻訳には ChatGPT プランまたはクレジットを使用します。音声認識は従来のプロバイダーを使い、上限到達時に API キーへ自動切り替えしません。', + account: 'ChatGPT アカウント', + cancel: 'ログインをキャンセル', + enableUsage: 'プラン利用を許可', + reauthorize: '再ログイン', + addAccount: 'アカウント・ワークスペースを追加', + disconnect: '接続を解除', + manageUsage: '使用量を管理', + retry: 'プラン利用を再試行', + model: '校正モデル', + selectModel: '利用可能なモデルを選択', + modelUnavailable: '{{model}} は現在利用できません。モデルを選び直してください。', + noModels: 'このアカウントには表示可能なモデルがありません。', + refreshModels: 'モデルを更新', + test: '接続をテスト', + testHint: 'モデル一覧の取得では推論を実行しません。接続テストはプランの使用量を少量消費します。', + testSuccess: '接続テストに成功しました', + remoteUnconfirmed: + '端末ではログアウトしましたが、サーバー側の失効を確認できません。ChatGPT 設定でこのアプリの接続を解除してください。', + welcome: 'ChatGPT プランを使用しています', + gotIt: '了解', + failed: 'リクエストを完了できませんでした。接続を確認するか再ログインしてください。', + states: { + signed_out: 'ChatGPT に未接続', + authorizing: 'システムブラウザーで認証を完了してください', + connected: 'ChatGPT プランを使用中', + usage_disabled: 'ログイン済み・プラン利用は未許可', + usage_limited: 'プランまたはアプリの利用上限に達しました', + reauthorization_required: 'ChatGPT に再ログインしてください', + }, + errors: { + sign_in_required: 'ChatGPT に再ログインしてください。', + reauthorization_required: 'ChatGPT に再ログインしてください。', + session_changed: 'ChatGPT に再ログインしてください。', + refresh_interrupted: 'ChatGPT に再ログインしてください。', + invalid_grant: 'ChatGPT に再ログインしてください。', + invalid_refresh_token: 'ChatGPT に再ログインしてください。', + token_expired: 'ChatGPT に再ログインしてください。', + refresh_token_expired: 'ChatGPT に再ログインしてください。', + refresh_token_invalidated: 'ChatGPT に再ログインしてください。', + refresh_token_reused: 'ChatGPT に再ログインしてください。', + unauthorized: 'ChatGPT に再ログインしてください。', + subscription_sharing_invalid_user: 'ChatGPT に再ログインしてください。', + usage_disabled: 'OpenLess に ChatGPT プランの利用を許可してください。', + access_denied: 'OpenLess に ChatGPT プランの利用を許可してください。', + chatpass_v2_scope_not_authorized: 'OpenLess に ChatGPT プランの利用を許可してください。', + chatpass_v2_invalid_authorization_context: + 'OpenLess に ChatGPT プランの利用を許可してください。', + subscription_sharing_usage_limit_exceeded: + 'プランまたはアプリの上限に達しました。「使用量を管理」を開いてください。', + rate_limited: 'プランまたはアプリの上限に達しました。「使用量を管理」を開いてください。', + subscription_sharing_user_not_eligible: + 'このアカウント、ワークスペース、地域ではプランを利用できません。', + forbidden: 'このアカウント、ワークスペース、地域ではプランを利用できません。', + network_failed: + 'サービスに接続できません。アカウント情報は保持されています。後で再試行してください。', + unavailable: + 'サービスに接続できません。アカウント情報は保持されています。後で再試行してください。', + subscription_sharing_usage_unavailable: + 'サービスに接続できません。アカウント情報は保持されています。後で再試行してください。', + subscription_sharing_user_unavailable: + 'サービスに接続できません。アカウント情報は保持されています。後で再試行してください。', + id_token_invalid: '認証を検証できません。ログインをやり直してください。', + id_token_missing: '認証を検証できません。ログインをやり直してください。', + issuer_mismatch: '認証を検証できません。ログインをやり直してください。', + client_mismatch: '認証を検証できません。ログインをやり直してください。', + identity_mismatch: '認証を検証できません。ログインをやり直してください。', + callback_invalid: '認証を検証できません。ログインをやり直してください。', + state_mismatch: '認証を検証できません。ログインをやり直してください。', + registration_incomplete: '認証を検証できません。ログインをやり直してください。', + discovery_invalid: '認証を検証できません。ログインをやり直してください。', + jwks_invalid: '認証を検証できません。ログインをやり直してください。', + token_response_invalid: '認証を検証できません。ログインをやり直してください。', + storage_failed: + '認証情報を安全に読み書きできません。システムの資格情報ストアを確認してください。', + storage_corrupt: + '認証情報を安全に読み書きできません。システムの資格情報ストアを確認してください。', + storage_unavailable: + '認証情報を安全に読み書きできません。システムの資格情報ストアを確認してください。', + storage_full: + '認証情報を安全に読み書きできません。システムの資格情報ストアを確認してください。', + cancelled: '操作をキャンセルしました。', + authorization_timeout: '認証がタイムアウトしました。再ログインしてください。', + subscription_sharing_unsupported_capability: + 'このモデルまたは機能は利用できません。モデルを更新して選び直してください。', + subscription_sharing_route_not_supported: + 'このモデルまたは機能は利用できません。モデルを更新して選び直してください。', + models_invalid: 'このモデルまたは機能は利用できません。モデルを更新して選び直してください。', + empty_response: 'このモデルまたは機能は利用できません。モデルを更新して選び直してください。', + busy: '別のアカウント操作が進行中です。少し待って再試行してください。', + browser_failed: 'システムブラウザーを開けません。再試行してください。', + callback_unavailable: 'システムブラウザーを開けません。再試行してください。', + }, +}; + +export const siwcKo = { + description: + '지원되는 텍스트 다듬기와 번역에 ChatGPT 요금제 또는 크레딧을 사용합니다. 음성 인식은 기존 서비스를 사용하며 한도에 도달해도 API 키로 자동 전환하지 않습니다.', + account: 'ChatGPT 계정', + cancel: '로그인 취소', + enableUsage: '요금제 사용 허용', + reauthorize: '다시 로그인', + addAccount: '계정 또는 워크스페이스 추가', + disconnect: '연결 해제', + manageUsage: '사용량 관리', + retry: '요금제 사용 재시도', + model: '다듬기 모델', + selectModel: '사용 가능한 모델 선택', + modelUnavailable: '{{model}} 모델을 사용할 수 없습니다. 다른 모델을 선택하세요.', + noModels: '이 계정에서 표시할 수 있는 모델이 없습니다.', + refreshModels: '모델 새로고침', + test: '연결 테스트', + testHint: + '모델 목록 조회는 추론을 실행하지 않습니다. 연결 테스트는 요금제 사용량을 소량 소모합니다.', + testSuccess: '연결 테스트 성공', + remoteUnconfirmed: + '이 기기에서는 로그아웃했지만 원격 취소는 확인되지 않았습니다. ChatGPT 설정에서 이 앱의 연결을 해제하세요.', + welcome: 'ChatGPT 요금제를 사용하고 있습니다', + gotIt: '확인', + failed: '요청이 완료되지 않았습니다. 연결을 확인하거나 다시 로그인하세요.', + states: { + signed_out: 'ChatGPT에 연결되지 않음', + authorizing: '시스템 브라우저에서 인증을 완료하세요', + connected: 'ChatGPT 요금제 사용 중', + usage_disabled: '로그인됨 · 요금제 사용 허용 필요', + usage_limited: '요금제 또는 앱의 사용 한도에 도달함', + reauthorization_required: 'ChatGPT에 다시 로그인하세요', + }, + errors: { + sign_in_required: 'ChatGPT에 다시 로그인하세요.', + reauthorization_required: 'ChatGPT에 다시 로그인하세요.', + session_changed: 'ChatGPT에 다시 로그인하세요.', + refresh_interrupted: 'ChatGPT에 다시 로그인하세요.', + invalid_grant: 'ChatGPT에 다시 로그인하세요.', + invalid_refresh_token: 'ChatGPT에 다시 로그인하세요.', + token_expired: 'ChatGPT에 다시 로그인하세요.', + refresh_token_expired: 'ChatGPT에 다시 로그인하세요.', + refresh_token_invalidated: 'ChatGPT에 다시 로그인하세요.', + refresh_token_reused: 'ChatGPT에 다시 로그인하세요.', + unauthorized: 'ChatGPT에 다시 로그인하세요.', + subscription_sharing_invalid_user: 'ChatGPT에 다시 로그인하세요.', + usage_disabled: 'OpenLess의 ChatGPT 요금제 사용을 허용하세요.', + access_denied: 'OpenLess의 ChatGPT 요금제 사용을 허용하세요.', + chatpass_v2_scope_not_authorized: 'OpenLess의 ChatGPT 요금제 사용을 허용하세요.', + chatpass_v2_invalid_authorization_context: 'OpenLess의 ChatGPT 요금제 사용을 허용하세요.', + subscription_sharing_usage_limit_exceeded: + '요금제 또는 앱의 사용 한도에 도달했습니다. 사용량 관리를 여세요.', + rate_limited: '요금제 또는 앱의 사용 한도에 도달했습니다. 사용량 관리를 여세요.', + subscription_sharing_user_not_eligible: + '이 계정, 워크스페이스 또는 지역은 요금제 사용 대상이 아닙니다.', + forbidden: '이 계정, 워크스페이스 또는 지역은 요금제 사용 대상이 아닙니다.', + network_failed: '서비스에 연결할 수 없습니다. 계정 정보는 유지됩니다. 나중에 다시 시도하세요.', + unavailable: '서비스에 연결할 수 없습니다. 계정 정보는 유지됩니다. 나중에 다시 시도하세요.', + subscription_sharing_usage_unavailable: + '서비스에 연결할 수 없습니다. 계정 정보는 유지됩니다. 나중에 다시 시도하세요.', + subscription_sharing_user_unavailable: + '서비스에 연결할 수 없습니다. 계정 정보는 유지됩니다. 나중에 다시 시도하세요.', + id_token_invalid: '인증을 확인할 수 없습니다. 다시 로그인하세요.', + id_token_missing: '인증을 확인할 수 없습니다. 다시 로그인하세요.', + issuer_mismatch: '인증을 확인할 수 없습니다. 다시 로그인하세요.', + client_mismatch: '인증을 확인할 수 없습니다. 다시 로그인하세요.', + identity_mismatch: '인증을 확인할 수 없습니다. 다시 로그인하세요.', + callback_invalid: '인증을 확인할 수 없습니다. 다시 로그인하세요.', + state_mismatch: '인증을 확인할 수 없습니다. 다시 로그인하세요.', + registration_incomplete: '인증을 확인할 수 없습니다. 다시 로그인하세요.', + discovery_invalid: '인증을 확인할 수 없습니다. 다시 로그인하세요.', + jwks_invalid: '인증을 확인할 수 없습니다. 다시 로그인하세요.', + token_response_invalid: '인증을 확인할 수 없습니다. 다시 로그인하세요.', + storage_failed: + '인증 정보를 안전하게 읽거나 저장할 수 없습니다. 시스템 자격 증명 저장소를 확인하세요.', + storage_corrupt: + '인증 정보를 안전하게 읽거나 저장할 수 없습니다. 시스템 자격 증명 저장소를 확인하세요.', + storage_unavailable: + '인증 정보를 안전하게 읽거나 저장할 수 없습니다. 시스템 자격 증명 저장소를 확인하세요.', + storage_full: + '인증 정보를 안전하게 읽거나 저장할 수 없습니다. 시스템 자격 증명 저장소를 확인하세요.', + cancelled: '작업이 취소되었습니다.', + authorization_timeout: '인증 시간이 초과되었습니다. 다시 로그인하세요.', + subscription_sharing_unsupported_capability: + '이 모델 또는 기능을 사용할 수 없습니다. 모델을 새로고침하고 다시 선택하세요.', + subscription_sharing_route_not_supported: + '이 모델 또는 기능을 사용할 수 없습니다. 모델을 새로고침하고 다시 선택하세요.', + models_invalid: '이 모델 또는 기능을 사용할 수 없습니다. 모델을 새로고침하고 다시 선택하세요.', + empty_response: '이 모델 또는 기능을 사용할 수 없습니다. 모델을 새로고침하고 다시 선택하세요.', + busy: '다른 계정 작업이 진행 중입니다. 잠시 후 다시 시도하세요.', + browser_failed: '시스템 브라우저를 열 수 없습니다. 다시 시도하세요.', + callback_unavailable: '시스템 브라우저를 열 수 없습니다. 다시 시도하세요.', + }, +}; + +export const siwcDe = { + description: + 'Geeignete Textüberarbeitung und Übersetzung nutzen Ihren ChatGPT-Tarif oder Ihr Guthaben. Die Spracherkennung bleibt beim bisherigen Anbieter. Bei Nutzungslimits erfolgt kein automatischer Wechsel zu einem API-Schlüssel.', + account: 'ChatGPT-Konto', + cancel: 'Anmeldung abbrechen', + enableUsage: 'Tarifnutzung erlauben', + reauthorize: 'Erneut anmelden', + addAccount: 'Konto oder Arbeitsbereich hinzufügen', + disconnect: 'Verbindung trennen', + manageUsage: 'Nutzung verwalten', + retry: 'Tarifnutzung erneut versuchen', + model: 'Überarbeitungsmodell', + selectModel: 'Verfügbares Modell auswählen', + modelUnavailable: '{{model}} ist nicht mehr verfügbar. Wählen Sie ein anderes Modell.', + noModels: 'Für dieses Konto sind keine anzeigbaren Modelle verfügbar.', + refreshModels: 'Modelle aktualisieren', + test: 'Verbindung testen', + testHint: + 'Die Modellabfrage führt keine Inferenz aus. Der Verbindungstest verbraucht einen kleinen Teil Ihres ChatGPT-Kontingents.', + testSuccess: 'Verbindungstest erfolgreich', + remoteUnconfirmed: + 'Lokal abgemeldet, aber der Widerruf auf dem Server wurde nicht bestätigt. Trennen Sie die App in den ChatGPT-Einstellungen.', + welcome: 'Sie nutzen Ihren ChatGPT-Tarif', + gotIt: 'Verstanden', + failed: + 'Die Anfrage wurde nicht abgeschlossen. Prüfen Sie die Verbindung oder melden Sie sich erneut an.', + states: { + signed_out: 'Nicht mit ChatGPT verbunden', + authorizing: 'Autorisierung im Systembrowser abschließen', + connected: 'ChatGPT-Tarif wird verwendet', + usage_disabled: 'Angemeldet; Tarifnutzung noch nicht erlaubt', + usage_limited: 'Nutzungslimit des Tarifs oder der App erreicht', + reauthorization_required: 'Erneute ChatGPT-Anmeldung erforderlich', + }, + errors: { + sign_in_required: 'Melden Sie sich erneut bei ChatGPT an.', + reauthorization_required: 'Melden Sie sich erneut bei ChatGPT an.', + session_changed: 'Melden Sie sich erneut bei ChatGPT an.', + refresh_interrupted: 'Melden Sie sich erneut bei ChatGPT an.', + invalid_grant: 'Melden Sie sich erneut bei ChatGPT an.', + invalid_refresh_token: 'Melden Sie sich erneut bei ChatGPT an.', + token_expired: 'Melden Sie sich erneut bei ChatGPT an.', + refresh_token_expired: 'Melden Sie sich erneut bei ChatGPT an.', + refresh_token_invalidated: 'Melden Sie sich erneut bei ChatGPT an.', + refresh_token_reused: 'Melden Sie sich erneut bei ChatGPT an.', + unauthorized: 'Melden Sie sich erneut bei ChatGPT an.', + subscription_sharing_invalid_user: 'Melden Sie sich erneut bei ChatGPT an.', + usage_disabled: 'Erlauben Sie OpenLess die Nutzung Ihres ChatGPT-Tarifs.', + access_denied: 'Erlauben Sie OpenLess die Nutzung Ihres ChatGPT-Tarifs.', + chatpass_v2_scope_not_authorized: 'Erlauben Sie OpenLess die Nutzung Ihres ChatGPT-Tarifs.', + chatpass_v2_invalid_authorization_context: + 'Erlauben Sie OpenLess die Nutzung Ihres ChatGPT-Tarifs.', + subscription_sharing_usage_limit_exceeded: + 'Das Limit Ihres Tarifs oder dieser App wurde erreicht. Öffnen Sie „Nutzung verwalten“.', + rate_limited: + 'Das Limit Ihres Tarifs oder dieser App wurde erreicht. Öffnen Sie „Nutzung verwalten“.', + subscription_sharing_user_not_eligible: + 'Dieses Konto, dieser Arbeitsbereich oder diese Region ist nicht zur Tarifnutzung berechtigt.', + forbidden: + 'Dieses Konto, dieser Arbeitsbereich oder diese Region ist nicht zur Tarifnutzung berechtigt.', + network_failed: + 'Der Dienst ist vorübergehend nicht erreichbar. Ihre Kontodaten bleiben erhalten. Versuchen Sie es später erneut.', + unavailable: + 'Der Dienst ist vorübergehend nicht erreichbar. Ihre Kontodaten bleiben erhalten. Versuchen Sie es später erneut.', + subscription_sharing_usage_unavailable: + 'Der Dienst ist vorübergehend nicht erreichbar. Ihre Kontodaten bleiben erhalten. Versuchen Sie es später erneut.', + subscription_sharing_user_unavailable: + 'Der Dienst ist vorübergehend nicht erreichbar. Ihre Kontodaten bleiben erhalten. Versuchen Sie es später erneut.', + id_token_invalid: + 'Die Autorisierung konnte nicht überprüft werden. Starten Sie eine neue Anmeldung.', + id_token_missing: + 'Die Autorisierung konnte nicht überprüft werden. Starten Sie eine neue Anmeldung.', + issuer_mismatch: + 'Die Autorisierung konnte nicht überprüft werden. Starten Sie eine neue Anmeldung.', + client_mismatch: + 'Die Autorisierung konnte nicht überprüft werden. Starten Sie eine neue Anmeldung.', + identity_mismatch: + 'Die Autorisierung konnte nicht überprüft werden. Starten Sie eine neue Anmeldung.', + callback_invalid: + 'Die Autorisierung konnte nicht überprüft werden. Starten Sie eine neue Anmeldung.', + state_mismatch: + 'Die Autorisierung konnte nicht überprüft werden. Starten Sie eine neue Anmeldung.', + registration_incomplete: + 'Die Autorisierung konnte nicht überprüft werden. Starten Sie eine neue Anmeldung.', + discovery_invalid: + 'Die Autorisierung konnte nicht überprüft werden. Starten Sie eine neue Anmeldung.', + jwks_invalid: + 'Die Autorisierung konnte nicht überprüft werden. Starten Sie eine neue Anmeldung.', + token_response_invalid: + 'Die Autorisierung konnte nicht überprüft werden. Starten Sie eine neue Anmeldung.', + storage_failed: + 'Anmeldedaten konnten nicht sicher gelesen oder gespeichert werden. Prüfen Sie den Systemspeicher für Zugangsdaten.', + storage_corrupt: + 'Anmeldedaten konnten nicht sicher gelesen oder gespeichert werden. Prüfen Sie den Systemspeicher für Zugangsdaten.', + storage_unavailable: + 'Anmeldedaten konnten nicht sicher gelesen oder gespeichert werden. Prüfen Sie den Systemspeicher für Zugangsdaten.', + storage_full: + 'Anmeldedaten konnten nicht sicher gelesen oder gespeichert werden. Prüfen Sie den Systemspeicher für Zugangsdaten.', + cancelled: 'Der Vorgang wurde abgebrochen.', + authorization_timeout: 'Die Autorisierung ist abgelaufen. Melden Sie sich erneut an.', + subscription_sharing_unsupported_capability: + 'Dieses Modell oder diese Funktion ist nicht verfügbar. Aktualisieren Sie die Modellauswahl.', + subscription_sharing_route_not_supported: + 'Dieses Modell oder diese Funktion ist nicht verfügbar. Aktualisieren Sie die Modellauswahl.', + models_invalid: + 'Dieses Modell oder diese Funktion ist nicht verfügbar. Aktualisieren Sie die Modellauswahl.', + empty_response: + 'Dieses Modell oder diese Funktion ist nicht verfügbar. Aktualisieren Sie die Modellauswahl.', + busy: 'Ein anderer Kontovorgang läuft. Versuchen Sie es gleich erneut.', + browser_failed: 'Der Systembrowser konnte nicht geöffnet werden. Versuchen Sie es erneut.', + callback_unavailable: + 'Der Systembrowser konnte nicht geöffnet werden. Versuchen Sie es erneut.', + }, +}; + +export const siwcEs = { + description: + 'Las tareas compatibles de revisión y traducción usan tu plan o créditos de ChatGPT. El reconocimiento de voz conserva su proveedor. Si alcanzas un límite, no se cambia automáticamente a una clave API.', + account: 'Cuenta de ChatGPT', + cancel: 'Cancelar inicio de sesión', + enableUsage: 'Autorizar uso del plan', + reauthorize: 'Volver a iniciar sesión', + addAccount: 'Añadir cuenta o espacio de trabajo', + disconnect: 'Desconectar', + manageUsage: 'Gestionar uso', + retry: 'Reintentar uso del plan', + model: 'Modelo de revisión', + selectModel: 'Selecciona un modelo disponible', + modelUnavailable: '{{model}} ya no está disponible. Selecciona otro modelo.', + noModels: 'Esta cuenta no tiene modelos disponibles para mostrar.', + refreshModels: 'Actualizar modelos', + test: 'Probar conexión', + testHint: + 'Consultar modelos no ejecuta inferencia. La prueba consume una pequeña parte del uso de tu plan de ChatGPT.', + testSuccess: 'Prueba de conexión correcta', + remoteUnconfirmed: + 'Sesión cerrada localmente, pero no se confirmó la revocación remota. Desconecta la aplicación en los ajustes de ChatGPT.', + welcome: 'Estás usando tu plan de ChatGPT', + gotIt: 'Entendido', + failed: 'La solicitud no se completó. Comprueba la conexión o vuelve a iniciar sesión.', + states: { + signed_out: 'Sin conexión con ChatGPT', + authorizing: 'Completa la autorización en el navegador del sistema', + connected: 'Usando el plan de ChatGPT', + usage_disabled: 'Sesión iniciada; falta autorizar el uso del plan', + usage_limited: 'Límite del plan o de la aplicación alcanzado', + reauthorization_required: 'Vuelve a iniciar sesión en ChatGPT', + }, + errors: { + sign_in_required: 'Vuelve a iniciar sesión en ChatGPT.', + reauthorization_required: 'Vuelve a iniciar sesión en ChatGPT.', + session_changed: 'Vuelve a iniciar sesión en ChatGPT.', + refresh_interrupted: 'Vuelve a iniciar sesión en ChatGPT.', + invalid_grant: 'Vuelve a iniciar sesión en ChatGPT.', + invalid_refresh_token: 'Vuelve a iniciar sesión en ChatGPT.', + token_expired: 'Vuelve a iniciar sesión en ChatGPT.', + refresh_token_expired: 'Vuelve a iniciar sesión en ChatGPT.', + refresh_token_invalidated: 'Vuelve a iniciar sesión en ChatGPT.', + refresh_token_reused: 'Vuelve a iniciar sesión en ChatGPT.', + unauthorized: 'Vuelve a iniciar sesión en ChatGPT.', + subscription_sharing_invalid_user: 'Vuelve a iniciar sesión en ChatGPT.', + usage_disabled: 'Autoriza a OpenLess a usar tu plan de ChatGPT.', + access_denied: 'Autoriza a OpenLess a usar tu plan de ChatGPT.', + chatpass_v2_scope_not_authorized: 'Autoriza a OpenLess a usar tu plan de ChatGPT.', + chatpass_v2_invalid_authorization_context: 'Autoriza a OpenLess a usar tu plan de ChatGPT.', + subscription_sharing_usage_limit_exceeded: + 'Se alcanzó el límite de tu plan o de esta aplicación. Abre Gestionar uso.', + rate_limited: 'Se alcanzó el límite de tu plan o de esta aplicación. Abre Gestionar uso.', + subscription_sharing_user_not_eligible: + 'Esta cuenta, espacio de trabajo o región no cumple los requisitos de uso del plan.', + forbidden: 'Esta cuenta, espacio de trabajo o región no cumple los requisitos de uso del plan.', + network_failed: + 'El servicio no está disponible temporalmente. Se conservaron los datos de tu cuenta. Inténtalo más tarde.', + unavailable: + 'El servicio no está disponible temporalmente. Se conservaron los datos de tu cuenta. Inténtalo más tarde.', + subscription_sharing_usage_unavailable: + 'El servicio no está disponible temporalmente. Se conservaron los datos de tu cuenta. Inténtalo más tarde.', + subscription_sharing_user_unavailable: + 'El servicio no está disponible temporalmente. Se conservaron los datos de tu cuenta. Inténtalo más tarde.', + id_token_invalid: 'No se pudo verificar esta autorización. Inicia una nueva sesión.', + id_token_missing: 'No se pudo verificar esta autorización. Inicia una nueva sesión.', + issuer_mismatch: 'No se pudo verificar esta autorización. Inicia una nueva sesión.', + client_mismatch: 'No se pudo verificar esta autorización. Inicia una nueva sesión.', + identity_mismatch: 'No se pudo verificar esta autorización. Inicia una nueva sesión.', + callback_invalid: 'No se pudo verificar esta autorización. Inicia una nueva sesión.', + state_mismatch: 'No se pudo verificar esta autorización. Inicia una nueva sesión.', + registration_incomplete: 'No se pudo verificar esta autorización. Inicia una nueva sesión.', + discovery_invalid: 'No se pudo verificar esta autorización. Inicia una nueva sesión.', + jwks_invalid: 'No se pudo verificar esta autorización. Inicia una nueva sesión.', + token_response_invalid: 'No se pudo verificar esta autorización. Inicia una nueva sesión.', + storage_failed: + 'No se pudieron leer o guardar las credenciales de forma segura. Revisa el almacén de credenciales del sistema.', + storage_corrupt: + 'No se pudieron leer o guardar las credenciales de forma segura. Revisa el almacén de credenciales del sistema.', + storage_unavailable: + 'No se pudieron leer o guardar las credenciales de forma segura. Revisa el almacén de credenciales del sistema.', + storage_full: + 'No se pudieron leer o guardar las credenciales de forma segura. Revisa el almacén de credenciales del sistema.', + cancelled: 'Operación cancelada.', + authorization_timeout: 'La autorización ha caducado. Inicia sesión de nuevo.', + subscription_sharing_unsupported_capability: + 'Este modelo o función no está disponible. Actualiza los modelos y selecciona otro.', + subscription_sharing_route_not_supported: + 'Este modelo o función no está disponible. Actualiza los modelos y selecciona otro.', + models_invalid: + 'Este modelo o función no está disponible. Actualiza los modelos y selecciona otro.', + empty_response: + 'Este modelo o función no está disponible. Actualiza los modelos y selecciona otro.', + busy: 'Hay otra operación de cuenta en curso. Inténtalo en unos instantes.', + browser_failed: 'No se pudo abrir el navegador del sistema. Inténtalo de nuevo.', + callback_unavailable: 'No se pudo abrir el navegador del sistema. Inténtalo de nuevo.', + }, +}; + +export const siwcFr = { + description: + 'Les tâches compatibles de révision et de traduction utilisent votre forfait ou vos crédits ChatGPT. La reconnaissance vocale conserve son fournisseur. Aucun basculement automatique vers une clé API en cas de limite.', + account: 'Compte ChatGPT', + cancel: 'Annuler la connexion', + enableUsage: 'Autoriser l’utilisation du forfait', + reauthorize: 'Se reconnecter', + addAccount: 'Ajouter un compte ou un espace de travail', + disconnect: 'Déconnecter', + manageUsage: 'Gérer l’utilisation', + retry: 'Réessayer avec le forfait', + model: 'Modèle de révision', + selectModel: 'Choisir un modèle disponible', + modelUnavailable: '{{model}} n’est plus disponible. Choisissez un autre modèle.', + noModels: 'Aucun modèle affichable n’est disponible pour ce compte.', + refreshModels: 'Actualiser les modèles', + test: 'Tester la connexion', + testHint: + 'La liste des modèles ne lance pas d’inférence. Le test consomme une petite partie de votre forfait ChatGPT.', + testSuccess: 'Test de connexion réussi', + remoteUnconfirmed: + 'Déconnexion locale effectuée, mais la révocation à distance n’a pas été confirmée. Déconnectez l’application dans les paramètres ChatGPT.', + welcome: 'Vous utilisez votre forfait ChatGPT', + gotIt: 'Compris', + failed: 'La requête n’a pas abouti. Vérifiez la connexion ou reconnectez-vous.', + states: { + signed_out: 'Non connecté à ChatGPT', + authorizing: 'Terminez l’autorisation dans le navigateur système', + connected: 'Forfait ChatGPT utilisé', + usage_disabled: 'Connecté ; utilisation du forfait non autorisée', + usage_limited: 'Limite du forfait ou de l’application atteinte', + reauthorization_required: 'Reconnectez-vous à ChatGPT', + }, + errors: { + sign_in_required: 'Reconnectez-vous à ChatGPT.', + reauthorization_required: 'Reconnectez-vous à ChatGPT.', + session_changed: 'Reconnectez-vous à ChatGPT.', + refresh_interrupted: 'Reconnectez-vous à ChatGPT.', + invalid_grant: 'Reconnectez-vous à ChatGPT.', + invalid_refresh_token: 'Reconnectez-vous à ChatGPT.', + token_expired: 'Reconnectez-vous à ChatGPT.', + refresh_token_expired: 'Reconnectez-vous à ChatGPT.', + refresh_token_invalidated: 'Reconnectez-vous à ChatGPT.', + refresh_token_reused: 'Reconnectez-vous à ChatGPT.', + unauthorized: 'Reconnectez-vous à ChatGPT.', + subscription_sharing_invalid_user: 'Reconnectez-vous à ChatGPT.', + usage_disabled: 'Autorisez OpenLess à utiliser votre forfait ChatGPT.', + access_denied: 'Autorisez OpenLess à utiliser votre forfait ChatGPT.', + chatpass_v2_scope_not_authorized: 'Autorisez OpenLess à utiliser votre forfait ChatGPT.', + chatpass_v2_invalid_authorization_context: + 'Autorisez OpenLess à utiliser votre forfait ChatGPT.', + subscription_sharing_usage_limit_exceeded: + 'La limite de votre forfait ou de cette application est atteinte. Ouvrez Gérer l’utilisation.', + rate_limited: + 'La limite de votre forfait ou de cette application est atteinte. Ouvrez Gérer l’utilisation.', + subscription_sharing_user_not_eligible: + 'Ce compte, cet espace de travail ou cette région n’est pas éligible à l’utilisation du forfait.', + forbidden: + 'Ce compte, cet espace de travail ou cette région n’est pas éligible à l’utilisation du forfait.', + network_failed: + 'Le service est temporairement indisponible. Les données du compte sont conservées. Réessayez plus tard.', + unavailable: + 'Le service est temporairement indisponible. Les données du compte sont conservées. Réessayez plus tard.', + subscription_sharing_usage_unavailable: + 'Le service est temporairement indisponible. Les données du compte sont conservées. Réessayez plus tard.', + subscription_sharing_user_unavailable: + 'Le service est temporairement indisponible. Les données du compte sont conservées. Réessayez plus tard.', + id_token_invalid: 'Cette autorisation n’a pas pu être vérifiée. Recommencez la connexion.', + id_token_missing: 'Cette autorisation n’a pas pu être vérifiée. Recommencez la connexion.', + issuer_mismatch: 'Cette autorisation n’a pas pu être vérifiée. Recommencez la connexion.', + client_mismatch: 'Cette autorisation n’a pas pu être vérifiée. Recommencez la connexion.', + identity_mismatch: 'Cette autorisation n’a pas pu être vérifiée. Recommencez la connexion.', + callback_invalid: 'Cette autorisation n’a pas pu être vérifiée. Recommencez la connexion.', + state_mismatch: 'Cette autorisation n’a pas pu être vérifiée. Recommencez la connexion.', + registration_incomplete: + 'Cette autorisation n’a pas pu être vérifiée. Recommencez la connexion.', + discovery_invalid: 'Cette autorisation n’a pas pu être vérifiée. Recommencez la connexion.', + jwks_invalid: 'Cette autorisation n’a pas pu être vérifiée. Recommencez la connexion.', + token_response_invalid: + 'Cette autorisation n’a pas pu être vérifiée. Recommencez la connexion.', + storage_failed: + 'Impossible de lire ou d’enregistrer les identifiants en toute sécurité. Vérifiez le coffre d’identifiants du système.', + storage_corrupt: + 'Impossible de lire ou d’enregistrer les identifiants en toute sécurité. Vérifiez le coffre d’identifiants du système.', + storage_unavailable: + 'Impossible de lire ou d’enregistrer les identifiants en toute sécurité. Vérifiez le coffre d’identifiants du système.', + storage_full: + 'Impossible de lire ou d’enregistrer les identifiants en toute sécurité. Vérifiez le coffre d’identifiants du système.', + cancelled: 'Opération annulée.', + authorization_timeout: 'L’autorisation a expiré. Reconnectez-vous.', + subscription_sharing_unsupported_capability: + 'Ce modèle ou cette fonctionnalité est indisponible. Actualisez les modèles et choisissez-en un autre.', + subscription_sharing_route_not_supported: + 'Ce modèle ou cette fonctionnalité est indisponible. Actualisez les modèles et choisissez-en un autre.', + models_invalid: + 'Ce modèle ou cette fonctionnalité est indisponible. Actualisez les modèles et choisissez-en un autre.', + empty_response: + 'Ce modèle ou cette fonctionnalité est indisponible. Actualisez les modèles et choisissez-en un autre.', + busy: 'Une autre opération sur le compte est en cours. Réessayez dans un instant.', + browser_failed: 'Impossible d’ouvrir le navigateur système. Réessayez.', + callback_unavailable: 'Impossible d’ouvrir le navigateur système. Réessayez.', + }, +}; diff --git a/openless-all/app/src/i18n/zh-CN.ts b/openless-all/app/src/i18n/zh-CN.ts index 021c8516d..1314e4288 100644 --- a/openless-all/app/src/i18n/zh-CN.ts +++ b/openless-all/app/src/i18n/zh-CN.ts @@ -1,3 +1,4 @@ +import { siwcZhCN as siwc } from './siwc'; // Simplified Chinese resources — kept in sync with the product's current copy. // The eight locale files share one structure; add/remove keys in all of them together. @@ -5,6 +6,7 @@ import { uiRefinementZhCN as uiRefinement } from './uiRefinement'; import { experimentalAsrZhCN as experimentalAsr } from './experimentalAsr'; export const zhCN = { + siwc, experimentalAsr, uiRefinement, voiceEdit: { @@ -1653,6 +1655,7 @@ export const zhCN = { openai: 'OpenAI', gemini: 'Google Gemini', codexOAuth: 'Codex OAuth', + chatgptSiwc: 'ChatGPT Plan', mimo: '小米 MiMo', cometapi: 'CometAPI', openrouterFree: 'OpenRouter(免费模型)', diff --git a/openless-all/app/src/i18n/zh-TW.ts b/openless-all/app/src/i18n/zh-TW.ts index 9498ddf6c..aed89b600 100644 --- a/openless-all/app/src/i18n/zh-TW.ts +++ b/openless-all/app/src/i18n/zh-TW.ts @@ -1,3 +1,4 @@ +import { siwcZhTW as siwc } from './siwc'; import type { zhCN } from './zh-CN'; // Traditional Chinese resources, sharing the same copy keys as the other seven locales. @@ -5,6 +6,7 @@ import { uiRefinementZhTW as uiRefinement } from './uiRefinement'; import { experimentalAsrZhTW as experimentalAsr } from './experimentalAsr'; export const zhTW: typeof zhCN = { + siwc, experimentalAsr, uiRefinement, voiceEdit: { @@ -1653,6 +1655,7 @@ export const zhTW: typeof zhCN = { openai: 'OpenAI', gemini: 'Google Gemini', codexOAuth: 'Codex OAuth', + chatgptSiwc: 'ChatGPT Plan', mimo: '小米 MiMo', cometapi: 'CometAPI', openrouterFree: 'OpenRouter(免費模型)', diff --git a/openless-all/app/src/lib/ipc/index.ts b/openless-all/app/src/lib/ipc/index.ts index b67012b1e..b949d6cb4 100644 --- a/openless-all/app/src/lib/ipc/index.ts +++ b/openless-all/app/src/lib/ipc/index.ts @@ -48,6 +48,7 @@ export { export type { AuthRequirement, ProviderDescriptor, ProviderKind } from './providers'; export { listProviderDescriptors } from './providers'; +export * from './siwc'; // channels (channel cards) export type { Channel, ChannelKind, ChannelTestResult } from './channels'; diff --git a/openless-all/app/src/lib/ipc/mock-provider-descriptors.json b/openless-all/app/src/lib/ipc/mock-provider-descriptors.json index 65aa91cd6..d1c5f3d19 100644 --- a/openless-all/app/src/lib/ipc/mock-provider-descriptors.json +++ b/openless-all/app/src/lib/ipc/mock-provider-descriptors.json @@ -120,6 +120,18 @@ "defaultRequestFormat": null, "supportedRequestFormats": [] }, + { + "kind": "llm", + "providerType": "chatgpt_siwc", + "labelKey": "chatgptSiwc", + "defaultEndpoint": "https://api.openai.com/v1", + "defaultModel": null, + "authRequirement": "siwc", + "validationProbe": "llm_text", + "staticModels": [], + "defaultRequestFormat": null, + "supportedRequestFormats": [] + }, { "kind": "llm", "providerType": "mimo", diff --git a/openless-all/app/src/lib/ipc/providers.ts b/openless-all/app/src/lib/ipc/providers.ts index 04ca73957..747498821 100644 --- a/openless-all/app/src/lib/ipc/providers.ts +++ b/openless-all/app/src/lib/ipc/providers.ts @@ -12,7 +12,8 @@ export type AuthRequirement = | 'volcengine' | 'xfyun' | 'tencent_cloud' - | 'o_auth'; + | 'o_auth' + | 'siwc'; export interface ProviderDescriptor { kind: ProviderKind; diff --git a/openless-all/app/src/lib/ipc/siwc.ts b/openless-all/app/src/lib/ipc/siwc.ts new file mode 100644 index 000000000..b4b3d5492 --- /dev/null +++ b/openless-all/app/src/lib/ipc/siwc.ts @@ -0,0 +1,103 @@ +import { invokeOrMock } from './shared'; +export interface SiwcAccount { + id: string; + label: string; + email: string | null; +} +export interface SiwcError { + code: string; + httpStatus?: number; + param?: string; + requestId?: string; +} +export interface SiwcStatus { + state: + | 'signed_out' + | 'authorizing' + | 'connected' + | 'usage_disabled' + | 'usage_limited' + | 'reauthorization_required'; + account: SiwcAccount | null; + accounts: SiwcAccount[]; + attemptId: string | null; + error: SiwcError | null; + welcomePending: boolean; +} +export interface SiwcModel { + slug: string; + displayName: string; +} +export type SiwcAction = + | 'sign_in' + | 'add_account' + | 'enable_usage' + | 'cancel' + | 'select' + | 'disconnect' + | 'acknowledge' + | 'retry'; +const preview = new Map(); +const initial = (): SiwcStatus => ({ + state: 'signed_out', + account: null, + accounts: [], + attemptId: null, + error: null, + welcomePending: false, +}); +export function siwcStatus(channelId: string): Promise { + return invokeOrMock('siwc_status', { channelId }, () => + structuredClone(preview.get(channelId) ?? initial()), + ); +} +export function siwcModels(channelId: string): Promise { + return invokeOrMock('siwc_models', { channelId }, () => [ + { slug: 'preview-model', displayName: 'Preview model' }, + ]); +} +export function siwcAction( + channelId: string, + action: SiwcAction, + registrationId?: string, +): Promise<{ attemptId?: string; remoteRevoked?: boolean } | null> { + return invokeOrMock('siwc_action', { channelId, action, registrationId }, () => { + const status = preview.get(channelId) ?? initial(); + if (action === 'sign_in' || action === 'add_account' || action === 'enable_usage') { + status.state = 'authorizing'; + status.attemptId = 'preview-authorization'; + } else if (action === 'cancel') { + status.state = status.account ? 'connected' : 'signed_out'; + status.attemptId = null; + } else if (action === 'disconnect') { + status.state = 'reauthorization_required'; + status.attemptId = null; + } else if (action === 'acknowledge') { + status.welcomePending = false; + } else if (action === 'retry') { + status.state = 'connected'; + status.error = null; + } else if (action === 'select') { + status.account = status.accounts.find((a) => a.id === registrationId) ?? null; + status.state = status.account ? 'connected' : 'signed_out'; + } + preview.set(channelId, status); + if (action === 'sign_in' || action === 'add_account' || action === 'enable_usage') { + return { attemptId: status.attemptId ?? undefined }; + } + return action === 'disconnect' ? { remoteRevoked: true } : null; + }); +} +export function siwcErrorCode(value: unknown): string { + if (typeof value === 'object' && value !== null && 'code' in value) return String(value.code); + const text = value instanceof Error ? value.message : String(value); + const at = text.indexOf('siwc:'); + if (at >= 0) { + try { + return JSON.parse(text.slice(at + 5)).code || 'request_failed'; + } catch { + /* Only structured, non-secret codes reach the UI. */ + } + } + return 'request_failed'; +} diff --git a/openless-all/app/src/pages/settings/ProvidersSection.tsx b/openless-all/app/src/pages/settings/ProvidersSection.tsx index 07c64b299..b08ff16f0 100644 --- a/openless-all/app/src/pages/settings/ProvidersSection.tsx +++ b/openless-all/app/src/pages/settings/ProvidersSection.tsx @@ -31,6 +31,7 @@ import { BailianProtocolField } from './BailianProtocolField'; import type { BailianProtocol } from '../../lib/bailianProtocol'; import { LlmProtocolFields } from './LlmProtocolFields'; import { ProviderFormContext } from './ProviderForm'; +import { SiwcChannelFields } from './SiwcChannelFields'; import { LocalModelPicker } from './models/LocalModelPicker'; import { emitSaved } from '../../lib/savedEvent'; import { useContentMotion } from '../../lib/motion'; @@ -148,6 +149,7 @@ export const LLM_LABELS = [ ['openai', 'openai'], ['gemini', 'gemini'], ['codex_oauth', 'codexOAuth'], + ['chatgpt_siwc', 'chatgptSiwc'], ['mimo', 'mimo'], ['cometapi', 'cometapi'], ['openrouterFree', 'openrouterFree'], @@ -316,6 +318,16 @@ export function ChannelCredentialFields({ return
{t('common.loading')}
; } + if (kind === 'llm' && providerType === 'chatgpt_siwc') { + return ( + + ); + } if (kind === 'llm') { const defaultEndpoint = descriptor?.defaultEndpoint; const defaultModel = descriptor?.defaultModel; diff --git a/openless-all/app/src/pages/settings/SiwcChannelFields.tsx b/openless-all/app/src/pages/settings/SiwcChannelFields.tsx new file mode 100644 index 000000000..914a8400d --- /dev/null +++ b/openless-all/app/src/pages/settings/SiwcChannelFields.tsx @@ -0,0 +1,295 @@ +import { useCallback, useEffect, useId, useRef, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Modal } from '../../components/ui/Modal'; +import { SelectLite } from '../../components/ui/SelectLite'; +import { Btn } from '../_atoms'; +import { + siwcAction, + siwcStatus, + siwcModels, + siwcErrorCode, + readCredential, + setCredential, + openExternal, + validateProviderCredentials, + recordChannelTest, + type SiwcAction, + type SiwcStatus, + type SiwcModel, +} from '../../lib/ipc'; + +const USAGE_URL = 'https://chatgpt.com/settings/usage'; +export function SiwcChannelFields({ + channelId, + onUserMutation, + onTested, +}: { + channelId: string; + onUserMutation?: () => void; + onTested?: () => void; +}) { + const { t } = useTranslation(); + const [status, setStatus] = useState(null); + const [models, setModels] = useState([]); + const [model, setModel] = useState(''); + const [busy, setBusy] = useState(false); + const [loadingModels, setLoadingModels] = useState(false); + const [message, setMessage] = useState(''); + const [notice, setNotice] = useState(''); + const alive = useRef(true); + const catalogRequest = useRef(0); + const titleId = useId(); + const explain = useCallback( + (error: unknown) => { + const code = siwcErrorCode(error); + if (code === 'remote_revocation_unconfirmed') return t('siwc.remoteUnconfirmed'); + return ( + t('siwc.errors.' + code, { defaultValue: t('siwc.failed') }) + + (code === 'request_failed' ? '' : ' (' + code + ')') + ); + }, + [t], + ); + const refresh = useCallback(async () => { + try { + const next = await siwcStatus(channelId); + if (alive.current) setStatus(next); + } catch (error) { + if (alive.current) setMessage(explain(error)); + } + }, [channelId, explain]); + useEffect(() => { + alive.current = true; + void refresh(); + const focus = () => void refresh(); + window.addEventListener('focus', focus); + return () => { + alive.current = false; + catalogRequest.current++; + window.removeEventListener('focus', focus); + }; + }, [refresh]); + useEffect(() => { + if (status?.state !== 'authorizing') return; + const timer = window.setInterval(() => void refresh(), 1000); + return () => window.clearInterval(timer); + }, [status?.state, refresh]); + const loadModels = useCallback(async () => { + const request = ++catalogRequest.current; + setLoadingModels(true); + setMessage(''); + try { + const [catalog, saved] = await Promise.all([ + siwcModels(channelId), + readCredential('ark.model_id', channelId), + ]); + if (!alive.current || request !== catalogRequest.current) return; + setModels(catalog); + setModel(saved ?? ''); + } catch (error) { + if (alive.current && request === catalogRequest.current) { + setModels([]); + setMessage(explain(error)); + } + } finally { + if (alive.current && request === catalogRequest.current) setLoadingModels(false); + } + }, [channelId, explain]); + const connected = status?.state === 'connected'; + useEffect(() => { + if (connected) void loadModels(); + else { + catalogRequest.current++; + setModels([]); + setLoadingModels(false); + } + }, [connected, status?.account?.id, loadModels]); + const action = async (action: SiwcAction, registrationId?: string) => { + onUserMutation?.(); + setBusy(true); + setMessage(''); + setNotice(''); + try { + const result = await siwcAction(channelId, action, registrationId); + if (!alive.current) return; + if (result?.remoteRevoked === false) setNotice(t('siwc.remoteUnconfirmed')); + await refresh(); + onTested?.(); + } catch (error) { + if (alive.current) setMessage(explain(error)); + } finally { + if (alive.current) setBusy(false); + } + }; + const chooseModel = async (value: string) => { + onUserMutation?.(); + setBusy(true); + setMessage(''); + try { + await setCredential('ark.model_id', value, channelId); + if (alive.current) { + setModel(value); + onTested?.(); + } + } catch (error) { + if (alive.current) setMessage(explain(error)); + } finally { + if (alive.current) setBusy(false); + } + }; + const verify = async () => { + setBusy(true); + setMessage(''); + const started = performance.now(); + try { + await validateProviderCredentials('llm', channelId); + await recordChannelTest( + 'llm', + channelId, + true, + Math.round(performance.now() - started), + null, + ); + if (alive.current) setMessage(t('siwc.testSuccess')); + } catch (error) { + if (alive.current) setMessage(explain(error)); + await recordChannelTest( + 'llm', + channelId, + false, + null, + 'siwc:' + JSON.stringify({ code: siwcErrorCode(error) }), + ).catch(() => {}); + } finally { + if (alive.current) { + setBusy(false); + await refresh(); + onTested?.(); + } + } + }; + if (!status) return

{message || t('common.loading')}

; + const authorizing = status.state === 'authorizing'; + const invalidModel = !!model && !models.some((m) => m.slug === model); + return ( +
+

{t('siwc.description')}

+ {status.accounts.length > 0 && ( + ({ value: account.id, label: account.label }))} + onChange={(id) => void action('select', id)} + ariaLabel={t('siwc.account')} + disabled={busy || authorizing} + /> + )} +
+ {t('siwc.states.' + status.state)} +
+
+ {authorizing ? ( + void action('cancel')}> + {t('siwc.cancel')} + + ) : ( + <> + + void action(status.state === 'usage_disabled' ? 'enable_usage' : 'sign_in') + } + > + {status.account + ? t(status.state === 'usage_disabled' ? 'siwc.enableUsage' : 'siwc.reauthorize') + : 'Continue with ChatGPT'} + + {status.accounts.length > 0 && ( + void action('add_account')}> + {t('siwc.addAccount')} + + )} + {status.account && status.state !== 'reauthorization_required' && ( + void action('disconnect')}> + {t('siwc.disconnect')} + + )} + + )} + void openExternal(USAGE_URL)} + > + {t('siwc.manageUsage')} + + {status.state === 'usage_limited' && ( + void action('retry')}> + {t('siwc.retry')} + + )} +
+ {status.error && ( +

+ {explain(status.error)} +

+ )} + {connected && ( + <> + + ({ value: m.slug, label: m.displayName }))} + onChange={(value) => void chooseModel(value)} + ariaLabel={t('siwc.model')} + placeholder={t(loadingModels ? 'common.loading' : 'siwc.selectModel')} + searchable + searchPlaceholder={t('settings.providers.searchModels')} + disabled={busy || loadingModels || models.length === 0} + /> + {invalidModel && ( +

+ {t('siwc.modelUnavailable', { model })} +

+ )} + {!loadingModels && models.length === 0 && ( +

{t('siwc.noModels')}

+ )} +
+ void loadModels()}> + {t('siwc.refreshModels')} + + void verify()} + > + {t('siwc.test')} + +
+

{t('siwc.testHint')}

+ + )} + {message && ( +

+ {message} +

+ )} + {notice && status.error?.code !== 'remote_revocation_unconfirmed' && ( +

+ {notice} +

+ )} + {status.welcomePending && ( + void action('acknowledge')}> +

{t('siwc.welcome')}

+

{t('siwc.description')}

+ void action('acknowledge')}> + {t('siwc.gotIt')} + +
+ )} +
+ ); +}