diff --git a/.github/workflows/desktop.yml b/.github/workflows/desktop.yml new file mode 100644 index 000000000..eae62565c --- /dev/null +++ b/.github/workflows/desktop.yml @@ -0,0 +1,70 @@ +# Desktop app (apps/desktop): a standalone cargo workspace excluded from the +# root one, so the library jobs never build the gpui tree. It still runs on +# library-path changes: the app is the library's consumer test. +name: Desktop App + +on: + push: + branches: ["main"] + paths: + - "apps/desktop/**" + - "src/**" + - "wacore/**" + - "waproto/**" + - "storages/**" + - "transports/**" + - "http_clients/**" + - "Cargo.toml" + - ".github/workflows/desktop.yml" + pull_request: + paths: + - "apps/desktop/**" + - "src/**" + - "wacore/**" + - "waproto/**" + - "storages/**" + - "transports/**" + - "http_clients/**" + - "Cargo.toml" + - ".github/workflows/desktop.yml" + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +env: + CARGO_TERM_COLOR: always + +jobs: + desktop: + name: Check desktop app + runs-on: ubuntu-latest + defaults: + run: + working-directory: apps/desktop + steps: + - uses: actions/checkout@v6 + with: + persist-credentials: false + - uses: dtolnay/rust-toolchain@master + with: + toolchain: nightly-2026-06-16 + components: rustfmt, clippy + - name: Install system dependencies (cached) + uses: awalsh128/cache-apt-pkgs-action@v1 + with: + packages: libasound2-dev libxkbcommon-dev libxkbcommon-x11-dev libwayland-dev libxcb1-dev libxcb-render0-dev libxcb-shape0-dev libxcb-xfixes0-dev libfontconfig1-dev libfreetype6-dev + version: 1.2 + - name: Install protoc + uses: taiki-e/install-action@v2 + with: + tool: protoc + - uses: Swatinem/rust-cache@v2 + with: + workspaces: apps/desktop + - name: Check formatting + run: cargo fmt --all -- --check + - name: Clippy (deny warnings) + run: cargo clippy --all-targets -- -D warnings + - name: Tests + run: cargo test diff --git a/.gitignore b/.gitignore index 335a7273e..ac521d058 100644 --- a/.gitignore +++ b/.gitignore @@ -15,3 +15,6 @@ dhat-heap*.json # proptest persists failure seeds next to the test on failure; these are # environment-local replay caches, not source. *.proptest-regressions + +# Nested workspaces build output +apps/*/target/ diff --git a/Cargo.toml b/Cargo.toml index cbf1eb214..0f9ed18a4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -10,6 +10,9 @@ readme = "README.md" description = "Rust client for WhatsApp Web" [workspace] +# apps/desktop is its own workspace (heavy gpui tree, own Cargo.lock and +# [patch]); it consumes this workspace's crates via path deps. +exclude = ["apps/desktop"] members = [ ".", "examples/voip-cli", diff --git a/apps/desktop/Cargo.lock b/apps/desktop/Cargo.lock new file mode 100644 index 000000000..3d80ac373 --- /dev/null +++ b/apps/desktop/Cargo.lock @@ -0,0 +1,10936 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "accesskit" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3b7f7f85a7e5f68090000ed7622545829afd484d210358702ae4cb97dd0c320" +dependencies = [ + "uuid", +] + +[[package]] +name = "accesskit_atspi_common" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e8c61bee90b42a772d39d06a740207dc71a4e780004ace1db8d99fb1baaa954" +dependencies = [ + "accesskit", + "accesskit_consumer 0.36.0", + "atspi-common", + "phf 0.13.1", + "serde", + "zvariant", +] + +[[package]] +name = "accesskit_consumer" +version = "0.36.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25e0d7e25d06f4dc21d1774d67146e9e80d6789216cbd4d1e88185b0095dba60" +dependencies = [ + "accesskit", + "hashbrown 0.16.1", +] + +[[package]] +name = "accesskit_consumer" +version = "0.37.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f950720ce064757a1b629caad3a408e8d2c63bb01f29b8a3ff8daa331053ffeb" +dependencies = [ + "accesskit", + "hashbrown 0.16.1", +] + +[[package]] +name = "accesskit_macos" +version = "0.26.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "17cb8b66cef272d48161b02a6317cc2bdd5f98bb0a5e79c68f704a5862aa396b" +dependencies = [ + "accesskit", + "accesskit_consumer 0.37.0", + "hashbrown 0.16.1", + "objc2 0.5.2", + "objc2-app-kit 0.2.2", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "accesskit_unix" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b016ca8db0ea0ea2ceff29a9d6240391492d960716aa471967c00e8cc8cb197c" +dependencies = [ + "accesskit", + "accesskit_atspi_common", + "async-channel", + "async-executor", + "async-task", + "atspi", + "futures-lite", + "futures-util", + "serde", + "zbus", +] + +[[package]] +name = "accesskit_windows" +version = "0.33.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36e93ac7bf50b964f1cbb75f741629a4e950571baa1ef1274457ab5a80d9bcc2" +dependencies = [ + "accesskit", + "accesskit_consumer 0.37.0", + "hashbrown 0.16.1", + "static_assertions", + "windows 0.62.2", + "windows-core 0.62.2", +] + +[[package]] +name = "addr2line" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b5d307320b3181d6d7954e663bd7c774a838b8220fe0593c86d9fb09f498b4b" +dependencies = [ + "gimli", +] + +[[package]] +name = "adler2" +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 = "aead" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1973cfbc1a2daf9cf550e74e1f088c28e7f7d8c1e1418fb6c9dc5184b7e84c99" +dependencies = [ + "bytes", + "crypto-common 0.2.2", + "inout 0.2.2", +] + +[[package]] +name = "aes" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" +dependencies = [ + "cfg-if", + "cipher 0.4.4", + "cpufeatures 0.2.17", + "zeroize", +] + +[[package]] +name = "aes" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1fc76eaeac4c9164506c466d4ffdd8ec9d0c5bf57ee97177c4d8eceb3a0e138" +dependencies = [ + "cipher 0.5.2", + "cpubits", + "cpufeatures 0.3.0", +] + +[[package]] +name = "aes-gcm" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "831010a0f742e1209b3bcea8fab6a8e149051ba6099432c8cb2cc117dec3ead1" +dependencies = [ + "aead 0.5.2", + "aes 0.8.4", + "cipher 0.4.4", + "ctr 0.9.2", + "ghash 0.5.1", + "subtle", +] + +[[package]] +name = "aes-gcm" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdf011db2e21ce0d575593d749db5554b47fed37aff429e4dc50bc91ac93a028" +dependencies = [ + "aead 0.6.1", + "aes 0.9.1", + "cipher 0.5.2", + "ctr 0.10.1", + "ghash 0.6.0", + "subtle", +] + +[[package]] +name = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if", + "const-random", + "once_cell", + "version_check", + "zerocopy", +] + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "aligned" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee4508988c62edf04abd8d92897fca0c2995d907ce1dfeaf369dac3716a40685" +dependencies = [ + "as-slice", +] + +[[package]] +name = "aligned-vec" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc890384c8602f339876ded803c97ad529f3842aba97f6392b3dba0dd171769b" +dependencies = [ + "equator", +] + +[[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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "812947049edcd670a82cd5c73c3661d2e58468577ba8489de58e1a73c04cbd5d" +dependencies = [ + "alsa-sys", + "bitflags 2.13.0", + "cfg-if", + "libc", +] + +[[package]] +name = "alsa-sys" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad7569085a265dd3f607ebecce7458eaab2132a84393534c95b18dcbc3f31e04" +dependencies = [ + "libc", + "pkg-config", +] + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + +[[package]] +name = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anstyle-parse" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys 0.61.2", +] + +[[package]] +name = "anyhow" +version = "1.0.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a4385e2e34eb35d6b3efe798b9eb88096925d87726c0798709bf56d9ed84af3" + +[[package]] +name = "ar_archive_writer" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4087686b4b0a3427190bae57a1d9a478dbb2d40c5dc1bd6e2b6d797913bdd348" +dependencies = [ + "object", +] + +[[package]] +name = "arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1" + +[[package]] +name = "arc-swap" +version = "1.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c049c0be4daef0b145cb3555416b3b8ef5b7888a38aea1a3a155801fe7b0810b" +dependencies = [ + "rustversion", +] + +[[package]] +name = "arg_enum_proc_macro" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ae92a5119aa49cdbcf6b9f893fe4e1d98b04ccbf82ee0584ad948a44a734dea" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "arrayref" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76a2e8124351fda1ef8aaaa3bbd7ebbcb486bbcd4225aca0aa0d84bb2db8fecb" + +[[package]] +name = "arrayvec" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56" + +[[package]] +name = "as-raw-xcb-connection" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "175571dd1d178ced59193a6fc02dde1b972eb0bc56c892cde9beeceac5bf0f6b" + +[[package]] +name = "as-slice" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "516b6b4f0e40d50dcda9365d53964ec74560ad4284da2e7fc97122cd83174516" +dependencies = [ + "stable_deref_trait", +] + +[[package]] +name = "ash" +version = "0.38.0+1.3.281" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bb44936d800fea8f016d7f2311c6a4f97aebd5dc86f09906139ec848cf3a46f" +dependencies = [ + "libloading", +] + +[[package]] +name = "ashpd" +version = "0.13.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "281e6645758940dee594495e28807a7672ce40f11ebf4df6c22c4fcd59e2689f" +dependencies = [ + "enumflags2", + "futures-channel", + "futures-util", + "getrandom 0.4.3", + "serde", + "serde_repr", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "zbus", +] + +[[package]] +name = "asn1-rs" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5493c3bedbacf7fd7382c6346bbd66687d12bbaad3a89a2d2c303ee6cf20b048" +dependencies = [ + "asn1-rs-derive", + "asn1-rs-impl", + "displaydoc", + "nom 7.1.3", + "num-traits", + "rusticata-macros", + "thiserror 1.0.69", + "time", +] + +[[package]] +name = "asn1-rs-derive" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "965c2d33e53cb6b267e148a4cb0760bc01f4904c1cd4bb4002a085bb016d1490" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "asn1-rs-impl" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b18050c2cd6fe86c3a76584ef5e0baf286d038cda203eb6223df2cc413565f7" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "async-broadcast" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "435a87a52755b8f27fcf321ac4f04b2802e337c8c4872923137471ec39c37532" +dependencies = [ + "event-listener", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-channel" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "924ed96dd52d1b75e9c1a3e6275715fd320f5f9439fb5a4a11fa51f4221158d2" +dependencies = [ + "concurrent-queue", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-compression" +version = "0.4.42" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e79b3f8a79cccc2898f31920fc69f304859b3bd567490f75ebf51ae1c792a9ac" +dependencies = [ + "compression-codecs", + "compression-core", + "futures-io", + "pin-project-lite", +] + +[[package]] +name = "async-executor" +version = "1.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c96bf972d85afc50bf5ab8fe2d54d1586b4e0b46c97c50a0c9e71e2f7bcd812a" +dependencies = [ + "async-task", + "concurrent-queue", + "fastrand", + "futures-lite", + "pin-project-lite", + "slab", +] + +[[package]] +name = "async-fs" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8034a681df4aed8b8edbd7fbe472401ecf009251c8b40556b304567052e294c5" +dependencies = [ + "async-lock", + "blocking", + "futures-lite", +] + +[[package]] +name = "async-io" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "456b8a8feb6f42d237746d4b3e9a178494627745c3c56c6ea55d92ba50d026fc" +dependencies = [ + "autocfg", + "cfg-if", + "concurrent-queue", + "futures-io", + "futures-lite", + "parking", + "polling", + "rustix 1.1.4", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-lock" +version = "3.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311" +dependencies = [ + "event-listener", + "event-listener-strategy", + "pin-project-lite", +] + +[[package]] +name = "async-net" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b948000fad4873c1c9339d60f2623323a0cfd3816e5181033c6a5cb68b2accf7" +dependencies = [ + "async-io", + "blocking", + "futures-lite", +] + +[[package]] +name = "async-process" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc50921ec0055cdd8a16de48773bfeec5c972598674347252c0399676be7da75" +dependencies = [ + "async-channel", + "async-io", + "async-lock", + "async-signal", + "async-task", + "blocking", + "cfg-if", + "event-listener", + "futures-lite", + "rustix 1.1.4", +] + +[[package]] +name = "async-recursion" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "async-signal" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52b5aaafa020cf5053a01f2a60e8ff5dccf550f0f77ec54a4e47285ac2bab485" +dependencies = [ + "async-io", + "async-lock", + "atomic-waker", + "cfg-if", + "futures-core", + "futures-io", + "rustix 1.1.4", + "signal-hook-registry", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-task" +version = "4.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de" + +[[package]] +name = "async-trait" +version = "0.1.89" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "atomic" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c59bdb34bc650a32731b31bd8f0829cc15d24a708ee31559e0bb34f2bc320cba" + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "atspi" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c77886257be21c9cd89a4ae7e64860c6f0eefca799bb79127913052bd0eefb3d" +dependencies = [ + "atspi-common", + "atspi-proxies", +] + +[[package]] +name = "atspi-common" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20c5617155740c98003016429ad13fe43ce7a77b007479350a9f8bf95a29f63d" +dependencies = [ + "enumflags2", + "serde", + "static_assertions", + "zbus", + "zbus-lockstep", + "zbus-lockstep-macros", + "zbus_names", + "zvariant", +] + +[[package]] +name = "atspi-proxies" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2230e48787ed3eb4088996eab66a32ca20c0b67bbd4fd6cdfe79f04f1f04c9fc" +dependencies = [ + "atspi-common", + "serde", + "zbus", +] + +[[package]] +name = "audiopus_sys" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62314a1546a2064e033665d658e88c620a62904be945f8147e6b16c3db9f8651" +dependencies = [ + "cmake", + "log", + "pkg-config", +] + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "av-scenechange" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f321d77c20e19b92c39e7471cf986812cbb46659d2af674adc4331ef3f18394" +dependencies = [ + "aligned", + "anyhow", + "arg_enum_proc_macro", + "arrayvec", + "log", + "num-rational", + "num-traits", + "pastey", + "rayon", + "thiserror 2.0.18", + "v_frame", + "y4m", +] + +[[package]] +name = "av1-grain" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8cfddb07216410377231960af4fcab838eaa12e013417781b78bd95ee22077f8" +dependencies = [ + "anyhow", + "arrayvec", + "log", + "nom 8.0.0", + "num-rational", + "v_frame", +] + +[[package]] +name = "avif-serialize" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7178fe5f7d460b13895ebb9dcb28a3a6216d2df2574a0806cb51b555d297f38" +dependencies = [ + "arrayvec", +] + +[[package]] +name = "backtrace" +version = "0.3.76" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb531853791a215d7c62a30daf0dde835f381ab5de4589cfe7c649d2cbe92bd6" +dependencies = [ + "addr2line", + "cfg-if", + "libc", + "miniz_oxide", + "object", + "rustc-demangle", + "windows-link 0.2.1", +] + +[[package]] +name = "base16ct" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf" + +[[package]] +name = "base62" +version = "2.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd637ac531c60eb7fbc4684dc061c2d7d90d73d758181aa02eeff0464b9eee4b" + +[[package]] +name = "base64" +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 = "bincode" +version = "1.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f45e9417d87227c7a56d22e471c6206462cba514c7590c09aff4cf6d1ddcad" +dependencies = [ + "serde", +] + +[[package]] +name = "bindgen" +version = "0.71.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f58bf3d7db68cfbac37cfc485a8d711e87e064c3d0fe0435b92f7a407f9d6b3" +dependencies = [ + "bitflags 2.13.0", + "cexpr", + "clang-sys", + "itertools 0.11.0", + "log", + "prettyplease", + "proc-macro2", + "quote", + "regex", + "rustc-hash 2.1.3", + "shlex 1.3.0", + "syn", +] + +[[package]] +name = "bit-set" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34ddef2995421ab6a5c779542c81ee77c115206f4ad9d5a8e05f4ff49716a3dd" +dependencies = [ + "bit-vec", +] + +[[package]] +name = "bit-vec" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b71798fca2c1fe1086445a7258a4bc81e6e49dcd24c8d0dd9a1e57395b603f51" + +[[package]] +name = "bit_field" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e4b40c7323adcfc0a41c4b88143ed58346ff65a288fc144329c5c45e05d70c6" + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "bitflags" +version = "2.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8" + +[[package]] +name = "bitstream-io" +version = "4.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7eff00be299a18769011411c9def0d827e8f2d7bf0c3dbf53633147a8867fd1f" +dependencies = [ + "no_std_io2", +] + +[[package]] +name = "block" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d8c1fef690941d3e7788d328517591fecc684c084084702d6ff1641e993699a" + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +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 = "block-padding" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8894febbff9f758034a5b8e12d87918f56dfc64a8e1fe757d65e29041538d93" +dependencies = [ + "generic-array", +] + +[[package]] +name = "block-padding" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "710f1dd022ef4e93f8a438b4ba958de7f64308434fa6a87104481645cc30068b" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "block2" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c132eebf10f5cad5289222520a4a058514204aed6d791f1cf4fe8088b82d15f" +dependencies = [ + "objc2 0.5.2", +] + +[[package]] +name = "block2" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5" +dependencies = [ + "objc2 0.6.4", +] + +[[package]] +name = "blocking" +version = "1.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e83f8d02be6967315521be875afa792a316e28d57b5a2d401897e2a7921b7f21" +dependencies = [ + "async-channel", + "async-task", + "futures-io", + "futures-lite", + "piper", +] + +[[package]] +name = "bon" +version = "3.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a602c73c7b0148ec6d12af6fd5cc7a46e2eacc8878271a999abac56eed12f561" +dependencies = [ + "bon-macros", + "rustversion", +] + +[[package]] +name = "bon-macros" +version = "3.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dee98b0db6a962de883bf5d20362dee4d7ca0d12fe39a7c6c73c844e1cd7c1f" +dependencies = [ + "darling 0.23.0", + "ident_case", + "prettyplease", + "proc-macro2", + "quote", + "rustversion", + "syn", +] + +[[package]] +name = "borsh" +version = "1.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f3f6da4992df95bbcd9af42a6c7dcb994498fc9048230405f3b36ff7cd3f145" +dependencies = [ + "bytes", + "cfg_aliases", +] + +[[package]] +name = "bstr" +version = "1.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5cee35f73844aa3014bb606320a6c1f010249dbdf43342fe54b5a4f6a8ed4b79" +dependencies = [ + "memchr", + "serde_core", +] + +[[package]] +name = "buffa" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33f29a40702df4b86ccd84211bfde8cee0bce6d0811450ade4a86a7d0958a23a" +dependencies = [ + "base64", + "bytes", + "foldhash 0.1.5", + "hashbrown 0.15.5", + "once_cell", + "rustversion", + "serde", + "serde_json", + "smoothutf8 0.1.1", + "thiserror 2.0.18", +] + +[[package]] +name = "buffa-build" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef33217cabddfad99c0a54d42ddb7ca5b73fa492f7b16ddb621132ef51107556" +dependencies = [ + "buffa", + "buffa-codegen", + "tempfile", +] + +[[package]] +name = "buffa-codegen" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6681b562b18ea719622d0d12684e88ecbdca600d517557dc7bcef7704631e28" +dependencies = [ + "buffa", + "buffa-descriptor", + "prettyplease", + "proc-macro2", + "quote", + "syn", + "thiserror 2.0.18", +] + +[[package]] +name = "buffa-descriptor" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57ed423c4ecec86d1500879ce42e7e5f6def01bc632985ac756c1fd723fa21fc" +dependencies = [ + "buffa", + "rustversion", + "serde", + "serde_json", +] + +[[package]] +name = "built" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c0e531d93d39c34eef561e929e8a7f86d77a5af08aac4f6d6e39976c51858e9" + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "bytemuck" +version = "1.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6aedf8ae72766347502cf3cb4f41cf5e9cc37d28bee90f1fdaaae15f9cf9424" +dependencies = [ + "bytemuck_derive", +] + +[[package]] +name = "bytemuck_derive" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f65693059b6b9c588b9f62fed1cedbf0a8b805631457ea162d68f0de186f3de5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "byteorder-lite" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495" + +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" + +[[package]] +name = "bzip2" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3a53fac24f34a81bc9954b5d6cfce0c21e18ec6959f44f56e8e90e4bb7c346c" +dependencies = [ + "libbz2-rs-sys", +] + +[[package]] +name = "calloop" +version = "0.14.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4dbf9978365bac10f54d1d4b04f7ce4427e51f71d61f2fe15e3fed5166474df7" +dependencies = [ + "bitflags 2.13.0", + "polling", + "rustix 1.1.4", + "slab", + "tracing", +] + +[[package]] +name = "calloop-wayland-source" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "138efcf0940a02ebf0cc8d1eff41a1682a46b431630f4c52450d6265876021fa" +dependencies = [ + "calloop", + "rustix 1.1.4", + "wayland-backend", + "wayland-client", +] + +[[package]] +name = "castaway" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dec551ab6e7578819132c713a93c022a05d60159dc86e7a7050223577484c55a" +dependencies = [ + "rustversion", +] + +[[package]] +name = "cbc" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26b52a9543ae338f279b96b0b9fed9c8093744685043739079ce85cd58f289a6" +dependencies = [ + "cipher 0.4.4", +] + +[[package]] +name = "cbc" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce2dc9ee5f88d11e0beb842c88b33c8a5cf0d1329c4b19494af42b07dbfe8896" +dependencies = [ + "cipher 0.5.2", +] + +[[package]] +name = "cbindgen" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eadd868a2ce9ca38de7eeafdcec9c7065ef89b42b32f0839278d55f35c54d1ff" +dependencies = [ + "heck 0.4.1", + "indexmap", + "log", + "proc-macro2", + "quote", + "serde", + "serde_json", + "syn", + "tempfile", + "toml 0.8.23", +] + +[[package]] +name = "cc" +version = "1.2.66" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f5d6cac793997bd970000024b2934968efe83b382de4fdcf4fcb46b6ee4ad996" +dependencies = [ + "find-msvc-tools", + "jobserver", + "libc", + "shlex 2.0.1", +] + +[[package]] +name = "ccm" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ae3c82e4355234767756212c570e29833699ab63e6ffd161887314cc5b43847" +dependencies = [ + "aead 0.5.2", + "cipher 0.4.4", + "ctr 0.9.2", + "subtle", +] + +[[package]] +name = "cexpr" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6fac387a98bb7c37292057cffc56d62ecb629900026402633ae9160df93a8766" +dependencies = [ + "nom 7.1.3", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cfg_aliases" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" + +[[package]] +name = "cgl" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ced0551234e87afee12411d535648dd89d2e7f34c78b753395567aff3d447ff" +dependencies = [ + "libc", +] + +[[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.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" +dependencies = [ + "iana-time-zone", + "js-sys", + "num-traits", + "serde", + "wasm-bindgen", + "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 0.1.4", + "zeroize", +] + +[[package]] +name = "cipher" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8cf2a2c93cd704877c0858356ed03480ff301ee950b43f1cbe4573b088bfa6c" +dependencies = [ + "block-buffer 0.12.1", + "crypto-common 0.2.2", + "inout 0.2.2", +] + +[[package]] +name = "clang-sys" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b023947811758c97c59bf9d1c188fd619ad4718dcaa767947df1cadb14f39f4" +dependencies = [ + "glob", + "libc", + "libloading", +] + +[[package]] +name = "cmake" +version = "0.1.58" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678" +dependencies = [ + "cc", +] + +[[package]] +name = "cmov" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a" + +[[package]] +name = "cocoa" +version = "0.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6140449f97a6e97f9511815c5632d84c8aacf8ac271ad77c559218161a1373c" +dependencies = [ + "bitflags 1.3.2", + "block", + "cocoa-foundation 0.1.2", + "core-foundation 0.9.4", + "core-graphics 0.23.2", + "foreign-types", + "libc", + "objc", +] + +[[package]] +name = "cocoa" +version = "0.26.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f79398230a6e2c08f5c9760610eb6924b52aa9e7950a619602baba59dcbbdbb2" +dependencies = [ + "bitflags 2.13.0", + "block", + "cocoa-foundation 0.2.0", + "core-foundation 0.10.0", + "core-graphics 0.24.0", + "foreign-types", + "libc", + "objc", +] + +[[package]] +name = "cocoa-foundation" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c6234cbb2e4c785b456c0644748b1ac416dd045799740356f8363dfe00c93f7" +dependencies = [ + "bitflags 1.3.2", + "block", + "core-foundation 0.9.4", + "core-graphics-types 0.1.3", + "libc", + "objc", +] + +[[package]] +name = "cocoa-foundation" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e14045fb83be07b5acf1c0884b2180461635b433455fa35d1cd6f17f1450679d" +dependencies = [ + "bitflags 2.13.0", + "block", + "core-foundation 0.10.0", + "core-graphics-types 0.2.0", + "libc", + "objc", +] + +[[package]] +name = "codespan-reporting" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af491d569909a7e4dee0ad7db7f5341fef5c614d5b8ec8cf765732aba3cff681" +dependencies = [ + "serde", + "termcolor", + "unicode-width", +] + +[[package]] +name = "collections" +version = "0.1.0" +source = "git+https://github.com/zed-industries/zed#c545fb67d0ce13e335bff76f7c08986000333f2c" +dependencies = [ + "gpui_util", + "indexmap", + "rustc-hash 2.1.3", +] + +[[package]] +name = "color_quant" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b" + +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + +[[package]] +name = "combine" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" +dependencies = [ + "bytes", + "memchr", +] + +[[package]] +name = "compact_str" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9dfdd1c2274d9aa354115b09dc9a901d6c5576818cdf70d14cae2bdb47df00ab" +dependencies = [ + "castaway", + "cfg-if", + "itoa", + "rustversion", + "ryu", + "serde", + "static_assertions", +] + +[[package]] +name = "compression-codecs" +version = "0.4.38" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce2548391e9c1929c21bf6aa2680af86fe4c1b33e6cea9ac1cfeec0bd11218cf" +dependencies = [ + "bzip2", + "compression-core", + "flate2", + "memchr", +] + +[[package]] +name = "compression-core" +version = "0.4.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc14f565cf027a105f7a44ccf9e5b424348421a1d8952a8fc9d499d313107789" + +[[package]] +name = "concurrent-queue" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "console_error_panic_hook" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a06aeb73f470f66dcdbf7223caeebb85984942f22f1adb2a088cf9668146bbbc" +dependencies = [ + "cfg-if", + "wasm-bindgen", +] + +[[package]] +name = "const-oid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" + +[[package]] +name = "const-oid" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" + +[[package]] +name = "const-random" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87e00182fe74b066627d63b85fd550ac2998d4b0bd86bfed477a0ae4c7c71359" +dependencies = [ + "const-random-macro", +] + +[[package]] +name = "const-random-macro" +version = "0.1.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9d839f2a20b0aee515dc581a6172f2321f96cab76c1a38a4c584a194955390e" +dependencies = [ + "getrandom 0.2.17", + "once_cell", + "tiny-keccak", +] + +[[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 = "core-foundation" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b55271e5c8c478ad3f38ad24ef34923091e0548492a266d19b3c0b4d82574c63" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "core-graphics" +version = "0.23.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c07782be35f9e1140080c6b96f0d44b739e2278479f64e02fdab4e32dfd8b081" +dependencies = [ + "bitflags 1.3.2", + "core-foundation 0.9.4", + "core-graphics-types 0.1.3", + "foreign-types", + "libc", +] + +[[package]] +name = "core-graphics" +version = "0.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa95a34622365fa5bbf40b20b75dba8dfa8c94c734aea8ac9a5ca38af14316f1" +dependencies = [ + "bitflags 2.13.0", + "core-foundation 0.10.0", + "core-graphics-types 0.2.0", + "foreign-types", + "libc", +] + +[[package]] +name = "core-graphics-helmer-fork" +version = "0.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32eb7c354ae9f6d437a6039099ce7ecd049337a8109b23d73e48e8ffba8e9cd5" +dependencies = [ + "bitflags 2.13.0", + "core-foundation 0.9.4", + "core-graphics-types 0.1.3", + "foreign-types", + "libc", +] + +[[package]] +name = "core-graphics-types" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "45390e6114f68f718cc7a830514a96f903cccd70d02a8f6d9f643ac4ba45afaf" +dependencies = [ + "bitflags 1.3.2", + "core-foundation 0.9.4", + "libc", +] + +[[package]] +name = "core-graphics-types" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d44a101f213f6c4cdc1853d4b78aef6db6bdfa3468798cc1d9912f4735013eb" +dependencies = [ + "bitflags 2.13.0", + "core-foundation 0.10.0", + "libc", +] + +[[package]] +name = "core-graphics2" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4416167a69126e617f8d0a214af0e3c1dbdeffcb100ddf72dcd1a1ac9893c146" +dependencies = [ + "bitflags 2.13.0", + "block", + "cfg-if", + "core-foundation 0.10.0", + "libc", +] + +[[package]] +name = "core-text" +version = "21.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a593227b66cbd4007b2a050dfdd9e1d1318311409c8d600dc82ba1b15ca9c130" +dependencies = [ + "core-foundation 0.10.0", + "core-graphics 0.24.0", + "foreign-types", + "libc", +] + +[[package]] +name = "core-video" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "139679cc63eb9504bdbe37e37874b0247136177655f0008588781e90863afa62" +dependencies = [ + "block", + "core-foundation 0.10.0", + "core-graphics2", + "io-surface", + "libc", + "metal", +] + +[[package]] +name = "core_maths" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77745e017f5edba1a9c1d854f6f3a52dac8a12dd5af5d2f54aecf61e43d80d30" +dependencies = [ + "libm", +] + +[[package]] +name = "coreaudio-rs" +version = "0.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d5d7dca3ebcf65a035582c9ad4385371a9d9ee6537474d2a278f4e1e475bb58" +dependencies = [ + "bitflags 2.13.0", + "libc", + "objc2-audio-toolbox", + "objc2-core-audio", + "objc2-core-audio-types", + "objc2-core-foundation", +] + +[[package]] +name = "cosmic-text" +version = "0.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be17b688510d934ce13f48a2beba700e11583e281e0fda99c22bb256a14eda73" +dependencies = [ + "bitflags 2.13.0", + "fontdb", + "harfrust", + "linebender_resource_handle", + "log", + "rangemap", + "rustc-hash 2.1.3", + "self_cell", + "skrifa 0.40.0", + "smol_str", + "swash", + "sys-locale", + "unicode-bidi", + "unicode-linebreak", + "unicode-script", + "unicode-segmentation", +] + +[[package]] +name = "cpal" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f77b11176c37874be37e8d691c946e31b2b8c357abce9526f6a99eb469e1028" +dependencies = [ + "alsa", + "block2 0.6.2", + "coreaudio-rs", + "dasp_sample", + "jni", + "js-sys", + "libc", + "mach2 0.6.0", + "ndk", + "ndk-context", + "num-derive", + "num-traits", + "objc2 0.6.4", + "objc2-audio-toolbox", + "objc2-avf-audio", + "objc2-core-audio", + "objc2-core-audio-types", + "objc2-core-foundation", + "objc2-foundation 0.3.2", + "web-sys", + "windows 0.62.2", + "windows-core 0.62.2", +] + +[[package]] +name = "cpubits" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15b85f9c39137c3a891689859392b1bd49812121d0d61c9caf00d46ed5ce06ae" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +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 = "crc" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5eb8a2a1cd12ab0d987a5d5e825195d372001a4094a0376319d5a0ad71c1ba0d" +dependencies = [ + "crc-catalog", +] + +[[package]] +name = "crc-catalog" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "217698eaf96b4a3f0bc4f3662aaa55bdf913cd54d7204591faa790070c6d0853" + +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crossbeam-deque" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5181e0de7b61eb03a81e347d6dd8797bae9da5146707b51077e2d71a54ec0ceb" +dependencies = [ + "crossbeam-epoch", + "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-queue" +version = "0.3.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "803d13fb3b09d88be9f4dbc29062c66b19bf7170867ceb746d2a8689bf6c7a26" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17" + +[[package]] +name = "crunchy" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" + +[[package]] +name = "crypto-bigint" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76" +dependencies = [ + "generic-array", + "rand_core 0.6.4", + "subtle", + "zeroize", +] + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "rand_core 0.6.4", + "typenum", +] + +[[package]] +name = "crypto-common" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "ctor" +version = "1.0.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb22e947478ccf9dc44d8922042c677a63fbb88f2cb468521d1145816e5087cb" +dependencies = [ + "link-section", + "linktime-proc-macro", +] + +[[package]] +name = "ctr" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835" +dependencies = [ + "cipher 0.4.4", +] + +[[package]] +name = "ctr" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baaca1c4b237092596f64d571e9db6ce4109c4ef9742e27590f1709594461f21" +dependencies = [ + "cipher 0.5.2", +] + +[[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 = "4.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "curve25519-dalek-derive", + "fiat-crypto 0.2.9", + "rustc_version", + "subtle", + "zeroize", +] + +[[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", + "fiat-crypto 0.3.0", + "rustc_version", + "subtle", + "zeroize", +] + +[[package]] +name = "curve25519-dalek-derive" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "darling" +version = "0.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9cdf337090841a411e2a7f3deb9187445851f91b309c0c0a29e05f74a00a48c0" +dependencies = [ + "darling_core 0.21.3", + "darling_macro 0.21.3", +] + +[[package]] +name = "darling" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" +dependencies = [ + "darling_core 0.23.0", + "darling_macro 0.23.0", +] + +[[package]] +name = "darling_core" +version = "0.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1247195ecd7e3c85f83c8d2a366e4210d588e802133e1e355180a9870b517ea4" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn", +] + +[[package]] +name = "darling_core" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0" +dependencies = [ + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn", +] + +[[package]] +name = "darling_macro" +version = "0.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d38308df82d1080de0afee5d069fa14b0326a88c14f15c5ccda35b4a6c414c81" +dependencies = [ + "darling_core 0.21.3", + "quote", + "syn", +] + +[[package]] +name = "darling_macro" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" +dependencies = [ + "darling_core 0.23.0", + "quote", + "syn", +] + +[[package]] +name = "dasp_sample" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c87e182de0887fd5361989c677c4e8f5000cd9491d6d563161a8f3a5519fc7f" + +[[package]] +name = "data-encoding" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8" + +[[package]] +name = "data-url" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be1e0bca6c3637f992fc1cc7cbc52a78c1ef6db076dbf1059c4323d6a2048376" + +[[package]] +name = "defmt" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2953bfe4f93bbd20cc71198842756f77d161884c99ebbabc41d80231ded88d1" +dependencies = [ + "bitflags 1.3.2", + "defmt-macros", +] + +[[package]] +name = "defmt-macros" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bad9c72e7ca2137e0dc3813245a0d282fd6daad32fd800af018306a9169b5fe8" +dependencies = [ + "defmt-parser", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "defmt-parser" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10d60334b3b2e7c9d91ef8150abfb6fa4c1c39ebbcf4a81c2e346aad939fee3e" +dependencies = [ + "thiserror 2.0.18", +] + +[[package]] +name = "der" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" +dependencies = [ + "const-oid 0.9.6", + "pem-rfc7468", + "zeroize", +] + +[[package]] +name = "der-parser" +version = "9.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5cd0a5c643689626bec213c4d8bd4d96acc8ffdb4ad4bb6bc16abf27d5f4b553" +dependencies = [ + "asn1-rs", + "displaydoc", + "nom 7.1.3", + "num-bigint", + "num-traits", + "rusticata-macros", +] + +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" + +[[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", + "proc-macro2", + "quote", + "rustc_version", + "syn", + "unicode-xid", +] + +[[package]] +name = "derive_refineable" +version = "0.1.0" +source = "git+https://github.com/zed-industries/zed#c545fb67d0ce13e335bff76f7c08986000333f2c" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "diesel" +version = "2.3.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e54d1f576cd3a3460f212a4615fd12ce1b6303c095b79a44449ffbe627753dc1" +dependencies = [ + "diesel_derives", + "downcast-rs 2.0.2", + "libsqlite3-sys", + "r2d2", + "sqlite-wasm-rs", + "time", +] + +[[package]] +name = "diesel_derives" +version = "2.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1817b7f4279b947fc4cafddec12b0e5f8727141706561ce3ac94a60bddd1cf5" +dependencies = [ + "diesel_table_macro_syntax", + "dsl_auto_type", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "diesel_migrations" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28d0f4a98124ba6d4ca75da535f65984badec16a003b6e2f94a01e31a79490b8" +dependencies = [ + "diesel", + "migrations_internals", + "migrations_macros", +] + +[[package]] +name = "diesel_table_macro_syntax" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe2444076b48641147115697648dc743c2c00b61adade0f01ce67133c7babe8c" +dependencies = [ + "syn", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer 0.10.4", + "const-oid 0.9.6", + "crypto-common 0.1.7", + "subtle", +] + +[[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", + "const-oid 0.10.2", + "crypto-common 0.2.2", + "ctutils", +] + +[[package]] +name = "dirs" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e" +dependencies = [ + "dirs-sys", +] + +[[package]] +name = "dirs-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" +dependencies = [ + "libc", + "option-ext", + "redox_users", + "windows-sys 0.61.2", +] + +[[package]] +name = "dispatch" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd0c93bb4b0c6d9b77f4435b0ae98c24d17f1c45b2ff844c6151a07256ca923b" + +[[package]] +name = "dispatch2" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" +dependencies = [ + "bitflags 2.13.0", + "block2 0.6.2", + "libc", + "objc2 0.6.4", +] + +[[package]] +name = "displaydoc" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "dlib" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab8ecd87370524b461f8557c119c405552c396ed91fc0a8eec68679eab26f94a" +dependencies = [ + "libloading", +] + +[[package]] +name = "document-features" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61" +dependencies = [ + "litrs", +] + +[[package]] +name = "downcast-rs" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2" + +[[package]] +name = "downcast-rs" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "117240f60069e65410b3ae1bb213295bd828f707b5bec6596a1afc8793ce0cbc" + +[[package]] +name = "dsl_auto_type" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd122633e4bef06db27737f21d3738fb89c8f6d5360d6d9d7635dda142a7757e" +dependencies = [ + "darling 0.21.3", + "either", + "heck 0.5.0", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "dunce" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" + +[[package]] +name = "dwrote" +version = "0.11.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1b35532432acc8b19ceed096e35dfa088d3ea037fe4f3c085f1f97f33b4d02" +dependencies = [ + "lazy_static", + "libc", + "winapi", + "wio", +] + +[[package]] +name = "dyn-clone" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" + +[[package]] +name = "ecdsa" +version = "0.16.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca" +dependencies = [ + "der", + "digest 0.10.7", + "elliptic-curve", + "rfc6979", + "signature", + "spki", +] + +[[package]] +name = "either" +version = "1.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e" + +[[package]] +name = "elliptic-curve" +version = "0.13.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47" +dependencies = [ + "base16ct", + "crypto-bigint", + "digest 0.10.7", + "ff", + "generic-array", + "group", + "hkdf 0.12.4", + "pem-rfc7468", + "pkcs8", + "rand_core 0.6.4", + "sec1", + "subtle", + "zeroize", +] + +[[package]] +name = "embed-resource" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fbfdaacccebec3b28e4866b8973543c7647797db5ada1bdab552e48fe665fbbd" +dependencies = [ + "cc", + "memchr", + "rustc_version", + "toml 1.1.2+spec-1.1.0", + "vswhom", + "winreg", +] + +[[package]] +name = "encoding_rs" +version = "0.8.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "endi" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099" + +[[package]] +name = "enum-iterator" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4549325971814bda7a44061bf3fe7e487d447cba01e4220a4b454d630d7a016" +dependencies = [ + "enum-iterator-derive", +] + +[[package]] +name = "enum-iterator-derive" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "685adfa4d6f3d765a26bc5dbc936577de9abf756c1feeb3089b01dd395034842" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "enumflags2" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1027f7680c853e056ebcec683615fb6fbbc07dbaa13b4d5d9442b146ded4ecef" +dependencies = [ + "enumflags2_derive", + "serde", +] + +[[package]] +name = "enumflags2_derive" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "env_filter" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "900d271a03799a1ee8d1ca9b19893b48ca674a9284fefcfb85f05e74ed314217" +dependencies = [ + "log", + "regex", +] + +[[package]] +name = "env_logger" +version = "0.11.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de671bd27a75a797dc9ae289ba1e77276e75e2026408aab65185384e2d5cd3f6" +dependencies = [ + "anstream", + "anstyle", + "env_filter", + "jiff", + "log", +] + +[[package]] +name = "equator" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4711b213838dfee0117e3be6ac926007d7f433d7bbe33595975d4190cb07e6fc" +dependencies = [ + "equator-macro", +] + +[[package]] +name = "equator-macro" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44f23cf4b44bfce11a86ace86f8a73ffdec849c9fd00a386a53d278bd9e81fb3" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "erased-serde" +version = "0.4.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2add8a07dd6a8d93ff627029c51de145e12686fbc36ecb298ac22e74cf02dec" +dependencies = [ + "serde", + "serde_core", + "typeid", +] + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.52.0", +] + +[[package]] +name = "etagere" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc89bf99e5dc15954a60f707c1e09d7540e5cd9af85fa75caa0b510bc08c5342" +dependencies = [ + "euclid", + "svg_fmt", +] + +[[package]] +name = "euclid" +version = "0.22.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1a05365e3b1c6d1650318537c7460c6923f1abdd272ad6842baa2b509957a06" +dependencies = [ + "num-traits", +] + +[[package]] +name = "event-listener" +version = "5.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab" +dependencies = [ + "concurrent-queue", + "parking", + "pin-project-lite", +] + +[[package]] +name = "event-listener-strategy" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93" +dependencies = [ + "event-listener", + "pin-project-lite", +] + +[[package]] +name = "exr" +version = "1.74.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6be87932f10230a4339ab394edd8e4611fcb72553d8295b4d52ea55249b3daa5" +dependencies = [ + "bit_field", + "half", + "lebe", + "miniz_oxide", + "num-complex", + "pulp", + "rayon-core", + "smallvec", + "zune-inflate", +] + +[[package]] +name = "fastrand" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" + +[[package]] +name = "fax" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caf1079563223d5d59d83c85886a56e586cfd5c1a26292e971a0fa266531ac5a" + +[[package]] +name = "fdeflate" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c" +dependencies = [ + "simd-adler32", +] + +[[package]] +name = "ff" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0b50bfb653653f9ca9095b427bed08ab8d75a137839d9ad64eb11810d5b6393" +dependencies = [ + "rand_core 0.6.4", + "subtle", +] + +[[package]] +name = "fiat-crypto" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" + +[[package]] +name = "fiat-crypto" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64cd1e32ddd350061ae6edb1b082d7c54915b5c672c389143b9a63403a109f24" + +[[package]] +name = "filedescriptor" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e40758ed24c9b2eeb76c35fb0aebc66c626084edd827e07e1552279814c6682d" +dependencies = [ + "libc", + "thiserror 1.0.69", + "winapi", +] + +[[package]] +name = "filetime" +version = "0.2.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c287a33c7f0a620c38e641e7f60827713987b3c0f26e8ddc9462cc69cf75759" +dependencies = [ + "cfg-if", + "libc", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "fixedbitset" +version = "0.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99" + +[[package]] +name = "flate2" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +dependencies = [ + "crc32fast", + "miniz_oxide", + "zlib-rs", +] + +[[package]] +name = "float-cmp" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "98de4bbd547a563b716d8dfa9aad1cb19bfab00f4fa09a6a4ed21dbcf44ce9c4" + +[[package]] +name = "float-ord" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ce81f49ae8a0482e4c55ea62ebbd7e5a686af544c00b9d090bba3ff9be97b3d" + +[[package]] +name = "float_next_after" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8bf7cc16383c4b8d58b9905a8509f02926ce3058053c056376248d958c9df1e8" + +[[package]] +name = "fluent-uri" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "17c704e9dbe1ddd863da1e6ff3567795087b1eb201ce80d8fa81162e1516500d" +dependencies = [ + "bitflags 1.3.2", +] + +[[package]] +name = "flume" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da0e4dd2a88388a1f4ccc7c9ce104604dab68d9f408dc34cd45823d5a9069095" +dependencies = [ + "futures-core", + "futures-sink", + "nanorand", + "spin 0.9.8", +] + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "foldhash" +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 = "font-types" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b38ad915f6dadd993ced50848a8291a543bd41ca62bc10740d5e64e2ab4cfd7" +dependencies = [ + "bytemuck", +] + +[[package]] +name = "fontconfig-parser" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbc773e24e02d4ddd8395fd30dc147524273a83e54e0f312d986ea30de5f5646" +dependencies = [ + "roxmltree 0.20.0", +] + +[[package]] +name = "fontdb" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "457e789b3d1202543297a350643cf459f836cade38934e7a4cf6a39e7cde2905" +dependencies = [ + "fontconfig-parser", + "log", + "memmap2", + "slotmap", + "tinyvec", + "ttf-parser", +] + +[[package]] +name = "foreign-types" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965" +dependencies = [ + "foreign-types-macros", + "foreign-types-shared", +] + +[[package]] +name = "foreign-types-macros" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a5c6c585bc94aaf2c7b51dd4c2ba22680844aba4c687be581871a6f518c5742" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "foreign-types-shared" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa9a19cbb55df58761df49b23516a86d432839add4af60fc256da840f66ed35b" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "freetype-sys" +version = "0.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e7edc5b9669349acfda99533e9e0bcf26a51862ab43b08ee7745c55d28eb134" +dependencies = [ + "cc", + "libc", + "pkg-config", +] + +[[package]] +name = "fsevent-sys" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76ee7a02da4d231650c7cea31349b889be2f45ddb3ef3032d2ec8185f6313fd2" +dependencies = [ + "libc", +] + +[[package]] +name = "futf" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df420e2e84819663797d1ec6544b13c5be84629e7bb00dc960d6917db2987843" +dependencies = [ + "mac", + "new_debug_unreachable", +] + +[[package]] +name = "futures" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-concurrency" +version = "7.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "175cd8cca9e1d45b87f18ffa75088f2099e3c4fe5e2f83e42de112560bea8ea6" +dependencies = [ + "fixedbitset", + "futures-core", + "futures-lite", + "pin-project", + "smallvec", +] + +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-executor" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" + +[[package]] +name = "futures-lite" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad" +dependencies = [ + "fastrand", + "futures-core", + "futures-io", + "parking", + "pin-project-lite", +] + +[[package]] +name = "futures-macro" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "futures-sink" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", + "zeroize", +] + +[[package]] +name = "gethostname" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bd49230192a3797a9a4d6abe9b3eed6f7fa4c8a8a4947977c6f80025f92cbd8" +dependencies = [ + "rustix 1.1.4", + "windows-link 0.2.1", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "wasi", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi 5.3.0", + "wasip2", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi 6.0.0", + "rand_core 0.10.1", + "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 0.6.2", +] + +[[package]] +name = "ghash" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2eecf2d5dc9b66b732b97707a0210906b1d30523eb773193ab777c0c84b3e8d5" +dependencies = [ + "polyval 0.7.1", +] + +[[package]] +name = "gif" +version = "0.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ae047235e33e2829703574b54fdec96bfbad892062d97fed2f76022287de61b" +dependencies = [ + "color_quant", + "weezl", +] + +[[package]] +name = "gif" +version = "0.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee8cfcc411d9adbbaba82fb72661cc1bcca13e8bba98b364e62b2dba8f960159" +dependencies = [ + "color_quant", + "weezl", +] + +[[package]] +name = "gimli" +version = "0.32.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e629b9b98ef3dd8afe6ca2bd0f89306cec16d43d907889945bc5d6687f2f13c7" + +[[package]] +name = "gl_generator" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a95dfc23a2b4a9a2f5ab41d194f8bfda3cabec42af4e39f08c339eb2a0c124d" +dependencies = [ + "khronos_api", + "log", + "xml-rs", +] + +[[package]] +name = "glob" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" + +[[package]] +name = "globset" +version = "0.4.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52dfc19153a48bde0cbd630453615c8151bce3a5adfac7a0aebfbf0a1e1f57e3" +dependencies = [ + "aho-corasick", + "bstr", + "log", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "globwalk" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93e3af942408868f6934a7b85134a3230832b9977cf66125df2f9edcfce4ddcc" +dependencies = [ + "bitflags 1.3.2", + "ignore", + "walkdir", +] + +[[package]] +name = "glow" +version = "0.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29038e1c483364cc6bb3cf78feee1816002e127c331a1eec55a4d202b9e1adb5" +dependencies = [ + "js-sys", + "slotmap", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "glutin_wgl_sys" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c4ee00b289aba7a9e5306d57c2d05499b2e5dc427f84ac708bd2c090212cf3e" +dependencies = [ + "gl_generator", +] + +[[package]] +name = "gpu-allocator" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51255ea7cfaadb6c5f1528d43e92a82acb2b96c43365989a28b2d44ee38f8795" +dependencies = [ + "ash", + "hashbrown 0.16.1", + "log", + "presser", + "thiserror 2.0.18", + "windows 0.58.0", +] + +[[package]] +name = "gpu-descriptor" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b89c83349105e3732062a895becfc71a8f921bb71ecbbdd8ff99263e3b53a0ca" +dependencies = [ + "bitflags 2.13.0", + "gpu-descriptor-types", + "hashbrown 0.15.5", +] + +[[package]] +name = "gpu-descriptor-types" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdf242682df893b86f33a73828fb09ca4b2d3bb6cc95249707fc684d27484b91" +dependencies = [ + "bitflags 2.13.0", +] + +[[package]] +name = "gpui" +version = "0.2.2" +source = "git+https://github.com/zed-industries/zed#c545fb67d0ce13e335bff76f7c08986000333f2c" +dependencies = [ + "accesskit", + "anyhow", + "async-channel", + "async-task", + "bindgen", + "bitflags 2.13.0", + "block", + "cbindgen", + "chrono", + "cocoa 0.26.0", + "cocoa-foundation 0.2.0", + "collections", + "core-foundation 0.10.0", + "core-foundation-sys", + "core-graphics 0.24.0", + "core-text", + "core-video", + "ctor", + "derive_more", + "embed-resource", + "etagere", + "foreign-types", + "futures", + "futures-concurrency", + "getrandom 0.3.4", + "gpui_macros", + "gpui_shared_string", + "gpui_util", + "heapless", + "http_client", + "image", + "inventory", + "itertools 0.14.0", + "log", + "lyon", + "mach2 0.5.0", + "media", + "metal", + "num_cpus", + "objc", + "parking", + "parking_lot", + "pathfinder_geometry", + "pin-project", + "pollster 0.4.0", + "postage", + "profiling", + "rand 0.9.4", + "raw-window-handle", + "refineable", + "regex", + "resvg 0.46.0", + "scheduler", + "schemars", + "seahash", + "serde", + "serde_json", + "slotmap", + "smallvec", + "spin 0.10.0", + "stacksafe", + "strum", + "sum_tree", + "taffy", + "thiserror 2.0.18", + "ttf-parser", + "url", + "usvg 0.46.0", + "util_macros", + "uuid", + "waker-fn", + "web-time", + "windows 0.61.3", + "zed-font-kit", + "zed-scap", +] + +[[package]] +name = "gpui-component" +version = "0.5.2" +source = "git+https://github.com/longbridge/gpui-component.git?rev=0775df394083c1ed74f36f846b78868d1267398f#0775df394083c1ed74f36f846b78868d1267398f" +dependencies = [ + "aho-corasick", + "anyhow", + "async-channel", + "chrono", + "core-text", + "enum-iterator", + "futures", + "gpui", + "gpui-component-assets", + "gpui-component-macros", + "gpui_macros", + "html5ever", + "instant", + "itertools 0.13.0", + "log", + "lsp-types", + "markdown", + "markup5ever_rcdom", + "notify", + "num-traits", + "objc2 0.6.4", + "objc2-app-kit 0.3.2", + "objc2-foundation 0.3.2", + "once_cell", + "paste", + "raw-window-handle", + "regex", + "resvg 0.45.1", + "ropey", + "rust-i18n", + "schemars", + "serde", + "serde_json", + "serde_repr", + "smallvec", + "smol", + "tracing", + "unicode-segmentation", + "uuid", + "windows 0.58.0", + "zed-sum-tree", +] + +[[package]] +name = "gpui-component-assets" +version = "0.5.1" +source = "git+https://github.com/longbridge/gpui-component.git?rev=0775df394083c1ed74f36f846b78868d1267398f#0775df394083c1ed74f36f846b78868d1267398f" +dependencies = [ + "anyhow", + "gpui", + "log", + "rust-embed", + "wasm-bindgen", + "wasm-bindgen-futures", + "zed-reqwest", +] + +[[package]] +name = "gpui-component-macros" +version = "0.5.1" +source = "git+https://github.com/longbridge/gpui-component.git?rev=0775df394083c1ed74f36f846b78868d1267398f#0775df394083c1ed74f36f846b78868d1267398f" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "gpui_linux" +version = "0.1.0" +source = "git+https://github.com/zed-industries/zed#c545fb67d0ce13e335bff76f7c08986000333f2c" +dependencies = [ + "accesskit", + "accesskit_unix", + "anyhow", + "as-raw-xcb-connection", + "ashpd", + "bitflags 2.13.0", + "bytemuck", + "calloop", + "calloop-wayland-source", + "collections", + "filedescriptor", + "futures", + "gpui", + "gpui_util", + "gpui_wgpu", + "http_client", + "image", + "itertools 0.14.0", + "libc", + "log", + "oo7", + "open", + "parking_lot", + "pathfinder_geometry", + "pollster 0.4.0", + "profiling", + "raw-window-handle", + "smallvec", + "smol", + "strum", + "swash", + "url", + "uuid", + "wayland-backend", + "wayland-client", + "wayland-cursor", + "wayland-protocols", + "wayland-protocols-plasma", + "wayland-protocols-wlr", + "x11-clipboard", + "x11rb", + "xkbcommon", + "zed-scap", + "zed-xim", +] + +[[package]] +name = "gpui_macos" +version = "0.1.0" +source = "git+https://github.com/zed-industries/zed#c545fb67d0ce13e335bff76f7c08986000333f2c" +dependencies = [ + "accesskit", + "accesskit_macos", + "anyhow", + "async-task", + "block", + "cbindgen", + "cocoa 0.26.0", + "collections", + "core-foundation 0.10.0", + "core-foundation-sys", + "core-graphics 0.24.0", + "core-text", + "core-video", + "ctor", + "derive_more", + "dispatch2", + "etagere", + "foreign-types", + "futures", + "gpui", + "gpui_util", + "image", + "itertools 0.14.0", + "libc", + "log", + "mach2 0.5.0", + "media", + "metal", + "objc", + "objc2 0.6.4", + "objc2-app-kit 0.3.2", + "objc2-foundation 0.3.2", + "parking_lot", + "pathfinder_geometry", + "raw-window-handle", + "semver", + "smallvec", + "strum", + "uuid", + "zed-font-kit", +] + +[[package]] +name = "gpui_macros" +version = "0.1.0" +source = "git+https://github.com/zed-industries/zed#c545fb67d0ce13e335bff76f7c08986000333f2c" +dependencies = [ + "heck 0.5.0", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "gpui_platform" +version = "0.1.0" +source = "git+https://github.com/zed-industries/zed#c545fb67d0ce13e335bff76f7c08986000333f2c" +dependencies = [ + "console_error_panic_hook", + "gpui", + "gpui_linux", + "gpui_macos", + "gpui_web", + "gpui_windows", +] + +[[package]] +name = "gpui_shared_string" +version = "0.1.0" +source = "git+https://github.com/zed-industries/zed#c545fb67d0ce13e335bff76f7c08986000333f2c" +dependencies = [ + "schemars", + "serde", + "smol_str", +] + +[[package]] +name = "gpui_util" +version = "0.1.0" +source = "git+https://github.com/zed-industries/zed#c545fb67d0ce13e335bff76f7c08986000333f2c" +dependencies = [ + "anyhow", + "log", + "which", +] + +[[package]] +name = "gpui_web" +version = "0.1.0" +source = "git+https://github.com/zed-industries/zed#c545fb67d0ce13e335bff76f7c08986000333f2c" +dependencies = [ + "anyhow", + "console_error_panic_hook", + "futures", + "gpui", + "gpui_wgpu", + "http_client", + "js-sys", + "log", + "parking_lot", + "raw-window-handle", + "smallvec", + "uuid", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm_thread", + "web-sys", + "web-time", +] + +[[package]] +name = "gpui_wgpu" +version = "0.1.0" +source = "git+https://github.com/zed-industries/zed#c545fb67d0ce13e335bff76f7c08986000333f2c" +dependencies = [ + "anyhow", + "bytemuck", + "collections", + "cosmic-text", + "etagere", + "gpui", + "gpui_util", + "itertools 0.14.0", + "js-sys", + "log", + "parking_lot", + "pollster 0.4.0", + "profiling", + "raw-window-handle", + "smallvec", + "swash", + "unicode-segmentation", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "wgpu", + "zed-font-kit", +] + +[[package]] +name = "gpui_windows" +version = "0.1.0" +source = "git+https://github.com/zed-industries/zed#c545fb67d0ce13e335bff76f7c08986000333f2c" +dependencies = [ + "accesskit", + "accesskit_windows", + "anyhow", + "collections", + "dunce", + "etagere", + "futures", + "gpui", + "gpui_util", + "image", + "itertools 0.14.0", + "log", + "parking_lot", + "rand 0.9.4", + "raw-window-handle", + "smallvec", + "uuid", + "windows 0.61.3", + "windows-core 0.61.2", + "windows-numerics 0.2.0", + "windows-registry 0.5.3", +] + +[[package]] +name = "grid" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b40ca9252762c466af32d0b1002e91e4e1bc5398f77455e55474deb466355ff5" + +[[package]] +name = "group" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63" +dependencies = [ + "ff", + "rand_core 0.6.4", + "subtle", +] + +[[package]] +name = "h2" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6cb093c84e8bd9b188d4c4a8cb6579fc016968d14c99882163cd3ff402a4f155" +dependencies = [ + "atomic-waker", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "http", + "indexmap", + "slab", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "half" +version = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" +dependencies = [ + "cfg-if", + "crunchy", + "num-traits", + "zerocopy", +] + +[[package]] +name = "harfrust" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9da2e5ae821f6e96664977bf974d6d6a2d6682f9ccee23e62ec1d134246845f9" +dependencies = [ + "bitflags 2.13.0", + "bytemuck", + "core_maths", + "read-fonts 0.37.0", + "smallvec", +] + +[[package]] +name = "hash32" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47d60b12902ba28e2730cd37e95b8c9223af2808df9e902d4df49588d1470606" +dependencies = [ + "byteorder", +] + +[[package]] +name = "hashbrown" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash 0.1.5", + "serde", +] + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash 0.2.0", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "hashify" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd1246c0e5493286aeb2dde35b1f4eb9c4ce00e628641210a5e553fc001a1f26" +dependencies = [ + "indexmap", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "heapless" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25ba4bd83f9415b58b4ed8dc5714c76e626a105be4646c02630ad730ad3b5aa4" +dependencies = [ + "hash32", + "stable_deref_trait", +] + +[[package]] +name = "heck" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hermit-abi" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "hexf-parse" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfa686283ad6dd069f105e5ab091b04c62850d3e4cf5d67debad1933f55023df" + +[[package]] +name = "hkdf" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" +dependencies = [ + "hmac 0.12.1", +] + +[[package]] +name = "hkdf" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4aaa26c720c68b866f2c96ef5c1264b3e6f473fe5d4ce61cd44bbe913e553018" +dependencies = [ + "hmac 0.13.0", +] + +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest 0.10.7", +] + +[[package]] +name = "hmac" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6303bc9732ae41b04cb554b844a762b4115a61bfaa81e3e83050991eeb56863f" +dependencies = [ + "digest 0.11.3", +] + +[[package]] +name = "home" +version = "0.5.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc627f471c528ff0c4a49e1d5e60450c8f6461dd6d10ba9dcd3a61d3dff7728d" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "html5ever" +version = "0.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c13771afe0e6e846f1e67d038d4cb29998a6779f93c809212e4e9c32efd244d4" +dependencies = [ + "log", + "mac", + "markup5ever", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "http" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6970f50e31d6fc17d3fa27329444bfa74e196cf62e95052a3f6fee181dba6425" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "http_client" +version = "0.1.0" +source = "git+https://github.com/zed-industries/zed#c545fb67d0ce13e335bff76f7c08986000333f2c" +dependencies = [ + "anyhow", + "async-compression", + "bytes", + "derive_more", + "futures", + "http", + "http-body", + "log", + "parking_lot", + "serde", + "serde_json", + "serde_urlencoded", + "url", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "hybrid-array" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "818356c5132c1fede50f837ca96afbe78ff42413047f4abb886217845e1b6c8c" +dependencies = [ + "typenum", +] + +[[package]] +name = "hyper" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55281c53a1894c864990125767da440a4e630446785086f52523b20033b74498" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "h2", + "http", + "http-body", + "httparse", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "rustls-native-certs", + "tokio", + "tokio-rustls", + "tower-service", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "hyper", + "libc", + "pin-project-lite", + "socket2", + "tokio", + "tower-service", + "tracing", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core 0.58.0", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "icu_collections" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" + +[[package]] +name = "icu_properties" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" + +[[package]] +name = "icu_provider" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "ignore" +version = "0.4.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2adf14691c72bcfc1058740436a35bdd3ae9c07d1a941ef00b749e9ea16aefa7" +dependencies = [ + "crossbeam-deque", + "globset", + "log", + "memchr", + "regex-automata", + "same-file", + "walkdir", + "winapi-util", +] + +[[package]] +name = "image" +version = "0.25.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104" +dependencies = [ + "bytemuck", + "byteorder-lite", + "color_quant", + "exr", + "gif 0.14.2", + "image-webp", + "moxcms", + "num-traits", + "png 0.18.1", + "qoi", + "ravif", + "rayon", + "rgb", + "tiff", + "zune-core 0.5.1", + "zune-jpeg 0.5.15", +] + +[[package]] +name = "image-webp" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "525e9ff3e1a4be2fbea1fdf0e98686a6d98b4d8f937e1bf7402245af1909e8c3" +dependencies = [ + "byteorder-lite", + "quick-error", +] + +[[package]] +name = "imagesize" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edcd27d72f2f071c64249075f42e205ff93c9a4c5f6c6da53e79ed9f9832c285" + +[[package]] +name = "imagesize" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09e54e57b4c48b40f7aec75635392b12b3421fa26fe8b4332e63138ed278459c" + +[[package]] +name = "imgref" +version = "1.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89194689a993ab15268672e99e7b0e19da2da3268ac682e8f02d29d4d1434cd7" + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", + "serde", + "serde_core", +] + +[[package]] +name = "inotify" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdd168d97690d0b8c412d6b6c10360277f4d7ee495c5d0d5d5fe0854923255cc" +dependencies = [ + "bitflags 1.3.2", + "inotify-sys", + "libc", +] + +[[package]] +name = "inotify-sys" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c033f80b2c113cdf91ab7a33faa9cbc014726dcad99880c8609af2a370edf37d" +dependencies = [ + "libc", +] + +[[package]] +name = "inout" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +dependencies = [ + "block-padding 0.3.3", + "generic-array", +] + +[[package]] +name = "inout" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4250ce6452e92010fdf7268ccc5d14faa80bb12fc741938534c58f16804e03c7" +dependencies = [ + "block-padding 0.4.2", + "hybrid-array", +] + +[[package]] +name = "instant" +version = "0.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0242819d153cba4b4b05a5a8f2a7e9bbf97b6055b2a002b395c96b5ff3c0222" +dependencies = [ + "cfg-if", + "js-sys", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "interpolate_name" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c34819042dc3d3971c46c2190835914dfbe0c3c13f61449b2997f4e9722dfa60" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "inventory" +version = "0.3.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4f0c30c76f2f4ccee3fe55a2435f691ca00c0e4bd87abe4f4a851b1d4dac39b" +dependencies = [ + "rustversion", +] + +[[package]] +name = "io-surface" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "554b8c5d64ec09a3a520fe58e4d48a73e00ff32899cdcbe32a4877afd4968b8e" +dependencies = [ + "cgl", + "core-foundation 0.10.0", + "core-foundation-sys", + "leaky-cow", +] + +[[package]] +name = "ipnet" +version = "2.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" + +[[package]] +name = "is-docker" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "928bae27f42bc99b60d9ac7334e3a21d10ad8f1835a4e12ec3ec0464765ed1b3" +dependencies = [ + "once_cell", +] + +[[package]] +name = "is-wsl" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "173609498df190136aa7dea1a91db051746d339e18476eed5ca40521f02d7aa5" +dependencies = [ + "is-docker", + "once_cell", +] + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "itertools" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1c173a5686ce8bfa551b3563d0c2170bf24ca44da99c7ca4bfdab5418c3fe57" +dependencies = [ + "either", +] + +[[package]] +name = "itertools" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" +dependencies = [ + "either", +] + +[[package]] +name = "itertools" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "jiff" +version = "0.2.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "961d16382652bfdd8c6f68b223b26a8c93e0d475c672f414411db31c6c5c900e" +dependencies = [ + "defmt", + "jiff-static", + "log", + "portable-atomic", + "portable-atomic-util", + "serde_core", +] + +[[package]] +name = "jiff-static" +version = "0.2.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0879bd39df99c4c5e2c6615ccc026391a423dde10532c573e6086eb94a802cc" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "jni" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498" +dependencies = [ + "cfg-if", + "combine", + "jni-macros", + "jni-sys 0.4.1", + "log", + "simd_cesu8", + "thiserror 2.0.18", + "walkdir", + "windows-link 0.2.1", +] + +[[package]] +name = "jni-macros" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3" +dependencies = [ + "proc-macro2", + "quote", + "rustc_version", + "simd_cesu8", + "syn", +] + +[[package]] +name = "jni-sys" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41a652e1f9b6e0275df1f15b32661cf0d4b78d4d87ddec5e0c3c20f097433258" +dependencies = [ + "jni-sys 0.4.1", +] + +[[package]] +name = "jni-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2" +dependencies = [ + "jni-sys-macros", +] + +[[package]] +name = "jni-sys-macros" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" +dependencies = [ + "quote", + "syn", +] + +[[package]] +name = "jobserver" +version = "0.1.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3" +dependencies = [ + "getrandom 0.4.3", + "libc", +] + +[[package]] +name = "js-sys" +version = "0.3.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53b44bfcdb3f8d5837a46dae1ca9660a837176eee74a28b229bc626816589102" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "khronos-egl" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6aae1df220ece3c0ada96b8153459b67eebe9ae9212258bb0134ae60416fdf76" +dependencies = [ + "libc", + "libloading", + "pkg-config", +] + +[[package]] +name = "khronos_api" +version = "3.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2db585e1d738fc771bf08a151420d3ed193d9d895a36df7f6f8a9456b911ddc" + +[[package]] +name = "kqueue" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "273c0752728918e0ac4976f2b275b6fefb9ecd400585dec929419f3844cd87b5" +dependencies = [ + "kqueue-sys", + "libc", +] + +[[package]] +name = "kqueue-sys" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07293a4e297ac234359b510362495713f75ea345d5307140414f20c69ffeb087" +dependencies = [ + "bitflags 2.13.0", + "libc", +] + +[[package]] +name = "kurbo" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c62026ae44756f8a599ba21140f350303d4f08dcdcc71b5ad9c9bb8128c13c62" +dependencies = [ + "arrayvec", + "euclid", + "smallvec", +] + +[[package]] +name = "kurbo" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b60dfc32f652b926df6192e55525b16d186c69d47876c3ead4da5cc9f8450e2" +dependencies = [ + "arrayvec", + "euclid", + "polycool", + "smallvec", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "leak" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd100e01f1154f2908dfa7d02219aeab25d0b9c7fa955164192e3245255a0c73" + +[[package]] +name = "leaky-cow" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40a8225d44241fd324a8af2806ba635fc7c8a7e9a7de4d5cf3ef54e71f5926fc" +dependencies = [ + "leak", +] + +[[package]] +name = "lebe" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a79a3332a6609480d7d0c9eab957bca6b455b91bb84e66d19f5ff66294b85b8" + +[[package]] +name = "libbz2-rs-sys" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34b357333733e8260735ba5894eb928c02ecc69c78715f01a8019e7fa7f2db4c" + +[[package]] +name = "libc" +version = "0.2.186" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" + +[[package]] +name = "libfuzzer-sys" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9fd2f41a1cba099f79a0b6b6c35656cf7c03351a7bae8ff0f28f25270f929d2" +dependencies = [ + "arbitrary", + "cc", +] + +[[package]] +name = "libloading" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55" +dependencies = [ + "cfg-if", + "windows-link 0.2.1", +] + +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + +[[package]] +name = "libredox" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c943259e342f1e06ff2da7a83eabdfe7f92ce10262688dbf1895ff0b3e6e4652" +dependencies = [ + "libc", +] + +[[package]] +name = "libsqlite3-sys" +version = "0.37.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f111c8c41e7c61a49cd34e44c7619462967221a6443b0ec299e0ac30cfb9b1" +dependencies = [ + "cc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "linebender_resource_handle" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4a5ff6bcca6c4867b1c4fd4ef63e4db7436ef363e0ad7531d1558856bae64f4" + +[[package]] +name = "link-section" +version = "0.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e333fe507b738576d6da5bb3f1a7d7a1c80307ed9ef31624c057d844c19c93e9" + +[[package]] +name = "linktime-proc-macro" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c7b0a3383c2a1002d11349c92c85a666a5fb679e96c79d782cf0dbe557fd6ee" + +[[package]] +name = "linux-raw-sys" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "litemap" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" + +[[package]] +name = "litrs" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" +dependencies = [ + "serde_core", + "value-bag", +] + +[[package]] +name = "loop9" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fae87c125b03c1d2c0150c90365d7d6bcc53fb73a9acaef207d2d065860f062" +dependencies = [ + "imgref", +] + +[[package]] +name = "lru-slab" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" + +[[package]] +name = "lsp-types" +version = "0.97.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53353550a17c04ac46c585feb189c2db82154fc84b79c7a66c96c2c644f66071" +dependencies = [ + "bitflags 1.3.2", + "fluent-uri", + "serde", + "serde_json", + "serde_repr", +] + +[[package]] +name = "lyon" +version = "1.0.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd0578bdecb7d6d88987b8b2b1e3a4e2f81df9d0ece1078623324a567904e7b7" +dependencies = [ + "lyon_algorithms", + "lyon_tessellation", +] + +[[package]] +name = "lyon_algorithms" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8575c0d003ae459399623c4def180c63b77f343b1a7fee64f249b349e7699a31" +dependencies = [ + "lyon_path", + "num-traits", +] + +[[package]] +name = "lyon_geom" +version = "1.0.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4336502e29e32af93cf2dad2214ed6003c17ceb5bd499df77b1de663b9042b92" +dependencies = [ + "arrayvec", + "euclid", + "num-traits", +] + +[[package]] +name = "lyon_path" +version = "1.0.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c463f9c428b7fc5ec885dcd39ce4aa61e29111d0e33483f6f98c74e89d8621e" +dependencies = [ + "lyon_geom", + "num-traits", +] + +[[package]] +name = "lyon_tessellation" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e43b7e44161571868f5c931d12583592c223c5583eef86b08aa02b7048a3552" +dependencies = [ + "float_next_after", + "lyon_path", + "num-traits", +] + +[[package]] +name = "mac" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4" + +[[package]] +name = "mach2" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a1b95cd5421ec55b445b5ae102f5ea0e768de1f82bd3001e11f426c269c3aea" +dependencies = [ + "libc", +] + +[[package]] +name = "mach2" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dae608c151f68243f2b000364e1f7b186d9c29845f7d2d85bd31b9ad77ad552b" + +[[package]] +name = "malloc_buf" +version = "0.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62bb907fe88d54d8d9ce32a3cceab4218ed2f6b7d35617cafe9adf84e43919cb" +dependencies = [ + "libc", +] + +[[package]] +name = "markdown" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5cab8f2cadc416a82d2e783a1946388b31654d391d1c7d92cc1f03e295b1deb" +dependencies = [ + "serde", + "unicode-id", +] + +[[package]] +name = "markup5ever" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16ce3abbeba692c8b8441d036ef91aea6df8da2c6b6e21c7e14d3c18e526be45" +dependencies = [ + "log", + "phf 0.11.3", + "phf_codegen", + "string_cache", + "string_cache_codegen", + "tendril", +] + +[[package]] +name = "markup5ever_rcdom" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edaa21ab3701bfee5099ade5f7e1f84553fd19228cf332f13cd6e964bf59be18" +dependencies = [ + "html5ever", + "markup5ever", + "tendril", + "xml5ever", +] + +[[package]] +name = "maybe-rayon" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ea1f30cedd69f0a2954655f7188c6a834246d2bcf1e315e2ac40c4b24dc9519" +dependencies = [ + "cfg-if", + "rayon", +] + +[[package]] +name = "md-5" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf" +dependencies = [ + "cfg-if", + "digest 0.10.7", +] + +[[package]] +name = "md5" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ebb8d8732c6a6df3d8f032a82911cfc747e00efb95cc46e8d0acd5b5b88570c" + +[[package]] +name = "media" +version = "0.1.0" +source = "git+https://github.com/zed-industries/zed#c545fb67d0ce13e335bff76f7c08986000333f2c" +dependencies = [ + "anyhow", + "bindgen", + "core-foundation 0.10.0", + "core-video", + "ctor", + "foreign-types", + "metal", + "objc", +] + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "memmap2" +version = "0.9.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1219ed1b7f229ee7104d281dd01d6802fe28bb6e95d292942c4daacdeb798c0" +dependencies = [ + "libc", +] + +[[package]] +name = "memoffset" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5de893c32cde5f383baa4c04c5d6dbdd735cfd4a794b0debdb2bb1b421da5ff4" +dependencies = [ + "autocfg", +] + +[[package]] +name = "memoffset" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" +dependencies = [ + "autocfg", +] + +[[package]] +name = "metal" +version = "0.33.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7047791b5bc903b8cd963014b355f71dc9864a9a0b727057676c1dcae5cbc15" +dependencies = [ + "bitflags 2.13.0", + "block", + "core-graphics-types 0.2.0", + "foreign-types", + "log", + "objc", + "paste", +] + +[[package]] +name = "migrations_internals" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36c791ecdf977c99f45f23280405d7723727470f6689a5e6dbf513ac547ae10d" +dependencies = [ + "serde", + "toml 0.9.12+spec-1.1.0", +] + +[[package]] +name = "migrations_macros" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36fc5ac76be324cfd2d3f2cf0fdf5d5d3c4f14ed8aaebadb09e304ba42282703" +dependencies = [ + "migrations_internals", + "proc-macro2", + "quote", +] + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "mime_guess" +version = "2.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e" +dependencies = [ + "mime", + "unicase", +] + +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "mio" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02bd0af71c67b473010cbbc60715ee815645a4dc942899111f494b4b737d6fda" +dependencies = [ + "libc", + "log", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "moxcms" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb85c154ba489f01b25c0d36ae69a87e4a1c73a72631fc6c0eb6dde34a73e44b" +dependencies = [ + "num-traits", + "pxfm", +] + +[[package]] +name = "mp4" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9ef834d5ed55e494a2ae350220314dc4aacd1c43a9498b00e320e0ea352a5c3" +dependencies = [ + "byteorder", + "bytes", + "num-rational", + "serde", + "serde_json", + "thiserror 1.0.69", +] + +[[package]] +name = "naga" +version = "29.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2bf919621e7975acb27d881bae2fb993e0d45c8e0446e85e6272971e00dc8df" +dependencies = [ + "arrayvec", + "bit-set", + "bitflags 2.13.0", + "cfg-if", + "cfg_aliases", + "codespan-reporting", + "half", + "hashbrown 0.16.1", + "hexf-parse", + "indexmap", + "libm", + "log", + "num-traits", + "once_cell", + "rustc-hash 1.1.0", + "spirv", + "thiserror 2.0.18", + "unicode-ident", +] + +[[package]] +name = "nanorand" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a51313c5820b0b02bd422f4b44776fbf47961755c74ce64afc73bfad10226c3" +dependencies = [ + "getrandom 0.2.17", +] + +[[package]] +name = "nasm-rs" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "706bf8a5e8c8ddb99128c3291d31bd21f4bcde17f0f4c20ec678d85c74faa149" +dependencies = [ + "log", +] + +[[package]] +name = "ndk" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3f42e7bbe13d351b6bead8286a43aac9534b82bd3cc43e47037f012ebfd62d4" +dependencies = [ + "bitflags 2.13.0", + "jni-sys 0.3.1", + "log", + "ndk-sys", + "num_enum", + "thiserror 1.0.69", +] + +[[package]] +name = "ndk-context" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27b02d87554356db9e9a873add8782d4ea6e3e58ea071a9adb9a2e8ddb884a8b" + +[[package]] +name = "ndk-sys" +version = "0.6.0+11769913" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee6cda3051665f1fb8d9e08fc35c96d5a244fb1be711a03b71118828afc9a873" +dependencies = [ + "jni-sys 0.3.1", +] + +[[package]] +name = "new_debug_unreachable" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" + +[[package]] +name = "nix" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "598beaf3cc6fdd9a5dfb1630c2800c7acd31df7aaf0f565796fba2b53ca1af1b" +dependencies = [ + "bitflags 1.3.2", + "cfg-if", + "libc", + "memoffset 0.7.1", + "pin-utils", +] + +[[package]] +name = "no_std_io2" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "418abd1b6d34fbf6cae440dc874771b0525a604428704c76e48b29a5e67b8003" +dependencies = [ + "memchr", +] + +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + +[[package]] +name = "nom" +version = "8.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405" +dependencies = [ + "memchr", +] + +[[package]] +name = "noop_proc_macro" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0676bb32a98c1a483ce53e500a81ad9c3d5b3f7c920c28c24e9cb0980d0b5bc8" + +[[package]] +name = "normpath" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9985ef7269fa99f3b12437bb698381da2428743ab90f20393f399fa14cab21a" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "notify" +version = "7.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c533b4c39709f9ba5005d8002048266593c1cfaf3c5f0739d5b8ab0c6c504009" +dependencies = [ + "bitflags 2.13.0", + "filetime", + "fsevent-sys", + "inotify", + "kqueue", + "libc", + "log", + "mio", + "notify-types", + "walkdir", + "windows-sys 0.52.0", +] + +[[package]] +name = "notify-types" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "585d3cb5e12e01aed9e8a1f70d5c6b5e86fe2a6e48fc8cd0b3e0b8df6f6eb174" +dependencies = [ + "instant", +] + +[[package]] +name = "ntapi" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3b335231dfd352ffb0f8017f3b6027a4917f7df785ea2143d8af2adc66980ae" +dependencies = [ + "winapi", +] + +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "num" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35bd024e8b2ff75562e5f34e7f4905839deb4b22955ef5e73d2fea1b9813cb23" +dependencies = [ + "num-bigint", + "num-complex", + "num-integer", + "num-iter", + "num-rational", + "num-traits", +] + +[[package]] +name = "num-bigint" +version = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-bigint-dig" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7f9a86e097b0d187ad0e65667c2f58b9254671e86e7dbb78036b16692eae099" +dependencies = [ + "libm", + "num-integer", + "num-iter", + "num-traits", + "once_cell", + "rand 0.9.4", + "serde", + "smallvec", + "zeroize", +] + +[[package]] +name = "num-complex" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495" +dependencies = [ + "bytemuck", + "num-traits", +] + +[[package]] +name = "num-conv" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" + +[[package]] +name = "num-derive" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "num-integer" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-iter" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c92800bd69a1eac91786bcfe9da64a897eb72911b8dc3095decbd07429e8048b" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-rational" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824" +dependencies = [ + "num-bigint", + "num-integer", + "num-traits", + "serde", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", + "libm", +] + +[[package]] +name = "num_cpus" +version = "1.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91df4bbde75afed763b708b7eee1e8e7651e02d97f6d5dd763e89367e957b23b" +dependencies = [ + "hermit-abi", + "libc", +] + +[[package]] +name = "num_enum" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0bca838442ec211fa11de3a8b0e0e8f3a4522575b5c4c06ed722e005036f26" +dependencies = [ + "num_enum_derive", + "rustversion", +] + +[[package]] +name = "num_enum_derive" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "680998035259dcfcafe653688bf2aa6d3e2dc05e98be6ab46afb089dc84f1df8" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "objc" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "915b1b472bc21c53464d6c8461c9d3af805ba1ef837e1cac254428f4a77177b1" +dependencies = [ + "malloc_buf", + "objc_exception", +] + +[[package]] +name = "objc-foundation" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1add1b659e36c9607c7aab864a76c7a4c2760cd0cd2e120f3fb8b952c7e22bf9" +dependencies = [ + "block", + "objc", + "objc_id", +] + +[[package]] +name = "objc-sys" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdb91bdd390c7ce1a8607f35f3ca7151b65afc0ff5ff3b34fa350f7d7c7e4310" + +[[package]] +name = "objc2" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46a785d4eeff09c14c487497c162e92766fbb3e4059a71840cecc03d9a50b804" +dependencies = [ + "objc-sys", + "objc2-encode", +] + +[[package]] +name = "objc2" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a12a8ed07aefc768292f076dc3ac8c48f3781c8f2d5851dd3d98950e8c5a89f" +dependencies = [ + "objc2-encode", +] + +[[package]] +name = "objc2-app-kit" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4e89ad9e3d7d297152b17d39ed92cd50ca8063a89a9fa569046d41568891eff" +dependencies = [ + "bitflags 2.13.0", + "block2 0.5.1", + "libc", + "objc2 0.5.2", + "objc2-core-data 0.2.2", + "objc2-core-image 0.2.2", + "objc2-foundation 0.2.2", + "objc2-quartz-core 0.2.2", +] + +[[package]] +name = "objc2-app-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c" +dependencies = [ + "bitflags 2.13.0", + "block2 0.6.2", + "libc", + "objc2 0.6.4", + "objc2-cloud-kit", + "objc2-core-data 0.3.2", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-core-image 0.3.2", + "objc2-core-text", + "objc2-core-video", + "objc2-foundation 0.3.2", + "objc2-quartz-core 0.3.2", +] + +[[package]] +name = "objc2-audio-toolbox" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6948501a91121d6399b79abaa33a8aa4ea7857fe019f341b8c23ad6e81b79b08" +dependencies = [ + "bitflags 2.13.0", + "libc", + "objc2 0.6.4", + "objc2-core-audio", + "objc2-core-audio-types", + "objc2-core-foundation", + "objc2-foundation 0.3.2", +] + +[[package]] +name = "objc2-avf-audio" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13a380031deed8e99db00065c45937da434ca987c034e13b87e4441f9e4090be" +dependencies = [ + "bitflags 2.13.0", + "objc2 0.6.4", + "objc2-foundation 0.3.2", +] + +[[package]] +name = "objc2-cloud-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73ad74d880bb43877038da939b7427bba67e9dd42004a18b809ba7d87cee241c" +dependencies = [ + "bitflags 2.13.0", + "objc2 0.6.4", + "objc2-foundation 0.3.2", +] + +[[package]] +name = "objc2-core-audio" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1eebcea8b0dbff5f7c8504f3107c68fc061a3eb44932051c8cf8a68d969c3b2" +dependencies = [ + "dispatch2", + "objc2 0.6.4", + "objc2-core-audio-types", + "objc2-core-foundation", + "objc2-foundation 0.3.2", +] + +[[package]] +name = "objc2-core-audio-types" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a89f2ec274a0cf4a32642b2991e8b351a404d290da87bb6a9a9d8632490bd1c" +dependencies = [ + "bitflags 2.13.0", + "objc2 0.6.4", +] + +[[package]] +name = "objc2-core-data" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "617fbf49e071c178c0b24c080767db52958f716d9eabdf0890523aeae54773ef" +dependencies = [ + "bitflags 2.13.0", + "block2 0.5.1", + "objc2 0.5.2", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-core-data" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b402a653efbb5e82ce4df10683b6b28027616a2715e90009947d50b8dd298fa" +dependencies = [ + "bitflags 2.13.0", + "objc2 0.6.4", + "objc2-foundation 0.3.2", +] + +[[package]] +name = "objc2-core-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" +dependencies = [ + "bitflags 2.13.0", + "block2 0.6.2", + "dispatch2", + "libc", + "objc2 0.6.4", +] + +[[package]] +name = "objc2-core-graphics" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807" +dependencies = [ + "bitflags 2.13.0", + "dispatch2", + "objc2 0.6.4", + "objc2-core-foundation", + "objc2-io-surface", +] + +[[package]] +name = "objc2-core-image" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55260963a527c99f1819c4f8e3b47fe04f9650694ef348ffd2227e8196d34c80" +dependencies = [ + "block2 0.5.1", + "objc2 0.5.2", + "objc2-foundation 0.2.2", + "objc2-metal 0.2.2", +] + +[[package]] +name = "objc2-core-image" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5d563b38d2b97209f8e861173de434bd0214cf020e3423a52624cd1d989f006" +dependencies = [ + "objc2 0.6.4", + "objc2-foundation 0.3.2", +] + +[[package]] +name = "objc2-core-text" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cde0dfb48d25d2b4862161a4d5fcc0e3c24367869ad306b0c9ec0073bfed92d" +dependencies = [ + "bitflags 2.13.0", + "objc2 0.6.4", + "objc2-core-foundation", + "objc2-core-graphics", +] + +[[package]] +name = "objc2-core-video" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d425caf1df73233f29fd8a5c3e5edbc30d2d4307870f802d18f00d83dc5141a6" +dependencies = [ + "bitflags 2.13.0", + "objc2 0.6.4", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-io-surface", +] + +[[package]] +name = "objc2-encode" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" + +[[package]] +name = "objc2-foundation" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ee638a5da3799329310ad4cfa62fbf045d5f56e3ef5ba4149e7452dcf89d5a8" +dependencies = [ + "bitflags 2.13.0", + "block2 0.5.1", + "libc", + "objc2 0.5.2", +] + +[[package]] +name = "objc2-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" +dependencies = [ + "bitflags 2.13.0", + "block2 0.6.2", + "libc", + "objc2 0.6.4", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-io-surface" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d" +dependencies = [ + "bitflags 2.13.0", + "objc2 0.6.4", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-metal" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd0cba1276f6023976a406a14ffa85e1fdd19df6b0f737b063b95f6c8c7aadd6" +dependencies = [ + "bitflags 2.13.0", + "block2 0.5.1", + "objc2 0.5.2", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-metal" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a0125f776a10d00af4152d74616409f0d4a2053a6f57fa5b7d6aa2854ac04794" +dependencies = [ + "bitflags 2.13.0", + "block2 0.6.2", + "objc2 0.6.4", + "objc2-foundation 0.3.2", +] + +[[package]] +name = "objc2-quartz-core" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e42bee7bff906b14b167da2bac5efe6b6a07e6f7c0a21a7308d40c960242dc7a" +dependencies = [ + "bitflags 2.13.0", + "block2 0.5.1", + "objc2 0.5.2", + "objc2-foundation 0.2.2", + "objc2-metal 0.2.2", +] + +[[package]] +name = "objc2-quartz-core" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96c1358452b371bf9f104e21ec536d37a650eb10f7ee379fff67d2e08d537f1f" +dependencies = [ + "bitflags 2.13.0", + "objc2 0.6.4", + "objc2-core-foundation", + "objc2-foundation 0.3.2", + "objc2-metal 0.3.2", +] + +[[package]] +name = "objc_exception" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad970fb455818ad6cba4c122ad012fae53ae8b4795f86378bce65e4f6bab2ca4" +dependencies = [ + "cc", +] + +[[package]] +name = "objc_id" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c92d4ddb4bd7b50d730c215ff871754d0da6b2178849f8a2a2ab69712d0c073b" +dependencies = [ + "objc", +] + +[[package]] +name = "object" +version = "0.37.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff76201f031d8863c38aa7f905eca4f53abbfa15f609db4277d44cd8938f33fe" +dependencies = [ + "memchr", +] + +[[package]] +name = "ogg" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdab8dcd8d4052eaacaf8fb07a3ccd9a6e26efadb42878a413c68fc4af1dee2b" +dependencies = [ + "byteorder", +] + +[[package]] +name = "oid-registry" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8d8034d9489cdaf79228eb9f6a3b8d7bb32ba00d6645ebd48eef4077ceb5bd9" +dependencies = [ + "asn1-rs", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "oo7" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78f2bfed90f1618b4b48dcad9307f25e14ae894e2949642c87c351601d62cebd" +dependencies = [ + "aes 0.8.4", + "ashpd", + "async-fs", + "async-io", + "async-lock", + "blocking", + "cbc 0.1.2", + "cipher 0.4.4", + "digest 0.10.7", + "endi", + "futures-lite", + "futures-util", + "getrandom 0.4.3", + "hkdf 0.12.4", + "hmac 0.12.1", + "md-5", + "num", + "num-bigint-dig", + "pbkdf2", + "serde", + "serde_bytes", + "sha2 0.10.9", + "subtle", + "zbus", + "zbus_macros", + "zeroize", + "zvariant", +] + +[[package]] +name = "opaque-debug" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" + +[[package]] +name = "open" +version = "5.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd8d3b65c44123a56e0133d2cd06ce4361bd3ca99d41198b2f25e3c3db9b8b4a" +dependencies = [ + "is-wsl", + "libc", +] + +[[package]] +name = "openh264" +version = "0.9.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6b2b561d2103303e233779545da757ecd35bad82188d619a6d8901f3007e1ff" +dependencies = [ + "openh264-sys2", + "wide", +] + +[[package]] +name = "openh264-sys2" +version = "0.9.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75a8867e48183bbd9147380227448c065fe456eb30b0ebc68929809c36c30985" +dependencies = [ + "cc", + "nasm-rs", + "walkdir", +] + +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + +[[package]] +name = "opus" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d3809943dff6fbad5f0484449ea26bdb9cb7d8efdf26ed50d3c7f227f69eb5c" +dependencies = [ + "audiopus_sys", +] + +[[package]] +name = "ordered-float" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7d950ca161dc355eaf28f82b11345ed76c6e1f6eb1f4f4479e0323b9e2fbd0e" +dependencies = [ + "num-traits", +] + +[[package]] +name = "ordered-stream" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aa2b01e1d916879f73a53d01d1d6cee68adbb31d6d9177a8cfce093cced1d50" +dependencies = [ + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "p256" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9863ad85fa8f4460f9c48cb909d38a0d689dba1f6f6988a5e3e0d31071bcd4b" +dependencies = [ + "ecdsa", + "elliptic-curve", + "primeorder", + "sha2 0.10.9", +] + +[[package]] +name = "p384" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe42f1670a52a47d448f14b6a5c61dd78fce51856e68edaa38f7ae3a46b8d6b6" +dependencies = [ + "ecdsa", + "elliptic-curve", + "primeorder", + "sha2 0.10.9", +] + +[[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link 0.2.1", +] + +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + +[[package]] +name = "pastey" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35fb2e5f958ec131621fdd531e9fc186ed768cbe395337403ae56c17a74c68ec" + +[[package]] +name = "pathfinder_geometry" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b7b7e7b4ea703700ce73ebf128e1450eb69c3a8329199ffbfb9b2a0418e5ad3" +dependencies = [ + "log", + "pathfinder_simd", +] + +[[package]] +name = "pathfinder_simd" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4500030c302e4af1d423f36f3b958d1aecb6c04184356ed5a833bf6b60435777" +dependencies = [ + "rustc_version", +] + +[[package]] +name = "pbkdf2" +version = "0.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ed6a7761f76e3b9f92dfb0a60a6a6477c61024b775147ff0973a02653abaf2" +dependencies = [ + "digest 0.10.7", + "hmac 0.12.1", +] + +[[package]] +name = "pem" +version = "3.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be" +dependencies = [ + "base64", + "serde_core", +] + +[[package]] +name = "pem-rfc7468" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412" +dependencies = [ + "base64ct", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "perf" +version = "0.1.0" +source = "git+https://github.com/zed-industries/zed#c545fb67d0ce13e335bff76f7c08986000333f2c" +dependencies = [ + "collections", + "serde", + "serde_json", +] + +[[package]] +name = "phf" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078" +dependencies = [ + "phf_shared 0.11.3", +] + +[[package]] +name = "phf" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf" +dependencies = [ + "phf_macros", + "phf_shared 0.13.1", + "serde", +] + +[[package]] +name = "phf_codegen" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aef8048c789fa5e851558d709946d6d79a8ff88c0440c587967f8e94bfb1216a" +dependencies = [ + "phf_generator 0.11.3", + "phf_shared 0.11.3", +] + +[[package]] +name = "phf_generator" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d" +dependencies = [ + "phf_shared 0.11.3", + "rand 0.8.6", +] + +[[package]] +name = "phf_generator" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "135ace3a761e564ec88c03a77317a7c6b80bb7f7135ef2544dbe054243b89737" +dependencies = [ + "fastrand", + "phf_shared 0.13.1", +] + +[[package]] +name = "phf_macros" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "812f032b54b1e759ccd5f8b6677695d5268c588701effba24601f6932f8269ef" +dependencies = [ + "phf_generator 0.13.1", + "phf_shared 0.13.1", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "phf_shared" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5" +dependencies = [ + "siphasher", +] + +[[package]] +name = "phf_shared" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e57fef6bc5981e38c2ce2d63bfa546861309f875b8a75f092d1d54ae2d64f266" +dependencies = [ + "siphasher", +] + +[[package]] +name = "pico-args" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5be167a7af36ee22fe3115051bc51f6e6c7054c9348e28deb4f49bd6f705a315" + +[[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", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pin-utils" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" + +[[package]] +name = "piper" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c835479a4443ded371d6c535cbfd8d31ad92c5d23ae9770a61bc155e4992a3c1" +dependencies = [ + "atomic-waker", + "fastrand", + "futures-io", +] + +[[package]] +name = "pkcs8" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" +dependencies = [ + "der", + "spki", +] + +[[package]] +name = "pkg-config" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" + +[[package]] +name = "png" +version = "0.17.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82151a2fc869e011c153adc57cf2789ccb8d9906ce52c0b39a6b5697749d7526" +dependencies = [ + "bitflags 1.3.2", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide", +] + +[[package]] +name = "png" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61" +dependencies = [ + "bitflags 2.13.0", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide", +] + +[[package]] +name = "polling" +version = "3.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218" +dependencies = [ + "cfg-if", + "concurrent-queue", + "hermit-abi", + "pin-project-lite", + "rustix 1.1.4", + "windows-sys 0.61.2", +] + +[[package]] +name = "pollster" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5da3b0203fd7ee5720aa0b5e790b591aa5d3f41c3ed2c34a3a393382198af2f7" + +[[package]] +name = "pollster" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f3a9f18d041e6d0e102a0a46750538147e5e8992d3b4873aaafee2520b00ce3" + +[[package]] +name = "polycool" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50596ddc09eb5ad5f75cacd40209568e66df71baf86e1499a0e99c4cff12a5a6" +dependencies = [ + "arrayvec", +] + +[[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 0.5.1", +] + +[[package]] +name = "polyval" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dfc63250416fea14f5749b90725916a6c903f599d51cb635aa7a52bfd03eede" +dependencies = [ + "cpubits", + "cpufeatures 0.3.0", + "universal-hash 0.6.1", +] + +[[package]] +name = "portable-atomic" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" + +[[package]] +name = "portable-atomic-util" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a106d1259c23fac8e543272398ae0e3c0b8d33c88ed73d0cc71b0f1d902618" +dependencies = [ + "portable-atomic", +] + +[[package]] +name = "postage" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af3fb618632874fb76937c2361a7f22afd393c982a2165595407edc75b06d3c1" +dependencies = [ + "atomic", + "crossbeam-queue", + "futures", + "log", + "parking_lot", + "pin-project", + "pollster 0.2.5", + "static_assertions", + "thiserror 1.0.69", +] + +[[package]] +name = "potential_utf" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +dependencies = [ + "zerovec", +] + +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "precomputed-hash" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c" + +[[package]] +name = "presser" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8cf8e6a8aa66ce33f63993ffc4ea4271eb5b0530a9002db8455ea6050c77bfa" + +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn", +] + +[[package]] +name = "primeorder" +version = "0.13.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "353e1ca18966c16d9deb1c69278edbc5f194139612772bd9537af60ac231e1e6" +dependencies = [ + "elliptic-curve", +] + +[[package]] +name = "proc-macro-crate" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" +dependencies = [ + "toml_edit 0.25.12+spec-1.1.0", +] + +[[package]] +name = "proc-macro-error-attr2" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96de42df36bb9bba5542fe9f1a054b8cc87e172759a1868aa05c1f3acc89dfc5" +dependencies = [ + "proc-macro2", + "quote", +] + +[[package]] +name = "proc-macro-error2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11ec05c52be0a07b08061f7dd003e7d7092e0472bc731b4af7bb1ef876109802" +dependencies = [ + "proc-macro-error-attr2", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "profiling" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d595e54a326bc53c1c197b32d295e14b169e3cfeaa8dc82b529f947fba6bcf5" +dependencies = [ + "profiling-procmacros", +] + +[[package]] +name = "profiling-procmacros" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4488a4a36b9a4ba6b9334a32a39971f77c1436ec82c38707bce707699cc3bbcb" +dependencies = [ + "quote", + "syn", +] + +[[package]] +name = "psm" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "645dbe486e346d9b5de3ef16ede18c26e6c70ad97418f4874b8b1889d6e761ea" +dependencies = [ + "ar_archive_writer", + "cc", +] + +[[package]] +name = "pulp" +version = "0.22.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "046aa45b989642ec2e4717c8e72d677b13edd831a4d3b6cf37d9a3e54912496a" +dependencies = [ + "bytemuck", + "cfg-if", + "libm", + "num-complex", + "paste", + "pulp-wasm-simd-flag", + "raw-cpuid", + "reborrow", + "version_check", +] + +[[package]] +name = "pulp-wasm-simd-flag" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d8f70e07b9c3962945a74e59ca1c511bba65b6419468acc217c457d93f3c740" + +[[package]] +name = "pxfm" +version = "0.1.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d55d956fa96f5ec02be2e13af0e20391a5aa83d6a074e3ad368959d0fab299ea" + +[[package]] +name = "qoi" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f6d64c71eb498fe9eae14ce4ec935c555749aef511cca85b5568910d6e48001" +dependencies = [ + "bytemuck", +] + +[[package]] +name = "qrcode" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d68782463e408eb1e668cf6152704bd856c78c5b6417adaee3203d8f4c1fc9ec" +dependencies = [ + "image", +] + +[[package]] +name = "quick-error" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3" + +[[package]] +name = "quick-xml" +version = "0.30.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eff6510e86862b57b210fd8cbe8ed3f0d7d600b9c2863cd4549a2e033c66e956" +dependencies = [ + "memchr", +] + +[[package]] +name = "quick-xml" +version = "0.39.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdcc8dd4e2f670d309a5f0e83fe36dfdc05af317008fea29144da1a2ac858e5e" +dependencies = [ + "memchr", +] + +[[package]] +name = "quinn" +version = "0.11.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c1a41e437b6bbd489372cd4971de128e85c855f56c57f283d20ff016cf7c0a8" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash 2.1.3", + "rustls", + "socket2", + "thiserror 2.0.18", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f4bfc015262b9df63c8845072ce59068853ff5872180c2ce2f13038b970e560" +dependencies = [ + "bytes", + "getrandom 0.4.3", + "lru-slab", + "rand 0.10.2", + "rand_pcg", + "ring", + "rustc-hash 2.1.3", + "rustls", + "rustls-pki-types", + "slab", + "thiserror 2.0.18", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35a133f956daabe89a61a685c2649f13d82d5aa4bd5d12d1277e1072a21c0694" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2", + "tracing", + "windows-sys 0.52.0", +] + +[[package]] +name = "quote" +version = "1.0.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfbc457d0c7a0759a614551b11a6409e5951f6c7537be1f1b7682b9ae9230368" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "r2d2" +version = "0.8.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51de85fb3fb6524929c8a2eb85e6b6d363de4e8c48f9e2c2eac4944abc181c93" +dependencies = [ + "log", + "parking_lot", + "scheduled-thread-pool", +] + +[[package]] +name = "rand" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a" +dependencies = [ + "libc", + "rand_chacha 0.3.1", + "rand_core 0.6.4", +] + +[[package]] +name = "rand" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea" +dependencies = [ + "rand_chacha 0.9.0", + "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.3", + "rand_core 0.10.1", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core 0.6.4", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +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_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 = "range-alloc" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca45419789ae5a7899559e9512e58ca889e41f04f1f2445e9f4b290ceccd1d08" + +[[package]] +name = "rangemap" +version = "1.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "973443cf09a9c8656b574a866ab68dfa19f0867d0340648c7d2f6a71b8a8ea68" + +[[package]] +name = "rav1e" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43b6dd56e85d9483277cde964fd1bdb0428de4fec5ebba7540995639a21cb32b" +dependencies = [ + "aligned-vec", + "arbitrary", + "arg_enum_proc_macro", + "arrayvec", + "av-scenechange", + "av1-grain", + "bitstream-io", + "built", + "cfg-if", + "interpolate_name", + "itertools 0.14.0", + "libc", + "libfuzzer-sys", + "log", + "maybe-rayon", + "new_debug_unreachable", + "noop_proc_macro", + "num-derive", + "num-traits", + "paste", + "profiling", + "rand 0.9.4", + "rand_chacha 0.9.0", + "simd_helpers", + "thiserror 2.0.18", + "v_frame", + "wasm-bindgen", +] + +[[package]] +name = "ravif" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e52310197d971b0f5be7fe6b57530dcd27beb35c1b013f29d66c1ad73fbbcc45" +dependencies = [ + "avif-serialize", + "imgref", + "loop9", + "quick-error", + "rav1e", + "rayon", + "rgb", +] + +[[package]] +name = "raw-cpuid" +version = "11.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "498cd0dc59d73224351ee52a95fee0f1a617a2eae0e7d9d720cc622c73a54186" +dependencies = [ + "bitflags 2.13.0", +] + +[[package]] +name = "raw-window-handle" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539" + +[[package]] +name = "raw-window-metal" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40d213455a5f1dc59214213c7330e074ddf8114c9a42411eb890c767357ce135" +dependencies = [ + "objc2 0.6.4", + "objc2-core-foundation", + "objc2-foundation 0.3.2", + "objc2-quartz-core 0.3.2", +] + +[[package]] +name = "rayon" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb39b166781f92d482534ef4b4b1b2568f42613b53e5b6c160e24cfbfa30926d" +dependencies = [ + "either", + "rayon-core", +] + +[[package]] +name = "rayon-core" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91" +dependencies = [ + "crossbeam-deque", + "crossbeam-utils", +] + +[[package]] +name = "rcgen" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75e669e5202259b5314d1ea5397316ad400819437857b90861765f24c4cf80a2" +dependencies = [ + "pem", + "ring", + "rustls-pki-types", + "time", + "yasna", +] + +[[package]] +name = "read-fonts" +version = "0.37.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b634fabf032fab15307ffd272149b622260f55974d9fad689292a5d33df02e5" +dependencies = [ + "bytemuck", + "core_maths", + "font-types", +] + +[[package]] +name = "read-fonts" +version = "0.39.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4ed38b89c2c77ff968c524145ad65fb010f38af5c7a224b53b81d47ac2daa81" +dependencies = [ + "bytemuck", + "font-types", +] + +[[package]] +name = "reborrow" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03251193000f4bd3b042892be858ee50e8b3719f2b08e5833ac4353724632430" + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags 2.13.0", +] + +[[package]] +name = "redox_users" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" +dependencies = [ + "getrandom 0.2.17", + "libredox", + "thiserror 2.0.18", +] + +[[package]] +name = "ref-cast" +version = "1.0.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f354300ae66f76f1c85c5f84693f0ce81d747e2c3f21a45fef496d89c960bf7d" +dependencies = [ + "ref-cast-impl", +] + +[[package]] +name = "ref-cast-impl" +version = "1.0.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7186006dcb21920990093f30e3dea63b7d6e977bf1256be20c3563a5db070da" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "refineable" +version = "0.1.0" +source = "git+https://github.com/zed-industries/zed#c545fb67d0ce13e335bff76f7c08986000333f2c" +dependencies = [ + "derive_refineable", +] + +[[package]] +name = "regex" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a0e75113e14dc5acb068cd0786884f214f1312650a3d36d269f5c4f3cdee8a2" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f388202e4b80542a0921078cc23b6333bcf1409c1e3f86404cae4766a6131db" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-lite" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cab834c73d247e67f4fae452806d17d3c7501756d98c8808d7c9c7aa7d18f973" + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "renderdoc-sys" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19b30a45b0cd0bcca8037f3d0dc3421eaf95327a17cad11964fb8179b4fc4832" + +[[package]] +name = "resvg" +version = "0.45.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8928798c0a55e03c9ca6c4c6846f76377427d2c1e1f7e6de3c06ae57942df43" +dependencies = [ + "gif 0.13.3", + "image-webp", + "log", + "pico-args", + "rgb", + "svgtypes 0.15.3", + "tiny-skia", + "usvg 0.45.1", + "zune-jpeg 0.4.21", +] + +[[package]] +name = "resvg" +version = "0.46.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b563218631706d614e23059436526d005b50ab5f2d506b55a17eb65c5eb83419" +dependencies = [ + "gif 0.14.2", + "image-webp", + "log", + "pico-args", + "rgb", + "svgtypes 0.16.1", + "tiny-skia", + "usvg 0.46.0", + "zune-jpeg 0.5.15", +] + +[[package]] +name = "rfc6979" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dd2a808d456c4a54e300a23e9f5a67e122c3024119acbfd73e3bf664491cb2" +dependencies = [ + "hmac 0.12.1", + "subtle", +] + +[[package]] +name = "rgb" +version = "0.8.53" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b34b781b31e5d73e9fbc8689c70551fd1ade9a19e3e28cfec8580a79290cc4" +dependencies = [ + "bytemuck", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "ringbuf" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d3ecbcab081b935fb9c618b07654924f27686b4aac8818e700580a83eedcb7f" +dependencies = [ + "crossbeam-utils", + "portable-atomic", + "portable-atomic-util", +] + +[[package]] +name = "ropey" +version = "2.0.0-beta.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4045a00dc327d084a2bbf126976e14125b54f23bd30511d45b842eba76c52d74" +dependencies = [ + "str_indices", +] + +[[package]] +name = "roxmltree" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c20b6793b5c2fa6553b250154b78d6d0db37e72700ae35fad9387a46f487c97" + +[[package]] +name = "roxmltree" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1964b10c76125c36f8afe190065a4bf9a87bf324842c05701330bba9f1cacbb" +dependencies = [ + "memchr", +] + +[[package]] +name = "rsqlite-vfs" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c51c9ae4df8a7fba42103df5c621fa3c37eccf3a3c650879e90fc48b11cc192c" +dependencies = [ + "hashbrown 0.16.1", + "thiserror 2.0.18", +] + +[[package]] +name = "rust-embed" +version = "8.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9e7760e252aaba7b09f4be00e36476cf585bdb68a53552ac954cdf504ab4bc9" +dependencies = [ + "rust-embed-impl", + "rust-embed-utils", + "walkdir", +] + +[[package]] +name = "rust-embed-impl" +version = "8.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3bcfc4d6f53af43755f7a723e4b6b8794fcce052a178dd8c6c1dadc5f5343097" +dependencies = [ + "mime_guess", + "proc-macro2", + "quote", + "rust-embed-utils", + "shellexpand", + "syn", + "walkdir", +] + +[[package]] +name = "rust-embed-utils" +version = "8.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42ffa149f6aa81b58a5b3011d01a857c4ed12c7a732d2c51947a4c7c692185f0" +dependencies = [ + "globset", + "sha2 0.11.0", + "walkdir", +] + +[[package]] +name = "rust-i18n" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55691a65892c33ee2de49c15ea5600c6f4a70e8eeb8e6c3cd96d2a231d230c40" +dependencies = [ + "globwalk", + "regex", + "rust-i18n-macro", + "rust-i18n-support", + "smallvec", +] + +[[package]] +name = "rust-i18n-macro" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30de488acadcf767d97cd48518a8da8ea9777b1c9a5beca4eab78bbf77d07309" +dependencies = [ + "glob", + "proc-macro2", + "quote", + "rust-i18n-support", + "serde", + "serde_json", + "serde_yaml", + "syn", +] + +[[package]] +name = "rust-i18n-support" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aea0fef8a93c06326b66392c95a115120e609674cb2132d37d276a6b05b545b4" +dependencies = [ + "arc-swap", + "base62", + "globwalk", + "itertools 0.11.0", + "normpath", + "serde", + "serde_json", + "serde_yaml", + "siphasher", + "toml 0.8.23", + "triomphe", +] + +[[package]] +name = "rustc-demangle" +version = "0.1.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b74b56ffa8bb2830709a538c2cbcae9aa062db0d2a42563bfb09bdaae44020eb" + +[[package]] +name = "rustc-hash" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2" + +[[package]] +name = "rustc-hash" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rusticata-macros" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "faf0c4a6ece9950b9abdb62b1cfcf2a68b3b67a10ba445b3bb85be2a293d0632" +dependencies = [ + "nom 7.1.3", +] + +[[package]] +name = "rustix" +version = "0.38.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" +dependencies = [ + "bitflags 2.13.0", + "errno", + "libc", + "linux-raw-sys 0.4.15", + "windows-sys 0.52.0", +] + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags 2.13.0", + "errno", + "libc", + "linux-raw-sys 0.12.1", + "windows-sys 0.52.0", +] + +[[package]] +name = "rustls" +version = "0.23.41" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b92b125634d9b795e7beca796cc790df15a7fb38323bf3196fda83292d06b1f" +dependencies = [ + "log", + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-native-certs" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dab5152771c58876a2146916e53e35057e1a4dfa2b9df0f0305b07f611fdea4d" +dependencies = [ + "openssl-probe", + "rustls-pki-types", + "schannel", + "security-framework", +] + +[[package]] +name = "rustls-pemfile" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dce314e5fee3f39953d46bb63bb8a46d40c2f8fb7cc5a3b6cab2bde9721d6e50" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "rustls-pki-types" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "764899a24af3980067ee14bc143654f297b22eaebfe3c7b6b211920a5a59b046" +dependencies = [ + "web-time", + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "rustybuzz" +version = "0.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd3c7c96f8a08ee34eff8857b11b49b07d71d1c3f4e88f8a88d4c9e9f90b1702" +dependencies = [ + "bitflags 2.13.0", + "bytemuck", + "core_maths", + "log", + "smallvec", + "ttf-parser", + "unicode-bidi-mirroring", + "unicode-ccc", + "unicode-properties", + "unicode-script", +] + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "safe_arch" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f7caad094bd561859bcd467734a720c3c1f5d1f338995351fefe2190c45efed" +dependencies = [ + "bytemuck", +] + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "schannel" +version = "0.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "scheduled-thread-pool" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3cbc66816425a074528352f5789333ecff06ca41b36b0b0efdfbb29edc391a19" +dependencies = [ + "parking_lot", +] + +[[package]] +name = "scheduler" +version = "0.1.0" +source = "git+https://github.com/zed-industries/zed#c545fb67d0ce13e335bff76f7c08986000333f2c" +dependencies = [ + "async-task", + "backtrace", + "chrono", + "flume", + "futures", + "parking_lot", + "rand 0.9.4", + "web-time", +] + +[[package]] +name = "schemars" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2b42f36aa1cd011945615b92222f6bf73c599a102a300334cd7f8dbeec726cc" +dependencies = [ + "dyn-clone", + "indexmap", + "ref-cast", + "schemars_derive", + "serde", + "serde_json", +] + +[[package]] +name = "schemars_derive" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d115b50f4aaeea07e79c1912f645c7513d81715d0420f8bc77a18c6260b307f" +dependencies = [ + "proc-macro2", + "quote", + "serde_derive_internals", + "syn", +] + +[[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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "screencapturekit" +version = "0.2.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a5eeeb57ac94960cfe5ff4c402be6585ae4c8d29a2cf41b276048c2e849d64e" +dependencies = [ + "screencapturekit-sys", +] + +[[package]] +name = "screencapturekit-sys" +version = "0.2.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22411b57f7d49e7fe08025198813ee6fd65e1ee5eff4ebc7880c12c82bde4c60" +dependencies = [ + "block", + "dispatch", + "objc", + "objc-foundation", + "objc_id", + "once_cell", +] + +[[package]] +name = "seahash" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c107b6f4780854c8b126e228ea8869f4d7b71260f962fefb57b996b8959ba6b" + +[[package]] +name = "sec1" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc" +dependencies = [ + "base16ct", + "der", + "generic-array", + "pkcs8", + "subtle", + "zeroize", +] + +[[package]] +name = "security-framework" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags 2.13.0", + "core-foundation 0.10.0", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "self_cell" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b12e76d157a900eb52e81bc6e9f3069344290341720e9178cde2407113ac8d89" + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" +dependencies = [ + "serde", + "serde_core", +] + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde-big-array" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11fc7cc2c76d73e0f27ee52abbd64eec84d46f370c88371120433196934e4b7f" +dependencies = [ + "serde", +] + +[[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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_derive_internals" +version = "0.29.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18d26a20a969b9e3fdf2fc2d9f21eda6c40e2de84c9408bb5d3b05d499aae711" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_fmt" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e497af288b3b95d067a23a4f749f2861121ffcb2f6d8379310dcda040c345ed" +dependencies = [ + "serde_core", +] + +[[package]] +name = "serde_json" +version = "1.0.150" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" +dependencies = [ + "indexmap", + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_repr" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "175ee3e80ae9982737ca543e96133087cbd9a485eecc3bc4de9c1a37b47ea59c" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_spanned" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" +dependencies = [ + "serde", +] + +[[package]] +name = "serde_spanned" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26" +dependencies = [ + "serde_core", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "serde_yaml" +version = "0.9.34+deprecated" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47" +dependencies = [ + "indexmap", + "itoa", + "ryu", + "serde", + "unsafe-libyaml", +] + +[[package]] +name = "sha1" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest 0.10.7", +] + +[[package]] +name = "sha1" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aacc4cc499359472b4abe1bf11d0b12e688af9a805fa5e3016f9a386dc2d0214" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "digest 0.11.3", +] + +[[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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest 0.10.7", +] + +[[package]] +name = "sha2" +version = "0.11.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]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + +[[package]] +name = "shellexpand" +version = "3.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32824fab5e16e6c4d86dc1ba84489390419a39f97699852b66480bb87d297ed8" +dependencies = [ + "dirs", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "signature" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" +dependencies = [ + "digest 0.10.7", + "rand_core 0.6.4", +] + +[[package]] +name = "simd-adler32" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" + +[[package]] +name = "simd_cesu8" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94f90157bb87cddf702797c5dadfa0be7d266cdf49e22da2fcaa32eff75b2c33" +dependencies = [ + "rustc_version", + "simdutf8", +] + +[[package]] +name = "simd_helpers" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95890f873bec569a0362c235787f3aca6e1e887302ba4840839bcc6459c42da6" +dependencies = [ + "quote", +] + +[[package]] +name = "simdutf8" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" + +[[package]] +name = "simplecss" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a9c6883ca9c3c7c90e888de77b7a5c849c779d25d74a1269b0218b14e8b136c" +dependencies = [ + "log", +] + +[[package]] +name = "siphasher" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649" + +[[package]] +name = "skrifa" +version = "0.40.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7fbdfe3d2475fbd7ddd1f3e5cf8288a30eb3e5f95832829570cd88115a7434ac" +dependencies = [ + "bytemuck", + "read-fonts 0.37.0", +] + +[[package]] +name = "skrifa" +version = "0.42.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c34617370ae968efb7161bb2beb517d9084659aae19e24b89e3db25b46e4564" +dependencies = [ + "bytemuck", + "read-fonts 0.39.2", +] + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "slotmap" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bdd58c3c93c3d278ca835519292445cb4b0d4dc59ccfdf7ceadaab3f8aeb4038" +dependencies = [ + "version_check", +] + +[[package]] +name = "smallvec" +version = "1.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" +dependencies = [ + "serde", +] + +[[package]] +name = "smol" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a33bd3e260892199c3ccfc487c88b2da2265080acb316cd920da72fdfd7c599f" +dependencies = [ + "async-channel", + "async-executor", + "async-fs", + "async-io", + "async-lock", + "async-net", + "async-process", + "blocking", + "futures-lite", +] + +[[package]] +name = "smol_str" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4aaa7368fcf4852a4c2dd92df0cace6a71f2091ca0a23391ce7f3a31833f1523" +dependencies = [ + "borsh", + "serde_core", +] + +[[package]] +name = "smoothutf8" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36358427d32ecdb1624616deed99eccfef0a167fe5bf40ddb51efe6980bc1ec8" +dependencies = [ + "simdutf8", +] + +[[package]] +name = "smoothutf8" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b4ec95892483d6d94284caccfddb9b77111a4dcac33ed25d624248def0fa5f1" + +[[package]] +name = "socket2" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "spin" +version = "0.9.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" +dependencies = [ + "lock_api", +] + +[[package]] +name = "spin" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d5fe4ccb98d9c292d56fec89a5e07da7fc4cf0dc11e156b41793132775d3e591" +dependencies = [ + "lock_api", +] + +[[package]] +name = "spirv" +version = "0.4.0+sdk-1.4.341.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9571ea910ebd84c86af4b3ed27f9dbdc6ad06f17c5f96146b2b671e2976744f" +dependencies = [ + "bitflags 2.13.0", +] + +[[package]] +name = "spki" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" +dependencies = [ + "base64ct", + "der", +] + +[[package]] +name = "sqlite-wasm-rs" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc3efc0da82635d7e1ced0053bbbfa8c7ab9645d0bf36ceb4f7127bb85315d75" +dependencies = [ + "cc", + "js-sys", + "rsqlite-vfs", + "wasm-bindgen", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "stacker" +version = "0.1.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "640c8cdd92b6b12f5bcb1803ca3bbf5ab96e5e6b6b96b9ab77dabe9e880b3190" +dependencies = [ + "cc", + "cfg-if", + "libc", + "psm", + "windows-sys 0.61.2", +] + +[[package]] +name = "stacksafe" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d9c1172965d317e87ddb6d364a040d958b40a1db82b6ef97da26253a8b3d090" +dependencies = [ + "stacker", + "stacksafe-macro", +] + +[[package]] +name = "stacksafe-macro" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "172175341049678163e979d9107ca3508046d4d2a7c6682bee46ac541b17db69" +dependencies = [ + "proc-macro-error2", + "quote", + "syn", +] + +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + +[[package]] +name = "str_indices" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d08889ec5408683408db66ad89e0e1f93dff55c73a4ccc71c427d5b277ee47e6" + +[[package]] +name = "strict-num" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6637bab7722d379c8b41ba849228d680cc12d0a45ba1fa2b48f2a30577a06731" +dependencies = [ + "float-cmp", +] + +[[package]] +name = "string_cache" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf776ba3fa74f83bf4b63c3dcbbf82173db2632ed8452cb2d891d33f459de70f" +dependencies = [ + "new_debug_unreachable", + "parking_lot", + "phf_shared 0.11.3", + "precomputed-hash", + "serde", +] + +[[package]] +name = "string_cache_codegen" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c711928715f1fe0fe509c53b43e993a9a557babc2d0a3567d0a3006f1ac931a0" +dependencies = [ + "phf_generator 0.11.3", + "phf_shared 0.11.3", + "proc-macro2", + "quote", +] + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "strum" +version = "0.27.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af23d6f6c1a224baef9d3f61e287d2761385a5b88fdab4eb4c6f11aeb54c4bcf" +dependencies = [ + "strum_macros", +] + +[[package]] +name = "strum_macros" +version = "0.27.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7695ce3845ea4b33927c055a39dc438a45b059f7c1b3d91d38d10355fb8cbca7" +dependencies = [ + "heck 0.5.0", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "sum_tree" +version = "0.1.0" +source = "git+https://github.com/zed-industries/zed#c545fb67d0ce13e335bff76f7c08986000333f2c" +dependencies = [ + "heapless", + "log", + "rayon", + "tracing", + "ztracing", +] + +[[package]] +name = "sval" +version = "2.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fb9efbae90f97301f4d25f3be63dfd99d6b7af9d088228a52ec960d649b2e7d" + +[[package]] +name = "sval_buffer" +version = "2.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff5c0280ea0af40b3a1fd0b532680b5067482ffb81412ee66ec04b1d9952b49a" +dependencies = [ + "sval", + "sval_ref", +] + +[[package]] +name = "sval_dynamic" +version = "2.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59b9067f2e68f58e110cf8019a268057e3791cf74b0fdb1b3c7c6e49104f44e6" +dependencies = [ + "sval", +] + +[[package]] +name = "sval_fmt" +version = "2.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96ebdbf0e4b175884aa587fcf551c16dabe245ea04235abdd8cbbd160b27ed5a" +dependencies = [ + "itoa", + "ryu", + "sval", +] + +[[package]] +name = "sval_json" +version = "2.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e448d9fa216a6c16670b28d624fbbcf5c04e41eb187bd7c52e01222ffa72a12" +dependencies = [ + "itoa", + "ryu", + "sval", +] + +[[package]] +name = "sval_nested" +version = "2.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "021de5b5c26efd544c694cef9b8a9abe8633481bf3be1ea145a18a740818b291" +dependencies = [ + "sval", + "sval_buffer", + "sval_ref", +] + +[[package]] +name = "sval_ref" +version = "2.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "54ef5ffec8bc52ded04ee424ab8d959e25e64fd40a48a16d21f8991ce824c1bb" +dependencies = [ + "sval", +] + +[[package]] +name = "sval_serde" +version = "2.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b983833a8a2390f89ebcf9b9acd06883017014b4ffd72ee28e0c9de6852039f5" +dependencies = [ + "serde_core", + "sval", + "sval_nested", +] + +[[package]] +name = "svg_fmt" +version = "0.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0193cc4331cfd2f3d2011ef287590868599a2f33c3e69bc22c1a3d3acf9e02fb" + +[[package]] +name = "svgtypes" +version = "0.15.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68c7541fff44b35860c1a7a47a7cadf3e4a304c457b58f9870d9706ece028afc" +dependencies = [ + "kurbo 0.11.3", + "siphasher", +] + +[[package]] +name = "svgtypes" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "695b5790b3131dafa99b3bbfd25a216edb3d216dad9ca208d4657bfb8f2abc3d" +dependencies = [ + "kurbo 0.13.1", + "siphasher", +] + +[[package]] +name = "swash" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0811b01ca2c4e8718760713911feaf4675c24f94e50530a015ec646cfb622f7c" +dependencies = [ + "skrifa 0.42.1", + "yazi", + "zeno", +] + +[[package]] +name = "symphonia" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1758d6c853020a7244de03cc3e0185eaea3f58715122422dd3cc7452e6d4c16a" +dependencies = [ + "lazy_static", + "symphonia-bundle-mp3", + "symphonia-codec-aac", + "symphonia-codec-pcm", + "symphonia-core", + "symphonia-format-isomp4", + "symphonia-format-ogg", + "symphonia-metadata", +] + +[[package]] +name = "symphonia-bundle-mp3" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "350f1f2f2e19ad4dd315db94304d1eb361b29af070681f94e51b8fdaad769546" +dependencies = [ + "lazy_static", + "log", + "symphonia-core", +] + +[[package]] +name = "symphonia-codec-aac" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1979c515a76371b186aad2feff5f23e21cbec775bf95de08bf1e3af92a2ad76" +dependencies = [ + "lazy_static", + "log", + "symphonia-common", + "symphonia-core", +] + +[[package]] +name = "symphonia-codec-pcm" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50baee168f0e9dcf6ba7fc06e8b57eb62072a4490cc7cf13af77e72baae5d328" +dependencies = [ + "log", + "symphonia-core", +] + +[[package]] +name = "symphonia-common" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8257891ffa7f05e02b58f4761e2abf7e5278c8744fd59e981559e050f86eef55" +dependencies = [ + "log", + "symphonia-core", + "symphonia-metadata", +] + +[[package]] +name = "symphonia-core" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95ec293b5f288383b72a7bffcade6b2860b642cf66f28b3bd5967349a49938b1" +dependencies = [ + "bitflags 2.13.0", + "bytemuck", + "lazy_static", + "log", + "num-complex", + "smallvec", +] + +[[package]] +name = "symphonia-format-isomp4" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d179a01305b3505940135a9f0180d6ef4b487912748fe97554756f120fbd05e" +dependencies = [ + "log", + "symphonia-common", + "symphonia-core", + "symphonia-metadata", +] + +[[package]] +name = "symphonia-format-ogg" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b05a67e02b1e4fca1a261ba4fe06910a9357489ad8c36aafdd2960e9c6559433" +dependencies = [ + "log", + "symphonia-common", + "symphonia-core", + "symphonia-metadata", +] + +[[package]] +name = "symphonia-metadata" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a31acf5cd623398a6208e2225d18f4b20f761c55098a796a5247ad516a4a8681" +dependencies = [ + "lazy_static", + "log", + "regex-lite", + "smallvec", + "symphonia-core", +] + +[[package]] +name = "syn" +version = "2.0.118" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b9ae57f904213ebb649ce6895b8a66c66f0203b9319718f69a5612a065b1422" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "sys-locale" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8eab9a99a024a169fe8a903cf9d4a3b3601109bcc13bd9e3c6fff259138626c4" +dependencies = [ + "libc", +] + +[[package]] +name = "sysinfo" +version = "0.31.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "355dbe4f8799b304b05e1b0f05fc59b2a18d36645cf169607da45bde2f69a1be" +dependencies = [ + "core-foundation-sys", + "libc", + "memchr", + "ntapi", + "rayon", + "windows 0.57.0", +] + +[[package]] +name = "system-configuration" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c879d448e9d986b661742763247d3693ed13609438cf3d006f51f5368a5ba6b" +dependencies = [ + "bitflags 2.13.0", + "core-foundation 0.9.4", + "system-configuration-sys", +] + +[[package]] +name = "system-configuration-sys" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "taffy" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aea22054047c16c3f34d3ac473a2170be1424b1115b2a3adcf28cfb067c88859" +dependencies = [ + "arrayvec", + "grid", + "serde", + "slotmap", +] + +[[package]] +name = "tao-core-video-sys" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "271450eb289cb4d8d0720c6ce70c72c8c858c93dd61fc625881616752e6b98f6" +dependencies = [ + "cfg-if", + "core-foundation-sys", + "libc", + "objc", +] + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.3", + "once_cell", + "rustix 1.1.4", + "windows-sys 0.52.0", +] + +[[package]] +name = "tendril" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d24a120c5fc464a3458240ee02c299ebcb9d67b5249c8848b09d639dca8d7bb0" +dependencies = [ + "futf", + "mac", + "utf-8", +] + +[[package]] +name = "termcolor" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06794f8f6c5c898b3275aebefa6b8a1cb24cd2c6c79397ab15774837a0bc5755" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl 2.0.18", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "thread_local" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ad99c4c6d32803332c548b1af0540b357b3f5fc0be8f6c6bfe8b2e6ae784070" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "tiff" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b63feaf3343d35b6ca4d50483f94843803b0f51634937cc2ec519fc32232bc52" +dependencies = [ + "fax", + "flate2", + "half", + "quick-error", + "weezl", + "zune-jpeg 0.5.15", +] + +[[package]] +name = "time" +version = "0.3.53" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18dfaaeddcb932337b5e7866ee7d0ce9b76d2fd092997146f187ec09b4558a50" +dependencies = [ + "deranged", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" + +[[package]] +name = "time-macros" +version = "0.2.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c431b87111666e491a90baa837f914fb45cd5dc3c268591b0220ff5057f2085f" +dependencies = [ + "num-conv", + "time-core", +] + +[[package]] +name = "tiny-keccak" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c9d3793400a45f954c52e73d068316d76b6f4e36977e3fcebb13a2721e80237" +dependencies = [ + "crunchy", +] + +[[package]] +name = "tiny-skia" +version = "0.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83d13394d44dae3207b52a326c0c85a8bf87f1541f23b0d143811088497b09ab" +dependencies = [ + "arrayref", + "arrayvec", + "bytemuck", + "cfg-if", + "log", + "png 0.17.16", + "tiny-skia-path", +] + +[[package]] +name = "tiny-skia-path" +version = "0.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c9e7fc0c2e86a30b117d0462aa261b72b7a99b7ebd7deb3a14ceda95c5bdc93" +dependencies = [ + "arrayref", + "bytemuck", + "strict-num", +] + +[[package]] +name = "tinystr" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb4ebadaa0af04fab11ae01eb5f9fdb5f9c5b875506e210e71c07873528baa7f" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + +[[package]] +name = "tokio" +version = "1.52.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe" +dependencies = [ + "bytes", + "libc", + "mio", + "parking_lot", + "pin-project-lite", + "signal-hook-registry", + "socket2", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls", + "tokio", +] + +[[package]] +name = "tokio-socks" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7e2948f60dbe26b35f2c7fb74ac2854c1fddded0fe9d7548fcc674a246f7615" +dependencies = [ + "either", + "futures-util", + "thiserror 1.0.69", + "tokio", +] + +[[package]] +name = "tokio-util" +version = "0.7.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tokio-websockets" +version = "0.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d52efb639344a7c6adb8e62c6f3d2c19c001ff1b79a5041ba1c6ed42e19c6aa5" +dependencies = [ + "base64", + "bytes", + "futures-core", + "futures-sink", + "http", + "httparse", + "rand 0.10.2", + "ring", + "rustls-pki-types", + "simdutf8", + "tokio", + "tokio-rustls", + "tokio-util", +] + +[[package]] +name = "toml" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" +dependencies = [ + "serde", + "serde_spanned 0.6.9", + "toml_datetime 0.6.11", + "toml_edit 0.22.27", +] + +[[package]] +name = "toml" +version = "0.9.12+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf92845e79fc2e2def6a5d828f0801e29a2f8acc037becc5ab08595c7d5e9863" +dependencies = [ + "serde_core", + "serde_spanned 1.1.1", + "toml_datetime 0.7.5+spec-1.1.0", + "toml_parser", + "winnow 0.7.15", +] + +[[package]] +name = "toml" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81f3d15e84cbcd896376e6730314d59fb5a87f31e4b038454184435cd57defee" +dependencies = [ + "indexmap", + "serde_core", + "serde_spanned 1.1.1", + "toml_datetime 1.1.1+spec-1.1.0", + "toml_parser", + "toml_writer", + "winnow 1.0.3", +] + +[[package]] +name = "toml_datetime" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" +dependencies = [ + "serde", +] + +[[package]] +name = "toml_datetime" +version = "0.7.5+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92e1cfed4a3038bc5a127e35a2d360f145e1f4b971b551a2ba5fd7aedf7e1347" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_datetime" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_edit" +version = "0.22.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" +dependencies = [ + "indexmap", + "serde", + "serde_spanned 0.6.9", + "toml_datetime 0.6.11", + "toml_write", + "winnow 0.7.15", +] + +[[package]] +name = "toml_edit" +version = "0.25.12+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2153edc6955a6c354fad8f5efd38b6a8769bdccf9fe50f8e1329f81b0baa5d7" +dependencies = [ + "indexmap", + "toml_datetime 1.1.1+spec-1.1.0", + "toml_parser", + "winnow 1.0.3", +] + +[[package]] +name = "toml_parser" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" +dependencies = [ + "winnow 1.0.3", +] + +[[package]] +name = "toml_write" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" + +[[package]] +name = "toml_writer" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "756daf9b1013ebe47a8776667b466417e2d4c5679d441c26230efd9ef78692db" + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "log", + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" +dependencies = [ + "nu-ansi-term", + "sharded-slab", + "smallvec", + "thread_local", + "tracing-core", + "tracing-log", +] + +[[package]] +name = "triomphe" +version = "0.1.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b40688ea6389c8171614b25491f71d4a27946e0c7ce2da1c6de27e25abf1a0ae" +dependencies = [ + "arc-swap", + "serde", + "stable_deref_trait", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "ttf-parser" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2df906b07856748fa3f6e0ad0cbaa047052d4a7dd609e231c4f72cee8c36f31" +dependencies = [ + "core_maths", +] + +[[package]] +name = "typed-builder" +version = "0.23.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "31aa81521b70f94402501d848ccc0ecaa8f93c8eb6999eb9747e72287757ffda" +dependencies = [ + "typed-builder-macro", +] + +[[package]] +name = "typed-builder-macro" +version = "0.23.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "076a02dc54dd46795c2e9c8282ed40bcfb1e22747e955de9389a1de28190fb26" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "typeid" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc7d623258602320d5c55d1bc22793b57daff0ec7efc270ea7d55ce1d5f5471c" + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "uds_windows" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e" +dependencies = [ + "memoffset 0.9.1", + "tempfile", + "windows-sys 0.61.2", +] + +[[package]] +name = "unicase" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" + +[[package]] +name = "unicode-bidi" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5" + +[[package]] +name = "unicode-bidi-mirroring" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5dfa6e8c60bb66d49db113e0125ee8711b7647b5579dc7f5f19c42357ed039fe" + +[[package]] +name = "unicode-ccc" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce61d488bcdc9bc8b5d1772c404828b17fc481c0a582b5581e95fb233aef503e" + +[[package]] +name = "unicode-id" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70ba288e709927c043cbe476718d37be306be53fb1fafecd0dbe36d072be2580" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-linebreak" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b09c83c3c29d37506a3e260c08c03743a6bb66a9cd432c6934ab501a190571f" + +[[package]] +name = "unicode-properties" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d" + +[[package]] +name = "unicode-script" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "383ad40bb927465ec0ce7720e033cb4ca06912855fc35db31b5755d0de75b1ee" + +[[package]] +name = "unicode-segmentation" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" + +[[package]] +name = "unicode-vo" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1d386ff53b415b7fe27b50bb44679e2cc4660272694b7b6f3326d8480823a94" + +[[package]] +name = "unicode-width" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" + +[[package]] +name = "unicode-xid" +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 = "universal-hash" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4987bdc12753382e0bec4a65c50738ffaabc998b9cdd1f952fb5f39b0048a96" +dependencies = [ + "crypto-common 0.2.2", + "ctutils", +] + +[[package]] +name = "unsafe-libyaml" +version = "0.2.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861" + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "ureq" +version = "3.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dea7109cdcd5864d4eeb1b58a1648dc9bf520360d7af16ec26d0a9354bafcfc0" +dependencies = [ + "base64", + "log", + "percent-encoding", + "rustls", + "rustls-pki-types", + "ureq-proto", + "utf8-zero", + "webpki-roots", +] + +[[package]] +name = "ureq-proto" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e994ba84b0bd1b1b0cf92878b7ef898a5c1760108fe7b6010327e274917a808c" +dependencies = [ + "base64", + "http", + "httparse", + "log", +] + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "usvg" +version = "0.45.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "80be9b06fbae3b8b303400ab20778c80bbaf338f563afe567cf3c9eea17b47ef" +dependencies = [ + "base64", + "data-url", + "flate2", + "fontdb", + "imagesize 0.13.0", + "kurbo 0.11.3", + "log", + "pico-args", + "roxmltree 0.20.0", + "rustybuzz", + "simplecss", + "siphasher", + "strict-num", + "svgtypes 0.15.3", + "tiny-skia-path", + "unicode-bidi", + "unicode-script", + "unicode-vo", + "xmlwriter", +] + +[[package]] +name = "usvg" +version = "0.46.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e419dff010bb12512b0ae9e3d2f318dfbdf0167fde7eb05465134d4e8756076f" +dependencies = [ + "base64", + "data-url", + "flate2", + "fontdb", + "imagesize 0.14.0", + "kurbo 0.13.1", + "log", + "pico-args", + "roxmltree 0.21.1", + "rustybuzz", + "simplecss", + "siphasher", + "strict-num", + "svgtypes 0.16.1", + "tiny-skia-path", + "unicode-bidi", + "unicode-script", + "unicode-vo", + "xmlwriter", +] + +[[package]] +name = "utf-8" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" + +[[package]] +name = "utf8-zero" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8c0a043c9540bae7c578c88f91dda8bd82e59ae27c21baca69c8b191aaf5a6e" + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "util_macros" +version = "0.1.0" +source = "git+https://github.com/zed-industries/zed#c545fb67d0ce13e335bff76f7c08986000333f2c" +dependencies = [ + "perf", + "quote", + "syn", +] + +[[package]] +name = "uuid" +version = "1.23.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf80a72845275afea99e7f2b434723d3bc7e38470fcd1c7ed39a599c73319a53" +dependencies = [ + "getrandom 0.4.3", + "js-sys", + "serde_core", + "sha1_smol", + "wasm-bindgen", +] + +[[package]] +name = "v_frame" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "666b7727c8875d6ab5db9533418d7c764233ac9c0cff1d469aec8fa127597be2" +dependencies = [ + "aligned-vec", + "num-traits", + "wasm-bindgen", +] + +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + +[[package]] +name = "value-bag" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ba6f5989077681266825251a52748b8c1d8a4ad098cc37e440103d0ea717fc0" +dependencies = [ + "value-bag-serde1", + "value-bag-sval2", +] + +[[package]] +name = "value-bag-serde1" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16530907bfe2999a1773ca5900a65101e092c70f642f25cc23ca0c43573262c5" +dependencies = [ + "erased-serde", + "serde_core", + "serde_fmt", +] + +[[package]] +name = "value-bag-sval2" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d00ae130edd690eaa877e4f40605d534790d1cf1d651e7685bd6a144521b251f" +dependencies = [ + "sval", + "sval_buffer", + "sval_dynamic", + "sval_fmt", + "sval_json", + "sval_ref", + "sval_serde", +] + +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "vswhom" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be979b7f07507105799e854203b470ff7c78a1639e330a58f183b5fea574608b" +dependencies = [ + "libc", + "vswhom-sys", +] + +[[package]] +name = "vswhom-sys" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb067e4cbd1ff067d1df46c9194b5de0e98efd2810bbc95c5d5e5f25a3231150" +dependencies = [ + "cc", + "libc", +] + +[[package]] +name = "wacore" +version = "0.6.0" +dependencies = [ + "aes 0.9.1", + "aes-gcm 0.11.0", + "anyhow", + "async-channel", + "async-lock", + "async-trait", + "base64", + "bon", + "buffa", + "buffa-build", + "bytes", + "chrono", + "compact_str", + "ctr 0.10.1", + "event-listener", + "flate2", + "futures", + "hex", + "hkdf 0.13.0", + "hmac 0.13.0", + "itoa", + "log", + "md5", + "portable-atomic", + "rand 0.10.2", + "serde", + "serde-big-array", + "serde_json", + "sha1 0.11.0", + "sha2 0.11.0", + "smoothutf8 0.2.3", + "subtle", + "thiserror 2.0.18", + "typed-builder", + "wacore-appstate", + "wacore-binary", + "wacore-derive", + "wacore-libsignal", + "wacore-noise", + "waproto", + "zerocopy", +] + +[[package]] +name = "wacore-appstate" +version = "0.6.0" +dependencies = [ + "anyhow", + "buffa", + "bytemuck", + "hex", + "hkdf 0.13.0", + "hmac 0.13.0", + "log", + "serde", + "serde-big-array", + "serde_json", + "sha2 0.11.0", + "thiserror 2.0.18", + "wacore-binary", + "wacore-libsignal", + "waproto", +] + +[[package]] +name = "wacore-binary" +version = "0.6.0" +dependencies = [ + "bytes", + "compact_str", + "hashify", + "itoa", + "serde", + "serde_json", + "smallvec", + "smoothutf8 0.2.3", + "stable_deref_trait", + "yoke", + "zlib-rs", +] + +[[package]] +name = "wacore-derive" +version = "0.6.0" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "wacore-libsignal" +version = "0.6.0" +dependencies = [ + "aes 0.9.1", + "arrayref", + "async-lock", + "async-trait", + "buffa", + "bytes", + "cbc 0.2.1", + "chrono", + "ctr 0.10.1", + "curve25519-dalek 5.0.0", + "derive_more", + "displaydoc", + "ghash 0.6.0", + "hex", + "hkdf 0.13.0", + "hmac 0.13.0", + "log", + "rand 0.10.2", + "serde", + "sha1 0.11.0", + "sha2 0.11.0", + "subtle", + "thiserror 2.0.18", + "uuid", + "waproto", + "x25519-dalek 3.0.0", +] + +[[package]] +name = "wacore-noise" +version = "0.6.0" +dependencies = [ + "anyhow", + "buffa", + "bytes", + "hkdf 0.13.0", + "log", + "rand 0.10.2", + "sha2 0.11.0", + "thiserror 2.0.18", + "wacore-binary", + "wacore-libsignal", + "waproto", +] + +[[package]] +name = "waker-fn" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "317211a0dc0ceedd78fb2ca9a44aed3d7b9b26f81870d485c07122b4350673b7" + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + +[[package]] +name = "waproto" +version = "0.6.0" +dependencies = [ + "buffa", + "buffa-build", + "buffa-descriptor", + "bytes", + "heck 0.5.0", + "serde", + "sha2 0.11.0", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.4+wasi-0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b067c0c11094aef6b7a801c1e34a26affafdf3d051dba08456b868789aaf9a4" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.76" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c62df1340f32221cb9c54d6a27b030e3dba64361d4a95bed55f9aacb44da291d" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "167ce5e579f6bcf889c4f7175a8a5a585de84e8ff93976ce393efa5f2837aab1" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3997c7839262f4ef12cf90b818d6340c18e80f263f1a94bf157d0ec4420380e" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1b4cb0cc549fcf58d7dfc081778139b3d283a081644e833e84682ad71cea24" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wasm-streams" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15053d8d85c7eccdbefef60f06769760a563c7f0a9d6902a13d35c7800b0ad65" +dependencies = [ + "futures-util", + "js-sys", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "wasm_thread" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7516db7f32decdadb1c3b8deb1b7d78b9df7606c5cc2f6241737c2ab3a0258e" +dependencies = [ + "futures", + "js-sys", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "wayland-backend" +version = "0.3.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2857dd20b54e916ec7253b3d6b4d5c4d7d4ca2c33c2e11c6c76a99bd8744755d" +dependencies = [ + "cc", + "downcast-rs 1.2.1", + "rustix 1.1.4", + "scoped-tls", + "smallvec", + "wayland-sys", +] + +[[package]] +name = "wayland-client" +version = "0.31.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "645c7c96bb74690c3189b5c9cb4ca1627062bb23693a4fad9d8c3de958260144" +dependencies = [ + "bitflags 2.13.0", + "rustix 1.1.4", + "wayland-backend", + "wayland-scanner", +] + +[[package]] +name = "wayland-cursor" +version = "0.31.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a52d18780be9b1314328a3de5f930b73d2200112e3849ca6cb11822793fb34d" +dependencies = [ + "rustix 1.1.4", + "wayland-client", + "xcursor", +] + +[[package]] +name = "wayland-protocols" +version = "0.32.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23d0c813de3daa2ed6520af85a3bd49b0e722a3078506899aa9686fea58dc4b6" +dependencies = [ + "bitflags 2.13.0", + "wayland-backend", + "wayland-client", + "wayland-scanner", +] + +[[package]] +name = "wayland-protocols-plasma" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b6d8cf1eb2c1c31ed1f5643c88a6e53538129d4af80030c8cabd1f9fa884d91" +dependencies = [ + "bitflags 2.13.0", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-scanner", +] + +[[package]] +name = "wayland-protocols-wlr" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb04e52f7836d7c7976c78ca0250d61e33873c34156a2a1fc9474828ec268234" +dependencies = [ + "bitflags 2.13.0", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-scanner", +] + +[[package]] +name = "wayland-scanner" +version = "0.31.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c324a910fd86ebdc364a3e61ec1f11737d3b1d6c273c0239ee8ff4bc0d24b4a" +dependencies = [ + "proc-macro2", + "quick-xml 0.39.4", + "quote", +] + +[[package]] +name = "wayland-sys" +version = "0.31.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8eab23fefc9e41f8e841df4a9c707e8a8c4ed26e944ef69297184de2785e3be" +dependencies = [ + "dlib", + "log", + "once_cell", + "pkg-config", +] + +[[package]] +name = "web-sys" +version = "0.3.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8622dcb61c0bcc9fffa6938bed81210af2da9a7e4a1a834b2e37a59b6dfb6141" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "webpki-roots" +version = "1.0.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf85cb06032201fa7c6f829d7db5a7e5aa45bcc0655327713065f6f0576731bf" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "webrtc-data" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7ca42127ee64bcb71da36d151e6f87b12488c5f14c4f379e73d2d52a8e54aa0" +dependencies = [ + "bytes", + "log", + "portable-atomic", + "thiserror 1.0.69", + "tokio", + "webrtc-sctp", + "webrtc-util 0.17.1", +] + +[[package]] +name = "webrtc-dtls" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ccbe4d9049390ab52695c3646c1395c877e16c15fb05d3bda8eee0c7351711c" +dependencies = [ + "aes 0.8.4", + "aes-gcm 0.10.3", + "async-trait", + "bincode", + "byteorder", + "cbc 0.1.2", + "ccm", + "der-parser", + "hkdf 0.12.4", + "hmac 0.12.1", + "log", + "p256", + "p384", + "portable-atomic", + "rand 0.8.6", + "rand_core 0.6.4", + "rcgen", + "ring", + "rustls", + "sec1", + "serde", + "sha1 0.10.7", + "sha2 0.10.9", + "subtle", + "thiserror 1.0.69", + "tokio", + "webrtc-util 0.11.0", + "x25519-dalek 2.0.1", + "x509-parser", +] + +[[package]] +name = "webrtc-sctp" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ea6633bf951b3fd71eba3244731c8d0814f6a80300620a4370cad2983a5a42f" +dependencies = [ + "arc-swap", + "async-trait", + "bytes", + "crc", + "log", + "portable-atomic", + "rand 0.9.4", + "thiserror 1.0.69", + "tokio", + "webrtc-util 0.17.1", +] + +[[package]] +name = "webrtc-util" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64bfb10dbe6d762f80169ae07cf252bafa1f764b9594d140008a0231c0cdce58" +dependencies = [ + "async-trait", + "bitflags 1.3.2", + "bytes", + "ipnet", + "lazy_static", + "libc", + "log", + "nix", + "portable-atomic", + "rand 0.8.6", + "thiserror 1.0.69", + "tokio", + "winapi", +] + +[[package]] +name = "webrtc-util" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b65c1e0143a43d40f69e1d8c2ffc8734e379b49c06d45892ea4104c388bf9ead" +dependencies = [ + "async-trait", + "bitflags 1.3.2", + "bytes", + "ipnet", + "lazy_static", + "log", + "nix", + "portable-atomic", + "rand 0.9.4", + "thiserror 1.0.69", + "tokio", + "winapi", +] + +[[package]] +name = "weezl" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88" + +[[package]] +name = "wgpu" +version = "29.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76e8840e1ba2881d4cbb18d2147627a56af426ff064c0401eb0c8410c6325d07" +dependencies = [ + "arrayvec", + "bitflags 2.13.0", + "bytemuck", + "cfg-if", + "cfg_aliases", + "document-features", + "hashbrown 0.16.1", + "js-sys", + "log", + "naga", + "parking_lot", + "portable-atomic", + "profiling", + "raw-window-handle", + "smallvec", + "static_assertions", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "wgpu-core", + "wgpu-hal", + "wgpu-types", +] + +[[package]] +name = "wgpu-core" +version = "29.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f519832254e56965a9940c4af57dcb75f702b6f6fa4a0b172f685395843a4d7" +dependencies = [ + "arrayvec", + "bit-set", + "bit-vec", + "bitflags 2.13.0", + "bytemuck", + "cfg_aliases", + "document-features", + "hashbrown 0.16.1", + "indexmap", + "log", + "naga", + "once_cell", + "parking_lot", + "portable-atomic", + "profiling", + "raw-window-handle", + "rustc-hash 1.1.0", + "smallvec", + "thiserror 2.0.18", + "wgpu-core-deps-apple", + "wgpu-core-deps-emscripten", + "wgpu-core-deps-windows-linux-android", + "wgpu-hal", + "wgpu-naga-bridge", + "wgpu-types", +] + +[[package]] +name = "wgpu-core-deps-apple" +version = "29.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f5e39e26c4c0e07589e67d18546cf79ff45383659fc72fca4dd293358a0347f3" +dependencies = [ + "wgpu-hal", +] + +[[package]] +name = "wgpu-core-deps-emscripten" +version = "29.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01e09be551dc939498bdd5f6b2c66e55ab275dad25825267a08605a80fc9f0af" +dependencies = [ + "wgpu-hal", +] + +[[package]] +name = "wgpu-core-deps-windows-linux-android" +version = "29.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e592c1bbef6ad047647ae6e666ebd8cee7a32bb4544d9700ec96cbf73230257" +dependencies = [ + "wgpu-hal", +] + +[[package]] +name = "wgpu-hal" +version = "29.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97ace1c17727311c22a46e4e3faf56ea6de81af99dcc839bdfb54857b94d448d" +dependencies = [ + "android_system_properties", + "arrayvec", + "ash", + "bit-set", + "bitflags 2.13.0", + "block2 0.6.2", + "bytemuck", + "cfg-if", + "cfg_aliases", + "glow", + "glutin_wgl_sys", + "gpu-allocator", + "gpu-descriptor", + "hashbrown 0.16.1", + "js-sys", + "khronos-egl", + "libc", + "libloading", + "log", + "naga", + "ndk-sys", + "objc2 0.6.4", + "objc2-core-foundation", + "objc2-foundation 0.3.2", + "objc2-metal 0.3.2", + "objc2-quartz-core 0.3.2", + "once_cell", + "ordered-float", + "parking_lot", + "portable-atomic", + "portable-atomic-util", + "profiling", + "range-alloc", + "raw-window-handle", + "raw-window-metal", + "renderdoc-sys", + "smallvec", + "thiserror 2.0.18", + "wasm-bindgen", + "wayland-sys", + "web-sys", + "wgpu-naga-bridge", + "wgpu-types", + "windows 0.62.2", + "windows-core 0.62.2", + "windows-result 0.4.1", +] + +[[package]] +name = "wgpu-naga-bridge" +version = "29.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95226013f547544b223281cd16a4fb549aa9dcb562adbda0faae4c73ffbbc161" +dependencies = [ + "naga", + "wgpu-types", +] + +[[package]] +name = "wgpu-types" +version = "29.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "84bf84cd9ca8ca45e2b223a3868f1adf9bfc0c66aeac212e76ee7e40fdadf8f5" +dependencies = [ + "bitflags 2.13.0", + "bytemuck", + "js-sys", + "log", + "raw-window-handle", + "web-sys", +] + +[[package]] +name = "whatsapp-desktop" +version = "0.1.0" +dependencies = [ + "anyhow", + "async-channel", + "async-trait", + "base64", + "buffa", + "chrono", + "cpal", + "env_logger", + "gpui", + "gpui-component", + "gpui-component-assets", + "gpui_platform", + "image", + "indexmap", + "log", + "mp4", + "ogg", + "openh264", + "opus", + "portable-atomic", + "qrcode", + "rand 0.10.2", + "ringbuf", + "rust-embed", + "smallvec", + "smol", + "symphonia", + "tokio", + "wacore", + "wacore-binary", + "waproto", + "whatsapp-rust", + "whatsapp-rust-chat-store", + "whatsapp-rust-sqlite-storage", + "whatsapp-rust-tokio-transport", + "whatsapp-rust-ureq-http-client", +] + +[[package]] +name = "whatsapp-rust" +version = "0.6.0" +dependencies = [ + "anyhow", + "async-channel", + "async-lock", + "async-trait", + "base64", + "buffa", + "bytes", + "chrono", + "event-listener", + "futures", + "getrandom 0.4.3", + "hex", + "itoa", + "log", + "opus", + "portable-atomic", + "rand 0.10.2", + "rustls", + "scopeguard", + "serde", + "serde_json", + "thiserror 2.0.18", + "tokio", + "wacore", + "wacore-binary", + "waproto", + "webrtc-data", + "webrtc-dtls", + "webrtc-sctp", + "webrtc-util 0.11.0", + "webrtc-util 0.17.1", + "whatsapp-rust-sqlite-storage", + "whatsapp-rust-tokio-transport", + "whatsapp-rust-ureq-http-client", +] + +[[package]] +name = "whatsapp-rust-chat-store" +version = "0.1.0" +dependencies = [ + "buffa", + "chrono", + "diesel", + "diesel_migrations", + "log", + "serde_json", + "thiserror 2.0.18", + "tokio", + "wacore", + "wacore-binary", + "waproto", + "whatsapp-rust-sqlite-storage", +] + +[[package]] +name = "whatsapp-rust-sqlite-storage" +version = "0.6.0" +dependencies = [ + "async-trait", + "buffa", + "buffa-build", + "bytes", + "diesel", + "diesel_migrations", + "libsqlite3-sys", + "log", + "scheduled-thread-pool", + "serde_json", + "sha2 0.11.0", + "tokio", + "wacore", +] + +[[package]] +name = "whatsapp-rust-tokio-transport" +version = "0.6.0" +dependencies = [ + "anyhow", + "async-channel", + "async-trait", + "bytes", + "futures-util", + "http", + "log", + "rustls", + "tokio", + "tokio-rustls", + "tokio-websockets", + "wacore", + "webpki-roots", +] + +[[package]] +name = "whatsapp-rust-ureq-http-client" +version = "0.6.0" +dependencies = [ + "anyhow", + "async-trait", + "tokio", + "ureq", + "wacore", +] + +[[package]] +name = "which" +version = "6.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4ee928febd44d98f2f459a4a79bd4d928591333a494a10a868418ac1b39cf1f" +dependencies = [ + "either", + "home", + "rustix 0.38.44", + "winsafe", +] + +[[package]] +name = "wide" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfdfe6a32973f2d1b268b8895845a8a96cac2f0191e72c27cc929036060dbf89" +dependencies = [ + "bytemuck", + "safe_arch", +] + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.52.0", +] + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "windows" +version = "0.57.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12342cb4d8e3b046f3d80effd474a7a02447231330ef77d71daa6fbc40681143" +dependencies = [ + "windows-core 0.57.0", + "windows-targets 0.52.6", +] + +[[package]] +name = "windows" +version = "0.58.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd04d41d93c4992d421894c18c8b43496aa748dd4c081bac0dc93eb0489272b6" +dependencies = [ + "windows-core 0.58.0", + "windows-targets 0.52.6", +] + +[[package]] +name = "windows" +version = "0.61.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9babd3a767a4c1aef6900409f85f5d53ce2544ccdfaa86dad48c91782c6d6893" +dependencies = [ + "windows-collections 0.2.0", + "windows-core 0.61.2", + "windows-future 0.2.1", + "windows-link 0.1.3", + "windows-numerics 0.2.0", +] + +[[package]] +name = "windows" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "527fadee13e0c05939a6a05d5bd6eec6cd2e3dbd648b9f8e447c6518133d8580" +dependencies = [ + "windows-collections 0.3.2", + "windows-core 0.62.2", + "windows-future 0.3.2", + "windows-numerics 0.3.1", +] + +[[package]] +name = "windows-capture" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a4df73e95feddb9ec1a7e9c2ca6323b8c97d5eeeff78d28f1eccdf19c882b24" +dependencies = [ + "parking_lot", + "rayon", + "thiserror 2.0.18", + "windows 0.61.3", + "windows-future 0.2.1", +] + +[[package]] +name = "windows-collections" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3beeceb5e5cfd9eb1d76b381630e82c4241ccd0d27f1a39ed41b2760b255c5e8" +dependencies = [ + "windows-core 0.61.2", +] + +[[package]] +name = "windows-collections" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b2d95af1a8a14a3c7367e1ed4fc9c20e0a26e79551b1454d72583c97cc6610" +dependencies = [ + "windows-core 0.62.2", +] + +[[package]] +name = "windows-core" +version = "0.57.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2ed2439a290666cd67ecce2b0ffaad89c2a56b976b736e6ece670297897832d" +dependencies = [ + "windows-implement 0.57.0", + "windows-interface 0.57.0", + "windows-result 0.1.2", + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-core" +version = "0.58.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ba6d44ec8c2591c134257ce647b7ea6b20335bf6379a27dac5f1641fcf59f99" +dependencies = [ + "windows-implement 0.58.0", + "windows-interface 0.58.0", + "windows-result 0.2.0", + "windows-strings 0.1.0", + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-core" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0fdd3ddb90610c7638aa2b3a3ab2904fb9e5cdbecc643ddb3647212781c4ae3" +dependencies = [ + "windows-implement 0.60.2", + "windows-interface 0.59.3", + "windows-link 0.1.3", + "windows-result 0.3.4", + "windows-strings 0.4.2", +] + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement 0.60.2", + "windows-interface 0.59.3", + "windows-link 0.2.1", + "windows-result 0.4.1", + "windows-strings 0.5.1", +] + +[[package]] +name = "windows-future" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc6a41e98427b19fe4b73c550f060b59fa592d7d686537eebf9385621bfbad8e" +dependencies = [ + "windows-core 0.61.2", + "windows-link 0.1.3", + "windows-threading 0.1.0", +] + +[[package]] +name = "windows-future" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1d6f90251fe18a279739e78025bd6ddc52a7e22f921070ccdc67dde84c605cb" +dependencies = [ + "windows-core 0.62.2", + "windows-link 0.2.1", + "windows-threading 0.2.1", +] + +[[package]] +name = "windows-implement" +version = "0.57.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9107ddc059d5b6fbfbffdfa7a7fe3e22a226def0b2608f72e9d552763d3e1ad7" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-implement" +version = "0.58.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2bbd5b46c938e506ecbce286b6628a02171d56153ba733b6c741fc627ec9579b" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-interface" +version = "0.57.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29bee4b38ea3cde66011baa44dba677c432a78593e202392d1e9070cf2a7fca7" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-interface" +version = "0.58.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053c4c462dc91d3b1504c6fe5a726dd15e216ba718e84a0e46a88fbe5ded3515" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-link" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e6ad25900d524eaabdbbb96d20b4311e1e7ae1699af4fb28c17ae66c80d798a" + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-numerics" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9150af68066c4c5c07ddc0ce30421554771e528bde427614c61038bc2c92c2b1" +dependencies = [ + "windows-core 0.61.2", + "windows-link 0.1.3", +] + +[[package]] +name = "windows-numerics" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e2e40844ac143cdb44aead537bbf727de9b044e107a0f1220392177d15b0f26" +dependencies = [ + "windows-core 0.62.2", + "windows-link 0.2.1", +] + +[[package]] +name = "windows-registry" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4286ad90ddb45071efd1a66dfa43eb02dd0dfbae1545ad6cc3c51cf34d7e8ba3" +dependencies = [ + "windows-result 0.3.4", + "windows-strings 0.3.1", + "windows-targets 0.53.5", +] + +[[package]] +name = "windows-registry" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b8a9ed28765efc97bbc954883f4e6796c33a06546ebafacbabee9696967499e" +dependencies = [ + "windows-link 0.1.3", + "windows-result 0.3.4", + "windows-strings 0.4.2", +] + +[[package]] +name = "windows-result" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e383302e8ec8515204254685643de10811af0ed97ea37210dc26fb0032647f8" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-result" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d1043d8214f791817bab27572aaa8af63732e11bf84aa21a45a78d6c317ae0e" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-result" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56f42bd332cc6c8eac5af113fc0c1fd6a8fd2aa08a0119358686e5160d0586c6" +dependencies = [ + "windows-link 0.1.3", +] + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link 0.2.1", +] + +[[package]] +name = "windows-strings" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cd9b125c486025df0eabcb585e62173c6c9eddcec5d117d3b6e8c30e2ee4d10" +dependencies = [ + "windows-result 0.2.0", + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-strings" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87fa48cc5d406560701792be122a10132491cff9d0aeb23583cc2dcafc847319" +dependencies = [ + "windows-link 0.1.3", +] + +[[package]] +name = "windows-strings" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56e6c93f3a0c3b36176cb1327a4958a0353d5d166c2a35cb268ace15e91d3b57" +dependencies = [ + "windows-link 0.1.3", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link 0.2.1", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link 0.2.1", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm 0.52.6", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows-targets" +version = "0.53.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" +dependencies = [ + "windows-link 0.2.1", + "windows_aarch64_gnullvm 0.53.1", + "windows_aarch64_msvc 0.53.1", + "windows_i686_gnu 0.53.1", + "windows_i686_gnullvm 0.53.1", + "windows_i686_msvc 0.53.1", + "windows_x86_64_gnu 0.53.1", + "windows_x86_64_gnullvm 0.53.1", + "windows_x86_64_msvc 0.53.1", +] + +[[package]] +name = "windows-threading" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b66463ad2e0ea3bbf808b7f1d371311c80e115c0b71d60efc142cafbcfb057a6" +dependencies = [ + "windows-link 0.1.3", +] + +[[package]] +name = "windows-threading" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3949bd5b99cafdf1c7ca86b43ca564028dfe27d66958f2470940f73d86d75b37" +dependencies = [ + "windows-link 0.2.1", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_i686_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" + +[[package]] +name = "winnow" +version = "0.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" +dependencies = [ + "memchr", +] + +[[package]] +name = "winnow" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0592e1c9d151f854e6fd382574c3a0855250e1d9b2f99d9281c6e6391af352f1" +dependencies = [ + "memchr", +] + +[[package]] +name = "winreg" +version = "0.55.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb5a765337c50e9ec252c2069be9bf91c7df47afb103b642ba3a53bf8101be97" +dependencies = [ + "cfg-if", + "windows-sys 0.59.0", +] + +[[package]] +name = "winsafe" +version = "0.0.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d135d17ab770252ad95e9a872d365cf3090e3be864a34ab46f48555993efc904" + +[[package]] +name = "wio" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d129932f4644ac2396cb456385cbf9e63b5b30c6e8dc4820bdca4eb082037a5" +dependencies = [ + "winapi", +] + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "workspace-hack" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "beffa227304dbaea3ad6a06ac674f9bc83a3dec3b7f63eeb442de37e7cb6bb01" + +[[package]] +name = "writeable" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" + +[[package]] +name = "x11" +version = "2.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "502da5464ccd04011667b11c435cb992822c2c0dbde1770c988480d312a0db2e" +dependencies = [ + "libc", + "pkg-config", +] + +[[package]] +name = "x11-clipboard" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "662d74b3d77e396b8e5beb00b9cad6a9eccf40b2ef68cc858784b14c41d535a3" +dependencies = [ + "libc", + "x11rb", +] + +[[package]] +name = "x11rb" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9993aa5be5a26815fe2c3eacfc1fde061fc1a1f094bf1ad2a18bf9c495dd7414" +dependencies = [ + "as-raw-xcb-connection", + "gethostname", + "libc", + "rustix 1.1.4", + "x11rb-protocol", + "xcursor", +] + +[[package]] +name = "x11rb-protocol" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea6fc2961e4ef194dcbfe56bb845534d0dc8098940c7e5c012a258bfec6701bd" + +[[package]] +name = "x25519-dalek" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7e468321c81fb07fa7f4c636c3972b9100f0346e5b6a9f2bd0603a52f7ed277" +dependencies = [ + "curve25519-dalek 4.1.3", + "rand_core 0.6.4", + "serde", + "zeroize", +] + +[[package]] +name = "x25519-dalek" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7e8131a03190127fb2263afc72b322ecadae46b6ff8c6f399ff5d02f5559af6" +dependencies = [ + "curve25519-dalek 5.0.0", + "rand_core 0.10.1", + "zeroize", +] + +[[package]] +name = "x509-parser" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fcbc162f30700d6f3f82a24bf7cc62ffe7caea42c0b2cba8bf7f3ae50cf51f69" +dependencies = [ + "asn1-rs", + "data-encoding", + "der-parser", + "lazy_static", + "nom 7.1.3", + "oid-registry", + "rusticata-macros", + "thiserror 1.0.69", + "time", +] + +[[package]] +name = "xcb" +version = "1.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee4c580d8205abb0a5cf4eb7e927bd664e425b6c3263f9c5310583da96970cf6" +dependencies = [ + "bitflags 1.3.2", + "libc", + "quick-xml 0.30.0", + "x11", +] + +[[package]] +name = "xcursor" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bec9e4a500ca8864c5b47b8b482a73d62e4237670e5b5f1d6b9e3cae50f28f2b" + +[[package]] +name = "xim-ctext" +version = "0.3.0" +source = "git+https://github.com/zed-industries/xim-rs.git?rev=16f35a2c881b815a2b6cdfd6687988e84f8447d8#16f35a2c881b815a2b6cdfd6687988e84f8447d8" +dependencies = [ + "encoding_rs", +] + +[[package]] +name = "xim-parser" +version = "0.2.1" +source = "git+https://github.com/zed-industries/xim-rs.git?rev=16f35a2c881b815a2b6cdfd6687988e84f8447d8#16f35a2c881b815a2b6cdfd6687988e84f8447d8" +dependencies = [ + "bitflags 2.13.0", +] + +[[package]] +name = "xkbcommon" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d66ca9352cbd4eecbbc40871d8a11b4ac8107cfc528a6e14d7c19c69d0e1ac9" +dependencies = [ + "as-raw-xcb-connection", + "libc", + "memmap2", + "xkeysym", +] + +[[package]] +name = "xkeysym" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9cc00251562a284751c9973bace760d86c0276c471b4be569fe6b068ee97a56" + +[[package]] +name = "xml-rs" +version = "0.8.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ae8337f8a065cfc972643663ea4279e04e7256de865aa66fe25cec5fb912d3f" + +[[package]] +name = "xml5ever" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9bbb26405d8e919bc1547a5aa9abc95cbfa438f04844f5fdd9dc7596b748bf69" +dependencies = [ + "log", + "mac", + "markup5ever", +] + +[[package]] +name = "xmlwriter" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec7a2a501ed189703dba8b08142f057e887dfc4b2cc4db2d343ac6376ba3e0b9" + +[[package]] +name = "y4m" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a5a4b21e1a62b67a2970e6831bc091d7b87e119e7f9791aef9702e3bef04448" + +[[package]] +name = "yasna" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e17bb3549cc1321ae1296b9cdc2698e2b6cb1992adfa19a8c72e5b7a738f44cd" +dependencies = [ + "time", +] + +[[package]] +name = "yazi" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e01738255b5a16e78bbb83e7fbba0a1e7dd506905cfc53f4622d89015a03fbb5" + +[[package]] +name = "yeslogic-fontconfig-sys" +version = "6.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d8b8abf912b9a29ff112e1671c97c33636903d13a69712037190e6805af4f76" +dependencies = [ + "dlib", + "once_cell", + "pkg-config", +] + +[[package]] +name = "yoke" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zbus" +version = "5.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a28b97f866896a4be7aefd2b5a8e01bb6773d19a775d54ab28b4d094b9a4480e" +dependencies = [ + "async-broadcast", + "async-executor", + "async-io", + "async-lock", + "async-process", + "async-recursion", + "async-task", + "async-trait", + "blocking", + "enumflags2", + "event-listener", + "futures-core", + "futures-lite", + "hex", + "libc", + "ordered-stream", + "rustix 1.1.4", + "serde", + "serde_repr", + "tracing", + "uds_windows", + "uuid", + "windows-sys 0.61.2", + "winnow 1.0.3", + "zbus_macros", + "zbus_names", + "zvariant", +] + +[[package]] +name = "zbus-lockstep" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6998de05217a084b7578728a9443d04ea4cd80f2a0839b8d78770b76ccd45863" +dependencies = [ + "zbus_xml", + "zvariant", +] + +[[package]] +name = "zbus-lockstep-macros" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10da05367f3a7b7553c8cdf8fa91aee6b64afebe32b51c95177957efc47ca3a0" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "zbus-lockstep", + "zbus_xml", + "zvariant", +] + +[[package]] +name = "zbus_macros" +version = "5.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e05ad887425eecf5e8384dc2406a4a9313eb73468712fc1cdea362eb4fe0469" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn", + "zbus_names", + "zvariant", + "zvariant_utils", +] + +[[package]] +name = "zbus_names" +version = "4.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1039ca249fee9559680f3a9f05b55e0761fee51af4f6c1e7d8c1f31e549721d2" +dependencies = [ + "serde", + "winnow 1.0.3", + "zvariant", +] + +[[package]] +name = "zbus_xml" +version = "5.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ab0513c0a66a60a8718d5ad712a5094845a20d95b5c1c81e2bd8e904a52b4f" +dependencies = [ + "serde", + "winnow 1.0.3", + "zbus_names", + "zvariant", +] + +[[package]] +name = "zed-font-kit" +version = "0.14.1-zed" +source = "git+https://github.com/zed-industries/font-kit?rev=94b0f28166665e8fd2f53ff6d268a14955c82269#94b0f28166665e8fd2f53ff6d268a14955c82269" +dependencies = [ + "bitflags 2.13.0", + "byteorder", + "core-foundation 0.10.0", + "core-graphics 0.24.0", + "core-text", + "dirs", + "dwrote", + "float-ord", + "freetype-sys", + "lazy_static", + "libc", + "log", + "pathfinder_geometry", + "pathfinder_simd", + "walkdir", + "winapi", + "yeslogic-fontconfig-sys", +] + +[[package]] +name = "zed-reqwest" +version = "0.12.15-zed" +source = "git+https://github.com/zed-industries/reqwest.git?rev=c15662463bda39148ba154100dd44d3fba5873a4#c15662463bda39148ba154100dd44d3fba5873a4" +dependencies = [ + "base64", + "bytes", + "encoding_rs", + "futures-core", + "futures-util", + "h2", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "ipnet", + "js-sys", + "log", + "mime", + "mime_guess", + "once_cell", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-native-certs", + "rustls-pemfile", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "system-configuration", + "tokio", + "tokio-rustls", + "tokio-socks", + "tokio-util", + "tower", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-streams", + "web-sys", + "windows-registry 0.4.0", +] + +[[package]] +name = "zed-scap" +version = "0.0.8-zed" +source = "git+https://github.com/zed-industries/scap?rev=4afea48c3b002197176fb19cd0f9b180dd36eaac#4afea48c3b002197176fb19cd0f9b180dd36eaac" +dependencies = [ + "anyhow", + "cocoa 0.25.0", + "core-graphics-helmer-fork", + "log", + "objc", + "rand 0.8.6", + "screencapturekit", + "screencapturekit-sys", + "sysinfo", + "tao-core-video-sys", + "windows 0.61.3", + "windows-capture", + "x11", + "xcb", +] + +[[package]] +name = "zed-sum-tree" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d490156d0d7311855564d6e1d6dccab992405a0c0e15e1c8ef18920c02177e35" +dependencies = [ + "arrayvec", + "log", + "rayon", + "workspace-hack", +] + +[[package]] +name = "zed-xim" +version = "0.4.0-zed" +source = "git+https://github.com/zed-industries/xim-rs.git?rev=16f35a2c881b815a2b6cdfd6687988e84f8447d8#16f35a2c881b815a2b6cdfd6687988e84f8447d8" +dependencies = [ + "ahash", + "hashbrown 0.14.5", + "log", + "x11rb", + "xim-ctext", + "xim-parser", +] + +[[package]] +name = "zeno" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6df3dc4292935e51816d896edcd52aa30bc297907c26167fec31e2b0c6a32524" + +[[package]] +name = "zerocopy" +version = "0.8.54" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7cbbc0a705a0fd05cc3676525980d2bf5a9bc4adac6d6475209a7887cf59d19" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.54" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2e817b7b52d0c7358d3246da9d69935ebb18116b2b102b4230dac079b4862f5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +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", +] + +[[package]] +name = "zerotrie" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zlib-rs" +version = "0.6.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b142a20ec14a91d5bc708c1dc21b080c550113d8aa77afa29635673a65dd02c5" + +[[package]] +name = "zlog" +version = "0.1.0" +source = "git+https://github.com/zed-industries/zed#c545fb67d0ce13e335bff76f7c08986000333f2c" +dependencies = [ + "anyhow", + "chrono", + "collections", + "log", +] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" + +[[package]] +name = "ztracing" +version = "0.1.0" +source = "git+https://github.com/zed-industries/zed#c545fb67d0ce13e335bff76f7c08986000333f2c" +dependencies = [ + "tracing", + "tracing-subscriber", + "zlog", + "ztracing_macro", +] + +[[package]] +name = "ztracing_macro" +version = "0.1.0" +source = "git+https://github.com/zed-industries/zed#c545fb67d0ce13e335bff76f7c08986000333f2c" + +[[package]] +name = "zune-core" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f423a2c17029964870cfaabb1f13dfab7d092a62a29a89264f4d36990ca414a" + +[[package]] +name = "zune-core" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb8a0807f7c01457d0379ba880ba6322660448ddebc890ce29bb64da71fb40f9" + +[[package]] +name = "zune-inflate" +version = "0.2.54" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73ab332fe2f6680068f3582b16a24f90ad7096d5d39b974d1c0aff0125116f02" +dependencies = [ + "simd-adler32", +] + +[[package]] +name = "zune-jpeg" +version = "0.4.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29ce2c8a9384ad323cf564b67da86e21d3cfdff87908bc1223ed5c99bc792713" +dependencies = [ + "zune-core 0.4.12", +] + +[[package]] +name = "zune-jpeg" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27bc9d5b815bc103f142aa054f561d9187d191692ec7c2d1e2b4737f8dbd7296" +dependencies = [ + "zune-core 0.5.1", +] + +[[package]] +name = "zvariant" +version = "5.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cf057bb00bf5c9ad77abb6147b0ca4818236a1858416e9d988e40d6322fefa7" +dependencies = [ + "endi", + "enumflags2", + "serde", + "serde_bytes", + "winnow 1.0.3", + "zvariant_derive", + "zvariant_utils", +] + +[[package]] +name = "zvariant_derive" +version = "5.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8118ca6bda77bfc0ab51d660db0c955f2505eef854c9a449435bccb616933b31" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn", + "zvariant_utils", +] + +[[package]] +name = "zvariant_utils" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90cb9383f9b45290407a1258b202d3f8f01db719eb60b4e4055c6375af4fc7c7" +dependencies = [ + "proc-macro2", + "quote", + "serde", + "syn", + "winnow 1.0.3", +] diff --git a/apps/desktop/Cargo.toml b/apps/desktop/Cargo.toml new file mode 100644 index 000000000..83509042e --- /dev/null +++ b/apps/desktop/Cargo.toml @@ -0,0 +1,87 @@ +# Standalone workspace, excluded from the repository root workspace: the gpui +# dependency tree (and its [patch]) stays out of the library's Cargo.lock, CI +# and MSRV. The app tracks the library via path deps, so an API change in the +# lib breaks this build in the same PR that makes it. +[workspace] + +# Mirror the root workspace's safeguards (see /clippy.toml for the lists): +# wacore::time instead of std clocks, portable-atomic for 64-bit atomics. +[workspace.lints.clippy] +disallowed_methods = "deny" +disallowed_types = "deny" + +[package] +name = "whatsapp-desktop" +version = "0.1.0" +edition = "2024" +authors = ["João Lucas "] +license = "MIT" +description = "WhatsApp desktop client built on whatsapp-rust + GPUI" + +[[bin]] +name = "whatsapp-desktop" +path = "src/main.rs" + +[lints] +workspace = true + +[dependencies] +anyhow = "1.0" +async-channel = "2" +async-trait = "0.1" +base64 = "0.22" +buffa = "0.8" +chrono = { version = "0.4", default-features = false, features = ["std", "clock"] } + +# Audio capture/playback (PTT voice messages + calls) +cpal = "0.18" +env_logger = "0.11" + +# UI framework: GPUI upstream + gpui-component. gpui-component leaves its gpui +# dep unpinned (tracks zed HEAD), and cargo forbids [patch]-pinning a source to +# itself — so the rev pin lives in THIS workspace's committed Cargo.lock +# (`cargo update -p gpui --precise ` to move it). Same-source everywhere = +# one gpui in the tree, no fork. +gpui = { git = "https://github.com/zed-industries/zed", package = "gpui" } +gpui_platform = { git = "https://github.com/zed-industries/zed", package = "gpui_platform", features = ["font-kit", "x11", "wayland", "runtime_shaders"] } +gpui-component = { git = "https://github.com/longbridge/gpui-component.git", rev = "0775df394083c1ed74f36f846b78868d1267398f" } +gpui-component-assets = { git = "https://github.com/longbridge/gpui-component.git", rev = "0775df394083c1ed74f36f846b78868d1267398f" } + +image = { version = "0.25", default-features = false, features = ["png", "jpeg", "webp"] } +# Ordered maps for deterministic cache eviction +indexmap = "2" +log = "0.4" +mp4 = "0.14" +ogg = "0.9" +openh264 = "0.9" # H.264 software decoder +opus = "0.3" +qrcode = "0.14" +portable-atomic = "1" +rand = "0.10" +ringbuf = "0.5" +rust-embed = "8" +smallvec = "1" +# Async utilities (GPUI-compatible timers) +smol = "2" +# Audio processing (waveform generation) +symphonia = { version = "0.6", default-features = false, features = ["aac", "isomp4", "mp3", "ogg", "pcm"] } +tokio = { version = "1", features = ["macros", "rt-multi-thread", "sync", "time"] } + +# The library and its satellites, by path: always the checkout's version. +wacore = { path = "../../wacore" } +wacore-binary = { path = "../../wacore/binary" } +waproto = { path = "../../waproto" } +whatsapp-rust = { path = "../..", features = ["voip"] } +whatsapp-rust-chat-store = { path = "../../storages/chat-store", features = ["search"] } +whatsapp-rust-sqlite-storage = { path = "../../storages/sqlite-storage" } +whatsapp-rust-tokio-transport = { path = "../../transports/tokio-transport" } +whatsapp-rust-ureq-http-client = { path = "../../http_clients/ureq-client" } + +[profile.dev] +# gpui is unusably slow unoptimized; match zed's own dev profile for it. +[profile.dev.package.gpui] +opt-level = 3 + +[profile.release] +lto = "thin" +strip = true diff --git a/apps/desktop/README.md b/apps/desktop/README.md new file mode 100644 index 000000000..a10db0135 --- /dev/null +++ b/apps/desktop/README.md @@ -0,0 +1,258 @@ +# WhatsApp Desktop + +A native WhatsApp client built with [GPUI](https://github.com/zed-industries/zed/tree/main/crates/gpui) (the GPU-accelerated UI framework from Zed) that integrates with the `whatsapp-rust` library. + +## Why GPUI? + +- **GPU-accelerated rendering** via Vulkan/Metal/DX12 (using Blade graphics backend) +- **Reactive architecture** - UI only re-renders when state changes +- **Hybrid immediate/retained mode** - best of both worlds +- **Battle-tested** - powers the Zed code editor +- **Rich component library** via [gpui-component](https://longbridge.github.io/gpui-component/) + +## Architecture + +```text +apps/desktop/ +├── src/ +│ ├── main.rs # Entry point +│ ├── assets.rs # Embedded asset source (icons) +│ ├── responsive.rs # ResponsiveLayout: window-size-aware dimensions +│ ├── theme.rs # Colors and layout constants +│ ├── utils.rs # Shared helpers (time format, media scaling, MIME) +│ ├── app/ # WhatsAppApp: state, event handling, render dispatch +│ │ ├── mod.rs # Struct, UiEvent handling, playback/recording control +│ │ ├── calls.rs # Call UI state (incoming/outgoing/active) +│ │ ├── chats.rs # Chat list cache +│ │ ├── media/mod.rs # Active media playback state +│ │ └── messages.rs # Message list cache for VirtualList +│ ├── client/ # WhatsApp client wrapper +│ │ ├── mod.rs +│ │ └── whatsapp.rs # Tokio-side client, durable history, UiEvent bridge +│ ├── audio/ # PTT + call audio +│ │ ├── call_device.rs # cpal mic/speaker bridge for voice calls +│ │ ├── encoder.rs # Opus/OGG encoding for voice notes +│ │ ├── player.rs # Voice note playback +│ │ ├── recorder.rs # PTT capture +│ │ └── waveform.rs # Waveform generation +│ ├── video/ # Video message playback +│ │ ├── audio.rs # MP4 audio track extraction (AAC→ADTS) +│ │ ├── player.rs # Playback state machine +│ │ └── streaming.rs # H.264 decoding (openh264) +│ ├── state/ # Application state +│ │ ├── mod.rs +│ │ ├── app_state.rs # AppState enum (Loading, Connected, etc.) +│ │ ├── chat.rs # Chat, ChatMessage, MediaContent structs +│ │ ├── call.rs # IncomingCall, OutgoingCall, ActiveCall structs +│ │ └── events.rs # UiEvent enum for client->UI communication +│ ├── components/ # Reusable UI components +│ │ ├── mod.rs +│ │ ├── avatar.rs # Avatar with gpui-component +│ │ ├── call_popup.rs # Incoming call popup +│ │ ├── chat_header.rs # Chat header bar with call buttons +│ │ ├── chat_item.rs # Single chat in list +│ │ ├── chat_list.rs # Chat list sidebar with VirtualList +│ │ ├── input_area_view.rs # Message input + PTT recording controls +│ │ ├── message_bubble.rs # Message bubble with media support +│ │ ├── message_list.rs # Message list with VirtualList +│ │ └── outgoing_call_popup.rs # Outgoing/active call popup +│ └── views/ # Application views +│ ├── mod.rs +│ ├── loading.rs # Loading/connecting spinner views +│ ├── pairing.rs # QR code/pair code view +│ ├── error.rs # Error view with retry button +│ └── chat.rs # Main connected view +├── Cargo.toml +└── README.md +``` + +## Module Overview + +### `theme.rs` +Contains WhatsApp dark theme colors and layout constants: +- `colors::*` - Background, text, accent colors +- `layout::*` - Dimensions for sidebar, headers, avatars, etc. + +### `components/` +Reusable UI components built with GPUI and gpui-component: +- **Avatar** - User avatar with initials fallback (uses gpui-component) +- **ChatItem** - Single chat entry in the sidebar list +- **ChatList** - VirtualList-based scrollable chat sidebar +- **MessageBubble** - Message bubble with media (images, stickers, audio, docs) +- **MessageList** - VirtualList-based scrollable message area +- **ChatHeader** - Header bar showing chat name and avatar +- **InputArea** - Message input with send button + +### `views/` +Application views for different states: +- **Loading/Connecting** - Spinner with status message +- **Pairing** - QR code display and pair code +- **Error** - Error message with retry button +- **Chat** - Main connected view with sidebar and chat area + +### `app/` +Main application logic: +- `WhatsAppApp` struct with all state +- Event handling from WhatsApp client +- Render dispatch based on AppState +- Media playback and PTT recording control + +## GPUI Concepts + +### Render Trait + +Components implement the `Render` trait: + +```rust +impl Render for WhatsAppApp { + fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { + // Process events and dispatch to appropriate view + match &self.app_state { + AppState::Loading => render_loading_view(), + AppState::Connected => render_connected_view(self, window, cx), + // ... + } + } +} +``` + +### Reactive Updates + +GPUI uses `cx.notify()` to trigger re-renders when state changes: + +```rust +fn handle_event(&mut self, event: UiEvent, cx: &mut Context) { + match event { + UiEvent::Connected => { + self.app_state = AppState::Connected; + cx.notify(); // Trigger re-render + } + // ... + } +} +``` + +### VirtualList for Performance + +Large lists use VirtualList for efficient rendering: + +```rust +v_virtual_list( + entity, + "message-list", + item_sizes, + |_view, visible_range, _scroll_handle, _cx| { + visible_range + .map(|ix| render_message_bubble(messages[ix].clone())) + .collect() + }, +) +.track_scroll(&scroll_handle) +``` + +### Styling (Tailwind-like API) + +```rust +div() + .flex() + .flex_col() + .gap_4() + .p_4() + .bg(rgb(colors::BG_SECONDARY)) + .rounded(px(layout::RADIUS_MEDIUM)) + .text_color(rgb(colors::TEXT_PRIMARY)) +``` + +## Data Flow + +```text +┌─────────────────────────────────────────────────────────────────┐ +│ WhatsAppApp │ +│ │ +│ ┌─────────────────┐ ┌─────────────────────────────┐ │ +│ │ WhatsAppClient│────────►│ mpsc::UnboundedReceiver │ │ +│ │ (background │ UiEvent │ (polled via animation │ │ +│ │ thread) │ │ frame callback) │ │ +│ └─────────────────┘ └──────────────┬──────────────┘ │ +│ │ │ +│ ▼ │ +│ ┌──────────────────────────────┐ │ +│ │ handle_event() │ │ +│ │ - Updates app_state │ │ +│ │ - Calls cx.notify() │ │ +│ └──────────────┬───────────────┘ │ +│ │ │ +│ ▼ │ +│ ┌─────────────────────────────────────────────────────────┐ │ +│ │ render() │ │ +│ │ │ │ +│ │ match app_state: │ │ +│ │ Loading -> render_loading_view() │ │ +│ │ Connecting -> render_connecting_view() │ │ +│ │ Pairing -> render_pairing_view() │ │ +│ │ Connected -> render_connected_view() │ │ +│ │ Error -> render_error_view() │ │ +│ │ │ │ +│ └─────────────────────────────────────────────────────────┘ │ +└─────────────────────────────────────────────────────────────────┘ +``` + +## gpui-component + +We use the [gpui-component](https://longbridge.github.io/gpui-component/) library for pre-built UI components: + +- **Avatar** - User profile with fallback initials +- **Button** - Primary, secondary, danger variants +- **Input** - Text input with placeholder +- **Spinner** - Loading indicator +- **Scrollbar** - Custom scrollbar for lists +- **VirtualList** - Efficient scrollable lists +- And 50+ more components + +## Running + +```bash +# Development build +cargo run --manifest-path apps/desktop/Cargo.toml + +# Release build (optimized) +cargo run --release --manifest-path apps/desktop/Cargo.toml +``` + +## Dependencies + +- **gpui**: GPU-accelerated UI framework (from Zed) +- **gpui-component**: Pre-built UI components +- **tokio**: Async runtime +- **whatsapp-rust**: WhatsApp Web client library +- **chrono**: Date/time handling +- **log/env_logger**: Logging +- **image**: Image decoding (PNG, JPEG, WebP) + +## Features Status + +- [x] Basic app structure with GPUI +- [x] Loading/Connecting views with Spinner +- [x] Pairing view with QR code rendering +- [x] Connected view with chat layout +- [x] Error view with retry button +- [x] Event handling from WhatsApp client +- [x] Chat list with VirtualList +- [x] Message bubbles with media support +- [x] Image/sticker display +- [x] Video and voice note playback +- [x] PTT voice note recording +- [x] Input field for messages +- [x] Modular component architecture +- [x] Call UI (incoming/outgoing popups over the library's VoIP facade) +- [x] Durable chat history (SQLite via whatsapp-rust-chat-store) +- [x] Message sending status (pending, sent, delivered, read, failed) + +## Future Improvements + +- [x] Contact name resolution from address book +- [ ] Group chat features (participants, admin actions) +- [ ] Settings/preferences screen +- [ ] Notification system +- [ ] Video calls +- [ ] Theme customization diff --git a/apps/desktop/assets/icons/mic.svg b/apps/desktop/assets/icons/mic.svg new file mode 100644 index 000000000..4154dcfb6 --- /dev/null +++ b/apps/desktop/assets/icons/mic.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/apps/desktop/assets/icons/pause.svg b/apps/desktop/assets/icons/pause.svg new file mode 100644 index 000000000..89edb07ce --- /dev/null +++ b/apps/desktop/assets/icons/pause.svg @@ -0,0 +1,4 @@ + + + + diff --git a/apps/desktop/assets/icons/play.svg b/apps/desktop/assets/icons/play.svg new file mode 100644 index 000000000..0ce18748d --- /dev/null +++ b/apps/desktop/assets/icons/play.svg @@ -0,0 +1,3 @@ + + + diff --git a/apps/desktop/assets/icons/stop.svg b/apps/desktop/assets/icons/stop.svg new file mode 100644 index 000000000..47c0a8a47 --- /dev/null +++ b/apps/desktop/assets/icons/stop.svg @@ -0,0 +1,3 @@ + + + diff --git a/apps/desktop/src/app/calls.rs b/apps/desktop/src/app/calls.rs new file mode 100644 index 000000000..2391b4429 --- /dev/null +++ b/apps/desktop/src/app/calls.rs @@ -0,0 +1,122 @@ +//! Call state management for the WhatsApp UI +//! +//! This module manages incoming and outgoing call state. + +use crate::state::{CallId, IncomingCall, OutgoingCall, OutgoingCallState}; + +/// Unified call state management +#[derive(Default)] +pub struct CallState { + /// Current incoming call (if any) + incoming: Option, + /// Current outgoing call (if any) + outgoing: Option, +} + +impl CallState { + /// Create a new call state + pub fn new() -> Self { + Self::default() + } + + // ========== Incoming Call ========== + + /// Get the current incoming call (if any) + pub fn incoming(&self) -> Option<&IncomingCall> { + self.incoming.as_ref() + } + + /// Set an incoming call + pub fn set_incoming(&mut self, call: IncomingCall) { + // A second offer while one is still ringing would clobber the first + // and desync UI vs registry; ignore it (no multi-call UI yet). + if let Some(prev) = &self.incoming { + log::warn!( + "ignoring incoming call {} while {} is still pending", + call.call_id, + prev.call_id + ); + return; + } + self.incoming = Some(call); + } + + /// Take and clear the incoming call (for accept/decline) + pub fn take_incoming(&mut self) -> Option { + self.incoming.take() + } + + /// Dismiss incoming call if it matches the given call ID + pub fn dismiss_incoming(&mut self, call_id: &CallId) -> bool { + self.incoming.take_if(|c| c.call_id == *call_id).is_some() + } + + // ========== Outgoing Call ========== + + /// Get the current outgoing call (if any) + pub fn outgoing(&self) -> Option<&OutgoingCall> { + self.outgoing.as_ref() + } + + /// Get the current outgoing call mutably + pub fn outgoing_mut(&mut self) -> Option<&mut OutgoingCall> { + self.outgoing.as_mut() + } + + /// Check if there's an active outgoing call + pub fn has_outgoing(&self) -> bool { + self.outgoing.is_some() + } + + /// Check if there's an active incoming call + pub fn has_incoming(&self) -> bool { + self.incoming.is_some() + } + + /// Set an outgoing call + pub fn set_outgoing(&mut self, call: OutgoingCall) { + if let Some(prev) = &self.outgoing { + log::warn!( + "replacing outgoing call {} with {}", + prev.call_id, + call.call_id + ); + } + self.outgoing = Some(call); + } + + /// Take and clear the outgoing call (for cancel) + pub fn take_outgoing(&mut self) -> Option { + self.outgoing.take() + } + + /// Dismiss outgoing call if it matches the given call ID + pub fn dismiss_outgoing(&mut self, call_id: &CallId) -> bool { + self.outgoing.take_if(|c| c.call_id == *call_id).is_some() + } + + /// Update outgoing call state to connected + pub fn set_outgoing_connected(&mut self, call_id: &CallId) -> bool { + self.outgoing + .as_mut() + .filter(|c| c.call_id == *call_id) + .map(|c| c.set_state(OutgoingCallState::Connected)) + .is_some() + } + + /// Update outgoing call ID (when real ID comes from CallManager) + pub fn update_outgoing_call_id(&mut self, recipient_jid: &str, new_call_id: CallId) -> bool { + self.outgoing + .as_mut() + .filter(|c| c.recipient_jid == recipient_jid) + .map(|c| c.call_id = new_call_id) + .is_some() + } + + /// Dismiss outgoing call for a recipient (on failure) + pub fn dismiss_outgoing_for_recipient(&mut self, recipient_jid: &str) -> bool { + self.outgoing + .take_if(|c| c.recipient_jid == recipient_jid) + .is_some() + } +} diff --git a/apps/desktop/src/app/chats.rs b/apps/desktop/src/app/chats.rs new file mode 100644 index 000000000..5584995c9 --- /dev/null +++ b/apps/desktop/src/app/chats.rs @@ -0,0 +1,15 @@ +//! Chat list management types + +use std::sync::Arc; + +use crate::state::Chat; + +/// Cached data for chat list rendering to avoid recomputing on every frame. +/// Item sizes are computed at render time based on ResponsiveLayout. +#[derive(Clone)] +pub struct ChatListCache { + /// Chat count when cache was created (invalidation check) + pub chat_count: usize, + /// Shared chats reference (filtered if search is active) + pub chats: Arc<[Chat]>, +} diff --git a/apps/desktop/src/app/media/mod.rs b/apps/desktop/src/app/media/mod.rs new file mode 100644 index 000000000..853315b35 --- /dev/null +++ b/apps/desktop/src/app/media/mod.rs @@ -0,0 +1,28 @@ +//! Media handling for the WhatsApp UI +//! +//! This module contains types and utilities for media handling: +//! - `RecordingState`: PTT recording state machine +//! +//! Note: The actual recording/playback methods remain in `WhatsAppApp` for now +//! due to tight coupling with client, chat state, and UI updates. +//! Future refactoring could introduce a `MediaManager` that owns the +//! `AudioRecorder` and `AudioPlayer` with message-passing to the app. + +/// Recording state for PTT voice messages +#[derive(Default, Debug, Clone, Copy, PartialEq, Eq)] +pub enum RecordingState { + /// Not recording + #[default] + Idle, + /// Currently recording audio + Recording, + /// Processing recorded audio (encoding, sending) + Processing, +} + +impl RecordingState { + /// Check if currently recording + pub fn is_recording(&self) -> bool { + matches!(self, Self::Recording) + } +} diff --git a/apps/desktop/src/app/messages.rs b/apps/desktop/src/app/messages.rs new file mode 100644 index 000000000..7fe627e10 --- /dev/null +++ b/apps/desktop/src/app/messages.rs @@ -0,0 +1,144 @@ +//! Message list handling for the WhatsApp UI + +use std::rc::Rc; +use std::sync::Arc; + +use gpui::{Pixels, Size, px, size}; + +use crate::state::{ChatMessage, MediaType}; +use crate::theme::layout; +use crate::utils::scale_media_dimensions; + +/// Cached data for message list rendering to avoid recomputing on every frame. +#[derive(Clone)] +pub struct MessageListCache { + /// Message count when cache was created (invalidation check) + pub message_count: usize, + /// Group flag the sizes were computed with (invalidation check) + pub is_group: bool, + /// Media size cap the sizes were computed with (invalidation check) + pub max_media_size: f32, + /// Pre-computed item sizes for virtual list + pub item_sizes: Rc>>, + /// Pre-computed show_sender flags for message grouping + pub show_sender_flags: Arc<[bool]>, + /// Shared messages reference + pub messages: Arc<[ChatMessage]>, +} + +impl MessageListCache { + /// Create a new message list cache from messages. + /// `max_media_size` should come from ResponsiveLayout for correct sizing. + pub fn new(messages: &[ChatMessage], is_group: bool, max_media_size: f32) -> Self { + let messages_arc: Arc<[ChatMessage]> = Arc::from(messages); + + let show_sender_flags: Arc<[bool]> = Arc::from( + messages + .iter() + .enumerate() + .map(|(i, _)| should_show_sender(messages, i)) + .collect::>(), + ); + + let item_sizes: Rc>> = Rc::new( + messages + .iter() + .enumerate() + .map(|(i, msg)| { + size( + px(600.), + px(calculate_message_height( + msg, + show_sender_flags[i], + is_group, + max_media_size, + )), + ) + }) + .collect(), + ); + + Self { + message_count: messages.len(), + is_group, + max_media_size, + item_sizes, + show_sender_flags, + messages: messages_arc, + } + } +} + +/// Check if this message should show the sender name (for grouping) +pub fn should_show_sender(messages: &[ChatMessage], index: usize) -> bool { + if index == 0 { + return true; + } + let current = &messages[index]; + let previous = &messages[index - 1]; + current.sender != previous.sender || current.is_from_me != previous.is_from_me +} + +/// Calculate the height needed for a message bubble. +/// `show_sender` must be the same raw grouping flag the bubble renders with: +/// it drives the outer padding in every chat, while the sender-name line only +/// exists in groups. `max_media_size` should come from ResponsiveLayout. +pub fn calculate_message_height( + msg: &ChatMessage, + show_sender: bool, + is_group: bool, + max_media_size: f32, +) -> f32 { + let outer_top = if show_sender { + layout::MSG_PADDING_TOP_FIRST + } else { + layout::MSG_PADDING_TOP_GROUPED + }; + let mut height = outer_top + + layout::MSG_PADDING_BOTTOM + + (layout::MSG_BUBBLE_PADDING_Y * 2.0) + + layout::MSG_TIME_ROW_HEIGHT; + + let mut content_items = 1; + + if is_group && show_sender && msg.sender_name.is_some() && !msg.is_from_me { + height += layout::MSG_SENDER_NAME_HEIGHT; + content_items += 1; + } + + if let Some(media) = &msg.media { + let media_h = match media.media_type { + MediaType::Image | MediaType::Sticker | MediaType::Video => { + let (_, h) = scale_media_dimensions( + media.width.unwrap_or(300), + media.height.unwrap_or(300), + max_media_size, + ); + h + } + MediaType::Audio => 44.0, + MediaType::Document => 50.0, + }; + height += media_h; + content_items += 1; + } + + if !msg.content.is_empty() { + let char_count = msg.content.chars().count(); + let newlines = msg.content.matches('\n').count(); + let wrapped_lines = char_count.div_ceil(30); + let lines = (wrapped_lines + newlines).max(1); + height += lines as f32 * layout::MSG_TEXT_LINE_HEIGHT; + content_items += 1; + } + + if content_items > 1 { + height += (content_items - 1) as f32 * layout::MSG_CONTENT_GAP; + } + + if !msg.reactions.is_empty() { + height += layout::MSG_REACTION_MARGIN_TOP + layout::MSG_REACTION_HEIGHT; + } + + height +} diff --git a/apps/desktop/src/app/mod.rs b/apps/desktop/src/app/mod.rs new file mode 100644 index 000000000..b5fcb5347 --- /dev/null +++ b/apps/desktop/src/app/mod.rs @@ -0,0 +1,2283 @@ +//! Main WhatsApp application state and logic +//! +//! This module is being refactored into submodules for better organization: +//! - `chats`: Chat list management, selection, search, keyboard navigation +//! - `media`: Media handling (PTT recording state) +//! - `messages`: Message list caching and height calculation +//! - `calls`: Call state management (incoming/outgoing) + +mod calls; +mod chats; +mod media; +mod messages; + +pub use chats::ChatListCache; +pub use media::RecordingState; +pub use messages::MessageListCache; + +use calls::CallState; + +use std::cell::RefCell; +use std::collections::HashMap; +use std::sync::Arc; + +use indexmap::IndexMap; + +use gpui::{ + App, Context, Entity, FocusHandle, Focusable, Image, KeyBinding, ScrollStrategy, Task, + WeakEntity, Window, actions, prelude::*, +}; +use gpui_component::VirtualListScrollHandle; +use gpui_component::input::InputState; + +// Define our own actions since gpui-component's actions module is private +actions!(chat_list, [SelectUp, SelectDown]); + +use crate::components::{InputAreaEvent, InputAreaView}; +use log::{error, info, warn}; +use wacore_binary::jid::{Jid, JidExt, observe_str}; + +use crate::audio::{AudioPlayer, AudioRecorder, encode_to_opus_ogg, generate_waveform}; +use crate::client::{ReadBoundary, WhatsAppClient}; +use crate::responsive::{MobilePanel, ResponsiveLayout}; +use crate::state::{ + AppState, CachedQrCode, Chat, ChatMessage, DownloadableMedia, IncomingCall, MediaContent, + MediaType, OutgoingCall, ReceiptType, UiEvent, +}; +use crate::utils::mime_to_image_format; +use crate::video::{StreamingVideoDecoder, VideoPlayer, VideoPlayerState}; +use crate::views::pairing::generate_qr_png; +use crate::views::{ + render_connected_view, render_connecting_view, render_error_view, render_loading_view, + render_pairing_view, render_syncing_view, +}; + +// ChatListCache is now in chats.rs and re-exported above +// RecordingState is now in media/mod.rs and re-exported above +// MessageListCache is now in messages.rs and re-exported above + +/// Key context for chat list keyboard navigation +const CHAT_LIST_CONTEXT: &str = "ChatList"; + +/// Search debounce delay in milliseconds +const SEARCH_DEBOUNCE_MS: u64 = 150; + +/// Maximum number of video players to keep cached (each holds decoded frames) +const MAX_VIDEO_PLAYERS: usize = 10; + +/// Maximum number of sticker images to keep cached +const MAX_STICKER_IMAGES: usize = 50; + +/// Download timeout in seconds (for audio/video downloads) +const DOWNLOAD_TIMEOUT_SECS: u64 = 60; + +/// Download media with timeout - returns Ok(data) or Err(error message) +async fn download_with_timeout( + download_rx: tokio::sync::oneshot::Receiver, String>>, +) -> Result, String> { + let timeout = smol::Timer::after(std::time::Duration::from_secs(DOWNLOAD_TIMEOUT_SECS)); + let download = async { + download_rx + .await + .unwrap_or(Err("Download cancelled".to_string())) + }; + + // Race between download and timeout + smol::future::or(async { Some(download.await) }, async { + timeout.await; + None + }) + .await + .ok_or_else(|| "Download timed out".to_string())? +} + +/// Write a downloaded document into the user's Downloads directory +/// ($XDG_DOWNLOAD_DIR, then $HOME or %USERPROFILE% + /Downloads, then the CWD +/// like the database fallback when no home is known) and return the path +/// written. +fn save_to_downloads(file_name: &str, data: &[u8]) -> std::io::Result { + use std::io::Write; + use std::path::PathBuf; + + let not_empty = |v: std::ffi::OsString| (!v.is_empty()).then_some(PathBuf::from(v)); + let dir = std::env::var_os("XDG_DOWNLOAD_DIR") + .and_then(not_empty) + .or_else(|| { + std::env::var_os("HOME") + .and_then(not_empty) + .or_else(|| std::env::var_os("USERPROFILE").and_then(not_empty)) + .map(|home| home.join("Downloads")) + }) + .unwrap_or_else(|| PathBuf::from(".")); + std::fs::create_dir_all(&dir)?; + + // The name comes off the wire: strip path separators (and `:`, which on + // Windows makes a drive-relative path) so a hostile sender can't traverse + // out of the directory. + let sanitized: String = file_name + .chars() + .map(|c| { + if std::path::is_separator(c) || c == '\\' || c == ':' || c.is_control() { + '_' + } else { + c + } + }) + .collect(); + let name = match sanitized.trim() { + "" | "." | ".." => "document", + trimmed => trimmed, + }; + + // Windows treats device basenames (CON, NUL, COM1…) as reserved for any + // extension; prefix them so the save can't resolve to a device. + let stem = name + .split_once('.') + .map_or(name, |(stem, _)| stem) + .trim_end_matches([' ', '.']) + .to_ascii_uppercase(); + let reserved = matches!(stem.as_str(), "CON" | "PRN" | "AUX" | "NUL") + || (stem.len() == 4 + && (stem.starts_with("COM") || stem.starts_with("LPT")) + && stem.as_bytes()[3].is_ascii_digit()); + let name = if reserved { + format!("_{name}") + } else { + name.to_string() + }; + + // create_new + " (n)" suffixing so a download never clobbers an existing + // file of the same name. + for attempt in 0..1000u32 { + let candidate = if attempt == 0 { + name.to_string() + } else { + match name.rsplit_once('.') { + Some((stem, ext)) if !stem.is_empty() => format!("{stem} ({attempt}).{ext}"), + _ => format!("{name} ({attempt})"), + } + }; + let path = dir.join(candidate); + match std::fs::OpenOptions::new() + .write(true) + .create_new(true) + .open(&path) + { + Ok(mut file) => { + file.write_all(data)?; + return Ok(path); + } + Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => continue, + Err(e) => return Err(e), + } + } + Err(std::io::Error::new( + std::io::ErrorKind::AlreadyExists, + "too many downloads with the same name", + )) +} + +/// Currently active media playback (mutual exclusion: only one media at a time) +/// Animated stickers are excluded from this - they can play alongside audio/video. +#[derive(Clone, Debug, Default)] +enum ActiveMedia { + /// No media currently playing + #[default] + None, + /// Audio message playing (voice message, PTT) + Audio { message_id: String }, + /// Video message playing (includes video's audio track) + Video { message_id: String }, +} + +impl ActiveMedia { + /// Check if this is an audio message + fn is_audio(&self) -> bool { + matches!(self, Self::Audio { .. }) + } + + /// Check if this is a video message + fn is_video(&self) -> bool { + matches!(self, Self::Video { .. }) + } + + /// Get the message ID if any media is playing + fn message_id(&self) -> Option<&str> { + match self { + Self::None => None, + Self::Audio { message_id } | Self::Video { message_id } => Some(message_id), + } + } + + /// Check if the given message ID is currently playing + fn is_playing(&self, id: &str) -> bool { + self.message_id() == Some(id) + } +} + +/// Initialize chat list key bindings +pub fn init_chat_list_bindings(cx: &mut gpui::App) { + cx.bind_keys([ + KeyBinding::new("up", SelectUp, Some(CHAT_LIST_CONTEXT)), + KeyBinding::new("down", SelectDown, Some(CHAT_LIST_CONTEXT)), + ]); +} + +// Action to navigate back to chat list on mobile +actions!(mobile_nav, [NavigateBack]); + +/// Main application struct +pub struct WhatsAppApp { + /// Current application state + app_state: AppState, + /// List of chats + chats: Vec, + /// Currently selected chat JID + selected_chat: Option, + /// WhatsApp client wrapper + client: Option, + /// Scroll handle for chat list + chat_list_scroll: VirtualListScrollHandle, + /// Focus handle for chat list keyboard navigation + chat_list_focus: FocusHandle, + /// Search input state for chat list (created lazily when window is available) + chat_search_input: Option>, + /// Current search query (lowercase, trimmed) + chat_search_query: String, + /// Debounced search task + #[allow(dead_code)] + chat_search_task: Option>, + /// Scroll handle for message list + message_list_scroll: VirtualListScrollHandle, + /// Isolated input area view (has its own render cycle for performance) + input_area: Option>, + /// Chat a composing indicator was last sent to: paused must go back to + /// this chat even if the user switched chats before the typing timeout + composing_chat: Option, + /// Unsent input text stashed per chat on switch; the shared input view + /// would otherwise carry chat A's draft into chat B and send it there + drafts: HashMap, + /// Background task for event polling (must be retained) + #[allow(dead_code)] + event_task: Option>, + /// Audio recorder for PTT messages + audio_recorder: AudioRecorder, + /// Current recording state + recording_state: RecordingState, + /// Chat the current PTT recording started in; the note is sent there even + /// if the user switches chats before stopping + recording_chat: Option, + /// Audio player for voice message and video audio playback + audio_player: AudioPlayer, + /// Message ID of the audio currently loaded in audio_player (for ownership tracking) + /// This ensures we don't resume audio from a different video when switching + audio_owner: Option, + /// Currently active media (mutual exclusion: only one audio or video at a time) + active_media: ActiveMedia, + /// Message id of the most recent user-requested playback; download/decode + /// completions autoplay only if they still match it, so a stale download + /// can't steal playback from media the user started meanwhile. + pending_media_request: Option, + /// Call state (incoming and outgoing calls) + call_state: CallState, + /// Cache of JID -> display name mappings (from notify/pushname attribute) + name_cache: HashMap, + /// Video players for each message (message_id -> VideoPlayer) + video_players: HashMap, + /// Task for video frame updates + #[allow(dead_code)] + video_update_task: Option>, + /// Cache of sticker images (message_id -> Arc) for animation state preservation. + /// Uses RefCell for interior mutability since we need to cache during immutable render. + /// Uses IndexMap to maintain insertion order for deterministic FIFO eviction. + sticker_images: RefCell>>, + /// Cache of message list data per chat to avoid expensive recomputation on every render. + /// Key is the chat JID, value is the cached data. + message_list_cache: RefCell>, + /// Cache of chat list data to avoid recomputation on every render. + chat_list_cache: RefCell>, + /// Mobile navigation state - which panel to show on mobile devices + mobile_panel: MobilePanel, +} + +impl WhatsAppApp { + /// Spawn the event handling task that processes UI events from the WhatsApp client + fn spawn_event_task( + mut ui_rx: tokio::sync::mpsc::UnboundedReceiver, + cx: &mut Context, + ) -> Task<()> { + cx.spawn(async move |entity: WeakEntity, cx| { + while let Some(event) = ui_rx.recv().await { + let result = entity.update(cx, |app, cx| { + app.handle_event(event, cx); + }); + if result.is_err() { + // Entity was dropped, stop the loop + break; + } + } + }) + } + + /// Create a new WhatsApp application + pub fn new(cx: &mut Context) -> Self { + let bootstrap = WhatsAppClient::new().and_then(|mut client| { + let ui_rx = client.start().map_err(std::io::Error::other)?; + Ok((client, ui_rx)) + }); + let (app_state, client, event_task) = match bootstrap { + Ok((client, ui_rx)) => ( + AppState::Loading, + Some(client), + Some(Self::spawn_event_task(ui_rx, cx)), + ), + Err(e) => ( + AppState::Error(format!("Failed to start client: {e}")), + None, + None, + ), + }; + + Self { + app_state, + chats: Vec::new(), + selected_chat: None, + client, + chat_list_scroll: VirtualListScrollHandle::new(), + chat_list_focus: cx.focus_handle(), + chat_search_input: None, // Created lazily when window is available + chat_search_query: String::new(), + chat_search_task: None, + message_list_scroll: VirtualListScrollHandle::new(), + input_area: None, + composing_chat: None, + drafts: HashMap::new(), + event_task, + audio_recorder: AudioRecorder::new(), + recording_state: RecordingState::default(), + recording_chat: None, + audio_player: AudioPlayer::new(), + audio_owner: None, + active_media: ActiveMedia::None, + pending_media_request: None, + call_state: CallState::new(), + name_cache: HashMap::new(), + video_players: HashMap::new(), + video_update_task: None, + sticker_images: RefCell::new(IndexMap::new()), + message_list_cache: RefCell::new(HashMap::new()), + chat_list_cache: RefCell::new(None), + mobile_panel: MobilePanel::default(), + } + } + + // ========== Responsive Layout ========== + + /// Create a ResponsiveLayout from the current window viewport. + /// This should be called at the start of render to get all layout dimensions. + pub fn responsive_layout(&self, window: &Window) -> ResponsiveLayout { + ResponsiveLayout::new(window.viewport_size(), self.mobile_panel) + } + + /// Get the current mobile panel state + pub fn mobile_panel(&self) -> MobilePanel { + self.mobile_panel + } + + /// Navigate back to chat list (for mobile) + pub fn navigate_back(&mut self, cx: &mut Context) { + self.mobile_panel = MobilePanel::ChatList; + cx.notify(); + } + + /// Navigate to chat view (for mobile) - called when selecting a chat + fn navigate_to_chat(&mut self) { + self.mobile_panel = MobilePanel::Chat; + } + + // ========== Render Caches ========== + + /// Get or compute the chat list cache. + /// This avoids expensive recomputation of chat list data on every render. + /// Filters by search query if active. Item sizes are computed at render time + /// based on ResponsiveLayout. + pub fn get_chat_list_cache(&self) -> ChatListCache { + let mut cache = self.chat_list_cache.borrow_mut(); + + // Filter chats by search query + let filtered_chats: Vec<&Chat> = if self.chat_search_query.is_empty() { + self.chats.iter().collect() + } else { + self.chats + .iter() + .filter(|chat| { + chat.name.to_lowercase().contains(&self.chat_search_query) + || chat.jid.to_lowercase().contains(&self.chat_search_query) + }) + .collect() + }; + + // Check if we have a valid cache entry + if let Some(ref cached) = *cache + && cached.chat_count == filtered_chats.len() + { + return cached.clone(); + } + + // Compute new cache entry (item sizes computed at render time) + let chats_arc: Arc<[Chat]> = filtered_chats.into_iter().cloned().collect(); + + let new_cache = ChatListCache { + chat_count: chats_arc.len(), + chats: chats_arc, + }; + + *cache = Some(new_cache.clone()); + new_cache + } + + /// Invalidate chat list cache (call when chats change or search changes) + fn invalidate_chat_cache(&self) { + *self.chat_list_cache.borrow_mut() = None; + } + + // ========== Message List Cache ========== + + /// Get or compute the message list cache for a chat. + /// This avoids expensive recomputation of message heights on every render. + /// Uses interior mutability so it can be called during immutable render. + /// `max_media_size` should come from ResponsiveLayout for correct sizing. + pub fn get_message_list_cache( + &self, + chat_jid: &str, + messages: &[ChatMessage], + is_group: bool, + max_media_size: f32, + ) -> MessageListCache { + let mut cache = self.message_list_cache.borrow_mut(); + + // Check if we have a valid cache entry; heights depend on the layout + // inputs too, so a resize or group-flag change must recompute. + if let Some(cached) = cache.get(chat_jid) + && cached.message_count == messages.len() + && cached.is_group == is_group + && cached.max_media_size == max_media_size + { + return cached.clone(); + } + + // Compute new cache entry using the messages module + let new_cache = MessageListCache::new(messages, is_group, max_media_size); + cache.insert(chat_jid.to_string(), new_cache.clone()); + new_cache + } + + /// Invalidate message list cache for a chat (call when messages change) + fn invalidate_message_cache(&self, chat_jid: &str) { + self.message_list_cache.borrow_mut().remove(chat_jid); + } + + // ========== Accessors ========== + + /// Check if the client is connected + fn is_connected(&self) -> bool { + matches!(self.app_state, AppState::Connected) + } + + /// Get the selected chat JID + pub fn selected_chat_jid(&self) -> Option { + self.selected_chat.clone() + } + + /// Get the currently selected chat data + pub fn selected_chat_data(&self) -> Option<&Chat> { + self.selected_chat + .as_ref() + .and_then(|jid| self.find_chat(jid)) + } + + /// Find a chat by JID (immutable) + fn find_chat(&self, jid: &str) -> Option<&Chat> { + self.chats.iter().find(|c| c.jid == jid) + } + + /// Find a chat by JID (mutable) + fn find_chat_mut(&mut self, jid: &str) -> Option<&mut Chat> { + self.chats.iter_mut().find(|c| c.jid == jid) + } + + fn read_boundary(chat: &Chat) -> Option { + let last = chat.messages.last()?; + let ts_secs = last.timestamp.timestamp(); + let ids = chat + .messages + .iter() + .rev() + .take_while(|message| message.timestamp.timestamp() == ts_secs) + .map(|message| { + ( + message.id.clone(), + message.is_from_me, + (!message.is_from_me).then(|| message.sender.clone()), + ) + }) + .collect(); + Some((ts_secs, ids)) + } + + /// Update a message's media data (used to cache downloaded media) + fn update_message_media_data(&mut self, message_id: &str, data: Vec) { + // Find the message in any chat and update its media data + for chat in &mut self.chats { + if let Some(msg) = chat.messages.iter_mut().find(|m| m.id == message_id) { + if let Some(ref mut media) = msg.media { + media.data = Arc::new(data); + // Full bytes landed; the data no longer needs a re-download + media.data_is_preview = false; + // Decode with the real media's MIME, not the preview + // thumbnail's (a WebP sticker would fail as image/jpeg) + if let Some(ref dl) = media.downloadable { + media.mime_type = dl.mime_type.clone(); + } + // Drop any render-cached image built from the old bytes + self.sticker_images.borrow_mut().shift_remove(message_id); + info!("Cached media data for message {}", message_id); + // Invalidate message cache since we modified the message + self.message_list_cache.borrow_mut().remove(&chat.jid); + } + return; + } + } + } + + /// Add a message to a chat, bumping it to the top of the list only when + /// the message actually advances the chat (duplicates and older backfills + /// leave the ordering alone). + /// Returns true if the chat was found and updated, false otherwise. + fn add_message_to_chat(&mut self, jid: &str, message: ChatMessage) -> bool { + if let Some(index) = self.chats.iter().position(|c| c.jid == jid) { + if self.chats[index].add_message(message) { + self.move_chat_to_top(index); + } + // Always invalidate chat cache since the chat's content changed + // (even if it didn't move, the last message preview needs updating) + self.invalidate_chat_cache(); + // Also invalidate message cache for this chat + self.invalidate_message_cache(jid); + true + } else { + false + } + } + + /// Move a chat at the given index to the top of the list (index 0). + /// Does nothing if already at top. + fn move_chat_to_top(&mut self, index: usize) { + if index > 0 && index < self.chats.len() { + let chat = self.chats.remove(index); + self.chats.insert(0, chat); + // Note: chat cache invalidation is handled by the caller + } + } + + /// Get the chat list scroll handle + pub fn chat_list_scroll(&self) -> &VirtualListScrollHandle { + &self.chat_list_scroll + } + + /// Get the message list scroll handle + pub fn message_list_scroll(&self) -> &VirtualListScrollHandle { + &self.message_list_scroll + } + + /// Scroll to the last message in the currently selected chat. + /// Uses scroll_to_item with the actual message count (not scroll_to_bottom, + /// which relies on internal state that may be stale before paint). + fn scroll_to_last_message(&self) { + if let Some(ref jid) = self.selected_chat + && let Some(chat) = self.find_chat(jid) + && !chat.messages.is_empty() + { + self.message_list_scroll + .scroll_to_item(chat.messages.len() - 1, ScrollStrategy::Top); + } + } + + /// Get the isolated input area view entity + pub fn input_area(&self) -> Option> { + self.input_area.clone() + } + + /// Get the chat list focus handle + pub fn chat_list_focus(&self) -> &FocusHandle { + &self.chat_list_focus + } + + /// Get the chat search input entity + pub fn chat_search_input(&self) -> Option<&Entity> { + self.chat_search_input.as_ref() + } + + /// Ensure the chat search input is initialized + pub fn ensure_chat_search_input(&mut self, window: &mut Window, cx: &mut Context) { + use gpui_component::input::InputEvent; + + if self.chat_search_input.is_some() { + return; + } + + let search_input = cx.new(|cx| InputState::new(window, cx).placeholder("Search chats...")); + + // Subscribe to search input changes + cx.subscribe(&search_input, |this, input, event: &InputEvent, cx| { + if let InputEvent::Change = event { + let query = input.read(cx).value().to_string(); + this.set_chat_search(query, cx); + } + }) + .detach(); + + self.chat_search_input = Some(search_input); + } + + // ========== Chat List Navigation ========== + + /// Select the next chat in the list (keyboard navigation) + pub fn select_next_chat(&mut self, window: &mut Window, cx: &mut Context) { + let cache = self.get_chat_list_cache(); + if cache.chats.is_empty() { + return; + } + + let current_index = self + .selected_chat + .as_ref() + .and_then(|jid| cache.chats.iter().position(|c| &c.jid == jid)); + + let next_index = match current_index { + Some(idx) if idx + 1 < cache.chats.len() => idx + 1, + None => 0, + _ => return, // Already at the end + }; + + let next_jid = cache.chats[next_index].jid.clone(); + self.select_chat(next_jid, window, cx); + self.chat_list_scroll + .scroll_to_item(next_index, ScrollStrategy::Top); + } + + /// Select the previous chat in the list (keyboard navigation) + pub fn select_previous_chat(&mut self, window: &mut Window, cx: &mut Context) { + let cache = self.get_chat_list_cache(); + if cache.chats.is_empty() { + return; + } + + let current_index = self + .selected_chat + .as_ref() + .and_then(|jid| cache.chats.iter().position(|c| &c.jid == jid)); + + let prev_index = match current_index { + Some(idx) if idx > 0 => idx - 1, + None => cache.chats.len() - 1, + _ => return, // Already at the beginning + }; + + let prev_jid = cache.chats[prev_index].jid.clone(); + self.select_chat(prev_jid, window, cx); + self.chat_list_scroll + .scroll_to_item(prev_index, ScrollStrategy::Top); + } + + // ========== Chat Search ========== + + /// Update chat search query with debouncing + pub fn set_chat_search(&mut self, query: String, cx: &mut Context) { + // Cancel previous debounce task + self.chat_search_task = None; + + if query.is_empty() { + // Immediate clear + self.chat_search_query.clear(); + self.invalidate_chat_cache(); + cx.notify(); + return; + } + + // Debounce the actual filtering + let trimmed = query.trim().to_lowercase(); + self.chat_search_task = Some(cx.spawn(async move |entity: WeakEntity, cx| { + cx.background_executor() + .timer(std::time::Duration::from_millis(SEARCH_DEBOUNCE_MS)) + .await; + + let _ = entity.update(cx, |this, cx| { + this.chat_search_query = trimmed; + this.invalidate_chat_cache(); + cx.notify(); + }); + })); + } + + // ========== Actions ========== + + pub fn select_chat(&mut self, jid: String, window: &mut Window, cx: &mut Context) { + self.stop_current_media(); + // Leaving a chat mid-composition: release its typing indicator now, + // or it would stay "typing..." and the eventual paused would land on + // the newly selected chat instead. + if self.composing_chat.as_deref() != Some(jid.as_str()) + && let Some(prev) = self.composing_chat.take() + { + if let Some(client) = &self.client { + client.send_paused(&prev); + } + if let Some(ref input_area) = self.input_area { + input_area.update(cx, |view, _| view.reset_typing()); + } + } + // Stash the outgoing chat's unsent text and restore the target's, or + // the shared input would send A's draft to B. Skipped on reselect so + // in-progress text survives a same-chat click. + if self.selected_chat.as_deref() != Some(jid.as_str()) + && let Some(ref input_area) = self.input_area + { + let restored = self.drafts.remove(&jid).unwrap_or_default(); + let old = input_area.update(cx, |view, cx| view.swap_text(&restored, window, cx)); + if let Some(prev) = self.selected_chat.clone() + && !old.trim().is_empty() + { + self.drafts.insert(prev, old); + } + } + self.selected_chat = Some(jid.clone()); + self.navigate_to_chat(); + + // Collect unread messages from others to send read receipts + let unread_messages: Vec<(String, String)> = self + .find_chat(&jid) + .map(|chat| { + chat.messages + .iter() + .filter(|msg| !msg.is_from_me && !msg.is_read) + .map(|msg| (msg.id.clone(), msg.sender.clone())) + .collect() + }) + .unwrap_or_default(); + + // Send read receipts to WhatsApp + if !unread_messages.is_empty() { + info!( + "Sending read receipts for {} messages in {}", + unread_messages.len(), + observe_str(&jid) + ); + if let Some(client) = &self.client { + client.send_read_receipts(&jid, unread_messages); + } + } + + // The bounded action also covers unread rows not loaded by the UI. + if let Some(chat) = self + .find_chat(&jid) + .filter(|c| c.unread_count > 0 || c.manually_unread) + { + // Include same-second siblings so none can re-badge on hydration. + let last_displayed = Self::read_boundary(chat); + if let Some(client) = &self.client { + client.mark_chat_read(&jid, last_displayed); + } + } + + // Mark as read locally + if let Some(chat) = self.find_chat_mut(&jid) { + chat.mark_as_read(); + // Both caches: the badge, and the is_read snapshot the message + // list renders ticks from (its count guard can't see this). + self.invalidate_chat_cache(); + self.invalidate_message_cache(&jid); + } + + // Scroll to the last message + self.scroll_to_last_message(); + cx.notify(); + } + + /// Retry connection after an error + pub fn retry_connection(&mut self, cx: &mut Context) { + self.app_state = AppState::Loading; + + // Tear down the old client's background thread first; otherwise every + // retry stacks another live runtime + DB pool on the same file. + if let Some(client) = self.client.take() { + client.shutdown(); + } + self.event_task.take(); + + // A failed rebuild routes back to the error screen (retry stays + // available) instead of panicking the UI thread. + match WhatsAppClient::new() { + Ok(mut client) => match client.start() { + Ok(ui_rx) => { + self.event_task = Some(Self::spawn_event_task(ui_rx, cx)); + self.client = Some(client); + } + Err(e) => { + self.app_state = AppState::Error(format!("Failed to restart client: {e}")); + } + }, + Err(e) => { + self.app_state = AppState::Error(format!("Failed to restart client: {e}")); + } + } + cx.notify(); + } + + /// Initialize the isolated input area view (needs window context) + /// The InputAreaView has its own render cycle, so typing doesn't trigger app re-renders. + /// IMPORTANT: This method should NOT update the InputAreaView on every call, + /// as that would defeat the purpose of isolation. + pub fn ensure_input_area(&mut self, window: &mut Window, cx: &mut Context) { + if self.input_area.is_none() { + // Create the isolated input area view + let input_area = cx.new(|cx| InputAreaView::new(window, cx)); + + // Subscribe to events from the input area + cx.subscribe(&input_area, Self::handle_input_area_event) + .detach(); + + self.input_area = Some(input_area); + } + // NOTE: Do NOT call input_area.update() here - it would trigger re-renders + // on every parent render, defeating the purpose of component isolation. + // Recording state is updated via update_input_recording() when it changes. + } + + /// Update the recording state in the input area (call only when recording state changes) + fn update_input_recording(&self, cx: &mut Context) { + if let Some(ref input_area) = self.input_area { + let is_recording = self.is_recording(); + input_area.update(cx, |view, cx| { + view.set_recording(is_recording, cx); + }); + } + } + + /// Handle events from the isolated input area view + fn handle_input_area_event( + &mut self, + _input_area: Entity, + event: &InputAreaEvent, + cx: &mut Context, + ) { + match event { + InputAreaEvent::SendMessage(text) => { + self.send_message(text, cx); + } + InputAreaEvent::StartRecording => { + self.start_recording(cx); + } + InputAreaEvent::StopRecording => { + self.stop_recording_and_send(cx); + } + InputAreaEvent::StartedTyping => { + // Send "composing" presence + if let Some(jid) = &self.selected_chat { + self.composing_chat = Some(jid.clone()); + if let Some(client) = &self.client { + client.send_composing(jid); + } + } + } + InputAreaEvent::StoppedTyping => { + // Send "paused" presence to the chat the composing went to, + // not whatever chat is selected when the timeout fires + let target = self + .composing_chat + .take() + .or_else(|| self.selected_chat.clone()); + if let Some(jid) = target + && let Some(client) = &self.client + { + client.send_paused(&jid); + } + } + } + } + + /// Unique optimistic-bubble id: a millisecond timestamp alone collides on + /// fast double-sends (add_message would dedup one bubble away and + /// MessageIdAssigned could rename the wrong one). + fn next_local_id(prefix: &str) -> String { + use portable_atomic::AtomicU64; + use std::sync::atomic::Ordering; + static SEQ: AtomicU64 = AtomicU64::new(0); + format!( + "{prefix}_{}_{}", + wacore::time::now_millis(), + SEQ.fetch_add(1, Ordering::Relaxed) + ) + } + + /// Send a message to the currently selected chat + fn send_message(&mut self, text: &str, cx: &mut Context) { + // Check if connected before attempting to send + if !self.is_connected() { + warn!("Cannot send message: not connected"); + return; + } + + let Some(jid) = self.selected_chat.clone() else { + return; + }; + + let local_id = Self::next_local_id("local"); + let Some(client) = &self.client else { + warn!("Cannot send message: client is unavailable"); + return; + }; + client.send_message(&jid, text, local_id.clone()); + + // Add to local chat immediately for responsiveness; the client renames + // it to the real id via MessageIdAssigned. + let msg = ChatMessage::new_outgoing(local_id, text.to_string()); + if self.add_message_to_chat(&jid, msg) { + self.scroll_to_last_message(); + } + + cx.notify(); + } + + // ========== PTT Recording ========== + + /// Check if currently recording + pub fn is_recording(&self) -> bool { + self.recording_state.is_recording() + } + + /// Start audio recording for PTT + pub fn start_recording(&mut self, cx: &mut Context) { + if self.selected_chat.is_none() { + warn!("Cannot record: no chat selected"); + return; + } + + if self.recording_state != RecordingState::Idle { + warn!("Audio recording is already active"); + return; + } + + // Initialize and start recording + if let Err(e) = self.audio_recorder.init() { + error!("Failed to initialize audio recorder: {}", e); + return; + } + + if let Err(e) = self.audio_recorder.start() { + error!("Failed to start recording: {}", e); + return; + } + + self.recording_state = RecordingState::Recording; + // Bind the note to the chat it started in: resolving the destination + // at stop time would misdeliver if the user switches chats meanwhile. + self.recording_chat = self.selected_chat.clone(); + self.update_input_recording(cx); + info!("PTT recording started"); + cx.notify(); + } + + /// Stop recording and send the audio message + pub fn stop_recording_and_send(&mut self, cx: &mut Context) { + // Check if connected before attempting to send + if !self.is_connected() { + warn!("Cannot send audio: not connected"); + self.cancel_recording(cx); + return; + } + + if !self.is_recording() { + warn!("Not recording"); + return; + } + + let jid = match self.recording_chat.take() { + Some(jid) => jid, + None => { + warn!("No recording chat, cancelling recording"); + self.cancel_recording(cx); + return; + } + }; + + self.recording_state = RecordingState::Processing; + self.update_input_recording(cx); + cx.notify(); + + // Stop recording and get audio data + let recorded = match self.audio_recorder.stop() { + Ok(audio) => audio, + Err(e) => { + error!("Failed to stop recording: {}", e); + self.recording_state = RecordingState::Idle; + // Every abort path must reset the input area too, or it keeps + // rendering the recording UI forever. + self.update_input_recording(cx); + cx.notify(); + return; + } + }; + + // Check minimum duration (1 second) + if recorded.duration_secs < 1 { + warn!("Recording too short, discarding"); + self.recording_state = RecordingState::Idle; + self.update_input_recording(cx); + cx.notify(); + return; + } + + info!( + "Recording stopped: {} samples, {}s", + recorded.samples.len(), + recorded.duration_secs + ); + + let Some(runtime) = self.client.as_ref().map(WhatsAppClient::runtime) else { + warn!("Cannot send audio: client is unavailable"); + self.recording_state = RecordingState::Idle; + self.update_input_recording(cx); + cx.notify(); + return; + }; + cx.spawn(async move |entity: WeakEntity, cx| { + let encoded = runtime + .spawn_blocking(move || { + let waveform = generate_waveform(&recorded.samples); + encode_to_opus_ogg(&recorded) + .map(|ogg| (ogg, waveform, recorded.duration_secs)) + .map_err(|error| error.to_string()) + }) + .await + .unwrap_or_else(|error| Err(format!("encoder task failed: {error}"))); + let _ = entity.update(cx, |app, cx| { + app.finish_recording_send(jid, encoded, cx); + }); + }) + .detach(); + } + + fn finish_recording_send( + &mut self, + jid: String, + encoded: Result<(Vec, Vec, u32), String>, + cx: &mut Context, + ) { + let (ogg_data, waveform, duration_secs) = match encoded { + Ok(encoded) => encoded, + Err(error) => { + error!("Failed to encode audio: {error}"); + self.recording_state = RecordingState::Idle; + self.update_input_recording(cx); + cx.notify(); + return; + } + }; + + let Some(client) = &self.client else { + warn!("Cannot send audio: client is unavailable"); + self.recording_state = RecordingState::Idle; + self.update_input_recording(cx); + cx.notify(); + return; + }; + let local_id = Self::next_local_id("local_audio"); + client.send_audio_message( + &jid, + ogg_data.clone(), + duration_secs, + waveform, + local_id.clone(), + ); + + let msg = ChatMessage::new_outgoing_with_media( + local_id, + String::new(), + MediaContent { + media_type: MediaType::Audio, + data: Arc::new(ogg_data), + mime_type: "audio/ogg; codecs=opus".to_string(), + width: None, + height: None, + caption: None, + file_name: None, + downloadable: None, + is_animated: false, + duration_secs: Some(duration_secs), + data_is_preview: false, + }, + ); + + if self.add_message_to_chat(&jid, msg) { + self.scroll_to_last_message(); + } + self.recording_state = RecordingState::Idle; + self.update_input_recording(cx); + info!("PTT audio sent successfully"); + cx.notify(); + } + + /// Cancel recording without sending + pub fn cancel_recording(&mut self, cx: &mut Context) { + self.audio_recorder.cancel(); + self.recording_state = RecordingState::Idle; + self.recording_chat = None; + self.update_input_recording(cx); + info!("PTT recording cancelled"); + cx.notify(); + } + + // ========== Call State ========== + + /// Get the current incoming call (if any) + pub fn incoming_call(&self) -> Option<&IncomingCall> { + self.call_state.incoming() + } + + /// Get the current outgoing call (if any) + pub fn outgoing_call(&self) -> Option<&OutgoingCall> { + self.call_state.outgoing() + } + + /// Accept the incoming call + pub fn accept_call(&mut self, cx: &mut Context) { + let Some(client) = &self.client else { + warn!("Cannot accept call: client is unavailable"); + return; + }; + if let Some(call) = self.call_state.take_incoming() { + info!( + "Accepting call {} from {}", + call.call_id, + observe_str(&call.caller_jid) + ); + client.accept_call(call.call_id.as_str()); + cx.notify(); + } + } + + /// Decline the incoming call + pub fn decline_call(&mut self, cx: &mut Context) { + let Some(client) = &self.client else { + warn!("Cannot decline call: client is unavailable"); + return; + }; + if let Some(call) = self.call_state.take_incoming() { + info!( + "Declining call {} from {}", + call.call_id, + observe_str(&call.caller_jid) + ); + client.decline_call(call.call_id.as_str()); + cx.notify(); + } + } + + /// Start a call to the specified JID + pub fn start_call(&mut self, recipient_jid: String, is_video: bool, cx: &mut Context) { + let Some(client) = &self.client else { + warn!("Cannot start call: client is unavailable"); + return; + }; + + // Don't start a call if we already have an outgoing call + if self.call_state.has_outgoing() { + warn!("Already have an outgoing call in progress"); + return; + } + + // Don't start a call if there's an incoming call + if self.call_state.has_incoming() { + warn!("Cannot start a call while there's an incoming call"); + return; + } + + // Get the recipient name from the chat + let recipient_name = self + .find_chat(&recipient_jid) + .map(|chat| chat.name.clone()) + .unwrap_or_else(|| "Unknown contact".to_string()); + + info!( + "Starting {} call to {}", + if is_video { "video" } else { "audio" }, + observe_str(&recipient_jid) + ); + + // Create the outgoing call state + let placeholder_call_id = format!("ui-call-{}", wacore::time::now_millis()); + let call = OutgoingCall::new( + placeholder_call_id.clone(), + recipient_jid.clone(), + recipient_name, + is_video, + ); + self.call_state.set_outgoing(call); + + // Initiate the call through the client + client.start_call(&recipient_jid, is_video, placeholder_call_id); + + cx.notify(); + } + + /// Cancel the current outgoing call + pub fn cancel_outgoing_call(&mut self, cx: &mut Context) { + if let Some(call) = self.call_state.take_outgoing() { + info!("Cancelling outgoing call {}", call.call_id); + if let Some(client) = &self.client { + client.cancel_call(call.call_id.as_str()); + } + cx.notify(); + } + } + + // ========== Media Playback Control ========== + + /// Stop any currently playing media. Does NOT call cx.notify(). + fn stop_current_media(&mut self) { + self.audio_player.stop(); + self.audio_owner = None; + // An in-flight lazy download for the stopped media must not autoplay + // when it completes; user-initiated requests re-set this after the stop. + self.pending_media_request = None; + + if let ActiveMedia::Video { message_id } = &self.active_media { + if let Some(player) = self.video_players.get_mut(message_id) { + player.stop(); + } + self.video_update_task = None; + } + + self.active_media = ActiveMedia::None; + } + + /// Get the currently playing audio message ID (if audio is playing) + pub fn playing_message_id(&self) -> Option<&str> { + match &self.active_media { + // Gated on the stream so a paused voice note renders as paused; + // resume still works because toggle_audio matches on active_media. + ActiveMedia::Audio { message_id } if self.audio_player.is_playing() => Some(message_id), + _ => None, + } + } + + /// Get the currently playing video message ID (if video is playing) + pub fn playing_video_id(&self) -> Option<&str> { + match &self.active_media { + ActiveMedia::Video { message_id } => Some(message_id), + _ => None, + } + } + + // ========== Audio Playback ========== + + pub fn play_audio(&mut self, message_id: String, audio_data: Vec, cx: &mut Context) { + self.stop_current_media(); + self.pending_media_request = Some(message_id.clone()); + + let completion_rx = self.audio_player.on_complete(); + + match self.audio_player.play(audio_data) { + Ok(()) => { + self.audio_owner = Some(message_id.clone()); + self.active_media = ActiveMedia::Audio { + message_id: message_id.clone(), + }; + info!("Started audio playback for message {}", message_id); + + // Wait for completion event (no polling needed) + let completed_id = message_id; + cx.spawn(async move |entity: WeakEntity, cx| { + let _ = completion_rx.await; + + let _ = entity.update(cx, |app, cx| { + // Id check, not just is_audio: switching A -> B drops + // A's completion sender after B is active, and A's + // stale wakeup must not clear B's state. + if app.active_media.is_playing(&completed_id) { + app.active_media = ActiveMedia::None; + info!("Audio playback completed"); + cx.notify(); + } + }); + }) + .detach(); + } + Err(e) => { + error!("Failed to play audio: {}", e); + } + } + cx.notify(); + } + + /// Stop audio playback (only if audio is currently playing) + pub fn stop_audio(&mut self, cx: &mut Context) { + if self.active_media.is_audio() { + self.audio_player.stop(); + self.active_media = ActiveMedia::None; + cx.notify(); + } + } + + /// Toggle play/pause for the current audio + pub fn toggle_audio( + &mut self, + message_id: String, + audio_data: Vec, + cx: &mut Context, + ) { + if self.active_media.is_playing(&message_id) && self.active_media.is_audio() { + // Same message - toggle play/pause + if self.audio_player.is_playing() { + self.audio_player.pause(); + } else { + self.audio_player.resume(); + } + } else { + // Different message or not playing - play it + self.play_audio(message_id, audio_data, cx); + } + cx.notify(); + } + + /// Toggle audio playback with lazy loading (download first if needed) + pub fn toggle_audio_lazy( + &mut self, + message_id: String, + downloadable: DownloadableMedia, + cx: &mut Context, + ) { + // If already playing this audio message, just toggle + if self.active_media.is_playing(&message_id) && self.active_media.is_audio() { + if self.audio_player.is_playing() { + self.audio_player.pause(); + } else { + self.audio_player.resume(); + } + cx.notify(); + return; + } + + let Some(client) = &self.client else { + warn!("Cannot download audio: client is unavailable"); + return; + }; + let download_rx = client.download_downloadable_media(downloadable); + + // Stop any current playback, video included: a playing video must not + // keep running underneath the download. + self.stop_current_media(); + self.pending_media_request = Some(message_id.clone()); + + let msg_id = message_id.clone(); + + info!("Starting audio download for message {}", msg_id); + + // Spawn a GPUI task to await the download result with timeout + cx.spawn(async move |entity: WeakEntity, cx| { + match download_with_timeout(download_rx).await { + Ok(data) => { + info!("Audio downloaded: {} bytes", data.len()); + + // Cache the downloaded audio and play it + let _ = entity.update(cx, |app, cx| { + // Cache the audio data in the message so we don't need to download again + app.update_message_media_data(&msg_id, data.clone()); + // Autoplay only if the user hasn't started other media + // since this download began. + if app.pending_media_request.as_deref() == Some(msg_id.as_str()) { + app.play_audio(msg_id, data, cx); + } else { + cx.notify(); + } + }); + } + Err(e) => { + error!("Failed to download audio: {}", e); + } + } + }) + .detach(); + + cx.notify(); + } + + /// Fetch the full image for a bubble whose eager download failed and left + /// no thumbnail; mirrors the audio lazy-download path (no autoplay, the + /// cached bytes just render). + pub fn download_image( + &mut self, + message_id: String, + downloadable: DownloadableMedia, + cx: &mut Context, + ) { + let Some(client) = &self.client else { + warn!("Cannot download image: client is unavailable"); + return; + }; + let download_rx = client.download_downloadable_media(downloadable); + + cx.spawn(async move |entity: WeakEntity, cx| { + match download_with_timeout(download_rx).await { + Ok(data) => { + info!("Image downloaded: {} bytes", data.len()); + let _ = entity.update(cx, |app, cx| { + app.update_message_media_data(&message_id, data); + cx.notify(); + }); + } + Err(e) => { + error!("Failed to download image: {}", e); + } + } + }) + .detach(); + } + + /// Download a document and save it to the user's Downloads directory. + /// Documents open in external apps, so bytes on disk beat cached bytes. + pub fn download_document( + &mut self, + message_id: String, + file_name: String, + downloadable: DownloadableMedia, + cx: &mut Context, + ) { + let Some(client) = &self.client else { + warn!("Cannot download document: client is unavailable"); + return; + }; + let download_rx = client.download_downloadable_media(downloadable); + let runtime = client.runtime(); + + cx.spawn(async move |_entity: WeakEntity, _cx| { + match download_with_timeout(download_rx).await { + Ok(data) => { + let saved = runtime + .spawn_blocking(move || save_to_downloads(&file_name, &data)) + .await + .map_err(|error| std::io::Error::other(error.to_string())) + .and_then(|result| result); + match saved { + Ok(_) => info!("Document {} saved", message_id), + Err(e) => warn!("Failed to save document {}: {}", message_id, e), + } + } + Err(e) => error!("Failed to download document {}: {}", message_id, e), + } + }) + .detach(); + } + + // ========== Video Playback ========== + + /// Get the video player state for a message (if any) + pub fn video_player_state(&self, message_id: &str) -> Option { + self.video_players.get(message_id).map(|p| p.state()) + } + + /// Get current video frame for a message (if playing). + /// Returns an `Arc` — YUV→RGBA was already converted when + /// the frame was decoded (same pattern Zed uses on Linux). + pub fn video_current_frame(&self, message_id: &str) -> Option> { + self.video_players + .get(message_id) + .and_then(|p| p.current_frame()) + } + + /// Get or create a cached sticker image for animation state preservation. + /// The same Arc must be returned across renders for GPUI to track animation state. + /// Uses interior mutability (RefCell) so it can be called during immutable render. + pub fn get_sticker_image(&self, message_id: &str, data: &[u8], mime_type: &str) -> Arc { + // Check if already cached + if let Some(cached) = self.sticker_images.borrow().get(message_id).cloned() { + return cached; + } + + // Create and cache the image + let format = mime_to_image_format(mime_type); + let image = Arc::new(Image::from_bytes(format, data.to_vec())); + + let mut cache = self.sticker_images.borrow_mut(); + + // Evict oldest entries if cache is full (FIFO eviction using IndexMap insertion order) + while cache.len() >= MAX_STICKER_IMAGES { + // shift_remove removes from the front (oldest entry) + cache.shift_remove_index(0); + } + + cache.insert(message_id.to_string(), image.clone()); + image + } + + /// Toggle video playback for a message + pub fn toggle_video( + &mut self, + message_id: String, + downloadable: DownloadableMedia, + cx: &mut Context, + ) { + // Get player state first to determine action + let player_state = self.video_players.get(&message_id).map(|p| p.state()); + + match player_state { + Some(VideoPlayerState::Playing) => { + // Pause video and its audio + if let Some(player) = self.video_players.get_mut(&message_id) { + player.pause(); + } + self.audio_player.pause(); + self.active_media = ActiveMedia::None; + self.video_update_task = None; + } + Some(VideoPlayerState::Paused) => { + // Pausing cleared active_media, so is_playing alone can't + // tell "nothing else started" from "another media is live"; + // audio ownership does. Stopping here on resume would drop + // this video's own paused audio and it would come back mute. + let owns_audio = self.audio_owner.as_deref() == Some(message_id.as_str()); + if !self.active_media.is_playing(&message_id) && !owns_audio { + self.stop_current_media(); + } + self.pending_media_request = Some(message_id.clone()); + + let (needs_audio, audio_data) = + if let Some(player) = self.video_players.get_mut(&message_id) { + let resume_pos = player.current_time(); + let needs = player.play(); + let data = if needs { + player + .get_audio() + .map(|a| (a.samples.clone(), a.sample_rate)) + } else if !owns_audio { + // Another media's start stole the paused sink, so a + // plain resume() would leave this video silent; + // re-feed its audio from the pause position + // (samples are mono, so offset is seconds * rate). + player.get_audio().map(|a| { + let offset = ((resume_pos.as_secs_f64() * a.sample_rate as f64) + as usize) + .min(a.samples.len()); + (a.samples[offset..].to_vec(), a.sample_rate) + }) + } else { + None + }; + (needs, data) + } else { + return; + }; + + self.active_media = ActiveMedia::Video { + message_id: message_id.clone(), + }; + self.start_video_update_task(cx); + + if let Some((samples, sample_rate)) = audio_data { + info!( + "Playing video audio: {} samples at {} Hz", + samples.len(), + sample_rate + ); + if let Err(e) = self.audio_player.play_samples(samples, sample_rate) { + warn!("Failed to play video audio: {}", e); + } else { + // Ownership only on success: recording it for a dead + // sink would turn every later resume into a silent + // no-op resume(). + self.audio_owner = Some(message_id.clone()); + } + } else if !needs_audio && self.audio_owner.as_ref() == Some(&message_id) { + // Only resume if audio belongs to this video + self.audio_player.resume(); + } + } + Some(VideoPlayerState::Idle) | Some(VideoPlayerState::Error) => { + // Start downloading (or retry on error) + self.start_video_download(message_id, downloadable, cx); + } + Some(VideoPlayerState::Downloading) | Some(VideoPlayerState::Decoding) => { + // Already in progress, do nothing + } + None => { + // No player yet, start downloading + self.start_video_download(message_id, downloadable, cx); + } + } + cx.notify(); + } + + /// Start downloading a video for playback + fn start_video_download( + &mut self, + message_id: String, + downloadable: DownloadableMedia, + cx: &mut Context, + ) { + let Some(client) = &self.client else { + warn!("Cannot download video: client is unavailable"); + return; + }; + let download_rx = client.download_downloadable_media(downloadable); + let runtime = client.runtime(); + + // Stop any currently playing media (mutual exclusion) + self.stop_current_media(); + self.pending_media_request = Some(message_id.clone()); + + // Evict old video players if cache is full (excluding currently playing) + if self.video_players.len() >= MAX_VIDEO_PLAYERS { + // Remove players that aren't currently playing, up to half the limit + let current_playing = self.playing_video_id().map(|s| s.to_string()); + let to_remove: Vec<_> = self + .video_players + .keys() + .filter(|k| Some(*k) != current_playing.as_ref() && **k != message_id) + .take(MAX_VIDEO_PLAYERS / 2) + .cloned() + .collect(); + for key in to_remove { + self.video_players.remove(&key); + } + } + + // Create or get player and set to downloading + let player = self.video_players.entry(message_id.clone()).or_default(); + player.set_downloading(); + + let msg_id = message_id.clone(); + + // Spawn a GPUI task to await the download result with timeout + cx.spawn(async move |entity: WeakEntity, cx| { + match download_with_timeout(download_rx).await { + Ok(data) => { + info!("Video downloaded: {} bytes", data.len()); + + // Set to decoding state (quick UI update) + let _ = entity.update(cx, |app, cx| { + if let Some(player) = app.video_players.get_mut(&msg_id) { + player.set_decoding(); + } + cx.notify(); + }); + + let decode_result = match runtime + .spawn_blocking(move || StreamingVideoDecoder::new(&data)) + .await + { + Ok(result) => result, + Err(error) => Err(anyhow::anyhow!("video decoder task failed: {error}")), + }; + + // Update UI with decode results + let _ = entity.update(cx, |app, cx| { + match decode_result { + Ok(mut decoder) => { + info!( + "Video decoded: {} frames, {:.1}s", + decoder.frame_count(), + decoder.duration().as_secs_f64() + ); + + // Extract audio before loading decoder into player + let audio = decoder.take_audio(); + + if let Some(player) = app.video_players.get_mut(&msg_id) { + player.load(decoder); + + // Store audio in player for replay capability + if let Some(ref audio_data) = audio { + player.set_audio(audio_data.clone()); + } + // Don't call play() here - let the first frame render first + // so GPUI can decode the WebP image before playback starts + } + + // Invalidate message cache to force virtual list re-render + if let Some(ref jid) = app.selected_chat { + app.invalidate_message_cache(jid); + } + + // Schedule play() for the next frame to allow GPUI to decode the image + let msg_id_for_play = msg_id.clone(); + let audio_for_play = audio; + cx.spawn(async move |entity: WeakEntity, cx| { + // Wait one frame (~16ms at 60fps) for GPUI to decode the first frame + smol::Timer::after(std::time::Duration::from_millis(16)).await; + + let _ = entity.update(cx, |app, cx| { + // Skip autoplay when the user started + // other media during download/decode. + if app.pending_media_request.as_deref() + == Some(msg_id_for_play.as_str()) + && let Some(player) = + app.video_players.get_mut(&msg_id_for_play) + && player.state() == VideoPlayerState::Paused + { + let needs_audio = player.play(); + app.active_media = ActiveMedia::Video { + message_id: msg_id_for_play.clone(), + }; + app.start_video_update_task(cx); + + if needs_audio && let Some(audio) = audio_for_play { + info!( + "Playing video audio: {} samples at {} Hz", + audio.samples.len(), + audio.sample_rate + ); + if let Err(e) = app + .audio_player + .play_samples(audio.samples, audio.sample_rate) + { + warn!("Failed to play video audio: {}", e); + } else { + app.audio_owner = Some(msg_id_for_play.clone()); + } + } + } + cx.notify(); + }); + }) + .detach(); + } + Err(e) => { + error!("Failed to decode video: {}", e); + if let Some(player) = app.video_players.get_mut(&msg_id) { + player.set_error(e.to_string()); + } + } + } + cx.notify(); + }); + } + Err(e) => { + error!("Failed to download video: {}", e); + let _ = entity.update(cx, |app, cx| { + if let Some(player) = app.video_players.get_mut(&msg_id) { + player.set_error(e); + } + cx.notify(); + }); + } + } + }) + .detach(); + + cx.notify(); + } + + /// Start the video frame update task + fn start_video_update_task(&mut self, cx: &mut Context) { + // Cancel any existing task + self.video_update_task = None; + + // Get completion receiver from current video player + // Clone the message_id first to avoid borrow conflicts + let msg_id = self.playing_video_id().map(|s| s.to_string()); + let completion_rx = msg_id + .as_ref() + .and_then(|id| self.video_players.get_mut(id)) + .map(|player| player.on_complete()); + + // Spawn update loop (~30 fps) with completion handling + self.video_update_task = Some(cx.spawn(async move |entity: WeakEntity, cx| { + // Create a fused future for completion (handles None case) + let mut completion_rx = completion_rx; + + loop { + // Check for completion event (non-blocking) + if let Some(ref mut rx) = completion_rx { + // Try to receive without blocking + match rx.try_recv() { + Ok(()) => { + // Video completed naturally + let _ = entity.update(cx, |app, cx| { + app.active_media = ActiveMedia::None; + app.video_update_task = None; + app.audio_player.stop(); + cx.notify(); + }); + break; + } + Err(tokio::sync::oneshot::error::TryRecvError::Closed) => { + // Channel closed (player dropped or stopped manually) + break; + } + Err(tokio::sync::oneshot::error::TryRecvError::Empty) => { + // Not completed yet, continue updating frames + } + } + } + + // Wait for next frame (~30 fps) + smol::Timer::after(std::time::Duration::from_millis(33)).await; + + // Update frame + let should_stop = entity + .update(cx, |app, cx| { + // Clone message_id first to avoid borrow conflicts + let msg_id = app.playing_video_id().map(|s| s.to_string()); + if let Some(ref id) = msg_id + && let Some(player) = app.video_players.get_mut(id) + { + if player.update() { + cx.notify(); + } + // Continue as long as we're in Playing state + return player.state() != VideoPlayerState::Playing; + } + true // Stop if no playing video + }) + .unwrap_or(true); + + if should_stop { + let _ = entity.update(cx, |app, cx| { + app.active_media = ActiveMedia::None; + app.video_update_task = None; + app.audio_player.stop(); + cx.notify(); + }); + break; + } + } + })); + } + + // ========== Event Handling ========== + + /// Handle a single UI event + fn handle_event(&mut self, event: UiEvent, cx: &mut Context) { + match event { + UiEvent::InitComplete => { + self.app_state = AppState::Connecting; + cx.notify(); + } + UiEvent::HistoryLoaded { chats, complete } => { + info!("Loaded {} chats from durable history", chats.len()); + // Prune only against a COMPLETE load: there absence means the + // chat was archived/deleted (possibly on another device), so + // it must leave the UI too. A truncated load can't distinguish + // that from a chat that merely fell past the window, so it + // never prunes. The selected chat is spared either way so the + // open conversation isn't yanked mid-view. Live-only chats + // (`!from_store`) are spared too: during initial pairing the + // store is still empty while live messages already populate + // the UI, and an early reload (e.g. a push-name commit) sends + // a complete-but-empty load that must not wipe them — whereas + // a store-originated chat missing from a complete load really + // was deleted/archived. (Skipping empty loads instead would + // break clearing the last chat deleted on another device.) + if complete { + let loaded: std::collections::HashSet<&str> = + chats.iter().map(|c| c.jid.as_str()).collect(); + self.chats.retain(|c| { + !c.from_store + || loaded.contains(c.jid.as_str()) + || self.selected_chat.as_deref() == Some(c.jid.as_str()) + }); + } + for chat in chats { + // Later loads (post-HistorySync re-hydration) fold into + // chats the UI already shows instead of being dropped. + match self.chats.iter_mut().find(|c| c.jid == chat.jid) { + Some(existing) => { + let jid = chat.jid.clone(); + existing.merge_history(chat); + // The open chat was read locally the moment the + // message arrived; the store row commits with the + // unread bump before our receipt lands, so the + // hydrated counter must not resurrect the badge. + if self.selected_chat.as_deref() == Some(jid.as_str()) { + existing.mark_as_read(); + } + self.invalidate_message_cache(&jid); + } + None => self.chats.push(chat), + } + } + self.chats + .sort_by_key(|c| std::cmp::Reverse(c.last_message_time)); + // Count-based cache guards can't see reordering/merges. + self.invalidate_chat_cache(); + cx.notify(); + } + UiEvent::QrCode { code, timeout_secs } => { + let pair_code = match &self.app_state { + AppState::WaitingForPairing { pair_code, .. } => pair_code.clone(), + _ => None, + }; + let cached_qr = generate_qr_png(&code).map(|png_bytes| CachedQrCode { + data: code, + png_bytes: Arc::new(png_bytes), + }); + self.app_state = AppState::WaitingForPairing { + qr_code: cached_qr, + pair_code, + timeout_secs, + }; + cx.notify(); + } + UiEvent::PairCode { code, timeout_secs } => { + let qr_code = match &self.app_state { + AppState::WaitingForPairing { qr_code, .. } => qr_code.clone(), + _ => None, + }; + self.app_state = AppState::WaitingForPairing { + qr_code, + pair_code: Some(code), + timeout_secs, + }; + cx.notify(); + } + UiEvent::PairSuccess => { + self.app_state = AppState::Syncing; + cx.notify(); + } + UiEvent::Connected => { + self.app_state = AppState::Connected; + cx.notify(); + } + UiEvent::Disconnected(reason) => { + self.app_state = AppState::Error(reason); + cx.notify(); + } + UiEvent::Error(msg) => { + self.app_state = AppState::Error(msg); + cx.notify(); + } + UiEvent::MessageReceived { + chat_jid, + message, + sender_name, + } => { + self.handle_message_received(chat_jid, *message, sender_name); + cx.notify(); + } + UiEvent::MessageIdAssigned { + chat_jid, + local_id, + message_id, + } => { + if let Some(chat) = self.find_chat_mut(&chat_jid) { + // Re-insert, not mutate in place: messages sort by + // (timestamp, id) and the rename can reorder same-second + // siblings. + chat.rename_message(&local_id, &message_id); + } + self.invalidate_message_cache(&chat_jid); + cx.notify(); + } + UiEvent::SendFailed { + chat_jid, + message_id, + reason, + } => { + warn!( + "Send failed for {} in {}: {}", + message_id, + observe_str(&chat_jid), + reason + ); + if let Some(chat) = self.find_chat_mut(&chat_jid) + && let Some(msg) = chat.messages.iter_mut().find(|m| m.id == message_id) + { + msg.failed = true; + self.invalidate_message_cache(&chat_jid); + cx.notify(); + } + } + UiEvent::ReceiptReceived { + chat_jid, + message_ids, + receipt_type, + } => { + self.handle_receipt_received(chat_jid, message_ids, receipt_type); + cx.notify(); + } + UiEvent::ReactionReceived { + chat_jid, + message_id, + sender, + emoji, + } => { + self.handle_reaction_received(chat_jid, message_id, sender, emoji); + cx.notify(); + } + UiEvent::IncomingCall(mut call) => { + if let Some(name) = self + .find_chat(&call.caller_jid) + .map(|chat| chat.name.clone()) + { + call.caller_name = name; + } + info!( + "Incoming {} call from {}", + if call.is_video { "video" } else { "audio" }, + observe_str(&call.caller_jid) + ); + self.call_state.set_incoming(call); + cx.notify(); + } + UiEvent::CallAccepted(call_id) => { + info!("Call {} accepted by peer", call_id); + // Dismiss the incoming call popup if it matches + let incoming_dismissed = self.call_state.dismiss_incoming(&call_id); + // For outgoing calls, transition to Connected state + let outgoing_connected = self.call_state.set_outgoing_connected(&call_id); + if outgoing_connected { + info!("Outgoing call {} is now connected", call_id); + } + if incoming_dismissed || outgoing_connected { + cx.notify(); + } + } + UiEvent::CallEnded(call_id) => { + info!("Call {} ended", call_id); + // Dismiss the incoming call popup if it matches + let incoming_dismissed = self.call_state.dismiss_incoming(&call_id); + // Also dismiss outgoing call if it matches + let outgoing_dismissed = self.call_state.dismiss_outgoing(&call_id); + if incoming_dismissed || outgoing_dismissed { + cx.notify(); + } + } + UiEvent::OutgoingCallStarted { + call_id, + recipient_jid, + } => { + info!( + "Outgoing call started: {} to {}", + call_id, + observe_str(&recipient_jid) + ); + // Update the outgoing call with the actual call ID from CallManager + if self + .call_state + .update_outgoing_call_id(&recipient_jid, call_id.clone()) + { + cx.notify(); + } else { + // Popup already dismissed: the user cancelled while the + // call was connecting; hang up the now-real call. + if let Some(client) = &self.client { + client.cancel_call(&call_id); + } + } + } + UiEvent::OutgoingCallFailed { + recipient_jid, + error, + } => { + warn!( + "Outgoing call to {} failed: {}", + observe_str(&recipient_jid), + error + ); + // Dismiss the outgoing call popup + if self + .call_state + .dismiss_outgoing_for_recipient(&recipient_jid) + { + cx.notify(); + } + } + } + } + + /// Handle a received message + fn handle_message_received( + &mut self, + chat_jid: String, + mut message: ChatMessage, + sender_name: Option, + ) { + // Parse JID to determine chat type + let jid = chat_jid.parse::().ok(); + let is_group = jid.as_ref().is_some_and(|j| j.is_group()); + let is_status = jid.as_ref().is_some_and(|j| j.is_status_broadcast()); + + // A message landing in the currently open chat is read immediately: + // receipt out now, no badge (select_chat won't re-run to send it). + let read_now = (!message.is_from_me + && self.selected_chat.as_deref() == Some(chat_jid.as_str())) + .then(|| (message.id.clone(), message.sender.clone())); + + // Cache the sender's name if provided + if let Some(ref name) = sender_name { + self.name_cache.insert(message.sender.clone(), name.clone()); + } + + // For group chats, set sender_name on the message for display + if is_group && !message.is_from_me { + message.sender_name = sender_name + .clone() + .or_else(|| self.name_cache.get(&message.sender).cloned()); + } + + // Find the chat index so we can move it to the top after adding message + let chat_index = self.chats.iter().position(|c| c.jid == chat_jid); + + if let Some(index) = chat_index { + // Update the existing chat + let chat = &mut self.chats[index]; + + // For groups: update participant name, NOT the chat name + if is_group { + if let Some(ref name) = sender_name { + chat.update_participant(message.sender.clone(), name.clone()); + } + } else if !is_status { + // For DMs only: update chat name if we have a better one + if let Some(ref name) = sender_name + && !message.is_from_me + { + chat.set_name_if_not_worse(name.clone(), 2); + } + } + // Status broadcasts: don't update any names + let advanced = chat.add_message(message); + + // Move chat to top of list (most recent first); duplicates and + // older backfills don't reorder + if advanced { + self.move_chat_to_top(index); + } + + // Always invalidate caches since chat content changed + self.invalidate_chat_cache(); + self.invalidate_message_cache(&chat_jid); + } else { + // Create new chat + let display_name = if is_group || is_status { + // For groups/status, don't use sender name as chat name + None + } else if message.is_from_me { + // For outgoing DMs, use cached name + self.name_cache.get(&chat_jid).cloned() + } else { + // For incoming DMs, use sender name + sender_name.clone() + }; + + let mut new_chat = if let Some(name) = display_name { + Chat::with_name(chat_jid.clone(), name) + } else { + Chat::new(chat_jid.clone()) + }; + + // For groups: track participant + if is_group && let Some(ref name) = sender_name { + new_chat.update_participant(message.sender.clone(), name.clone()); + } + + new_chat.add_message(message); + self.chats.insert(0, new_chat); + self.invalidate_chat_cache(); + } + + if let Some(receipt) = read_now { + let boundary = self.find_chat(&chat_jid).and_then(Self::read_boundary); + if let Some(client) = &self.client { + client.send_read_receipts(&chat_jid, vec![receipt]); + // Persist this immediate read so hydration cannot re-badge it. + client.mark_chat_read(&chat_jid, boundary); + } + if let Some(chat) = self.find_chat_mut(&chat_jid) { + chat.mark_as_read(); + } + self.invalidate_chat_cache(); + self.invalidate_message_cache(&chat_jid); + } + } + + /// Handle a receipt event (read/played status update) + fn handle_receipt_received( + &mut self, + chat_jid: String, + message_ids: Vec, + receipt_type: ReceiptType, + ) { + if let Some(chat) = self.find_chat_mut(&chat_jid) { + let count = chat.mark_messages_as_read(&message_ids); + if count > 0 { + info!( + "Marked {} message(s) as {:?} in {}", + count, + receipt_type, + observe_str(&chat_jid) + ); + // Ticks and the unread badge changed; count-based cache + // guards can't see either. + self.invalidate_message_cache(&chat_jid); + self.invalidate_chat_cache(); + } + } + } + + /// Handle a reaction event + fn handle_reaction_received( + &mut self, + chat_jid: String, + message_id: String, + sender: String, + emoji: String, + ) { + if let Some(chat) = self.find_chat_mut(&chat_jid) { + if chat.add_reaction(&message_id, emoji.clone(), sender.clone()) { + // Invalidate cache since reactions affect message height + self.invalidate_message_cache(&chat_jid); + info!( + "Added reaction '{}' from {} to message {} in {}", + emoji, + observe_str(&sender), + message_id, + observe_str(&chat_jid) + ); + } else { + info!( + "Message {} not found for reaction in chat {}", + message_id, + observe_str(&chat_jid) + ); + } + } + } +} + +impl Focusable for WhatsAppApp { + fn focus_handle(&self, _cx: &App) -> FocusHandle { + self.chat_list_focus.clone() + } +} + +impl Render for WhatsAppApp { + fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { + let entity = cx.entity().clone(); + + match &self.app_state { + AppState::Loading => render_loading_view().into_any_element(), + AppState::Connecting => render_connecting_view().into_any_element(), + AppState::WaitingForPairing { + qr_code, + pair_code, + timeout_secs, + } => render_pairing_view(qr_code.as_ref(), pair_code.clone(), *timeout_secs) + .into_any_element(), + AppState::Syncing => render_syncing_view().into_any_element(), + AppState::Connected => render_connected_view(self, window, cx).into_any_element(), + AppState::Error(msg) => render_error_view(msg, entity).into_any_element(), + } + } +} diff --git a/apps/desktop/src/assets.rs b/apps/desktop/src/assets.rs new file mode 100644 index 000000000..a479f347a --- /dev/null +++ b/apps/desktop/src/assets.rs @@ -0,0 +1,51 @@ +//! Custom asset source that combines gpui-component-assets with our custom icons + +use anyhow::anyhow; +use gpui::{AssetSource, Result, SharedString}; +use rust_embed::RustEmbed; +use std::borrow::Cow; + +/// Our custom icons embedded at compile time +#[derive(RustEmbed)] +#[folder = "assets"] +#[include = "icons/**/*.svg"] +pub struct CustomIcons; + +/// Combined asset source that first checks our custom icons, +/// then falls back to gpui-component-assets +pub struct Assets; + +impl AssetSource for Assets { + fn load(&self, path: &str) -> Result>> { + if path.is_empty() { + return Ok(None); + } + + // First try our custom icons + if let Some(data) = CustomIcons::get(path) { + return Ok(Some(data.data)); + } + + // Fall back to gpui-component-assets + gpui_component_assets::Assets + .load(path) + .map_err(|e| anyhow!("could not find asset at path \"{path}\": {e}")) + } + + fn list(&self, path: &str) -> Result> { + // Combine both lists + let mut items: Vec = CustomIcons::iter() + .filter_map(|p| p.starts_with(path).then(|| p.into())) + .collect(); + + if let Ok(component_items) = gpui_component_assets::Assets.list(path) { + for item in component_items { + if !items.contains(&item) { + items.push(item); + } + } + } + + Ok(items) + } +} diff --git a/apps/desktop/src/audio/call_device.rs b/apps/desktop/src/audio/call_device.rs new file mode 100644 index 000000000..0491d1ef3 --- /dev/null +++ b/apps/desktop/src/audio/call_device.rs @@ -0,0 +1,486 @@ +//! cpal audio bridge for calls: mic -> 16 kHz mono i16 frames -> engine, engine -> speaker. +//! Lifted from examples/voip-cli (the reference consumer of the voip facade); candidate for a +//! shared crate if a third consumer appears. + +use std::sync::Arc; +use std::sync::atomic::{AtomicBool, AtomicI32, Ordering}; + +use anyhow::{Result, anyhow}; +use log::{error, info, warn}; +use portable_atomic::AtomicU64; + +const FRAME_SAMPLES: usize = 960; // 60 ms @ 16 kHz +const WA_RATE: u32 = 16_000; + +use cpal::traits::{DeviceTrait, HostTrait, StreamTrait}; +use cpal::{I24, Sample, SampleFormat, U24}; +use ringbuf::HeapRb; +use ringbuf::traits::{Consumer as _, Producer as _, Split as _}; + +/// Linear resample `src` (at `src_rate`) into the 16 kHz `out`, carrying a fractional read cursor +/// across calls so block boundaries don't click. Allocation-free: writes into the caller's `out`. +fn resample_into(src: &[i16], src_rate: u32, dst_rate: u32, pos: &mut f64, out: &mut Vec) { + if src.is_empty() { + return; + } + let step = src_rate as f64 / dst_rate as f64; + // `pos` is the fractional index into a virtual stream; it started somewhere in the previous + // block's tail, so rebase it into this block. + let mut p = *pos; + while p < src.len() as f64 { + let i = p as usize; + let frac = p - i as f64; + let a = src[i] as f64; + let b = if i + 1 < src.len() { + src[i + 1] as f64 + } else { + a + }; + out.push((a + (b - a) * frac).round() as i16); + p += step; + } + // Carry the leftover fraction (relative to the next block's start) so the next call continues + // smoothly instead of restarting at 0. + *pos = p - src.len() as f64; +} + +/// Pick the device's default config and an i16-friendly stream config. +fn default_config( + device: &cpal::Device, + input: bool, +) -> Result<(cpal::StreamConfig, SampleFormat)> { + let supported = if input { + device.default_input_config() + } else { + device.default_output_config() + } + .map_err(|e| anyhow!("default config: {e}"))?; + let format = supported.sample_format(); + Ok((supported.into(), format)) +} + +/// Open the default input device and stream 960-sample 16 kHz mono i16 frames over a channel. +/// +/// Mirrors the speaker path: the realtime cpal callback only downmixes to mono and PUSHES into a +/// lock-free SPSC ring -- it never touches the async runtime, because a cross-thread send inside a +/// WASAPI input callback can silently wedge capture (cpal #970). An async drain task pops the ring, +/// decimates the native rate to 16 kHz, chunks into 960-sample frames, and feeds the channel. The +/// stream lives on a parked std thread that exits when the receiver is dropped (call ended). +pub fn spawn_mic() -> Result>> { + let host = cpal::default_host(); + // Honor `WA_INPUT_DEVICE` (a name substring) to override a wrong/silent default endpoint, else use + // the OS default. We only enumerate `input_devices()` when a name is requested: enumerating on + // Linux/ALSA probes the legacy OSS plugin and prints harmless "Cannot open /dev/dsp" noise to + // stderr (from libasound, not our logger), so the default path -- `default_input_device()`, which + // does not enumerate -- avoids it entirely. A no-match error stays generic so device names do + // not leak through logs. + let want = std::env::var("WA_INPUT_DEVICE") + .ok() + .filter(|s| !s.is_empty()); + let device = match &want { + Some(want) => { + let devices: Vec = host + .input_devices() + .map(|i| i.collect()) + .unwrap_or_default(); + devices + .into_iter() + .find(|d| { + d.description() + .is_ok_and(|x| x.name().contains(want.as_str())) + }) + .ok_or_else(|| anyhow!("WA_INPUT_DEVICE did not match an input device"))? + } + None => host + .default_input_device() + .ok_or_else(|| anyhow!("no default input device"))?, + }; + let (config, format) = default_config(&device, true)?; + let src_rate = config.sample_rate; + let channels = config.channels as usize; + info!("🎤 cpal input: {src_rate} Hz, {channels} ch, {format:?}"); + + let (tx, rx) = async_channel::bounded::>(8); + + // ~1 s of native-rate mono headroom: absorbs the callback's bursty deliveries so the drain (and + // the channel) see a steady 60 ms cadence. + let ring = HeapRb::::new(src_rate as usize); + let (prod, mut cons) = ring.split(); + + // Peak amplitude of the captured audio, for the one-shot silence check below. + let peak = Arc::new(AtomicI32::new(0)); + // Cleared by the stream thread on ANY exit (build/play failure or teardown), so the drain stops + // and the channel closes -- a stream that never starts must not leave the consumer waiting for + // frames that will never come. + let alive = Arc::new(AtomicBool::new(true)); + // Frames produced vs dropped because the downstream channel was full -- i.e. the engine (and behind + // it the relay send) couldn't keep up, so captured audio was lost before transmit. `WA_AUDIO_DIAG=1` + // logs the rate, surfacing send-side backpressure (the SEND counterpart to the playout underruns). + let made = Arc::new(AtomicU64::new(0)); + let dropped = Arc::new(AtomicU64::new(0)); + let tx_drain = tx.clone(); + + if std::env::var_os("WA_AUDIO_DIAG").is_some() { + let (made, dropped, alive) = (made.clone(), dropped.clone(), alive.clone()); + tokio::spawn(async move { + let (mut lm, mut ld) = (0u64, 0u64); + while alive.load(Ordering::Relaxed) { + tokio::time::sleep(std::time::Duration::from_secs(3)).await; + let (m, d) = ( + made.load(Ordering::Relaxed), + dropped.load(Ordering::Relaxed), + ); + let (dm, dd) = (m - lm, d - ld); + lm = m; + ld = d; + // Only surface real send backpressure; steady state (0 dropped) is silent. + if dd > 0 { + warn!( + "🎤 mic->engine: {dd}/{dm} frames dropped (send backpressure) in the last 3s" + ); + } + } + }); + } + + // Async drain: ring -> resample(native -> 16 kHz) -> 960-frame chunks -> channel. + { + let (peak, alive, made, dropped) = + (peak.clone(), alive.clone(), made.clone(), dropped.clone()); + tokio::spawn(async move { + let mut pop_buf = vec![0i16; (src_rate as usize / 4).max(FRAME_SAMPLES)]; + let mut acc: Vec = Vec::with_capacity(FRAME_SAMPLES * 2); + let mut pos = 0.0f64; + loop { + if !alive.load(Ordering::Relaxed) || tx_drain.is_closed() { + break; + } + let n = cons.pop_slice(&mut pop_buf); + if n == 0 { + // Ring momentarily empty: yield briefly. The poll interval bounds the mic->engine + // forwarding latency/jitter, so keep it short (with the 1ms Windows timer above this + // sleep is accurate; on Linux it always was). 5ms keeps frame delivery near the + // audio clock without busy-spinning. + tokio::time::sleep(std::time::Duration::from_millis(5)).await; + continue; + } + resample_into(&pop_buf[..n], src_rate, WA_RATE, &mut pos, &mut acc); + while acc.len() >= FRAME_SAMPLES { + let rest = acc.split_off(FRAME_SAMPLES); + let frame = std::mem::replace(&mut acc, rest); + let p = frame.iter().map(|s| (*s as i32).abs()).max().unwrap_or(0); + peak.fetch_max(p, Ordering::Relaxed); + made.fetch_add(1, Ordering::Relaxed); + // Drop on full: loss tolerant. Steady state keeps the channel near-empty. + match tx_drain.try_send(frame) { + Ok(()) => {} + Err(async_channel::TrySendError::Full(_)) => { + dropped.fetch_add(1, Ordering::Relaxed); + } + // Receiver gone = call ended: teardown, not backpressure. + Err(async_channel::TrySendError::Closed(_)) => return, + } + } + } + }); + } + + // One-shot silence check: if a few seconds in the callback is firing but every captured sample is + // zero, the OS is handing us silence -- typically a Windows mic-privacy block on a desktop app, or + // the wrong input endpoint. Warn ONCE (not a recurring diagnostic) with what to try. + { + let (peak, alive) = (peak.clone(), alive.clone()); + tokio::spawn(async move { + tokio::time::sleep(std::time::Duration::from_secs(3)).await; + if alive.load(Ordering::Relaxed) && peak.load(Ordering::Relaxed) == 0 { + warn!( + "🎤 microphone is capturing only silence. On Windows, allow desktop apps to use \ + the mic (Settings > Privacy & security > Microphone), or select another device \ + with WA_INPUT_DEVICE=." + ); + } + }); + } + + // Teardown probe: once the call drops the receiver the channel closes, so the stream thread can + // wake and free the input device instead of holding it until process exit. + let teardown = tx.clone(); + let alive_thread = alive; + // Startup barrier: the caller must not get a "working" mic whose stream never started — + // the call would establish and transmit silence forever. + let (ready_tx, ready_rx) = std::sync::mpsc::channel::>(); + // Build on a dedicated thread: the !Send stream must be created, played, and dropped there. + std::thread::spawn(move || { + let started = + build_input_stream(&device, &config, format, channels, prod).and_then(|stream| { + match stream.play() { + Ok(()) => Ok(stream), + Err(e) => Err(anyhow!("play input stream: {e}")), + } + }); + match started { + // The binding keeps the stream alive while parked. + Ok(_stream) => { + let _ = ready_tx.send(Ok(())); + // Hold the stream until the call drops its receiver (or the call ends), polling in + // short intervals (park alone never wakes on a channel close), then fall out of + // scope so the stream drops and frees the device. + while !teardown.is_closed() && alive_thread.load(Ordering::Relaxed) { + std::thread::park_timeout(std::time::Duration::from_millis(250)); + } + } + Err(e) => { + error!("mic stream startup failed: {e}"); + let _ = ready_tx.send(Err(e)); + } + } + // Signal the drain to stop (single exit, any reason) so the channel closes and the + // consumer observes the mic ending instead of blocking forever on frames that never arrive. + alive_thread.store(false, Ordering::Relaxed); + }); + // Build/play are near-instant; a dead sender means the thread panicked. + ready_rx + .recv() + .map_err(|_| anyhow!("mic stream thread exited before signaling readiness"))??; + Ok(rx) +} + +fn build_input_stream

( + device: &cpal::Device, + config: &cpal::StreamConfig, + format: SampleFormat, + channels: usize, + mut prod: P, +) -> Result +where + P: ringbuf::traits::Producer + Send + 'static, +{ + macro_rules! build { + ($t:ty) => {{ + // Reusable mono scratch; reallocates only past the warmup size. The realtime callback does + // NO async work and NO logging -- only a downmix and a lock-free push into the ring. + let mut mono: Vec = Vec::with_capacity(2048); + let err = |e| error!("mic stream error: {e}"); + device + .build_input_stream( + config.clone(), + move |data: &[$t], _| { + mono.clear(); + for frame in data.chunks(channels) { + // Downmix: average the channels into one i16. + let mut sum = 0i32; + for &s in frame { + sum += i16::from_sample(s) as i32; + } + mono.push((sum / channels as i32) as i16); + } + // Drop overflow: loss tolerant; the ring is full only if the drain fell behind. + let _ = prod.push_slice(&mono); + }, + err, + None, + ) + .map_err(|e| anyhow!("build input stream: {e}")) + }}; + } + match format { + SampleFormat::I8 => build!(i8), + SampleFormat::I16 => build!(i16), + SampleFormat::I32 => build!(i32), + SampleFormat::U8 => build!(u8), + SampleFormat::U16 => build!(u16), + SampleFormat::U32 => build!(u32), + SampleFormat::I24 => build!(I24), + SampleFormat::U24 => build!(U24), + SampleFormat::F32 => build!(f32), + SampleFormat::F64 => build!(f64), + other => Err(anyhow!("unsupported input sample format {other:?}")), + } +} + +/// Open the default output device and play 16 kHz mono i16 frames pushed onto the returned channel. +/// +/// An async drain task moves frames from the channel into a lock-free SPSC ring (resampling 16 kHz -> +/// native rate as it goes, allocation-free). cpal's realtime output callback pops mono i16 from the +/// ring and writes the device format, duplicating mono across channels. Underrun = silence. The +/// stream lives on a parked std thread that exits when the sender is dropped (call ended). +pub fn spawn_speaker() -> Result>> { + let host = cpal::default_host(); + let device = host + .default_output_device() + .ok_or_else(|| anyhow!("no default output device"))?; + let (config, format) = default_config(&device, false)?; + let dst_rate = config.sample_rate; + let channels = config.channels as usize; + info!("🔈 cpal output: {dst_rate} Hz, {channels} ch, {format:?}"); + + // ~1 s of native-rate mono headroom: rides out DTX gaps and scheduling jitter. + let ring = HeapRb::::new(dst_rate as usize); + let (mut prod, cons) = ring.split(); + + // Windows/WASAPI only: pre-roll ~100 ms of silence so the ring is never empty at the strict ~10 ms + // WASAPI pull period. cpal opens the WASAPI shared-mode output with the minimal buffer + // (hnsBufferDuration=0), and our drain refills in 60 ms bursts, so without headroom any Windows + // scheduler jitter (default timer ~15.6 ms) drains the ring and the callback emits silence -> + // choppy. ALSA/PipeWire already buffer ~100 ms, so this is gated to Windows to add ZERO latency on + // Linux (where playout is already smooth). The cost is a one-time ~100 ms of output latency. + #[cfg(target_os = "windows")] + { + let _ = prod.push_slice(&vec![0i16; dst_rate as usize / 10]); + } + + let (tx, rx) = async_channel::bounded::>(64); + // Set once the call drops the playout sender (the drain loop below ends), so the output thread + // frees the device instead of holding it until process exit. + let teardown = Arc::new(AtomicBool::new(false)); + let teardown_drain = teardown.clone(); + + // Playout health counters: samples the WASAPI/ALSA output callback pulled, and how many of those + // were underruns (ring empty -> silence -> choppy). `WA_AUDIO_DIAG=1` logs the rate periodically so + // a choppy run shows whether the OUTPUT path is starving (the classic Windows/WASAPI failure) vs a + // clean playout (then the gap is upstream: network/decode). + let pops = Arc::new(AtomicU64::new(0)); + let underruns = Arc::new(AtomicU64::new(0)); + if std::env::var_os("WA_AUDIO_DIAG").is_some() { + let (pops, underruns, teardown) = (pops.clone(), underruns.clone(), teardown.clone()); + tokio::spawn(async move { + let (mut last_pops, mut last_under) = (0u64, 0u64); + while !teardown.load(Ordering::Relaxed) { + tokio::time::sleep(std::time::Duration::from_secs(3)).await; + let (p, u) = ( + pops.load(Ordering::Relaxed), + underruns.load(Ordering::Relaxed), + ); + let (dp, du) = (p - last_pops, u - last_under); + last_pops = p; + last_under = u; + let pct = if dp > 0 { + du as f64 * 100.0 / dp as f64 + } else { + 0.0 + }; + // Only surface a genuinely choppy interval; a clean playout (~0% underruns) is silent. + if pct >= 2.0 { + info!( + "🔈 playout: {du}/{dp} samples were underruns ({pct:.1}%) in the last 3s (high % = choppy output)" + ); + } + } + }); + } + + // Held by the stream thread so a build/play failure can close the channel. + let rx_close = rx.clone(); + + // Async drain: channel -> resample -> ring. Owns the ring producer. + tokio::spawn(async move { + let mut resampled: Vec = Vec::with_capacity(4096); + let mut pos = 0.0f64; + while let Ok(frame) = rx.recv().await { + resampled.clear(); + resample_into(&frame, WA_RATE, dst_rate, &mut pos, &mut resampled); + // Drop overflow: the ring is full only if playout fell badly behind (loss tolerant). + let _ = prod.push_slice(&resampled); + } + teardown_drain.store(true, Ordering::Relaxed); + }); + + // Startup barrier mirroring spawn_mic: a speaker whose stream never started must fail call + // setup, not leave the engine writing remote audio into a ring nobody drains (silent call). + let (ready_tx, ready_rx) = std::sync::mpsc::channel::>(); + // Build/play the !Send output stream on a dedicated parked thread. + std::thread::spawn(move || { + let started = + build_output_stream(&device, &config, format, channels, cons, pops, underruns) + .and_then(|stream| match stream.play() { + Ok(()) => Ok(stream), + Err(e) => Err(anyhow!("play output stream: {e}")), + }); + match started { + // The binding keeps the stream alive while parked. + Ok(_stream) => { + let _ = ready_tx.send(Ok(())); + // Hold the stream until the drain signals teardown, then fall out of scope so + // the stream drops and frees the output device. + while !teardown.load(Ordering::Relaxed) { + std::thread::park_timeout(std::time::Duration::from_millis(250)); + } + } + Err(e) => { + error!("speaker stream startup failed: {e}"); + let _ = ready_tx.send(Err(e)); + // Signal teardown and close the channel so the drain and diag tasks stop and + // senders see the dead speaker instead of feeding a stream that never started. + teardown.store(true, Ordering::Relaxed); + rx_close.close(); + } + } + }); + // Build/play are near-instant; a dead sender means the thread panicked. + ready_rx + .recv() + .map_err(|_| anyhow!("speaker stream thread exited before signaling readiness"))??; + Ok(tx) +} + +fn build_output_stream( + device: &cpal::Device, + config: &cpal::StreamConfig, + format: SampleFormat, + channels: usize, + mut cons: C, + pops: Arc, + underruns: Arc, +) -> Result +where + C: ringbuf::traits::Consumer + Send + 'static, +{ + macro_rules! build { + ($t:ty) => {{ + let err = |e| error!("speaker stream error: {e}"); + device + .build_output_stream( + config.clone(), + move |data: &mut [$t], _| { + // Accumulate the underrun count locally (single-threaded RT callback) and + // publish once per callback, not per sample, to keep the hot path lock-free. + let mut under = 0u64; + for frame in data.chunks_mut(channels) { + // Pop one mono sample (silence on underrun) and fan it across channels. + let s: i16 = match cons.try_pop() { + Some(s) => s, + None => { + under += 1; + 0 + } + }; + let v = <$t>::from_sample(s); + for out in frame.iter_mut() { + *out = v; + } + } + pops.fetch_add((data.len() / channels) as u64, Ordering::Relaxed); + if under > 0 { + underruns.fetch_add(under, Ordering::Relaxed); + } + }, + err, + None, + ) + .map_err(|e| anyhow!("build output stream: {e}")) + }}; + } + match format { + SampleFormat::I8 => build!(i8), + SampleFormat::I16 => build!(i16), + SampleFormat::I32 => build!(i32), + SampleFormat::U8 => build!(u8), + SampleFormat::U16 => build!(u16), + SampleFormat::U32 => build!(u32), + SampleFormat::I24 => build!(I24), + SampleFormat::U24 => build!(U24), + SampleFormat::F32 => build!(f32), + SampleFormat::F64 => build!(f64), + other => Err(anyhow!("unsupported output sample format {other:?}")), + } +} diff --git a/apps/desktop/src/audio/encoder.rs b/apps/desktop/src/audio/encoder.rs new file mode 100644 index 000000000..1e42a778c --- /dev/null +++ b/apps/desktop/src/audio/encoder.rs @@ -0,0 +1,228 @@ +//! Opus audio encoder for WhatsApp PTT messages. + +use std::io::Cursor; + +use log::info; +use ogg::writing::PacketWriteEndInfo; +use opus::{Application, Channels, Encoder}; + +use super::recorder::RecordedAudio; + +const SAMPLE_RATE: u32 = 16000; +const CHANNELS: Channels = Channels::Mono; +const BITRATE: i32 = 16000; +const FRAME_SIZE_MS: usize = 20; +const FRAME_SIZE_SAMPLES: usize = (SAMPLE_RATE as usize * FRAME_SIZE_MS) / 1000; +const GRANULE_RATE: u32 = 48000; +const GRANULE_PER_FRAME: u64 = (GRANULE_RATE as u64 * FRAME_SIZE_MS as u64) / 1000; +const PRE_SKIP: u16 = 312; + +pub fn encode_to_opus_ogg(audio: &RecordedAudio) -> Result, EncoderError> { + let samples = audio.resample_to_16khz(); + if samples.is_empty() { + return Err(EncoderError::EmptyAudio); + } + + info!( + "Encoding {} samples ({:.1}s) to Opus/OGG", + samples.len(), + samples.len() as f32 / SAMPLE_RATE as f32 + ); + + let mut encoder = Encoder::new(SAMPLE_RATE, CHANNELS, Application::Voip) + .map_err(|e| EncoderError::OpusError(e.to_string()))?; + encoder + .set_bitrate(opus::Bitrate::Bits(BITRATE)) + .map_err(|e| EncoderError::OpusError(e.to_string()))?; + + let mut ogg_buffer = Vec::new(); + let serial = rand_serial(); + + let samples_i16: Vec = samples + .iter() + .map(|&s| (s.clamp(-1.0, 1.0) * 32767.0) as i16) + .collect(); + + let mut encoded_packets: Vec> = Vec::new(); + for chunk in samples_i16.chunks(FRAME_SIZE_SAMPLES) { + let mut frame = chunk.to_vec(); + if frame.len() < FRAME_SIZE_SAMPLES { + frame.resize(FRAME_SIZE_SAMPLES, 0); + } + + let mut output = vec![0u8; 4000]; + let len = encoder + .encode(&frame, &mut output) + .map_err(|e| EncoderError::OpusError(e.to_string()))?; + output.truncate(len); + encoded_packets.push(output); + } + + // EOS granule reflects the real capture length plus pre-skip: decoders + // discard PRE_SKIP samples up front, so trimming to the granule must land + // on the capture's end, not PRE_SKIP samples before it. + let eos_granule = + PRE_SKIP as u64 + samples_i16.len() as u64 * (GRANULE_RATE / SAMPLE_RATE) as u64; + // When the final frame's zero-padding can't absorb the pre-skip (exact + // frame multiples have none at all), one extra silence frame keeps the + // packet stream covering the full logical duration. + if eos_granule > encoded_packets.len() as u64 * GRANULE_PER_FRAME { + let silence = vec![0i16; FRAME_SIZE_SAMPLES]; + let mut output = vec![0u8; 4000]; + let len = encoder + .encode(&silence, &mut output) + .map_err(|e| EncoderError::OpusError(e.to_string()))?; + output.truncate(len); + encoded_packets.push(output); + } + + { + let cursor = Cursor::new(&mut ogg_buffer); + let mut writer = ogg::PacketWriter::new(cursor); + + writer + .write_packet( + create_opus_id_header(), + serial, + PacketWriteEndInfo::EndPage, + 0, + ) + .map_err(|e| EncoderError::OggError(e.to_string()))?; + + writer + .write_packet( + create_opus_comment_header(), + serial, + PacketWriteEndInfo::EndPage, + 0, + ) + .map_err(|e| EncoderError::OggError(e.to_string()))?; + + let total_packets = encoded_packets.len(); + let mut current_granule: u64 = 0; + + for (i, packet) in encoded_packets.into_iter().enumerate() { + current_granule += GRANULE_PER_FRAME; + let (end_info, granule) = if i == total_packets - 1 { + (PacketWriteEndInfo::EndStream, eos_granule) + } else { + (PacketWriteEndInfo::NormalPacket, current_granule) + }; + writer + .write_packet(packet, serial, end_info, granule) + .map_err(|e| EncoderError::OggError(e.to_string()))?; + } + } + + info!("Encoded to {} bytes OGG", ogg_buffer.len()); + Ok(ogg_buffer) +} + +fn create_opus_id_header() -> Vec { + let mut header = Vec::with_capacity(19); + header.extend_from_slice(b"OpusHead"); + header.push(1); // Version + header.push(1); // Channels (mono) + header.extend_from_slice(&PRE_SKIP.to_le_bytes()); + header.extend_from_slice(&SAMPLE_RATE.to_le_bytes()); + header.extend_from_slice(&0u16.to_le_bytes()); // Output gain (0 dB) + header.push(0); // Channel mapping family + header +} + +fn create_opus_comment_header() -> Vec { + let mut header = Vec::new(); + header.extend_from_slice(b"OpusTags"); + let vendor = b"whatsapp-rust"; + header.extend_from_slice(&(vendor.len() as u32).to_le_bytes()); + header.extend_from_slice(vendor); + header.extend_from_slice(&0u32.to_le_bytes()); // No comments + header +} + +fn rand_serial() -> u32 { + let seed = wacore::time::now_millis() as u32; + seed.wrapping_mul(1103515245).wrapping_add(12345) +} + +#[derive(Debug)] +pub enum EncoderError { + EmptyAudio, + OpusError(String), + OggError(String), +} + +impl std::fmt::Display for EncoderError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::EmptyAudio => write!(f, "No audio data to encode"), + Self::OpusError(e) => write!(f, "Opus encoder error: {}", e), + Self::OggError(e) => write!(f, "OGG writer error: {}", e), + } + } +} + +impl std::error::Error for EncoderError {} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_opus_id_header() { + let header = create_opus_id_header(); + assert_eq!(&header[0..8], b"OpusHead"); + assert_eq!(header[8], 1); // version + assert_eq!(header[9], 1); // channels + } + + #[test] + fn test_opus_comment_header() { + let header = create_opus_comment_header(); + assert_eq!(&header[0..8], b"OpusTags"); + } + + #[test] + fn test_encode_simple_audio() { + // Generate 1 second of silence + let audio = RecordedAudio { + samples: vec![0.0f32; 16000], + sample_rate: 16000, + duration_secs: 1, + }; + + let result = encode_to_opus_ogg(&audio); + assert!(result.is_ok()); + + let ogg_data = result.unwrap(); + // Check OGG magic number + assert_eq!(&ogg_data[0..4], b"OggS"); + } + + #[test] + fn test_exact_frame_multiple_keeps_full_duration() { + // 16000 samples = exactly 50 frames: no zero-padding to absorb the + // pre-skip, so a capped EOS granule would clip ~6.5ms of real audio. + let samples = 16000u64; + let audio = RecordedAudio { + samples: vec![0.0f32; samples as usize], + sample_rate: 16000, + duration_secs: 1, + }; + + let ogg_data = encode_to_opus_ogg(&audio).unwrap(); + + // The EOS granule lives in the header of the last OGG page + // (byte offset 6, 8 bytes LE after the "OggS" capture pattern). + let last_page = ogg_data + .windows(4) + .rposition(|w| w == b"OggS") + .expect("no OGG page found"); + let granule_bytes: [u8; 8] = ogg_data[last_page + 6..last_page + 14].try_into().unwrap(); + let eos_granule = u64::from_le_bytes(granule_bytes); + assert_eq!( + eos_granule, + PRE_SKIP as u64 + samples * (GRANULE_RATE / SAMPLE_RATE) as u64 + ); + } +} diff --git a/apps/desktop/src/audio/mod.rs b/apps/desktop/src/audio/mod.rs new file mode 100644 index 000000000..ee5d812f1 --- /dev/null +++ b/apps/desktop/src/audio/mod.rs @@ -0,0 +1,20 @@ +//! Audio module for PTT voice message recording and call audio +//! +//! This module provides: +//! - Audio capture using cpal +//! - Opus encoding to OGG container +//! - Waveform generation for WhatsApp PTT messages +//! - Audio playback for received voice messages +//! - The cpal mic/speaker bridge for VoIP calls (engine lives in the library) + +mod call_device; +mod encoder; +mod player; +mod recorder; +mod waveform; + +pub use call_device::{spawn_mic, spawn_speaker}; +pub use encoder::encode_to_opus_ogg; +pub use player::AudioPlayer; +pub use recorder::AudioRecorder; +pub use waveform::generate_waveform; diff --git a/apps/desktop/src/audio/player.rs b/apps/desktop/src/audio/player.rs new file mode 100644 index 000000000..06f866237 --- /dev/null +++ b/apps/desktop/src/audio/player.rs @@ -0,0 +1,385 @@ +//! Audio playback using cpal +//! +//! Plays Opus/OGG audio files for PTT voice message playback. + +use std::io::Cursor; +use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; +use std::sync::{Arc, Mutex}; + +use cpal::traits::{DeviceTrait, HostTrait, StreamTrait}; +use cpal::{FromSample, SampleFormat, SizedSample, Stream, StreamConfig}; +use log::{error, info, warn}; +use ogg::reading::PacketReader; +use opus::{Channels, Decoder as OpusDecoder}; +use tokio::sync::oneshot; + +/// Audio player for PTT voice messages. +pub struct AudioPlayer { + stream: Option, + is_playing: Arc, + position: Arc, + total_samples: u64, + sample_rate: u32, + completion_tx: Option>, +} + +impl Default for AudioPlayer { + fn default() -> Self { + Self::new() + } +} + +impl AudioPlayer { + pub fn new() -> Self { + Self { + stream: None, + is_playing: Arc::new(AtomicBool::new(false)), + position: Arc::new(AtomicUsize::new(0)), + total_samples: 0, + sample_rate: 48000, + completion_tx: None, + } + } + + /// Returns a receiver that fires when playback completes. + pub fn on_complete(&mut self) -> oneshot::Receiver<()> { + let (tx, rx) = oneshot::channel(); + self.completion_tx = Some(tx); + rx + } + + pub fn is_playing(&self) -> bool { + self.is_playing.load(Ordering::Relaxed) + } + + pub fn play(&mut self, ogg_data: Vec) -> Result<(), PlayerError> { + let samples = decode_ogg(&ogg_data)?; + if samples.is_empty() { + return Err(PlayerError::EmptyAudio); + } + + info!("Decoded {} samples for playback", samples.len()); + self.play_samples(samples, 48000) + } + + /// Play raw f32 PCM samples at the specified sample rate. + pub fn play_samples( + &mut self, + samples: Vec, + src_sample_rate: u32, + ) -> Result<(), PlayerError> { + // Preserve completion sender through stop() since it may have been set by on_complete() + let saved_completion_tx = self.completion_tx.take(); + self.stop(); + self.completion_tx = saved_completion_tx; + + if samples.is_empty() { + return Err(PlayerError::EmptyAudio); + } + + info!( + "Playing {} samples at {} Hz", + samples.len(), + src_sample_rate + ); + + let host = cpal::default_host(); + let device = host + .default_output_device() + .ok_or(PlayerError::NoOutputDevice)?; + + info!("Using default output device"); + + // Prefer F32 (native to our samples), but i16/u16-only devices still + // play: the callback converts per sample. Formats build_stream can't + // dispatch (i32/f64/...) are filtered out up front so a fallback pick + // never lands on one while a buildable range exists. + let supported_configs: Vec<_> = device + .supported_output_configs() + .map_err(|e| PlayerError::DeviceError(e.to_string()))? + .filter(|c| { + matches!( + c.sample_format(), + SampleFormat::F32 | SampleFormat::I16 | SampleFormat::U16 + ) + }) + .collect(); + + let supports_48k = |c: &cpal::SupportedStreamConfigRange| { + c.min_sample_rate() <= 48000 && c.max_sample_rate() >= 48000 + }; + let is_f32 = |c: &cpal::SupportedStreamConfigRange| c.sample_format() == SampleFormat::F32; + let chosen = supported_configs + .iter() + .find(|c| is_f32(c) && supports_48k(c)) + .or_else(|| supported_configs.iter().find(|c| is_f32(c))) + .or_else(|| supported_configs.iter().find(|c| supports_48k(c))) + .or_else(|| supported_configs.first()) + .ok_or(PlayerError::NoSupportedConfig)?; + + let sample_format = chosen.sample_format(); + let config: StreamConfig = if supports_48k(chosen) { + chosen.with_sample_rate(48000) + } else { + chosen.with_sample_rate(chosen.min_sample_rate()) + } + .into(); + self.sample_rate = config.sample_rate; + let output_channels = config.channels as usize; + + info!( + "Output config: {} Hz, {} channels, {:?}", + config.sample_rate, output_channels, sample_format + ); + + let resampled = + resample_audio(&samples, src_sample_rate, self.sample_rate, output_channels); + self.total_samples = resampled.len() as u64; + + let is_playing = self.is_playing.clone(); + let position = self.position.clone(); + position.store(0, Ordering::Relaxed); + is_playing.store(true, Ordering::Relaxed); + + let completion_tx: Arc>>> = + Arc::new(Mutex::new(self.completion_tx.take())); + let audio_data = Arc::new(resampled); + + let stream = match sample_format { + SampleFormat::F32 => build_stream::( + &device, + config, + audio_data, + position, + is_playing, + completion_tx, + ), + SampleFormat::I16 => build_stream::( + &device, + config, + audio_data, + position, + is_playing, + completion_tx, + ), + SampleFormat::U16 => build_stream::( + &device, + config, + audio_data, + position, + is_playing, + completion_tx, + ), + other => Err(PlayerError::StreamError(format!( + "unsupported output sample format {other:?}" + ))), + }?; + + stream + .play() + .map_err(|e| PlayerError::StreamError(e.to_string()))?; + + self.stream = Some(stream); + info!("Audio playback started"); + + Ok(()) + } + + pub fn stop(&mut self) { + self.stream.take(); + self.is_playing.store(false, Ordering::Relaxed); + self.position.store(0, Ordering::Relaxed); + self.total_samples = 0; + self.completion_tx = None; + } + + pub fn pause(&mut self) { + if let Some(ref stream) = self.stream { + let _ = stream.pause(); + self.is_playing.store(false, Ordering::Relaxed); + } + } + + pub fn resume(&mut self) { + if let Some(ref stream) = self.stream { + let _ = stream.play(); + self.is_playing.store(true, Ordering::Relaxed); + } + } +} + +/// Build the output stream for the device's sample format, converting our +/// f32 samples in the callback (same dispatch as call_device's speaker path). +fn build_stream>( + device: &cpal::Device, + config: StreamConfig, + audio: Arc>, + position: Arc, + is_playing: Arc, + completion_tx: Arc>>>, +) -> Result { + device + .build_output_stream( + config, + move |data: &mut [T], _: &cpal::OutputCallbackInfo| { + let mut pos = position.load(Ordering::Relaxed); + + for sample in data.iter_mut() { + let s = if pos < audio.len() { + let s = audio[pos]; + pos += 1; + s + } else { + // Mark as done and notify completion (only once) + if is_playing.swap(false, Ordering::Relaxed) + && let Ok(mut guard) = completion_tx.lock() + && let Some(tx) = guard.take() + { + let _ = tx.send(()); + } + 0.0 + }; + *sample = T::from_sample(s); + } + + position.store(pos, Ordering::Relaxed); + }, + move |err| { + error!("Audio output error: {}", err); + }, + None, + ) + .map_err(|e| PlayerError::StreamError(e.to_string())) +} + +fn decode_ogg(ogg_data: &[u8]) -> Result, PlayerError> { + let cursor = Cursor::new(ogg_data); + let mut packet_reader = PacketReader::new(cursor); + let mut all_samples: Vec = Vec::new(); + let mut packet_count = 0; + let mut decoder: Option = None; + let mut channel_count = 1usize; + let mut pre_skip = 0usize; + + while let Some(packet) = packet_reader + .read_packet() + .map_err(|e| PlayerError::DecodeError(format!("OGG read error: {}", e)))? + { + packet_count += 1; + + // First packet: OpusHead header, second packet: OpusTags (skip both) + if packet_count == 1 { + if packet.data.len() >= 12 && &packet.data[0..8] == b"OpusHead" { + let channels = packet.data[9]; + // 48kHz priming samples the decoder must discard before real audio + pre_skip = u16::from_le_bytes([packet.data[10], packet.data[11]]) as usize; + channel_count = if channels > 1 { 2 } else { 1 }; + let opus_channels = if channel_count == 2 { + Channels::Stereo + } else { + Channels::Mono + }; + decoder = + Some(OpusDecoder::new(48000, opus_channels).map_err(|e| { + PlayerError::DecodeError(format!("Opus decoder init: {}", e)) + })?); + } + continue; + } + if packet_count == 2 { + continue; + } + + // Some malformed-but-playable streams omit OpusHead. + if decoder.is_none() { + decoder = Some( + OpusDecoder::new(48000, Channels::Mono) + .map_err(|e| PlayerError::DecodeError(format!("Opus decoder init: {e}")))?, + ); + } + let Some(dec) = decoder.as_mut() else { + return Err(PlayerError::DecodeError( + "Opus decoder was not initialized".to_string(), + )); + }; + + let mut output = vec![0.0f32; 5760 * 2]; + match dec.decode_float(&packet.data, &mut output, false) { + Ok(n) => { + // n is frames per channel; the buffer is interleaved + output.truncate(n * channel_count); + let mono = if channel_count == 2 { + output + .chunks_exact(2) + .map(|pair| (pair[0] + pair[1]) / 2.0) + .collect() + } else { + output + }; + let skip = pre_skip.min(mono.len()); + pre_skip -= skip; + all_samples.extend_from_slice(&mono[skip..]); + } + Err(e) => warn!("Opus decode error (packet {}): {}", packet_count, e), + } + } + + info!( + "Decoded {} packets, {} samples", + packet_count, + all_samples.len() + ); + + if all_samples.is_empty() { + return Err(PlayerError::DecodeError("No samples decoded".to_string())); + } + + Ok(all_samples) +} + +fn resample_audio(samples: &[f32], src_rate: u32, dst_rate: u32, channels: usize) -> Vec { + if src_rate == 0 || dst_rate == 0 { + return samples.to_vec(); + } + + if src_rate == dst_rate && channels == 1 { + return samples.to_vec(); + } + + let ratio = dst_rate as f32 / src_rate as f32; + let output_len = (samples.len() as f32 * ratio) as usize; + let mut output = Vec::with_capacity(output_len * channels); + + for i in 0..output_len { + let src_idx = (i as f32 / ratio) as usize; + let sample = samples.get(src_idx).copied().unwrap_or(0.0); + output.extend(std::iter::repeat_n(sample, channels)); + } + + output +} + +#[derive(Debug)] +pub enum PlayerError { + NoOutputDevice, + NoSupportedConfig, + EmptyAudio, + DeviceError(String), + StreamError(String), + DecodeError(String), +} + +impl std::fmt::Display for PlayerError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::NoOutputDevice => write!(f, "No audio output device found"), + Self::NoSupportedConfig => write!(f, "No supported audio configuration"), + Self::EmptyAudio => write!(f, "No audio data to play"), + Self::DeviceError(e) => write!(f, "Audio device error: {}", e), + Self::StreamError(e) => write!(f, "Audio stream error: {}", e), + Self::DecodeError(e) => write!(f, "Audio decode error: {}", e), + } + } +} + +impl std::error::Error for PlayerError {} diff --git a/apps/desktop/src/audio/recorder.rs b/apps/desktop/src/audio/recorder.rs new file mode 100644 index 000000000..20bae0972 --- /dev/null +++ b/apps/desktop/src/audio/recorder.rs @@ -0,0 +1,395 @@ +//! Audio recording using cpal +//! +//! Captures audio from the default input device at 48kHz mono. +//! The samples are stored and can be resampled to 16kHz for Opus encoding. + +use std::sync::{Arc, Mutex}; +use std::time::Duration; + +use cpal::traits::{DeviceTrait, HostTrait, StreamTrait}; +use cpal::{Device, FromSample, Sample as _, SampleFormat, SizedSample, Stream, StreamConfig}; +use log::{error, info, warn}; +use wacore::time::Instant; + +/// Target sample rate for Opus encoding (WhatsApp standard) +pub const TARGET_SAMPLE_RATE: u32 = 16000; + +/// Capture sample rate (most hardware supports this) +const CAPTURE_SAMPLE_RATE: u32 = 48000; + +pub struct RecordedAudio { + pub samples: Vec, + pub sample_rate: u32, + pub duration_secs: u32, +} + +impl RecordedAudio { + pub fn resample_to_16khz(&self) -> Vec { + if self.sample_rate == TARGET_SAMPLE_RATE { + return self.samples.clone(); + } + + let ratio = self.sample_rate as f32 / TARGET_SAMPLE_RATE as f32; + let output_len = (self.samples.len() as f32 / ratio) as usize; + let mut output = Vec::with_capacity(output_len); + + if self.sample_rate.is_multiple_of(TARGET_SAMPLE_RATE) { + // Integer decimation (the common 48kHz case): low-pass BEFORE + // dropping samples, or everything above the 8kHz target Nyquist + // folds back into the voice band (a box average only manages + // ~10dB there). Windowed-sinc FIR, ~7kHz cutoff at the input + // rate, unity DC gain so speech level is preserved; evaluated + // only at the kept samples, O(n·taps) — fine for PTT lengths. + const CUTOFF_HZ: f32 = 7_000.0; + let step = (self.sample_rate / TARGET_SAMPLE_RATE) as usize; + // The transition band scales with the input rate, so the tap + // count must too: 63 taps suit 48kHz (step 3), but a 96/192kHz + // fallback device needs proportionally more or content above + // 8kHz still folds into the output. + let taps = (21 * step) | 1; + let fc = CUTOFF_HZ / self.sample_rate as f32; + let center = (taps - 1) / 2; + let mut fir = vec![0.0f32; taps]; + for (k, tap) in fir.iter_mut().enumerate() { + let n = k as f32 - center as f32; + let sinc = if n == 0.0 { + 2.0 * fc + } else { + (std::f32::consts::TAU * fc * n).sin() / (std::f32::consts::PI * n) + }; + let hamming = + 0.54 - 0.46 * (std::f32::consts::TAU * k as f32 / (taps - 1) as f32).cos(); + *tap = sinc * hamming; + } + let dc_gain: f32 = fir.iter().sum(); + for tap in fir.iter_mut() { + *tap /= dc_gain; + } + // saturating: an empty capture must not underflow (the loop below + // is a no-op then anyway). + let last = self.samples.len().saturating_sub(1); + for i in 0..self.samples.len() / step { + let mid = (i * step) as isize; + let mut acc = 0.0f32; + for (k, &tap) in fir.iter().enumerate() { + // Clamped edges: replicating the boundary sample beats + // zero-padding, which would fade the clip's ends. + let src = (mid + k as isize - center as isize).clamp(0, last as isize); + acc += tap * self.samples[src as usize]; + } + output.push(acc); + } + } else { + for i in 0..output_len { + // Linear interpolation: nearest-neighbor sample dropping + // aliases audibly on voice. + let src_pos = i as f32 * ratio; + let idx = src_pos as usize; + let frac = src_pos - idx as f32; + let Some(&a) = self.samples.get(idx) else { + break; + }; + let b = self.samples.get(idx + 1).copied().unwrap_or(a); + output.push(a + (b - a) * frac); + } + } + + output + } +} + +pub struct AudioRecorder { + stream: Option, + samples: Arc>>, + is_recording: bool, + start_time: Option, + device: Option, + config: Option, + sample_format: SampleFormat, + sample_rate: u32, +} + +impl Default for AudioRecorder { + fn default() -> Self { + Self::new() + } +} + +impl AudioRecorder { + pub fn new() -> Self { + Self { + stream: None, + samples: Arc::new(Mutex::new(Vec::new())), + is_recording: false, + start_time: None, + device: None, + config: None, + sample_format: SampleFormat::F32, + sample_rate: CAPTURE_SAMPLE_RATE, + } + } + + pub fn init(&mut self) -> Result<(), RecorderError> { + let host = cpal::default_host(); + + let device = host + .default_input_device() + .ok_or(RecorderError::NoInputDevice)?; + + info!("Using default input device"); + + let supported = device + .supported_input_configs() + .map_err(|e| RecorderError::DeviceError(e.to_string()))?; + + // Prefer F32 (native to our buffer), but i16/u16-only mics still + // record: the callback converts per sample. Format outranks 48kHz + // support, which outranks mono: multichannel is downmixed anyway, + // while a low capture rate permanently costs voice bandwidth. + let mut best: Option<(u8, _)> = None; + for config in supported { + if !matches!( + config.sample_format(), + SampleFormat::F32 | SampleFormat::I16 | SampleFormat::U16 + ) { + continue; + } + let supports_rate = config.min_sample_rate() <= CAPTURE_SAMPLE_RATE + && config.max_sample_rate() >= CAPTURE_SAMPLE_RATE; + let score = u8::from(config.sample_format() == SampleFormat::F32) * 4 + + u8::from(supports_rate) * 2 + + u8::from(config.channels() == 1); + if best.as_ref().is_none_or(|(s, _)| score > *s) { + let candidate = if supports_rate { + config.with_sample_rate(CAPTURE_SAMPLE_RATE) + } else { + config.with_max_sample_rate() + }; + best = Some((score, candidate)); + } + } + + let supported_config = best + .map(|(_, c)| c) + .ok_or(RecorderError::NoSupportedConfig)?; + + self.sample_format = supported_config.sample_format(); + let stream_config: StreamConfig = supported_config.into(); + self.sample_rate = stream_config.sample_rate; + + info!( + "Audio config: {} Hz, {} channel(s), {:?}", + stream_config.sample_rate, stream_config.channels, self.sample_format + ); + + self.device = Some(device); + self.config = Some(stream_config); + + Ok(()) + } + + pub fn start(&mut self) -> Result<(), RecorderError> { + if self.is_recording { + return Err(RecorderError::AlreadyRecording); + } + + if self.device.is_none() { + self.init()?; + } + + let device = self.device.as_ref().ok_or(RecorderError::NotInitialized)?; + let config = self.config.ok_or(RecorderError::NotInitialized)?; + + if let Ok(mut samples) = self.samples.lock() { + samples.clear(); + } + + let samples = self.samples.clone(); + + let stream = match self.sample_format { + SampleFormat::F32 => build_input_stream::(device, config, samples), + SampleFormat::I16 => build_input_stream::(device, config, samples), + SampleFormat::U16 => build_input_stream::(device, config, samples), + other => Err(RecorderError::StreamError(format!( + "unsupported input sample format {other:?}" + ))), + }?; + + stream + .play() + .inspect_err(|_| { + self.device = None; + self.config = None; + }) + .map_err(|e| RecorderError::StreamError(e.to_string()))?; + + self.stream = Some(stream); + self.is_recording = true; + self.start_time = Some(Instant::now()); + + info!("Recording started"); + Ok(()) + } + + pub fn stop(&mut self) -> Result { + if !self.is_recording { + return Err(RecorderError::NotRecording); + } + + self.stream.take(); + self.is_recording = false; + + let duration = self.start_time.map_or(Duration::ZERO, |t| t.elapsed()); + let samples = self.samples.lock().map(|b| b.clone()).unwrap_or_default(); + + info!( + "Recording stopped: {} samples, {:.1}s", + samples.len(), + duration.as_secs_f32() + ); + + Ok(RecordedAudio { + samples, + sample_rate: self.sample_rate, + duration_secs: duration.as_secs() as u32, + }) + } + + pub fn cancel(&mut self) { + self.stream.take(); + self.is_recording = false; + self.start_time = None; + if let Ok(mut samples) = self.samples.lock() { + samples.clear(); + } + warn!("Recording cancelled"); + } +} + +/// Build the input stream for the device's sample format, converting to f32 +/// in the callback (same dispatch as the player's output path). +fn build_input_stream( + device: &Device, + config: StreamConfig, + samples: Arc>>, +) -> Result +where + f32: FromSample, +{ + let channels = config.channels as usize; + device + .build_input_stream( + config, + move |data: &[T], _: &cpal::InputCallbackInfo| { + let Ok(mut buffer) = samples.lock() else { + return; + }; + if channels == 1 { + buffer.extend(data.iter().map(|&s| f32::from_sample(s))); + } else { + // Downmix to mono + for chunk in data.chunks(channels) { + let mono: f32 = chunk.iter().map(|&s| f32::from_sample(s)).sum::() + / channels as f32; + buffer.push(mono); + } + } + }, + move |err| { + error!("Audio input stream error: {}", err); + }, + None, + ) + .map_err(|e| RecorderError::StreamError(e.to_string())) +} + +#[derive(Debug)] +pub enum RecorderError { + NoInputDevice, + NoSupportedConfig, + NotInitialized, + AlreadyRecording, + NotRecording, + DeviceError(String), + StreamError(String), +} + +impl std::fmt::Display for RecorderError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::NoInputDevice => write!(f, "No audio input device found"), + Self::NoSupportedConfig => write!(f, "No supported audio configuration found"), + Self::NotInitialized => write!(f, "Recorder not initialized"), + Self::AlreadyRecording => write!(f, "Already recording"), + Self::NotRecording => write!(f, "Not recording"), + Self::DeviceError(e) => write!(f, "Audio device error: {}", e), + Self::StreamError(e) => write!(f, "Audio stream error: {}", e), + } + } +} + +impl std::error::Error for RecorderError {} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn resample_interpolates_between_source_samples() { + // A linear ramp resamples to exact fractional positions; the old + // nearest-neighbor drop would return [0.0, 1.0, 3.0, 4.0]. + let audio = RecordedAudio { + samples: vec![0.0, 1.0, 2.0, 3.0, 4.0, 5.0], + sample_rate: 24_000, + duration_secs: 0, + }; + assert_eq!(audio.resample_to_16khz(), vec![0.0, 1.5, 3.0, 4.5]); + } + + #[test] + fn resample_decimation_has_unity_dc_gain() { + // The FIR taps are normalized to unity DC gain: a constant signal + // must come out at the same level (edges included — they clamp to + // the boundary sample, so even they see pure DC). + let audio = RecordedAudio { + samples: vec![0.5; 4800], + sample_rate: 48_000, + duration_secs: 0, + }; + let out = audio.resample_to_16khz(); + assert_eq!(out.len(), 1600); + for &s in &out { + assert!((s - 0.5).abs() < 1e-3, "DC gain drifted: {s}"); + } + } + + #[test] + fn resample_decimation_attenuates_aliasing_band() { + // 12kHz at 48k folds to 4kHz after naive 3:1 decimation — squarely + // in the voice band. The low-pass must crush it while passing 1kHz + // essentially untouched. + let rate = 48_000u32; + let tone = |freq: f32| -> RecordedAudio { + RecordedAudio { + samples: (0..rate as usize) + .map(|i| (std::f32::consts::TAU * freq * i as f32 / rate as f32).sin()) + .collect(), + sample_rate: rate, + duration_secs: 1, + } + }; + let rms = |samples: &[f32]| -> f32 { + (samples.iter().map(|s| s * s).sum::() / samples.len() as f32).sqrt() + }; + let low = tone(1_000.0).resample_to_16khz(); + let high = tone(12_000.0).resample_to_16khz(); + // Skip the clamped edges; a full-scale sine has ~0.707 rms. + let low_rms = rms(&low[100..low.len() - 100]); + let high_rms = rms(&high[100..high.len() - 100]); + assert!(low_rms > 0.65, "1kHz should pass through, rms {low_rms}"); + assert!( + high_rms < 0.02, + "12kHz should alias-filter to near silence, rms {high_rms}" + ); + } +} diff --git a/apps/desktop/src/audio/waveform.rs b/apps/desktop/src/audio/waveform.rs new file mode 100644 index 000000000..c39308a5b --- /dev/null +++ b/apps/desktop/src/audio/waveform.rs @@ -0,0 +1,70 @@ +//! Waveform generation for WhatsApp PTT voice messages. + +pub const WAVEFORM_SAMPLES: usize = 64; +const MAX_AMPLITUDE: u8 = 100; + +/// Generate a 64-byte waveform from audio samples using RMS. +pub fn generate_waveform(samples: &[f32]) -> Vec { + if samples.is_empty() { + return vec![0u8; WAVEFORM_SAMPLES]; + } + + // Ceiling division so the tail of the message is represented too + let chunk_size = samples.len().div_ceil(WAVEFORM_SAMPLES); + + let rms_values: Vec = samples + .chunks(chunk_size) + .take(WAVEFORM_SAMPLES) + .map(|chunk| { + let sum_squares: f32 = chunk.iter().map(|s| s * s).sum(); + (sum_squares / chunk.len() as f32).sqrt() + }) + .collect(); + + let max_rms = rms_values.iter().copied().fold(f32::MIN, f32::max); + if max_rms < f32::EPSILON { + return vec![0u8; WAVEFORM_SAMPLES]; + } + + let mut waveform: Vec = rms_values + .iter() + .map(|rms| ((rms / max_rms) * MAX_AMPLITUDE as f32) as u8) + .collect(); + waveform.resize(WAVEFORM_SAMPLES, 0); + waveform +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_waveform_length() { + let samples = vec![0.5f32; 1000]; + let waveform = generate_waveform(&samples); + assert_eq!(waveform.len(), WAVEFORM_SAMPLES); + } + + #[test] + fn test_waveform_range() { + let samples: Vec = (0..10000).map(|i| (i as f32 / 100.0).sin()).collect(); + let waveform = generate_waveform(&samples); + for &val in &waveform { + assert!(val <= MAX_AMPLITUDE); + } + } + + #[test] + fn test_empty_samples() { + let waveform = generate_waveform(&[]); + assert_eq!(waveform.len(), WAVEFORM_SAMPLES); + assert!(waveform.iter().all(|&v| v == 0)); + } + + #[test] + fn test_silent_audio() { + let samples = vec![0.0f32; 10000]; + let waveform = generate_waveform(&samples); + assert!(waveform.iter().all(|&v| v == 0)); + } +} diff --git a/apps/desktop/src/client/mod.rs b/apps/desktop/src/client/mod.rs new file mode 100644 index 000000000..56dc91f2a --- /dev/null +++ b/apps/desktop/src/client/mod.rs @@ -0,0 +1,8 @@ +//! WhatsApp client wrapper +//! +//! This module handles all communication with the WhatsApp service, +//! keeping the async/network logic separate from the UI. + +mod whatsapp; + +pub use whatsapp::{ReadBoundary, WhatsAppClient}; diff --git a/apps/desktop/src/client/whatsapp.rs b/apps/desktop/src/client/whatsapp.rs new file mode 100644 index 000000000..dbe43f557 --- /dev/null +++ b/apps/desktop/src/client/whatsapp.rs @@ -0,0 +1,2265 @@ +//! WhatsApp client wrapper for UI integration + +use std::collections::{HashMap, HashSet}; +use std::sync::Arc; + +use log::{debug, error, info, warn}; +use tokio::sync::{Mutex, mpsc}; +use wacore::proto_helpers::MessageExt; +use wacore::types::call::{CallAction, IncomingCall as WaIncomingCall}; +use wacore::types::events::Event; +use wacore::types::presence::ReceiptType; +use wacore_binary::jid::{Jid, JidExt, observe_str}; +use waproto::whatsapp as wa; +use whatsapp_rust::bot::Bot; +use whatsapp_rust::client::Client; +use whatsapp_rust::store::SqliteStore; +use whatsapp_rust::voip::CallHandle; +use whatsapp_rust_chat_store::ChatStore; + +use crate::audio::{spawn_mic, spawn_speaker}; +use crate::state::{ + Chat, ChatMessage, DownloadableMedia, IncomingCall, MediaContent, MediaType, UiEvent, + fallback_chat_name, +}; +use wacore::download::MediaType as DownloadMediaType; + +/// Resolve a stable per-user path for the SQLite database. A CWD-relative +/// path would silently split state between launch methods (desktop launcher +/// vs terminal), so prefer the platform data dir and only fall back to the +/// working directory when no home is known. +fn resolve_database_path() -> String { + const DB_FILE: &str = "whatsapp.db"; + + let not_empty = |v: std::ffi::OsString| (!v.is_empty()).then_some(std::path::PathBuf::from(v)); + let data_root = if cfg!(target_os = "macos") { + std::env::var_os("HOME") + .and_then(not_empty) + .map(|home| home.join("Library/Application Support")) + } else if cfg!(target_os = "windows") { + std::env::var_os("LOCALAPPDATA") + .and_then(not_empty) + .or_else(|| { + std::env::var_os("USERPROFILE") + .and_then(not_empty) + .map(|profile| profile.join("AppData").join("Local")) + }) + } else { + std::env::var_os("XDG_DATA_HOME") + .and_then(not_empty) + .or_else(|| { + std::env::var_os("HOME") + .and_then(not_empty) + .map(|home| home.join(".local/share")) + }) + }; + + let Some(dir) = data_root.map(|root| root.join("whatsapp-rust-desktop")) else { + warn!("No home directory found; using CWD-relative {DB_FILE}"); + return DB_FILE.to_string(); + }; + // SQLite won't create missing parent directories itself. + if let Err(e) = std::fs::create_dir_all(&dir) { + warn!("Failed to create desktop data dir: {e}; using CWD-relative {DB_FILE}"); + return DB_FILE.to_string(); + } + + dir.join(DB_FILE).to_string_lossy().into_owned() +} + +/// Helper struct for building DownloadableMedia from common message fields +struct DownloadableBuilder<'a> { + direct_path: Option<&'a str>, + media_key: Option<&'a [u8]>, + file_enc_sha256: Option<&'a [u8]>, + file_length: Option, + mime_type: &'a str, + duration_secs: Option, + download_type: DownloadMediaType, +} + +impl<'a> DownloadableBuilder<'a> { + /// Try to build a DownloadableMedia from the provided fields. + /// Returns None if any required field (direct_path, media_key, file_enc_sha256) is missing. + fn build(self) -> Option { + let direct_path = self.direct_path?; + let media_key = self.media_key?; + let file_enc_sha256 = self.file_enc_sha256?; + + Some(DownloadableMedia { + direct_path: direct_path.to_string(), + media_key: media_key.to_vec(), + file_enc_sha256: file_enc_sha256.to_vec(), + file_length: self.file_length.unwrap_or(0), + mime_type: self.mime_type.to_string(), + duration_secs: self.duration_secs, + download_type: self.download_type, + }) + } +} + +/// Shared client handle for accessing the WhatsApp client from UI +pub type ClientHandle = Arc>>>; + +/// Shared UI event sender for sending events from async operations +pub type UiEventSender = Arc>>>; + +/// Shared chat-store handle (durable message history in the same SQLite file) +pub type ChatStoreHandle = Arc>>>; + +pub type ReadBoundary = (i64, Vec<(String, bool, Option)>); + +type CallAudio = ( + async_channel::Receiver>, + async_channel::Sender>, +); + +async fn open_call_audio() -> Result { + tokio::task::spawn_blocking(|| { + let mic = spawn_mic().map_err(|e| e.to_string())?; + let speaker = spawn_speaker().map_err(|e| e.to_string())?; + Ok((mic, speaker)) + }) + .await + .map_err(|e| format!("audio setup task failed: {e}"))? +} + +fn participant_keyed_chat(jid: &Jid) -> bool { + jid.is_group() || jid.is_broadcast_list() || jid.is_status_broadcast() +} + +/// Live call state shared between the event pump and the UI action methods. +#[derive(Clone, Default)] +pub struct CallRegistry { + /// Ringing offers by call id, consumed by accept/decline. + pending: Arc>>>, + /// Media-live calls by call id. + active: Arc>>>, + /// Ids cancelled before any handle existed (the UI's placeholder id while + /// start_call is still connecting); start_call hangs these up on arrival. + cancelled: Arc>>, +} + +/// WhatsApp client wrapper that manages the connection and provides +/// a clean interface for UI operations. +pub struct WhatsAppClient { + /// Tokio runtime for async operations + runtime: Arc, + /// Shared client reference + client_handle: ClientHandle, + /// Shared UI event sender for sending events from operations like start_call + ui_sender: UiEventSender, + /// Live/ringing calls + calls: CallRegistry, + /// Durable chat history (same SQLite file as the device store) + chat_store: ChatStoreHandle, + /// Tears down `run_client` on retry: without it the replaced client's + /// thread would keep its runtime and SQLite pool alive forever (bot.run() + /// reconnects internally and never returns on its own). + shutdown: Arc, + /// Whether the client has been started + started: bool, +} + +impl WhatsAppClient { + /// Create a new WhatsApp client wrapper. Errors when the tokio runtime + /// can't be built (resource exhaustion) so a retry can route to the + /// error screen instead of panicking the UI thread. + pub fn new() -> std::io::Result { + let runtime = Arc::new( + tokio::runtime::Builder::new_multi_thread() + .enable_all() + .build()?, + ); + + Ok(Self { + runtime, + client_handle: Arc::new(Mutex::new(None)), + ui_sender: Arc::new(Mutex::new(None)), + calls: CallRegistry::default(), + chat_store: Arc::new(Mutex::new(None)), + shutdown: Arc::new(tokio::sync::Notify::new()), + started: false, + }) + } + + /// Stop the background run loop so its thread exits and the runtime and + /// SQLite handles drop. Idempotent; a signal fired before the loop is up + /// still lands (notify_one stores a permit). + pub fn shutdown(&self) { + self.shutdown.notify_one(); + } + + /// Get the runtime handle for UI async operations + #[allow(dead_code)] + pub fn runtime(&self) -> Arc { + self.runtime.clone() + } + + /// Get the client handle for sending messages + #[allow(dead_code)] + pub fn client_handle(&self) -> ClientHandle { + self.client_handle.clone() + } + + /// Durable chat history store, once the client is up (None before init). + #[allow(dead_code)] + pub fn chat_store(&self) -> ChatStoreHandle { + self.chat_store.clone() + } + + /// Start the WhatsApp client in a background thread + /// + /// Returns a receiver for UI events, or an error if already started + pub fn start(&mut self) -> Result, &'static str> { + if self.started { + return Err("WhatsApp client already started"); + } + self.started = true; + + let (ui_tx, ui_rx) = mpsc::unbounded_channel::(); + let client_handle = self.client_handle.clone(); + let ui_sender = self.ui_sender.clone(); + let calls = self.calls.clone(); + let chat_store = self.chat_store.clone(); + let runtime = self.runtime.clone(); + let shutdown = self.shutdown.clone(); + + let spawned = std::thread::Builder::new() + .name("whatsapp-desktop-client".to_string()) + .spawn(move || { + runtime.block_on(async move { + { + let mut guard = ui_sender.lock().await; + *guard = Some(ui_tx.clone()); + } + Self::run_client( + ui_tx, + client_handle, + calls, + chat_store, + ui_sender.clone(), + shutdown, + ) + .await; + }); + }); + if spawned.is_err() { + self.started = false; + return Err("failed to spawn WhatsApp client thread"); + } + + Ok(ui_rx) + } + + /// Internal async function to run the client + async fn run_client( + ui_tx: mpsc::UnboundedSender, + client_handle: ClientHandle, + calls: CallRegistry, + chat_store_handle: ChatStoreHandle, + ui_sender: UiEventSender, + shutdown: Arc, + ) { + // Device store + durable chat history share one SQLite file (one pool, + // one WAL writer). + let db_path = match tokio::task::spawn_blocking(resolve_database_path).await { + Ok(path) => path, + Err(e) => { + error!("Failed to resolve database path: {e}"); + let _ = ui_tx.send(UiEvent::Error("Database initialization failed".to_string())); + return; + } + }; + info!("Opening desktop data database"); + let backend = match SqliteStore::new(&db_path).await { + Ok(store) => store, + Err(e) => { + error!("Failed to create SQLite backend: {}", e); + let _ = ui_tx.send(UiEvent::Error(format!("Database error: {}", e))); + return; + } + }; + let chat_store = match ChatStore::new(&backend).await { + Ok(store) => store, + Err(e) => { + error!("Failed to open chat store: {}", e); + let _ = ui_tx.send(UiEvent::Error(format!("Database error: {}", e))); + return; + } + }; + { + let mut guard = chat_store_handle.lock().await; + *guard = Some(chat_store.clone()); + } + info!("SQLite backend + chat store initialized."); + + let ui_tx_clone = ui_tx.clone(); + let calls_clone = calls.clone(); + let ui_sender_clone = ui_sender.clone(); + + // Transport, HTTP client and runtime come from the default cargo + // features (Tokio WebSocket, ureq, Tokio). + let bot = match Bot::builder() + .with_backend(backend) + .on_event(move |event, client| { + let ui_tx = ui_tx_clone.clone(); + let calls = calls_clone.clone(); + let ui_sender = ui_sender_clone.clone(); + async move { + Self::handle_event(event, client, ui_tx, calls, ui_sender).await; + } + }) + .build() + .await + { + Ok(bot) => bot, + Err(e) => { + error!("Failed to build bot: {}", e); + let _ = ui_tx.send(UiEvent::Error(format!("Connection failed: {}", e))); + return; + } + }; + + // Hydrate the UI from durable history before the network is even up + // (bot.run() is what connects). The client is needed here so hydrated + // JIDs normalize through the same PN->LID mapping live events use. + match Self::load_history(&chat_store, &bot.client()).await { + Ok((chats, complete)) if !chats.is_empty() => { + let _ = ui_tx.send(UiEvent::HistoryLoaded { chats, complete }); + } + Ok(_) => {} + Err(e) => warn!("Failed to load chat history: {e}"), + } + + // The chat store materializes history straight off the event bus. + bot.client().register_handler(chat_store.handler()); + + // Re-hydrate the UI off the store's invalidation stream instead of raw + // client events: changes are emitted only after commit, so a reload can + // never observe pre-commit state (no flush barrier, no dispatch-order + // dependency), and the debounce coalesces history-sync bursts into one + // reload. HistoryLoaded merges, so re-sending is safe. + Self::spawn_history_reloader(chat_store.subscribe(), chat_store.clone(), &bot, &ui_tx); + + // Store client reference for UI to use + { + let mut guard = client_handle.lock().await; + *guard = Some(bot.client()); + } + + // Notify UI that init is complete + let _ = ui_tx.send(UiEvent::InitComplete); + + // bot.run() reconnects internally, so on its own it only returns after + // a logout; the shutdown signal is how a replaced client's thread gets + // to exit (letting block_on return drops the runtime + SQLite pool). + let client = bot.client(); + tokio::select! { + _ = bot.run() => {} + _ = shutdown.notified() => { + // Graceful stop: flushes state and closes the transport. The + // dropped run future is not awaited out instead, because a + // disconnect() landing before run()'s first poll would be + // clobbered by run's own is_running swap. + client.disconnect().await; + } + } + } + + /// Handle events from the WhatsApp client + async fn handle_event( + event: Arc, + client: Arc, + ui_tx: mpsc::UnboundedSender, + calls: CallRegistry, + ui_sender: UiEventSender, + ) { + match &*event { + Event::PairingQrCode(qr) => { + info!("QR code received"); + let _ = ui_tx.send(UiEvent::QrCode { + code: qr.code.clone(), + timeout_secs: qr.timeout.as_secs(), + }); + } + Event::PairingCode(pair) => { + info!("Pair code received"); + let _ = ui_tx.send(UiEvent::PairCode { + code: pair.code.clone(), + timeout_secs: pair.timeout.as_secs(), + }); + } + Event::PairSuccess(_) => { + info!("Pairing successful, syncing..."); + let _ = ui_tx.send(UiEvent::PairSuccess); + } + Event::Connected(_) => { + info!("Connected to WhatsApp!"); + let _ = ui_tx.send(UiEvent::Connected); + } + Event::LoggedOut(_) => { + info!("Logged out from WhatsApp"); + let _ = ui_tx.send(UiEvent::Disconnected("Logged out".to_string())); + } + Event::IncomingCall(call) => match &call.action { + CallAction::Offer { + call_id, is_video, .. + } => { + if call.offline { + info!("Ignoring offline call {} (stale)", call_id); + return; + } + info!("Incoming call from {}", call.from.observe()); + let offer = Arc::new(call.clone()); + calls + .pending + .lock() + .await + .insert(call_id.clone(), offer.clone()); + let caller_jid = normalize_chat_jid(&client, &call.from.to_string()).await; + let caller_name = call + .notify + .as_deref() + .filter(|name| !name.trim().is_empty()) + .map(str::to_owned) + .unwrap_or_else(|| fallback_chat_name(&call.from)); + let ui_call = IncomingCall::new( + call_id.clone(), + caller_name, + caller_jid, + *is_video, + offer, + ); + let _ = ui_tx.send(UiEvent::IncomingCall(ui_call)); + } + CallAction::Accept { call_id, .. } => { + info!("Call {} accepted by peer", call_id); + let _ = ui_tx.send(UiEvent::CallAccepted(call_id.clone())); + } + CallAction::Reject { call_id, .. } => { + info!("Call {} rejected by peer", call_id); + calls.pending.lock().await.remove(call_id); + let _ = ui_tx.send(UiEvent::CallEnded(call_id.clone())); + } + CallAction::Terminate { call_id, .. } => { + info!("Call {} terminated by peer", call_id); + calls.pending.lock().await.remove(call_id); + if let Some(handle) = calls.active.lock().await.remove(call_id) { + tokio::spawn(async move { handle.hangup().await }); + } + let _ = ui_tx.send(UiEvent::CallEnded(call_id.clone())); + } + _ => {} + }, + Event::MissedCall(missed) => { + info!( + "Missed call {} from {}", + missed.call_id, + missed.from.observe() + ); + calls.pending.lock().await.remove(&missed.call_id); + let _ = ui_tx.send(UiEvent::CallEnded(missed.call_id.clone())); + } + Event::CallEndedElsewhere(ended) => { + info!("Call {} handled on another device", ended.call_id); + calls.pending.lock().await.remove(&ended.call_id); + let _ = ui_tx.send(UiEvent::CallEnded(ended.call_id.clone())); + } + Event::Messages(batch) => { + for inbound in batch.iter() { + Self::handle_inbound_message(&inbound.message, &inbound.info, &client, &ui_tx) + .await; + } + } + Event::Receipt(receipt) => { + let Some(dominated_type) = (match &receipt.r#type { + ReceiptType::Read | ReceiptType::ReadSelf => Some(ReceiptType::Read), + ReceiptType::Played | ReceiptType::PlayedSelf => Some(ReceiptType::Played), + _ => None, + }) else { + return; + }; + + debug!( + "Receipt {:?} for {} message(s) in {}", + dominated_type, + receipt.message_ids.len(), + receipt.source.chat.observe() + ); + + // Normalize the chat JID + let normalized_chat_jid = + normalize_chat_jid(&client, &receipt.source.chat.to_string()).await; + + let _ = ui_tx.send(UiEvent::ReceiptReceived { + chat_jid: normalized_chat_jid, + message_ids: receipt.message_ids.clone(), + receipt_type: dominated_type, + }); + } + _ => { + let _ = ui_sender; // silences unused when no branch needs it + } + } + } + + /// One decrypted inbound message -> UiEvent (reaction or chat message). + async fn handle_inbound_message( + msg: &wa::Message, + info: &wacore::types::message::MessageInfo, + client: &Arc, + ui_tx: &mpsc::UnboundedSender, + ) { + // Use MessageExt to unwrap ephemeral/device_sent/view_once wrappers + let base_msg = msg.get_base_message(); + + // Check if this is a reaction message + if let Some(reaction) = base_msg.reaction_message.as_option() { + if let Some(key) = reaction.key.as_option() + && let Some(target_id) = &key.id + { + let emoji = reaction.text.clone().unwrap_or_default(); + debug!( + "Reaction '{}' from {} on message {}", + emoji, + info.source.sender.observe(), + target_id + ); + + // Use remote_jid from key if available, otherwise use chat from info + let chat_jid = key + .remote_jid + .clone() + .unwrap_or_else(|| info.source.chat.to_string()); + + let normalized_chat_jid = normalize_chat_jid(client, &chat_jid).await; + + let _ = ui_tx.send(UiEvent::ReactionReceived { + chat_jid: normalized_chat_jid, + message_id: target_id.clone(), + sender: info.source.sender.to_string(), + emoji, + }); + } + return; + } + + // Revokes/edits and other protocol stubs carry no displayable body; + // the chat store materializes them durably (a reload shows the right + // state), so don't fabricate a "[Media]" bubble under their own id. + if base_msg.protocol_message.is_set() { + return; + } + + // Try to extract media content + let media_result = Self::try_extract_media(base_msg, client).await; + + // Extract text content + let content = msg + .text_content() + .map(|s| s.to_string()) + .or_else(|| msg.get_caption().map(|s| s.to_string())) + .unwrap_or_else(|| { + if media_result.is_some() { + String::new() // Empty for media-only messages + } else { + "[Media]".to_string() + } + }); + + let mut chat_message = ChatMessage { + id: info.id.clone(), + sender: info.source.sender.to_string(), + sender_name: None, // Will be set in handle_message_received for groups + content, + timestamp: info.timestamp, + is_from_me: info.source.is_from_me, + is_read: false, + media: None, + reactions: std::collections::HashMap::new(), + failed: false, + }; + + if let Some(media) = media_result { + chat_message.media = Some(media); + } + + // Normalize chat JID to LID if mapping exists, so the same user doesn't + // appear as two chats when messages come from PN vs LID. + let normalized_chat_jid = normalize_chat_jid(client, &info.source.chat.to_string()).await; + + let sender_name = (!info.push_name.is_empty()).then(|| info.push_name.clone()); + + let _ = ui_tx.send(UiEvent::MessageReceived { + chat_jid: normalized_chat_jid, + message: Box::new(chat_message), + sender_name, + }); + } + + /// Helper to download media with logging + async fn download_media( + client: &Arc, + media: &T, + media_name: &str, + ) -> Option> { + info!("Downloading {}...", media_name); + match client.download(media).await { + Ok(data) => { + info!( + "{} downloaded successfully: {} bytes", + media_name, + data.len() + ); + Some(data) + } + Err(e) => { + warn!("Failed to download {}: {}", media_name, e); + None + } + } + } + + /// Try to extract and download media from a message + async fn try_extract_media(msg: &wa::Message, _client: &Arc) -> Option { + // Check for sticker message + if let Some(sticker) = effective_sticker(msg) { + let mime = sticker + .mimetype + .clone() + .unwrap_or_else(|| "image/webp".to_string()); + let downloadable = DownloadableBuilder { + direct_path: sticker.direct_path.as_deref(), + media_key: sticker.media_key.as_deref(), + file_enc_sha256: sticker.file_enc_sha256.as_deref(), + file_length: sticker.file_length, + mime_type: &mime, + duration_secs: None, + download_type: DownloadMediaType::Sticker, + } + .build(); + // Same rule as the image path below: a failed eager download + // degrades to the thumbnail (and stays retryable through the + // download metadata) instead of the message losing its media. + let (data, mime_type, is_animated, data_is_preview) = + match Self::download_media(_client, sticker, "sticker").await { + Some(data) => (data, mime, sticker.is_animated.unwrap_or(false), false), + None => ( + sticker + .png_thumbnail + .as_ref() + .filter(|t| !t.is_empty()) + .cloned() + .unwrap_or_default(), + "image/png".to_string(), + false, + true, + ), + }; + if data.is_empty() && downloadable.is_none() { + return None; + } + info!( + "Sticker: mime={}, is_animated={}, is_lottie={}, size={} bytes", + mime_type, + is_animated, + sticker.is_lottie.unwrap_or(false), + data.len() + ); + return Some(MediaContent { + media_type: MediaType::Sticker, + data: Arc::new(data), + mime_type, + width: sticker.width, + height: sticker.height, + caption: None, + file_name: None, + downloadable, + is_animated, + duration_secs: None, + data_is_preview, + }); + } + + // Check for image message + if let Some(image) = msg.image_message.as_option() { + let downloadable = DownloadableBuilder { + direct_path: image.direct_path.as_deref(), + media_key: image.media_key.as_deref(), + file_enc_sha256: image.file_enc_sha256.as_deref(), + file_length: image.file_length, + mime_type: image.mimetype.as_deref().unwrap_or("image/jpeg"), + duration_secs: None, + download_type: DownloadMediaType::Image, + } + .build(); + // A failed eager download keeps the metadata: the thumbnail shows + // now and the full image stays retryable, instead of the message + // degrading to a plain text row for the whole session. + let (data, mime_type, data_is_preview) = + match Self::download_media(_client, image, "image").await { + Some(data) => ( + data, + image + .mimetype + .clone() + .unwrap_or_else(|| "image/jpeg".to_string()), + false, + ), + None => ( + image + .jpeg_thumbnail + .as_ref() + .filter(|t| !t.is_empty()) + .cloned() + .unwrap_or_default(), + "image/jpeg".to_string(), + true, + ), + }; + if data.is_empty() && downloadable.is_none() { + return None; + } + return Some(MediaContent { + media_type: MediaType::Image, + data: Arc::new(data), + mime_type, + width: image.width, + height: image.height, + caption: image.caption.clone(), + file_name: None, + downloadable, + is_animated: false, + duration_secs: None, + data_is_preview, + }); + } + + // Check for video message - store thumbnail for preview, metadata for + // download. PTVs (round video notes) are the same proto type in a + // different field and play like any other video. + if let Some(video) = msg + .ptv_message + .as_option() + .or(msg.video_message.as_option()) + { + // Use thumbnail for display, or empty vec if none + let thumbnail_data = video + .jpeg_thumbnail + .as_ref() + .filter(|t| !t.is_empty()) + .cloned() + .unwrap_or_default(); + + // Build downloadable info using helper + let downloadable = DownloadableBuilder { + direct_path: video.direct_path.as_deref(), + media_key: video.media_key.as_deref(), + file_enc_sha256: video.file_enc_sha256.as_deref(), + file_length: video.file_length, + mime_type: video.mimetype.as_deref().unwrap_or("video/mp4"), + duration_secs: video.seconds, + download_type: DownloadMediaType::Video, + } + .build(); + + // Only return if we have either thumbnail or downloadable info + if !thumbnail_data.is_empty() || downloadable.is_some() { + return Some(MediaContent { + media_type: MediaType::Video, + data: Arc::new(thumbnail_data), + mime_type: "image/jpeg".to_string(), // Thumbnail is JPEG + width: video.width, + height: video.height, + caption: video.caption.clone(), + file_name: None, + downloadable, + is_animated: false, + duration_secs: video.seconds, + data_is_preview: false, + }); + } + } + + // Check for audio message - lazy load, only download when user clicks play + if let Some(audio) = msg.audio_message.as_option() { + let default_mime = "audio/ogg; codecs=opus"; + let mime_type = audio.mimetype.as_deref().unwrap_or(default_mime); + + // Build downloadable info using helper + let downloadable = DownloadableBuilder { + direct_path: audio.direct_path.as_deref(), + media_key: audio.media_key.as_deref(), + file_enc_sha256: audio.file_enc_sha256.as_deref(), + file_length: audio.file_length, + mime_type, + duration_secs: audio.seconds, + download_type: DownloadMediaType::Audio, + } + .build(); + + // Only return if we have downloadable info + if downloadable.is_some() { + return Some(MediaContent { + media_type: MediaType::Audio, + data: Arc::new(vec![]), // Empty until downloaded + mime_type: mime_type.to_string(), + width: None, + height: None, + caption: None, + file_name: None, + downloadable, + is_animated: false, + duration_secs: audio.seconds, + data_is_preview: false, + }); + } + } + + // Check for document message (no eager download, just metadata) + if let Some(doc) = msg.document_message.as_option() { + let mime = doc.mimetype.clone().unwrap_or_default(); + let downloadable = DownloadableBuilder { + direct_path: doc.direct_path.as_deref(), + media_key: doc.media_key.as_deref(), + file_enc_sha256: doc.file_enc_sha256.as_deref(), + file_length: doc.file_length, + mime_type: &mime, + duration_secs: None, + download_type: DownloadMediaType::Document, + } + .build(); + return Some(MediaContent { + media_type: MediaType::Document, + data: Arc::new(vec![]), + mime_type: mime, + width: None, + height: None, + caption: doc.caption.clone(), + file_name: doc.file_name.clone(), + downloadable, + is_animated: false, + duration_secs: None, + data_is_preview: false, + }); + } + + None + } + + /// Send a text message to a chat + pub fn send_message(&self, jid_str: &str, content: &str, local_id: String) { + let client_handle = self.client_handle.clone(); + let chat_store = self.chat_store.clone(); + let ui_sender = self.ui_sender.clone(); + let jid_str = jid_str.to_string(); + let content = content.to_string(); + let runtime = self.runtime.clone(); + + runtime.spawn(async move { + let jid: Jid = match jid_str.parse() { + Ok(j) => j, + Err(e) => { + error!("Invalid JID {}: {}", observe_str(&jid_str), e); + // The optimistic bubble still carries its local id; + // without this it would sit unsent with no indicator. + notify_send_failed(&ui_sender, &jid_str, &local_id, e.to_string()).await; + return; + } + }; + + // Clone the Arc and release the mutex: a slow network call + // here must not queue every other client action behind it. + let client = client_handle.lock().await.clone(); + if let Some(client) = client { + let message = wa::Message { + conversation: Some(content.clone()), + ..Default::default() + }; + + // Record BEFORE sending: the server ack event fires during + // send_message, so a row recorded after it would stay + // Pending forever (the ack precedes it in writer order). + let msg_id = client.generate_message_id(); + // Receipts/reactions arrive keyed by this id; rename the + // optimistic bubble before they can race it. + notify_message_id(&ui_sender, &jid_str, local_id, &msg_id).await; + record_outgoing(&chat_store, &jid, &msg_id, &message).await; + let options = whatsapp_rust::SendOptions { + message_id: Some(msg_id.clone()), + ..Default::default() + }; + match client + .send_message_with_options(jid.clone(), message, options) + .await + { + Ok(result) => { + info!("Message sent successfully: {}", result.message_id); + } + Err(e) => { + error!("Failed to send message {}: {}", msg_id, e); + mark_send_failed(&chat_store, &jid, &msg_id).await; + notify_send_failed(&ui_sender, &jid_str, &msg_id, e.to_string()).await; + } + } + } else { + error!("Client not available for sending message"); + // The bubble still carries its local id (no rename ran) + notify_send_failed( + &ui_sender, + &jid_str, + &local_id, + "client not available".to_string(), + ) + .await; + } + }); + } + + /// Download media using DownloadableMedia info + /// Returns a oneshot receiver that will contain the result + pub fn download_downloadable_media( + &self, + downloadable: DownloadableMedia, + ) -> tokio::sync::oneshot::Receiver, String>> { + let (tx, rx) = tokio::sync::oneshot::channel(); + let client_handle = self.client_handle.clone(); + let runtime = self.runtime.clone(); + + runtime.spawn(async move { + // Clone the Arc and release the mutex: a slow network call + // here must not queue every other client action behind it. + let client = client_handle.lock().await.clone(); + if let Some(client) = client { + info!( + "Downloading media: {} bytes expected", + downloadable.file_length + ); + match client.download(&downloadable).await { + Ok(data) => { + info!("Media downloaded successfully: {} bytes", data.len()); + let _ = tx.send(Ok(data)); + } + Err(e) => { + error!("Failed to download media: {}", e); + let _ = tx.send(Err(e.to_string())); + } + } + } else { + let _ = tx.send(Err("Client not available".to_string())); + } + }); + + rx + } + + /// Send a PTT audio message to a chat + pub fn send_audio_message( + &self, + jid_str: &str, + audio_data: Vec, + duration_secs: u32, + waveform: Vec, + local_id: String, + ) { + let chat_store = self.chat_store.clone(); + let ui_sender = self.ui_sender.clone(); + let client_handle = self.client_handle.clone(); + let jid_str = jid_str.to_string(); + let runtime = self.runtime.clone(); + + runtime.spawn(async move { + let jid: Jid = match jid_str.parse() { + Ok(j) => j, + Err(e) => { + error!("Invalid JID {}: {}", observe_str(&jid_str), e); + // The optimistic bubble still carries its local id; + // without this it would sit unsent with no indicator. + notify_send_failed(&ui_sender, &jid_str, &local_id, e.to_string()).await; + return; + } + }; + + // Clone the Arc and release the mutex: a slow network call + // here must not queue every other client action behind it. + let client = client_handle.lock().await.clone(); + if let Some(client) = client { + let upload_result = match client + .upload(audio_data, DownloadMediaType::Audio, Default::default()) + .await + { + Ok(resp) => resp, + Err(e) => { + error!("Failed to upload audio: {}", e); + // Bubble still carries the local id at this point. + notify_send_failed(&ui_sender, &jid_str, &local_id, e.to_string()).await; + return; + } + }; + + info!("Audio uploaded successfully"); + + let audio_message = wa::message::AudioMessage { + url: Some(upload_result.url), + direct_path: Some(upload_result.direct_path), + media_key: Some(upload_result.media_key.to_vec()), + file_sha256: Some(upload_result.file_sha256.to_vec()), + file_enc_sha256: Some(upload_result.file_enc_sha256.to_vec()), + file_length: Some(upload_result.file_length), + mimetype: Some("audio/ogg; codecs=opus".to_string()), + seconds: Some(duration_secs), + ptt: Some(true), // This marks it as a voice message + waveform: Some(waveform), + ..Default::default() + }; + + let message = wa::Message { + audio_message: buffa::MessageField::some(audio_message), + ..Default::default() + }; + + // Same ordering as the text path: record before sending so + // the ack can't precede the row in the writer queue. + let msg_id = client.generate_message_id(); + notify_message_id(&ui_sender, &jid_str, local_id, &msg_id).await; + record_outgoing(&chat_store, &jid, &msg_id, &message).await; + let options = whatsapp_rust::SendOptions { + message_id: Some(msg_id.clone()), + ..Default::default() + }; + match client + .send_message_with_options(jid.clone(), message, options) + .await + { + Ok(result) => { + info!("Audio message sent successfully: {}", result.message_id); + } + Err(e) => { + error!("Failed to send audio message {}: {}", msg_id, e); + mark_send_failed(&chat_store, &jid, &msg_id).await; + notify_send_failed(&ui_sender, &jid_str, &msg_id, e.to_string()).await; + } + } + } else { + error!("Client not available for sending audio message"); + // The bubble still carries its local id (no rename ran) + notify_send_failed( + &ui_sender, + &jid_str, + &local_id, + "client not available".to_string(), + ) + .await; + } + }); + } + + /// Send "composing" chat state (typing indicator) + pub fn send_composing(&self, jid_str: &str) { + let client_handle = self.client_handle.clone(); + let jid_str = jid_str.to_string(); + let runtime = self.runtime.clone(); + + runtime.spawn(async move { + let jid: Jid = match jid_str.parse() { + Ok(j) => j, + Err(e) => { + error!("Invalid JID {}: {}", observe_str(&jid_str), e); + return; + } + }; + + let client = client_handle.lock().await.clone(); + if let Some(client) = client + && let Err(e) = client.chatstate().send_composing(&jid).await + { + warn!("Failed to send composing state: {}", e); + } + }); + } + + /// Send "paused" chat state (stopped typing) + pub fn send_paused(&self, jid_str: &str) { + let client_handle = self.client_handle.clone(); + let jid_str = jid_str.to_string(); + let runtime = self.runtime.clone(); + + runtime.spawn(async move { + let jid: Jid = match jid_str.parse() { + Ok(j) => j, + Err(e) => { + error!("Invalid JID {}: {}", observe_str(&jid_str), e); + return; + } + }; + + let client = client_handle.lock().await.clone(); + if let Some(client) = client + && let Err(e) = client.chatstate().send_paused(&jid).await + { + warn!("Failed to send paused state: {}", e); + } + }); + } + + /// Send read receipts to mark messages as read + /// + /// # Arguments + /// * `chat_jid_str` - The JID of the chat (e.g., "123456@s.whatsapp.net") + /// * `messages` - List of (message_id, sender_jid_string) tuples + pub fn send_read_receipts(&self, chat_jid_str: &str, messages: Vec<(String, String)>) { + if messages.is_empty() { + return; + } + + let client_handle = self.client_handle.clone(); + let chat_jid_str = chat_jid_str.to_string(); + let runtime = self.runtime.clone(); + + runtime.spawn(async move { + let chat_jid: Jid = match chat_jid_str.parse() { + Ok(j) => j, + Err(e) => { + error!("Invalid chat JID {}: {}", observe_str(&chat_jid_str), e); + return; + } + }; + + let parsed_messages: Vec<(String, Jid)> = messages + .into_iter() + .filter_map(|(msg_id, sender_str)| { + sender_str + .parse::() + .inspect_err(|e| { + warn!("Invalid sender JID {}: {}", observe_str(&sender_str), e) + }) + .ok() + .map(|jid| (msg_id, jid)) + }) + .collect(); + + if parsed_messages.is_empty() { + return; + } + + // Only group/broadcast receipts carry a participant (matches + // whatsmeow/WA Web); a plain DM receipt must not. + let needs_participant = participant_keyed_chat(&chat_jid); + + // Clone the Arc and release the mutex: a slow network call + // here must not queue every other client action behind it. + let client = client_handle.lock().await.clone(); + if let Some(client) = client { + let mut by_sender: HashMap> = HashMap::new(); + for (msg_id, sender) in parsed_messages { + by_sender.entry(sender).or_default().push(msg_id); + } + for (sender, msg_ids) in by_sender { + let id_refs: Vec<&str> = msg_ids.iter().map(String::as_str).collect(); + if let Err(e) = client + .mark_as_read(&chat_jid, needs_participant.then_some(&sender), &id_refs) + .await + { + warn!("Failed to mark messages as read: {}", e); + } + } + } else { + error!("Client not available for sending read receipts"); + } + }); + } + + /// Synchronize a bounded read action so newer messages remain unread. + pub fn mark_chat_read(&self, chat_jid_str: &str, last_displayed: Option) { + let client_handle = self.client_handle.clone(); + let chat_jid_str = chat_jid_str.to_string(); + let runtime = self.runtime.clone(); + + runtime.spawn(async move { + let chat_jid: Jid = match chat_jid_str.parse() { + Ok(j) => j, + Err(e) => { + error!("Invalid chat JID {}: {}", observe_str(&chat_jid_str), e); + return; + } + }; + let Some(client) = client_handle.lock().await.clone() else { + error!("Client not available for marking chat read"); + return; + }; + let range = last_displayed.map(|boundary| read_message_range(&chat_jid, boundary)); + if let Err(e) = client + .chat_actions() + .mark_chat_as_read(&chat_jid, true, range) + .await + { + warn!("Failed to mark chat {} as read: {}", chat_jid.observe(), e); + } + }); + } + + /// Accept an incoming call: signaling, callKey decrypt, relay connect and + /// the audio engine are all inside `client.voip().accept(..)`; this side + /// only supplies the cpal mic/speaker bridge. + pub fn accept_call(&self, call_id: &str) { + let client_handle = self.client_handle.clone(); + let calls = self.calls.clone(); + let ui_sender = self.ui_sender.clone(); + let call_id = call_id.to_string(); + let runtime = self.runtime.clone(); + + runtime.spawn(async move { + let Some(client) = client_handle.lock().await.clone() else { + error!("Client not available for accepting call"); + return; + }; + let Some(offer) = calls.pending.lock().await.remove(&call_id) else { + warn!("No pending offer for call {}", call_id); + return; + }; + let (mic, speaker) = match open_call_audio().await { + Ok(audio) => audio, + Err(err) => { + error!("Audio device setup failed: {err}"); + // The offer is consumed and no accept went out: reject + // so the caller stops ringing instead of waiting out + // the timeout. + if let Err(e) = client.voip().reject(&offer).await { + error!( + "Failed to reject call {} after audio failure: {}", + call_id, e + ); + } + Self::notify_call_ended(&ui_sender, &call_id).await; + return; + } + }; + match client + .voip() + .accept(&offer) + .audio(mic, speaker) + .start() + .await + { + Ok(handle) => { + info!("Call {} media live", handle.call_id()); + let handle = Arc::new(handle); + calls + .active + .lock() + .await + .insert(call_id.clone(), handle.clone()); + Self::watch_call_end(handle, calls.clone(), ui_sender.clone()); + } + Err(e) => { + error!("Failed to start call media for {}: {}", call_id, e); + Self::notify_call_ended(&ui_sender, &call_id).await; + } + } + }); + } + + /// Decline an incoming call (sends the reject signaling). + pub fn decline_call(&self, call_id: &str) { + let client_handle = self.client_handle.clone(); + let calls = self.calls.clone(); + let call_id = call_id.to_string(); + let runtime = self.runtime.clone(); + + runtime.spawn(async move { + let Some(client) = client_handle.lock().await.clone() else { + error!("Client not available for declining call"); + return; + }; + let Some(offer) = calls.pending.lock().await.remove(&call_id) else { + warn!("No pending offer for call {}", call_id); + return; + }; + match client.voip().reject(&offer).await { + Ok(()) => info!("Call {} declined", call_id), + Err(e) => error!("Failed to decline call {}: {}", call_id, e), + } + }); + } + + /// Place an outgoing 1:1 voice call. Device discovery, callKey encrypt, + /// offer send and the relay/engine lifecycle are inside + /// `client.voip().call(..)`. Video calls are not supported by the library + /// yet; `is_video` only shapes the UI. + pub fn start_call(&self, recipient_jid_str: &str, is_video: bool, placeholder_id: String) { + let client_handle = self.client_handle.clone(); + let calls = self.calls.clone(); + let ui_sender = self.ui_sender.clone(); + let recipient_jid = recipient_jid_str.to_string(); + let runtime = self.runtime.clone(); + + if is_video { + warn!("Video calls are not supported yet; placing a voice call"); + } + + runtime.spawn(async move { + let notify_failure = |error: String| { + let ui_sender = ui_sender.clone(); + let recipient_jid = recipient_jid.clone(); + // A cancel may have landed for a call that will never + // start; consume the marker so the set doesn't grow. + let calls = calls.clone(); + let placeholder_id = placeholder_id.clone(); + async move { + calls.cancelled.lock().await.remove(&placeholder_id); + error!( + "Failed to start call to {}: {}", + observe_str(&recipient_jid), + error + ); + if let Some(tx) = ui_sender.lock().await.as_ref() { + let _ = tx.send(UiEvent::OutgoingCallFailed { + recipient_jid, + error, + }); + } + } + }; + + let jid: Jid = match recipient_jid.parse() { + Ok(j) => j, + Err(e) => { + notify_failure(format!("invalid JID: {e}")).await; + return; + } + }; + let Some(client) = client_handle.lock().await.clone() else { + notify_failure("client not available".to_string()).await; + return; + }; + let (mic, speaker) = match open_call_audio().await { + Ok(audio) => audio, + Err(err) => { + notify_failure(format!("audio device setup failed: {err}")).await; + return; + } + }; + + match client.voip().call(&jid).audio(mic, speaker).start().await { + Ok(handle) => { + let call_id = handle.call_id().to_string(); + // Cancelled while still connecting: the UI only knew + // the placeholder id, so honor it here. + if calls.cancelled.lock().await.remove(&placeholder_id) { + info!("Outgoing call {} cancelled before start", call_id); + handle.hangup().await; + return; + } + info!( + "Outgoing call {} to {} offered", + call_id, + observe_str(&recipient_jid) + ); + let handle = Arc::new(handle); + calls + .active + .lock() + .await + .insert(call_id.clone(), handle.clone()); + Self::watch_call_end(handle, calls.clone(), ui_sender.clone()); + if let Some(tx) = ui_sender.lock().await.as_ref() { + let _ = tx.send(UiEvent::OutgoingCallStarted { + call_id, + recipient_jid, + }); + } + } + Err(e) => notify_failure(e.to_string()).await, + } + }); + } + + /// Hang up / cancel a call we started or answered. + pub fn cancel_call(&self, call_id: &str) { + let calls = self.calls.clone(); + let call_id = call_id.to_string(); + let runtime = self.runtime.clone(); + + runtime.spawn(async move { + // Still ringing and never answered: nothing live to hang up. + calls.pending.lock().await.remove(&call_id); + if let Some(handle) = calls.active.lock().await.remove(&call_id) { + handle.hangup().await; + info!("Call {} hung up", call_id); + } else { + // No handle yet (start_call still connecting under a UI + // placeholder id): remember the cancel so it lands. + debug!("cancel_call: no live handle for {}, deferring", call_id); + calls.cancelled.lock().await.insert(call_id); + } + }); + } + + /// Mute or unmute the microphone of a live call. + #[allow(dead_code)] + pub fn set_call_muted(&self, call_id: &str, muted: bool) { + let calls = self.calls.clone(); + let call_id = call_id.to_string(); + let runtime = self.runtime.clone(); + runtime.spawn(async move { + if let Some(handle) = calls.active.lock().await.get(&call_id) { + handle.set_muted(muted); + } + }); + } + + /// Watch a live call until it ends (peer hangup, network loss, local + /// hangup) and clear it from the registry + UI. + fn watch_call_end(handle: Arc, calls: CallRegistry, ui_sender: UiEventSender) { + tokio::spawn(async move { + handle.wait_ended().await; + let call_id = handle.call_id().to_string(); + calls.active.lock().await.remove(&call_id); + Self::notify_call_ended(&ui_sender, &call_id).await; + }); + } + + async fn notify_call_ended(ui_sender: &UiEventSender, call_id: &str) { + if let Some(tx) = ui_sender.lock().await.as_ref() { + let _ = tx.send(UiEvent::CallEnded(call_id.to_string())); + } + } +} + +fn read_message_range( + chat_jid: &Jid, + (ts_secs, ids): ReadBoundary, +) -> wa::sync_action_value::SyncActionMessageRange { + use whatsapp_rust::features::{message_key, message_range}; + + let messages = ids + .into_iter() + .filter_map(|(id, from_me, sender)| { + let participant = if participant_keyed_chat(chat_jid) && !from_me { + let sender = sender?; + match sender.parse::() { + Ok(jid) => Some(jid), + Err(e) => { + warn!("Invalid chat participant {}: {e}", observe_str(&sender)); + return None; + } + } + } else { + None + }; + Some(( + message_key(id, chat_jid, from_me, participant.as_ref()), + ts_secs, + )) + }) + .collect(); + + message_range(ts_secs, None, messages) +} + +fn merge_alias_history_messages( + chat: &mut Chat, + mut messages: Vec, + alias_unread: u32, +) { + // Alias rows may be disjoint or repeated; only loaded message IDs prove + // that two unread counters overlap. + let existing_unread_ids: HashSet = chat + .messages + .iter() + .filter(|message| !message.is_from_me && !message.is_read) + .map(|message| message.id.clone()) + .collect(); + let duplicate_unread = messages + .iter() + .filter(|message| { + !message.is_from_me && !message.is_read && existing_unread_ids.contains(&message.id) + }) + .count() as u32; + + for message in &mut messages { + if !message.is_from_me && existing_unread_ids.contains(&message.id) { + message.is_read = false; + } + } + for message in messages { + chat.insert_history_message(message); + } + + let visible_unread = chat + .messages + .iter() + .filter(|message| !message.is_from_me && !message.is_read) + .count() as u32; + chat.unread_count = chat + .unread_count + .saturating_add(alias_unread) + .saturating_sub(duplicate_unread) + .max(visible_unread); +} + +impl WhatsAppClient { + const HISTORY_CHAT_LIMIT: i64 = 100; + const HISTORY_MESSAGES_PER_CHAT: i64 = 50; + /// Quiet window before reloading: one history-sync chunk commits as many + /// write batches, each emitting a change; reload once per burst. + const RELOAD_DEBOUNCE: std::time::Duration = std::time::Duration::from_millis(200); + + /// One task for the whole session: chat-store invalidations -> debounced + /// load_history -> HistoryLoaded. Exits when the store or the UI goes away. + fn spawn_history_reloader( + mut changes: tokio::sync::broadcast::Receiver, + chat_store: Arc, + bot: &Bot, + ui_tx: &mpsc::UnboundedSender, + ) { + use tokio::sync::broadcast::error::RecvError; + use whatsapp_rust_chat_store::StoreChange; + + let client = bot.client(); + let ui_tx = ui_tx.clone(); + tokio::spawn(async move { + let mut open = true; + while open { + match changes.recv().await { + // Contacts too: a push-name landing after the chat row + // must refresh chats stuck on the JID placeholder. + Ok( + StoreChange::Chats | StoreChange::Messages { .. } | StoreChange::Contacts, + ) => {} + // Missed changes are covered by the full reload below. + Err(RecvError::Lagged(_)) => {} + Err(RecvError::Closed) => break, + } + // Drain the burst; a quiet window flushes the reload. + loop { + match tokio::time::timeout(Self::RELOAD_DEBOUNCE, changes.recv()).await { + Ok(Ok(_)) | Ok(Err(RecvError::Lagged(_))) => continue, + Ok(Err(RecvError::Closed)) => { + // Reload once more: these changes were committed. + open = false; + break; + } + Err(_) => break, + } + } + // An empty load still goes out: the UI prunes against the + // loaded set, so deleting/archiving the last chat elsewhere + // must clear the list here too. + match Self::load_history(&chat_store, &client).await { + Ok((chats, complete)) => { + if ui_tx + .send(UiEvent::HistoryLoaded { chats, complete }) + .is_err() + { + break; + } + } + Err(e) => warn!("failed to reload history after store change: {e}"), + } + } + }); + } + + /// Build the UI chat list from the durable store: chats in display order, + /// each with its most recent page of messages. Media bodies are not + /// hydrated here (the proto is in the store; download stays on demand). + /// The returned flag says whether this is the store's WHOLE display list; + /// it comes from the raw entry count, since PN/LID collapsing can shrink + /// a truncated fetch back under the limit. + async fn load_history( + chat_store: &Arc, + client: &Arc, + ) -> Result<(Vec, bool), whatsapp_rust_chat_store::ChatStoreError> { + let entries = chat_store.chats(false, Self::HISTORY_CHAT_LIMIT).await?; + let complete = (entries.len() as i64) < Self::HISTORY_CHAT_LIMIT; + let mut chats: Vec = Vec::with_capacity(entries.len()); + let mut contact_names: HashMap> = HashMap::new(); + // Sender-name lookups memoized across the whole load: group pages + // repeat the same handful of senders many times over. + let mut sender_names: HashMap> = HashMap::new(); + for entry in entries { + // Same PN->LID mapping live events go through, or the restored + // chat and the next live message split into two conversations. + // A PN/LID pair of stored rows collapses into one chat: the most + // recently active row (entries arrive in display order) keeps the + // metadata, the older row's messages merge in. + let identity = history_chat_identity(client, &entry.jid).await; + let (name, name_priority) = resolve_history_chat_name( + chat_store, + &entry.jid, + entry.name.as_deref(), + &identity, + &mut contact_names, + ) + .await; + let jid_str = identity.canonical_jid; + if let Some(existing) = chats.iter_mut().find(|c| c.jid == jid_str) { + let mut page = chat_store + .messages(&entry.jid, None, Self::HISTORY_MESSAGES_PER_CHAT) + .await?; + page.reverse(); + let mut msgs: Vec = + page.into_iter().map(stored_to_chat_message).collect(); + Self::hydrate_reactions(chat_store, &entry.jid, &mut msgs).await; + if existing.is_group { + Self::hydrate_sender_names(chat_store, client, &mut msgs, &mut sender_names) + .await; + } + // Each alias still needs its unread tail marked for receipts, + // but PN/LID counters describe the same logical chat. + let mut remaining = entry.unread_count.max(0) as u32; + for msg in msgs.iter_mut().rev() { + if remaining == 0 { + break; + } + if !msg.is_from_me { + msg.is_read = false; + remaining -= 1; + } + } + merge_alias_history_messages(existing, msgs, entry.unread_count.max(0) as u32); + existing.manually_unread |= entry.unread_count < 0; + existing.set_name_if_better(name, name_priority); + continue; + } + let mut chat = + crate::state::Chat::with_name_priority(jid_str.clone(), name, name_priority); + // Store-originated: the HistoryLoaded prune may drop it when a + // later complete load no longer returns it. + chat.from_store = true; + chat.unread_count = entry.unread_count.max(0) as u32; + // -1 = manually marked unread (WA Web convention); .max(0) above + // must not silently eat the flag. + chat.manually_unread = entry.unread_count < 0; + chat.last_message = entry.last_message_preview.clone(); + chat.last_message_time = entry.last_message_at; + + let mut page = chat_store + .messages(&entry.jid, None, Self::HISTORY_MESSAGES_PER_CHAT) + .await?; + page.reverse(); // store returns newest-first; the UI renders oldest-first + chat.messages = page.into_iter().map(stored_to_chat_message).collect(); + Self::hydrate_reactions(chat_store, &entry.jid, &mut chat.messages).await; + if chat.is_group { + Self::hydrate_sender_names( + chat_store, + client, + &mut chat.messages, + &mut sender_names, + ) + .await; + } + // The newest `unread_count` incoming messages are the unread ones; + // select_chat only sends read receipts for !is_read, so hydrated + // unread must not come up pre-read. + let mut remaining = chat.unread_count; + for msg in chat.messages.iter_mut().rev() { + if remaining == 0 { + break; + } + if !msg.is_from_me { + msg.is_read = false; + remaining -= 1; + } + } + chats.push(chat); + } + Ok((chats, complete)) + } + + /// Reactions live in their own table, so hydrated messages come out with + /// an empty map; fold the stored rows back in. Per-message point lookups: + /// the store exposes no per-chat batch query. Best-effort: one bad row + /// must not abort the whole history load and blank the chat list. + async fn hydrate_reactions( + chat_store: &Arc, + chat_jid: &Jid, + msgs: &mut [ChatMessage], + ) { + for msg in msgs.iter_mut() { + // The store keeps one row per sender (latest wins), matching the + // live add_reaction semantics, so a plain rebuild is enough. + let entries = match chat_store.reactions(chat_jid, &msg.id).await { + Ok(entries) => entries, + Err(e) => { + warn!("failed to hydrate reactions for {}: {e}", msg.id); + continue; + } + }; + for entry in entries { + msg.reactions + .entry(entry.emoji) + .or_default() + .push(entry.sender_jid.to_string()); + } + } + } + + /// Group bubbles label their sender, but hydrated rows don't carry the + /// push name the live path attaches; resolve it from the contacts table. + /// `cache` memoizes per sender JID (misses included) so a page never pays + /// more than one query per unique sender. Best-effort like reactions: a + /// failed lookup logs and the bubble falls back to the JID label. + async fn hydrate_sender_names( + chat_store: &Arc, + client: &Arc, + msgs: &mut [ChatMessage], + cache: &mut HashMap>, + ) { + for msg in msgs.iter_mut() { + if msg.is_from_me || msg.sender_name.is_some() { + continue; + } + if !cache.contains_key(&msg.sender) { + let name = match msg.sender.parse::() { + Ok(jid) => { + let identity = history_chat_identity(client, &jid).await; + Some( + resolve_history_chat_name(chat_store, &jid, None, &identity, cache) + .await + .0, + ) + } + Err(_) => None, + }; + cache.insert(msg.sender.clone(), name); + } + msg.sender_name = cache.get(&msg.sender).cloned().flatten(); + } + } +} + +/// Convert a durable store row into the UI message model. Media stays +/// download-on-demand (the encoded proto lives in the store if needed later). +/// Media metadata (thumbnail + download info) from a message proto, without +/// downloading anything. Shared by hydration; the live path additionally +/// downloads images/stickers eagerly. +fn media_metadata(msg: &wa::Message) -> Option { + if let Some(sticker) = effective_sticker(msg) { + let mime = sticker + .mimetype + .clone() + .unwrap_or_else(|| "image/webp".to_string()); + let downloadable = DownloadableBuilder { + direct_path: sticker.direct_path.as_deref(), + media_key: sticker.media_key.as_deref(), + file_enc_sha256: sticker.file_enc_sha256.as_deref(), + file_length: sticker.file_length, + mime_type: &mime, + duration_secs: None, + download_type: DownloadMediaType::Sticker, + } + .build(); + let thumbnail = sticker + .png_thumbnail + .as_ref() + .filter(|thumbnail| !thumbnail.is_empty()) + .cloned() + .unwrap_or_default(); + if thumbnail.is_empty() && downloadable.is_none() { + return None; + } + let has_preview = !thumbnail.is_empty(); + return Some(MediaContent { + media_type: MediaType::Sticker, + data: Arc::new(thumbnail), + mime_type: if has_preview { + "image/png".to_string() + } else { + mime + }, + width: sticker.width, + height: sticker.height, + caption: None, + file_name: None, + data_is_preview: has_preview && downloadable.is_some(), + downloadable, + is_animated: !has_preview && sticker.is_animated.unwrap_or(false), + duration_secs: None, + }); + } + if let Some(image) = msg.image_message.as_option() { + let downloadable = DownloadableBuilder { + direct_path: image.direct_path.as_deref(), + media_key: image.media_key.as_deref(), + file_enc_sha256: image.file_enc_sha256.as_deref(), + file_length: image.file_length, + mime_type: image.mimetype.as_deref().unwrap_or("image/jpeg"), + duration_secs: None, + download_type: DownloadMediaType::Image, + } + .build(); + let thumbnail = image + .jpeg_thumbnail + .as_ref() + .filter(|t| !t.is_empty()) + .cloned() + .unwrap_or_default(); + if thumbnail.is_empty() && downloadable.is_none() { + return None; + } + // Hydrated rows carry only the thumbnail; flag it so the renderer + // keeps offering the full download instead of treating it as final + let data_is_preview = !thumbnail.is_empty() && downloadable.is_some(); + return Some(MediaContent { + media_type: MediaType::Image, + data: Arc::new(thumbnail), + mime_type: "image/jpeg".to_string(), + width: image.width, + height: image.height, + caption: image.caption.clone(), + file_name: None, + downloadable, + is_animated: false, + duration_secs: None, + data_is_preview, + }); + } + // PTVs (round video notes) are VideoMessage in a different field. + if let Some(video) = msg + .ptv_message + .as_option() + .or(msg.video_message.as_option()) + { + let downloadable = DownloadableBuilder { + direct_path: video.direct_path.as_deref(), + media_key: video.media_key.as_deref(), + file_enc_sha256: video.file_enc_sha256.as_deref(), + file_length: video.file_length, + mime_type: video.mimetype.as_deref().unwrap_or("video/mp4"), + duration_secs: video.seconds, + download_type: DownloadMediaType::Video, + } + .build(); + let thumbnail = video + .jpeg_thumbnail + .as_ref() + .filter(|t| !t.is_empty()) + .cloned() + .unwrap_or_default(); + if thumbnail.is_empty() && downloadable.is_none() { + return None; + } + return Some(MediaContent { + media_type: MediaType::Video, + data: Arc::new(thumbnail), + mime_type: "image/jpeg".to_string(), + width: video.width, + height: video.height, + caption: video.caption.clone(), + file_name: None, + downloadable, + is_animated: false, + duration_secs: video.seconds, + data_is_preview: false, + }); + } + if let Some(audio) = msg.audio_message.as_option() { + let mime = audio + .mimetype + .clone() + .unwrap_or_else(|| "audio/ogg; codecs=opus".to_string()); + let downloadable = DownloadableBuilder { + direct_path: audio.direct_path.as_deref(), + media_key: audio.media_key.as_deref(), + file_enc_sha256: audio.file_enc_sha256.as_deref(), + file_length: audio.file_length, + mime_type: &mime, + duration_secs: audio.seconds, + download_type: DownloadMediaType::Audio, + } + .build()?; + return Some(MediaContent { + media_type: MediaType::Audio, + data: Arc::new(vec![]), + mime_type: mime.clone(), + width: None, + height: None, + caption: None, + file_name: None, + downloadable: Some(downloadable), + is_animated: false, + duration_secs: audio.seconds, + data_is_preview: false, + }); + } + if let Some(doc) = msg.document_message.as_option() { + let mime = doc.mimetype.clone().unwrap_or_default(); + let downloadable = DownloadableBuilder { + direct_path: doc.direct_path.as_deref(), + media_key: doc.media_key.as_deref(), + file_enc_sha256: doc.file_enc_sha256.as_deref(), + file_length: doc.file_length, + mime_type: &mime, + duration_secs: None, + download_type: DownloadMediaType::Document, + } + .build(); + return Some(MediaContent { + media_type: MediaType::Document, + data: Arc::new(vec![]), + mime_type: mime, + width: None, + height: None, + caption: doc.caption.clone(), + file_name: doc.file_name.clone(), + downloadable, + is_animated: false, + duration_secs: None, + data_is_preview: false, + }); + } + None +} + +/// Some animated stickers arrive wrapped in the `lottie_sticker_message` +/// future-proof envelope instead of the top-level `sticker_message`. +fn effective_sticker(msg: &wa::Message) -> Option<&wa::message::StickerMessage> { + msg.sticker_message.as_option().or_else(|| { + msg.lottie_sticker_message + .as_option() + .and_then(|w| w.message.as_option()) + .and_then(|m| m.sticker_message.as_option()) + }) +} + +fn stored_to_chat_message(stored: whatsapp_rust_chat_store::StoredMessage) -> ChatMessage { + // The stored proto still carries the media envelope: hydrate thumbnails + + // download info so historical media renders and stays fetchable, instead + // of degrading to a [kind] text row until a live redelivery. + let media = (!stored.revoked) + .then_some(stored.message.as_deref()) + .flatten() + .and_then(|m| media_metadata(m.get_base_message())); + let content = match (&stored.text, stored.revoked) { + (_, true) => "[Message deleted]".to_string(), + (Some(text), _) => text.clone(), + (None, _) if media.is_some() => String::new(), + (None, _) => format!("[{}]", stored.kind.as_str()), + }; + // Outgoing ticks come from the stored delivery status; incoming default + // to read and load_history un-reads the chat's unread tail (per-incoming + // read state lives on the chat cursor, not the row). + let is_read = if stored.from_me { + matches!( + stored.status, + whatsapp_rust_chat_store::MessageStatus::Read + | whatsapp_rust_chat_store::MessageStatus::Played + ) + } else { + true + }; + ChatMessage { + id: stored.id, + sender: stored.sender_jid.to_string(), + sender_name: None, + content, + timestamp: stored.timestamp, + is_from_me: stored.from_me, + is_read, + media, + reactions: std::collections::HashMap::new(), + // Error is terminal for from_me rows (nack or local send failure), so + // hydration restores the failure indicator instead of grey ticks. + failed: stored.from_me && stored.status == whatsapp_rust_chat_store::MessageStatus::Error, + } +} + +/// Tell the UI which real id a just-sent optimistic bubble got. +async fn notify_message_id( + ui_sender: &UiEventSender, + chat_jid: &str, + local_id: String, + message_id: &str, +) { + if let Some(tx) = ui_sender.lock().await.as_ref() { + let _ = tx.send(UiEvent::MessageIdAssigned { + chat_jid: chat_jid.to_string(), + local_id, + message_id: message_id.to_string(), + }); + } +} + +/// Tell the UI a send failed so the bubble doesn't sit pending forever. +async fn notify_send_failed( + ui_sender: &UiEventSender, + chat_jid: &str, + message_id: &str, + reason: String, +) { + if let Some(tx) = ui_sender.lock().await.as_ref() { + let _ = tx.send(UiEvent::SendFailed { + chat_jid: chat_jid.to_string(), + message_id: message_id.to_string(), + reason, + }); + } +} + +/// Best-effort durable record of a message this client just sent; the UI's +/// optimistic bubble is independent of this. +async fn record_outgoing( + chat_store: &ChatStoreHandle, + jid: &Jid, + message_id: &str, + message: &wa::Message, +) { + if let Some(store) = chat_store.lock().await.as_ref() + && let Err(e) = store.record_outgoing(jid, message_id, message, wacore::time::now_utc()) + { + warn!("Failed to record outgoing message {}: {e}", message_id); + } +} + +/// Best-effort failure mark on the durable row a client-side send error +/// orphans at Pending (no server nack will come to fail it), so a restart +/// hydrates the bubble with its failure indicator instead of grey ticks. +async fn mark_send_failed(chat_store: &ChatStoreHandle, jid: &Jid, message_id: &str) { + if let Some(store) = chat_store.lock().await.as_ref() + && let Err(e) = store.mark_send_failed(jid, message_id) + { + warn!("Failed to mark send {} as failed: {e}", message_id); + } +} + +struct HistoryChatIdentity { + canonical_jid: String, + contact_jids: Vec, + fallback_name: String, + has_phone: bool, +} + +async fn history_chat_identity(client: &Client, jid: &Jid) -> HistoryChatIdentity { + let source = jid.to_non_ad(); + let mut canonical = source.clone(); + let mut contact_jids = vec![source.clone()]; + let mut fallback_jid = source.clone(); + let mut has_phone = source.is_pn(); + + if let Ok(Some(mapping)) = client.get_lid_pn_entry(&source).await { + let pn = Jid::pn(mapping.phone_number.as_ref()); + let lid = Jid::lid(mapping.lid.as_ref()); + canonical = lid.clone(); + fallback_jid = pn.clone(); + has_phone = true; + // Address-book names are normally PN-keyed; push names may be LID-keyed. + contact_jids = vec![pn, lid]; + } + + HistoryChatIdentity { + canonical_jid: canonical.to_string(), + contact_jids, + fallback_name: fallback_chat_name(&fallback_jid), + has_phone, + } +} + +fn is_masked_phone_label(name: &str) -> bool { + name.starts_with('+') + && name + .chars() + .filter(|c| matches!(c, '\u{00b7}' | '\u{2022}' | '\u{2219}')) + .count() + >= 2 +} + +async fn resolve_history_chat_name( + chat_store: &ChatStore, + source_jid: &Jid, + history_name: Option<&str>, + identity: &HistoryChatIdentity, + cache: &mut HashMap>, +) -> (String, u8) { + let usable_name = |name: &&str| { + !name.trim().is_empty() && !(identity.has_phone && is_masked_phone_label(name)) + }; + let history_name = history_name.filter(usable_name); + + if source_jid.is_pn() || source_jid.is_lid() { + for candidate in &identity.contact_jids { + let key = candidate.to_string(); + let cached = if let Some(name) = cache.get(&key) { + name.clone() + } else { + let name = chat_store + .contact(candidate) + .await + .ok() + .flatten() + .and_then(|contact| contact.display_name().map(str::to_owned)); + cache.insert(key, name.clone()); + name + }; + if let Some(name) = cached + .filter(|name| !name.trim().is_empty()) + .filter(|name| !(identity.has_phone && is_masked_phone_label(name))) + { + return (name, 3); + } + } + } + + if let Some(name) = history_name { + return (name.to_string(), 1); + } + + (identity.fallback_name.clone(), 0) +} + +/// Map a PN chat JID to its LID form when a mapping is known, so the same user +/// doesn't split into two chats (PN vs LID addressing). +async fn normalize_chat_jid(client: &Client, jid_str: &str) -> String { + let Ok(jid) = jid_str.parse::() else { + return jid_str.to_string(); + }; + if !jid.is_pn() { + return jid_str.to_string(); + } + match client.get_lid_pn_entry(&jid).await { + Ok(Some(entry)) => format!("{}@lid", entry.lid), + _ => jid_str.to_string(), + } +} + +impl Drop for WhatsAppClient { + fn drop(&mut self) { + // A dropped wrapper can never be shut down explicitly anymore; free + // its background thread instead of leaking the runtime + DB pool. + self.shutdown.notify_one(); + } +} + +#[cfg(test)] +mod tests { + use std::collections::HashMap; + + use super::{ + ReadBoundary, is_masked_phone_label, media_metadata, merge_alias_history_messages, + read_message_range, + }; + use crate::state::{Chat, ChatMessage, fallback_chat_name}; + use buffa::MessageField; + use wacore_binary::Jid; + use waproto::whatsapp as wa; + + #[test] + fn history_fallbacks_do_not_expose_internal_lids() { + let lid: Jid = "111222333444555@lid".parse().expect("test LID"); + let pn: Jid = "12025550143@s.whatsapp.net".parse().expect("test PN"); + let group: Jid = "120363000000000001@g.us".parse().expect("test group"); + + assert_eq!(fallback_chat_name(&lid), "Unknown contact"); + assert_eq!(fallback_chat_name(&pn), "+12025550143"); + assert_eq!(fallback_chat_name(&group), "Unnamed group"); + } + + #[test] + fn detects_server_masked_phone_labels() { + assert!(is_masked_phone_label("+55\u{2219}\u{2219}\u{2219}00")); + assert!(is_masked_phone_label("+234\u{2022}\u{2022}\u{2022}64")); + assert!(!is_masked_phone_label("+12025550143")); + assert!(!is_masked_phone_label("Fictitious Contact")); + } + + #[test] + fn alias_history_unread_deduplicates_only_matching_messages() { + let message = |id: &str| ChatMessage { + id: id.to_string(), + sender: "12025550143@s.whatsapp.net".to_string(), + sender_name: None, + content: id.to_string(), + timestamp: wacore::time::now_utc(), + is_from_me: false, + is_read: false, + media: None, + reactions: HashMap::new(), + failed: false, + }; + let mut chat = Chat::new("111222333444555@lid".to_string()); + chat.messages = vec![message("MSG-A"), message("MSG-B")]; + chat.unread_count = 2; + + merge_alias_history_messages( + &mut chat, + vec![message("MSG-B"), message("MSG-C"), message("MSG-D")], + 3, + ); + assert_eq!(chat.unread_count, 4); + assert_eq!(chat.messages.len(), 4); + + merge_alias_history_messages( + &mut chat, + vec![message("MSG-B"), message("MSG-C"), message("MSG-D")], + 3, + ); + assert_eq!(chat.unread_count, 4); + } + + #[test] + fn historical_sticker_keeps_thumbnail_without_download_metadata() { + let message = wa::Message { + sticker_message: MessageField::some(wa::message::StickerMessage { + png_thumbnail: Some(vec![1, 2, 3]), + width: Some(64), + height: Some(64), + ..Default::default() + }), + ..Default::default() + }; + + let media = media_metadata(&message).expect("sticker metadata"); + assert_eq!(media.data.as_slice(), [1, 2, 3]); + assert_eq!(media.mime_type, "image/png"); + assert!(media.downloadable.is_none()); + assert!(!media.data_is_preview); + } + + #[test] + fn participant_keyed_read_ranges_include_incoming_participants() { + let group: Jid = "120363000000000001@g.us".parse().expect("test group"); + let boundary: ReadBoundary = ( + 1_700_000_000, + vec![ + ( + "incoming".to_string(), + false, + Some("12025550143@s.whatsapp.net".to_string()), + ), + ("outgoing".to_string(), true, None), + ], + ); + + let range = read_message_range(&group, boundary); + let incoming = range.messages[0].key.as_option().expect("incoming key"); + let outgoing = range.messages[1].key.as_option().expect("outgoing key"); + + assert_eq!( + incoming.participant.as_deref(), + Some("12025550143@s.whatsapp.net") + ); + assert_eq!(outgoing.participant, None); + + let status: Jid = "status@broadcast".parse().expect("test status"); + let boundary = ( + 1_700_000_000, + vec![( + "status".to_string(), + false, + Some("12025550144@s.whatsapp.net".to_string()), + )], + ); + let range = read_message_range(&status, boundary); + assert_eq!( + range.messages[0] + .key + .as_option() + .expect("status key") + .participant + .as_deref(), + Some("12025550144@s.whatsapp.net") + ); + } +} diff --git a/apps/desktop/src/components/avatar.rs b/apps/desktop/src/components/avatar.rs new file mode 100644 index 000000000..abcf2a795 --- /dev/null +++ b/apps/desktop/src/components/avatar.rs @@ -0,0 +1,24 @@ +//! Avatar component wrapper using gpui-component Avatar + +use gpui::prelude::*; +use gpui_component::Sizable; +use gpui_component::avatar::Avatar as GpuiAvatar; + +/// Avatar helper for rendering user avatars with fallback initials +pub struct Avatar; + +impl Avatar { + /// Render an avatar with the given name (uses initials as fallback) + #[allow(dead_code)] + pub fn render(name: impl Into, size: f32) -> impl IntoElement { + GpuiAvatar::new() + .name(name) + .with_size(gpui_component::Size::Large) + .size(gpui::px(size)) + } + + /// Render an avatar with just an initial character + pub fn from_initial(initial: char, size: f32) -> impl IntoElement { + Self::render(initial.to_string(), size) + } +} diff --git a/apps/desktop/src/components/call_popup.rs b/apps/desktop/src/components/call_popup.rs new file mode 100644 index 000000000..088c3730c --- /dev/null +++ b/apps/desktop/src/components/call_popup.rs @@ -0,0 +1,145 @@ +//! Call popup components (incoming and outgoing) + +use gpui::{Entity, SharedString, div, prelude::*, px, rgb}; + +use crate::app::WhatsAppApp; +use crate::state::IncomingCall; +use crate::theme::{colors, layout}; + +use super::Avatar; + +/// Render the base call popup structure shared by incoming and outgoing popups. +/// +/// This creates the overlay, card, avatar, name, and call type display. +/// The `extra_content` closure allows adding custom content (buttons, status, etc). +pub fn render_call_popup_base( + name: SharedString, + initial: char, + is_video: bool, + extra_content: impl IntoElement, +) -> impl IntoElement { + let call_type_text = if is_video { "Video Call" } else { "Audio Call" }; + + // Overlay container - full screen semi-transparent background + div() + .absolute() + .inset_0() + .flex() + .items_center() + .justify_center() + .bg(gpui::rgba(0x00000099)) // Semi-transparent black overlay + .child( + // Popup card + div() + .w(px(320.0)) + .bg(rgb(colors::BG_SECONDARY)) + .rounded(px(layout::RADIUS_MEDIUM)) + .shadow_lg() + .p_6() + .flex() + .flex_col() + .items_center() + .gap_4() + // Avatar + .child(Avatar::from_initial(initial, 80.0)) + // Name + .child( + div() + .text_xl() + .font_weight(gpui::FontWeight::SEMIBOLD) + .text_color(rgb(colors::TEXT_PRIMARY)) + .child(name), + ) + // Call type indicator + .child( + div() + .text_sm() + .text_color(rgb(colors::TEXT_SECONDARY)) + .child(call_type_text), + ) + // Custom content (buttons, status, etc) + .child(extra_content), + ) +} + +/// Render the incoming call popup overlay +/// +/// This popup appears centered on screen when an incoming call is received. +/// It shows the caller name, call type (audio/video), and accept/decline buttons. +pub fn render_call_popup(call: &IncomingCall, app_entity: Entity) -> impl IntoElement { + let caller_name: SharedString = call.caller_name.clone().into(); + let initial = call.initial(); + let is_video = call.is_video; + + // Clone entity for callbacks + let accept_entity = app_entity.clone(); + let decline_entity = app_entity; + + let buttons = div() + .mt_4() + .flex() + .gap_6() + .child(render_call_button( + "Decline", + "✕", + colors::ACCENT_RED, + move |_, _window, cx| { + decline_entity.update(cx, |app, cx| { + app.decline_call(cx); + }); + }, + )) + .child(render_call_button( + "Accept", + "✓", + colors::ACCENT_GREEN, + move |_, _window, cx| { + accept_entity.update(cx, |app, cx| { + app.accept_call(cx); + }); + }, + )); + + render_call_popup_base(caller_name, initial, is_video, buttons) +} + +/// Render a circular call action button +fn render_call_button( + label: &'static str, + icon: &'static str, + color: u32, + on_click: impl Fn(&gpui::ClickEvent, &mut gpui::Window, &mut gpui::App) + 'static, +) -> impl IntoElement { + div() + .flex() + .flex_col() + .items_center() + .gap_2() + .child( + div() + .id(SharedString::from(format!("call-btn-{}", label))) + .w(px(56.0)) + .h(px(56.0)) + .bg(rgb(color)) + .rounded_full() + .flex() + .items_center() + .justify_center() + .cursor_pointer() + .hover(|s| s.opacity(0.8)) + .on_click(on_click) + .child( + div() + .text_color(rgb(colors::WHITE)) + .text_lg() + .font_weight(gpui::FontWeight::BOLD) + .child(icon), + ), + ) + .child( + div() + .text_xs() + .text_color(rgb(colors::TEXT_SECONDARY)) + .child(label), + ) +} diff --git a/apps/desktop/src/components/chat_header.rs b/apps/desktop/src/components/chat_header.rs new file mode 100644 index 000000000..82ac52c5f --- /dev/null +++ b/apps/desktop/src/components/chat_header.rs @@ -0,0 +1,109 @@ +//! Chat header with back button (mobile) and call buttons. + +use gpui::{Entity, SharedString, div, prelude::*, px, rgb}; +use gpui_component::Sizable; +use gpui_component::button::Button; +use gpui_component::{Icon, IconName}; + +use crate::app::WhatsAppApp; +use crate::responsive::ResponsiveLayout; +use crate::state::Chat; +use crate::theme::colors; + +use super::Avatar; + +/// Only plain PN/LID user JIDs can receive a call (not groups, broadcast +/// lists, status or newsletters). +fn is_callable_user(jid: &str) -> bool { + jid.parse::() + .map(|j| j.is_pn() || j.is_lid()) + .unwrap_or(false) +} + +pub fn render_chat_header( + chat: &Chat, + entity: Entity, + layout: ResponsiveLayout, +) -> impl IntoElement { + let initial = chat.name.chars().next().unwrap_or('?'); + let name: SharedString = chat.name.clone().into(); + let audio_jid = chat.jid.clone(); + + let back_entity = entity.clone(); + let audio_call_entity = entity; + + div() + .h(px(layout.header_height())) + .flex() + .items_center() + .justify_between() + .px(px(layout.padding())) + .gap(px(layout.gap())) + .bg(rgb(colors::BG_SECONDARY)) + .border_b_1() + .border_color(rgb(colors::BORDER)) + .child( + div() + .flex() + .flex_1() + .min_w_0() + .items_center() + .gap(px(layout.gap())) + .overflow_hidden() + .when(layout.show_back_button(), |el| { + el.child( + div() + .id("back-button") + .flex() + .items_center() + .justify_center() + .w(px(layout.icon_button_size())) + .h(px(layout.icon_button_size())) + .rounded(px(layout.icon_button_size() / 2.0)) + .cursor_pointer() + .hover(|s| s.bg(rgb(colors::BG_HOVER))) + .on_click(move |_, _, cx| { + back_entity.update(cx, |app, cx| app.navigate_back(cx)); + }) + .child( + Icon::new(IconName::ArrowLeft) + .text_color(rgb(colors::TEXT_PRIMARY)), + ), + ) + }) + .child(Avatar::from_initial(initial, layout.avatar_size())) + .child( + div() + .flex_1() + .min_w_0() + .text_color(rgb(colors::TEXT_PRIMARY)) + .font_weight(gpui::FontWeight::MEDIUM) + .overflow_hidden() + .text_ellipsis() + .whitespace_nowrap() + .child(name), + ), + ) + // Calls are 1:1 only: gate on a parsed PN/LID user JID, since + // !is_group alone would still offer calls to status/broadcast and + // newsletter rows. No video button: the VoIP facade only does audio, + // and offering "video" while placing a voice call misleads both sides. + .when( + layout.show_call_buttons() && is_callable_user(&chat.jid), + |el| { + el.child( + div().flex().flex_shrink_0().items_center().gap_2().child( + Button::new("audio-call") + .label("Call") + .outline() + .small() + .on_click(move |_, _window, cx| { + audio_call_entity.update(cx, |app, cx| { + app.start_call(audio_jid.clone(), false, cx) + }); + }), + ), + ) + }, + ) +} diff --git a/apps/desktop/src/components/chat_item.rs b/apps/desktop/src/components/chat_item.rs new file mode 100644 index 000000000..860ff58d7 --- /dev/null +++ b/apps/desktop/src/components/chat_item.rs @@ -0,0 +1,128 @@ +//! Chat list item component with responsive dimensions. + +use gpui::{Entity, SharedString, div, prelude::*, px, rgb}; + +use crate::app::WhatsAppApp; +use crate::responsive::ResponsiveLayout; +use crate::state::Chat; +use crate::theme::colors; + +use super::Avatar; + +fn single_line(text: String) -> String { + text.split_whitespace().collect::>().join(" ") +} + +pub fn render_chat_item( + chat: Chat, + is_selected: bool, + jid: String, + entity: Entity, + layout: ResponsiveLayout, +) -> impl IntoElement { + let name: SharedString = single_line(chat.name).into(); + let last_message: SharedString = chat + .last_message + .map(single_line) + .unwrap_or_else(|| "No messages".to_string()) + .into(); + let unread = chat.unread_count; + let manually_unread = chat.manually_unread; + let initial = name.chars().next().unwrap_or('?'); + + let bg = if is_selected { + rgb(colors::BG_SELECTED) + } else { + rgb(colors::BG_SECONDARY) + }; + + div() + .id(SharedString::from(format!("chat-{}", jid))) + .w_full() + .h(px(layout.chat_item_height())) + .flex() + .items_center() + .px(px(layout.padding_small())) + .gap(px(layout.gap())) + .cursor_pointer() + .bg(bg) + .when(!is_selected, |el| el.hover(|s| s.bg(rgb(colors::BG_HOVER)))) + .on_click(move |_, window, cx| { + entity.update(cx, |this, cx| this.select_chat(jid.clone(), window, cx)); + }) + .child(Avatar::from_initial(initial, layout.avatar_size())) + .child( + div() + .flex_1() + .min_w_0() + .flex() + .flex_col() + .gap_1() + .overflow_hidden() + .child( + div().flex().min_w_0().justify_between().child( + div() + .min_w_0() + .flex_1() + .text_color(rgb(colors::TEXT_PRIMARY)) + .font_weight(gpui::FontWeight::MEDIUM) + .overflow_hidden() + .text_ellipsis() + .whitespace_nowrap() + .child(name), + ), + ) + .child( + div() + .flex() + .min_w_0() + .justify_between() + .items_center() + .gap_2() + .child( + div() + .min_w_0() + .text_color(rgb(colors::TEXT_SECONDARY)) + .text_sm() + .overflow_hidden() + .text_ellipsis() + .whitespace_nowrap() + .flex_1() + .child(last_message), + ) + .when(unread > 0 || manually_unread, |el| { + // Manual mark-unread has no count: render a dot. + let label = if unread > 0 { + unread.to_string() + } else { + "\u{2022}".to_string() + }; + el.child( + div() + .flex_shrink_0() + .px_2() + .py_0p5() + .rounded_full() + .bg(rgb(colors::ACCENT_GREEN)) + .text_color(rgb(colors::WHITE)) + .text_xs() + .font_weight(gpui::FontWeight::BOLD) + .child(label), + ) + }), + ), + ) +} + +#[cfg(test)] +mod tests { + use super::single_line; + + #[test] + fn preview_whitespace_cannot_escape_the_row() { + assert_eq!( + single_line("Line one\nLine two\tLine three".to_string()), + "Line one Line two Line three" + ); + } +} diff --git a/apps/desktop/src/components/chat_list.rs b/apps/desktop/src/components/chat_list.rs new file mode 100644 index 000000000..615ede3eb --- /dev/null +++ b/apps/desktop/src/components/chat_list.rs @@ -0,0 +1,161 @@ +//! Chat list sidebar component with responsive layout support. + +use std::rc::Rc; +use std::sync::Arc; + +use gpui::{Entity, FocusHandle, Pixels, Size, div, prelude::*, px, rgb, size}; +use gpui_component::input::{Input, InputState}; +use gpui_component::{Icon, IconName, VirtualListScrollHandle, scroll::Scrollbar, v_virtual_list}; + +use crate::app::{ChatListCache, SelectDown, SelectUp, WhatsAppApp}; +use crate::responsive::ResponsiveLayout; +use crate::theme::colors; + +use super::render_chat_item; + +const CHAT_LIST_CONTEXT: &str = "ChatList"; + +pub fn render_chat_list( + cache: ChatListCache, + selected_jid: Option, + scroll_handle: &VirtualListScrollHandle, + focus_handle: &FocusHandle, + search_input: Option<&Entity>, + entity: Entity, + layout: ResponsiveLayout, +) -> impl IntoElement { + let item_sizes: Rc>> = Rc::new( + (0..cache.chats.len()) + .map(|_| size(px(layout.sidebar_width()), px(layout.chat_item_height()))) + .collect(), + ); + + let chats_arc = cache.chats; + let selected_jid_clone = selected_jid.clone(); + let entity_clone = entity.clone(); + let entity_for_up = entity.clone(); + let entity_for_down = entity.clone(); + let is_empty = chats_arc.is_empty(); + + let width = if layout.is_mobile() { + div().w_full() + } else { + div().w(px(layout.sidebar_width())) + }; + + width + .id("chat-list-container") + .key_context(CHAT_LIST_CONTEXT) + .track_focus(focus_handle) + .on_action(move |_: &SelectUp, window, cx| { + entity_for_up.update(cx, |app, cx| { + app.select_previous_chat(window, cx); + }); + }) + .on_action(move |_: &SelectDown, window, cx| { + entity_for_down.update(cx, |app, cx| { + app.select_next_chat(window, cx); + }); + }) + .flex() + .flex_col() + .h_full() + .bg(rgb(colors::BG_SECONDARY)) + // Only show border on non-mobile (mobile is full screen) + .when(!layout.is_mobile(), |el| { + el.border_r_1().border_color(rgb(colors::BORDER)) + }) + // Header with title + .child( + div() + .flex() + .items_center() + .justify_between() + .h(px(layout.header_height())) + .px(px(layout.padding())) + .border_b_1() + .border_color(rgb(colors::BORDER)) + .child( + div() + .text_color(rgb(colors::TEXT_PRIMARY)) + .text_xl() + .font_weight(gpui::FontWeight::BOLD) + .child("Chats"), + ), + ) + // Search input (only if available) + .when_some(search_input.cloned(), |el, input| { + el.child( + div() + .px(px(layout.padding_small())) + .py_2() + .border_b_1() + .border_color(rgb(colors::BORDER)) + .child( + Input::new(&input) + .prefix( + Icon::new(IconName::Search).text_color(rgb(colors::TEXT_SECONDARY)), + ) + .cleanable(true) + .appearance(false), + ), + ) + }) + // Chat list + .child( + div() + .flex_1() + .overflow_hidden() + .relative() + .when(is_empty, |el| { + el.child( + div() + .size_full() + .flex() + .justify_center() + .items_center() + .child( + div() + .text_color(rgb(colors::TEXT_SECONDARY)) + .child("No chats found"), + ), + ) + }) + .when(!is_empty, |el| { + el.child( + v_virtual_list(entity.clone(), "chat-list", item_sizes.clone(), { + let chats_for_render = Arc::clone(&chats_arc); + move |_view, visible_range, _scroll_handle, _cx| { + visible_range + .map(|ix| { + // Clone only visible chats (virtual list optimization) + let chat = chats_for_render[ix].clone(); + let is_selected = + selected_jid_clone.as_ref() == Some(&chat.jid); + let jid = chat.jid.clone(); + + render_chat_item( + chat, + is_selected, + jid, + entity_clone.clone(), + layout, + ) + }) + .collect() + } + }) + .track_scroll(scroll_handle) + .size_full(), + ) + .child( + div() + .absolute() + .top_0() + .right_0() + .bottom_0() + .child(Scrollbar::vertical(scroll_handle)), + ) + }), + ) +} diff --git a/apps/desktop/src/components/input_area_view.rs b/apps/desktop/src/components/input_area_view.rs new file mode 100644 index 000000000..f89a66b2b --- /dev/null +++ b/apps/desktop/src/components/input_area_view.rs @@ -0,0 +1,268 @@ +//! Isolated input area component with its own Entity and render cycle. +//! +//! This component is designed for performance: when the user types, +//! only this component re-renders, NOT the parent app. + +use std::time::Duration; + +use wacore::time::Instant; + +use gpui::{Entity, EventEmitter, Task, WeakEntity, Window, div, prelude::*, px, rgb}; +use gpui_component::{ + ActiveTheme, Icon, IconName, + button::{Button, ButtonVariants}, + input::{Input, InputEvent, InputState}, +}; + +use crate::theme::{colors, layout}; + +/// Events emitted by the input area to communicate with the parent app. +#[derive(Clone, Debug)] +pub enum InputAreaEvent { + /// User wants to send the current message + SendMessage(String), + /// User started PTT recording + StartRecording, + /// User stopped PTT recording (send the audio) + StopRecording, + /// Typing indicator: user started typing + StartedTyping, + /// Typing indicator: user stopped typing (timeout) + StoppedTyping, +} + +/// Typing indicator state with debouncing. +#[derive(Default)] +enum TypingState { + #[default] + Idle, + /// Currently typing - stores the instant of the last keystroke + Composing(Instant), +} + +/// Timeout before sending "paused" after typing stops (matches WhatsApp Web) +const TYPING_PAUSED_TIMEOUT: Duration = Duration::from_millis(2500); +/// How often the typing monitor checks for timeout +const TYPING_MONITOR_INTERVAL: Duration = Duration::from_millis(500); + +/// Isolated input area view with its own render cycle. +/// When the user types, only this component re-renders. +pub struct InputAreaView { + /// The input state entity + input: Entity, + /// Whether PTT recording is active + is_recording: bool, + /// Typing indicator state + typing_state: TypingState, + /// Task that monitors typing state + #[allow(dead_code)] + typing_monitor_task: Option>, +} + +impl EventEmitter for InputAreaView {} + +impl InputAreaView { + /// Create a new input area view + pub fn new(window: &mut Window, cx: &mut Context) -> Self { + let input = cx.new(|cx| InputState::new(window, cx).placeholder("Type a message")); + + // Subscribe to input events (for Enter key to send, etc.) + cx.subscribe_in(&input, window, Self::handle_input_event) + .detach(); + + Self { + input, + is_recording: false, + typing_state: TypingState::default(), + typing_monitor_task: None, + } + } + + /// Handle input events (Enter, Change, etc.) + fn handle_input_event( + &mut self, + _input: &Entity, + event: &InputEvent, + window: &mut Window, + cx: &mut Context, + ) { + match event { + InputEvent::PressEnter { .. } => { + self.submit_input(window, cx); + } + InputEvent::Change => { + // Handle typing indicator - minimal work, no notify + self.on_keystroke(cx); + } + _ => {} + } + } + + /// Handle a keystroke - updates typing state + /// Does NOT call cx.notify() to avoid triggering parent re-renders + fn on_keystroke(&mut self, cx: &mut Context) { + let now = Instant::now(); + + match self.typing_state { + TypingState::Idle => { + // First keystroke - start typing + self.typing_state = TypingState::Composing(now); + // Emit event to parent (they handle their own notification) + cx.emit(InputAreaEvent::StartedTyping); + self.start_typing_monitor(cx); + } + TypingState::Composing(_) => { + // Already typing - just update the timestamp (O(1), no allocations) + // NO notification needed - just internal state update + self.typing_state = TypingState::Composing(now); + } + } + } + + /// Start the typing monitor task + fn start_typing_monitor(&mut self, cx: &mut Context) { + self.typing_monitor_task = None; + + self.typing_monitor_task = Some(cx.spawn(async move |entity: WeakEntity, cx| { + loop { + smol::Timer::after(TYPING_MONITOR_INTERVAL).await; + + let should_stop = entity + .update(cx, |view, cx| { + let TypingState::Composing(last_keystroke) = view.typing_state else { + return true; + }; + if last_keystroke.elapsed() >= TYPING_PAUSED_TIMEOUT { + view.stop_typing_internal(cx); + return true; + } + false + }) + .unwrap_or(true); + + if should_stop { + break; + } + } + })); + } + + fn stop_typing_internal(&mut self, cx: &mut Context) { + if matches!(self.typing_state, TypingState::Composing(_)) { + cx.emit(InputAreaEvent::StoppedTyping); + } + self.typing_state = TypingState::Idle; + self.typing_monitor_task = None; + } + + /// Reset typing state without emitting StoppedTyping (called by parent on + /// chat switch, which routes the paused presence itself); otherwise the + /// state machine would stay Composing and swallow the first keystroke's + /// StartedTyping in the newly selected chat. + pub fn reset_typing(&mut self) { + self.typing_state = TypingState::Idle; + self.typing_monitor_task = None; + } + + /// Set recording state (called by parent) + pub fn set_recording(&mut self, is_recording: bool, cx: &mut Context) { + self.is_recording = is_recording; + cx.notify(); + } + + /// Swap the composed text on chat switch: install the target chat's draft + /// and hand the outgoing chat's draft back to the parent, so unsent text + /// never rides along to a different recipient. + pub fn swap_text( + &mut self, + new_text: &str, + window: &mut Window, + cx: &mut Context, + ) -> String { + let old = self.input.read(cx).text().to_string(); + self.input.update(cx, |state, cx| { + state.set_value(new_text, window, cx); + }); + old + } + + /// Read, trim-check, clear and emit the composed message (Enter and the + /// send button share this path). + fn submit_input(&mut self, window: &mut Window, cx: &mut Context) { + let text = self.input.read(cx).text().to_string(); + if text.trim().is_empty() { + return; + } + self.input.update(cx, |state, cx| { + state.set_value("", window, cx); + }); + self.stop_typing_internal(cx); + cx.emit(InputAreaEvent::SendMessage(text)); + } + + /// Toggle PTT recording + fn toggle_recording(&mut self, cx: &mut Context) { + if self.is_recording { + cx.emit(InputAreaEvent::StopRecording); + } else { + cx.emit(InputAreaEvent::StartRecording); + } + } +} + +impl Render for InputAreaView { + fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + let is_recording = self.is_recording; + let entity = cx.entity().clone(); + + let muted_fg = cx.theme().muted_foreground; + + div() + .h(px(layout::INPUT_AREA_HEIGHT)) + .flex() + .items_center() + .px_4() + .gap_3() + .bg(cx.theme().background) + .border_t_1() + .border_color(cx.theme().border) + .child(div().flex_1().child(Input::new(&self.input).w_full())) + .child( + div() + .flex() + .gap_2() + .child( + Button::new("ptt") + .icon(if is_recording { + Icon::default() + .path("icons/stop.svg") + .text_color(rgb(colors::WHITE)) + } else { + Icon::default().path("icons/mic.svg").text_color(muted_fg) + }) + .when(is_recording, |btn| btn.danger()) + .when(!is_recording, |btn| btn.ghost()) + .on_click({ + let entity = entity.clone(); + move |_, _window, cx| { + entity.update(cx, |view, cx| { + view.toggle_recording(cx); + }); + } + }), + ) + .child( + Button::new("send") + .icon(IconName::ArrowRight) + .primary() + .on_click({ + move |_, window, cx| { + entity.update(cx, |view, cx| { + view.submit_input(window, cx); + }); + } + }), + ), + ) + } +} diff --git a/apps/desktop/src/components/message_bubble.rs b/apps/desktop/src/components/message_bubble.rs new file mode 100644 index 000000000..9d73e42a2 --- /dev/null +++ b/apps/desktop/src/components/message_bubble.rs @@ -0,0 +1,718 @@ +//! Message bubble component with responsive layout support. + +use std::collections::HashMap; +use std::sync::Arc; + +use gpui::{ + Entity, Image, ImageSource, ObjectFit, RenderImage, SharedString, div, img, prelude::*, px, rgb, +}; +use gpui_component::button::{Button, ButtonVariants}; +use gpui_component::clipboard::Clipboard; +use gpui_component::h_flex; +use gpui_component::v_flex; +use gpui_component::{Disableable, Icon, IconName}; + +use crate::app::WhatsAppApp; +use crate::responsive::ResponsiveLayout; +use crate::state::{ChatMessage, DownloadableMedia, MediaType}; +use crate::theme::{colors, layout}; +use crate::utils::{format_time_local, mime_to_image_format, scale_media_dimensions}; +use crate::video::VideoPlayerState; + +pub fn render_message_bubble( + message: ChatMessage, + entity: Entity, + playing_message_id: Option, + is_group: bool, + show_sender: bool, + video_player_state: Option, + video_frame: Option>, + sticker_image: Option>, + responsive_layout: ResponsiveLayout, +) -> impl IntoElement { + let is_from_me = message.is_from_me; + let message_id = message.id.clone(); + let content: SharedString = message.content.clone().into(); + let time: SharedString = format_time_local(&message.timestamp).into(); + let media = message.media.clone(); + let content_for_copy = message.content.clone(); + let bubble_id: SharedString = format!("msg-{}", message.id).into(); + let is_playing = playing_message_id.as_ref() == Some(&message_id); + let send_failed = is_from_me && message.failed; + let reactions = message.reactions.clone(); + let has_reactions = !reactions.is_empty(); + let sender_name: Option = if is_group && !is_from_me && show_sender { + message.sender_name.clone().map(|s| s.into()) + } else { + None + }; + + div() + .w_full() + .flex() + .map(|el| { + if is_from_me { + el.justify_end() + } else { + el.justify_start() + } + }) + .pt(px(if show_sender { + layout::MSG_PADDING_TOP_FIRST + } else { + layout::MSG_PADDING_TOP_GROUPED + })) + .pb(px(layout::MSG_PADDING_BOTTOM)) + .child( + v_flex() + .items_end() + .when(!is_from_me, |el| el.items_start()) + .child( + div() + .id(bubble_id.clone()) + .max_w(px(responsive_layout.max_bubble_width())) + .px(px(layout::MSG_BUBBLE_PADDING_X)) + .py(px(layout::MSG_BUBBLE_PADDING_Y)) + .rounded(px(layout::RADIUS_MEDIUM)) + .bg(if is_from_me { + rgb(colors::BG_MESSAGE_SENT) + } else { + rgb(colors::BG_MESSAGE_RECEIVED) + }) + .child( + v_flex() + .gap(px(layout::MSG_CONTENT_GAP)) + .when_some(sender_name, |el, name| { + el.child( + div() + .text_sm() + .font_weight(gpui::FontWeight::SEMIBOLD) + .text_color(rgb(colors::ACCENT_GREEN)) + .child(name), + ) + }) + .when_some(media, |el, media_content| { + render_media_content( + el, + media_content, + message_id.clone(), + is_playing, + entity.clone(), + video_player_state, + video_frame.clone(), + sticker_image.clone(), + responsive_layout.max_media_size(), + ) + }) + .when(!content.is_empty(), |el| { + el.child( + div() + .overflow_hidden() + .text_color(rgb(colors::TEXT_PRIMARY)) + .child(content), + ) + }) + // Time and copy button row + .child( + div() + .flex() + .items_center() + .justify_between() + .gap_2() + .child( + div() + .text_color(rgb(colors::TEXT_SECONDARY)) + .text_xs() + .child(time), + ) + .when(send_failed, |el| { + el.child( + div() + .text_xs() + .text_color(rgb(colors::ERROR)) + .child("failed"), + ) + }) + .when(!content_for_copy.is_empty(), |el| { + el.child( + Clipboard::new(bubble_id).value(content_for_copy), + ) + }), + ), + ), + ) + .when(has_reactions, |el| { + el.child(render_reactions(reactions, is_from_me)) + }), + ) +} + +fn render_reactions(reactions: HashMap>, is_from_me: bool) -> impl IntoElement { + let mut sorted_reactions: Vec<_> = reactions.into_iter().collect(); + sorted_reactions.sort_by(|a, b| b.1.len().cmp(&a.1.len()).then(a.0.cmp(&b.0))); + + h_flex() + .gap_1() + .mt(px(layout::MSG_REACTION_MARGIN_TOP)) + .h(px(layout::MSG_REACTION_HEIGHT)) + .map(|el| { + if is_from_me { + el.justify_end() + } else { + el.justify_start() + } + }) + .px_1() + .children(sorted_reactions.into_iter().map(|(emoji, senders)| { + let count = senders.len(); + let emoji_str: SharedString = emoji.into(); + + div() + .px(px(6.)) + .py(px(2.)) + .rounded(px(12.)) + .bg(rgb(colors::BG_SELECTED)) + .border_1() + .border_color(rgb(colors::BORDER)) + .flex() + .items_center() + .gap(px(2.)) + .child(div().text_sm().child(emoji_str)) + .when(count > 1, |el| { + el.child( + div() + .text_xs() + .text_color(rgb(colors::TEXT_SECONDARY)) + .child(count.to_string()), + ) + }) + })) +} + +fn render_media_content( + el: gpui::Div, + media_content: crate::state::MediaContent, + message_id: String, + is_playing: bool, + entity: Entity, + video_player_state: Option, + video_frame: Option>, + sticker_image: Option>, + max_media_size: f32, +) -> gpui::Div { + match media_content.media_type { + MediaType::Image => { + let (display_w, display_h) = scale_media_dimensions( + media_content.width.unwrap_or(300), + media_content.height.unwrap_or(300), + max_media_size, + ); + + if !media_content.data.is_empty() { + let image = render_image_from_bytes( + media_content.data, + &media_content.mime_type, + display_w, + display_h, + true, + ); + if media_content.data_is_preview + && let Some(dl) = media_content.downloadable.clone() + { + // Only the fallback thumbnail is local: tapping it fetches + // the full image, same path as the empty placeholder + let preview_id: SharedString = format!("img-preview-{}", message_id).into(); + el.child( + div() + .id(preview_id) + .cursor_pointer() + .on_click(move |_, _window, cx| { + let msg_id = message_id.clone(); + let dl = dl.clone(); + entity.update(cx, |app, cx| { + app.download_image(msg_id, dl, cx); + }); + }) + .child(image), + ) + } else { + el.child(image) + } + } else if let Some(dl) = media_content.downloadable.clone() { + // Eager download failed but the metadata survived: keep the + // image fetchable on tap, like audio/video already are. + el.child(render_download_placeholder( + "img-dl", + "[Image] Tap to download", + message_id, + dl, + entity, + display_w, + display_h, + )) + } else { + el.child(render_media_placeholder("[Image]", display_w, display_h)) + } + } + MediaType::Sticker => { + let (display_w, display_h) = scale_media_dimensions( + media_content.width.unwrap_or(300), + media_content.height.unwrap_or(300), + max_media_size, + ); + + if media_content.data_is_preview + && !media_content.data.is_empty() + && let Some(dl) = media_content.downloadable.clone() + { + // Only the fallback PNG thumbnail is local: tapping fetches + // the real sticker, mirroring the image preview branch. + let image = render_image_from_bytes( + media_content.data, + &media_content.mime_type, + display_w, + display_h, + false, + ); + let preview_id: SharedString = format!("sticker-preview-{}", message_id).into(); + el.child( + div() + .id(preview_id) + .cursor_pointer() + .on_click(move |_, _window, cx| { + let msg_id = message_id.clone(); + let dl = dl.clone(); + entity.update(cx, |app, cx| { + app.download_image(msg_id, dl, cx); + }); + }) + .child(image), + ) + } else if let Some(cached_image) = sticker_image { + let sticker_id: SharedString = format!("sticker-{}", message_id).into(); + el.child( + img(ImageSource::Image(cached_image)) + .id(sticker_id) + .w(px(display_w)) + .h(px(display_h)) + .object_fit(gpui::ObjectFit::Contain), + ) + } else if !media_content.data.is_empty() { + el.child(render_image_from_bytes( + media_content.data, + &media_content.mime_type, + display_w, + display_h, + false, + )) + } else if let Some(dl) = media_content.downloadable.clone() { + // Hydrated stickers (and failed eager downloads without a + // thumbnail) carry only metadata: fetch on tap like images. + el.child(render_download_placeholder( + "sticker-dl", + "[Sticker] Tap to download", + message_id, + dl, + entity, + display_w, + display_h, + )) + } else { + el.child(render_media_placeholder("[Sticker]", display_w, display_h)) + } + } + MediaType::Video => el.child(render_video_player( + media_content, + message_id, + entity, + video_player_state, + video_frame, + max_media_size, + )), + MediaType::Audio => el.child(render_audio_player( + media_content, + message_id, + is_playing, + entity, + )), + MediaType::Document => el.child(render_document_placeholder( + media_content, + message_id, + entity, + )), + } +} + +/// Tap-to-download placeholder for media whose bytes aren't local yet, sized +/// like the real media so virtual-list row heights don't jump on arrival. +fn render_download_placeholder( + id_prefix: &str, + label: &'static str, + message_id: String, + dl: DownloadableMedia, + entity: Entity, + width: f32, + height: f32, +) -> impl IntoElement { + let placeholder_id: SharedString = format!("{id_prefix}-{message_id}").into(); + div() + .id(placeholder_id) + .w(px(width)) + .h(px(height)) + .bg(rgb(colors::BG_SELECTED)) + .rounded(px(layout::RADIUS_SMALL)) + .cursor_pointer() + .flex() + .justify_center() + .items_center() + .child(div().text_color(rgb(colors::TEXT_SECONDARY)).child(label)) + .on_click(move |_, _window, cx| { + let msg_id = message_id.clone(); + let dl = dl.clone(); + entity.update(cx, |app, cx| { + app.download_image(msg_id, dl, cx); + }); + }) +} + +fn render_media_placeholder(text: &'static str, width: f32, height: f32) -> impl IntoElement { + div() + .w(px(width)) + .h(px(height)) + .bg(rgb(colors::BG_SELECTED)) + .rounded(px(layout::RADIUS_SMALL)) + .flex() + .justify_center() + .items_center() + .child(div().text_color(rgb(colors::TEXT_SECONDARY)).child(text)) +} + +fn render_image_from_bytes( + data: Arc>, + mime_type: &str, + width: f32, + height: f32, + rounded: bool, +) -> gpui::Img { + let format = mime_to_image_format(mime_type); + let image_data = Arc::unwrap_or_clone(data); + let image = Image::from_bytes(format, image_data); + + let img_el = img(ImageSource::Image(Arc::new(image))) + .w(px(width)) + .h(px(height)) + .object_fit(gpui::ObjectFit::Contain); + + if rounded { + img_el.rounded(px(layout::RADIUS_SMALL)) + } else { + img_el + } +} + +fn render_audio_player( + media_content: crate::state::MediaContent, + message_id: String, + is_playing: bool, + entity: Entity, +) -> impl IntoElement { + let has_data = media_content.has_data(); + let can_download = media_content.can_download(); + let can_play = has_data || can_download; + let downloadable = media_content.downloadable.clone(); + let button_id: SharedString = format!("play-{}", message_id).into(); + let duration_text: SharedString = if let Some(secs) = media_content.duration_secs { + let mins = secs / 60; + let secs = secs % 60; + format!("{:02}:{:02}", mins, secs).into() + } else { + "Voice message".into() + }; + + div() + .w(px(220.)) + .h(px(44.)) + .bg(rgb(colors::BG_SELECTED)) + .rounded(px(layout::RADIUS_LARGE)) + .flex() + .items_center() + .px_2() + .gap_2() + .child( + Button::new(button_id) + .icon( + Icon::default() + .path(if is_playing { + "icons/pause.svg" + } else { + "icons/play.svg" + }) + .text_color(rgb(colors::TEXT_PRIMARY)), + ) + .ghost() + .disabled(!can_play) + .on_click({ + let data = media_content.data.clone(); + let downloadable = downloadable.clone(); + move |_, _window, cx| { + let msg_id = message_id.clone(); + entity.update(cx, |app, cx| { + if !data.is_empty() { + app.toggle_audio(msg_id, (*data).clone(), cx); + } else if let Some(dl) = downloadable.clone() { + app.toggle_audio_lazy(msg_id, dl, cx); + } + }); + } + }), + ) + .child( + div() + .flex_1() + .h(px(24.)) + .rounded(px(4.)) + .bg(rgb(if is_playing { + colors::ACCENT_GREEN + } else { + colors::BG_HOVER + })) + .flex() + .items_center() + .justify_center() + .child( + div() + .text_xs() + .text_color(rgb(colors::TEXT_SECONDARY)) + .child(match (is_playing, !has_data && can_download) { + (true, _) => SharedString::from("Playing..."), + (_, true) => SharedString::from("Tap to download"), + _ => duration_text, + }), + ), + ) +} + +fn render_document_placeholder( + media_content: crate::state::MediaContent, + message_id: String, + entity: Entity, +) -> impl IntoElement { + let label: SharedString = media_content + .file_name + .clone() + .unwrap_or_else(|| "Document".to_string()) + .into(); + let row = div() + .w(px(200.)) + .h(px(50.)) + .bg(rgb(colors::BG_SELECTED)) + .rounded(px(layout::RADIUS_MEDIUM)) + .flex() + .items_center() + .px_3() + .gap_2() + .child( + div() + .min_w_0() + .flex_1() + .overflow_hidden() + .text_ellipsis() + .whitespace_nowrap() + .text_color(rgb(colors::TEXT_SECONDARY)) + .child(label), + ); + + // Doc bytes are never cached for rendering; with download metadata the + // row saves the file to the Downloads dir on tap. + if let Some(dl) = media_content.downloadable { + let file_name = media_content + .file_name + .unwrap_or_else(|| "document".to_string()); + let doc_id: SharedString = format!("doc-{}", message_id).into(); + row.id(doc_id) + .cursor_pointer() + .on_click(move |_, _window, cx| { + let msg_id = message_id.clone(); + let name = file_name.clone(); + let dl = dl.clone(); + entity.update(cx, |app, cx| { + app.download_document(msg_id, name, dl, cx); + }); + }) + .into_any_element() + } else { + row.into_any_element() + } +} + +fn render_video_player( + media_content: crate::state::MediaContent, + message_id: String, + entity: Entity, + video_player_state: Option, + video_frame: Option>, + max_media_size: f32, +) -> impl IntoElement { + let (display_w, display_h) = scale_media_dimensions( + media_content.width.unwrap_or(300), + media_content.height.unwrap_or(200), + max_media_size, + ); + + let button_id: SharedString = format!("video-{}", message_id).into(); + let state = video_player_state.unwrap_or(VideoPlayerState::Idle); + let downloadable = media_content.downloadable.clone(); + let can_download = media_content.can_download(); + let is_playing = state.is_playing(); + let is_paused = state.is_paused(); + let is_loading = state.is_loading(); + let is_error = state.is_error(); + + div() + .relative() + .w(px(display_w)) + .h(px(display_h)) + .rounded(px(layout::RADIUS_SMALL)) + .overflow_hidden() + .child( + if let Some(frame) = video_frame.filter(|_| is_playing || is_paused) { + // Frame is a pre-decoded RGBA `RenderImage`; render with the + // standard `img()` element. GPU-side YUV surfaces (the old + // `surface()` path) are macOS-only upstream. + div() + .w_full() + .h_full() + .child( + img(frame) + .w(px(display_w)) + .h(px(display_h)) + .object_fit(ObjectFit::Contain), + ) + .into_any_element() + } else if !media_content.data.is_empty() { + div() + .w_full() + .h_full() + .child(render_image_from_bytes( + media_content.data, + &media_content.mime_type, + display_w, + display_h, + false, + )) + .into_any_element() + } else { + div() + .w_full() + .h_full() + .bg(rgb(colors::BG_SELECTED)) + .flex() + .justify_center() + .items_center() + .child( + div() + .text_color(rgb(colors::TEXT_SECONDARY)) + .child("[Video]"), + ) + .into_any_element() + }, + ) + .child( + div() + .absolute() + .inset_0() + .flex() + .justify_center() + .items_center() + .bg(gpui::rgba(0x00000066)) + .when(is_playing, |el| el.bg(gpui::rgba(0x00000000))) + .child(if is_loading { + div() + .w(px(48.)) + .h(px(48.)) + .rounded_full() + .bg(gpui::rgba(0x00000088)) + .flex() + .justify_center() + .items_center() + .child(div().text_color(rgb(colors::TEXT_PRIMARY)).text_sm().child( + if state == VideoPlayerState::Downloading { + "↓" + } else { + "⏳" + }, + )) + .into_any_element() + } else if is_error { + // toggle_video's Error arm restarts the download; without + // a handler a transient failure left the video stuck. + div() + .id(button_id) + .w(px(48.)) + .h(px(48.)) + .rounded_full() + .bg(gpui::rgba(0xFF000088)) + .flex() + .justify_center() + .items_center() + .child( + Icon::new(IconName::Redo) + .text_color(rgb(colors::TEXT_PRIMARY)) + .size(px(20.)), + ) + .when_some(downloadable.clone(), |el, dl| { + el.cursor_pointer().on_click(move |_, _window, cx| { + let msg_id = message_id.clone(); + let dl = dl.clone(); + entity.update(cx, |app, cx| { + app.toggle_video(msg_id, dl, cx); + }); + }) + }) + .into_any_element() + } else if !is_playing { + Button::new(button_id) + .icon( + Icon::default() + .path("icons/play.svg") + .text_color(rgb(colors::TEXT_PRIMARY)) + .size(px(32.)), + ) + .ghost() + .disabled(!can_download) + .on_click({ + let downloadable = downloadable.clone(); + move |_, _window, cx| { + if let Some(dl) = downloadable.clone() { + let msg_id = message_id.clone(); + entity.update(cx, |app, cx| { + app.toggle_video(msg_id, dl, cx); + }); + } + } + }) + .into_any_element() + } else { + Button::new(button_id) + .icon( + Icon::default() + .path("icons/pause.svg") + .text_color(gpui::rgba(0xFFFFFF66)) + .size(px(24.)), + ) + .ghost() + .on_click({ + let downloadable = downloadable.clone(); + move |_, _window, cx| { + if let Some(dl) = downloadable.clone() { + let msg_id = message_id.clone(); + entity.update(cx, |app, cx| { + app.toggle_video(msg_id, dl, cx); + }); + } + } + }) + .into_any_element() + }), + ) +} diff --git a/apps/desktop/src/components/message_list.rs b/apps/desktop/src/components/message_list.rs new file mode 100644 index 000000000..aa1b1a11f --- /dev/null +++ b/apps/desktop/src/components/message_list.rs @@ -0,0 +1,112 @@ +//! Message list with virtual scrolling and responsive layout. + +use std::sync::Arc; + +use gpui::{Entity, div, prelude::*, px, rgb}; +use gpui_component::{VirtualListScrollHandle, scroll::Scrollbar, v_virtual_list}; + +use crate::app::{MessageListCache, WhatsAppApp}; +use crate::responsive::ResponsiveLayout; +use crate::state::MediaType; +use crate::theme::colors; + +use super::render_message_bubble; + +/// Renders the message list using cached values to avoid recomputation on every render. +pub fn render_message_list( + cache: MessageListCache, + scroll_handle: &VirtualListScrollHandle, + entity: Entity, + _playing_message_id: Option, + is_group: bool, + layout: ResponsiveLayout, +) -> impl IntoElement { + // Use pre-computed values from cache (cheap Rc/Arc clones) + let messages_arc = cache.messages; + let show_sender_flags = cache.show_sender_flags; + let item_sizes = cache.item_sizes; + + let is_empty = messages_arc.is_empty(); + let padding = layout.padding(); + + div() + .flex_1() + .overflow_hidden() + .relative() + .when(is_empty, |el| { + el.flex().justify_center().items_center().child( + div() + .text_color(rgb(colors::TEXT_SECONDARY)) + .child("No messages yet"), + ) + }) + .when(!is_empty, |el| { + // Clone the Arc (cheap reference count increment) + let messages_for_render = Arc::clone(&messages_arc); + let show_sender_for_render = Arc::clone(&show_sender_flags); + let entity_for_render = entity.clone(); + el.child( + v_virtual_list( + entity.clone(), + "message-list", + item_sizes.clone(), + move |app, visible_range, _scroll_handle, _cx| { + // Read playing_message_id fresh from app state each render + // This ensures we always have the current value, not a stale captured one + let current_playing_id = app.playing_message_id().map(|s| s.to_string()); + + visible_range + // Clone only visible messages (virtual list optimization) + .map(|ix| { + let msg = &messages_for_render[ix]; + let message_id = &msg.id; + + // Get video player state and frame for this message + let video_state = app.video_player_state(message_id); + // Returns Arc - cheap to clone, no data copy + let video_frame = app.video_current_frame(message_id); + + // Get cached sticker image if this is a sticker (preserves animation state) + let sticker_image = msg.media.as_ref().and_then(|m| { + if matches!(m.media_type, MediaType::Sticker) + && !m.data.is_empty() + { + Some(app.get_sticker_image( + message_id, + &m.data, + &m.mime_type, + )) + } else { + None + } + }); + + render_message_bubble( + msg.clone(), + entity_for_render.clone(), + current_playing_id.clone(), + is_group, + show_sender_for_render[ix], + video_state, + video_frame, + sticker_image, + layout, + ) + }) + .collect() + }, + ) + .track_scroll(scroll_handle) + .size_full() + .p(px(padding)), + ) + .child( + div() + .absolute() + .top_0() + .right_0() + .bottom_0() + .child(Scrollbar::vertical(scroll_handle)), + ) + }) +} diff --git a/apps/desktop/src/components/mod.rs b/apps/desktop/src/components/mod.rs new file mode 100644 index 000000000..7492787b8 --- /dev/null +++ b/apps/desktop/src/components/mod.rs @@ -0,0 +1,21 @@ +//! Reusable UI components + +mod avatar; +mod call_popup; +mod chat_header; +mod chat_item; +mod chat_list; +mod input_area_view; +mod message_bubble; +mod message_list; +mod outgoing_call_popup; + +pub use avatar::Avatar; +pub use call_popup::{render_call_popup, render_call_popup_base}; +pub use chat_header::render_chat_header; +pub use chat_item::render_chat_item; +pub use chat_list::render_chat_list; +pub use input_area_view::{InputAreaEvent, InputAreaView}; +pub use message_bubble::render_message_bubble; +pub use message_list::render_message_list; +pub use outgoing_call_popup::render_outgoing_call_popup; diff --git a/apps/desktop/src/components/outgoing_call_popup.rs b/apps/desktop/src/components/outgoing_call_popup.rs new file mode 100644 index 000000000..e1ba51efa --- /dev/null +++ b/apps/desktop/src/components/outgoing_call_popup.rs @@ -0,0 +1,53 @@ +//! Outgoing call popup component + +use gpui::{Entity, SharedString, div, prelude::*, rgb}; +use gpui_component::button::{Button, ButtonVariants as _}; + +use crate::app::WhatsAppApp; +use crate::state::OutgoingCall; +use crate::theme::colors; + +use super::render_call_popup_base; + +/// Render the outgoing call popup overlay +/// +/// This popup appears centered on screen when initiating a call. +/// It shows the recipient name, call type (audio/video), status, and a cancel button. +pub fn render_outgoing_call_popup( + call: &OutgoingCall, + app_entity: Entity, +) -> impl IntoElement { + let recipient_name: SharedString = call.recipient_name.clone().into(); + let initial = call.initial(); + let is_video = call.is_video; + let status_text = call.status_message(); + + // Custom content: status message + cancel button + let extra_content = div() + .flex() + .flex_col() + .items_center() + .gap_4() + // Status message (Calling..., Ringing..., etc.) + .child( + div() + .text_sm() + .text_color(rgb(colors::ACCENT_GREEN)) + .child(status_text), + ) + // Cancel button + .child( + div().mt_4().child( + Button::new("cancel-call") + .label("Cancel") + .danger() + .on_click(move |_, _window, cx| { + app_entity.update(cx, |app, cx| { + app.cancel_outgoing_call(cx); + }); + }), + ), + ); + + render_call_popup_base(recipient_name, initial, is_video, extra_content) +} diff --git a/apps/desktop/src/main.rs b/apps/desktop/src/main.rs new file mode 100644 index 000000000..3c760ac05 --- /dev/null +++ b/apps/desktop/src/main.rs @@ -0,0 +1,63 @@ +//! WhatsApp UI - A GPUI-based WhatsApp client +//! +//! This is the main entry point for the WhatsApp UI application. + +// Allow dead code for WIP features (calls, media playback, etc.) +#![allow(dead_code)] +#![allow(clippy::too_many_arguments)] + +mod app; +mod assets; +mod audio; +mod client; +mod components; +mod responsive; +mod state; +mod theme; +mod utils; +mod video; +mod views; + +use gpui::{App, AppContext, Bounds, SharedString, WindowBounds, WindowOptions, px, size}; +use gpui_component::Root; +use gpui_component::theme::{Theme, ThemeMode}; + +use crate::app::{WhatsAppApp, init_chat_list_bindings}; + +fn main() { + env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info")) + .filter_module("blade_graphics", log::LevelFilter::Warn) + .filter_module("naga", log::LevelFilter::Warn) + .filter_module("zbus", log::LevelFilter::Warn) + .filter_module("tracing", log::LevelFilter::Warn) + .filter_module("gpui", log::LevelFilter::Warn) + .init(); + + gpui_platform::application() + .with_assets(assets::Assets) + .run(|cx: &mut App| { + gpui_component::init(cx); + Theme::change(ThemeMode::Dark, None, cx); + init_chat_list_bindings(cx); + + let bounds = Bounds::centered(None, size(px(1200.), px(800.)), cx); + + if let Err(error) = cx.open_window( + WindowOptions { + window_bounds: Some(WindowBounds::Windowed(bounds)), + titlebar: Some(gpui::TitlebarOptions { + title: Some(SharedString::from("WhatsApp")), + ..Default::default() + }), + ..Default::default() + }, + |window, cx| { + let view = cx.new(WhatsAppApp::new); + cx.new(|cx| Root::new(view, window, cx)) + }, + ) { + log::error!("Failed to open main window: {error}"); + cx.quit(); + } + }); +} diff --git a/apps/desktop/src/responsive.rs b/apps/desktop/src/responsive.rs new file mode 100644 index 000000000..ce08d9336 --- /dev/null +++ b/apps/desktop/src/responsive.rs @@ -0,0 +1,236 @@ +use gpui::{Pixels, Size}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Breakpoint { + Mobile, + Tablet, + Desktop, +} + +impl Breakpoint { + pub const MOBILE_MAX: f32 = 600.0; + pub const TABLET_MAX: f32 = 900.0; + + pub fn from_width(width: f32) -> Self { + if width < Self::MOBILE_MAX { + Self::Mobile + } else if width < Self::TABLET_MAX { + Self::Tablet + } else { + Self::Desktop + } + } + + pub fn is_mobile(&self) -> bool { + matches!(self, Self::Mobile) + } + + pub fn is_tablet(&self) -> bool { + matches!(self, Self::Tablet) + } + + pub fn is_desktop(&self) -> bool { + matches!(self, Self::Desktop) + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum MobilePanel { + #[default] + ChatList, + Chat, +} + +impl MobilePanel { + pub fn is_chat_list(&self) -> bool { + matches!(self, Self::ChatList) + } + + pub fn is_chat(&self) -> bool { + matches!(self, Self::Chat) + } +} + +#[derive(Debug, Clone, Copy)] +pub struct ResponsiveLayout { + breakpoint: Breakpoint, + mobile_panel: MobilePanel, + viewport_width: f32, +} + +impl ResponsiveLayout { + const SIDEBAR_WIDTH_DESKTOP: f32 = 320.0; + const SIDEBAR_WIDTH_TABLET: f32 = 240.0; + const SIDEBAR_WIDTH_MIN: f32 = 200.0; + + const HEADER_HEIGHT: f32 = 56.0; + const HEADER_HEIGHT_MOBILE: f32 = 52.0; + + const CHAT_ITEM_HEIGHT_DESKTOP: f32 = 72.0; + const CHAT_ITEM_HEIGHT_TABLET: f32 = 68.0; + const CHAT_ITEM_HEIGHT_MOBILE: f32 = 64.0; + + const AVATAR_SIZE_LARGE: f32 = 48.0; + const AVATAR_SIZE_MOBILE: f32 = 44.0; + + const INPUT_AREA_HEIGHT: f32 = 62.0; + const INPUT_AREA_HEIGHT_MOBILE: f32 = 56.0; + + const MAX_BUBBLE_WIDTH_DESKTOP: f32 = 400.0; + const MAX_BUBBLE_WIDTH_TABLET: f32 = 350.0; + const MAX_BUBBLE_WIDTH_MOBILE_RATIO: f32 = 0.85; + + const MAX_MEDIA_SIZE_DESKTOP: f32 = 300.0; + const MAX_MEDIA_SIZE_TABLET: f32 = 280.0; + const MAX_MEDIA_SIZE_MOBILE_RATIO: f32 = 0.75; + + pub fn new(viewport: Size, mobile_panel: MobilePanel) -> Self { + let width: f32 = viewport.width.into(); + + Self { + breakpoint: Breakpoint::from_width(width), + mobile_panel, + viewport_width: width, + } + } + + pub fn breakpoint(&self) -> Breakpoint { + self.breakpoint + } + + pub fn is_mobile(&self) -> bool { + self.breakpoint.is_mobile() + } + + pub fn is_tablet(&self) -> bool { + self.breakpoint.is_tablet() + } + + pub fn is_desktop(&self) -> bool { + self.breakpoint.is_desktop() + } + + pub fn is_compact(&self) -> bool { + self.is_mobile() || self.is_tablet() + } + + pub fn mobile_panel(&self) -> MobilePanel { + self.mobile_panel + } + + pub fn show_sidebar(&self) -> bool { + match self.breakpoint { + Breakpoint::Desktop | Breakpoint::Tablet => true, + Breakpoint::Mobile => self.mobile_panel.is_chat_list(), + } + } + + pub fn show_chat_area(&self) -> bool { + match self.breakpoint { + Breakpoint::Desktop | Breakpoint::Tablet => true, + Breakpoint::Mobile => self.mobile_panel.is_chat(), + } + } + + pub fn show_back_button(&self) -> bool { + self.is_mobile() && self.mobile_panel.is_chat() + } + + pub fn show_call_buttons(&self) -> bool { + self.viewport_width >= 400.0 + } + + pub fn sidebar_width(&self) -> f32 { + match self.breakpoint { + Breakpoint::Desktop => Self::SIDEBAR_WIDTH_DESKTOP, + Breakpoint::Tablet => { + let proportional = self.viewport_width * 0.35; + proportional.clamp(Self::SIDEBAR_WIDTH_MIN, Self::SIDEBAR_WIDTH_TABLET) + } + Breakpoint::Mobile => self.viewport_width, + } + } + + pub fn header_height(&self) -> f32 { + if self.is_mobile() { + Self::HEADER_HEIGHT_MOBILE + } else { + Self::HEADER_HEIGHT + } + } + + pub fn chat_item_height(&self) -> f32 { + match self.breakpoint { + Breakpoint::Desktop => Self::CHAT_ITEM_HEIGHT_DESKTOP, + Breakpoint::Tablet => Self::CHAT_ITEM_HEIGHT_TABLET, + Breakpoint::Mobile => Self::CHAT_ITEM_HEIGHT_MOBILE, + } + } + + pub fn avatar_size(&self) -> f32 { + if self.is_mobile() { + Self::AVATAR_SIZE_MOBILE + } else { + Self::AVATAR_SIZE_LARGE + } + } + + pub fn input_area_height(&self) -> f32 { + if self.is_mobile() { + Self::INPUT_AREA_HEIGHT_MOBILE + } else { + Self::INPUT_AREA_HEIGHT + } + } + + pub fn max_bubble_width(&self) -> f32 { + match self.breakpoint { + Breakpoint::Desktop => Self::MAX_BUBBLE_WIDTH_DESKTOP, + Breakpoint::Tablet => Self::MAX_BUBBLE_WIDTH_TABLET, + Breakpoint::Mobile => { + (self.viewport_width * Self::MAX_BUBBLE_WIDTH_MOBILE_RATIO).min(350.0) + } + } + } + + pub fn max_media_size(&self) -> f32 { + match self.breakpoint { + Breakpoint::Desktop => Self::MAX_MEDIA_SIZE_DESKTOP, + Breakpoint::Tablet => Self::MAX_MEDIA_SIZE_TABLET, + Breakpoint::Mobile => { + (self.viewport_width * Self::MAX_MEDIA_SIZE_MOBILE_RATIO).min(280.0) + } + } + } + + pub fn chat_area_width(&self) -> f32 { + match self.breakpoint { + Breakpoint::Desktop | Breakpoint::Tablet => self.viewport_width - self.sidebar_width(), + Breakpoint::Mobile => self.viewport_width, + } + } + + pub fn message_list_width(&self) -> f32 { + self.chat_area_width() - (self.padding() * 2.0) - self.gap() + } + + pub fn min_touch_target(&self) -> f32 { + if self.is_mobile() { 48.0 } else { 36.0 } + } + + pub fn icon_button_size(&self) -> f32 { + if self.is_mobile() { 44.0 } else { 36.0 } + } + + pub fn padding(&self) -> f32 { + if self.is_mobile() { 12.0 } else { 16.0 } + } + + pub fn padding_small(&self) -> f32 { + if self.is_mobile() { 8.0 } else { 12.0 } + } + + pub fn gap(&self) -> f32 { + if self.is_mobile() { 8.0 } else { 12.0 } + } +} diff --git a/apps/desktop/src/state/app_state.rs b/apps/desktop/src/state/app_state.rs new file mode 100644 index 000000000..a718acfdd --- /dev/null +++ b/apps/desktop/src/state/app_state.rs @@ -0,0 +1,56 @@ +//! Application state enum. + +use std::sync::Arc; + +#[derive(Debug, Clone)] +pub struct CachedQrCode { + pub data: String, + pub png_bytes: Arc>, +} + +impl PartialEq for CachedQrCode { + fn eq(&self, other: &Self) -> bool { + self.data == other.data + } +} + +#[derive(Debug, Clone, PartialEq)] +pub enum AppState { + Loading, + Connecting, + WaitingForPairing { + qr_code: Option, + pair_code: Option, + timeout_secs: u64, + }, + Syncing, + Connected, + Error(String), +} + +#[allow(dead_code)] +impl AppState { + pub fn is_loading(&self) -> bool { + matches!(self, Self::Loading | Self::Connecting) + } + + pub fn is_ready(&self) -> bool { + matches!(self, Self::Connected) + } + + pub fn needs_pairing(&self) -> bool { + matches!(self, Self::WaitingForPairing { .. }) + } + + pub fn is_error(&self) -> bool { + matches!(self, Self::Error(_)) + } + + pub fn error_message(&self) -> Option<&str> { + if let Self::Error(msg) = self { + Some(msg) + } else { + None + } + } +} diff --git a/apps/desktop/src/state/call.rs b/apps/desktop/src/state/call.rs new file mode 100644 index 000000000..3812ef45b --- /dev/null +++ b/apps/desktop/src/state/call.rs @@ -0,0 +1,149 @@ +//! Call state structures for the UI. + +use std::sync::Arc; + +use chrono::{DateTime, Utc}; +use wacore::types::call::IncomingCall as WaIncomingCall; + +/// Call ids are plain strings on the wire (and in the voip facade). +pub type CallId = String; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum OutgoingCallState { + Initiating, + Ringing, + Connected, + Declined, + Timeout, +} + +#[derive(Debug, Clone)] +pub struct OutgoingCall { + pub call_id: CallId, + pub recipient_name: String, + pub recipient_jid: String, + pub is_video: bool, + pub state: OutgoingCallState, + pub initiated_at: DateTime, +} + +impl OutgoingCall { + pub fn new( + call_id: impl Into, + recipient_jid: String, + recipient_name: String, + is_video: bool, + ) -> Self { + Self { + call_id: call_id.into(), + recipient_name, + recipient_jid, + is_video, + state: OutgoingCallState::Initiating, + initiated_at: wacore::time::now_utc(), + } + } + + pub fn initial(&self) -> char { + self.recipient_name.chars().next().unwrap_or('?') + } + + pub fn set_state(&mut self, state: OutgoingCallState) { + self.state = state; + } + + pub fn is_active(&self) -> bool { + !matches!( + self.state, + OutgoingCallState::Declined | OutgoingCallState::Timeout + ) + } + + pub fn status_message(&self) -> &'static str { + match self.state { + OutgoingCallState::Initiating => "Calling...", + OutgoingCallState::Ringing => "Ringing...", + OutgoingCallState::Connected => "Connected", + OutgoingCallState::Declined => "Call declined", + OutgoingCallState::Timeout => "No answer", + } + } +} + +#[derive(Debug, Clone)] +pub struct IncomingCall { + pub call_id: CallId, + pub caller_name: String, + pub caller_jid: String, + pub is_video: bool, + pub is_offline: bool, + pub received_at: DateTime, + /// The library offer payload; `client.voip().accept()/reject()` consume it. + pub offer: Arc, +} + +impl IncomingCall { + pub fn new( + call_id: impl Into, + caller_name: String, + caller_jid: String, + is_video: bool, + offer: Arc, + ) -> Self { + Self { + call_id: call_id.into(), + caller_name, + caller_jid, + is_video, + is_offline: offer.offline, + received_at: wacore::time::now_utc(), + offer, + } + } + + pub fn initial(&self) -> char { + self.caller_name.chars().next().unwrap_or('?') + } +} + +#[derive(Debug, Clone)] +pub struct ActiveCall { + pub call_id: CallId, + pub peer_name: String, + pub peer_jid: String, + pub is_video: bool, + pub is_muted: bool, + pub started_at: DateTime, +} + +impl ActiveCall { + pub fn from_incoming(call: IncomingCall) -> Self { + Self { + call_id: call.call_id, + peer_name: call.caller_name, + peer_jid: call.caller_jid, + is_video: call.is_video, + is_muted: false, + started_at: wacore::time::now_utc(), + } + } + + pub fn toggle_mute(&mut self) { + self.is_muted = !self.is_muted; + } + + pub fn duration_secs(&self) -> i64 { + wacore::time::now_utc() + .signed_duration_since(self.started_at) + .num_seconds() + } + + pub fn duration_formatted(&self) -> String { + let total_secs = self.duration_secs(); + format!("{:02}:{:02}", total_secs / 60, total_secs % 60) + } + + pub fn initial(&self) -> char { + self.peer_name.chars().next().unwrap_or('?') + } +} diff --git a/apps/desktop/src/state/chat.rs b/apps/desktop/src/state/chat.rs new file mode 100644 index 000000000..7e0b05e34 --- /dev/null +++ b/apps/desktop/src/state/chat.rs @@ -0,0 +1,1135 @@ +//! Chat and message state structures + +use async_trait::async_trait; +use chrono::{DateTime, Utc}; +use std::collections::HashMap; +use std::sync::Arc; +use wacore::download::{Downloadable, MediaType as DownloadMediaType}; +use wacore_binary::jid::{Jid, JidExt, Server}; + +/// Maximum number of unique emoji reactions per message to prevent spam +const MAX_REACTIONS_PER_MESSAGE: usize = 50; + +pub(crate) fn fallback_chat_name(jid: &Jid) -> String { + if jid.is_status_broadcast() { + "Status".to_string() + } else if jid.is_group() { + "Unnamed group".to_string() + } else if jid.is_broadcast_list() { + "Broadcast list".to_string() + } else if jid.is_newsletter() { + "Channel".to_string() + } else if jid.server.is_lid_family() { + "Unknown contact".to_string() + } else if (jid.server.is_pn_family() || jid.server == Server::Legacy) + && jid.user_base().chars().all(|c| c.is_ascii_digit()) + { + format!("+{}", jid.user_base()) + } else { + "Unknown chat".to_string() + } +} + +/// Type of media content +#[derive(Debug, Clone)] +pub enum MediaType { + /// Image (JPEG, PNG, WebP) + Image, + /// Sticker (WebP, animated or static) + Sticker, + /// Video (thumbnail displayed, full video downloadable) + Video, + /// Audio (shown as placeholder) + Audio, + /// Document (shown as placeholder) + Document, +} + +impl MediaType { + /// Get a display label for chat list preview + pub fn display_label(&self) -> &'static str { + match self { + MediaType::Image => "📷 Photo", + MediaType::Sticker => "🎭 Sticker", + MediaType::Video => "🎥 Video", + MediaType::Audio => "🎤 Voice message", + MediaType::Document => "📄 Document", + } + } +} + +/// Information needed to download encrypted media from WhatsApp servers. +/// This is stored separately from the thumbnail/preview data. +#[derive(Debug, Clone)] +pub struct DownloadableMedia { + /// Direct path for CDN URL construction + pub direct_path: String, + /// Encryption key for decrypting the media + pub media_key: Vec, + /// SHA256 of encrypted file (used for URL token) + pub file_enc_sha256: Vec, + /// Expected file size in bytes + pub file_length: u64, + /// MIME type of the actual media (e.g., "video/mp4") + pub mime_type: String, + /// Duration in seconds (for video/audio) + pub duration_secs: Option, + /// Download media type (for key derivation) + pub download_type: DownloadMediaType, +} + +/// Implement Downloadable trait for DownloadableMedia to enable downloading +#[async_trait] +impl Downloadable for DownloadableMedia { + fn direct_path(&self) -> Option<&str> { + Some(&self.direct_path) + } + + fn media_key(&self) -> Option<&[u8]> { + Some(&self.media_key) + } + + fn file_enc_sha256(&self) -> Option<&[u8]> { + Some(&self.file_enc_sha256) + } + + fn file_sha256(&self) -> Option<&[u8]> { + None // Not required for download + } + + fn file_length(&self) -> Option { + Some(self.file_length) + } + + fn app_info(&self) -> DownloadMediaType { + self.download_type + } +} + +/// Media content attached to a message +#[derive(Debug, Clone)] +pub struct MediaContent { + /// Type of media + pub media_type: MediaType, + /// Raw data for display (thumbnail for video, full data for images/stickers) + pub data: Arc>, + /// MIME type of the display data (may differ from downloadable media) + pub mime_type: String, + /// Width in pixels (if known) + pub width: Option, + /// Height in pixels (if known) + pub height: Option, + /// Caption text (if any) + #[allow(dead_code)] + pub caption: Option, + /// Original file name (documents), used when saving to disk + pub file_name: Option, + /// Download info for fetching full media (videos, documents) + pub downloadable: Option, + /// Whether this is an animated sticker (WebP animation) + pub is_animated: bool, + /// Duration in seconds (for audio/video) + pub duration_secs: Option, + /// Whether `data` holds only a fallback thumbnail (eager download of the + /// full media failed), so the renderer keeps offering the real download + pub data_is_preview: bool, +} + +impl MediaContent { + /// Check if this media has inline data available + pub fn has_data(&self) -> bool { + !self.data.is_empty() + } + + /// Check if this media can be downloaded from server + pub fn can_download(&self) -> bool { + self.downloadable.is_some() + } + + /// Check if this media can be played (has data or can be downloaded) + pub fn can_play(&self) -> bool { + self.has_data() || self.can_download() + } +} + +/// A chat message +#[derive(Debug, Clone)] +pub struct ChatMessage { + /// Unique message ID + pub id: String, + /// Sender identifier (JID) + pub sender: String, + /// Sender's display name (push name, for group chats) + pub sender_name: Option, + /// Message text content + pub content: String, + /// When the message was sent/received + pub timestamp: DateTime, + /// Whether this message was sent by the current user + pub is_from_me: bool, + /// Whether the message has been read + pub is_read: bool, + /// Optional media content + pub media: Option, + /// Reactions on this message (emoji -> list of sender JIDs) + pub reactions: HashMap>, + /// Whether an outgoing send attempt for this message failed + pub failed: bool, +} + +impl ChatMessage { + /// Create a new outgoing message + pub fn new_outgoing(id: String, content: String) -> Self { + Self { + id, + sender: "Me".to_string(), + sender_name: None, + content, + timestamp: wacore::time::now_utc(), + is_from_me: true, + is_read: false, + media: None, + reactions: HashMap::new(), + failed: false, + } + } + + /// Create a new outgoing message with media + pub fn new_outgoing_with_media(id: String, content: String, media: MediaContent) -> Self { + Self { + id, + sender: "Me".to_string(), + sender_name: None, + content, + timestamp: wacore::time::now_utc(), + is_from_me: true, + is_read: false, + media: Some(media), + reactions: HashMap::new(), + failed: false, + } + } + + /// Create a new incoming message + #[allow(dead_code)] + pub fn new_incoming(id: String, sender: String, content: String) -> Self { + Self { + id, + sender, + sender_name: None, + content, + timestamp: wacore::time::now_utc(), + is_from_me: false, + is_read: false, + media: None, + reactions: HashMap::new(), + failed: false, + } + } + + /// Add or update a reaction to this message from a sender. + /// Each sender can only have one reaction - adding a new one removes the previous. + /// An empty emoji string removes the sender's reaction entirely. + pub fn add_reaction(&mut self, emoji: String, sender: String) { + // Enforce the limit BEFORE removing the sender's old reaction: a + // rejected replacement must not erase what they already had. + if !emoji.is_empty() + && !self.reactions.contains_key(&emoji) + && self.reactions.len() >= MAX_REACTIONS_PER_MESSAGE + { + let frees_a_slot = self + .reactions + .values() + .any(|senders| senders.len() == 1 && senders.contains(&sender)); + if !frees_a_slot { + return; + } + } + + // Remove any existing reaction from this sender (one reaction per person) + for senders in self.reactions.values_mut() { + senders.retain(|s| s != &sender); + } + self.reactions.retain(|_, senders| !senders.is_empty()); + + // Empty emoji means remove reaction + if emoji.is_empty() { + return; + } + + self.reactions.entry(emoji).or_default().push(sender); + } + + /// Get the preview text for chat list display. + /// + /// Returns: + /// - For text-only messages: the message content + /// - For media messages: "[MediaType] caption" or just "[MediaType]" + pub fn preview_text(&self) -> String { + if let Some(media) = &self.media { + let label = media.media_type.display_label(); + // Check caption first, then fall back to content + let caption = media + .caption + .as_ref() + .filter(|c| !c.is_empty()) + .or_else(|| Some(&self.content).filter(|c| !c.is_empty())); + + if let Some(text) = caption { + format!("{} {}", label, text) + } else { + label.to_string() + } + } else { + self.content.clone() + } + } + + /// Create a message with media content + #[allow(dead_code)] + pub fn with_media(mut self, media: MediaContent) -> Self { + // Use caption as content if available + if let Some(caption) = &media.caption + && !caption.is_empty() + { + self.content = caption.clone(); + } + self.media = Some(media); + self + } +} + +/// A chat/conversation +#[derive(Debug, Clone)] +pub struct Chat { + /// JID (Jabber ID) - unique identifier + pub jid: String, + /// Display name + pub name: String, + /// Fallback < history < live push name < address-book contact. + pub(crate) name_priority: u8, + /// Last message preview + pub last_message: Option, + /// Time of last message + pub last_message_time: Option>, + /// Number of unread messages + pub unread_count: u32, + /// Manually marked unread (WA's `-1` sentinel): badge without a count. + pub manually_unread: bool, + /// Whether this is a group chat + pub is_group: bool, + /// Participant names in group chats (sender JID -> display name) + pub participants: HashMap, + /// Messages in this chat + pub messages: Vec, + /// Whether the durable store has ever handed us this chat. Live-created + /// chats (incoming message before its store row commits — e.g. the + /// initial-pairing window) stay `false` until a history load adopts them, + /// so a complete-but-still-empty store load must not prune them; a chat + /// the store DID originate and no longer returns was deleted/archived + /// elsewhere and must go. + pub(crate) from_store: bool, +} + +impl Chat { + /// Create a new chat from a JID + pub fn new(jid: String) -> Self { + let name = jid + .parse::() + .map(|jid| fallback_chat_name(&jid)) + .unwrap_or_else(|_| "Unknown chat".to_string()); + let is_group = jid.contains("@g.us"); + + Self { + jid, + name, + name_priority: 0, + last_message: None, + last_message_time: None, + unread_count: 0, + manually_unread: false, + is_group, + participants: HashMap::new(), + messages: Vec::new(), + from_store: false, + } + } + + /// Create a new chat with a custom name + #[allow(dead_code)] + pub fn with_name(jid: String, name: String) -> Self { + Self::with_name_priority(jid, name, 2) + } + + pub(crate) fn with_name_priority(jid: String, name: String, name_priority: u8) -> Self { + let is_group = jid.contains("@g.us"); + + Self { + jid, + name, + name_priority, + last_message: None, + last_message_time: None, + unread_count: 0, + manually_unread: false, + is_group, + participants: HashMap::new(), + messages: Vec::new(), + from_store: false, + } + } + + pub(crate) fn set_name_if_better(&mut self, name: String, priority: u8) { + if priority > self.name_priority { + self.name = name; + self.name_priority = priority; + } + } + + pub(crate) fn set_name_if_not_worse(&mut self, name: String, priority: u8) { + if priority >= self.name_priority { + self.name = name; + self.name_priority = priority; + } + } + + /// Update a participant's display name (for group chats) + pub fn update_participant(&mut self, jid: String, name: String) { + self.participants.insert(jid, name); + } + + /// Get a participant's display name, with fallback to JID prefix + #[allow(dead_code)] + pub fn get_participant_name(&self, jid: &str) -> String { + self.participants.get(jid).cloned().unwrap_or_else(|| { + jid.parse::() + .map(|jid| fallback_chat_name(&jid)) + .unwrap_or_else(|_| "Unknown contact".to_string()) + }) + } + + /// Add a message to the chat, maintaining chronological order by timestamp. + /// Returns true when the message became the chat's newest content, so the + /// caller knows whether to bump the chat in the list; duplicates and older + /// backfills return false. + pub fn add_message(&mut self, message: ChatMessage) -> bool { + // Redelivery of a message we already show (live traffic overlapping + // hydrated history): no duplicate bubble, no recount. Id-only, not + // (timestamp, id): the optimistic bubble's UI clock and the store's + // commit clock can stamp the same message a second apart. + if self.messages.iter().any(|m| m.id == message.id) { + return false; + } + + // Sorted insert (out-of-order decryption during history sync); equal + // timestamps tie-break on message ID for stable ordering. + let pos = self + .messages + .binary_search_by(|m| { + m.timestamp + .cmp(&message.timestamp) + .then_with(|| m.id.cmp(&message.id)) + }) + .unwrap_or_else(|pos| pos); + + // >= on purpose: WhatsApp timestamps are second-granular, so a live + // same-second sibling still takes over the preview. + let is_newer_or_same = self + .last_message_time + .map(|t| message.timestamp >= t) + .unwrap_or(true); + + // Unread is not gated on recency: history hydration never goes through + // here (insert_history_message/merge_history) and the dup guard above + // blocks redelivery recounts, so every incoming message that reaches + // this point is new even when it lands behind the newest bubble + // (offline catch-up, out-of-order decryption). + if !message.is_from_me { + self.unread_count += 1; + } + + if is_newer_or_same { + self.last_message = Some(message.preview_text()); + self.last_message_time = Some(message.timestamp); + } + + self.messages.insert(pos, message); + is_newer_or_same + } + + /// Fold a freshly hydrated copy of this chat (from the durable store) into + /// the live one. Messages merge dedup-guarded without unread bumps; the + /// store's counters are authoritative after a flush. + pub fn merge_history(&mut self, hydrated: Chat) { + // A live-created chat adopted by a store load becomes prunable: the + // store owns it from here on. + self.from_store |= hydrated.from_store; + self.set_name_if_not_worse(hydrated.name, hydrated.name_priority); + for msg in hydrated.messages { + self.insert_history_message(msg); + } + self.unread_count = hydrated.unread_count; + self.manually_unread = hydrated.manually_unread; + if hydrated.last_message_time >= self.last_message_time { + self.last_message = hydrated.last_message; + self.last_message_time = hydrated.last_message_time; + } + } + + /// Rename a message (optimistic local id -> real WhatsApp id), + /// re-inserting so the (timestamp, id) sort invariant holds for + /// same-second siblings. The renamed bubble replaces a row already + /// present under the new id (server echo of the same message). + pub fn rename_message(&mut self, old_id: &str, new_id: &str) -> bool { + let Some(pos) = self.messages.iter().position(|m| m.id == old_id) else { + return false; + }; + let mut msg = self.messages.remove(pos); + msg.id = new_id.to_string(); + self.insert_history_message(msg); + true + } + + /// Insert a hydrated message in order, without touching unread counters + /// or the preview. An id match replaces the live bubble: the store is + /// authoritative (edits and revokes materialize there), so the hydrated + /// copy must not be dropped in favor of stale content. + pub fn insert_history_message(&mut self, mut message: ChatMessage) { + // Id-only match: the hydrated copy may carry a slightly different + // timestamp than the optimistic bubble. Remove-and-reinsert keeps + // the (timestamp, id) sort invariant when the timestamp shifted. + if let Some(pos) = self.messages.iter().position(|m| m.id == message.id) { + let existing = self.messages.remove(pos); + // The store never holds downloaded media bytes — hydrated rows + // come back empty or with just a preview thumbnail; graft the + // bytes the live bubble already fetched so a reload can't + // downgrade a full download. + if let Some(new_media) = message.media.as_mut() + && let Some(old_media) = existing.media + && !old_media.data.is_empty() + && (new_media.data.is_empty() + || (new_media.data_is_preview && !old_media.data_is_preview)) + { + new_media.data = old_media.data; + new_media.mime_type = old_media.mime_type; + new_media.data_is_preview = old_media.data_is_preview; + } + // Live-only state the store doesn't carry must also survive the + // replace: the SendFailed flag and the push name on group bubbles. + message.failed |= existing.failed; + if message.sender_name.is_none() { + message.sender_name = existing.sender_name; + } + } + let pos = self + .messages + .binary_search_by(|m| { + m.timestamp + .cmp(&message.timestamp) + .then_with(|| m.id.cmp(&message.id)) + }) + .unwrap_or_else(|pos| pos); + self.messages.insert(pos, message); + } + + /// Mark all incoming messages as read and clear the unread badge. + /// Outgoing bubbles are untouched: their `is_read` means "the peer read + /// it" (delivery ticks), which opening the chat locally must not fake. + pub fn mark_as_read(&mut self) { + self.unread_count = 0; + self.manually_unread = false; + for msg in &mut self.messages { + if !msg.is_from_me { + msg.is_read = true; + } + } + } + + /// Mark specific messages as read by their IDs + /// + /// Returns the number of messages that were actually updated. + pub fn mark_messages_as_read(&mut self, message_ids: &[String]) -> usize { + let mut count = 0; + for msg in &mut self.messages { + if message_ids.contains(&msg.id) && !msg.is_read { + msg.is_read = true; + count += 1; + // Decrement unread count for incoming messages + if !msg.is_from_me && self.unread_count > 0 { + self.unread_count -= 1; + } + } + } + count + } + + /// Get the initial letter for avatar display + #[allow(dead_code)] + pub fn initial(&self) -> char { + self.name.chars().next().unwrap_or('?') + } + + /// Add a reaction to a message in this chat + /// + /// Returns true if the message was found and the reaction was added. + pub fn add_reaction(&mut self, message_id: &str, emoji: String, sender: String) -> bool { + if let Some(msg) = self.messages.iter_mut().find(|m| m.id == message_id) { + msg.add_reaction(emoji, sender); + true + } else { + false + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use chrono::TimeZone; + + fn make_message(id: &str, timestamp_secs: i64) -> ChatMessage { + ChatMessage { + id: id.to_string(), + sender: "test".to_string(), + sender_name: None, + content: format!("Message {}", id), + timestamp: Utc.timestamp_opt(timestamp_secs, 0).unwrap(), + is_from_me: false, + is_read: false, + media: None, + reactions: HashMap::new(), + failed: false, + } + } + + fn make_media(data: Vec, data_is_preview: bool) -> MediaContent { + MediaContent { + media_type: MediaType::Image, + data: Arc::new(data), + mime_type: "image/jpeg".to_string(), + width: None, + height: None, + caption: None, + file_name: None, + downloadable: None, + is_animated: false, + duration_secs: None, + data_is_preview, + } + } + + #[test] + fn chat_fallback_hides_internal_lid() { + let lid = Chat::new("111222333444555@lid".to_string()); + let pn = Chat::new("12025550143@s.whatsapp.net".to_string()); + let legacy = Chat::new("12025550144@c.us".to_string()); + + assert_eq!(lid.name, "Unknown contact"); + assert_eq!(pn.name, "+12025550143"); + assert_eq!(legacy.name, "+12025550144"); + } + + #[test] + fn better_name_survives_history_merges() { + let jid = "111222333444555@lid".to_string(); + let mut chat = Chat::new(jid.clone()); + + chat.merge_history(Chat::with_name_priority( + jid.clone(), + "Masked label".to_string(), + 1, + )); + assert_eq!(chat.name, "Masked label"); + + chat.merge_history(Chat::with_name_priority( + jid.clone(), + "Fictitious Contact".to_string(), + 3, + )); + chat.merge_history(Chat::with_name_priority( + jid.clone(), + "Renamed Fictitious Contact".to_string(), + 3, + )); + chat.merge_history(Chat::with_name_priority( + jid, + "Older history label".to_string(), + 1, + )); + assert_eq!(chat.name, "Renamed Fictitious Contact"); + } + + #[test] + fn store_provenance_sticks_after_history_merge() { + // Live-created chats are not store-originated... + let mut chat = Chat::new("12025550143@s.whatsapp.net".to_string()); + assert!(!chat.from_store); + + // ...until a history load adopts them; from there they stay prunable + // even when a later merge comes from a live-built Chat value. + let mut hydrated = Chat::new("12025550143@s.whatsapp.net".to_string()); + hydrated.from_store = true; + chat.merge_history(hydrated); + assert!(chat.from_store); + + chat.merge_history(Chat::new("12025550143@s.whatsapp.net".to_string())); + assert!(chat.from_store); + } + + #[test] + fn test_messages_ordered_by_timestamp_when_added_in_order() { + let mut chat = Chat::new("test@s.whatsapp.net".to_string()); + + chat.add_message(make_message("1", 1000)); + chat.add_message(make_message("2", 2000)); + chat.add_message(make_message("3", 3000)); + + assert_eq!(chat.messages.len(), 3); + assert_eq!(chat.messages[0].id, "1"); + assert_eq!(chat.messages[1].id, "2"); + assert_eq!(chat.messages[2].id, "3"); + } + + #[test] + fn test_messages_ordered_by_timestamp_when_added_out_of_order() { + let mut chat = Chat::new("test@s.whatsapp.net".to_string()); + + // Simulate history sync where messages are decrypted out of order + chat.add_message(make_message("2", 2000)); // Middle message first + chat.add_message(make_message("3", 3000)); // Newest message second + chat.add_message(make_message("1", 1000)); // Oldest message last + + assert_eq!(chat.messages.len(), 3); + // Should be sorted by timestamp, not insertion order + assert_eq!(chat.messages[0].id, "1"); // oldest + assert_eq!(chat.messages[1].id, "2"); // middle + assert_eq!(chat.messages[2].id, "3"); // newest + } + + #[test] + fn test_out_of_order_incoming_still_counts_as_unread() { + let mut chat = Chat::new("test@s.whatsapp.net".to_string()); + + chat.add_message(make_message("2", 2000)); + // Lands behind the newest bubble (offline catch-up / late decrypt): + // no preview takeover, but the badge must still count it. + let bumped = chat.add_message(make_message("1", 1000)); + + assert!(!bumped); + assert_eq!(chat.unread_count, 2); + // Redelivery of the same id must not recount. + chat.add_message(make_message("1", 1000)); + assert_eq!(chat.unread_count, 2); + } + + #[test] + fn test_messages_ordered_by_timestamp_reverse_order() { + let mut chat = Chat::new("test@s.whatsapp.net".to_string()); + + // Add messages in reverse chronological order (newest first) + chat.add_message(make_message("3", 3000)); + chat.add_message(make_message("2", 2000)); + chat.add_message(make_message("1", 1000)); + + assert_eq!(chat.messages.len(), 3); + assert_eq!(chat.messages[0].id, "1"); + assert_eq!(chat.messages[1].id, "2"); + assert_eq!(chat.messages[2].id, "3"); + } + + #[test] + fn test_messages_with_same_timestamp_sorted_by_id() { + let mut chat = Chat::new("test@s.whatsapp.net".to_string()); + + // Messages with same timestamp should be sorted by ID for stable ordering + chat.add_message(make_message("c", 1000)); + chat.add_message(make_message("a", 1000)); + chat.add_message(make_message("b", 1000)); + + assert_eq!(chat.messages.len(), 3); + // Same timestamp: sorted alphabetically by ID + assert_eq!(chat.messages[0].id, "a"); + assert_eq!(chat.messages[1].id, "b"); + assert_eq!(chat.messages[2].id, "c"); + } + + #[test] + fn test_rename_message_keeps_same_second_sort_order() { + let mut chat = Chat::new("test@s.whatsapp.net".to_string()); + + chat.add_message(make_message("3EB0BBB", 1000)); + chat.add_message(make_message("local_1000_0", 1000)); + // 'l' > '3', so the optimistic bubble sits after its sibling + assert_eq!(chat.messages[1].id, "local_1000_0"); + + assert!(chat.rename_message("local_1000_0", "3EB0AAA")); + // The real id sorts before the sibling; a plain in-place rename + // would have left the vector mis-sorted for binary_search_by + assert_eq!(chat.messages[0].id, "3EB0AAA"); + assert_eq!(chat.messages[1].id, "3EB0BBB"); + + // A later same-second insert still lands in the right slot + chat.add_message(make_message("3EB0AB0", 1000)); + let ids: Vec<_> = chat.messages.iter().map(|m| m.id.as_str()).collect(); + assert_eq!(ids, ["3EB0AAA", "3EB0AB0", "3EB0BBB"]); + } + + #[test] + fn test_rename_message_dedups_against_existing_real_id() { + let mut chat = Chat::new("test@s.whatsapp.net".to_string()); + + chat.add_message(make_message("3EB0AAA", 1000)); + chat.add_message(make_message("local_1000_0", 1000)); + + // The real id already arrived (e.g. server echo); the rename must + // not create a duplicate bubble. The renamed local bubble replaces + // the echo row — same message, and the local copy is the one that + // may hold media bytes. + assert!(chat.rename_message("local_1000_0", "3EB0AAA")); + assert_eq!(chat.messages.len(), 1); + assert_eq!(chat.messages[0].id, "3EB0AAA"); + assert_eq!(chat.messages[0].content, "Message local_1000_0"); + + assert!(!chat.rename_message("missing", "whatever")); + } + + #[test] + fn test_hydration_replaces_same_id_at_different_timestamp() { + let mut chat = Chat::new("test@s.whatsapp.net".to_string()); + + // Optimistic bubble stamped with the UI clock, renamed to the real id + chat.add_message(make_message("local_1000_0", 1000)); + assert!(chat.rename_message("local_1000_0", "3EB0AAA")); + + // The store commits its own slightly-later timestamp; the hydrated + // copy must replace the existing bubble (store is authoritative for + // content — edits/revokes materialize there), not sit next to it + let mut hydrated = make_message("3EB0AAA", 1001); + hydrated.content = "edited text".to_string(); + chat.insert_history_message(hydrated); + assert_eq!(chat.messages.len(), 1); + assert_eq!( + chat.messages[0].timestamp, + Utc.timestamp_opt(1001, 0).unwrap() + ); + assert_eq!(chat.messages[0].content, "edited text"); + + // Live redelivery with the shifted timestamp dedups the same way + assert!(!chat.add_message(make_message("3EB0AAA", 1001))); + assert_eq!(chat.messages.len(), 1); + assert_eq!(chat.messages[0].content, "edited text"); + } + + #[test] + fn test_hydration_replacement_keeps_downloaded_media_bytes() { + let mut chat = Chat::new("test@s.whatsapp.net".to_string()); + + // Live bubble whose full media bytes were already downloaded + let mut live = make_message("3EB0AAA", 1000); + live.media = Some(make_media(vec![1, 2, 3], false)); + chat.add_message(live); + + // The hydrated copy carries no media bytes (the store never holds + // them) but newer content; the replace must graft the old bytes + let mut hydrated = make_message("3EB0AAA", 1000); + hydrated.content = "edited caption".to_string(); + hydrated.media = Some(make_media(Vec::new(), true)); + chat.insert_history_message(hydrated); + + assert_eq!(chat.messages.len(), 1); + assert_eq!(chat.messages[0].content, "edited caption"); + let media = chat.messages[0].media.as_ref().unwrap(); + assert_eq!(*media.data, vec![1, 2, 3]); + assert!(!media.data_is_preview); + } + + #[test] + fn test_hydration_replacement_keeps_full_bytes_over_incoming_preview() { + let mut chat = Chat::new("test@s.whatsapp.net".to_string()); + + // Full media bytes already downloaded on the live bubble + let mut live = make_message("3EB0AAA", 1000); + let mut full = make_media(vec![1, 2, 3], false); + full.mime_type = "image/png".to_string(); + live.media = Some(full); + chat.add_message(live); + + // Hydrated image rows carry a jpeg thumbnail flagged as preview; the + // replace must not downgrade the full download to it + let mut hydrated = make_message("3EB0AAA", 1000); + hydrated.media = Some(make_media(vec![9], true)); + chat.insert_history_message(hydrated); + + assert_eq!(chat.messages.len(), 1); + let media = chat.messages[0].media.as_ref().unwrap(); + assert_eq!(*media.data, vec![1, 2, 3]); + assert_eq!(media.mime_type, "image/png"); + assert!(!media.data_is_preview); + } + + #[test] + fn test_hydration_replacement_full_bytes_win_over_existing_preview() { + let mut chat = Chat::new("test@s.whatsapp.net".to_string()); + + // Live bubble degraded to a thumbnail (eager download failed) + let mut live = make_message("3EB0AAA", 1000); + live.media = Some(make_media(vec![9], true)); + chat.add_message(live); + + // Incoming copy carrying real bytes must replace the preview + let mut hydrated = make_message("3EB0AAA", 1000); + hydrated.media = Some(make_media(vec![1, 2, 3, 4], false)); + chat.insert_history_message(hydrated); + + assert_eq!(chat.messages.len(), 1); + let media = chat.messages[0].media.as_ref().unwrap(); + assert_eq!(*media.data, vec![1, 2, 3, 4]); + assert!(!media.data_is_preview); + } + + #[test] + fn test_hydration_replacement_keeps_failed_flag_and_sender_name() { + let mut chat = Chat::new("123456789-group@g.us".to_string()); + + // Live bubble: an outgoing send that failed, plus an incoming group + // bubble that carried its sender's push name + let mut failed_send = make_message("OUT-1", 1000); + failed_send.is_from_me = true; + failed_send.failed = true; + chat.add_message(failed_send); + let mut incoming = make_message("IN-1", 2000); + incoming.sender_name = Some("Alice".to_string()); + chat.add_message(incoming); + + // The hydrated rows carry neither the failure flag nor the push name; + // the replace must not lose them + let mut hydrated_send = make_message("OUT-1", 1000); + hydrated_send.is_from_me = true; + chat.insert_history_message(hydrated_send); + chat.insert_history_message(make_message("IN-1", 2000)); + + assert_eq!(chat.messages.len(), 2); + assert!(chat.messages[0].failed); + assert_eq!(chat.messages[1].sender_name.as_deref(), Some("Alice")); + + // A hydrated name wins over the live one (store is authoritative) + let mut renamed = make_message("IN-1", 2000); + renamed.sender_name = Some("Alice Example".to_string()); + chat.insert_history_message(renamed); + assert_eq!( + chat.messages[1].sender_name.as_deref(), + Some("Alice Example") + ); + } + + #[test] + fn test_mark_as_read_leaves_outgoing_delivery_state_alone() { + let mut chat = Chat::new("test@s.whatsapp.net".to_string()); + let mut outgoing = make_message("out", 1000); + outgoing.is_from_me = true; + chat.add_message(outgoing); + chat.add_message(make_message("in", 2000)); + chat.manually_unread = true; + + chat.mark_as_read(); + + assert_eq!(chat.unread_count, 0); + assert!(!chat.manually_unread); + // Outgoing is_read renders as the peer-read ticks; opening the chat + // must not fabricate them + assert!(!chat.messages[0].is_read); + assert!(chat.messages[1].is_read); + } + + #[test] + fn test_history_sync_batch_simulation() { + let mut chat = Chat::new("test@s.whatsapp.net".to_string()); + + // Simulate a realistic history sync batch where messages arrive + // in random order due to parallel decryption + let messages_in_arrival_order = vec![ + ("msg5", 5000), + ("msg2", 2000), + ("msg8", 8000), + ("msg1", 1000), + ("msg4", 4000), + ("msg7", 7000), + ("msg3", 3000), + ("msg6", 6000), + ]; + + for (id, ts) in messages_in_arrival_order { + chat.add_message(make_message(id, ts)); + } + + assert_eq!(chat.messages.len(), 8); + + // Verify messages are in chronological order + let expected_order = [ + "msg1", "msg2", "msg3", "msg4", "msg5", "msg6", "msg7", "msg8", + ]; + for (i, expected_id) in expected_order.iter().enumerate() { + assert_eq!( + chat.messages[i].id, *expected_id, + "Message at index {} should be {} but was {}", + i, expected_id, chat.messages[i].id + ); + } + + // Verify timestamps are actually ascending + for i in 1..chat.messages.len() { + assert!( + chat.messages[i].timestamp >= chat.messages[i - 1].timestamp, + "Messages should be in ascending timestamp order" + ); + } + } + + #[test] + fn test_new_message_inserted_at_correct_position() { + let mut chat = Chat::new("test@s.whatsapp.net".to_string()); + + // Add some historical messages + chat.add_message(make_message("old1", 1000)); + chat.add_message(make_message("old2", 2000)); + chat.add_message(make_message("old3", 3000)); + + // Now receive a new real-time message + chat.add_message(make_message("new", 4000)); + + assert_eq!(chat.messages.len(), 4); + assert_eq!(chat.messages[3].id, "new"); // Should be at the end + + // Add a late-arriving historical message + chat.add_message(make_message("late_history", 1500)); + + assert_eq!(chat.messages.len(), 5); + assert_eq!(chat.messages[0].id, "old1"); + assert_eq!(chat.messages[1].id, "late_history"); // Inserted in correct position + assert_eq!(chat.messages[2].id, "old2"); + assert_eq!(chat.messages[3].id, "old3"); + assert_eq!(chat.messages[4].id, "new"); + } + + // Reaction tests + + #[test] + fn test_add_single_reaction_to_message() { + let mut msg = make_message("msg1", 1000); + + msg.add_reaction("👍".to_string(), "user1".to_string()); + + assert_eq!(msg.reactions.len(), 1); + assert!(msg.reactions.contains_key("👍")); + assert_eq!(msg.reactions.get("👍").unwrap(), &vec!["user1".to_string()]); + } + + #[test] + fn test_add_multiple_different_reactions_to_message() { + let mut msg = make_message("msg1", 1000); + + msg.add_reaction("👍".to_string(), "user1".to_string()); + msg.add_reaction("❤️".to_string(), "user2".to_string()); + msg.add_reaction("😂".to_string(), "user3".to_string()); + + assert_eq!(msg.reactions.len(), 3); + assert!(msg.reactions.contains_key("👍")); + assert!(msg.reactions.contains_key("❤️")); + assert!(msg.reactions.contains_key("😂")); + } + + #[test] + fn test_multiple_users_same_reaction() { + let mut msg = make_message("msg1", 1000); + + msg.add_reaction("👍".to_string(), "user1".to_string()); + msg.add_reaction("👍".to_string(), "user2".to_string()); + msg.add_reaction("👍".to_string(), "user3".to_string()); + + assert_eq!(msg.reactions.len(), 1); + let senders = msg.reactions.get("👍").unwrap(); + assert_eq!(senders.len(), 3); + assert!(senders.contains(&"user1".to_string())); + assert!(senders.contains(&"user2".to_string())); + assert!(senders.contains(&"user3".to_string())); + } + + #[test] + fn test_user_changes_reaction() { + let mut msg = make_message("msg1", 1000); + + // User1 reacts with 👍 + msg.add_reaction("👍".to_string(), "user1".to_string()); + assert_eq!(msg.reactions.get("👍").unwrap().len(), 1); + + // User1 changes to ❤️ + msg.add_reaction("❤️".to_string(), "user1".to_string()); + + // 👍 should be removed (empty), ❤️ should have user1 + assert!(!msg.reactions.contains_key("👍")); + assert!(msg.reactions.contains_key("❤️")); + assert_eq!(msg.reactions.get("❤️").unwrap(), &vec!["user1".to_string()]); + } + + #[test] + fn test_user_removes_reaction_with_empty_string() { + let mut msg = make_message("msg1", 1000); + + msg.add_reaction("👍".to_string(), "user1".to_string()); + assert_eq!(msg.reactions.len(), 1); + + // Remove reaction by sending empty emoji + msg.add_reaction("".to_string(), "user1".to_string()); + + assert_eq!(msg.reactions.len(), 0); + } + + #[test] + fn test_chat_add_reaction() { + let mut chat = Chat::new("test@s.whatsapp.net".to_string()); + chat.add_message(make_message("msg1", 1000)); + chat.add_message(make_message("msg2", 2000)); + + // Add reaction to msg1 + let found = chat.add_reaction("msg1", "👍".to_string(), "user1".to_string()); + assert!(found); + assert_eq!(chat.messages[0].reactions.len(), 1); + + // Add reaction to msg2 + let found = chat.add_reaction("msg2", "❤️".to_string(), "user2".to_string()); + assert!(found); + assert_eq!(chat.messages[1].reactions.len(), 1); + } + + #[test] + fn test_chat_add_reaction_message_not_found() { + let mut chat = Chat::new("test@s.whatsapp.net".to_string()); + chat.add_message(make_message("msg1", 1000)); + + // Try to add reaction to non-existent message + let found = chat.add_reaction("nonexistent", "👍".to_string(), "user1".to_string()); + assert!(!found); + } + + #[test] + fn test_reaction_count_multiple_emojis() { + let mut msg = make_message("msg1", 1000); + + // 3 users react with 👍 + msg.add_reaction("👍".to_string(), "user1".to_string()); + msg.add_reaction("👍".to_string(), "user2".to_string()); + msg.add_reaction("👍".to_string(), "user3".to_string()); + + // 2 users react with ❤️ + msg.add_reaction("❤️".to_string(), "user4".to_string()); + msg.add_reaction("❤️".to_string(), "user5".to_string()); + + // 1 user reacts with 😂 + msg.add_reaction("😂".to_string(), "user6".to_string()); + + assert_eq!(msg.reactions.len(), 3); + assert_eq!(msg.reactions.get("👍").unwrap().len(), 3); + assert_eq!(msg.reactions.get("❤️").unwrap().len(), 2); + assert_eq!(msg.reactions.get("😂").unwrap().len(), 1); + } +} diff --git a/apps/desktop/src/state/events.rs b/apps/desktop/src/state/events.rs new file mode 100644 index 000000000..f492c8a5c --- /dev/null +++ b/apps/desktop/src/state/events.rs @@ -0,0 +1,75 @@ +//! UI events for communication between client and UI. + +use super::call::{CallId, IncomingCall}; +use super::chat::ChatMessage; + +pub use wacore::types::presence::ReceiptType; + +#[derive(Debug)] +pub enum UiEvent { + InitComplete, + /// Durable history hydrated from the chat store at startup. + HistoryLoaded { + chats: Vec, + /// Whether `chats` is the store's whole display list (the load came + /// back under its limit). Only then can absence from the list mean + /// archived/deleted; a truncated load says nothing about the tail. + complete: bool, + }, + QrCode { + code: String, + timeout_secs: u64, + }, + PairCode { + code: String, + timeout_secs: u64, + }, + PairSuccess, + Connected, + Disconnected(String), + MessageReceived { + chat_jid: String, + message: Box, + sender_name: Option, + }, + ReceiptReceived { + chat_jid: String, + message_ids: Vec, + receipt_type: ReceiptType, + }, + /// The client assigned the real WhatsApp id to a just-sent message; the UI + /// renames its optimistic bubble so receipts/reactions keyed by the real + /// id land on it. + MessageIdAssigned { + chat_jid: String, + local_id: String, + message_id: String, + }, + /// A send attempt failed after the optimistic bubble was already renamed + /// to its real id; the UI marks that bubble failed instead of leaving it + /// pending forever. + SendFailed { + chat_jid: String, + message_id: String, + reason: String, + }, + ReactionReceived { + chat_jid: String, + message_id: String, + sender: String, + emoji: String, + }, + IncomingCall(IncomingCall), + OutgoingCallStarted { + call_id: CallId, + recipient_jid: String, + }, + OutgoingCallFailed { + recipient_jid: String, + error: String, + }, + #[allow(dead_code)] + CallAccepted(CallId), + CallEnded(CallId), + Error(String), +} diff --git a/apps/desktop/src/state/mod.rs b/apps/desktop/src/state/mod.rs new file mode 100644 index 000000000..861383168 --- /dev/null +++ b/apps/desktop/src/state/mod.rs @@ -0,0 +1,18 @@ +//! Application state management +//! +//! This module contains all state-related structures: +//! - `AppState`: The overall application state machine +//! - `Chat` and `ChatMessage`: Chat data structures +//! - `IncomingCall`, `OutgoingCall`, `OutgoingCallState`: Call state +//! - `UiEvent`: Events for UI updates + +mod app_state; +mod call; +mod chat; +mod events; + +pub use app_state::{AppState, CachedQrCode}; +pub use call::{CallId, IncomingCall, OutgoingCall, OutgoingCallState}; +pub(crate) use chat::fallback_chat_name; +pub use chat::{Chat, ChatMessage, DownloadableMedia, MediaContent, MediaType}; +pub use events::{ReceiptType, UiEvent}; diff --git a/apps/desktop/src/theme.rs b/apps/desktop/src/theme.rs new file mode 100644 index 000000000..6a68791a1 --- /dev/null +++ b/apps/desktop/src/theme.rs @@ -0,0 +1,57 @@ +//! WhatsApp theme colors and layout constants +//! +//! This module provides static theme constants. For responsive layout dimensions +//! that adapt to screen size, use the `responsive` module instead. + +/// WhatsApp-specific colors +pub mod colors { + pub const ACCENT_GREEN: u32 = 0x00a884; + pub const ACCENT_RED: u32 = 0xff4444; + #[allow(dead_code)] + pub const ACCENT_BLUE: u32 = 0x53bdeb; + + pub const BG_MESSAGE_SENT: u32 = 0x005c4b; + pub const BG_MESSAGE_RECEIVED: u32 = 0x202c33; + + pub const BG_PRIMARY: u32 = 0x111b21; + pub const BG_SECONDARY: u32 = 0x202c33; + pub const BG_CHAT: u32 = 0x0b141a; + pub const BG_HOVER: u32 = 0x2a3942; + pub const BG_SELECTED: u32 = 0x374248; + + pub const TEXT_PRIMARY: u32 = 0xe9edef; + pub const TEXT_SECONDARY: u32 = 0x8696a0; + + pub const BORDER: u32 = 0x2a3942; + pub const ERROR: u32 = 0xff4444; + pub const WHITE: u32 = 0xffffff; + pub const BLACK: u32 = 0x000000; +} + +/// Static layout constants (non-responsive) +/// +/// For dimensions that should adapt to screen size, use `ResponsiveLayout` instead. +/// These constants are for fixed-size elements. +pub mod layout { + // Used by InputAreaView (doesn't receive ResponsiveLayout) + pub const INPUT_AREA_HEIGHT: f32 = 62.0; + + // Fixed-size elements + pub const QR_CODE_SIZE: f32 = 256.0; + pub const RADIUS_SMALL: f32 = 4.0; + pub const RADIUS_MEDIUM: f32 = 8.0; + pub const RADIUS_LARGE: f32 = 20.0; + + // Message bubble constants + pub const MSG_PADDING_TOP_FIRST: f32 = 8.0; + pub const MSG_PADDING_TOP_GROUPED: f32 = 6.0; + pub const MSG_PADDING_BOTTOM: f32 = 4.0; + pub const MSG_BUBBLE_PADDING_Y: f32 = 8.0; + pub const MSG_BUBBLE_PADDING_X: f32 = 12.0; + pub const MSG_CONTENT_GAP: f32 = 4.0; + pub const MSG_TEXT_LINE_HEIGHT: f32 = 22.0; + pub const MSG_TIME_ROW_HEIGHT: f32 = 24.0; + pub const MSG_SENDER_NAME_HEIGHT: f32 = 22.0; + pub const MSG_REACTION_MARGIN_TOP: f32 = 4.0; + pub const MSG_REACTION_HEIGHT: f32 = 28.0; +} diff --git a/apps/desktop/src/utils.rs b/apps/desktop/src/utils.rs new file mode 100644 index 000000000..c4e3d507e --- /dev/null +++ b/apps/desktop/src/utils.rs @@ -0,0 +1,74 @@ +//! Shared utility functions for the WhatsApp UI + +use chrono::{DateTime, Local, Utc}; +use gpui::ImageFormat; + +/// Convert a MIME type string to a GPUI ImageFormat +pub fn mime_to_image_format(mime: &str) -> ImageFormat { + match mime { + // image/jpg is non-standard but some senders emit it + "image/jpeg" | "image/jpg" => ImageFormat::Jpeg, + "image/png" => ImageFormat::Png, + "image/gif" => ImageFormat::Gif, + "image/webp" => ImageFormat::Webp, + "image/bmp" => ImageFormat::Bmp, + _ => { + log::warn!("unrecognized image MIME type {mime}, falling back to PNG"); + ImageFormat::Png + } + } +} + +/// Scale media dimensions to fit within `max_size` without upscaling, with a +/// ~50px floor on the short side. Both fit and floor are single uniform +/// factors so aspect ratio is always preserved; the floor yields to the +/// `max_size` cap, so a pathological ratio (e.g. 200x20) keeps its shape and +/// accepts a sub-50px short side instead of stretching or overflowing. +pub fn scale_media_dimensions(width: u32, height: u32, max_size: f32) -> (f32, f32) { + let w = width.max(1) as f32; + let h = height.max(1) as f32; + let fit = (max_size / w).min(max_size / h).min(1.0); + let floor = 50.0 / (w.min(h) * fit); + let cap = (max_size / (w.max(h) * fit)).max(1.0); + let scale = fit * floor.clamp(1.0, cap); + (w * scale, h * scale) +} + +/// Format a UTC timestamp as local time (HH:MM format). +/// +/// Converts from UTC to the system's local timezone before formatting. +/// This ensures timestamps are displayed correctly regardless of where +/// the user is located. +pub fn format_time_local(timestamp: &DateTime) -> String { + let local: DateTime = timestamp.with_timezone(&Local); + local.format("%H:%M").to_string() +} + +#[cfg(test)] +mod tests { + use super::scale_media_dimensions; + + fn assert_close(actual: (f32, f32), expected: (f32, f32)) { + assert!( + (actual.0 - expected.0).abs() < 0.01 && (actual.1 - expected.1).abs() < 0.01, + "{actual:?} != {expected:?}" + ); + } + + #[test] + fn shrinks_large_media_uniformly() { + assert_close(scale_media_dimensions(4000, 3000, 300.0), (300.0, 225.0)); + } + + #[test] + fn floors_tiny_media_uniformly() { + assert_close(scale_media_dimensions(10, 10, 300.0), (50.0, 50.0)); + } + + #[test] + fn extreme_ratio_keeps_shape_and_respects_cap() { + // 10:1 stays 10:1; the floor grow stops at max_size instead of + // stretching only the short side (the old per-axis behavior). + assert_close(scale_media_dimensions(200, 20, 300.0), (300.0, 30.0)); + } +} diff --git a/apps/desktop/src/video/audio.rs b/apps/desktop/src/video/audio.rs new file mode 100644 index 000000000..2f5ff0212 --- /dev/null +++ b/apps/desktop/src/video/audio.rs @@ -0,0 +1,226 @@ +//! Audio extraction from video files +//! +//! This module provides audio extraction functionality for MP4 video files. +//! The video decoding is handled by StreamingVideoDecoder in streaming.rs. + +use std::io::Cursor; + +use mp4::{Mp4Reader, TrackType}; + +/// ADTS sample rate to frequency index mapping +const ADTS_FREQ_TABLE: [(u32, u8); 13] = [ + (96000, 0), + (88200, 1), + (64000, 2), + (48000, 3), + (44100, 4), + (32000, 5), + (24000, 6), + (22050, 7), + (16000, 8), + (12000, 9), + (11025, 10), + (8000, 11), + (7350, 12), +]; + +/// Decoded audio data from video (always mono after conversion) +#[derive(Clone)] +pub struct VideoAudio { + /// PCM samples (f32, mono) + pub samples: Vec, + /// Sample rate in Hz + pub sample_rate: u32, +} + +/// Extract audio from MP4 using mp4 crate for demuxing and symphonia for AAC decoding +pub fn extract_audio_from_mp4(mp4_data: &[u8]) -> Option { + use symphonia::core::codecs::CodecParameters; + use symphonia::core::codecs::audio::AudioDecoderOptions; + use symphonia::core::formats::FormatOptions; + use symphonia::core::formats::probe::Hint; + use symphonia::core::io::MediaSourceStream; + use symphonia::core::meta::MetadataOptions; + + // First, use mp4 crate to find and extract audio track + let cursor = Cursor::new(mp4_data); + let mut mp4 = match Mp4Reader::read_header(cursor, mp4_data.len() as u64) { + Ok(mp4) => mp4, + Err(e) => { + log::warn!("Failed to read MP4 for audio extraction: {}", e); + return None; + } + }; + + // Find audio track + let audio_track = mp4 + .tracks() + .values() + .find(|t| matches!(t.track_type(), Ok(TrackType::Audio)))?; + + let track_id = audio_track.track_id(); + let sample_count = audio_track.sample_count(); + + // Get audio parameters + let sample_rate = audio_track + .sample_freq_index() + .ok() + .map(|f| f.freq()) + .unwrap_or(44100); + let channels = audio_track + .channel_config() + .ok() + .map(|c| c as u8) + .unwrap_or(2); + + log::info!( + "Audio track found: id={}, {} samples, {} Hz, {} channels", + track_id, + sample_count, + sample_rate, + channels + ); + + // Extract raw AAC frames from MP4 (reusing the same reader) + let mut aac_frames: Vec> = Vec::new(); + for sample_idx in 1..=sample_count { + if let Ok(Some(sample)) = mp4.read_sample(track_id, sample_idx) { + aac_frames.push(sample.bytes.to_vec()); + } + } + + if aac_frames.is_empty() { + log::info!("No AAC frames extracted"); + return None; + } + + log::info!("Extracted {} AAC frames from MP4", aac_frames.len()); + + // Convert raw AAC frames to ADTS format for symphonia + let adts_data = wrap_aac_as_adts(&aac_frames, sample_rate, channels); + + log::info!("Created ADTS stream: {} bytes", adts_data.len()); + + // Now decode ADTS using symphonia + let cursor = Cursor::new(adts_data); + let mss = MediaSourceStream::new(Box::new(cursor), Default::default()); + + let mut hint = Hint::new(); + hint.with_extension("aac"); + let format_opts = FormatOptions::default(); + let metadata_opts = MetadataOptions::default(); + + let mut format = + match symphonia::default::get_probe().probe(&hint, mss, format_opts, metadata_opts) { + Ok(f) => f, + Err(e) => { + log::warn!("Failed to probe ADTS format: {}", e); + return None; + } + }; + + // Find the audio track in ADTS + let track = format.tracks().first()?; + + let adts_track_id = track.id; + let Some(CodecParameters::Audio(audio_params)) = track.codec_params.clone() else { + log::warn!("ADTS track carries no audio codec parameters"); + return None; + }; + let decoder_opts = AudioDecoderOptions::default(); + let mut decoder = + match symphonia::default::get_codecs().make_audio_decoder(&audio_params, &decoder_opts) { + Ok(d) => d, + Err(e) => { + log::warn!("Failed to create AAC decoder: {}", e); + return None; + } + }; + + let mut all_samples: Vec = Vec::new(); + let mut frame: Vec = Vec::new(); + + // Decode all audio packets + while let Ok(Some(packet)) = format.next_packet() { + if packet.track_id != adts_track_id { + continue; + } + + match decoder.decode(&packet) { + Ok(decoded) => { + decoded.copy_to_vec_interleaved(&mut frame); + all_samples.extend_from_slice(&frame); + } + Err(e) => { + log::debug!("Audio decode error (skipping frame): {}", e); + } + } + } + + if all_samples.is_empty() { + log::info!("No audio samples decoded"); + return None; + } + + // Downmix to mono if needed (average across all channels; the API promises mono) + let mono_samples = if channels > 1 { + let mono: Vec = all_samples + .chunks(channels as usize) + .map(|chunk| chunk.iter().sum::() / chunk.len() as f32) + .collect(); + log::info!( + "Converted {} interleaved samples to {} mono samples", + all_samples.len(), + mono.len() + ); + mono + } else { + all_samples + }; + + log::info!( + "Decoded {} audio samples ({:.2}s)", + mono_samples.len(), + mono_samples.len() as f32 / sample_rate as f32 + ); + + Some(VideoAudio { + samples: mono_samples, + sample_rate, + }) +} + +/// Wrap raw AAC frames in ADTS format for symphonia +fn wrap_aac_as_adts(frames: &[Vec], sample_rate: u32, channels: u8) -> Vec { + let mut adts = Vec::new(); + + // Map sample rate to ADTS frequency index using lookup table + let freq_idx = ADTS_FREQ_TABLE + .iter() + .find(|(rate, _)| *rate == sample_rate) + .map(|(_, idx)| *idx) + .unwrap_or(4); // Default to 44100 (index 4) + + // ADTS profile field stores Audio Object Type minus one; AAC-LC AOT = 2 + let profile = 2u8; // AAC-LC + + for frame in frames { + let frame_len = frame.len() + 7; // ADTS header is 7 bytes + + // Build 7-byte ADTS header + let header: [u8; 7] = [ + 0xFF, + 0xF1, // Syncword + MPEG-4 + no CRC + ((profile - 1) << 6) | (freq_idx << 2) | ((channels >> 2) & 0x01), + ((channels & 0x03) << 6) | ((frame_len >> 11) & 0x03) as u8, + ((frame_len >> 3) & 0xFF) as u8, + (((frame_len & 0x07) << 5) | 0x1F) as u8, + 0xFC, // Buffer fullness VBR + 0 frames - 1 + ]; + + adts.extend_from_slice(&header); + adts.extend_from_slice(frame); + } + + adts +} diff --git a/apps/desktop/src/video/mod.rs b/apps/desktop/src/video/mod.rs new file mode 100644 index 000000000..25295a520 --- /dev/null +++ b/apps/desktop/src/video/mod.rs @@ -0,0 +1,17 @@ +//! Video module for video message playback +//! +//! This module provides: +//! - MP4 demuxing and H.264 software decoding via OpenH264 +//! - Memory-efficient streaming decoder (on-demand frame decoding, ~16x less memory) +//! - Video player state management +//! - Audio extraction from video files (for video audio track playback) + +mod audio; +mod player; +mod streaming; + +// Memory-efficient streaming decoder (on-demand decoding, ~3MB vs ~48MB) +pub use streaming::StreamingVideoDecoder; + +// Video player state machine +pub use player::{VideoPlayer, VideoPlayerState}; diff --git a/apps/desktop/src/video/player.rs b/apps/desktop/src/video/player.rs new file mode 100644 index 000000000..d6f6379ec --- /dev/null +++ b/apps/desktop/src/video/player.rs @@ -0,0 +1,226 @@ +//! Video player state management + +use std::sync::Arc; +use std::time::Duration; + +use gpui::RenderImage; +use tokio::sync::oneshot; +use wacore::time::Instant; + +use super::audio::VideoAudio; +use super::streaming::StreamingVideoDecoder; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum VideoPlayerState { + Idle, + Downloading, + Decoding, + Playing, + Paused, + Error, +} + +impl VideoPlayerState { + pub fn is_playing(self) -> bool { + self == Self::Playing + } + + pub fn is_paused(self) -> bool { + self == Self::Paused + } + + pub fn is_loading(self) -> bool { + matches!(self, Self::Downloading | Self::Decoding) + } + + pub fn is_error(self) -> bool { + self == Self::Error + } +} + +/// Video player using StreamingVideoDecoder for memory-efficient on-demand frame decoding. +pub struct VideoPlayer { + state: VideoPlayerState, + decoder: Option, + playback_start: Option, + paused_at: Option, + error: Option, + current_frame: Option>, + current_timestamp: Duration, + audio: Option>, + needs_audio_start: bool, + completion_tx: Option>, +} + +impl Default for VideoPlayer { + fn default() -> Self { + Self::new() + } +} + +impl VideoPlayer { + pub fn new() -> Self { + Self { + state: VideoPlayerState::Idle, + decoder: None, + playback_start: None, + paused_at: None, + error: None, + current_frame: None, + current_timestamp: Duration::ZERO, + audio: None, + needs_audio_start: false, + completion_tx: None, + } + } + + /// Subscribe to playback completion (only one subscriber supported at a time). + pub fn on_complete(&mut self) -> oneshot::Receiver<()> { + let (tx, rx) = oneshot::channel(); + self.completion_tx = Some(tx); + rx + } + + pub fn state(&self) -> VideoPlayerState { + self.state + } + + pub fn set_downloading(&mut self) { + self.state = VideoPlayerState::Downloading; + self.error = None; + } + + pub fn set_decoding(&mut self) { + self.state = VideoPlayerState::Decoding; + } + + pub fn load(&mut self, mut decoder: StreamingVideoDecoder) { + log::info!( + "VideoPlayer::load - frames: {}, duration: {:?}", + decoder.frame_count(), + decoder.duration() + ); + decoder.seek_to_frame(0); + self.decoder = Some(decoder); + self.state = VideoPlayerState::Paused; + self.paused_at = Some(Duration::ZERO); + let frame_updated = self.update_current_frame(); + log::info!( + "VideoPlayer::load - frame_updated: {}, current_frame.is_some: {}", + frame_updated, + self.current_frame.is_some() + ); + } + + pub fn set_error(&mut self, error: String) { + self.state = VideoPlayerState::Error; + self.error = Some(error); + } + + /// Start or resume playback. Returns true if audio needs to be started. + pub fn play(&mut self) -> bool { + let Some(_) = self.decoder.as_ref() else { + return false; + }; + + let offset = self.paused_at.unwrap_or(Duration::ZERO); + self.playback_start = Some(Instant::now() - offset); + self.paused_at = None; + self.state = VideoPlayerState::Playing; + + if self.needs_audio_start { + self.needs_audio_start = false; + return self.audio.is_some(); + } + false + } + + pub fn set_audio(&mut self, audio: VideoAudio) { + self.audio = Some(Arc::new(audio)); + self.needs_audio_start = true; + } + + pub fn get_audio(&self) -> Option<&VideoAudio> { + self.audio.as_deref() + } + + pub fn pause(&mut self) { + if self.state == VideoPlayerState::Playing { + self.paused_at = Some(self.current_time()); + self.playback_start = None; + self.state = VideoPlayerState::Paused; + } + } + + pub fn stop(&mut self) { + self.playback_start = None; + self.paused_at = Some(Duration::ZERO); + self.state = VideoPlayerState::Paused; + if let Some(decoder) = &mut self.decoder { + decoder.reset(); + } + self.update_current_frame(); + self.needs_audio_start = true; + self.completion_tx = None; + } + + pub fn current_time(&self) -> Duration { + self.playback_start + .map(|start| start.elapsed()) + .or(self.paused_at) + .unwrap_or(Duration::ZERO) + } + + /// Update the current frame based on playback time. Returns true if frame changed. + pub fn update(&mut self) -> bool { + let Some(decoder) = &self.decoder else { + return false; + }; + if self.state != VideoPlayerState::Playing { + return false; + } + + let current_time = self.current_time(); + + if current_time >= decoder.duration() { + // stop() clears completion_tx, so take it first or the notification is lost + let completion_tx = self.completion_tx.take(); + self.stop(); + if let Some(tx) = completion_tx { + let _ = tx.send(()); + } + return true; + } + + if let Some(decoder) = &mut self.decoder { + decoder.seek(current_time); + } + self.update_current_frame() + } + + fn update_current_frame(&mut self) -> bool { + let Some(decoder) = &self.decoder else { + return false; + }; + let Some(frame) = decoder.current_frame() else { + return false; + }; + + let changed = self.current_timestamp != frame.timestamp; + if changed { + log::debug!( + "VideoPlayer: frame {} -> {} ({})", + self.current_timestamp.as_millis(), + frame.timestamp.as_millis(), + frame.index + ); + } + self.current_frame = Some(Arc::clone(&frame.image)); + self.current_timestamp = frame.timestamp; + changed + } + + pub fn current_frame(&self) -> Option> { + self.current_frame.clone() + } +} diff --git a/apps/desktop/src/video/streaming.rs b/apps/desktop/src/video/streaming.rs new file mode 100644 index 000000000..fca1ab0d2 --- /dev/null +++ b/apps/desktop/src/video/streaming.rs @@ -0,0 +1,633 @@ +//! Streaming video decoder with on-demand frame decoding. +//! +//! Same pipeline Zed's livekit_client uses on Linux: decode H.264 with +//! openh264, let `YUVSource::write_rgba8` do the YUV→RGBA conversion +//! (SIMD-accelerated when available), then wrap the RGBA buffer in a +//! [`gpui::RenderImage`]. Upstream GPUI has no YUV surface on Linux — the +//! macOS `CVPixelBuffer` path is the only hardware-accelerated route, so +//! doing the convert in CPU here matches what Zed itself does. + +use std::io::Cursor; +use std::sync::Arc; +use std::time::Duration; + +use anyhow::{Context, Result, anyhow}; +use gpui::RenderImage; +use image::{Frame, RgbaImage}; +use mp4::{Mp4Reader, TrackType}; +use openh264::decoder::Decoder; +use openh264::formats::YUVSource; +use smallvec::SmallVec; + +use super::audio::VideoAudio; + +/// NAL unit start code for Annex B format +const NAL_START_CODE: &[u8] = &[0x00, 0x00, 0x00, 0x01]; + +/// NAL length size in AVCC format (typically 4 bytes for WhatsApp videos) +const NAL_LENGTH_SIZE: usize = 4; + +/// A decoded video frame, BGRA8-encoded and ready to hand to `gpui::img`. +#[derive(Clone)] +pub struct StreamingFrame { + /// Decoded RGBA frame, converted from YUV to BGRA in CPU. + pub image: Arc, + /// Presentation timestamp + pub timestamp: Duration, + /// Frame index + pub index: usize, +} + +/// H.264 sample in Annex B format (ready for decoder) +struct H264Sample { + /// NAL units in Annex B format + data: Vec, + /// Whether this is a keyframe (IDR) + is_keyframe: bool, +} + +/// Streaming video decoder that decodes frames on-demand. +pub struct StreamingVideoDecoder { + /// H.264 samples (Annex B format) - compressed, small + samples: Vec, + /// SPS/PPS NAL units (needed to initialize decoder) + sps_pps: Vec, + /// Video dimensions + width: u32, + height: u32, + /// Frame duration + frame_duration: Duration, + /// Total video duration + duration: Duration, + /// Current decoder state + decoder: Decoder, + /// Index of last decoded frame (-1 if none) + last_decoded_index: i32, + /// Currently decoded frame (only 1 in memory) + current_frame: Option, + /// Decoded audio from the video + audio: Option, + /// Reusable RGBA scratch buffer so we don't allocate `w*h*4` per frame. + /// `std::mem::take`'d each time a frame is kept, to move ownership into + /// `RenderImage`; refilled from capacity on the next keep. + rgba_buffer: Vec, + /// Precomputed `width * height * 4`; re-allocation size for the buffer. + rgba_byte_len: usize, +} + +impl StreamingVideoDecoder { + /// Create a new streaming video decoder from MP4 data + pub fn new(mp4_data: &[u8]) -> Result { + log::info!( + "StreamingVideoDecoder: parsing MP4 data ({} bytes)", + mp4_data.len() + ); + + let cursor = Cursor::new(mp4_data); + let mp4 = Mp4Reader::read_header(cursor, mp4_data.len() as u64) + .context("Failed to read MP4 header")?; + + // Log all tracks found + log::info!("MP4 contains {} tracks:", mp4.tracks().len()); + for (id, track) in mp4.tracks() { + let track_type = track + .track_type() + .map(|t| format!("{:?}", t)) + .unwrap_or_else(|_| "Unknown".to_string()); + log::info!( + " Track {}: type={}, media_type={:?}, codec={:?}", + id, + track_type, + track.media_type(), + track + .video_profile() + .map(|p| format!("{:?}", p)) + .unwrap_or_else(|_| "N/A".to_string()) + ); + } + + // Find video track + let video_track = mp4 + .tracks() + .values() + .find(|t| matches!(t.track_type(), Ok(TrackType::Video))) + .ok_or_else(|| anyhow!("No video track found in MP4"))?; + + let track_id = video_track.track_id(); + let duration = video_track.duration(); + let sample_count = video_track.sample_count(); + + // Calculate FPS and frame duration; guard sample_count so an empty track + // can't produce fps=0 and panic Duration::from_secs_f64 with infinity + let fps = if sample_count > 0 && duration.as_secs_f64() > 0.0 { + sample_count as f64 / duration.as_secs_f64() + } else { + 30.0 + }; + let frame_duration = Duration::from_secs_f64(1.0 / fps); + + // Get video dimensions + let width = video_track.width() as u32; + let height = video_track.height() as u32; + + // Log detailed video track info + log::info!( + "Video track {}: {}x{}, {} samples, {:.2} fps, duration: {:.2}s", + track_id, + width, + height, + sample_count, + fps, + duration.as_secs_f64(), + ); + log::info!( + "Video track details: timescale={}, bitrate={} kbps", + video_track.timescale(), + video_track.bitrate() / 1000, + ); + + // Get SPS and PPS from the track + let sps = video_track + .sequence_parameter_set() + .ok() + .map(|s| s.to_vec()); + let pps = video_track.picture_parameter_set().ok().map(|s| s.to_vec()); + + // Log SPS/PPS info + log::info!( + "SPS: {} bytes, PPS: {} bytes", + sps.as_ref().map(|s| s.len()).unwrap_or(0), + pps.as_ref().map(|s| s.len()).unwrap_or(0), + ); + if let Some(ref sps_data) = sps + && !sps_data.is_empty() + { + // Log first few bytes of SPS for debugging + let preview: Vec = sps_data + .iter() + .take(16) + .map(|b| format!("{:02x}", b)) + .collect(); + log::debug!("SPS data (first 16 bytes): {}", preview.join(" ")); + + // Parse H.264 profile from SPS (byte 1 after NAL header) + // SPS NAL type is 7, so first byte is NAL header, then profile_idc + if sps_data.len() >= 4 { + let profile_idc = sps_data[1]; + let constraint_flags = sps_data[2]; + let level_idc = sps_data[3]; + + let profile_name = match profile_idc { + 66 => "Baseline", + 77 => "Main", + 88 => "Extended", + 100 => "High", + 110 => "High 10", + 122 => "High 4:2:2", + 244 => "High 4:4:4 Predictive", + _ => "Unknown", + }; + + log::info!( + "H.264 Profile: {} (profile_idc={}), Level: {}.{}, Constraints: 0x{:02x}", + profile_name, + profile_idc, + level_idc / 10, + level_idc % 10, + constraint_flags + ); + + // Warn about potentially problematic profiles + if profile_idc >= 100 { + log::warn!( + "Video uses {} profile - OpenH264 may have limited support for advanced features", + profile_name + ); + } + } + } + + // Build SPS/PPS in Annex B format + let sps_pps = Self::build_sps_pps_annexb(sps.as_deref(), pps.as_deref()); + log::info!("Built SPS/PPS Annex B data: {} bytes", sps_pps.len()); + + // Extract H.264 samples (keep compressed) + let samples = Self::extract_samples(mp4_data, track_id, sample_count)?; + + // Calculate memory savings + let compressed_size: usize = samples.iter().map(|s| s.data.len()).sum(); + let yuv_frame_size = (width as usize * height as usize * 3) / 2; // YUV420 = 1.5 bytes/pixel + let bgra_frame_size = width as usize * height as usize * 4; + log::info!( + "StreamingVideoDecoder: H.264={} KB, YUV frame={} KB (vs {} KB BGRA, {:.0}% savings)", + compressed_size / 1024, + yuv_frame_size / 1024, + bgra_frame_size / 1024, + (1.0 - yuv_frame_size as f64 / bgra_frame_size as f64) * 100.0 + ); + + // Create decoder + let decoder = Decoder::new().context("Failed to create H.264 decoder")?; + + // Extract audio + let audio = Self::extract_audio(mp4_data); + + let rgba_byte_len = (width as usize) * (height as usize) * 4; + + Ok(Self { + samples, + sps_pps, + width, + height, + frame_duration, + duration, + decoder, + last_decoded_index: -1, + current_frame: None, + audio, + rgba_buffer: vec![0u8; rgba_byte_len], + rgba_byte_len, + }) + } + + /// Extract H.264 samples from MP4 without decoding + fn extract_samples( + mp4_data: &[u8], + track_id: u32, + sample_count: u32, + ) -> Result> { + let cursor = Cursor::new(mp4_data); + let mut mp4 = Mp4Reader::read_header(cursor, mp4_data.len() as u64)?; + + let mut samples = Vec::with_capacity(sample_count as usize); + let mut keyframe_count = 0; + let mut total_size = 0usize; + let mut failed_reads = 0; + + for sample_idx in 1..=sample_count { + match mp4.read_sample(track_id, sample_idx) { + Ok(Some(sample)) => { + // Log first sample's raw data for debugging + if sample_idx == 1 { + let preview: Vec = sample + .bytes + .iter() + .take(32) + .map(|b| format!("{:02x}", b)) + .collect(); + log::debug!("First sample raw data (32 bytes): {}", preview.join(" ")); + log::debug!("First sample size: {} bytes", sample.bytes.len()); + } + + // Convert AVCC to Annex B format + let annexb_data = Self::avcc_to_annexb(&sample.bytes, NAL_LENGTH_SIZE); + + // Log NAL unit types in first sample + if sample_idx == 1 { + let nal_types = Self::get_nal_types(&annexb_data); + log::info!("First sample NAL types: {:?}", nal_types); + } + + // Check if this is a keyframe by looking at NAL unit type + let is_keyframe = Self::is_keyframe(&annexb_data); + if is_keyframe { + keyframe_count += 1; + } + total_size += annexb_data.len(); + + samples.push(H264Sample { + data: annexb_data, + is_keyframe, + }); + } + Ok(None) => { + failed_reads += 1; + log::warn!("Sample {} returned None", sample_idx); + } + Err(e) => { + failed_reads += 1; + log::warn!("Failed to read sample {}: {}", sample_idx, e); + } + } + } + + log::info!( + "Extracted {} samples: {} keyframes, {} failed reads, total size: {} KB", + samples.len(), + keyframe_count, + failed_reads, + total_size / 1024 + ); + + if samples.is_empty() { + return Err(anyhow!("No video samples could be extracted")); + } + + // Log keyframe positions if there are issues + if keyframe_count == 0 { + log::warn!("No keyframes detected! Video may not decode correctly."); + } + + Ok(samples) + } + + /// Get all NAL unit types in the data (for debugging) + fn get_nal_types(annexb_data: &[u8]) -> Vec { + let mut types = Vec::new(); + let mut i = 0; + while i + 4 < annexb_data.len() { + if annexb_data[i..i + 4] == [0, 0, 0, 1] + && let Some(&byte) = annexb_data.get(i + 4) + { + types.push(byte & 0x1F); + } + i += 1; + } + types + } + + /// Check if NAL units contain an IDR (keyframe) + fn is_keyframe(annexb_data: &[u8]) -> bool { + // Look for NAL unit type 5 (IDR slice) + let mut i = 0; + while i + 4 < annexb_data.len() { + if annexb_data[i..i + 4] == [0, 0, 0, 1] { + let nal_type = annexb_data.get(i + 4).map(|b| b & 0x1F).unwrap_or(0); + if nal_type == 5 { + return true; + } + } + i += 1; + } + false + } + + /// Get total number of frames + pub fn frame_count(&self) -> usize { + self.samples.len() + } + + /// Get video duration + pub fn duration(&self) -> Duration { + self.duration + } + + /// Seek to a specific time and decode that frame + pub fn seek(&mut self, time: Duration) { + let target_index = (time.as_secs_f64() / self.frame_duration.as_secs_f64()) as usize; + let target_index = target_index.min(self.samples.len().saturating_sub(1)); + self.seek_to_frame(target_index); + } + + /// Seek to a specific frame index + pub fn seek_to_frame(&mut self, target_index: usize) { + if target_index >= self.samples.len() { + return; + } + + // If we're already at this frame, no need to decode + if let Some(ref frame) = self.current_frame + && frame.index == target_index + { + return; + } + + // Determine where to start decoding from + let start_index = if target_index as i32 > self.last_decoded_index { + // Moving forward - continue from where we are + (self.last_decoded_index + 1) as usize + } else { + // Moving backward - need to reset decoder and start from beginning + self.reset_decoder(); + 0 + }; + + // Decode frames from start_index to target_index + for idx in start_index..=target_index { + self.decode_frame(idx, idx == target_index); + } + } + + /// Reset decoder state (needed when seeking backward) + fn reset_decoder(&mut self) { + // Create new decoder instance + if let Ok(new_decoder) = Decoder::new() { + self.decoder = new_decoder; + self.last_decoded_index = -1; + + // Feed SPS/PPS to initialize + if !self.sps_pps.is_empty() { + let _ = self.decoder.decode(&self.sps_pps); + } + } + } + + /// Decode a single frame + fn decode_frame(&mut self, index: usize, keep_output: bool) { + if index >= self.samples.len() { + return; + } + + let is_keyframe = self.samples[index].is_keyframe; + let sample_size = self.samples[index].data.len(); + + // Log first frame decode attempt + if index == 0 { + log::info!( + "Decoding first frame: keyframe={}, size={} bytes, keep_output={}", + is_keyframe, + sample_size, + keep_output + ); + } + + // For keyframes, feed SPS/PPS first + if is_keyframe && !self.sps_pps.is_empty() { + log::debug!("Feeding SPS/PPS before keyframe {}", index); + let _ = self.decoder.decode(&self.sps_pps); + } + + // Decode the sample + match self.decoder.decode(&self.samples[index].data) { + Ok(Some(yuv)) => { + self.last_decoded_index = index as i32; + + if index == 0 { + let (y_stride, u_stride, v_stride) = yuv.strides(); + log::info!( + "First frame decoded: strides=({}, {}, {}), plane sizes=({}, {}, {})", + y_stride, + u_stride, + v_stride, + yuv.y().len(), + yuv.u().len(), + yuv.v().len() + ); + } + + // Only materialize a frame if the caller wants to keep it + if keep_output { + // openh264 writes RGBA directly (SIMD path `write_rgba8_f32x8` + // when the host supports it, scalar fallback otherwise). + yuv.write_rgba8(&mut self.rgba_buffer); + + let owned = + std::mem::replace(&mut self.rgba_buffer, vec![0u8; self.rgba_byte_len]); + let Some(image) = RgbaImage::from_raw(self.width, self.height, owned) else { + log::warn!( + "Frame {}: RgbaImage::from_raw failed (size mismatch)", + index + ); + return; + }; + let render_image = + Arc::new(RenderImage::new(SmallVec::from_elem(Frame::new(image), 1))); + + let timestamp = self.frame_duration * index as u32; + self.current_frame = Some(StreamingFrame { + image: render_image, + timestamp, + index, + }); + + if index == 0 { + log::info!( + "First frame RGBA created: {} bytes ({}x{})", + self.rgba_byte_len, + self.width, + self.height + ); + } + } + } + Ok(None) => { + // Decoder needs more data (buffering) + self.last_decoded_index = index as i32; + if index == 0 { + log::warn!( + "First frame returned None (decoder buffering) - may need more data" + ); + } + } + Err(e) => { + // Get NAL types for debugging + let nal_types = Self::get_nal_types(&self.samples[index].data); + let error_str = format!("{}", e); + + // Parse native error code and provide human-readable explanation + let error_explanation = if error_str.contains("Native:") { + // Extract native code from error string like "Native:16" + let native_code = error_str + .split("Native:") + .nth(1) + .and_then(|s| s.split('.').next()) + .and_then(|s| s.trim().parse::().ok()) + .unwrap_or(-1); + + match native_code { + 1 => "dsFramePending - decoder needs more data, not enough NAL units", + 2 => "dsRefLost - reference frame lost, may need to seek to keyframe", + 3 => "dsBitstreamError - corrupted bitstream or invalid NAL", + 4 => "dsDepLayerLost - dependency layer lost", + 5 => "dsNoParamSets - missing SPS/PPS parameter sets", + 6 => "dsDataErrorConcealed - error concealed, frame may be corrupted", + 16 => { + "dsInvalidArgument - invalid data passed to decoder (possibly wrong NAL format or corrupted frame)" + } + 32 => "dsInitialOptExpected - initialization option expected", + 64 => "dsOutOfMemory - decoder ran out of memory", + _ => "unknown error code", + } + } else { + "see error details above" + }; + + log::warn!( + "Failed to decode frame {} (keyframe={}, size={} bytes, NAL types={:?}): {} - {}", + index, + is_keyframe, + sample_size, + nal_types, + e, + error_explanation + ); + + // If this is after many consecutive failures, it might indicate a codec issue + if index > 0 && index.is_multiple_of(100) { + log::warn!( + "Multiple decode failures - video may use unsupported H.264 features (B-frames, high profile, etc.)" + ); + } + + self.last_decoded_index = index as i32; + } + } + } + + /// Get current decoded frame + pub fn current_frame(&self) -> Option<&StreamingFrame> { + self.current_frame.as_ref() + } + + /// Reset to first frame + pub fn reset(&mut self) { + self.reset_decoder(); + self.current_frame = None; + self.seek_to_frame(0); + } + + /// Take the audio data (consumes it from the decoder) + pub fn take_audio(&mut self) -> Option { + self.audio.take() + } + + /// Convert AVCC format NAL units to Annex B format + fn avcc_to_annexb(avcc_data: &[u8], nal_length_size: usize) -> Vec { + let mut annexb = Vec::with_capacity(avcc_data.len() + 128); + let mut pos = 0; + + while pos + nal_length_size <= avcc_data.len() { + let mut nal_len: usize = 0; + for i in 0..nal_length_size { + nal_len = (nal_len << 8) | (avcc_data[pos + i] as usize); + } + pos += nal_length_size; + + if pos + nal_len > avcc_data.len() { + break; + } + + annexb.extend_from_slice(NAL_START_CODE); + annexb.extend_from_slice(&avcc_data[pos..pos + nal_len]); + pos += nal_len; + } + + annexb + } + + /// Build Annex B format data from SPS and PPS + fn build_sps_pps_annexb(sps: Option<&[u8]>, pps: Option<&[u8]>) -> Vec { + let mut annexb = Vec::new(); + + if let Some(sps_data) = sps + && !sps_data.is_empty() + { + annexb.extend_from_slice(NAL_START_CODE); + annexb.extend_from_slice(sps_data); + } + + if let Some(pps_data) = pps + && !pps_data.is_empty() + { + annexb.extend_from_slice(NAL_START_CODE); + annexb.extend_from_slice(pps_data); + } + + annexb + } + + /// Extract audio from MP4 + fn extract_audio(mp4_data: &[u8]) -> Option { + super::audio::extract_audio_from_mp4(mp4_data) + } +} diff --git a/apps/desktop/src/views/chat.rs b/apps/desktop/src/views/chat.rs new file mode 100644 index 000000000..321b52337 --- /dev/null +++ b/apps/desktop/src/views/chat.rs @@ -0,0 +1,129 @@ +//! Connected/chat view with responsive layout support. + +use gpui::{Context, Entity, Window, div, prelude::*, px, rgb}; +use gpui_component::VirtualListScrollHandle; + +use crate::app::{MessageListCache, WhatsAppApp}; +use crate::components::{ + InputAreaView, render_call_popup, render_chat_header, render_chat_list, render_message_list, + render_outgoing_call_popup, +}; +use crate::responsive::ResponsiveLayout; +use crate::state::Chat; +use crate::theme::colors; + +pub fn render_connected_view( + app: &mut WhatsAppApp, + window: &mut Window, + cx: &mut Context, +) -> impl IntoElement { + app.ensure_input_area(window, cx); + app.ensure_chat_search_input(window, cx); + + let layout = app.responsive_layout(window); + let entity = cx.entity().clone(); + let selected_jid = app.selected_chat_jid(); + let chat_list_scroll = app.chat_list_scroll(); + let chat_list_focus = app.chat_list_focus(); + let chat_search_input = app.chat_search_input().cloned(); + let message_list_scroll = app.message_list_scroll(); + let input_area = app.input_area(); + let selected_chat = app.selected_chat_data(); + let playing_message_id = app.playing_message_id().map(|s| s.to_string()); + let incoming_call = app.incoming_call().cloned(); + let outgoing_call = app.outgoing_call().cloned(); + + let chat_list_cache = app.get_chat_list_cache(); + let message_cache = selected_chat.map(|chat| { + app.get_message_list_cache( + &chat.jid, + &chat.messages, + chat.is_group, + layout.max_media_size(), + ) + }); + + div() + .relative() + .size_full() + .child( + div() + .flex() + .size_full() + .bg(rgb(colors::BG_PRIMARY)) + .when(layout.show_sidebar(), |el| { + el.child(render_chat_list( + chat_list_cache.clone(), + selected_jid.clone(), + chat_list_scroll, + chat_list_focus, + chat_search_input.as_ref(), + entity.clone(), + layout, + )) + }) + .when(layout.show_chat_area(), |el| { + el.child(render_chat_area( + selected_chat, + message_cache, + message_list_scroll, + input_area, + entity.clone(), + playing_message_id, + layout, + )) + }), + ) + .when_some(incoming_call, |el, call| { + el.child(render_call_popup(&call, entity.clone())) + }) + .when_some(outgoing_call, |el, call| { + el.child(render_outgoing_call_popup(&call, entity)) + }) +} + +fn render_chat_area( + selected_chat: Option<&Chat>, + message_cache: Option, + message_scroll: &VirtualListScrollHandle, + input_area: Option>, + entity: Entity, + playing_message_id: Option, + layout: ResponsiveLayout, +) -> impl IntoElement { + let base = if layout.is_mobile() { + div().w_full() + } else { + div().flex_1() + }; + + base.flex() + .flex_col() + .h_full() + .bg(rgb(colors::BG_CHAT)) + .when(selected_chat.is_none(), |el| { + el.justify_center().items_center().child( + div() + .text_color(rgb(colors::TEXT_SECONDARY)) + .text_lg() + .child("Select a chat to start messaging"), + ) + }) + .when_some(selected_chat, |el, chat| { + let is_group = chat.is_group; + el.child(render_chat_header(chat, entity.clone(), layout)) + .when_some(message_cache, |el, cache| { + el.child(render_message_list( + cache, + message_scroll, + entity.clone(), + playing_message_id, + is_group, + layout, + )) + }) + .when_some(input_area, |el, input| { + el.child(div().h(px(layout.input_area_height())).child(input)) + }) + }) +} diff --git a/apps/desktop/src/views/error.rs b/apps/desktop/src/views/error.rs new file mode 100644 index 000000000..a653fe55f --- /dev/null +++ b/apps/desktop/src/views/error.rs @@ -0,0 +1,38 @@ +//! Error view + +use gpui::{Entity, div, prelude::*, px, rgb}; +use gpui_component::button::{Button, ButtonVariants}; + +use super::centered_view; +use crate::app::WhatsAppApp; +use crate::theme::colors; + +/// Render error view +pub fn render_error_view(error: &str, entity: Entity) -> impl IntoElement { + centered_view(px(24.0)) + .child( + div() + .text_color(rgb(colors::ERROR)) + .text_2xl() + .font_weight(gpui::FontWeight::BOLD) + .child("Error"), + ) + .child( + div() + .text_color(rgb(colors::TEXT_PRIMARY)) + .text_base() + .max_w(px(400.)) + .text_center() + .child(error.to_string()), + ) + .child( + Button::new("retry") + .label("Retry") + .primary() + .on_click(move |_, _, cx| { + entity.update(cx, |this, cx| { + this.retry_connection(cx); + }); + }), + ) +} diff --git a/apps/desktop/src/views/loading.rs b/apps/desktop/src/views/loading.rs new file mode 100644 index 000000000..304c0c978 --- /dev/null +++ b/apps/desktop/src/views/loading.rs @@ -0,0 +1,39 @@ +//! Loading and connecting views + +use gpui::{div, prelude::*, px, rgb}; +use gpui_component::{IconName, Sizable, spinner::Spinner}; + +use super::centered_view; +use crate::theme::colors; + +/// Render loading view +pub fn render_loading_view() -> impl IntoElement { + render_spinner_view("Loading WhatsApp...") +} + +/// Render connecting view +pub fn render_connecting_view() -> impl IntoElement { + render_spinner_view("Connecting...") +} + +/// Render syncing view (after pairing, before fully connected) +pub fn render_syncing_view() -> impl IntoElement { + render_spinner_view("Pairing successful! Syncing...") +} + +/// Render a centered spinner with message +fn render_spinner_view(message: &str) -> impl IntoElement { + centered_view(px(16.0)) + .child( + Spinner::new() + .large() + .icon(IconName::Loader) + .color(rgb(colors::ACCENT_GREEN).into()), + ) + .child( + div() + .text_color(rgb(colors::TEXT_PRIMARY)) + .text_xl() + .child(message.to_string()), + ) +} diff --git a/apps/desktop/src/views/mod.rs b/apps/desktop/src/views/mod.rs new file mode 100644 index 000000000..1898eaae0 --- /dev/null +++ b/apps/desktop/src/views/mod.rs @@ -0,0 +1,29 @@ +//! Application views + +use gpui::{Div, div, prelude::*, rgb}; + +use crate::theme::colors; + +mod chat; +mod error; +mod loading; +pub mod pairing; + +pub use chat::render_connected_view; +pub use error::render_error_view; +pub use loading::{render_connecting_view, render_loading_view, render_syncing_view}; +pub use pairing::render_pairing_view; + +/// Create a centered full-screen view container with consistent styling. +/// +/// This provides the base layout for loading, error, and pairing views. +pub fn centered_view(gap: gpui::Pixels) -> Div { + div() + .flex() + .flex_col() + .size_full() + .bg(rgb(colors::BG_PRIMARY)) + .justify_center() + .items_center() + .gap(gap) +} diff --git a/apps/desktop/src/views/pairing.rs b/apps/desktop/src/views/pairing.rs new file mode 100644 index 000000000..63a6fe2e7 --- /dev/null +++ b/apps/desktop/src/views/pairing.rs @@ -0,0 +1,103 @@ +//! Pairing view (QR code / pair code) + +use std::sync::Arc; + +use gpui::{Image, ImageFormat, ImageSource, div, img, prelude::*, px, rgb}; + +use super::centered_view; +use crate::state::CachedQrCode; +use crate::theme::{colors, layout}; + +/// Generate QR code as PNG bytes (called once when QR data changes) +pub fn generate_qr_png(data: &str) -> Option> { + use image::ImageEncoder; + use qrcode::QrCode; + + let code = QrCode::new(data.as_bytes()).ok()?; + let image = code.render::>().build(); + + let mut png_bytes = Vec::new(); + let encoder = image::codecs::png::PngEncoder::new(&mut png_bytes); + encoder + .write_image( + image.as_raw(), + image.width(), + image.height(), + image::ExtendedColorType::L8, + ) + .ok()?; + + Some(png_bytes) +} + +/// Render pairing view (QR code / pair code) +pub fn render_pairing_view( + qr_code: Option<&CachedQrCode>, + pair_code: Option, + timeout_secs: u64, +) -> impl IntoElement { + centered_view(px(24.0)) + .child( + div() + .text_color(rgb(colors::TEXT_PRIMARY)) + .text_2xl() + .font_weight(gpui::FontWeight::BOLD) + .child("Link your phone"), + ) + .child( + div() + .text_color(rgb(colors::TEXT_SECONDARY)) + .text_base() + .child("Open WhatsApp on your phone and scan the QR code"), + ) + .child( + div() + .size(px(layout::QR_CODE_SIZE)) + .bg(rgb(colors::WHITE)) + .rounded(px(layout::RADIUS_MEDIUM)) + .flex() + .justify_center() + .items_center() + .child(if let Some(cached) = qr_code { + let image = + Image::from_bytes(ImageFormat::Png, cached.png_bytes.as_ref().clone()); + img(ImageSource::Image(Arc::new(image))) + .size(px(layout::QR_CODE_SIZE - 16.0)) + .into_any_element() + } else { + div() + .text_color(rgb(colors::BLACK)) + .text_sm() + .child("Waiting for QR...") + .into_any_element() + }), + ) + .when_some(pair_code, |el, code| { + el.child( + div() + .flex() + .flex_col() + .items_center() + .gap_2() + .child( + div() + .text_color(rgb(colors::TEXT_SECONDARY)) + .text_sm() + .child("Or enter this code:"), + ) + .child( + div() + .text_color(rgb(colors::ACCENT_GREEN)) + .text_2xl() + .font_weight(gpui::FontWeight::BOLD) + .child(code), + ), + ) + }) + .child( + div() + .text_color(rgb(colors::TEXT_SECONDARY)) + .text_sm() + .child(format!("Expires in {} seconds", timeout_secs)), + ) +} diff --git a/src/client/accessors.rs b/src/client/accessors.rs index 9485b4b2a..d9af785fe 100644 --- a/src/client/accessors.rs +++ b/src/client/accessors.rs @@ -317,7 +317,7 @@ impl Client { return; } - log::debug!("Updating push name from '{}' -> '{}'", old_name, new_name); + log::debug!("Updating push name"); self.persistence_manager .process_command(DeviceCommand::SetPushName(new_name.clone())) .await; diff --git a/src/client/app_state.rs b/src/client/app_state.rs index a27d0866c..310bba3f7 100644 --- a/src/client/app_state.rs +++ b/src/client/app_state.rs @@ -870,7 +870,7 @@ impl Client { let snapshot = self.persistence_manager.get_device_snapshot(); let old = snapshot.push_name.clone(); if old != new_name { - debug!(target: "Client/AppState", "Persisting push name from app state mutation: '{}' (old='{}')", new_name, old); + debug!(target: "Client/AppState", "Persisting changed push name from app state mutation"); self.persistence_manager .process_command(DeviceCommand::SetPushName(new_name.clone())) .await; @@ -890,7 +890,7 @@ impl Client { } } } else { - debug!(target: "Client/AppState", "Push name mutation received but name unchanged: '{}'", new_name); + debug!(target: "Client/AppState", "Push name mutation received but name unchanged"); } } } diff --git a/src/history_sync.rs b/src/history_sync.rs index a37cd2ef1..b23fd1717 100644 --- a/src/history_sync.rs +++ b/src/history_sync.rs @@ -185,7 +185,7 @@ impl Client { // Update own push name if found if let Some(new_name) = sync_result.own_pushname { - log::info!("Updating own push name from history sync to '{new_name}'"); + log::info!("Updating own push name from history sync"); self.update_push_name_and_notify(new_name).await; } diff --git a/src/pair.rs b/src/pair.rs index 34e8da5d1..617c4474c 100644 --- a/src/pair.rs +++ b/src/pair.rs @@ -330,7 +330,7 @@ async fn handle_pair_success<'a>( } if !business_name.is_empty() { - info!("✅ Setting push_name during pairing: '{}'", business_name); + info!("✅ Setting push_name during pairing"); client .persistence_manager .process_command(crate::store::commands::DeviceCommand::SetPushName( diff --git a/storages/chat-store/src/store.rs b/storages/chat-store/src/store.rs index 5908dc61e..777032f88 100644 --- a/storages/chat-store/src/store.rs +++ b/storages/chat-store/src/store.rs @@ -48,6 +48,10 @@ pub(crate) enum WriterMsg { text: Option, timestamp_ms: i64, }, + SendFailed { + chat: Jid, + msg_id: String, + }, // String, not StoreError: one batch outcome fans out to many waiters and // StoreError is not Clone. Flush(oneshot::Sender>), @@ -168,6 +172,20 @@ impl ChatStore { .map_err(|_| ChatStoreError::Store(StoreError::Validation("writer stopped".into()))) } + /// Mark a send this client gave up on (no server answer will come to do + /// it). Goes through the writer queue so it cannot outrun the + /// [`record_outgoing`](Self::record_outgoing) row it targets. Same rule + /// as a server nack: only a still-[`Pending`](crate::types::MessageStatus::Pending) + /// row fails — a positive ack that won the race must not be regressed. + pub fn mark_send_failed(&self, chat: &Jid, msg_id: impl Into) -> Result<()> { + self.tx + .send(WriterMsg::SendFailed { + chat: chat.clone(), + msg_id: msg_id.into(), + }) + .map_err(|_| ChatStoreError::Store(StoreError::Validation("writer stopped".into()))) + } + /// Wait until every write enqueued before this call is committed. Errors /// with [`ChatStoreError::WriteBatchFailed`] when any batch since the /// previous flush answer rolled back. The contract is TEMPORAL, not @@ -530,6 +548,25 @@ fn apply_writer_msg( cs.message_chats.insert(chat_str); Ok(()) } + WriterMsg::SendFailed { chat, msg_id } => { + let chat_str = chat.to_string(); + // Same guard as the nack path: a row past PENDING already got its + // positive answer, so a late local failure must not regress it. + let updated = + diesel::update(message_row(device_id, &chat_str, msg_id).filter( + schema::messages::from_me.eq(true).and( + schema::messages::status.eq(wa::web_message_info::Status::PENDING as i32), + ), + )) + .set(schema::messages::status.eq(wa::web_message_info::Status::ERROR as i32)) + .execute(conn)?; + // A no-op update (row already acked, or unknown id) must not + // broadcast an invalidation and re-hydrate the UI for nothing. + if updated > 0 { + cs.message_chats.insert(chat_str); + } + Ok(()) + } WriterMsg::Flush(_) => Ok(()), } } @@ -1424,14 +1461,23 @@ fn apply_history_conversation( { use schema::chats::dsl; - let name = conv.name.as_deref().or(conv.display_name.as_deref()); + let name = conv + .name + .as_deref() + .or(conv.display_name.as_deref()) + .or(conv.username.as_deref()); + let unread_count = match conv.unread_count { + _ if conv.marked_as_unread == Some(true) => UNREAD_MARKER, + Some(count) if count > 0 => i32::try_from(count).unwrap_or(i32::MAX), + _ => 0, + }; diesel::insert_into(dsl::chats) .values(( dsl::device_id.eq(device_id), dsl::jid.eq(chat), dsl::name.eq(name), dsl::last_message_ts.eq(last_ts_ms), - dsl::unread_count.eq(conv.unread_count.unwrap_or(0) as i32), + dsl::unread_count.eq(unread_count), // Wire values are unix SECONDS; the columns (and the live // app-state paths) are milliseconds. dsl::pinned_at.eq(conv diff --git a/storages/chat-store/tests/chat_store_test.rs b/storages/chat-store/tests/chat_store_test.rs index 221383dc5..5d85bd3a9 100644 --- a/storages/chat-store/tests/chat_store_test.rs +++ b/storages/chat-store/tests/chat_store_test.rs @@ -372,6 +372,9 @@ async fn history_sync_materializes_without_clobbering_live_rows() { conversation_timestamp: Some(1_700_000_500), mute_end_time: Some(1_800_000_000), pinned: Some(1_700_000_800), + username: Some("alice_example".to_string()), + unread_count: Some(7), + marked_as_unread: Some(true), ..Default::default() }, wa::Conversation { @@ -446,6 +449,8 @@ async fn history_sync_materializes_without_clobbering_live_rows() { .iter() .find(|c| c.jid == jid("559900000004@s.whatsapp.net")) .expect("muted chat"); + assert_eq!(muted.name.as_deref(), Some("alice_example")); + assert_eq!(muted.unread_count, -1); assert!(muted.muted_until.unwrap().year() > 2020); assert!(muted.pinned_at.unwrap().year() > 2020); @@ -2522,3 +2527,54 @@ async fn server_nack_marks_outgoing_failed() { .unwrap(); assert_eq!(msg.status, MessageStatus::ServerAck); } + +#[tokio::test] +async fn mark_send_failed_fails_pending_row_only() { + let (_store, chat_store) = test_store().await; + let chat = jid(PEER); + + // A client-side send error has no server nack to fail the row; the + // explicit mark does it. Same writer queue as the record, so the mark + // cannot outrun the row it targets. + chat_store + .record_outgoing( + &chat, + "OUT-LOCAL", + &wa::Message::text("oi"), + Utc.timestamp_opt(1_700_000_100, 0).unwrap(), + ) + .unwrap(); + chat_store.mark_send_failed(&chat, "OUT-LOCAL").unwrap(); + chat_store.flush().await.unwrap(); + let msg = chat_store + .message(&chat, "OUT-LOCAL") + .await + .unwrap() + .unwrap(); + assert_eq!(msg.status, MessageStatus::Error); + + // A row the server already answered keeps its positive status. + chat_store + .record_outgoing( + &chat, + "OUT-WON", + &wa::Message::text("oi2"), + Utc.timestamp_opt(1_700_000_200, 0).unwrap(), + ) + .unwrap(); + feed( + &chat_store, + [Event::ServerAck( + ServerAck::builder() + .id("OUT-WON".to_string()) + .class("message".to_string()) + .from(chat.clone()) + .build(), + )], + ) + .await; + chat_store.mark_send_failed(&chat, "OUT-WON").unwrap(); + chat_store.flush().await.unwrap(); + let msg = chat_store.message(&chat, "OUT-WON").await.unwrap().unwrap(); + assert_eq!(msg.status, MessageStatus::ServerAck); +} diff --git a/wacore/src/history_sync.rs b/wacore/src/history_sync.rs index 5cdd872a2..c15f1a335 100644 --- a/wacore/src/history_sync.rs +++ b/wacore/src/history_sync.rs @@ -39,9 +39,9 @@ pub struct HistorySyncResult { /// Tctoken candidates extracted from 1:1 conversations during streaming. pub tc_token_candidates: Vec, pub msg_secret_records: Vec, - /// PN↔LID pairs from `HistorySync.phoneNumberToLidMappings` (field 15) — - /// the bulk identity seed the server sends alongside the chats. Same - /// source whatsmeow harvests in `storeHistoricalPNLIDMappings`. + /// PN/LID pairs from the bulk mapping block and from per-conversation + /// metadata (both are harvested; conversations often carry pairs the + /// bulk block omits). pub lid_mappings: Vec, /// The original zlib-compressed input, handed back (moved, never copied or /// re-inflated) only when event listeners exist. Wrapped in @@ -309,9 +309,11 @@ fn process_history_sync_streaming( // conversations (repeated) tags::history_sync::CONVERSATIONS => { result.conversations_processed += 1; - if let Some(candidate) = - extract_conversation_fields(value, &mut result.msg_secret_records) - { + if let Some(candidate) = extract_conversation_fields( + value, + &mut result.msg_secret_records, + &mut result.lid_mappings, + ) { result.tc_token_candidates.push(candidate); } if !density_reserved && result.msg_secret_records.len() >= RESERVE_SAMPLE_RECORDS { @@ -634,8 +636,6 @@ fn read_str_field<'a>(data: &'a [u8], pos: &mut usize) -> Option<&'a str> { // Best-effort: a malformed mapping entry (optional metadata) must not abort // the sync, mirroring the pushname extractor above. fn extract_lid_mapping(data: &[u8]) -> Option { - use wacore_binary::{Jid, Server}; - let mut pn_raw: Option<&str> = None; let mut lid_raw: Option<&str> = None; let mut pos = 0; @@ -659,8 +659,14 @@ fn extract_lid_mapping(data: &[u8]) -> Option { } } - let pn: Jid = pn_raw?.parse().ok()?; - let lid: Jid = lid_raw?.parse().ok()?; + history_lid_mapping(pn_raw?, lid_raw?) +} + +fn history_lid_mapping(pn_raw: &str, lid_raw: &str) -> Option { + use wacore_binary::{Jid, Server}; + + let pn: Jid = pn_raw.parse().ok()?; + let lid: Jid = lid_raw.parse().ok()?; // Legacy `@c.us` is the PN namespace under its old name (whatsmeow maps it // to `@s.whatsapp.net` the same way); the user part is the phone either way. if !(pn.is_pn() || pn.server == Server::Legacy) || !lid.is_lid() { @@ -684,6 +690,10 @@ const _: () = { assert!(tags::phone_number_to_lid_mapping::PN_JID == 1); assert!(tags::phone_number_to_lid_mapping::LID_JID == 2); + assert!(tags::conversation::ID == 1); + assert!(tags::conversation::PN_JID == 39); + assert!(tags::conversation::LID_JID == 42); + assert!(tags::history_sync_msg::MESSAGE == 1); assert!(tags::web_message_info::KEY == 1); @@ -1394,6 +1404,7 @@ pub struct HistoryMsgSecretRecord { fn extract_conversation_fields( data: &[u8], records: &mut Vec, + lid_mappings: &mut Vec, ) -> Option { let mut pos = 0; // Conversation.id precedes messages/tctoken in tag order, so it is @@ -1405,6 +1416,8 @@ fn extract_conversation_fields( let mut tc_token: &[u8] = &[]; let mut tc_token_timestamp: Option = None; let mut tc_token_sender_timestamp: Option = None; + let mut pn_jid: Option<&str> = None; + let mut lid_jid: Option<&str> = None; while pos < data.len() { let Some((tag, br)) = read_varint(&data[pos..]) else { @@ -1501,6 +1514,18 @@ fn extract_conversation_fields( tc_token_sender_timestamp = Some(v); pos += vl; } + (tags::conversation::PN_JID, wire_type::LENGTH_DELIMITED) => { + let Some(value) = read_str_field(data, &mut pos) else { + break; + }; + pn_jid = Some(value); + } + (tags::conversation::LID_JID, wire_type::LENGTH_DELIMITED) => { + let Some(value) = read_str_field(data, &mut pos) else { + break; + }; + lid_jid = Some(value); + } _ => match skip_field(wt, data, pos) { Ok(np) => pos = np, Err(_) => break, @@ -1508,6 +1533,20 @@ fn extract_conversation_fields( } } + let chat_jid = chat_id.parse::().ok(); + let pn_jid = pn_jid.or_else(|| chat_jid.as_ref().filter(|jid| jid.is_pn()).map(|_| chat_id)); + let lid_jid = lid_jid.or_else(|| { + chat_jid + .as_ref() + .filter(|jid| jid.is_lid()) + .map(|_| chat_id) + }); + if let (Some(pn_jid), Some(lid_jid)) = (pn_jid, lid_jid) + && let Some(mapping) = history_lid_mapping(pn_jid, lid_jid) + { + lid_mappings.push(mapping); + } + // tc-token candidate: only for 1:1 chats that actually carry a token. A // malformed conversation without an id must not emit a candidate either // (same guard the message-secret extraction applies). @@ -1801,6 +1840,67 @@ mod tests { ); } + #[test] + fn test_lid_mappings_extracted_from_conversations() { + let hs = wa::HistorySync { + sync_type: wa::history_sync::HistorySyncType::INITIAL_BOOTSTRAP, + conversations: vec![ + wa::Conversation { + id: "111222333444555@lid".to_string(), + pn_jid: Some("12025550143@s.whatsapp.net".to_string()), + ..Default::default() + }, + wa::Conversation { + id: "12025550144@s.whatsapp.net".to_string(), + lid_jid: Some("222333444555666@lid".to_string()), + ..Default::default() + }, + ], + ..Default::default() + }; + + let result = process_history_sync(encode_and_compress(&hs), None, false).unwrap(); + assert_eq!( + result.lid_mappings, + vec![ + HistoryLidMapping { + phone_number: "12025550143".to_string(), + lid: "111222333444555".to_string(), + }, + HistoryLidMapping { + phone_number: "12025550144".to_string(), + lid: "222333444555666".to_string(), + }, + ] + ); + } + + #[test] + fn malformed_optional_mapping_does_not_drop_tc_token() { + let chat = "12025550143@s.whatsapp.net"; + let tc_token = vec![0xAB; 16]; + let mut conv = Vec::new(); + emit_len_field(&mut conv, tags::conversation::ID, chat.as_bytes()); + emit_len_field(&mut conv, tags::conversation::TC_TOKEN, &tc_token); + emit_varint( + &mut conv, + ((tags::conversation::TC_TOKEN_TIMESTAMP << 3) | wire_type::VARINT) as u64, + ); + emit_varint(&mut conv, 1_700_000_000); + emit_len_field(&mut conv, tags::conversation::PN_JID, &[0xFF, 0xFE]); + + let mut raw = Vec::new(); + emit_len_field(&mut raw, tags::history_sync::CONVERSATIONS, &conv); + let mut encoder = ZlibEncoder::new(Vec::new(), Compression::default()); + encoder.write_all(&raw).unwrap(); + let compressed = encoder.finish().unwrap(); + + let result = process_history_sync(compressed, None, false).unwrap(); + assert!(result.lid_mappings.is_empty()); + assert_eq!(result.tc_token_candidates.len(), 1); + assert_eq!(result.tc_token_candidates[0].tc_token, tc_token); + } + /// Prost-only oracle: what the pre-fast-path pipeline produced for one /// raw `HistorySyncMsg`. The fast path must match this exactly. fn oracle_records(raw_msg: &[u8]) -> Vec { @@ -2988,6 +3088,7 @@ mod tests { if let Some(candidate) = extract_conversation_fields( &decompressed[pos..end], &mut result.msg_secret_records, + &mut result.lid_mappings, ) { result.tc_token_candidates.push(candidate); }