diff --git a/.github/workflows/website.yml b/.github/workflows/website.yml index 02da909..1f1de88 100644 --- a/.github/workflows/website.yml +++ b/.github/workflows/website.yml @@ -8,6 +8,8 @@ on: - "website/**" - "i18n/**" - "scripts/website_links.py" + - "pwa/**" + - "handoff-wasm/**" release: types: [published] workflow_dispatch: @@ -56,6 +58,38 @@ jobs: env: GH_TOKEN: ${{ github.token }} + # The phone PWA ships inside the same Pages artifact, at /vibe/phone/. + # Its iroh client is Rust compiled to wasm, so it needs a toolchain. + - name: setup Rust for the wasm client + uses: dtolnay/rust-toolchain@stable + with: + targets: wasm32-unknown-unknown + + - name: cache Rust build + uses: Swatinem/rust-cache@v2 + with: + workspaces: handoff-wasm + + - name: install wasm-bindgen-cli + uses: taiki-e/install-action@v2 + with: + # Must match the pinned `wasm-bindgen` dependency in handoff-wasm/Cargo.toml + # exactly, or the CLI refuses to process the module. + tool: wasm-bindgen-cli@0.2.122 + + - name: install binaryen + run: brew install binaryen + + - name: Build phone PWA + run: | + ./handoff-wasm/build.sh + pnpm --dir pwa install + pnpm --dir pwa build + mkdir -p website/dist/phone + cp -R pwa/dist/. website/dist/phone/ + env: + PWA_BASE: /vibe/phone/ + - name: Setup Pages uses: actions/configure-pages@v6 diff --git a/Cargo.lock b/Cargo.lock index 3c4ad88..b0ded32 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -8,6 +8,41 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" +[[package]] +name = "aead" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" +dependencies = [ + "crypto-common 0.1.7", + "generic-array", +] + +[[package]] +name = "aes" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures 0.2.17", +] + +[[package]] +name = "aes-gcm" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "831010a0f742e1209b3bcea8fab6a8e149051ba6099432c8cb2cc117dec3ead1" +dependencies = [ + "aead", + "aes", + "cipher", + "ctr", + "ghash", + "subtle", +] + [[package]] name = "aho-corasick" version = "1.1.4" @@ -32,6 +67,12 @@ dependencies = [ "alloc-no-stdlib", ] +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + [[package]] name = "alsa" version = "0.11.0" @@ -77,7 +118,7 @@ checksum = "38f109ee76f68b4767848cb5dc93bfcc7c425deca849c4c81fa11cdce525e3d2" dependencies = [ "apple-sdk", "bindgen", - "derive_more", + "derive_more 0.99.20", "regex", "serde", "thiserror 1.0.69", @@ -136,6 +177,21 @@ dependencies = [ "x11rb", ] +[[package]] +name = "arc-swap" +version = "1.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c049c0be4daef0b145cb3555416b3b8ef5b7888a38aea1a3a155801fe7b0810b" +dependencies = [ + "rustversion", +] + +[[package]] +name = "arrayvec" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56" + [[package]] name = "async-broadcast" version = "0.5.1" @@ -335,6 +391,17 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "async_io_stream" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d7b9decdf35d8908a7e3ef02f64c5e9b1695e230154c0e8de3969142d9b94c" +dependencies = [ + "futures", + "pharos", + "rustc_version", +] + [[package]] name = "atk" version = "0.18.2" @@ -364,6 +431,29 @@ version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" +[[package]] +name = "attohttpc" +version = "0.30.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16e2cdb6d5ed835199484bb92bb8b3edd526effe995c61732580439c1a67e2e9" +dependencies = [ + "base64 0.22.1", + "http", + "log", + "url", +] + +[[package]] +name = "auto-launch" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f012b8cc0c850f34117ec8252a44418f2e34a2cf501de89e29b241ae5f79471" +dependencies = [ + "dirs 4.0.0", + "thiserror 1.0.69", + "winreg 0.10.1", +] + [[package]] name = "autocfg" version = "1.5.0" @@ -392,6 +482,23 @@ dependencies = [ "fs_extra", ] +[[package]] +name = "backon" +version = "1.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cffb0e931875b666fc4fcb20fee52e9bbd1ef836fd9e9e04ec21555f9f85f7ef" +dependencies = [ + "fastrand 2.3.0", + "gloo-timers", + "tokio", +] + +[[package]] +name = "base16ct" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd307490d624467aa6f74b0eabb77633d1f758a7b25f12bceb0b22e08d9726f6" + [[package]] name = "base64" version = "0.21.7" @@ -404,6 +511,12 @@ version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + [[package]] name = "bindgen" version = "0.63.0" @@ -441,6 +554,19 @@ dependencies = [ "serde_core", ] +[[package]] +name = "blake3" +version = "1.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d9e454fc11f76977dc803893aff6304ed33d6a26efae8696573bea74baa27ae" +dependencies = [ + "arrayvec", + "cc", + "cfg-if", + "constant_time_eq", + "cpufeatures 0.3.0", +] + [[package]] name = "block-buffer" version = "0.10.4" @@ -450,6 +576,15 @@ dependencies = [ "generic-array", ] +[[package]] +name = "block-buffer" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa" +dependencies = [ + "hybrid-array", +] + [[package]] name = "block2" version = "0.5.1" @@ -662,6 +797,17 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" +[[package]] +name = "chacha20" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "rand_core 0.10.1", +] + [[package]] name = "chrono" version = "0.4.44" @@ -676,6 +822,16 @@ dependencies = [ "windows-link 0.2.1", ] +[[package]] +name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common 0.1.7", + "inout", +] + [[package]] name = "clang-sys" version = "1.8.1" @@ -705,6 +861,21 @@ dependencies = [ "cc", ] +[[package]] +name = "cmov" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a" + +[[package]] +name = "cobs" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fa961b519f0b462e3a3b4a34b64d119eeaca1d59af726fe450bbba07a9fc0a1" +dependencies = [ + "thiserror 2.0.18", +] + [[package]] name = "combine" version = "4.6.7" @@ -724,6 +895,12 @@ dependencies = [ "crossbeam-utils", ] +[[package]] +name = "const-oid" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" + [[package]] name = "const-random" version = "0.1.18" @@ -744,12 +921,27 @@ dependencies = [ "tiny-keccak", ] +[[package]] +name = "constant_time_eq" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d52eff69cd5e647efe296129160853a42795992097e8af39800e1060caeea9b" + [[package]] name = "convert_case" version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6245d59a3e82a7fc217c5828a6692dbc6dfb63a0c8c90495621f7b9d79704a0e" +[[package]] +name = "convert_case" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "633458d4ef8c78b72454de2d54fd6ab2e60f9e02be22f3c6104cdc8a4e0fceb9" +dependencies = [ + "unicode-segmentation", +] + [[package]] name = "cookie" version = "0.18.1" @@ -797,6 +989,16 @@ dependencies = [ "url", ] +[[package]] +name = "cordyceps" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b9ab7e0ca1d179628fa0172b2b97203c7fa0cd81be2448bd446fb9559ca9261" +dependencies = [ + "loom", + "tracing", +] + [[package]] name = "core-foundation" version = "0.9.4" @@ -913,6 +1115,15 @@ dependencies = [ "libc", ] +[[package]] +name = "cpufeatures" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" +dependencies = [ + "libc", +] + [[package]] name = "crash-context" version = "0.6.3" @@ -946,6 +1157,12 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "critical-section" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b" + [[package]] name = "crossbeam-channel" version = "0.5.15" @@ -955,6 +1172,15 @@ dependencies = [ "crossbeam-utils", ] +[[package]] +name = "crossbeam-epoch" +version = "0.9.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d6914041f254d6e9176c01941b21115dcfb7089e55135a35411081bd106ef3f" +dependencies = [ + "crossbeam-utils", +] + [[package]] name = "crossbeam-utils" version = "0.8.21" @@ -977,6 +1203,16 @@ dependencies = [ "typenum", ] +[[package]] +name = "crypto-common" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" +dependencies = [ + "hybrid-array", + "rand_core 0.10.1", +] + [[package]] name = "cssparser" version = "0.29.6" @@ -1014,6 +1250,53 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "ctr" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835" +dependencies = [ + "cipher", +] + +[[package]] +name = "ctutils" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d5515a3834141de9eafb9717ad39eea8247b5674e6066c404e8c4b365d2a29e" +dependencies = [ + "cmov", +] + +[[package]] +name = "curve25519-dalek" +version = "5.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5eed333089e2e1c1ac8c6c0398e5e2497b4c9926ca6d0365ed1e099afa5bc23" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "curve25519-dalek-derive", + "digest 0.11.3", + "fiat-crypto", + "rand_core 0.10.1", + "rustc_version", + "serde", + "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.117", +] + [[package]] name = "darling" version = "0.14.4" @@ -1024,6 +1307,16 @@ dependencies = [ "darling_macro 0.14.4", ] +[[package]] +name = "darling" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee" +dependencies = [ + "darling_core 0.20.11", + "darling_macro 0.20.11", +] + [[package]] name = "darling" version = "0.21.3" @@ -1048,6 +1341,20 @@ dependencies = [ "syn 1.0.109", ] +[[package]] +name = "darling_core" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d00b9596d185e565c2207a0b01f8bd1a135483d02d9b7b0a54b11da8d53412e" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "strsim 0.11.1", + "syn 2.0.117", +] + [[package]] name = "darling_core" version = "0.21.3" @@ -1073,6 +1380,17 @@ dependencies = [ "syn 1.0.109", ] +[[package]] +name = "darling_macro" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" +dependencies = [ + "darling_core 0.20.11", + "quote", + "syn 2.0.117", +] + [[package]] name = "darling_macro" version = "0.21.3" @@ -1090,12 +1408,49 @@ version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0c87e182de0887fd5361989c677c4e8f5000cd9491d6d563161a8f3a5519fc7f" +[[package]] +name = "data-encoding" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4583a4551df46e2792f82ceeac45e850d2e2d5debba0b91f102385cda5b11f06" + +[[package]] +name = "data-encoding-macro" +version = "0.1.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6a127ecbb3c4632e1525380e04c0c3fcf8dcb44d32a79ea290d8a36906edcd8" +dependencies = [ + "data-encoding", + "data-encoding-macro-internal", +] + +[[package]] +name = "data-encoding-macro-internal" +version = "0.1.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c54e03a951783e8b327515db3f2a2fd0e3bed362a96b066f341ce66ed49b4ead" +dependencies = [ + "data-encoding", + "syn 3.0.3", +] + [[package]] name = "data-url" version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "be1e0bca6c3637f992fc1cc7cbc52a78c1ef6db076dbf1059c4323d6a2048376" +[[package]] +name = "der" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a69dedd701da44b0536442edf09c81a64b0ab97a7a4a5e3d1971f00027cbc63d" +dependencies = [ + "const-oid", + "pem-rfc7468", + "zeroize", +] + [[package]] name = "deranged" version = "0.5.8" @@ -1134,7 +1489,16 @@ version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f59169f400d8087f238c5c0c7db6a28af18681717f3b623227d92f397e938c7" dependencies = [ - "derive_builder_macro", + "derive_builder_macro 0.13.1", +] + +[[package]] +name = "derive_builder" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "507dfb09ea8b7fa618fcf76e953f4f5e192547945816d5358edffe39f6f94947" +dependencies = [ + "derive_builder_macro 0.20.2", ] [[package]] @@ -1149,37 +1513,107 @@ dependencies = [ "syn 1.0.109", ] +[[package]] +name = "derive_builder_core" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d5bcf7b024d6835cfb3d473887cd966994907effbe9227e8c8219824d06c4e8" +dependencies = [ + "darling 0.20.11", + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "derive_builder_macro" version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "870368c3fb35b8031abb378861d4460f573b92238ec2152c927a21f77e3e0127" dependencies = [ - "derive_builder_core", + "derive_builder_core 0.13.1", "syn 1.0.109", ] +[[package]] +name = "derive_builder_macro" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab63b0e2bf4d5928aff72e83a7dace85d7bba5fe12dcc3c5a572d78caffd3f3c" +dependencies = [ + "derive_builder_core 0.20.2", + "syn 2.0.117", +] + [[package]] name = "derive_more" version = "0.99.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6edb4b64a43d977b8e99788fe3a04d483834fba1215a7e02caa415b626497f7f" dependencies = [ - "convert_case", + "convert_case 0.4.0", "proc-macro2", "quote", "rustc_version", "syn 2.0.117", ] +[[package]] +name = "derive_more" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134" +dependencies = [ + "derive_more-impl", +] + +[[package]] +name = "derive_more-impl" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" +dependencies = [ + "convert_case 0.10.0", + "proc-macro2", + "quote", + "rustc_version", + "syn 2.0.117", + "unicode-xid", +] + +[[package]] +name = "diatomic-waker" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab03c107fafeb3ee9f5925686dbb7a73bc76e3932abb0d2b365cb64b169cf04c" + [[package]] name = "digest" version = "0.10.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ - "block-buffer", - "crypto-common", + "block-buffer 0.10.4", + "crypto-common 0.1.7", +] + +[[package]] +name = "digest" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" +dependencies = [ + "block-buffer 0.12.1", + "crypto-common 0.2.2", +] + +[[package]] +name = "dirs" +version = "4.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca3aa72a6f96ea37bbc5aa912f6788242832f75369bdfdadcb0e38423f100059" +dependencies = [ + "dirs-sys 0.3.7", ] [[package]] @@ -1188,7 +1622,18 @@ version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e" dependencies = [ - "dirs-sys", + "dirs-sys 0.5.0", +] + +[[package]] +name = "dirs-sys" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b1d1d91c932ef41c0f2663aa8b0ca0342d444d842c06914aa0a7e352d0bada6" +dependencies = [ + "libc", + "redox_users 0.4.6", + "winapi", ] [[package]] @@ -1199,7 +1644,7 @@ checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" dependencies = [ "libc", "option-ext", - "redox_users", + "redox_users 0.5.2", "windows-sys 0.61.2", ] @@ -1309,6 +1754,33 @@ version = "1.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" +[[package]] +name = "ed25519" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29fcf32e6c73d1079f83ab4d782de2d81620346a5f38c6237a86a22f8368980a" +dependencies = [ + "pkcs8", + "serdect", + "signature", +] + +[[package]] +name = "ed25519-dalek" +version = "3.0.0-rc.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b011170fe4f04665565b4110afef66774fe9ffff278f3eb5b81cc73d26e27d60" +dependencies = [ + "curve25519-dalek", + "ed25519", + "rand_core 0.10.1", + "serde", + "sha2 0.11.0", + "signature", + "subtle", + "zeroize", +] + [[package]] name = "either" version = "1.15.0" @@ -1326,7 +1798,7 @@ dependencies = [ "rustc_version", "toml 0.9.12+spec-1.1.0", "vswhom", - "winreg", + "winreg 0.55.0", ] [[package]] @@ -1335,6 +1807,18 @@ version = "1.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4ef6b89e5b37196644d8796de5268852ff179b44e96276cf4290264843743bb7" +[[package]] +name = "embedded-io" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef1a6892d9eef45c8fa6b9e0086428a2cca8491aca8f787c534a3d6d0bcb3ced" + +[[package]] +name = "embedded-io" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edd0f118536f44f5ccd48bcb8b111bdc3de888b58c74639dfb034a357d0f206d" + [[package]] name = "encoding_rs" version = "0.8.35" @@ -1369,6 +1853,17 @@ dependencies = [ "xkeysym", ] +[[package]] +name = "enum-assoc" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0590c4a94da3372e83493b956755a6e2266830b6e4e3b101afe66e3f39477b91" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + [[package]] name = "enumflags2" version = "0.7.12" @@ -1521,6 +2016,12 @@ dependencies = [ "simd-adler32", ] +[[package]] +name = "fiat-crypto" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64cd1e32ddd350061ae6edb1b082d7c54915b5c672c389143b9a63403a109f24" + [[package]] name = "field-offset" version = "0.3.6" @@ -1576,6 +2077,12 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + [[package]] name = "foreign-types" version = "0.3.2" @@ -1667,6 +2174,19 @@ dependencies = [ "futures-util", ] +[[package]] +name = "futures-buffered" +version = "0.2.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4421cb78ee172b6b06080093479d3c50f058e7c81b7d577bbb8d118d551d4cd5" +dependencies = [ + "cordyceps", + "diatomic-waker", + "futures-core", + "pin-project-lite", + "spin", +] + [[package]] name = "futures-channel" version = "0.3.32" @@ -1876,6 +2396,21 @@ dependencies = [ "x11", ] +[[package]] +name = "generator" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3b854b0e584ead1a33f18b2fcad7cf7be18b3875c78816b753639aa501513ae" +dependencies = [ + "cc", + "cfg-if", + "libc", + "log", + "rustversion", + "windows-link 0.2.1", + "windows-result 0.4.1", +] + [[package]] name = "generic-array" version = "0.14.7" @@ -1941,10 +2476,23 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" dependencies = [ "cfg-if", + "js-sys", "libc", "r-efi 6.0.0", + "rand_core 0.10.1", "wasip2", "wasip3", + "wasm-bindgen", +] + +[[package]] +name = "ghash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0d8a4362ccb29cb0b265253fb0a2728f592895ee6854fd9bc13f2ffda266ff1" +dependencies = [ + "opaque-debug", + "polyval", ] [[package]] @@ -2050,6 +2598,18 @@ dependencies = [ "xkeysym", ] +[[package]] +name = "gloo-timers" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbb143cf96099802033e0d4f4963b19fd2e0b728bcf076cd9cf7f6634f092994" +dependencies = [ + "futures-channel", + "futures-core", + "js-sys", + "wasm-bindgen", +] + [[package]] name = "gobject-sys" version = "0.18.0" @@ -2161,7 +2721,7 @@ version = "0.15.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" dependencies = [ - "foldhash", + "foldhash 0.1.5", ] [[package]] @@ -2170,6 +2730,17 @@ version = "0.16.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash 0.2.0", +] + [[package]] name = "heck" version = "0.4.1" @@ -2200,6 +2771,83 @@ version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" +[[package]] +name = "hickory-net" +version = "0.26.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2295ed2f9c31e471e1428a8f88a3f0e1f4b27c15049592138d1eebe9c35b183" +dependencies = [ + "async-trait", + "bytes", + "cfg-if", + "data-encoding", + "futures-channel", + "futures-io", + "futures-util", + "h2", + "hickory-proto", + "http", + "idna", + "ipnet", + "jni 0.22.4", + "rand 0.10.2", + "rustls", + "thiserror 2.0.18", + "tinyvec", + "tokio", + "tokio-rustls", + "tracing", + "url", +] + +[[package]] +name = "hickory-proto" +version = "0.26.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bab31817bfb44672a252e97fe81cd0c18d1b2cf892108922f6818820df8c643" +dependencies = [ + "data-encoding", + "idna", + "ipnet", + "jni 0.22.4", + "once_cell", + "prefix-trie", + "rand 0.10.2", + "ring", + "thiserror 2.0.18", + "tinyvec", + "tracing", + "url", +] + +[[package]] +name = "hickory-resolver" +version = "0.26.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0d58d28879ceecde6607729660c2667a081ccdc082e082675042793960f178c" +dependencies = [ + "cfg-if", + "futures-util", + "hickory-net", + "hickory-proto", + "ipconfig", + "ipnet", + "jni 0.22.4", + "moka", + "ndk-context", + "once_cell", + "parking_lot", + "rand 0.10.2", + "resolv-conf", + "rustls", + "smallvec", + "system-configuration", + "thiserror 2.0.18", + "tokio", + "tokio-rustls", + "tracing", +] + [[package]] name = "home" version = "0.5.12" @@ -2272,6 +2920,21 @@ version = "1.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[package]] +name = "hybrid-array" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "707114b52a152fa7bdb290cd7cd5912d9467273b6d74e21b8d81aca1f8533f6b" +dependencies = [ + "typenum", +] + [[package]] name = "hyper" version = "1.8.1" @@ -2286,6 +2949,7 @@ dependencies = [ "http", "http-body", "httparse", + "httpdate", "itoa", "pin-project-lite", "pin-utils", @@ -2479,6 +3143,12 @@ version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" +[[package]] +name = "identity-hash" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfdd7caa900436d8f13b2346fe10257e0c05c1f1f9e351f4f5d57c03bd5f45da" + [[package]] name = "idna" version = "1.1.0" @@ -2500,6 +3170,26 @@ dependencies = [ "icu_properties", ] +[[package]] +name = "igd-next" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de7238d487a9aff61f81b5ab41c0a841532a115a398b5fa92a2fadd0885e2581" +dependencies = [ + "attohttpc", + "bytes", + "futures", + "http", + "http-body-util", + "hyper", + "hyper-util", + "log", + "rand 0.10.2", + "tokio", + "url", + "xmltree", +] + [[package]] name = "image" version = "0.25.9" @@ -2572,6 +3262,15 @@ dependencies = [ "libc", ] +[[package]] +name = "inout" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +dependencies = [ + "generic-array", +] + [[package]] name = "instant" version = "0.1.13" @@ -2592,11 +3291,27 @@ dependencies = [ "windows-sys 0.48.0", ] +[[package]] +name = "ipconfig" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d40460c0ce33d6ce4b0630ad68ff63d6661961c48b6dba35e5a4d81cfb48222" +dependencies = [ + "socket2 0.6.2", + "widestring", + "windows-registry 0.6.1", + "windows-result 0.4.1", + "windows-sys 0.61.2", +] + [[package]] name = "ipnet" version = "2.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" +dependencies = [ + "serde", +] [[package]] name = "iri-string" @@ -2608,6 +3323,173 @@ dependencies = [ "serde", ] +[[package]] +name = "iroh" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fca9b4b462c343ff88fc0af4096c186f939b602a0bc08723536ef2c31c93971" +dependencies = [ + "backon", + "blake3", + "bytes", + "cfg_aliases", + "ctutils", + "data-encoding", + "derive_more 2.1.1", + "ed25519-dalek", + "futures-util", + "getrandom 0.4.2", + "hickory-resolver", + "http", + "ipnet", + "iroh-base", + "iroh-dns", + "iroh-metrics", + "iroh-relay", + "n0-error", + "n0-future", + "n0-watcher", + "netwatch", + "noq", + "noq-proto", + "noq-udp", + "papaya", + "pin-project", + "portable-atomic", + "portmapper", + "rand 0.10.2", + "reqwest 0.13.2", + "rustc-hash 2.1.1", + "rustls", + "rustls-pki-types", + "serde", + "smallvec", + "strum", + "time", + "tokio", + "tokio-stream", + "tokio-util", + "tracing", + "url", + "wasm-bindgen-futures", +] + +[[package]] +name = "iroh-base" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6be73e16ee21c923aca9b3121aaa0db936f7c7ecc156ff47b8dac944c68d59a8" +dependencies = [ + "curve25519-dalek", + "data-encoding", + "data-encoding-macro", + "derive_more 2.1.1", + "ed25519-dalek", + "getrandom 0.4.2", + "n0-error", + "rand 0.10.2", + "serde", + "url", + "zeroize", +] + +[[package]] +name = "iroh-dns" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "516e4eedc38e33ab69a6bd325520332dc3d67b25454e2d590ebb84a25240dd9a" +dependencies = [ + "arc-swap", + "cfg_aliases", + "derive_more 2.1.1", + "hickory-resolver", + "iroh-base", + "n0-error", + "n0-future", + "ndk-context", + "portable-atomic", + "rand 0.10.2", + "rustls", + "simple-dns", + "strum", + "tokio", + "tracing", + "url", +] + +[[package]] +name = "iroh-metrics" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291065721ad7c477b972e581bbc528df031dc8eb5e39fe1ff3300ae5dfb157ef" +dependencies = [ + "iroh-metrics-derive", + "itoa", + "n0-error", + "portable-atomic", + "ryu", + "serde", + "tracing", +] + +[[package]] +name = "iroh-metrics-derive" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ae5f0c4405d1fbc9fb16ff422ca40620e93dc36c30ecaba0c2aee3992b7bd48" +dependencies = [ + "heck 0.5.0", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "iroh-relay" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8149bb6a57126225a07d6928846d82dcedfd24ea0f863ef7b2eb475e1d726354" +dependencies = [ + "blake3", + "bytes", + "cfg_aliases", + "data-encoding", + "derive_more 2.1.1", + "getrandom 0.4.2", + "hickory-resolver", + "http", + "http-body-util", + "hyper", + "hyper-util", + "iroh-base", + "iroh-dns", + "iroh-metrics", + "lru", + "n0-error", + "n0-future", + "noq", + "noq-proto", + "num_enum", + "pin-project", + "postcard", + "rand 0.10.2", + "reqwest 0.13.2", + "rustls", + "rustls-pki-types", + "serde", + "serde_bytes", + "strum", + "tokio", + "tokio-rustls", + "tokio-util", + "tokio-websockets", + "tracing", + "url", + "vergen-gitcl", + "webpki-roots", + "ws_stream_wasm", +] + [[package]] name = "is-docker" version = "0.2.0" @@ -2778,7 +3660,7 @@ dependencies = [ "apple-sys", "cfg-if", "core-foundation 0.9.4", - "derive_builder", + "derive_builder 0.13.1", "thiserror 1.0.69", "windows 0.52.0", "zbus 3.15.2", @@ -2962,6 +3844,28 @@ version = "0.4.29" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" +[[package]] +name = "loom" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "419e0dc8046cb947daa77eb95ae174acfbddb7673b4151f56d1eed8e93fbfaca" +dependencies = [ + "cfg-if", + "generator", + "scoped-tls", + "tracing", + "tracing-subscriber", +] + +[[package]] +name = "lru" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d2f2f9b4ba7e6b24d95e7e899329d35be83bcded72c8540cdd5368932d1d90a" +dependencies = [ + "hashbrown 0.17.1", +] + [[package]] name = "lru-slab" version = "0.1.2" @@ -2974,6 +3878,12 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4" +[[package]] +name = "mac-addr" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3d25b0e0b648a86960ac23b7ad4abb9717601dec6f66c165f5b037f3f03065f" + [[package]] name = "mac-notification-sys" version = "0.6.9" @@ -3144,6 +4054,23 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "moka" +version = "0.12.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4293f18e7567a1caf3c584855554377025c65e0aa445344d04171f5ad63d19b9" +dependencies = [ + "crossbeam-channel", + "crossbeam-epoch", + "crossbeam-utils", + "equivalent", + "parking_lot", + "portable-atomic", + "smallvec", + "tagptr", + "uuid", +] + [[package]] name = "moxcms" version = "0.7.11" @@ -3175,6 +4102,59 @@ dependencies = [ "windows-sys 0.60.2", ] +[[package]] +name = "n0-error" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c37e81176a83a77d2514528b91bdafc70ef88aab428f0e1b91aebb8d99888895" +dependencies = [ + "n0-error-macros", + "spez", +] + +[[package]] +name = "n0-error-macros" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2acd8b070213b0299282f884b4beba4e7b52d624fdcd504a3ad3665390c11e1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "n0-future" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2ab99dfb861450e68853d34ae665243a88b8c493d01ba957321a1e9b2312bbe" +dependencies = [ + "cfg_aliases", + "derive_more 2.1.1", + "futures-buffered", + "futures-lite 2.6.1", + "futures-util", + "js-sys", + "pin-project", + "send_wrapper", + "tokio", + "tokio-util", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-time", +] + +[[package]] +name = "n0-watcher" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbc618745ad0b7414b149d0517ad8b5573b2fb4d4e2717add3d2446ce1fdd826" +dependencies = [ + "derive_more 2.1.1", + "n0-error", + "n0-future", +] + [[package]] name = "native-tls" version = "0.2.18" @@ -3222,6 +4202,119 @@ dependencies = [ "jni-sys 0.3.0", ] +[[package]] +name = "netdev" +version = "0.45.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "569dfbdd2efd771b24ec9bb57f956e04d4fbfc72f62b2f11961723f9b3f4b020" +dependencies = [ + "block2 0.6.2", + "dispatch2", + "dlopen2", + "ipnet", + "jni 0.21.1", + "libc", + "mac-addr", + "ndk-context", + "netlink-packet-core", + "netlink-packet-route", + "netlink-sys", + "objc2 0.6.4", + "objc2-core-foundation", + "objc2-core-wlan", + "objc2-foundation 0.3.2", + "objc2-system-configuration", + "once_cell", + "plist", + "windows-sys 0.61.2", +] + +[[package]] +name = "netlink-packet-core" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b897d7bd4f0af82e68d40d0344cf37e97f9c97ddf74a098de3e4da05e96ca395" +dependencies = [ + "paste", +] + +[[package]] +name = "netlink-packet-route" +version = "0.31.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2288fcb784eb3defd5fb16f4c4160d5f477de192eac730f43e1d11c24d9a007" +dependencies = [ + "bitflags 2.11.0", + "libc", + "log", + "netlink-packet-core", +] + +[[package]] +name = "netlink-proto" +version = "0.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93af8261786086024cd5e96e0a991dd65ced07bbf7c233a487bbc96b971d5539" +dependencies = [ + "bytes", + "futures-channel", + "futures-util", + "log", + "netlink-packet-core", + "netlink-sys", + "thiserror 2.0.18", +] + +[[package]] +name = "netlink-sys" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd6c30ed10fa69cc491d491b85cc971f6bdeb8e7367b7cde2ee6cc878d583fae" +dependencies = [ + "bytes", + "futures-util", + "libc", + "log", + "tokio", +] + +[[package]] +name = "netwatch" +version = "0.19.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d9cbe01741347ef750d743d6690603f5eed8341e679fb51c8e629337aa11976" +dependencies = [ + "atomic-waker", + "bytes", + "cfg_aliases", + "derive_more 2.1.1", + "ipnet", + "js-sys", + "libc", + "n0-error", + "n0-future", + "n0-watcher", + "netdev", + "netlink-packet-core", + "netlink-packet-route", + "netlink-proto", + "netlink-sys", + "noq-udp", + "objc2-core-foundation", + "objc2-system-configuration", + "pin-project-lite", + "serde", + "socket2 0.6.2", + "time", + "tokio", + "tokio-util", + "tracing", + "web-sys", + "windows 0.62.2", + "windows-result 0.4.1", + "wmi", +] + [[package]] name = "new_debug_unreachable" version = "1.0.6" @@ -3287,6 +4380,68 @@ dependencies = [ "memchr", ] +[[package]] +name = "noq" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4bf95190af1bd4a00a10e8255ca0c8ddd9e9a9f5e79151d7a7eb6d56aff5dc89" +dependencies = [ + "bytes", + "cfg_aliases", + "derive_more 2.1.1", + "noq-proto", + "noq-udp", + "pin-project-lite", + "rustc-hash 2.1.1", + "rustls", + "socket2 0.6.2", + "thiserror 2.0.18", + "tokio", + "tokio-stream", + "tracing", + "web-time", +] + +[[package]] +name = "noq-proto" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baa7b5ccd819a9c68a0d955e67a881032d09b1a17219b1f90b0997a0888e1a15" +dependencies = [ + "aes-gcm", + "bytes", + "derive_more 2.1.1", + "enum-assoc", + "getrandom 0.4.2", + "identity-hash", + "lru-slab", + "rand 0.10.2", + "rand_pcg 0.10.2", + "ring", + "rustc-hash 2.1.1", + "rustls", + "rustls-pki-types", + "slab", + "sorted-index-buffer", + "thiserror 2.0.18", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "noq-udp" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3137a52df66c20090a889828d1c655f21f52294cba64e5c4fbb04fc83eee7c8e" +dependencies = [ + "cfg_aliases", + "libc", + "socket2 0.6.2", + "tracing", + "windows-sys 0.61.2", +] + [[package]] name = "notify" version = "8.2.0" @@ -3385,6 +4540,15 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "num_threads" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c7398b9c8b70908f6371f47ed36737907c87c52af34c268fed0bf0ceb92ead9" +dependencies = [ + "libc", +] + [[package]] name = "objc" version = "0.2.7" @@ -3623,6 +4787,20 @@ dependencies = [ "objc2-io-surface", ] +[[package]] +name = "objc2-core-wlan" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c71e34919aba0d701380d911702455038a8a3587467fe0141d6a71501e7ffe48" +dependencies = [ + "bitflags 2.11.0", + "objc2 0.6.4", + "objc2-core-foundation", + "objc2-foundation 0.3.2", + "objc2-security", + "objc2-security-foundation", +] + [[package]] name = "objc2-encode" version = "4.1.0" @@ -3723,6 +4901,41 @@ dependencies = [ "objc2-foundation 0.3.2", ] +[[package]] +name = "objc2-security" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe137109bd1e8b5a99390f77a7d8b2961dafc1a1c5db8f2e60329ad6d895a" +dependencies = [ + "bitflags 2.11.0", + "objc2 0.6.4", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-security-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef76382e9cedd18123099f17638715cc3d81dba3637d4c0d39ab69df2ef345a5" +dependencies = [ + "objc2 0.6.4", + "objc2-foundation 0.3.2", +] + +[[package]] +name = "objc2-system-configuration" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7216bd11cbda54ccabcab84d523dc93b858ec75ecfb3a7d89513fa22464da396" +dependencies = [ + "bitflags 2.11.0", + "dispatch2", + "libc", + "objc2 0.6.4", + "objc2-core-foundation", + "objc2-security", +] + [[package]] name = "objc2-ui-kit" version = "0.3.2" @@ -3773,6 +4986,16 @@ name = "once_cell" version = "1.21.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" +dependencies = [ + "critical-section", + "portable-atomic", +] + +[[package]] +name = "opaque-debug" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" [[package]] name = "open" @@ -3931,6 +5154,16 @@ dependencies = [ "system-deps", ] +[[package]] +name = "papaya" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da2442474a9404698c42509b8967f437249dbc7b50493e83020333d3943ec0ae" +dependencies = [ + "equivalent", + "seize", +] + [[package]] name = "parking" version = "2.2.1" @@ -3960,6 +5193,12 @@ dependencies = [ "windows-link 0.2.1", ] +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + [[package]] name = "pathdiff" version = "0.2.3" @@ -3972,6 +5211,15 @@ version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "19b17cddbe7ec3f8bc800887bab5e717348c95ea2ca0b1bf0837fb964dc67099" +[[package]] +name = "pem-rfc7468" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6305423e0e7738146434843d1694d621cce767262b2a86910beab705e4493d9" +dependencies = [ + "base64ct", +] + [[package]] name = "percent-encoding" version = "2.3.2" @@ -3989,6 +5237,16 @@ dependencies = [ "indexmap 2.13.0", ] +[[package]] +name = "pharos" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9567389417feee6ce15dd6527a8a1ecac205ef62c2932bcf3d9f6fc5b78b414" +dependencies = [ + "futures", + "rustc_version", +] + [[package]] name = "phf" version = "0.8.0" @@ -4123,6 +5381,26 @@ dependencies = [ "siphasher 1.0.2", ] +[[package]] +name = "pin-project" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2466b2336ed02bcdca6b294417127b90ec92038d1d5c4fbeac971a922e0e0924" +dependencies = [ + "pin-project-internal", +] + +[[package]] +name = "pin-project-internal" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c96395f0a926bc13b1c17622aaddda1ecb55d49c8f1bf9777e4d877800a43f8b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "pin-project-lite" version = "0.2.17" @@ -4146,6 +5424,16 @@ dependencies = [ "futures-io", ] +[[package]] +name = "pkcs8" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "451913da69c775a56034ea8d9003d27ee8948e12443eae7c038ba100a4f21cb7" +dependencies = [ + "der", + "spki", +] + [[package]] name = "pkg-config" version = "0.3.32" @@ -4227,6 +5515,80 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "polyval" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d1fe60d06143b2430aa532c94cfe9e29783047f06c0d7fd359a9a51b729fa25" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "opaque-debug", + "universal-hash", +] + +[[package]] +name = "portable-atomic" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05c8b63e8d9609db387f0324918f81d68fe27748f084ef092fb35954d0539a85" +dependencies = [ + "serde", +] + +[[package]] +name = "portmapper" +version = "0.19.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb3713e4977408279158444a18c1a01ac9bf2e7eaf1fbfd1a19ac9cd18d90721" +dependencies = [ + "base64 0.22.1", + "bytes", + "derive_more 2.1.1", + "hyper-util", + "igd-next", + "iroh-metrics", + "libc", + "n0-error", + "n0-future", + "netwatch", + "num_enum", + "rand 0.10.2", + "serde", + "smallvec", + "socket2 0.6.2", + "time", + "tokio", + "tokio-util", + "tower-layer", + "tracing", + "url", +] + +[[package]] +name = "postcard" +version = "1.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6764c3b5dd454e283a30e6dfe78e9b31096d9e32036b5d1eaac7a6119ccb9a24" +dependencies = [ + "cobs", + "embedded-io 0.4.0", + "embedded-io 0.6.1", + "postcard-derive", + "serde", +] + +[[package]] +name = "postcard-derive" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0232bd009a197ceec9cc881ba46f727fcd8060a2d8d6a9dde7a69030a6fe2bb" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "potential_utf" version = "0.1.4" @@ -4257,6 +5619,17 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c" +[[package]] +name = "prefix-trie" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cf6e3177f0684016a5c209b00882e15f8bdd3f3bb48f0491df10cd102d0c6e7" +dependencies = [ + "either", + "ipnet", + "num-traits", +] + [[package]] name = "prettyplease" version = "0.2.37" @@ -4478,7 +5851,7 @@ dependencies = [ "rand_chacha 0.2.2", "rand_core 0.5.1", "rand_hc", - "rand_pcg", + "rand_pcg 0.2.1", ] [[package]] @@ -4502,6 +5875,17 @@ dependencies = [ "rand_core 0.9.5", ] +[[package]] +name = "rand" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" +dependencies = [ + "chacha20", + "getrandom 0.4.2", + "rand_core 0.10.1", +] + [[package]] name = "rand_chacha" version = "0.2.2" @@ -4559,6 +5943,12 @@ dependencies = [ "getrandom 0.3.4", ] +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + [[package]] name = "rand_hc" version = "0.2.0" @@ -4577,6 +5967,15 @@ dependencies = [ "rand_core 0.5.1", ] +[[package]] +name = "rand_pcg" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a" +dependencies = [ + "rand_core 0.10.1", +] + [[package]] name = "raw-window-handle" version = "0.6.2" @@ -4601,6 +6000,17 @@ dependencies = [ "bitflags 2.11.0", ] +[[package]] +name = "redox_users" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43" +dependencies = [ + "getrandom 0.2.17", + "libredox", + "thiserror 1.0.69", +] + [[package]] name = "redox_users" version = "0.5.2" @@ -4751,6 +6161,12 @@ dependencies = [ "web-sys", ] +[[package]] +name = "resolv-conf" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e061d1b48cb8d38042de4ae0a7a6401009d6143dc80d2e2d6f31f0bdd6470c7" + [[package]] name = "rfd" version = "0.16.0" @@ -4889,6 +6305,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "758025cb5fccfd3bc2fd74708fd4682be41d99e5dff73c377c0646c6012c73a4" dependencies = [ "aws-lc-rs", + "log", "once_cell", "ring", "rustls-pki-types", @@ -5039,6 +6456,12 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "scoped-tls" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1cf6437eb19a8f4a6cc0f7dca544973b0b78843adbfeb3683d1a94a0024a294" + [[package]] name = "scopeguard" version = "1.2.0" @@ -5068,6 +6491,12 @@ dependencies = [ "libc", ] +[[package]] +name = "seize" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b55fb86dfd3a2f5f76ea78310a88f96c4ea21a3031f8d212443d56123fd0521" + [[package]] name = "selectors" version = "0.24.0" @@ -5076,7 +6505,7 @@ checksum = "0c37578180969d00692904465fb7f6b3d50b9a2b952b87c23d0e2e5cb5013416" dependencies = [ "bitflags 1.3.2", "cssparser", - "derive_more", + "derive_more 0.99.20", "fxhash", "log", "phf 0.8.0", @@ -5096,6 +6525,12 @@ dependencies = [ "serde_core", ] +[[package]] +name = "send_wrapper" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd0b0ec5f1c1ca621c432a25813d8d60c88abe6d3e08a3eb9cf37d97a0fe3d73" + [[package]] name = "serde" version = "1.0.228" @@ -5118,6 +6553,16 @@ dependencies = [ "typeid", ] +[[package]] +name = "serde_bytes" +version = "0.11.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5d440709e79d88e51ac01c4b72fc6cb7314017bb7da9eeff678aa94c10e3ea8" +dependencies = [ + "serde", + "serde_core", +] + [[package]] name = "serde_core" version = "1.0.228" @@ -5234,6 +6679,16 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "serdect" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66cf8fedced2fcf12406bcb34223dffb92eaf34908ede12fed414c82b7f00b3e" +dependencies = [ + "base16ct", + "serde", +] + [[package]] name = "serialize-to-javascript" version = "0.1.2" @@ -5273,10 +6728,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" dependencies = [ "cfg-if", - "cpufeatures", - "digest", + "cpufeatures 0.2.17", + "digest 0.10.7", ] +[[package]] +name = "sha1_smol" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbfa15b3dddfee50a0fff136974b3e1bde555604ba463834a7eb7deb6417705d" + [[package]] name = "sha2" version = "0.10.9" @@ -5284,8 +6745,19 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" dependencies = [ "cfg-if", - "cpufeatures", - "digest", + "cpufeatures 0.2.17", + "digest 0.10.7", +] + +[[package]] +name = "sha2" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "digest 0.11.3", ] [[package]] @@ -5325,6 +6797,12 @@ dependencies = [ "libc", ] +[[package]] +name = "signature" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28d567dcbaf0049cb8ac2608a76cd95ff9e4412e1899d389ee400918ca7537f5" + [[package]] name = "simd-adler32" version = "0.3.8" @@ -5347,6 +6825,15 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" +[[package]] +name = "simple-dns" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a75cbde1bf934313596a004973e462f9a82caa814dcf1a5f507bdf51597eeb4" +dependencies = [ + "bitflags 2.11.0", +] + [[package]] name = "siphasher" version = "0.3.11" @@ -5413,6 +6900,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "sorted-index-buffer" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea06cc588e43c632923a55450401b8f25e628131571d4e1baea1bdfdb2b5ed06" + [[package]] name = "soup3" version = "0.5.0" @@ -5439,6 +6932,33 @@ dependencies = [ "system-deps", ] +[[package]] +name = "spez" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c87e960f4dca2788eeb86bbdde8dd246be8948790b7618d656e68f9b720a86e8" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "spin" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "023a211cb3138dbc438680b32560ad89f699977624c9f8dbb95a47d5b4c07dd3" + +[[package]] +name = "spki" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d9efca8738c78ee9484207732f728b1ef517bbb1833d6fc0879ca898a522f6f" +dependencies = [ + "base64ct", + "der", +] + [[package]] name = "stable_deref_trait" version = "1.2.1" @@ -5488,6 +7008,27 @@ version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" +[[package]] +name = "strum" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9628de9b8791db39ceda2b119bbe13134770b56c138ec1d3af810d045c04f9bd" +dependencies = [ + "strum_macros", +] + +[[package]] +name = "strum_macros" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab85eea0270ee17587ed4156089e10b9e6880ee688791d45a905f5b1ca36f664" +dependencies = [ + "heck 0.5.0", + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "subtle" version = "2.6.1" @@ -5527,6 +7068,17 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "syn" +version = "3.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + [[package]] name = "sync_wrapper" version = "1.0.2" @@ -5590,6 +7142,12 @@ dependencies = [ "version-compare", ] +[[package]] +name = "tagptr" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b2093cf4c8eb1e67749a6762251bc9cd836b6fc171623bd0a9d324d37af2417" + [[package]] name = "tao" version = "0.34.6" @@ -5665,7 +7223,7 @@ dependencies = [ "anyhow", "bytes", "cookie", - "dirs", + "dirs 6.0.0", "dunce", "embed_plist", "getrandom 0.3.4", @@ -5716,7 +7274,7 @@ checksum = "4bbc990d1dbf57a8e1c7fa2327f2a614d8b757805603c1b9ba5c81bade09fd4d" dependencies = [ "anyhow", "cargo_toml", - "dirs", + "dirs 6.0.0", "glob", "heck 0.5.0", "json-patch", @@ -5747,7 +7305,7 @@ dependencies = [ "semver", "serde", "serde_json", - "sha2", + "sha2 0.10.9", "syn 2.0.117", "tauri-utils", "thiserror 2.0.18", @@ -5801,7 +7359,7 @@ dependencies = [ "reqwest 0.12.28", "serde", "serde_json", - "sha2", + "sha2 0.10.9", "sys-locale", "tauri", "tauri-plugin", @@ -5809,6 +7367,20 @@ dependencies = [ "tokio", ] +[[package]] +name = "tauri-plugin-autostart" +version = "2.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "459383cebc193cdd03d1ba4acc40f2c408a7abce419d64bdcd2d745bc2886f70" +dependencies = [ + "auto-launch", + "serde", + "serde_json", + "tauri", + "tauri-plugin", + "thiserror 2.0.18", +] + [[package]] name = "tauri-plugin-clipboard-manager" version = "2.3.2" @@ -6044,7 +7616,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3fe8e9bebd88fc222938ffdfbdcfa0307081423bd01e3252fc337d8bde81fc61" dependencies = [ "base64 0.22.1", - "dirs", + "dirs 6.0.0", "flate2", "futures-util", "http", @@ -6292,7 +7864,10 @@ checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" dependencies = [ "deranged", "itoa", + "js-sys", + "libc", "num-conv", + "num_threads", "powerfmt", "serde_core", "time-core", @@ -6359,6 +7934,7 @@ dependencies = [ "libc", "mio", "pin-project-lite", + "signal-hook-registry", "socket2 0.6.2", "tokio-macros", "windows-sys 0.61.2", @@ -6395,6 +7971,18 @@ dependencies = [ "tokio", ] +[[package]] +name = "tokio-stream" +version = "0.1.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3d06f0b082ba57c26b79407372e57cf2a1e28124f78e9479fe80322cf53420b" +dependencies = [ + "futures-core", + "pin-project-lite", + "tokio", + "tokio-util", +] + [[package]] name = "tokio-util" version = "0.7.18" @@ -6404,10 +7992,34 @@ dependencies = [ "bytes", "futures-core", "futures-sink", + "futures-util", "pin-project-lite", "tokio", ] +[[package]] +name = "tokio-websockets" +version = "0.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d52efb639344a7c6adb8e62c6f3d2c19c001ff1b79a5041ba1c6ed42e19c6aa5" +dependencies = [ + "base64 0.22.1", + "bytes", + "futures-core", + "futures-sink", + "getrandom 0.4.2", + "http", + "httparse", + "rand 0.10.2", + "ring", + "rustls-pki-types", + "sha1_smol", + "simdutf8", + "tokio", + "tokio-rustls", + "tokio-util", +] + [[package]] name = "toml" version = "0.6.0" @@ -6674,7 +8286,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a5e85aa143ceb072062fc4d6356c1b520a51d636e7bc8e77ec94be3608e5e80c" dependencies = [ "crossbeam-channel", - "dirs", + "dirs 6.0.0", "libappindicator", "muda", "objc2 0.6.4", @@ -6714,9 +8326,9 @@ checksum = "bc7d623258602320d5c55d1bc22793b57daff0ec7efc270ea7d55ce1d5f5471c" [[package]] name = "typenum" -version = "1.19.0" +version = "1.20.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" [[package]] name = "uds_windows" @@ -6794,6 +8406,16 @@ version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" +[[package]] +name = "universal-hash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea" +dependencies = [ + "crypto-common 0.1.7", + "subtle", +] + [[package]] name = "untrusted" version = "0.9.0" @@ -6867,6 +8489,43 @@ version = "0.2.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" +[[package]] +name = "vergen" +version = "9.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b849a1f6d8639e8de261e81ee0fc881e3e3620db1af9f2e0da015d4382ceaf75" +dependencies = [ + "anyhow", + "derive_builder 0.20.2", + "rustversion", + "vergen-lib", +] + +[[package]] +name = "vergen-gitcl" +version = "9.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ff3b5300a085d6bcd8fc96a507f706a28ae3814693236c9b409db71a1d15b9" +dependencies = [ + "anyhow", + "derive_builder 0.20.2", + "rustversion", + "time", + "vergen", + "vergen-lib", +] + +[[package]] +name = "vergen-lib" +version = "9.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b34a29ba7e9c59e62f229ae1932fb1b8fb8a6fdcc99215a641913f5f5a59a569" +dependencies = [ + "anyhow", + "derive_builder 0.20.2", + "rustversion", +] + [[package]] name = "version-compare" version = "0.2.1" @@ -6893,7 +8552,9 @@ dependencies = [ "futures", "futures-util", "glob", + "hex", "hound", + "iroh", "libc", "libc-stdhandle", "notify", @@ -6907,9 +8568,11 @@ dependencies = [ "serde", "serde_json", "showfile", + "subtle", "tauri", "tauri-build", "tauri-plugin-aptabase", + "tauri-plugin-autostart", "tauri-plugin-clipboard-manager", "tauri-plugin-deep-link", "tauri-plugin-dialog", @@ -6935,7 +8598,7 @@ dependencies = [ "which 8.0.0", "window-vibrancy", "windows 0.62.2", - "winreg", + "winreg 0.55.0", ] [[package]] @@ -7336,6 +8999,12 @@ dependencies = [ "winsafe", ] +[[package]] +name = "widestring" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72069c3113ab32ab29e5584db3c6ec55d416895e60715417b5b883a357c3e471" + [[package]] name = "winapi" version = "0.3.9" @@ -8010,6 +9679,15 @@ dependencies = [ "memchr", ] +[[package]] +name = "winreg" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "80d0f4e272c85def139476380b12f9ac60926689dd2e01d4923222f40580869d" +dependencies = [ + "winapi", +] + [[package]] name = "winreg" version = "0.55.0" @@ -8132,6 +9810,21 @@ dependencies = [ "wayland-protocols-wlr", ] +[[package]] +name = "wmi" +version = "0.18.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c81b85c57a57500e56669586496bf2abd5cf082b9d32995251185d105208b64" +dependencies = [ + "chrono", + "futures", + "log", + "serde", + "thiserror 2.0.18", + "windows 0.62.2", + "windows-core 0.62.2", +] + [[package]] name = "writeable" version = "0.6.2" @@ -8148,7 +9841,7 @@ dependencies = [ "block2 0.6.2", "cookie", "crossbeam-channel", - "dirs", + "dirs 6.0.0", "dpi", "dunce", "gdkx11", @@ -8169,7 +9862,7 @@ dependencies = [ "once_cell", "percent-encoding", "raw-window-handle", - "sha2", + "sha2 0.10.9", "soup3", "tao-macros", "thiserror 2.0.18", @@ -8183,6 +9876,25 @@ dependencies = [ "x11-dl", ] +[[package]] +name = "ws_stream_wasm" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c173014acad22e83f16403ee360115b38846fe754e735c5d9d3803fe70c6abc" +dependencies = [ + "async_io_stream", + "futures", + "js-sys", + "log", + "pharos", + "rustc_version", + "send_wrapper", + "thiserror 2.0.18", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + [[package]] name = "x11" version = "2.21.0" @@ -8258,6 +9970,21 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b9cc00251562a284751c9973bace760d86c0276c471b4be569fe6b068ee97a56" +[[package]] +name = "xml-rs" +version = "0.8.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7" + +[[package]] +name = "xmltree" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7d8a75eaf6557bb84a65ace8609883db44a29951042ada9b393151532e41fcb" +dependencies = [ + "xml-rs", +] + [[package]] name = "yoke" version = "0.8.1" @@ -8451,9 +10178,23 @@ dependencies = [ [[package]] name = "zeroize" -version = "1.8.2" +version = "1.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" +dependencies = [ + "zeroize_derive", +] + +[[package]] +name = "zeroize_derive" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c50655cbb0fe3fc43170059e702f1ce5e19b84cec58dc87b037a09935c2f328" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] [[package]] name = "zerotrie" diff --git a/desktop/package.json b/desktop/package.json index 7107560..e9b17c2 100644 --- a/desktop/package.json +++ b/desktop/package.json @@ -32,6 +32,7 @@ "@radix-ui/react-tabs": "^1.1.13", "@radix-ui/react-tooltip": "^1.2.8", "@tauri-apps/api": "~2.10.1", + "@tauri-apps/plugin-autostart": "^2.5.1", "@tauri-apps/plugin-clipboard-manager": "~2.3.2", "@tauri-apps/plugin-deep-link": "~2.4.7", "@tauri-apps/plugin-dialog": "~2.6.0", diff --git a/desktop/pnpm-lock.yaml b/desktop/pnpm-lock.yaml index 638678a..82e5b2f 100644 --- a/desktop/pnpm-lock.yaml +++ b/desktop/pnpm-lock.yaml @@ -62,6 +62,9 @@ importers: '@tauri-apps/api': specifier: ~2.10.1 version: 2.10.1 + '@tauri-apps/plugin-autostart': + specifier: ^2.5.1 + version: 2.5.1 '@tauri-apps/plugin-clipboard-manager': specifier: ~2.3.2 version: 2.3.2 @@ -1510,6 +1513,9 @@ packages: engines: {node: '>= 10'} hasBin: true + '@tauri-apps/plugin-autostart@2.5.1': + resolution: {integrity: sha512-zS/xx7yzveCcotkA+8TqkI2lysmG2wvQXv2HGAVExITmnFfHAdj1arGsbbfs3o6EktRHf6l34pJxc3YGG2mg7w==} + '@tauri-apps/plugin-clipboard-manager@2.3.2': resolution: {integrity: sha512-CUlb5Hqi2oZbcZf4VUyUH53XWPPdtpw43EUpCza5HWZJwxEoDowFzNUDt1tRUXA8Uq+XPn17Ysfptip33sG4eQ==} @@ -4737,6 +4743,10 @@ snapshots: '@tauri-apps/cli-win32-ia32-msvc': 2.10.0 '@tauri-apps/cli-win32-x64-msvc': 2.10.0 + '@tauri-apps/plugin-autostart@2.5.1': + dependencies: + '@tauri-apps/api': 2.10.1 + '@tauri-apps/plugin-clipboard-manager@2.3.2': dependencies: '@tauri-apps/api': 2.10.1 diff --git a/desktop/src-tauri/Cargo.toml b/desktop/src-tauri/Cargo.toml index a6a7322..8902fd6 100644 --- a/desktop/src-tauri/Cargo.toml +++ b/desktop/src-tauri/Cargo.toml @@ -31,6 +31,7 @@ tauri-plugin-aptabase = { git = "https://github.com/thewh1teagle/tauri-plugin-ap # Unsafe headers required for Ollama (to set Origin) tauri-plugin-http = { version = "2", features = ["unsafe-headers"] } tauri-plugin-opener = "2" +tauri-plugin-autostart = "2" serde_json = { workspace = true } eyre = { workspace = true } @@ -66,6 +67,11 @@ bytemuck = "1.24.0" which = "8" enigo = "0.3" +# Phone handoff (iroh p2p) +iroh = "1" +subtle = "2" +hex = "0.4" + # Linux [target.'cfg(target_os = "linux")'.dependencies] openssl = { version = "0.10.75", features = ["vendored"] } diff --git a/desktop/src-tauri/capabilities/main.json b/desktop/src-tauri/capabilities/main.json index ccc3bab..096c41a 100644 --- a/desktop/src-tauri/capabilities/main.json +++ b/desktop/src-tauri/capabilities/main.json @@ -70,6 +70,9 @@ "notification:allow-request-permission", "notification:allow-permission-state", "process:allow-exit", + "autostart:allow-enable", + "autostart:allow-disable", + "autostart:allow-is-enabled", { "identifier": "fs:scope", "allow": [ diff --git a/desktop/src-tauri/src/analytics.rs b/desktop/src-tauri/src/analytics.rs index 01d0d7f..0a46e22 100644 --- a/desktop/src-tauri/src/analytics.rs +++ b/desktop/src-tauri/src/analytics.rs @@ -18,6 +18,14 @@ pub mod events { pub const APP_STARTED: &str = "app_started"; pub const CLI_STARTED: &str = "cli_started"; pub const SONA_SPAWN_FAILED: &str = "sona_spawn_failed"; + + // Phone handoff. Props are technical facts only: never a transcript, filename, + // saved path, endpoint id, pairing token, model path, or chosen language. + pub const HANDOFF_ENABLED: &str = "handoff_enabled"; + pub const HANDOFF_DISABLED: &str = "handoff_disabled"; + pub const HANDOFF_TRANSCRIBE: &str = "handoff_transcribe"; + pub const HANDOFF_CAPABILITIES: &str = "handoff_capabilities"; + pub const HANDOFF_PAIRING_REGENERATED: &str = "handoff_pairing_regenerated"; } fn is_analytics_enabled(app_handle: &AppHandle) -> bool { diff --git a/desktop/src-tauri/src/cmd/handoff_cmd.rs b/desktop/src-tauri/src/cmd/handoff_cmd.rs new file mode 100644 index 0000000..5c7f38d --- /dev/null +++ b/desktop/src-tauri/src/cmd/handoff_cmd.rs @@ -0,0 +1,120 @@ +//! Tauri commands controlling the phone handoff endpoint. +//! +//! The endpoint is off by default; nothing binds until the user calls +//! `handoff_start`. + +use serde::Serialize; +use tauri::State; +use tokio::sync::Mutex; + +use crate::handoff::{self, HandoffState}; + +use super::CommandError; + +/// What the UI needs to render the handoff panel and its QR code. +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct HandoffStatus { + pub enabled: bool, + pub endpoint_id: Option, + pub pairing_url: Option, +} + +impl HandoffStatus { + fn disabled() -> Self { + Self { + enabled: false, + endpoint_id: None, + pairing_url: None, + } + } + + fn from_state(state: &HandoffState) -> Self { + Self { + enabled: true, + endpoint_id: Some(state.endpoint_id()), + pairing_url: Some(state.pairing_url(&handoff::pwa_origin())), + } + } +} + +/// Managed state holding the running endpoint, if any. +pub type HandoffRuntime = Mutex>; + +#[tauri::command] +pub async fn handoff_status(runtime: State<'_, HandoffRuntime>) -> Result { + let guard = runtime.lock().await; + Ok(match guard.as_ref() { + Some(state) => HandoffStatus::from_state(state), + None => HandoffStatus::disabled(), + }) +} + +/// Idempotent: returns the existing endpoint if one is already running. +#[tauri::command] +pub async fn handoff_start( + app_handle: tauri::AppHandle, + runtime: State<'_, HandoffRuntime>, +) -> Result { + let mut guard = runtime.lock().await; + if let Some(state) = guard.as_ref() { + return Ok(HandoffStatus::from_state(state)); + } + + let state = handoff::spawn(app_handle.clone()).await?; + tracing::info!("handoff started: {}", state.endpoint_id()); + // Only on a real off -> on transition, so this counts adoption rather than + // how often the settings page was opened. Never carries the endpoint id. + crate::analytics::track_event_handle(&app_handle, crate::analytics::events::HANDOFF_ENABLED); + // Persist the intent so enabling handoff survives a restart. + handoff::set_enabled(&app_handle, true); + let status = HandoffStatus::from_state(&state); + *guard = Some(state); + Ok(status) +} + +#[tauri::command] +pub async fn handoff_stop(app_handle: tauri::AppHandle, runtime: State<'_, HandoffRuntime>) -> Result<(), CommandError> { + let taken = { runtime.lock().await.take() }; + // Recorded even when nothing was running, so a restore that failed at startup + // cannot leave the stored preference stuck on. + handoff::set_enabled(&app_handle, false); + if let Some(state) = taken { + state.shutdown().await; + tracing::info!("handoff stopped"); + // Likewise only on a real on -> off transition. + crate::analytics::track_event_handle(&app_handle, crate::analytics::events::HANDOFF_DISABLED); + } + Ok(()) +} + +/// Issues a new pairing token, invalidating any QR code already handed out. +/// If the endpoint is running it is restarted so the new token takes effect. +#[tauri::command] +pub async fn handoff_regenerate_token( + app_handle: tauri::AppHandle, + runtime: State<'_, HandoffRuntime>, +) -> Result { + let mut guard = runtime.lock().await; + handoff::regenerate_token(&app_handle)?; + + let was_running = guard.is_some(); + // Rare, and a signal that someone is fighting with pairing. No token or + // endpoint id goes with it. + crate::analytics::track_event_handle_with_props( + &app_handle, + crate::analytics::events::HANDOFF_PAIRING_REGENERATED, + Some(serde_json::json!({ "was_running": was_running })), + ); + if let Some(state) = guard.take() { + state.shutdown().await; + } + if !was_running { + return Ok(HandoffStatus::disabled()); + } + + let state = handoff::spawn(app_handle).await?; + let status = HandoffStatus::from_state(&state); + *guard = Some(state); + Ok(status) +} diff --git a/desktop/src-tauri/src/cmd/mod.rs b/desktop/src-tauri/src/cmd/mod.rs index 6fe5c2d..3dc6e25 100644 --- a/desktop/src-tauri/src/cmd/mod.rs +++ b/desktop/src-tauri/src/cmd/mod.rs @@ -4,6 +4,7 @@ pub mod audio; pub mod config; pub mod download; pub mod files; +pub mod handoff_cmd; pub mod permissions; pub mod sona_cmd; pub mod transcribe; diff --git a/desktop/src-tauri/src/handoff/mod.rs b/desktop/src-tauri/src/handoff/mod.rs new file mode 100644 index 0000000..48bec64 --- /dev/null +++ b/desktop/src-tauri/src/handoff/mod.rs @@ -0,0 +1,955 @@ +//! Phone handoff: a phone records audio, sends it over iroh, this desktop +//! transcribes it with Sona and streams the transcript back. +//! +//! The endpoint is *not* spawned at startup — the user opts in from the UI, +//! which calls the `handoff_start` command. + +pub mod protocol; + +use std::path::PathBuf; +use std::sync::Arc; + +use eyre::{bail, Context, Result}; +use futures_util::StreamExt; +use iroh::endpoint::{presets, Connection}; +use iroh::protocol::{AcceptError, ProtocolHandler, Router}; +use iroh::{Endpoint, SecretKey}; +use subtle::ConstantTimeEq; +use tauri::{Emitter, Manager}; +use tokio::io::AsyncWriteExt; + +use crate::error::LogError; +use crate::sona::SonaEvent; +use protocol::{HandoffActivity, HandoffEvent, HandoffHeader, ALPN, MAX_AUDIO_BYTES, MAX_HEADER_LEN}; + +/// Keys in `app_config.json` holding the user's model settings (`lib/config-keys.ts`). +const CONFIG_KEY_MODEL_PATH: &str = "model.path"; +const CONFIG_KEY_GPU_DEVICE: &str = "model.gpuDevice"; +const CONFIG_KEY_UNLOAD_TIMEOUT_MINUTES: &str = "model.unloadTimeoutMinutes"; + +/// Whether the user turned handoff on. Namespaced like the other feature keys in +/// `lib/config-keys.ts` (`model.path`, `transcription.saveTranscripts`). +pub const CONFIG_KEY_HANDOFF_ENABLED: &str = "handoff.enabled"; + +/// Display name for a phone transcription in Recents. +const PHONE_TRANSCRIPT_NAME: &str = "Phone recording"; + +/// Matches the frontend default in `providers/preference.tsx`. +const DEFAULT_UNLOAD_TIMEOUT_MINUTES: u32 = 5; + +/// Where the phone PWA is deployed: it ships inside the website's GitHub Pages +/// artifact. This is a public URL, not a secret, so it lives in committed source +/// rather than `.env` (which is gitignored and holds signing credentials). +/// +/// Resolution order, widest to narrowest: +/// 1. `VIBE_PWA_ORIGIN` in the environment at run time — for `just dev` and for +/// pointing a real phone at a tunnel. +/// 2. `VIBE_PWA_ORIGIN` at compile time — lets a release build bake a different +/// origin, the same way `APTABASE_APP_KEY` is baked in `analytics.rs`. +/// 3. This constant. +pub const DEFAULT_PWA_ORIGIN: &str = match option_env!("VIBE_PWA_ORIGIN") { + Some(value) => value, + None => "https://thewh1teagle.github.io/vibe/phone", +}; + +/// A running handoff endpoint. Dropping this aborts the accept loop; prefer +/// [`HandoffState::shutdown`] for a clean close. +pub struct HandoffState { + router: Router, + endpoint_id: String, + token: String, +} + +impl HandoffState { + /// 64 lowercase hex chars identifying this desktop on the iroh network. + pub fn endpoint_id(&self) -> String { + self.endpoint_id.clone() + } + + /// The 32-hex-char pairing secret the phone must present. + #[allow(dead_code)] + pub fn token(&self) -> String { + self.token.clone() + } + + /// The exact URL encoded into the pairing QR code. + pub fn pairing_url(&self, pwa_origin: &str) -> String { + format_pairing_url(pwa_origin, &self.endpoint_id, &self.token) + } + + pub async fn shutdown(self) { + if let Err(error) = self.router.shutdown().await { + tracing::warn!("handoff router shutdown failed: {:?}", error); + } else { + tracing::debug!("handoff router shut down"); + } + } +} + +/// `/#:` — the exact string the QR encodes. +fn format_pairing_url(pwa_origin: &str, endpoint_id: &str, token: &str) -> String { + format!("{}/#{}:{}", pwa_origin.trim_end_matches('/'), endpoint_id, token) +} + +/// The origin the pairing QR should point at. +pub fn pwa_origin() -> String { + std::env::var("VIBE_PWA_ORIGIN") + .ok() + .filter(|value| !value.trim().is_empty()) + .unwrap_or_else(|| DEFAULT_PWA_ORIGIN.to_string()) +} + +fn handoff_dir(app_handle: &tauri::AppHandle) -> Result { + let dir = app_handle + .path() + .app_data_dir() + .context("failed to resolve app data dir")? + .join("handoff"); + std::fs::create_dir_all(&dir).with_context(|| format!("failed to create {}", dir.display()))?; + Ok(dir) +} + +#[cfg(unix)] +fn restrict_permissions(path: &std::path::Path) -> Result<()> { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600)) + .with_context(|| format!("failed to chmod {}", path.display())) +} + +#[cfg(not(unix))] +fn restrict_permissions(_path: &std::path::Path) -> Result<()> { + Ok(()) +} + +/// Load the persisted iroh identity, generating it on first use so pairing QR +/// codes keep working across restarts. +fn load_or_create_secret_key(app_handle: &tauri::AppHandle) -> Result { + let path = handoff_dir(app_handle)?.join("endpoint.key"); + if path.exists() { + let bytes = std::fs::read(&path).with_context(|| format!("failed to read {}", path.display()))?; + if bytes.len() == 32 { + let mut key = [0u8; 32]; + key.copy_from_slice(&bytes); + return Ok(SecretKey::from_bytes(&key)); + } + tracing::warn!("handoff secret key at {} is malformed, regenerating", path.display()); + } + let secret_key = SecretKey::generate(); + std::fs::write(&path, secret_key.to_bytes()).with_context(|| format!("failed to write {}", path.display()))?; + restrict_permissions(&path).log_error(); + Ok(secret_key) +} + +fn generate_token() -> String { + let bytes: [u8; 16] = rand::random(); + hex::encode(bytes) +} + +/// Load the persisted pairing token, generating it on first use. +fn load_or_create_token(app_handle: &tauri::AppHandle) -> Result { + let path = handoff_dir(app_handle)?.join("token"); + if let Ok(existing) = std::fs::read_to_string(&path) { + let existing = existing.trim().to_string(); + if existing.len() == 32 && existing.chars().all(|c| c.is_ascii_hexdigit()) { + return Ok(existing); + } + tracing::warn!("handoff token at {} is malformed, regenerating", path.display()); + } + let token = generate_token(); + std::fs::write(&path, &token).with_context(|| format!("failed to write {}", path.display()))?; + restrict_permissions(&path).log_error(); + Ok(token) +} + +/// Replace the pairing token, invalidating any QR code handed out earlier. +pub fn regenerate_token(app_handle: &tauri::AppHandle) -> Result { + let path = handoff_dir(app_handle)?.join("token"); + let token = generate_token(); + std::fs::write(&path, &token).with_context(|| format!("failed to write {}", path.display()))?; + restrict_permissions(&path).log_error(); + Ok(token) +} + +/// Remember whether the user wants handoff on, so enabling it survives a restart. +/// +/// Enablement has to persist alongside the identity: the secret key and token are +/// already on disk, so the pairing QR stays valid across restarts. If the endpoint +/// did not come back with it, the phone would keep believing it is paired and dial +/// an endpoint that no longer exists. +pub fn set_enabled(app_handle: &tauri::AppHandle, enabled: bool) { + use tauri_plugin_store::StoreExt; + + match app_handle.store(crate::config::STORE_FILENAME) { + Ok(store) => store.set(CONFIG_KEY_HANDOFF_ENABLED, serde_json::Value::Bool(enabled)), + Err(error) => tracing::warn!("handoff could not persist the enabled flag: {:?}", error), + } +} + +/// Whether handoff was on when the app last closed. Defaults to off: this opens a +/// network listener, so only a user who explicitly turned it on gets it back. +pub fn is_enabled(app_handle: &tauri::AppHandle) -> bool { + use tauri_plugin_store::StoreExt; + + app_handle + .store(crate::config::STORE_FILENAME) + .ok() + .and_then(|store| store.get(CONFIG_KEY_HANDOFF_ENABLED)) + .and_then(|value| value.as_bool()) + .unwrap_or(false) +} + +/// Bind the iroh endpoint and start accepting handoff connections. +pub async fn spawn(app_handle: tauri::AppHandle) -> Result { + let secret_key = load_or_create_secret_key(&app_handle)?; + let token = load_or_create_token(&app_handle)?; + + let endpoint = Endpoint::builder(presets::N0) + .secret_key(secret_key) + .alpns(vec![ALPN.to_vec()]) + .bind() + .await + .map_err(|error| eyre::eyre!("failed to bind handoff endpoint: {error}"))?; + + let endpoint_id = endpoint.id().to_string(); + tracing::info!("handoff endpoint bound: {}", endpoint_id); + + let handler = HandoffHandler { + app_handle, + token: Arc::new(token.clone()), + }; + let router = Router::builder(endpoint).accept(ALPN, handler).spawn(); + + Ok(HandoffState { + router, + endpoint_id, + token, + }) +} + +/// Bring handoff back if the user had it on, without making the app wait. +/// +/// Binding an iroh endpoint reaches the network, so this returns immediately and +/// finishes in the background; `handoff_status` reports the real state once it +/// settles. A brand-new user gets nothing: the flag defaults to off, and silently +/// opening a network listener on upgrade would be wrong. +pub fn restore_on_startup(app_handle: &tauri::AppHandle) { + if !is_enabled(app_handle) { + tracing::debug!("handoff is disabled; not restoring"); + return; + } + + let app_handle = app_handle.clone(); + tauri::async_runtime::spawn(async move { + tracing::info!("restoring handoff endpoint in the background"); + match spawn(app_handle.clone()).await { + Ok(state) => { + let runtime = app_handle.state::>>(); + let mut guard = runtime.lock().await; + if guard.is_some() { + // The user toggled it on while we were still binding; keep + // theirs rather than leaking a second router. + tracing::debug!("handoff was started manually during restore; dropping the restored one"); + drop(guard); + state.shutdown().await; + } else { + tracing::info!("handoff restored: {}", state.endpoint_id()); + *guard = Some(state); + drop(guard); + app_handle + .emit_to("main", "handoff_activity", HandoffActivity::new("ready", None)) + .log_error(); + } + } + Err(error) => { + // Never leave the UI claiming handoff is on when it is not. The + // saved preference stays true so the next launch retries, but + // `handoff_status` reports the truth: not running. + let message = format!("{error:#}"); + tracing::error!("failed to restore handoff endpoint: {}", message); + app_handle + .emit_to("main", "handoff_activity", HandoffActivity::new("error", Some(message))) + .log_error(); + } + } + }); +} + +#[derive(Debug, Clone)] +struct HandoffHandler { + app_handle: tauri::AppHandle, + token: Arc, +} + +impl ProtocolHandler for HandoffHandler { + async fn accept(&self, connection: Connection) -> Result<(), AcceptError> { + let (mut send, mut recv) = connection.accept_bi().await?; + + // Every failure path still owes the phone a terminal `error` line; only a + // broken stream (which we cannot report on anyway) escapes as an error. + let outcome = self.handle_transfer(&mut send, &mut recv).await; + if let Err(failure) = outcome { + tracing::error!("handoff transfer failed: [{}] {}", failure.code, failure.message); + self.emit_activity("error", Some(failure.message.clone())); + let line = HandoffEvent::Error { + code: failure.code, + message: failure.message, + } + .to_line(); + let _ = send.write_all(line.as_bytes()).await; + } + + let _ = send.finish(); + connection.closed().await; + Ok(()) + } +} + +/// A failure that must be reported to the phone as a terminal `error` line. +struct TransferError { + code: String, + message: String, +} + +impl TransferError { + fn new(code: &str, message: impl Into) -> Self { + Self { + code: code.to_string(), + message: message.into(), + } + } +} + +impl From for TransferError { + fn from(error: eyre::Error) -> Self { + Self::new("internal_error", error.to_string()) + } +} + +impl HandoffHandler { + fn emit_activity(&self, state: &'static str, message: Option) { + self.app_handle + .emit_to("main", "handoff_activity", HandoffActivity::new(state, message)) + .log_error(); + } + + fn emit_done(&self, completion: protocol::HandoffCompletion) { + self.app_handle + .emit_to("main", "handoff_activity", HandoffActivity::done(completion)) + .log_error(); + } + + async fn handle_transfer( + &self, + send: &mut iroh::endpoint::SendStream, + recv: &mut iroh::endpoint::RecvStream, + ) -> Result<(), TransferError> { + let header = read_header(recv).await?; + + // Constant-time comparison so a wrong token leaks nothing about the right one. + let expected = self.token.as_bytes(); + let provided = header.token.as_bytes(); + let authorized = expected.len() == provided.len() && bool::from(expected.ct_eq(provided)); + if !authorized { + tracing::warn!("handoff connection rejected: invalid pairing token"); + return Err(TransferError::new("unauthorized", "Invalid pairing token")); + } + + // Dispatch happens only after the token check, and each branch is a + // separate function, so a capabilities request can never fall through into + // the audio-reading loop and block on bytes that will never arrive. + match header.op.as_deref() { + None | Some(protocol::OP_TRANSCRIBE) => self.handle_transcribe(send, recv, header).await, + Some(protocol::OP_CAPABILITIES) => { + let event = self.capabilities().await; + // Counts PWA page loads rather than people — see the note on + // `HANDOFF_CAPABILITIES`. `model_loaded` is the useful part: it + // shows how often someone opens the phone app with nothing ready. + let model_loaded = matches!(event, HandoffEvent::Capabilities { model_loaded: true, .. }); + crate::analytics::track_event_handle_with_props( + &self.app_handle, + crate::analytics::events::HANDOFF_CAPABILITIES, + Some(serde_json::json!({ "model_loaded": model_loaded })), + ); + // Exactly one line, then the caller finishes the stream. + write_event(send, &event).await + } + Some(other) => Err(TransferError::new("invalid_request", format!("Unknown op '{other}'"))), + } + } + + /// Report what the currently loaded model can do. Never fails for the + /// ordinary "nothing loaded yet" case — that is a normal state the phone + /// renders, not an error. + async fn capabilities(&self) -> HandoffEvent { + match self.read_capabilities().await { + Ok(event) => event, + Err(error) => { + // Degrade honestly: if we cannot confirm what is loaded, we say + // nothing is, rather than advertising languages we may not have. + tracing::warn!("handoff could not read model capabilities: {:?}", error); + HandoffEvent::no_capabilities() + } + } + } + + async fn read_capabilities(&self) -> Result { + let sona_state = self.app_handle.state::>(); + let endpoint = { + let state = sona_state.lock().await; + state.process.as_ref().map(|process| (process.client(), process.base_url())) + }; // lock released here, before any I/O + let Some((client, base_url)) = endpoint else { + tracing::debug!("handoff capabilities: sona is not running"); + return Ok(HandoffEvent::no_capabilities()); + }; + + // The same selection the transcribe path will load on demand, so a + // `modelLoaded: true` here is a promise the transcribe path can keep. + let Some(model_path) = model_settings(&self.app_handle).map(|settings| settings.path) else { + tracing::debug!("handoff capabilities: no model selected in {}", crate::config::STORE_FILENAME); + return Ok(HandoffEvent::no_capabilities()); + }; + if !std::path::Path::new(&model_path).is_file() { + tracing::warn!("handoff capabilities: selected model {} does not exist", model_path); + return Ok(HandoffEvent::no_capabilities()); + } + + let metadata = crate::sona::SonaProcess::model_metadata_with(&client, &base_url, &model_path).await?; + let model_name = std::path::Path::new(&model_path) + .file_name() + .map(|name| name.to_string_lossy().to_string()); + + Ok(HandoffEvent::Capabilities { + model_loaded: true, + model_name, + languages: metadata.capabilities.languages, + language_detection: metadata.capabilities.language_detection, + translation: metadata.capabilities.translation, + max_audio_bytes: MAX_AUDIO_BYTES, + }) + } + + /// The transcribe op. Wraps [`HandoffHandler::run_transcribe`] so that every + /// outcome — including a transfer that dies partway — reports exactly one + /// `handoff_transcribe` event. Without this the dataset would only ever see + /// successes and the feature would look healthier than it is. + async fn handle_transcribe( + &self, + send: &mut iroh::endpoint::SendStream, + recv: &mut iroh::endpoint::RecvStream, + header: HandoffHeader, + ) -> Result<(), TransferError> { + let started = std::time::Instant::now(); + let mut stats = TransferStats::default(); + let result = self.run_transcribe(send, recv, &header, &mut stats).await; + self.track_transcribe(&header, &stats, started.elapsed(), &result); + result + } + + /// One `handoff_transcribe` per transfer. Technical facts only: no transcript, + /// no filename, no saved path, no model path, and the chosen language is + /// reduced to whether one was chosen at all. + fn track_transcribe( + &self, + header: &HandoffHeader, + stats: &TransferStats, + elapsed: std::time::Duration, + result: &Result<(), TransferError>, + ) { + let mut props = serde_json::json!({ + "success": result.is_ok(), + "audio_size_bucket": size_bucket(stats.audio_bytes), + // Whole operation: receive + model load + transcription. + "duration_sec": elapsed.as_secs(), + // Whether a language was picked, never which one. + "auto_detect": header.lang.is_none(), + "translate": header.translate.unwrap_or(false), + }); + if let Some(seconds) = stats.transcribe_sec { + props["transcribe_duration_sec"] = seconds.into(); + } + if let Some(ref model_name) = stats.model_name { + props["model_name"] = model_name.as_str().into(); + } + if let Err(ref failure) = *result { + props["error_code"] = failure.code.as_str().into(); + } + crate::analytics::track_event_handle_with_props( + &self.app_handle, + crate::analytics::events::HANDOFF_TRANSCRIBE, + Some(props), + ); + } + + async fn run_transcribe( + &self, + send: &mut iroh::endpoint::SendStream, + recv: &mut iroh::endpoint::RecvStream, + header: &HandoffHeader, + stats: &mut TransferStats, + ) -> Result<(), TransferError> { + write_event(send, &HandoffEvent::Accepted).await?; + self.emit_activity("receiving", None); + + // The recording exists nowhere but the phone until this point, so it is + // kept like any other Vibe recording rather than deleted after use. + let (audio_path, audio_bytes) = receive_audio(&self.app_handle, recv, header.filename.as_deref()).await?; + stats.audio_bytes = Some(audio_bytes); + let saved_path = audio_path.to_string_lossy().to_string(); + tracing::info!("handoff saved phone recording to {}", saved_path); + + // A failed transcription leaves the file in place on purpose: the audio is + // complete and is the only copy, so the user can retry from the desktop. + // Only a truncated or rejected transfer is deleted, inside `receive_audio`. + self.transcribe(send, &audio_path, header, saved_path.clone(), stats).await?; + + // The frontend decides whether to keep it — it owns the + // `transcription.saveTranscripts` preference. + if let Some(completion) = stats.completion.take() { + self.emit_done(completion); + } + Ok(()) + } + + /// Mirrors `cmd::transcribe::transcribe`, but forwards each Sona event to the + /// phone instead of the webview. + async fn transcribe( + &self, + send: &mut iroh::endpoint::SendStream, + audio_path: &std::path::Path, + header: &HandoffHeader, + saved_path: String, + stats: &mut TransferStats, + ) -> Result<(), TransferError> { + // The desktop UI calls `load_model` before every transcription; the phone + // has no way to do that, so the handoff path does it here. Without this, + // capabilities would promise a model that Sona was never told to load. + let Some(settings) = model_settings(&self.app_handle) else { + return Err(TransferError::new("no_model", "No model is selected in Vibe on the desktop")); + }; + + // Loading a large model takes real time, and the phone would otherwise sit + // at "transcribing 0%" for all of it. Non-terminal, so a client that does + // not know the `status` type can ignore it and keep reading. + write_event(send, &HandoffEvent::status(protocol::PHASE_LOADING_MODEL)).await?; + self.emit_activity("loading_model", None); + tracing::debug!("handoff loading model {}", settings.path); + // File name only, and only if it looks like a distributed model. + stats.model_name = Some(safe_model_name(&settings.path)); + crate::cmd::sona_cmd::load_model( + self.app_handle.clone(), + settings.path.clone(), + settings.gpu_device, + settings.unload_timeout_minutes, + ) + .await + // Surface why it failed — a missing model file and an unavailable GPU need + // different fixes, and only the desktop knows which one happened. + .map_err(|error| TransferError::new("model_load_failed", format!("{error:#}")))?; + + write_event(send, &HandoffEvent::status(protocol::PHASE_TRANSCRIBING)).await?; + self.emit_activity("transcribing", None); + + let sona_state = self.app_handle.state::>(); + let (client, base_url) = { + let state = sona_state.lock().await; + let process = state + .process + .as_ref() + .ok_or_else(|| TransferError::new("no_model", "Please load model first"))?; + (process.client(), process.base_url()) + }; // lock released here, before any I/O + + let options = crate::cmd::TranscribeOptions { + path: audio_path.to_string_lossy().to_string(), + lang: header.lang.clone(), + verbose: None, + n_threads: None, + init_prompt: None, + temperature: None, + // Passed straight through; whether it is meaningful is the phone's + // call, made against the `translation` flag we reported. + translate: header.translate, + max_text_ctx: None, + word_timestamps: None, + max_sentence_len: None, + sampling_strategy: None, + best_of: None, + beam_size: None, + diarize_model: None, + stable_timestamps: None, + vad_model: None, + }; + + let start = std::time::Instant::now(); + let stream = crate::sona::SonaProcess::transcribe_stream(&client, &base_url, &options) + .await + .map_err(|error| { + if let Some(api_error) = error.downcast_ref::() { + TransferError::new(&api_error.code, api_error.message.clone()) + } else { + TransferError::from(error) + } + })?; + tokio::pin!(stream); + + let mut full_text: Option = None; + // Kept so the frontend can write the same transcript record a local + // transcription produces; the phone gets each segment streamed as it lands. + let mut segments: Vec = Vec::new(); + + while let Some(event_result) = stream.next().await { + match event_result { + Ok(SonaEvent::Progress { progress }) => { + write_event(send, &HandoffEvent::Progress { progress }).await?; + } + Ok(SonaEvent::Segment { + start, + end, + text, + speaker, + }) => { + // Sona reports seconds as f64; the wire format wants centiseconds. + let segment = crate::transcript::Segment { + start: (start * 100.0) as i64, + stop: (end * 100.0) as i64, + text, + speaker, + }; + segments.push(segment.clone()); + write_event( + send, + &HandoffEvent::Segment { + start: segment.start, + stop: segment.stop, + text: segment.text, + speaker: segment.speaker, + }, + ) + .await?; + } + Ok(SonaEvent::Result { text }) => { + full_text = Some(text); + } + Ok(SonaEvent::Error { code, message }) => { + return Err(TransferError::new(code.as_deref().unwrap_or("internal_error"), message)); + } + Err(error) => { + tracing::error!("handoff sona stream error: {:?}", error); + return Err(TransferError::from(error)); + } + } + } + + let text = match full_text { + Some(text) => text, + None => { + return Err(TransferError::new( + "internal_error", + "Sona transcription stream ended before completion", + )) + } + }; + let processing_time_sec = start.elapsed().as_secs(); + stats.transcribe_sec = Some(processing_time_sec); + stats.completion = Some(protocol::HandoffCompletion { + // The store appends its own `-` stamp, so a bare + // label reads better in Recents than a second embedded timestamp. + name: PHONE_TRANSCRIPT_NAME.to_string(), + saved_path: saved_path.clone(), + segments, + language: header.lang.clone(), + model_path: Some(settings.path.clone()), + }); + + write_event( + send, + &HandoffEvent::Done { + text, + processing_time_sec, + saved_path: Some(saved_path), + }, + ) + .await?; + Ok(()) + } +} + +/// What one transfer is worth reporting, gathered as it happens so the event +/// fires even when the transfer fails partway. +#[derive(Debug, Default)] +struct TransferStats { + audio_bytes: Option, + transcribe_sec: Option, + model_name: Option, + /// The record the frontend needs to put this transcription into Recents. + completion: Option, +} + +/// Bucket the audio size. An exact byte count is closer to a fingerprint than we +/// need; buckets answer "are people sending long recordings?" just as well. +fn size_bucket(bytes: Option) -> &'static str { + const MB: u64 = 1024 * 1024; + match bytes { + None => "unknown", + Some(bytes) if bytes < MB => "<1MB", + Some(bytes) if bytes < 10 * MB => "1-10MB", + Some(bytes) if bytes < 50 * MB => "10-50MB", + Some(_) => ">50MB", + } +} + +/// The model's file name, never its path — a path would carry the user's home +/// directory. Anything that is not shaped like a distributed model file is +/// reported as `custom`, so a model someone renamed to something personal never +/// leaves the machine. +fn safe_model_name(model_path: &str) -> String { + let name = std::path::Path::new(model_path) + .file_name() + .map(|name| name.to_string_lossy().to_string()) + .unwrap_or_default(); + let plausible = !name.is_empty() + && name.len() <= 64 + && name + .chars() + .all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.')); + if plausible { + name + } else { + "custom".to_string() + } +} + +/// The model settings the desktop UI persists, as `load_model` wants them. +#[derive(Debug, Clone)] +struct ModelSettings { + path: String, + gpu_device: Option, + unload_timeout_minutes: u32, +} + +/// Read the user's model selection out of `app_config.json`. +/// +/// `SonaState` does not remember which model was last handed to `load_model` and +/// Sona exposes no "what is loaded" endpoint, so the persisted selection is the +/// source of truth — the same one the desktop UI passes to `load_model` before +/// every transcription. Reading all three keys here keeps the handoff path +/// honouring the user's GPU and unload-timeout choices too. +fn model_settings(app_handle: &tauri::AppHandle) -> Option { + use tauri_plugin_store::StoreExt; + + let store = app_handle + .store(crate::config::STORE_FILENAME) + .map_err(|error| tracing::warn!("handoff could not open the config store: {:?}", error)) + .ok()?; + + let path = store.get(CONFIG_KEY_MODEL_PATH)?.as_str()?.trim().to_string(); + if path.is_empty() { + return None; + } + + // Defaults match `providers/preference.tsx`: no GPU override, 5 minute unload. + let gpu_device = store + .get(CONFIG_KEY_GPU_DEVICE) + .and_then(|value| value.as_i64()) + .map(|value| value as i32); + let unload_timeout_minutes = store + .get(CONFIG_KEY_UNLOAD_TIMEOUT_MINUTES) + .and_then(|value| value.as_u64()) + .map(|value| value as u32) + .unwrap_or(DEFAULT_UNLOAD_TIMEOUT_MINUTES); + + Some(ModelSettings { + path, + gpu_device, + unload_timeout_minutes, + }) +} + +async fn write_event(send: &mut iroh::endpoint::SendStream, event: &HandoffEvent) -> Result<(), TransferError> { + send.write_all(event.to_line().as_bytes()) + .await + .map_err(|error| TransferError::new("internal_error", format!("failed to write to phone: {error}"))) +} + +async fn read_header(recv: &mut iroh::endpoint::RecvStream) -> Result { + let mut len_bytes = [0u8; 4]; + recv.read_exact(&mut len_bytes) + .await + .map_err(|error| TransferError::new("invalid_request", format!("failed to read header length: {error}")))?; + let header_len = u32::from_be_bytes(len_bytes); + if header_len == 0 || header_len > MAX_HEADER_LEN { + return Err(TransferError::new( + "invalid_request", + format!("header length {header_len} out of range"), + )); + } + + let mut buffer = vec![0u8; header_len as usize]; + recv.read_exact(&mut buffer) + .await + .map_err(|error| TransferError::new("invalid_request", format!("failed to read header: {error}")))?; + serde_json::from_slice::(&buffer) + .map_err(|error| TransferError::new("invalid_request", format!("malformed header: {error}"))) +} + +/// Stream the audio body to a temp file, enforcing the size cap as we go so a +/// hostile peer can never fill the disk. +async fn receive_audio( + app_handle: &tauri::AppHandle, + recv: &mut iroh::endpoint::RecvStream, + filename: Option<&str>, +) -> Result<(PathBuf, u64), TransferError> { + let path = recording_path(app_handle, filename) + .map_err(|error| TransferError::new("internal_error", format!("failed to pick a save path: {error:#}")))?; + let mut file = tokio::fs::File::create(&path) + .await + .map_err(|error| TransferError::new("internal_error", format!("failed to create recording file: {error}")))?; + + let mut buffer = vec![0u8; 64 * 1024]; + let mut total: u64 = 0; + loop { + // iroh's `RecvStream::read` returns `None` once the peer finished the stream. + let read = match recv + .read(&mut buffer) + .await + .map_err(|error| TransferError::new("invalid_request", format!("failed to read audio: {error}")))? + { + Some(read) if read > 0 => read, + _ => break, + }; + total += read as u64; + if total > MAX_AUDIO_BYTES { + let _ = tokio::fs::remove_file(&path).await; + return Err(TransferError::new( + "payload_too_large", + format!("Audio exceeds the {} MiB limit", MAX_AUDIO_BYTES / (1024 * 1024)), + )); + } + file.write_all(&buffer[..read]) + .await + .map_err(|error| TransferError::new("internal_error", format!("failed to write recording: {error}")))?; + } + + file.flush() + .await + .map_err(|error| TransferError::new("internal_error", format!("failed to flush recording: {error}")))?; + drop(file); + + if total == 0 { + let _ = tokio::fs::remove_file(&path).await; + return Err(TransferError::new("invalid_request", "No audio received")); + } + Ok((path, total)) +} + +/// The extension to save under, taken from the phone-supplied file name but +/// never the name itself: only a short alphanumeric extension is trusted, so a +/// peer cannot steer the write out of the recordings folder. +fn audio_extension(filename: Option<&str>) -> String { + filename + .and_then(|name| std::path::Path::new(name).extension()) + .and_then(|ext| ext.to_str()) + .filter(|ext| !ext.is_empty() && ext.len() <= 8 && ext.chars().all(|c| c.is_ascii_alphanumeric())) + .unwrap_or("m4a") + .to_lowercase() +} + +/// Where to save an incoming phone recording: `~/Documents/Vibe`, the same +/// folder `cmd::files::get_default_recording_path` hands the frontend. +/// +/// The name is timestamped so recordings sort chronologically, and a numeric +/// suffix is added rather than overwriting an existing file. +fn recording_path(app_handle: &tauri::AppHandle, filename: Option<&str>) -> Result { + let folder = app_handle + .path() + .document_dir() + .map_err(|error| eyre::eyre!("failed to resolve documents dir: {error:?}"))? + .join(crate::config::DOCUMENTS_SUBFOLDER); + std::fs::create_dir_all(&folder).with_context(|| format!("failed to create {}", folder.display()))?; + + let extension = audio_extension(filename); + let stem = format!("phone-{}", chrono::Local::now().format("%Y-%m-%d-%H-%M-%S")); + + let candidate = folder.join(format!("{stem}.{extension}")); + if !candidate.exists() { + return Ok(candidate); + } + // Two recordings can land in the same second; never clobber the earlier one. + for suffix in 1..1000 { + let candidate = folder.join(format!("{stem}-{suffix}.{extension}")); + if !candidate.exists() { + return Ok(candidate); + } + } + bail!("could not find a free filename for {}", candidate.display()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn extension_comes_from_the_phone_but_only_when_it_is_safe() { + assert_eq!(audio_extension(Some("recording.m4a")), "m4a"); + assert_eq!(audio_extension(Some("Recording.WAV")), "wav"); + // No name, no extension, or a hostile one all fall back to the default. + assert_eq!(audio_extension(None), "m4a"); + assert_eq!(audio_extension(Some("")), "m4a"); + assert_eq!(audio_extension(Some("recording")), "m4a"); + assert_eq!(audio_extension(Some("../../etc/passwd")), "m4a"); + assert_eq!(audio_extension(Some("x.verylongextension")), "m4a"); + assert_eq!(audio_extension(Some("x.m4a/../..")), "m4a"); + } + + #[test] + fn audio_size_is_bucketed_never_exact() { + const MB: u64 = 1024 * 1024; + assert_eq!(size_bucket(None), "unknown"); + assert_eq!(size_bucket(Some(0)), "<1MB"); + assert_eq!(size_bucket(Some(MB - 1)), "<1MB"); + assert_eq!(size_bucket(Some(MB)), "1-10MB"); + assert_eq!(size_bucket(Some(10 * MB - 1)), "1-10MB"); + assert_eq!(size_bucket(Some(10 * MB)), "10-50MB"); + assert_eq!(size_bucket(Some(50 * MB - 1)), "10-50MB"); + assert_eq!(size_bucket(Some(50 * MB)), ">50MB"); + assert_eq!(size_bucket(Some(MAX_AUDIO_BYTES)), ">50MB"); + } + + #[test] + fn model_name_never_leaks_a_path_or_a_personal_filename() { + assert_eq!( + safe_model_name("/Users/alice/models/ggml-large-v3-turbo.bin"), + "ggml-large-v3-turbo.bin" + ); + // A Windows path on a unix host is not split into components, so the + // whole string fails the shape check and degrades to "custom" — the + // failure direction we want. + assert!(!safe_model_name("C:\\Users\\alice\\ggml-medium.bin").contains("alice")); + // Anything not shaped like a distributed model is reported generically. + assert_eq!(safe_model_name("/models/alice's therapy notes model.bin"), "custom"); + assert_eq!(safe_model_name("/models/модель.bin"), "custom"); + assert_eq!(safe_model_name(&format!("/models/{}.bin", "x".repeat(80))), "custom"); + assert_eq!(safe_model_name(""), "custom"); + // Whatever happens, no directory component survives. + for path in ["/Users/alice/models/ggml-large-v3-turbo.bin", "/models/alice's model.bin"] { + assert!(!safe_model_name(path).contains("alice"), "{path} leaked a path component"); + assert!(!safe_model_name(path).contains('/')); + } + } + + #[test] + fn pairing_url_matches_the_contract_shape() { + let id = "a".repeat(64); + let token = "0123456789abcdef0123456789abcdef"; + assert_eq!( + format_pairing_url("http://localhost:8088", &id, token), + format!("http://localhost:8088/#{id}:{token}") + ); + // A trailing slash on the origin must not produce a double slash. + assert_eq!( + format_pairing_url("https://vibe.example/", &id, token), + format_pairing_url("https://vibe.example", &id, token) + ); + } +} diff --git a/desktop/src-tauri/src/handoff/protocol.rs b/desktop/src-tauri/src/handoff/protocol.rs new file mode 100644 index 0000000..ec139a5 --- /dev/null +++ b/desktop/src-tauri/src/handoff/protocol.rs @@ -0,0 +1,408 @@ +//! Wire types for the phone handoff protocol (`vibe/handoff/0`). +//! +//! One bi-directional stream per transfer. The phone is the connecting side: +//! it writes a `u32` big-endian header length, that many bytes of UTF-8 JSON +//! ([`HandoffHeader`]), then raw audio bytes until it finishes its send stream. +//! The desktop answers with newline-delimited JSON ([`HandoffEvent`]) on the +//! same stream. + +use serde::{Deserialize, Serialize}; + +/// ALPN negotiated by both sides of the handoff. +pub const ALPN: &[u8] = b"vibe/handoff/0"; + +/// Reject headers larger than this (bytes). +pub const MAX_HEADER_LEN: u32 = 8192; + +/// Reject transfers whose audio body exceeds this (bytes). +pub const MAX_AUDIO_BYTES: u64 = 512 * 1024 * 1024; + +/// What the phone is asking for. Absent means [`OP_TRANSCRIBE`]. +pub const OP_TRANSCRIBE: &str = "transcribe"; + +/// Ask what the loaded model can do. No audio body follows a capabilities request. +pub const OP_CAPABILITIES: &str = "capabilities"; + +/// Phases reported by [`HandoffEvent::Status`]. +pub const PHASE_LOADING_MODEL: &str = "loading_model"; +pub const PHASE_TRANSCRIBING: &str = "transcribing"; + +/// The JSON header the phone sends before the audio body. +#[derive(Debug, Clone, Deserialize)] +pub struct HandoffHeader { + /// 32 hex chars, must match the desktop's persisted pairing token. + pub token: String, + /// Which operation this stream is. Absent or `"transcribe"` means a + /// transcription request with an audio body; `"capabilities"` means a + /// question with no body. Kept as a raw string so an unknown value is + /// rejected as `invalid_request` rather than as a malformed header. + #[serde(default)] + pub op: Option, + /// Original file name, used only to pick a temp-file extension. + #[serde(default)] + pub filename: Option, + /// Content type reported by the phone. Informational. + #[allow(dead_code)] + #[serde(default)] + pub mime: Option, + /// Whisper language code, or `None` for auto-detect. + #[serde(default)] + pub lang: Option, + /// Translate the transcript to English. Passed straight through to Sona; only + /// meaningful when the loaded model reported `translation: true`, but that is + /// the phone's call to make, not enforced here. + #[serde(default)] + pub translate: Option, +} + +/// One newline-delimited JSON object sent back to the phone. +#[derive(Debug, Clone, Serialize)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum HandoffEvent { + /// Header parsed and token accepted; the desktop is reading the audio body. + Accepted, + /// A phase change, so the phone can say "Loading model…" instead of showing a + /// transcription stuck at 0% while a large model loads. + /// + /// NOT terminal, and deliberately additive: a client that does not know this + /// variant must ignore it and keep reading. Only `done` and `error` end a + /// stream — a client tracking "did I see a terminal event" must not set that + /// flag here. + Status { phase: String }, + /// Terminal answer to `op: "capabilities"`. The language list is whatever the + /// currently loaded model supports — the phone must never hardcode one. + #[serde(rename_all = "camelCase")] + Capabilities { + model_loaded: bool, + model_name: Option, + languages: Vec, + language_detection: bool, + translation: bool, + /// So the phone can refuse an oversized recording before spending the + /// user's cellular data pushing it through a relay. + max_audio_bytes: u64, + }, + /// Transcription progress, 0-100. + Progress { progress: i32 }, + /// A transcript segment. `start`/`stop` are centiseconds, matching + /// Vibe's `Segment` type. + Segment { + start: i64, + stop: i64, + text: String, + speaker: Option, + }, + /// Terminal success. `saved_path` is where the desktop kept the recording: + /// the audio only ever existed on the phone until now, so it is saved like any + /// other Vibe recording rather than thrown away. + #[serde(rename_all = "camelCase")] + Done { + text: String, + processing_time_sec: u64, + saved_path: Option, + }, + /// Terminal failure. No `Done` follows it. + Error { code: String, message: String }, +} + +impl HandoffEvent { + /// A non-terminal phase change. See [`PHASE_LOADING_MODEL`] and + /// [`PHASE_TRANSCRIBING`]. + pub fn status(phase: &str) -> Self { + Self::Status { + phase: phase.to_string(), + } + } + + /// Whether this event ends the stream. Only `done` and `error` do. + /// + /// Nothing on the desktop branches on this — the terminal set is pinned here + /// (and in the tests) so that adding a variant forces a decision about it, + /// rather than a client silently guessing which events end a stream. + #[allow(dead_code)] + pub fn is_terminal(&self) -> bool { + matches!(self, Self::Done { .. } | Self::Error { .. }) + } + + /// The honest answer whenever the desktop cannot confirm what is loaded: no + /// model, no language list. The phone renders this as "load a model on your + /// desktop first". This is a normal state, never an error. + pub fn no_capabilities() -> Self { + Self::Capabilities { + model_loaded: false, + model_name: None, + languages: Vec::new(), + language_detection: false, + translation: false, + max_audio_bytes: MAX_AUDIO_BYTES, + } + } + + /// Serialize as a single line, newline included. + pub fn to_line(&self) -> String { + match serde_json::to_string(self) { + Ok(json) => format!("{json}\n"), + Err(error) => { + // Serializing these types cannot realistically fail, but the phone + // must never be left waiting on a dropped connection. + tracing::error!("failed to serialize handoff event: {:?}", error); + "{\"type\":\"error\",\"code\":\"internal_error\",\"message\":\"failed to serialize event\"}\n".to_string() + } + } + } +} + +/// Everything the frontend needs to write a phone transcription into the +/// transcripts store, matching `SaveTranscriptInput` in `lib/transcripts-store.ts`. +/// +/// A handoff transcription happens entirely in Rust, so the frontend's queue never +/// sees it and would otherwise have nothing to save — which is why a phone +/// transcript never reached Recents. +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct HandoffCompletion { + /// Display name for the Recents row. The store appends its own timestamp. + pub name: String, + /// `sourcePath`: where the recording itself was saved. + pub saved_path: String, + pub segments: Vec, + /// The language actually used, or `None` for auto-detect. + pub language: Option, + pub model_path: Option, +} + +/// Payload of the `handoff_activity` Tauri event emitted to the main window. +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct HandoffActivity { + pub state: &'static str, + pub message: Option, + /// Where the phone's recording was saved. Set on the `done` state so the UI + /// can offer "Show in Finder"; `None` otherwise. + #[serde(skip_serializing_if = "Option::is_none")] + pub saved_path: Option, + /// Set only on `done`: hand the frontend a complete transcript record so it + /// can call `saveTranscript`. Whether it actually saves is the frontend's + /// call — it owns the `transcription.saveTranscripts` preference. + #[serde(skip_serializing_if = "Option::is_none")] + pub name: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub segments: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub language: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub model_path: Option, +} + +impl HandoffActivity { + pub fn new(state: &'static str, message: Option) -> Self { + Self { + state, + message, + saved_path: None, + name: None, + segments: None, + language: None, + model_path: None, + } + } + + pub fn done(completion: HandoffCompletion) -> Self { + Self { + state: "done", + message: None, + saved_path: Some(completion.saved_path), + name: Some(completion.name), + segments: Some(completion.segments), + language: completion.language, + model_path: completion.model_path, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn capabilities_line_uses_camel_case_field_names() { + let line = HandoffEvent::Capabilities { + model_loaded: true, + model_name: Some("ggml-large-v3-turbo.bin".to_string()), + languages: vec!["en".to_string(), "he".to_string()], + language_detection: true, + translation: true, + max_audio_bytes: MAX_AUDIO_BYTES, + } + .to_line(); + assert!(line.ends_with('\n')); + let parsed: serde_json::Value = serde_json::from_str(line.trim()).unwrap(); + assert_eq!(parsed["type"], "capabilities"); + assert_eq!(parsed["modelLoaded"], true); + assert_eq!(parsed["modelName"], "ggml-large-v3-turbo.bin"); + assert_eq!(parsed["languageDetection"], true); + assert_eq!(parsed["translation"], true); + assert_eq!(parsed["languages"][1], "he"); + // The phone pre-checks recording size against this to avoid a wasted upload. + assert_eq!(parsed["maxAudioBytes"], 536_870_912u64); + assert_eq!(parsed["maxAudioBytes"], MAX_AUDIO_BYTES); + } + + #[test] + fn no_model_is_reported_as_empty_capabilities_not_an_error() { + let parsed: serde_json::Value = serde_json::from_str(HandoffEvent::no_capabilities().to_line().trim()).unwrap(); + assert_eq!(parsed["type"], "capabilities"); + assert_eq!(parsed["modelLoaded"], false); + assert!(parsed["modelName"].is_null()); + assert_eq!(parsed["languages"].as_array().unwrap().len(), 0); + assert_eq!(parsed["languageDetection"], false); + assert_eq!(parsed["translation"], false); + // Still advertised with no model: the phone needs the cap regardless. + assert_eq!(parsed["maxAudioBytes"], MAX_AUDIO_BYTES); + } + + #[test] + fn done_line_keeps_camel_case_processing_time() { + let parsed: serde_json::Value = serde_json::from_str( + HandoffEvent::Done { + text: "hi".to_string(), + processing_time_sec: 12, + saved_path: Some("/Users/me/Documents/Vibe/phone-2026-08-22-14-30-05.m4a".to_string()), + } + .to_line() + .trim(), + ) + .unwrap(); + assert_eq!(parsed["type"], "done"); + assert_eq!(parsed["processingTimeSec"], 12); + assert_eq!(parsed["savedPath"], "/Users/me/Documents/Vibe/phone-2026-08-22-14-30-05.m4a"); + } + + #[test] + fn status_line_is_additive_and_never_terminal() { + for phase in [PHASE_LOADING_MODEL, PHASE_TRANSCRIBING] { + let event = HandoffEvent::status(phase); + // A client tracking "did I see a terminal event" must not set that flag here. + assert!(!event.is_terminal(), "{phase} must not end the stream"); + let parsed: serde_json::Value = serde_json::from_str(event.to_line().trim()).unwrap(); + assert_eq!(parsed["type"], "status"); + assert_eq!(parsed["phase"], phase); + } + assert_eq!( + HandoffEvent::status(PHASE_LOADING_MODEL).to_line(), + "{\"type\":\"status\",\"phase\":\"loading_model\"}\n" + ); + } + + #[test] + fn only_done_and_error_are_terminal() { + assert!(HandoffEvent::Done { + text: String::new(), + processing_time_sec: 0, + saved_path: None, + } + .is_terminal()); + assert!(HandoffEvent::Error { + code: "no_model".to_string(), + message: String::new(), + } + .is_terminal()); + // Everything else leaves the stream open. + assert!(!HandoffEvent::Accepted.is_terminal()); + assert!(!HandoffEvent::Progress { progress: 42 }.is_terminal()); + assert!(!HandoffEvent::no_capabilities().is_terminal()); + assert!(!HandoffEvent::Segment { + start: 0, + stop: 1, + text: String::new(), + speaker: None, + } + .is_terminal()); + } + + #[test] + fn transcribe_header_carries_lang_and_translate() { + let header: HandoffHeader = serde_json::from_str( + r#"{"token":"0123456789abcdef0123456789abcdef","filename":"recording.m4a","mime":"audio/mp4","lang":"he","translate":true}"#, + ) + .unwrap(); + assert!(header.op.is_none()); + assert_eq!(header.lang.as_deref(), Some("he")); + assert_eq!(header.translate, Some(true)); + } + + #[test] + fn translate_defaults_to_absent_when_the_phone_omits_it() { + let header: HandoffHeader = serde_json::from_str(r#"{"token":"0123456789abcdef0123456789abcdef"}"#).unwrap(); + assert!(header.translate.is_none()); + } + + #[test] + fn done_activity_carries_a_full_transcript_record_for_recents() { + let parsed: serde_json::Value = serde_json::to_value(HandoffActivity::done(HandoffCompletion { + name: "Phone recording".to_string(), + saved_path: "/Users/me/Documents/Vibe/phone-2026-08-22-14-30-05.m4a".to_string(), + segments: vec![crate::transcript::Segment { + start: 120, + stop: 350, + text: "hello there".to_string(), + speaker: None, + }], + language: Some("he".to_string()), + model_path: Some("/models/ggml-medium.bin".to_string()), + })) + .unwrap(); + assert_eq!(parsed["state"], "done"); + // Exactly the field names `saveTranscript` expects. + assert_eq!(parsed["name"], "Phone recording"); + assert_eq!(parsed["savedPath"], "/Users/me/Documents/Vibe/phone-2026-08-22-14-30-05.m4a"); + assert_eq!(parsed["language"], "he"); + assert_eq!(parsed["modelPath"], "/models/ggml-medium.bin"); + assert_eq!(parsed["segments"][0]["start"], 120); + assert_eq!(parsed["segments"][0]["stop"], 350); + assert_eq!(parsed["segments"][0]["text"], "hello there"); + } + + #[test] + fn non_done_activity_carries_no_transcript_fields() { + let receiving: serde_json::Value = serde_json::to_value(HandoffActivity::new("receiving", None)).unwrap(); + assert_eq!(receiving["state"], "receiving"); + for absent in ["savedPath", "name", "segments", "language", "modelPath"] { + assert!(receiving.get(absent).is_none(), "{absent} should be omitted"); + } + } + + #[test] + fn auto_detect_leaves_language_absent_rather_than_guessing() { + let parsed: serde_json::Value = serde_json::to_value(HandoffActivity::done(HandoffCompletion { + name: "Phone recording".to_string(), + saved_path: "/tmp/x.m4a".to_string(), + segments: Vec::new(), + language: None, + model_path: None, + })) + .unwrap(); + assert!(parsed.get("language").is_none()); + assert!(parsed.get("modelPath").is_none()); + assert_eq!(parsed["segments"].as_array().unwrap().len(), 0); + } + + #[test] + fn capabilities_header_with_empty_filename_and_mime_is_accepted() { + // Exactly what agent B's wasm client and the native test client emit. + let raw = r#"{"op":"capabilities","token":"0123456789abcdef0123456789abcdef","filename":"","mime":"","lang":null}"#; + let header: HandoffHeader = serde_json::from_str(raw).unwrap(); + assert_eq!(header.op.as_deref(), Some(OP_CAPABILITIES)); + assert_eq!(header.filename.as_deref(), Some("")); + assert_eq!(header.mime.as_deref(), Some("")); + assert!(header.lang.is_none()); + } + + #[test] + fn header_tolerates_missing_optional_fields() { + let header: HandoffHeader = serde_json::from_str(r#"{"token":"0123456789abcdef0123456789abcdef"}"#).unwrap(); + assert!(header.op.is_none()); + assert!(header.filename.is_none()); + assert!(header.mime.is_none()); + } +} diff --git a/desktop/src-tauri/src/main.rs b/desktop/src-tauri/src/main.rs index 2e0ddbe..345c89d 100644 --- a/desktop/src-tauri/src/main.rs +++ b/desktop/src-tauri/src/main.rs @@ -11,6 +11,7 @@ mod diagnostics; mod dictation_indicator; mod error; mod ffmpeg; +mod handoff; mod logging; mod setup; mod sona; @@ -39,6 +40,7 @@ async fn main() -> Result<()> { #[allow(unused_mut)] let mut builder = tauri::Builder::default() .manage(tray::TrayState::default()) + .manage(tokio::sync::Mutex::>::new(None)) .plugin(tauri_plugin_opener::init()) .plugin(tauri_plugin_http::init()) .plugin(tauri_plugin_clipboard_manager::init()) @@ -66,7 +68,11 @@ async fn main() -> Result<()> { .plugin(tauri_plugin_updater::Builder::default().build()) .plugin(tauri_plugin_process::init()) .plugin(tauri_plugin_global_shortcut::Builder::new().build()) - .plugin(tauri_plugin_notification::init()); + .plugin(tauri_plugin_notification::init()) + .plugin(tauri_plugin_autostart::init( + tauri_plugin_autostart::MacosLauncher::LaunchAgent, + None, + )); if analytics::is_aptabase_configured() { let options = tauri_plugin_aptabase::InitOptions { @@ -88,6 +94,10 @@ async fn main() -> Result<()> { let app = builder .invoke_handler(tauri::generate_handler![ cmd::download::download_file, + cmd::handoff_cmd::handoff_status, + cmd::handoff_cmd::handoff_start, + cmd::handoff_cmd::handoff_stop, + cmd::handoff_cmd::handoff_regenerate_token, cmd::app::get_cargo_features, cmd::config::write_config_atomically, cmd::config::get_config_path, @@ -153,6 +163,11 @@ async fn main() -> Result<()> { process.kill(); } }; + // Drop the handoff router so the iroh endpoint closes cleanly. + let handoff = app.state::>>(); + if let Ok(mut guard) = handoff.try_lock() { + guard.take(); + }; } _ => {} }); diff --git a/desktop/src-tauri/src/setup.rs b/desktop/src-tauri/src/setup.rs index 65c079a..2223aa1 100644 --- a/desktop/src-tauri/src/setup.rs +++ b/desktop/src-tauri/src/setup.rs @@ -153,5 +153,8 @@ pub fn setup(app: &App) -> Result<(), Box> { } crate::dictation_indicator::initialize(app.handle()); } + // Bring phone handoff back up if the user had it on. Returns immediately and binds + // in the background, so an offline or slow network never delays launch. + crate::handoff::restore_on_startup(app.handle()); Ok(()) } diff --git a/desktop/src-tauri/src/sona/mod.rs b/desktop/src-tauri/src/sona/mod.rs index 77ee852..b859d81 100644 --- a/desktop/src-tauri/src/sona/mod.rs +++ b/desktop/src-tauri/src/sona/mod.rs @@ -123,9 +123,15 @@ where impl SonaProcess { pub async fn model_metadata(&self, path: &str) -> Result { - let response = self - .client - .post(format!("{}/v1/models/metadata", self.base_url())) + Self::model_metadata_with(&self.client, &self.base_url(), path).await + } + + /// Same request as [`SonaProcess::model_metadata`], but taking a cloned client + /// and base url like [`SonaProcess::transcribe_stream`] does, so callers can + /// release the `SonaState` mutex before the round trip. + pub async fn model_metadata_with(client: &reqwest::Client, base_url: &str, path: &str) -> Result { + let response = client + .post(format!("{}/v1/models/metadata", base_url)) .json(&serde_json::json!({ "path": path })) .send() .await diff --git a/desktop/src/app.tsx b/desktop/src/app.tsx index 0c49405..fefdc50 100644 --- a/desktop/src/app.tsx +++ b/desktop/src/app.tsx @@ -13,6 +13,7 @@ import { usePreferenceProvider } from './providers/preference' import { ErrorBoundary } from 'react-error-boundary' import { BoundaryFallback } from './components/boundary-fallback' import ErrorModalWithContext from './components/error-modal-with-context' +import HandoffTranscriptSaver from './components/handoff-transcript-saver' import { FilesProvider } from './providers/files-provider' import { HotkeyProvider } from './providers/hotkey' import { ToastProvider } from './providers/toast' @@ -47,6 +48,8 @@ function AppContent() { + {/* Phone transcriptions arrive while the user is elsewhere, so this must outlive any page. */} + } /> diff --git a/desktop/src/components/handoff-transcript-saver.tsx b/desktop/src/components/handoff-transcript-saver.tsx new file mode 100644 index 0000000..f5ba501 --- /dev/null +++ b/desktop/src/components/handoff-transcript-saver.tsx @@ -0,0 +1,109 @@ +import { listen, type UnlistenFn } from '@tauri-apps/api/event' +import { useEffect, useRef } from 'react' +import { toast } from 'sonner' +import { m } from '~/paraglide/messages.js' +import type { Segment } from '~/lib/transcript' +import { notifyTranscriptsChanged, saveTranscript } from '~/lib/transcripts-store' +import { usePreferenceProvider } from '~/providers/preference' + +/** + * Phone handoff transcripts land in Recents. + * + * A phone recording is transcribed entirely in Rust, so it never passes through the transcribe + * queue that normally persists a finished job. Without this listener the result exists only as a + * `handoff_activity` event and disappears the moment the app is closed. + * + * The Settings → Phone section listens to the same event, but it is mounted only while that modal + * is open — and a phone transcription is by definition something that arrives while the user is + * doing something else. This component is mounted once for the app's lifetime instead. + */ + +interface HandoffActivity { + state: 'receiving' | 'loading_model' | 'transcribing' | 'done' | 'error' + message?: string | null + /** Absolute path of the saved phone audio. Only on `done`; either spelling is accepted. */ + savedPath?: string | null + saved_path?: string | null + /** Everything below is only present on `done`, and only once the backend supplies it. */ + segments?: Segment[] | null + language?: string | null + modelPath?: string | null + model_path?: string | null + name?: string | null +} + +function isSegment(value: unknown): value is Segment { + if (typeof value !== 'object' || value === null) return false + const candidate = value as Partial + return typeof candidate.text === 'string' && typeof candidate.start === 'number' && typeof candidate.stop === 'number' +} + +/** Keep only well-formed segments; a payload without any is treated as "nothing to save". */ +function usableSegments(payload: HandoffActivity): Segment[] { + return Array.isArray(payload.segments) ? payload.segments.filter(isSegment) : [] +} + +export default function HandoffTranscriptSaver() { + const preference = usePreferenceProvider() + // The listener is registered once; reading the preference through a ref keeps it current. + const preferenceRef = useRef(preference) + // Guards against saving the same recording twice (a re-emitted event, a remount in dev). + const savedRef = useRef(new Set()) + + useEffect(() => { + preferenceRef.current = preference + }, [preference]) + + useEffect(() => { + let unlisten: UnlistenFn | undefined + let cancelled = false + + const pending = listen('handoff_activity', ({ payload }) => { + if (payload?.state !== 'done') return + + const segments = usableSegments(payload) + // The backend may not carry the transcript yet; better nothing than an empty record. + if (segments.length === 0) return + + const sourcePath = payload.savedPath ?? payload.saved_path ?? '' + const name = payload.name?.trim() || m.phoneRecording() + // Same rule as a local transcription: auto-save only when the user asked for it. + if (!preferenceRef.current.saveTranscripts) return + + const key = sourcePath || `${name}:${segments.length}:${segments[0].start}` + if (savedRef.current.has(key)) return + savedRef.current.add(key) + + // Fire-and-forget, like the queue's own persist: saving must never block the UI. + void saveTranscript({ + name, + sourcePath, + segments, + language: payload.language ?? undefined, + modelPath: payload.modelPath ?? payload.model_path ?? null, + }).then((savedTranscriptPath) => { + if (!savedTranscriptPath) { + // Let it be retried if the same recording is announced again. + savedRef.current.delete(key) + return + } + notifyTranscriptsChanged() + // Quiet, non-modal: the transcription happened while the user was looking elsewhere, + // so a single line telling them where it went is worth more than silence. + toast.success(m.phoneTranscriptionSaved(), { description: name, position: 'bottom-right' }) + }) + }) + + void pending.then((fn) => { + if (cancelled) fn() + else unlisten = fn + }) + + return () => { + cancelled = true + unlisten?.() + } + }, []) + + return null +} diff --git a/desktop/src/pages/main/components/recents-sidebar.tsx b/desktop/src/pages/main/components/recents-sidebar.tsx index 4ef3112..b8ff17a 100644 --- a/desktop/src/pages/main/components/recents-sidebar.tsx +++ b/desktop/src/pages/main/components/recents-sidebar.tsx @@ -2,7 +2,7 @@ import { invoke } from '@tauri-apps/api/core' import * as pathApi from '@tauri-apps/api/path' import * as dialog from '@tauri-apps/plugin-dialog' import * as fs from '@tauri-apps/plugin-fs' -import { Download, MoreHorizontal, Plus, Search, Settings } from 'lucide-react' +import { Download, MoreHorizontal, Plus, Search, Settings, Smartphone } from 'lucide-react' import { useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react' import { m } from '~/paraglide/messages.js' import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger } from '~/components/ui/dropdown-menu' @@ -428,6 +428,18 @@ export default function RecentsSidebar() { {m.settings()} + + + + + {m.phone()} + {availableUpdate && ( diff --git a/desktop/src/pages/settings/page.tsx b/desktop/src/pages/settings/page.tsx index 1e4b2a4..b6833fa 100644 --- a/desktop/src/pages/settings/page.tsx +++ b/desktop/src/pages/settings/page.tsx @@ -1,6 +1,6 @@ import { ReactNode, useState } from 'react' import { m } from '~/paraglide/messages.js' -import { Bot, Cpu, Globe, Mic, ShieldCheck, SlidersHorizontal, Sparkles, Terminal, Wrench, X } from 'lucide-react' +import { Bot, Cpu, Globe, Mic, ShieldCheck, SlidersHorizontal, Smartphone, Sparkles, Terminal, Wrench, X } from 'lucide-react' import { ModifyState } from '~/lib/types' import { viewModel } from './view-model' import { Button } from '~/components/ui/button' @@ -9,6 +9,7 @@ import { ApiSection } from './sections/api' import { DictationSection } from './sections/dictation' import { GeneralSection } from './sections/general' import { ModelsSection } from './sections/models' +import { PhoneSection } from './sections/phone' import { PrivacySection } from './sections/privacy' import { SummarizeSection } from './sections/summarize' import { TranscriptionSection } from './sections/transcription' @@ -19,7 +20,7 @@ interface SettingsPageProps { scrollTo?: string } -type SectionId = 'general' | 'transcription' | 'models' | 'summarize' | 'tuning' | 'dictation' | 'api' | 'privacy' | 'advanced' +type SectionId = 'general' | 'transcription' | 'models' | 'summarize' | 'tuning' | 'dictation' | 'phone' | 'api' | 'privacy' | 'advanced' interface SettingsSection { id: SectionId @@ -56,6 +57,7 @@ export default function SettingsPage({ setVisible, scrollTo }: SettingsPageProps sections: [ { id: 'dictation', label: m.globalDictation(), icon: }, { id: 'summarize', label: m.processWithLlm(), icon: }, + { id: 'phone', label: m.phone(), icon: }, ], }, { @@ -125,6 +127,8 @@ export default function SettingsPage({ setVisible, scrollTo }: SettingsPageProps {activeSection === 'dictation' && } + {activeSection === 'phone' && } + {activeSection === 'api' && } {activeSection === 'privacy' && } diff --git a/desktop/src/pages/settings/sections/general.tsx b/desktop/src/pages/settings/sections/general.tsx index 3ee1373..8076e96 100644 --- a/desktop/src/pages/settings/sections/general.tsx +++ b/desktop/src/pages/settings/sections/general.tsx @@ -1,5 +1,8 @@ +import { disable, enable, isEnabled } from '@tauri-apps/plugin-autostart' import { openUrl } from '@tauri-apps/plugin-opener' import { Moon, Sun } from 'lucide-react' +import { useEffect, useState } from 'react' +import { toast } from 'sonner' import { m } from '~/paraglide/messages.js' import { ReactComponent as DiscordIcon } from '~/icons/discord.svg' import { ReactComponent as GithubIcon } from '~/icons/github.svg' @@ -11,6 +14,54 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '~ import { Switch } from '~/components/ui/switch' import { ActionRow, SettingsGroup, SettingsRow, rowControlClass, type SettingsViewModel } from './shared' +/** + * The OS owns this setting, not our config: the user can remove the login item from + * system settings and a reinstall can clear it. So we read the real state on mount and + * re-read it after every write instead of persisting a key that would drift. + */ +function LaunchAtStartupSwitch() { + const [enabled, setEnabled] = useState(false) + const [busy, setBusy] = useState(false) + + useEffect(() => { + let cancelled = false + isEnabled() + .then((value) => { + if (!cancelled) { + setEnabled(value) + } + }) + .catch((error) => { + console.error('failed to read autostart state', error) + }) + return () => { + cancelled = true + } + }, []) + + async function onCheckedChange(next: boolean) { + setBusy(true) + try { + if (next) { + await enable() + } else { + await disable() + } + } catch (error) { + const message = next ? m.couldNotEnableLaunchAtStartup : m.couldNotDisableLaunchAtStartup + toast.error(message({ error: String(error) })) + } + try { + setEnabled(await isEnabled()) + } catch (error) { + console.error('failed to read autostart state', error) + } + setBusy(false) + } + + return +} + export function GeneralSection({ vm }: { vm: SettingsViewModel }) { const themeLabels = { light: m.light, dark: m.dark } as const const themeIcons = { light: Sun, dark: Moon } as const @@ -24,6 +75,9 @@ export function GeneralSection({ vm }: { vm: SettingsViewModel }) { + + + onQueryChange(event.target.value)} + onKeyDown={onInputKeyDown} + placeholder={triggerLabel} + aria-label={ariaLabel} + className={cn( + 'w-full min-w-0 bg-transparent text-inherit outline-none placeholder:text-muted-foreground', + capitalize && 'placeholder:capitalize', + )} + /> + ) : ( + + )} + + + event.preventDefault()} + className={cn( + 'rounded-2xl p-0', + prominent ? 'w-[240px]' : 'w-[var(--radix-popper-anchor-width)] min-w-[240px] max-w-[calc(100vw-24px)]', + contentClassName + )}> +
+ {flat.length === 0 &&

{emptyLabel ?? 'No matches'}

} + {groups.map((group, groupIndex) => ( +
+ {group.label && ( +

{group.label}

+ )} + {group.items.map((entry) => { + runningIndex += 1 + const index = runningIndex + const active = entry.code === value + return ( + + ) + })} +
+ ))} +
+
+ + ) +} + +/** Does an option match what the user typed? */ +export function matchesQuery(option: LanguageOption, needle: string) { + if (!needle) return true + const haystack = [option.label, option.code, ...(option.keywords ?? [])] + return haystack.some((text) => text.toLowerCase().includes(needle)) +} diff --git a/pwa/src/components/language-picker.tsx b/pwa/src/components/language-picker.tsx new file mode 100644 index 0000000..637c3be --- /dev/null +++ b/pwa/src/components/language-picker.tsx @@ -0,0 +1,104 @@ +import { useMemo, useState } from 'react' + +import { LanguageCombobox, matchesQuery, type LanguageGroup, type LanguageOption } from '~/components/language-combobox' +import { AUTO_LANG, englishLanguageLabel, languageLabel, loadRecentLanguages, rememberLanguage } from '~/lib/languages' +import type { Capabilities } from '~/lib/handoff' + +interface Props { + capabilities: Capabilities | null + /** `''` means auto-detect; the combobox uses the `auto` sentinel internally. */ + value: string + onChange: (lang: string) => void +} + +/** + * Adapted from `desktop/src/components/language-input.tsx`: same control, same + * grouping shape, sourced from the same data. The desktop reads + * `capabilities.language_detection` off the model metadata; the phone reads + * `languageDetection` off the capabilities reply — the same fact, one relay hop + * away. Nothing here knows any language the desktop did not send. + */ +export function LanguagePicker({ capabilities, value, onChange }: Props) { + const [query, setQuery] = useState('') + const [recent, setRecent] = useState(() => loadRecentLanguages()) + + const hasAutoDetect = capabilities?.languageDetection ?? false + const selected = value || (hasAutoDetect ? AUTO_LANG : '') + + // Localized label for display, English name as an extra search needle — so + // typing "german" finds "Deutsch", exactly as the desktop intends. + const entries = useMemo(() => { + const list = (capabilities?.languages ?? []).map((code) => { + const english = englishLanguageLabel(code) + return { code, label: languageLabel(code), keywords: [english], flagCode: code, flagName: english } + }) + list.sort((a, b) => a.label.localeCompare(b.label)) + return list + }, [capabilities]) + + const autoEntry: LanguageOption | null = hasAutoDetect + ? { code: AUTO_LANG, label: 'Auto-detect', keywords: ['auto detect', 'automatic'], globe: true, emphasis: true } + : null + + const needle = query.trim().toLowerCase() + + const groups = useMemo(() => { + if (needle) { + const matches = entries.filter((entry) => matchesQuery(entry, needle)) + if (autoEntry && matchesQuery(autoEntry, needle)) matches.unshift(autoEntry) + return [{ label: null, items: matches }] + } + + // "Popular" on the desktop is a fixed shortlist. On a phone the device + // already knows which languages this person uses, so ask it instead of + // inventing a list. + const deviceCodes = new Set( + (navigator.languages ?? [navigator.language]) + .map((tag) => tag.split('-')[0]?.toLowerCase()) + .filter((code): code is string => !!code) + ) + const recentSet = new Set(recent) + + const recentItems: LanguageOption[] = [] + const deviceItems: LanguageOption[] = [] + const others: LanguageOption[] = [] + for (const entry of entries) { + if (recentSet.has(entry.code)) recentItems.push(entry) + else if (deviceCodes.has(entry.code.toLowerCase())) deviceItems.push(entry) + else others.push(entry) + } + recentItems.sort((a, b) => recent.indexOf(a.code) - recent.indexOf(b.code)) + + const result: LanguageGroup[] = [] + if (autoEntry) result.push({ label: null, items: [autoEntry] }) + if (recentItems.length) result.push({ label: 'Recently used', items: recentItems }) + if (deviceItems.length) result.push({ label: 'On this device', items: deviceItems }) + if (others.length) result.push({ label: 'Others', items: others }) + return result + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [entries, needle, recent, hasAutoDetect]) + + function select(code: string) { + if (code === AUTO_LANG) { + onChange('') + return + } + onChange(code) + setRecent(rememberLanguage(code)) + } + + const current = entries.find((entry) => entry.code === selected) + const triggerLabel = selected === AUTO_LANG && autoEntry ? autoEntry.label : (current?.label ?? 'Choose a language') + + return ( + + ) +} diff --git a/pwa/src/components/settings-sheet.tsx b/pwa/src/components/settings-sheet.tsx new file mode 100644 index 0000000..ffb36cf --- /dev/null +++ b/pwa/src/components/settings-sheet.tsx @@ -0,0 +1,81 @@ +import { Link2Off, X } from 'lucide-react' + +import { Button } from '~/components/ui/button' +import { LanguagePicker } from '~/components/language-picker' +import { truncateId, type Capabilities } from '~/lib/handoff' + +interface Props { + open: boolean + endpointId: string + capabilities: Capabilities | null + lang: string + onLangChange: (lang: string) => void + onUnpair: () => void + onClose: () => void +} + +export function SettingsSheet({ + open, + endpointId, + capabilities, + lang, + onLangChange, + onUnpair, + onClose, +}: Props) { + if (!open) return null + + // Every option below comes from the desktop's capabilities reply. + const canAuto = capabilities?.languageDetection ?? false + const hasLanguages = (capabilities?.languages.length ?? 0) > 0 + + return ( +
+
e.stopPropagation()}> +
+

Settings

+ +
+ +
+ Paired with + {truncateId(endpointId)} +
+ + {capabilities?.modelName && ( +
+ Model + {capabilities.modelName} +
+ )} + +
+ Language + {!hasLanguages ? ( +

+ The desktop has not reported any languages yet. Load a model in Vibe, then re-check. +

+ ) : ( + <> + +

+ {canAuto + ? 'Auto-detect lets the model work out the spoken language.' + : 'This model cannot detect the language, so pick one explicitly.'} +

+ + )} +
+ + +
+
+ ) +} diff --git a/pwa/src/components/ui/badge.tsx b/pwa/src/components/ui/badge.tsx new file mode 100644 index 0000000..c4e5193 --- /dev/null +++ b/pwa/src/components/ui/badge.tsx @@ -0,0 +1,29 @@ +import * as React from 'react' +import { cva, type VariantProps } from 'class-variance-authority' + +import { cn } from '~/lib/style' + +const badgeVariants = cva( + 'inline-flex items-center rounded-md border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2', + { + variants: { + variant: { + default: 'border-transparent bg-primary text-primary-foreground shadow hover:bg-primary/80', + secondary: 'border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80', + destructive: 'border-transparent bg-destructive text-destructive-foreground shadow hover:bg-destructive/80', + outline: 'text-foreground', + }, + }, + defaultVariants: { + variant: 'default', + }, + }, +) + +export interface BadgeProps extends React.HTMLAttributes, VariantProps {} + +function Badge({ className, variant, ...props }: BadgeProps) { + return
+} + +export { Badge, badgeVariants } diff --git a/pwa/src/components/ui/button.tsx b/pwa/src/components/ui/button.tsx new file mode 100644 index 0000000..35d2a65 --- /dev/null +++ b/pwa/src/components/ui/button.tsx @@ -0,0 +1,44 @@ +import * as React from 'react' +import { Slot } from '@radix-ui/react-slot' +import { cva, type VariantProps } from 'class-variance-authority' + +import { cn } from '~/lib/style' + +const buttonVariants = cva( + 'inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-xl text-sm font-semibold cursor-pointer transition-all duration-150 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/80 focus-visible:ring-offset-1 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0', + { + variants: { + variant: { + default: 'bg-primary text-primary-foreground shadow-xs hover:bg-primary/90', + destructive: 'bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90', + outline: 'border border-input/75 bg-card text-foreground shadow-xs hover:bg-accent/65', + secondary: 'bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/86', + ghost: 'hover:bg-accent/65 hover:text-accent-foreground', + link: 'text-primary underline-offset-4 hover:underline', + }, + size: { + default: 'h-11 px-4 py-2 text-sm', + sm: 'h-9 rounded-lg px-3 text-sm', + lg: 'h-12 rounded-xl px-8 text-lg', + icon: 'h-10 w-10', + iconSm: 'h-8 w-8', + }, + }, + defaultVariants: { + variant: 'default', + size: 'default', + }, + }, +) + +export interface ButtonProps extends React.ButtonHTMLAttributes, VariantProps { + asChild?: boolean +} + +const Button = React.forwardRef(({ className, variant, size, asChild = false, ...props }, ref) => { + const Comp = asChild ? Slot : 'button' + return +}) +Button.displayName = 'Button' + +export { Button, buttonVariants } diff --git a/pwa/src/components/ui/card.tsx b/pwa/src/components/ui/card.tsx new file mode 100644 index 0000000..594aa4e --- /dev/null +++ b/pwa/src/components/ui/card.tsx @@ -0,0 +1,35 @@ +import * as React from 'react' + +import { cn } from '~/lib/style' + +const Card = React.forwardRef>(({ className, ...props }, ref) => ( +
+)) +Card.displayName = 'Card' + +const CardHeader = React.forwardRef>(({ className, ...props }, ref) => ( +
+)) +CardHeader.displayName = 'CardHeader' + +const CardTitle = React.forwardRef>(({ className, ...props }, ref) => ( +
+)) +CardTitle.displayName = 'CardTitle' + +const CardDescription = React.forwardRef>(({ className, ...props }, ref) => ( +
+)) +CardDescription.displayName = 'CardDescription' + +const CardContent = React.forwardRef>(({ className, ...props }, ref) => ( +
+)) +CardContent.displayName = 'CardContent' + +const CardFooter = React.forwardRef>(({ className, ...props }, ref) => ( +
+)) +CardFooter.displayName = 'CardFooter' + +export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent } diff --git a/pwa/src/components/ui/popover.tsx b/pwa/src/components/ui/popover.tsx new file mode 100644 index 0000000..3d81a7c --- /dev/null +++ b/pwa/src/components/ui/popover.tsx @@ -0,0 +1,32 @@ +'use client' + +import * as React from 'react' +import * as PopoverPrimitive from '@radix-ui/react-popover' + +import { cn } from '~/lib/style' + +const Popover = PopoverPrimitive.Root + +const PopoverTrigger = PopoverPrimitive.Trigger + +const PopoverContent = React.forwardRef, React.ComponentPropsWithoutRef>( + ({ className, align = 'center', sideOffset = 4, ...props }, ref) => ( + + + + ), +) +PopoverContent.displayName = PopoverPrimitive.Content.displayName + +const PopoverAnchor = PopoverPrimitive.Anchor + +export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor } diff --git a/pwa/src/components/ui/progress.tsx b/pwa/src/components/ui/progress.tsx new file mode 100644 index 0000000..660a4a3 --- /dev/null +++ b/pwa/src/components/ui/progress.tsx @@ -0,0 +1,20 @@ +'use client' + +import * as React from 'react' +import * as ProgressPrimitive from '@radix-ui/react-progress' + +import { cn } from '~/lib/style' + +const Progress = React.forwardRef, React.ComponentPropsWithoutRef>( + ({ className, value, ...props }, ref) => ( + + + + ), +) +Progress.displayName = ProgressPrimitive.Root.displayName + +export { Progress } diff --git a/pwa/src/components/ui/spinner.tsx b/pwa/src/components/ui/spinner.tsx new file mode 100644 index 0000000..fb7dac9 --- /dev/null +++ b/pwa/src/components/ui/spinner.tsx @@ -0,0 +1,5 @@ +import { cn } from '~/lib/style' + +export function Spinner({ className }: { className?: string }) { + return
+} diff --git a/pwa/src/globals.css b/pwa/src/globals.css new file mode 100644 index 0000000..31f1ad4 --- /dev/null +++ b/pwa/src/globals.css @@ -0,0 +1,505 @@ +@import 'tailwindcss'; + +@custom-variant dark (&:is(.dark *)); + +@theme inline { + --color-background: var(--background); + --color-foreground: var(--foreground); + --color-card: var(--card); + --color-card-foreground: var(--card-foreground); + --color-popover: var(--popover); + --color-popover-foreground: var(--popover-foreground); + --color-primary: var(--primary); + --color-primary-foreground: var(--primary-foreground); + --color-secondary: var(--secondary); + --color-secondary-foreground: var(--secondary-foreground); + --color-muted: var(--muted); + --color-muted-foreground: var(--muted-foreground); + --color-accent: var(--accent); + --color-accent-foreground: var(--accent-foreground); + --color-destructive: var(--destructive); + --color-destructive-foreground: var(--destructive-foreground); + --color-success: var(--success); + --color-success-foreground: var(--success-foreground); + --color-border: var(--border); + --color-input: var(--input); + --color-ring: var(--ring); + --font-sans: 'Inter Variable', 'Inter', -apple-system, BlinkMacSystemFont, 'SF Pro Text', 'Helvetica Neue', Arial, sans-serif; + --font-serif: 'Inter Variable', 'Inter', -apple-system, 'Helvetica Neue', Arial, sans-serif; + --radius: 1rem; + --tracking-tighter: calc(var(--tracking-normal) - 0.05em); + --tracking-tight: calc(var(--tracking-normal) - 0.025em); + --tracking-wide: calc(var(--tracking-normal) + 0.025em); + --tracking-wider: calc(var(--tracking-normal) + 0.05em); + --tracking-widest: calc(var(--tracking-normal) + 0.1em); + --tracking-normal: var(--tracking-normal); + --shadow-2xl: var(--shadow-2xl); + --shadow-xl: var(--shadow-xl); + --shadow-lg: var(--shadow-lg); + --shadow-md: var(--shadow-md); + --shadow: var(--shadow); + --shadow-sm: var(--shadow-sm); + --shadow-xs: var(--shadow-xs); + --shadow-2xs: var(--shadow-2xs); + --color-sidebar-ring: var(--sidebar-ring); + --color-sidebar-border: var(--sidebar-border); + --color-sidebar-accent-foreground: var(--sidebar-accent-foreground); + --color-sidebar-accent: var(--sidebar-accent); + --color-sidebar-primary-foreground: var(--sidebar-primary-foreground); + --color-sidebar-primary: var(--sidebar-primary); + --color-sidebar-foreground: var(--sidebar-foreground); + --color-sidebar: var(--sidebar); + --color-chart-5: var(--chart-5); + --color-chart-4: var(--chart-4); + --color-chart-3: var(--chart-3); + --color-chart-2: var(--chart-2); + --color-chart-1: var(--chart-1); + --radius-sm: calc(var(--radius) - 4px); + --radius-md: calc(var(--radius) - 2px); + --radius-lg: var(--radius); + --radius-xl: calc(var(--radius) + 4px); + --radius-2xl: calc(var(--radius) + 8px); + --radius-3xl: calc(var(--radius) + 12px); + --radius-4xl: calc(var(--radius) + 16px); + --font-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Courier New', monospace; +} + +:root { + --radius: 1rem; + + /* ChatGPT-style neutrals — light (default) */ + --background: #ffffff; + --foreground: #1a1c1f; + --card: #ffffff; + --card-foreground: #1a1c1f; + --popover: #ffffff; + --popover-foreground: #1a1c1f; + --primary: #2563eb; + --primary-foreground: #ffffff; + --secondary: #f4f4f4; + --secondary-foreground: #1a1c1f; + --muted: #f4f4f4; + --muted-foreground: #6e6e6e; + --accent: #f4f4f4; + --accent-foreground: #1a1c1f; + --destructive: #b4453c; + --destructive-foreground: #fdf6f5; + --success: #3f7d5f; + --success-foreground: #f3faf6; + --border: #e6e6e6; + --input: #e6e6e6; + --ring: rgb(37 99 235 / 0.35); + + /* Aurora hues — the only chroma in the system */ + --aurora-1: #5b8def; + --aurora-2: #b98de3; + --aurora-3: #e8a87c; + --aurora-4: #7cc5a0; + + --chart-1: var(--aurora-1); + --chart-2: var(--aurora-2); + --chart-3: var(--aurora-3); + --chart-4: var(--aurora-4); + --chart-5: #6f6f6a; + + --sidebar: #f9f9f9; + --sidebar-foreground: #1a1c1f; + --sidebar-primary: #2563eb; + --sidebar-primary-foreground: #ffffff; + --sidebar-accent: #f1f1ee; + --sidebar-accent-foreground: #111110; + --sidebar-border: #e7e7e2; + --sidebar-ring: rgb(17 17 16 / 0.2); + + --shadow-2xs: 0 1px 1px rgb(17 17 16 / 0.04); + --shadow-xs: 0 1px 2px rgb(17 17 16 / 0.05); + --shadow-sm: 0 2px 8px rgb(17 17 16 / 0.04); + --shadow: 0 6px 18px rgb(17 17 16 / 0.05); + --shadow-md: 0 10px 26px rgb(17 17 16 / 0.06); + --shadow-lg: 0 16px 36px rgb(17 17 16 / 0.08); + --shadow-xl: 0 24px 56px rgb(17 17 16 / 0.1); + --shadow-2xl: 0 32px 72px rgb(17 17 16 / 0.12); + + --tracking-normal: -0.01em; +} + +.dark { + --background: #181818; + --foreground: #ececec; + --card: #212121; + --card-foreground: #ececec; + --popover: #2a2a2a; + --popover-foreground: #ececec; + --primary: #2563eb; + --primary-foreground: #ffffff; + --secondary: #242424; + --secondary-foreground: #ececec; + --muted: #242424; + --muted-foreground: #9a9a9a; + --accent: #2a2a2a; + --accent-foreground: #ececec; + --destructive: #c4655c; + --destructive-foreground: #1a0f0e; + --success: #6aab8b; + --success-foreground: #0d1512; + --border: #303030; + --input: #303030; + --ring: rgb(51 156 255 / 0.4); + + --aurora-1: #5b8def; + --aurora-2: #b98de3; + --aurora-3: #e8a87c; + --aurora-4: #7cc5a0; + + --chart-5: #9a9a94; + + --sidebar: #212121; + --sidebar-foreground: #ececec; + --sidebar-primary: #2563eb; + --sidebar-primary-foreground: #ffffff; + --sidebar-accent: #2a2a2a; + --sidebar-accent-foreground: #ececec; + --sidebar-border: #303030; + --sidebar-ring: rgb(51 156 255 / 0.4); + + --shadow-2xs: 0 1px 1px rgb(0 0 0 / 0.3); + --shadow-xs: 0 1px 2px rgb(0 0 0 / 0.35); + --shadow-sm: 0 2px 10px rgb(0 0 0 / 0.4); + --shadow: 0 8px 22px rgb(0 0 0 / 0.45); + --shadow-md: 0 14px 32px rgb(0 0 0 / 0.5); + --shadow-lg: 0 22px 46px rgb(0 0 0 / 0.55); + --shadow-xl: 0 30px 64px rgb(0 0 0 / 0.6); + --shadow-2xl: 0 40px 80px rgb(0 0 0 / 0.65); +} + +@layer base { + html, + body, + #root { + height: 100%; + } + + html { + font-family: + 'Inter Variable', + 'Inter', + -apple-system, + BlinkMacSystemFont, + 'SF Pro Text', + 'Helvetica Neue', + Arial, + sans-serif; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + } + + body { + @apply bg-background text-foreground; + font-size: 14px; + line-height: 1.55; + letter-spacing: -0.01em; + font-feature-settings: + 'rlig' 1, + 'calt' 1, + 'cv05' 1; + background-image: none; + overflow-x: hidden; + } + + h1, + h2, + h3 { + letter-spacing: -0.03em; + } + + html, + body { + scrollbar-width: none; + } + + html::-webkit-scrollbar, + body::-webkit-scrollbar { + width: 0; + height: 0; + } + + .dark body { + background-image: none; + } + + * { + border-color: var(--border); + } + + ::selection { + background: rgb(17 17 16 / 0.14); + } + + .dark ::selection { + background: rgb(244 244 241 / 0.2); + } + + ::-webkit-scrollbar { + height: 6px; + width: 6px; + } + + ::-webkit-scrollbar-track { + background: transparent; + } + + ::-webkit-scrollbar-thumb { + background: rgb(17 17 16 / 0.14); + border-radius: 999px; + } + + :hover::-webkit-scrollbar-thumb { + background: rgb(17 17 16 / 0.22); + } + + .dark ::-webkit-scrollbar-thumb { + background: rgb(244 244 241 / 0.14); + } + + .dark :hover::-webkit-scrollbar-thumb { + background: rgb(244 244 241 / 0.22); + } + + .transcript-editor { + scrollbar-width: auto; + scrollbar-color: rgb(17 17 16 / 0.28) rgb(17 17 16 / 0.05); + scrollbar-gutter: stable; + } + + .transcript-editor::-webkit-scrollbar { + width: 16px !important; + height: 0 !important; + background: transparent !important; + } + + .transcript-editor::-webkit-scrollbar-thumb { + background: rgb(17 17 16 / 0.28) !important; + border: 3px solid transparent !important; + background-clip: padding-box !important; + border-radius: 999px !important; + min-height: 96px !important; + } + + .transcript-editor::-webkit-scrollbar-thumb:hover { + background: rgb(17 17 16 / 0.4) !important; + background-clip: padding-box !important; + } + + .transcript-editor::-webkit-scrollbar-track { + background: transparent !important; + border-left: 1px solid var(--border) !important; + } + + .dark .transcript-editor::-webkit-scrollbar-thumb { + background: rgb(244 244 241 / 0.24) !important; + border: 3px solid transparent !important; + background-clip: padding-box !important; + } + + .dark .transcript-editor::-webkit-scrollbar-thumb:hover { + background: rgb(244 244 241 / 0.36) !important; + background-clip: padding-box !important; + } + + .dark .transcript-editor::-webkit-scrollbar-track { + background: transparent !important; + border-left: 1px solid var(--border) !important; + } + + .dark .transcript-editor { + scrollbar-color: rgb(244 244 241 / 0.24) transparent; + } +} + +@layer components { + .app-shell { + @apply mx-auto w-full max-w-5xl px-5 pb-10 pt-3 md:px-8 md:pt-4; + } + + .app-hero { + @apply relative mb-5 rounded-xl border border-border bg-card px-6 py-5; + } + + .app-panel { + @apply rounded-xl border border-border bg-card p-5 md:p-6; + } + + .app-subtle { + @apply rounded-lg border border-border bg-muted/60 p-3; + } + + .dark .app-hero, + .dark .app-panel { + @apply bg-card text-card-foreground; + background-image: none; + } + + .dark .app-subtle { + @apply bg-muted text-card-foreground; + } + + .app-title { + @apply text-[28px] font-semibold leading-[1.1] tracking-[-0.03em] md:text-[40px]; + } + + /* Eyebrow label — 11px, uppercase, +0.08em */ + .app-kicker, + .eyebrow { + @apply text-[11px] font-medium uppercase leading-none tracking-[0.08em] text-muted-foreground; + } + + .app-main-card { + @apply rounded-2xl border border-border bg-card p-6 shadow-sm md:p-9; + } + + .dark .app-main-card { + @apply bg-card shadow-md; + background-image: none; + } + + /* + * Aurora — the single expressive surface. Soft multi-hue gradient plus a + * grain overlay. Always behind a card or as a thin fill, never behind body text. + */ + .aurora { + position: relative; + isolation: isolate; + background-color: var(--muted); + background-image: + radial-gradient(58% 72% at 14% 16%, #5b8def8c 0%, transparent 68%), radial-gradient(54% 68% at 84% 10%, #b98de37a 0%, transparent 70%), + radial-gradient(58% 66% at 80% 88%, #e8a87c73 0%, transparent 70%), radial-gradient(62% 70% at 18% 92%, #7cc5a073 0%, transparent 70%); + background-repeat: no-repeat; + filter: saturate(0.85); + } + + .aurora::after { + content: ''; + position: absolute; + inset: 0; + pointer-events: none; + border-radius: inherit; + opacity: 0.28; + mix-blend-mode: overlay; + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='140' height='140'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='3' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='140' height='140' filter='url(%23n)' opacity='0.5'/%3E%3C/svg%3E"); + } + + .dark .aurora { + filter: saturate(0.7) brightness(0.8); + } + + /* Thin aurora fill for progress bars */ + .aurora-bar { + background-image: linear-gradient(90deg, var(--aurora-1) 0%, var(--aurora-2) 34%, var(--aurora-3) 67%, var(--aurora-4) 100%); + filter: saturate(0.85); + } + + /* Radix whose indicator is filled with the aurora gradient */ + .progress-aurora > * { + background-color: transparent; + background-image: linear-gradient(90deg, var(--aurora-1) 0%, var(--aurora-2) 34%, var(--aurora-3) 67%, var(--aurora-4) 100%); + filter: saturate(0.85); + } + + .stagger-in { + animation: fade-up 240ms ease-out both; + } +} + + +/* ---- phone-specific base ---------------------------------------------- */ + +@layer base { + html, + body, + #root { + min-height: 100%; + height: auto; + } + + body { + /* Mobile-first: slightly larger base text than the desktop app. */ + font-size: 15px; + overscroll-behavior-y: none; + -webkit-tap-highlight-color: transparent; + } + + button, + select, + a { + touch-action: manipulation; + } +} + +@keyframes fade-up { + from { + opacity: 0; + transform: translateY(6px); + } + + to { + opacity: 1; + transform: translateY(0); + } +} + +@keyframes record-pulse { + 0%, + 100% { + box-shadow: 0 0 0 0 color-mix(in srgb, var(--destructive) 45%, transparent); + } + + 50% { + box-shadow: 0 0 0 22px color-mix(in srgb, var(--destructive) 0%, transparent); + } +} + +@layer utilities { + .record-pulse { + animation: record-pulse 1.6s ease-in-out infinite; + } + + .safe-top { + padding-top: max(0.75rem, env(safe-area-inset-top)); + } + + .safe-bottom { + padding-bottom: max(1.5rem, env(safe-area-inset-bottom)); + } +} + +@media (prefers-reduced-motion: reduce) { + .record-pulse, + .stagger-in { + animation: none; + } +} + +/* Indeterminate bar: a short segment sweeping its track, for work whose + duration the desktop cannot report (loading a model into Sona). Ported from + the desktop app's download bar so the two read the same. */ +@keyframes handoff-sweep { + 0% { + transform: translateX(-100%); + } + 100% { + transform: translateX(300%); + } +} + +.handoff-sweep { + animation: handoff-sweep 1.4s ease-in-out infinite; +} + +@media (prefers-reduced-motion: reduce) { + .handoff-sweep { + animation: none; + transform: translateX(0); + width: 100% !important; + opacity: 0.5; + } +} diff --git a/pwa/src/lib/handoff.ts b/pwa/src/lib/handoff.ts new file mode 100644 index 0000000..cdefbbd --- /dev/null +++ b/pwa/src/lib/handoff.ts @@ -0,0 +1,204 @@ +/** + * Wire types and the wasm bridge. + * + * The wasm bundle is produced by the `handoff-wasm` crate and dropped into + * `pwa/public/wasm/`. Vite copies `public/` verbatim, so we load it with an + * explicit runtime import of an absolute URL — never a bundler-resolved one — + * and rebuilding the crate is picked up without touching the app. + */ + +export const PEER_KEY = 'vibe.handoff.peer' +export const LANG_KEY = 'vibe.handoff.lang' + +// Rebased on the deploy base: the app is served from a subpath on GitHub +// Pages (`/vibe/phone/`), so a root-absolute `/wasm/...` would 404. BASE_URL +// always carries a trailing slash, and `new URL(..., document.baseURI)` +// resolves it against the real document location. +const WASM_JS_URL = new URL(`${import.meta.env.BASE_URL}wasm/handoff_wasm.js`, document.baseURI).href +const WASM_BIN_URL = new URL(`${import.meta.env.BASE_URL}wasm/handoff_wasm_bg.wasm`, document.baseURI).href + +export interface Peer { + endpointId: string + token: string +} + +/** + * Reply to `op: "capabilities"`. The desktop owns this knowledge because it + * depends on the model currently loaded there; the phone must never guess it. + */ +export interface Capabilities { + type: 'capabilities' + modelLoaded: boolean + modelName: string | null + languages: string[] + languageDetection: boolean + /** Parsed to mirror the wire contract; the phone does not offer translation. */ + translation: boolean + /** The desktop's real audio cap in bytes. 0 or absent means "no known limit". */ + maxAudioBytes?: number +} + +export interface HandoffError { + type: 'error' + code: string + message: string +} + +export type CapabilitiesResult = Capabilities | HandoffError + +export type HandoffEvent = + | { type: 'uploadProgress'; sent: number; total: number } + | { type: 'accepted' } + /** + * Non-terminal progress phase. `phase` is deliberately a bare string: more + * phases may be added later and an unknown one must not break the UI. + */ + | { type: 'status'; phase: string } + | { type: 'progress'; progress: number } + | { type: 'segment'; start: number; stop: number; text: string; speaker: number | null } + | { type: 'done'; text: string; processingTimeSec?: number; savedPath?: string } + | { type: 'error'; code: string; message: string } + +interface HandoffClient { + endpoint_id(): string + fetch_capabilities(endpointId: string, token: string): Promise + send_recording( + endpointId: string, + token: string, + filename: string, + mime: string, + lang: string | null | undefined, + translate: boolean, + audio: Uint8Array + ): ReadableStream +} + +interface WasmModule { + default: (init?: unknown) => Promise + HandoffClient: { create(): Promise } +} + +let clientPromise: Promise | null = null + +/** Bind the browser iroh endpoint once and reuse it for every send. */ +export async function getClient(): Promise { + if (!clientPromise) { + clientPromise = (async () => { + const mod = (await import(/* @vite-ignore */ WASM_JS_URL)) as WasmModule + await mod.default({ module_or_path: new URL(WASM_BIN_URL) }) + return await mod.HandoffClient.create() + })().catch((err) => { + clientPromise = null + throw err + }) + } + return clientPromise +} + +/** + * Events cross the wasm boundary as plain JS objects, but be liberal: a string + * or byte chunk is parsed as JSON so a change on the Rust side cannot silently + * break the UI. + */ +export function normalizeEvent(value: unknown): HandoffEvent | null { + if (value == null) return null + if (typeof value === 'string') { + try { + return JSON.parse(value) as HandoffEvent + } catch { + return null + } + } + if (value instanceof Uint8Array) { + try { + return JSON.parse(new TextDecoder().decode(value)) as HandoffEvent + } catch { + return null + } + } + if (value instanceof Map) return Object.fromEntries(value) as unknown as HandoffEvent + if (typeof value === 'object') return value as HandoffEvent + return null +} + +/** + * Ask the desktop what it can do. Never falls back to a locally assumed answer: + * a failure here is reported so the user can retry, because guessing the + * language list is exactly what this round trip exists to avoid. + */ +export async function fetchCapabilities(peer: Peer): Promise { + let client: HandoffClient + try { + client = await getClient() + } catch (err) { + return { type: 'error', code: 'wasm', message: err instanceof Error ? err.message : String(err) } + } + + try { + const raw = await client.fetch_capabilities(peer.endpointId, peer.token) + const parsed = normalizeEvent(raw) as CapabilitiesResult | null + if (!parsed) return { type: 'error', code: 'protocol', message: 'The desktop sent an unreadable capabilities reply.' } + if (parsed.type === 'capabilities' || parsed.type === 'error') return parsed + return { type: 'error', code: 'protocol', message: 'Unexpected reply to the capabilities request.' } + } catch (err) { + return { type: 'error', code: 'transport', message: err instanceof Error ? err.message : String(err) } + } +} + +/* ------------------------------------------------------------------ pairing */ + +export function loadPeer(): Peer | null { + try { + const raw = localStorage.getItem(PEER_KEY) + if (!raw) return null + const parsed = JSON.parse(raw) as Partial + if (typeof parsed?.endpointId === 'string' && typeof parsed?.token === 'string') { + return { endpointId: parsed.endpointId, token: parsed.token } + } + } catch { + /* corrupt storage — treat as unpaired */ + } + return null +} + +export function savePeer(peer: Peer): void { + try { + localStorage.setItem(PEER_KEY, JSON.stringify(peer)) + } catch { + /* private mode */ + } +} + +export function clearPeer(): void { + try { + localStorage.removeItem(PEER_KEY) + } catch { + /* ignore */ + } +} + +/** Pairing URL is `/#:` — 64 hex, then 32 hex. */ +export function parsePairingHash(hash: string): Peer | null { + const raw = hash.replace(/^#/, '').trim() + if (!raw) return null + const idx = raw.indexOf(':') + if (idx <= 0) return null + const endpointId = raw.slice(0, idx).trim().toLowerCase() + const token = raw.slice(idx + 1).trim() + if (!/^[0-9a-f]{64}$/.test(endpointId)) return null + if (!/^[0-9a-f]{32}$/.test(token)) return null + return { endpointId, token } +} + +export function truncateId(id: string): string { + return id.length > 16 ? `${id.slice(0, 8)}…${id.slice(-4)}` : id +} + +/** + * Last path component of a desktop filesystem path. The full absolute path is + * meaningless on a phone screen, so the done state shows only this. + */ +export function basename(path: string): string { + const parts = path.split(/[\\/]/).filter(Boolean) + return parts.length > 0 ? parts[parts.length - 1] : path +} diff --git a/pwa/src/lib/languages.ts b/pwa/src/lib/languages.ts new file mode 100644 index 0000000..027e589 --- /dev/null +++ b/pwa/src/lib/languages.ts @@ -0,0 +1,100 @@ +/** + * Language *display*, not language *knowledge*. + * + * The set of supported languages belongs to the desktop — it depends on the + * model the user has loaded, so the phone asks for it (`op: "capabilities"`) + * and never carries a table of its own. What the phone does own is turning the + * raw whisper codes the desktop sends into names a human can read, which + * `Intl.DisplayNames` does in the browser's own locale for free. + */ + +/** Sentinel for the Radix select, which cannot hold an empty-string value. */ +export const AUTO_LANG = 'auto' + +let displayNames: Intl.DisplayNames | null | undefined + +function getDisplayNames(): Intl.DisplayNames | null { + if (displayNames === undefined) { + try { + displayNames = new Intl.DisplayNames(navigator.languages ?? [navigator.language], { + type: 'language', + fallback: 'none', + }) + } catch { + displayNames = null + } + } + return displayNames +} + +/** Human-readable name for a whisper language code, falling back to the code itself. */ +export function languageLabel(code: string): string { + const names = getDisplayNames() + if (names) { + try { + const label = names.of(code) + if (label) return label[0].toUpperCase() + label.slice(1) + } catch { + /* malformed code — fall through */ + } + } + return code +} + +let englishNames: Intl.DisplayNames | null | undefined + +/** + * English name for a code, used as a search keyword alongside the localized + * label so someone typing "german" still finds "Deutsch" — the same trick the + * desktop picker uses. + */ +export function englishLanguageLabel(code: string): string { + if (englishNames === undefined) { + try { + englishNames = new Intl.DisplayNames(['en'], { type: 'language', fallback: 'none' }) + } catch { + englishNames = null + } + } + if (englishNames) { + try { + const label = englishNames.of(code) + if (label) return label + } catch { + /* malformed code */ + } + } + return code +} + +/* ------------------------------------------------------------ recent list */ + +const RECENT_KEY = 'vibe.handoff.recentLangs' +const RECENT_MAX = 5 + +/** + * Recently-picked codes, most recent first. Stored as a plain ordered list, so + * the "recent" group needs no timestamps and therefore no date library. + */ +export function loadRecentLanguages(): string[] { + try { + const raw = localStorage.getItem(RECENT_KEY) + if (!raw) return [] + const parsed = JSON.parse(raw) + if (Array.isArray(parsed)) return parsed.filter((code): code is string => typeof code === 'string').slice(0, RECENT_MAX) + } catch { + /* corrupt or unavailable storage */ + } + return [] +} + +/** Move `code` to the front of the recent list and persist it. */ +export function rememberLanguage(code: string): string[] { + const next = [code, ...loadRecentLanguages().filter((entry) => entry !== code)].slice(0, RECENT_MAX) + try { + localStorage.setItem(RECENT_KEY, JSON.stringify(next)) + } catch { + /* private mode */ + } + return next +} diff --git a/pwa/src/lib/recorder.ts b/pwa/src/lib/recorder.ts new file mode 100644 index 0000000..aef87a9 --- /dev/null +++ b/pwa/src/lib/recorder.ts @@ -0,0 +1,73 @@ +/** + * MediaRecorder container negotiation. + * + * Safari supports none of the Opus containers — it records `audio/mp4` (AAC). + * Chrome/Firefox prefer WebM/Opus. So probe in preference order and, if nothing + * reports support, construct the recorder with no `mimeType` at all and take + * whatever the browser produces. We never synthesise WAV; the desktop side gets + * the real `blob.type` and a filename whose extension matches it. + */ + +export const MIME_CANDIDATES = ['audio/webm;codecs=opus', 'audio/ogg;codecs=opus', 'audio/mp4'] as const + +const EXT_BY_MIME: Array<[RegExp, string]> = [ + [/^audio\/mp4$/, 'm4a'], + [/^audio\/x-m4a$/, 'm4a'], + [/^audio\/aac$/, 'aac'], + [/^audio\/webm$/, 'webm'], + [/^audio\/ogg$/, 'ogg'], + [/^audio\/opus$/, 'opus'], + [/^video\/mp4$/, 'mp4'], + [/^audio\/mpeg$/, 'mp3'], + [/^audio\/wav$/, 'wav'], + [/^audio\/x-wav$/, 'wav'], +] + +/** Best supported container, or `null` to let the browser pick its default. */ +export function pickMimeType(): string | null { + if (typeof MediaRecorder === 'undefined') return null + if (typeof MediaRecorder.isTypeSupported !== 'function') return null + for (const candidate of MIME_CANDIDATES) { + try { + if (MediaRecorder.isTypeSupported(candidate)) return candidate + } catch { + /* some engines throw on unknown type strings */ + } + } + return null +} + +export function extForMime(mime: string): string { + const base = String(mime || '') + .split(';')[0] + .trim() + .toLowerCase() + for (const [pattern, ext] of EXT_BY_MIME) if (pattern.test(base)) return ext + return 'bin' +} + +export function filenameFor(mime: string): string { + return `recording.${extForMime(mime)}` +} + +export function canRecord(): boolean { + return ( + typeof MediaRecorder !== 'undefined' && + typeof navigator !== 'undefined' && + !!navigator.mediaDevices && + typeof navigator.mediaDevices.getUserMedia === 'function' + ) +} + +export function formatDuration(ms: number): string { + const total = Math.max(0, Math.floor(ms / 1000)) + const minutes = Math.floor(total / 60) + const seconds = total % 60 + return `${minutes}:${String(seconds).padStart(2, '0')}` +} + +export function formatSize(bytes: number): string { + if (bytes < 1024) return `${bytes} B` + if (bytes < 1024 * 1024) return `${Math.round(bytes / 1024)} KB` + return `${(bytes / (1024 * 1024)).toFixed(1)} MB` +} diff --git a/pwa/src/lib/style.ts b/pwa/src/lib/style.ts new file mode 100644 index 0000000..7b8d9c1 --- /dev/null +++ b/pwa/src/lib/style.ts @@ -0,0 +1,10 @@ +import { clsx, type ClassValue } from 'clsx' +import { twMerge } from 'tailwind-merge' + +export function cn(...inputs: ClassValue[]) { + return twMerge(clsx(inputs)) +} + +export function cx(...cns: (boolean | string | undefined)[]): string { + return cns.filter(Boolean).join(' ') +} diff --git a/pwa/src/lib/use-install.ts b/pwa/src/lib/use-install.ts new file mode 100644 index 0000000..5da988d --- /dev/null +++ b/pwa/src/lib/use-install.ts @@ -0,0 +1,116 @@ +import { useCallback, useEffect, useState } from 'react' + +const DISMISSED_KEY = 'vibe.handoff.installDismissed' + +/** The Chromium-only event; not in lib.dom, so declare the shape we use. */ +interface BeforeInstallPromptEvent extends Event { + prompt(): Promise + userChoice: Promise<{ outcome: 'accepted' | 'dismissed' }> +} + +export type InstallMode = 'none' | 'prompt' | 'ios-manual' + +/** Already running as an installed app? Then there is nothing to suggest. */ +export function isStandalone(): boolean { + const iosStandalone = (navigator as Navigator & { standalone?: boolean }).standalone === true + const displayMode = typeof matchMedia === 'function' && matchMedia('(display-mode: standalone)').matches + return iosStandalone || displayMode +} + +function isIosSafari(): boolean { + const ua = navigator.userAgent + // iPadOS 13+ reports a desktop UA, so also treat a touch-capable "Mac" as iOS. + const ios = /iPhone|iPad|iPod/.test(ua) || (navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1) + if (!ios) return false + // Chrome/Firefox/Edge on iOS cannot install to the home screen at all. + return !/CriOS|FxiOS|EdgiOS|OPiOS/.test(ua) +} + +/** + * Home-screen install affordance. + * + * Chromium fires `beforeinstallprompt`, which we capture and replay behind our + * own control. iOS Safari has no such event — installing is a manual Share → + * Add to Home Screen gesture — so there we show instructions instead. + */ +export function useInstall() { + const [mode, setMode] = useState('none') + const [deferred, setDeferred] = useState(null) + + useEffect(() => { + if (isStandalone()) return + + let dismissed = false + try { + dismissed = localStorage.getItem(DISMISSED_KEY) === '1' + } catch { + /* private mode */ + } + if (dismissed) return + + const onBeforeInstall = (event: Event) => { + event.preventDefault() + setDeferred(event as BeforeInstallPromptEvent) + setMode('prompt') + } + const onInstalled = () => { + setMode('none') + setDeferred(null) + try { + localStorage.setItem(DISMISSED_KEY, '1') + } catch { + /* private mode */ + } + } + + window.addEventListener('beforeinstallprompt', onBeforeInstall) + window.addEventListener('appinstalled', onInstalled) + + if (isIosSafari()) setMode('ios-manual') + + return () => { + window.removeEventListener('beforeinstallprompt', onBeforeInstall) + window.removeEventListener('appinstalled', onInstalled) + } + }, []) + + /** Never nag: a dismissal is remembered for good. */ + const dismiss = useCallback(() => { + setMode('none') + setDeferred(null) + try { + localStorage.setItem(DISMISSED_KEY, '1') + } catch { + /* private mode */ + } + }, []) + + const install = useCallback(async () => { + if (!deferred) return + await deferred.prompt() + const { outcome } = await deferred.userChoice + if (outcome === 'dismissed') dismiss() + else { + setMode('none') + setDeferred(null) + } + }, [deferred, dismiss]) + + return { mode, install, dismiss } +} + +/** + * Ask the browser to keep our storage. The pairing lives entirely in + * localStorage, and WebKit evicts script-written storage from origins that go + * unused. Persistence is granted on heuristics — being an installed home-screen + * web app is one of them — so this is best-effort and never blocks anything. + */ +export async function requestPersistentStorage(): Promise { + try { + if (navigator.storage?.persist && navigator.storage.persisted) { + if (!(await navigator.storage.persisted())) await navigator.storage.persist() + } + } catch { + /* unsupported or refused — nothing to do */ + } +} diff --git a/pwa/src/lib/use-wake-lock.ts b/pwa/src/lib/use-wake-lock.ts new file mode 100644 index 0000000..ceec505 --- /dev/null +++ b/pwa/src/lib/use-wake-lock.ts @@ -0,0 +1,80 @@ +import { useCallback, useEffect, useRef } from 'react' + +/** + * Screen wake lock for the whole handoff operation — recording, upload, and the + * wait for the desktop's transcript. Transcription on a large model runs for + * tens of seconds, and on iOS a sleeping screen suspends the page and drops the + * relay connection, so the lock must outlive the recording itself. + * + * Two facts drive the shape of this hook: + * - The spec releases the lock whenever the document becomes hidden, and never + * restores it. So we track whether a lock is still *wanted* and re-acquire on + * the way back to visible. + * - `request()` rejects when the document is hidden, on low battery, and in + * browsers without support. None of that may break a recording, so every + * call is guarded and failure is silent. + */ +export function useWakeLock() { + const lockRef = useRef(null) + const wantedRef = useRef(false) + + const acquire = useCallback(async () => { + wantedRef.current = true + if (lockRef.current) return + if (typeof document !== 'undefined' && document.visibilityState !== 'visible') return + try { + if (!navigator.wakeLock?.request) return + const lock = await navigator.wakeLock.request('screen') + // Released while we were awaiting: honour that, do not leak the lock. + if (!wantedRef.current) { + void lock.release() + return + } + lockRef.current = lock + lock.addEventListener('release', () => { + if (lockRef.current === lock) lockRef.current = null + }) + } catch { + lockRef.current = null + } + }, []) + + const release = useCallback(() => { + wantedRef.current = false + const lock = lockRef.current + lockRef.current = null + if (lock) { + try { + void lock.release() + } catch { + /* already released */ + } + } + }, []) + + /** After the page becomes visible again, take the lock back if still needed. */ + const reacquireIfWanted = useCallback(() => { + if (wantedRef.current && !lockRef.current) void acquire() + }, [acquire]) + + /** Test/diagnostic view of the current state. */ + const isHeld = useCallback(() => lockRef.current !== null, []) + + // A leaked screen lock is its own bug: drop it if we unmount mid-operation. + useEffect(() => { + return () => { + wantedRef.current = false + const lock = lockRef.current + lockRef.current = null + if (lock) { + try { + void lock.release() + } catch { + /* already released */ + } + } + } + }, []) + + return { acquire, release, reacquireIfWanted, isHeld } +} diff --git a/pwa/src/main.tsx b/pwa/src/main.tsx new file mode 100644 index 0000000..af0a49e --- /dev/null +++ b/pwa/src/main.tsx @@ -0,0 +1,36 @@ +import React from 'react' +import ReactDOM from 'react-dom/client' +import { Toaster } from 'sonner' + +import { App } from '~/App' +import '~/globals.css' + +// The desktop app follows the OS theme; a phone PWA has no theme switcher, so +// mirror `prefers-color-scheme` onto the `.dark` class the tokens key off. +function syncTheme() { + const media = window.matchMedia('(prefers-color-scheme: dark)') + const apply = () => document.documentElement.classList.toggle('dark', media.matches) + apply() + media.addEventListener('change', apply) +} + +syncTheme() + +ReactDOM.createRoot(document.getElementById('root')!).render( + + + + +) + +if ('serviceWorker' in navigator) { + window.addEventListener('load', () => { + // Registered at the deploy base, not the root. A worker at + // `/vibe/phone/sw.js` gets scope `/vibe/phone/` — exactly the app's + // subtree, and nothing of the website around it. + const base = import.meta.env.BASE_URL + navigator.serviceWorker.register(`${base}sw.js`, { scope: base }).catch(() => { + /* installability is a nice-to-have, never fatal */ + }) + }) +} diff --git a/pwa/tsconfig.json b/pwa/tsconfig.json new file mode 100644 index 0000000..f33ade6 --- /dev/null +++ b/pwa/tsconfig.json @@ -0,0 +1,30 @@ +{ + "compilerOptions": { + "target": "ES2022", + "useDefineForClassFields": true, + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "module": "ESNext", + "skipLibCheck": true, + + "paths": { + "~/*": ["./src/*"] + }, + "types": ["vite/client"], + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "jsx": "react-jsx", + + /* Linting */ + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true + }, + "include": ["src"], + "references": [{ "path": "./tsconfig.node.json" }] +} diff --git a/pwa/tsconfig.node.json b/pwa/tsconfig.node.json new file mode 100644 index 0000000..eca6668 --- /dev/null +++ b/pwa/tsconfig.node.json @@ -0,0 +1,10 @@ +{ + "compilerOptions": { + "composite": true, + "skipLibCheck": true, + "module": "ESNext", + "moduleResolution": "bundler", + "allowSyntheticDefaultImports": true + }, + "include": ["vite.config.ts"] +} diff --git a/pwa/vite.config.ts b/pwa/vite.config.ts new file mode 100644 index 0000000..7e9ebae --- /dev/null +++ b/pwa/vite.config.ts @@ -0,0 +1,36 @@ +import react from '@vitejs/plugin-react' +import tailwindcss from '@tailwindcss/vite' +import { defineConfig } from 'vite' + +/** + * The PWA is deployed inside the Vibe website's GitHub Pages artifact, at + * `https://thewh1teagle.github.io/vibe/phone/`. Nothing in the app may assume + * it lives at the domain root: every runtime URL is rebased on + * `import.meta.env.BASE_URL`, and the manifest/service worker use relative + * paths so they resolve against wherever they happen to be served from. + * + * `PWA_BASE` overrides the production base (must keep the leading and + * trailing slash). The dev server stays at `/` so `http://localhost:8088/` + * works unchanged. + */ +const PROD_BASE = process.env.PWA_BASE ?? '/vibe/phone/' + +export default defineConfig(({ command }) => ({ + base: command === 'serve' ? '/' : PROD_BASE, + plugins: [react(), tailwindcss()], + resolve: { + alias: { + '~': '/src', + }, + }, + server: { + port: 8088, + strictPort: true, + host: true, + }, + preview: { + port: 8088, + strictPort: true, + host: true, + }, +}))