commit 423ea59371218ee4c5aba3bd9a1d858e55243c51 Author: Ultradesu Date: Wed Aug 12 01:30:57 2026 +0100 Init commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..5be2a2a --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +/target/ +/.direnv/ +.DS_Store + diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md new file mode 100644 index 0000000..8122c6e --- /dev/null +++ b/ARCHITECTURE.md @@ -0,0 +1,74 @@ +# Architecture + +Furumi Desktop is a modular monolith: it ships as one process and one binary, +while keeping reusable backend services independent from the desktop UI. + +## Invariants + +1. The UI renders state and emits intent; it never performs backend work. +2. Application state changes through deterministic reducers. +3. Backend state is authoritative for playback, queue, library and operations. +4. Navigation is frontend state: the backend does not know which panel is open. +5. Commands use a bounded channel; snapshots use a coalescing watch channel. +6. Long operations carry request IDs and cancellation tokens. Stale results are + rejected before they reach authoritative state. +7. Local numeric IDs remain compatible with Furumi (`i64`). Stable track + identity is an optional normalized `b3:<64 hex>` content ID. +8. Audio output runs on a dedicated worker thread. Device access, decoding and + playback never block the UI or backend actor. +9. Durable settings belong to the backend. UI edits are projected immediately, + then persisted by a dedicated worker without blocking the UI or actor loop. +10. Catalog entities use source-aware keys. Local and federated providers map + into the same artist, release, track, and artwork contracts before a + snapshot reaches the application or UI. +11. UI events identify catalog items by stable source-aware keys. A row index + is never used as track identity because federation can reorder a list while + results are arriving. + +## Data flow + +```text +Slint callback -> UiAction -> reducer -> BackendCommand -> backend actor + ^ | + | BackendSnapshot <-----------+ + +---- UI projection <- reducer/state update +``` + +The desktop application uses typed in-process channels. `backend-api` contains +no Slint, Tokio, database or transport types, so a server adapter can map the +same semantics onto another transport without making desktop pay for HTTP. + +## Catalog providers and artwork + +The local SQLite library and federation are catalog providers, not separate +sets of screens or view models. The backend merges provider results into +`LibrarySnapshot`; `CatalogSource`, `ArtistKey`, and `ReleaseKey` preserve +identity and provenance across that merge. Release tracks are normalized by +disc and metadata track number after every merge, with local records taking +precedence over equivalent remote records. + +Artwork is asynchronously resolved. A provider may initially emit an entity +without a URI, fetch or cache its image, and publish a newer snapshot with the +same entity key and a local URI. Slint renders either that resolved image or +the common placeholder and never performs filesystem or network I/O. + +## Crates + +- `domain`: identifiers, entities and queue rules. +- `backend-api`: commands, snapshots, operation state and errors. +- `application`: frontend navigation state, reducers and UI projections. +- `backend`: actor/runtime orchestration, catalog federation, connected-device + synchronization, persistence and the audio engine. +- `platform-desktop`: narrow native OS adapters such as the folder picker. +- `ui`: Slint components and the adapter connecting callbacks to state. +- `apps/desktop`: composition root only. + +## Settings persistence + +The backend stores settings in `furumi-desktop.sqlite3` under the platform +application-data directory selected by `directories::ProjectDirs`. Schema +changes are ordered migrations recorded in `schema_migrations`; each migration +runs in a transaction. The settings writer owns its SQLite connection on a +dedicated thread and coalesces bursts of edits before writing the latest full +snapshot. The configured device name is also written to the connected-device +identity and published through the device-profile operation log. diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..72ea895 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,9521 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "ab_glyph" +version = "0.2.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01c0457472c38ea5bd1c3b5ada5e368271cb550be7a4ca4a0b4634e9913f6cc2" +dependencies = [ + "ab_glyph_rasterizer", + "owned_ttf_parser", +] + +[[package]] +name = "ab_glyph_rasterizer" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "366ffbaa4442f4684d91e2cd7c5ea7c4ed8add41959a31447066e279e432b618" + +[[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 = "aes" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures 0.2.17", +] + +[[package]] +name = "aes-gcm" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "831010a0f742e1209b3bcea8fab6a8e149051ba6099432c8cb2cc117dec3ead1" +dependencies = [ + "aead", + "aes", + "cipher", + "ctr", + "ghash", + "subtle", +] + +[[package]] +name = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if", + "getrandom 0.3.4", + "once_cell", + "version_check", + "zerocopy", +] + +[[package]] +name = "aho-corasick" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba" +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.1", + "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-activity" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f2a1bb052857d5dd49572219344a7332b31b76405648eabac5bc68978251bcd" +dependencies = [ + "android-properties", + "bitflags 2.13.1", + "cc", + "jni 0.22.4", + "libc", + "log", + "ndk", + "ndk-context", + "ndk-sys", + "num_enum", + "thiserror 2.0.20", +] + +[[package]] +name = "android-properties" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc7eb209b1518d6bb87b283c20095f5228ecda460da70b44f0802523dea6da04" + +[[package]] +name = "android_system_properties" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae221649c9976a6f6c56ae1facf410f3ddb33cc661c4b7b61020a912d4237fbc" +dependencies = [ + "libc", +] + +[[package]] +name = "annotate-snippets" +version = "0.12.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f211a51805bc641f3ad5b7664c77d2547af685cc33b4cd8d31964027a46f13f1" +dependencies = [ + "anstyle", + "memchr", + "unicode-width", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anyhow" +version = "1.0.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" + +[[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 2.0.119", +] + +[[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 = "ashpd" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2f3f79755c74fd155000314eb349864caa787c6592eace6c6882dad873d9c39" +dependencies = [ + "enumflags2", + "futures-channel", + "futures-util", + "rand 0.9.5", + "raw-window-handle", + "serde", + "serde_repr", + "url", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "zbus 5.19.0", +] + +[[package]] +name = "async-broadcast" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c48ccdbf6ca6b121e0f586cbc0e73ae440e56c67c30fa0873b4e110d9c26d2b" +dependencies = [ + "event-listener 2.5.3", + "futures-core", +] + +[[package]] +name = "async-broadcast" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "435a87a52755b8f27fcf321ac4f04b2802e337c8c4872923137471ec39c37532" +dependencies = [ + "event-listener 5.4.2", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-channel" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "924ed96dd52d1b75e9c1a3e6275715fd320f5f9439fb5a4a11fa51f4221158d2" +dependencies = [ + "concurrent-queue", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-executor" +version = "1.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c96bf972d85afc50bf5ab8fe2d54d1586b4e0b46c97c50a0c9e71e2f7bcd812a" +dependencies = [ + "async-task", + "concurrent-queue", + "fastrand 2.5.0", + "futures-lite 2.6.1", + "pin-project-lite", + "slab", +] + +[[package]] +name = "async-fs" +version = "1.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "279cf904654eeebfa37ac9bb1598880884924aab82e290aa65c9e77a0e142e06" +dependencies = [ + "async-lock 2.8.0", + "autocfg", + "blocking", + "futures-lite 1.13.0", +] + +[[package]] +name = "async-io" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fc5b45d93ef0529756f812ca52e44c221b35341892d3dcc34132ac02f3dd2af" +dependencies = [ + "async-lock 2.8.0", + "autocfg", + "cfg-if", + "concurrent-queue", + "futures-lite 1.13.0", + "log", + "parking", + "polling 2.8.0", + "rustix 0.37.28", + "slab", + "socket2 0.4.10", + "waker-fn", +] + +[[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 2.6.1", + "parking", + "polling 3.11.0", + "rustix 1.1.4", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-lock" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "287272293e9d8c41773cec55e365490fe034813a2f172f502d6ddcf75b2f582b" +dependencies = [ + "event-listener 2.5.3", +] + +[[package]] +name = "async-lock" +version = "3.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311" +dependencies = [ + "event-listener 5.4.2", + "event-listener-strategy", + "pin-project-lite", +] + +[[package]] +name = "async-process" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea6438ba0a08d81529c69b36700fa2f95837bfe3e776ab39cde9c14d9149da88" +dependencies = [ + "async-io 1.13.0", + "async-lock 2.8.0", + "async-signal", + "blocking", + "cfg-if", + "event-listener 3.1.0", + "futures-lite 1.13.0", + "rustix 0.38.44", + "windows-sys 0.48.0", +] + +[[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 2.6.0", + "async-lock 3.4.2", + "async-signal", + "async-task", + "blocking", + "cfg-if", + "event-listener 5.4.2", + "futures-lite 2.6.1", + "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 2.0.119", +] + +[[package]] +name = "async-signal" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52b5aaafa020cf5053a01f2a60e8ff5dccf550f0f77ec54a4e47285ac2bab485" +dependencies = [ + "async-io 2.6.0", + "async-lock 3.4.2", + "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.92" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "async_io_stream" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d7b9decdf35d8908a7e3ef02f64c5e9b1695e230154c0e8de3969142d9b94c" +dependencies = [ + "futures", + "pharos", + "rustc_version", +] + +[[package]] +name = "atomic-polyfill" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8cf2bce30dfe09ef0bfaef228b9d414faaf7e563035494d7fe092dba54b300f4" +dependencies = [ + "critical-section", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "attohttpc" +version = "0.30.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16e2cdb6d5ed835199484bb92bb8b3edd526effe995c61732580439c1a67e2e9" +dependencies = [ + "base64", + "http", + "log", + "url", +] + +[[package]] +name = "auto_enums" +version = "0.8.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3091d68264354f211516b91dce6f71046e444fab1867716035f736667243affb" +dependencies = [ + "derive_utils", + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[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.20", + "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 = "backon" +version = "1.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cffb0e931875b666fc4fcb20fee52e9bbd1ef836fd9e9e04ec21555f9f85f7ef" +dependencies = [ + "fastrand 2.5.0", + "gloo-timers", + "tokio", +] + +[[package]] +name = "base16ct" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd307490d624467aa6f74b0eabb77633d1f758a7b25f12bceb0b22e08d9726f6" + +[[package]] +name = "base64" +version = "0.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 = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36eaf5d7b090263e8150820482d5d93cd964a81e4019913c972f4edcc6edb740" +dependencies = [ + "serde", + "unty", +] + +[[package]] +name = "bindgen" +version = "0.72.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "993776b509cfb49c750f11b8f07a46fa23e0a1386ffc01fb1e7d343efc387895" +dependencies = [ + "bitflags 2.13.1", + "cexpr", + "clang-sys", + "itertools 0.13.0", + "log", + "prettyplease", + "proc-macro2", + "quote", + "regex", + "rustc-hash 2.1.3", + "shlex 1.3.0", + "syn 2.0.119", +] + +[[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.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" +dependencies = [ + "serde_core", +] + +[[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 = "blake3" +version = "1.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76ae7bad254120e9e4c63bafc385310756f90c484eac0e36b8317cf09cb92a77" +dependencies = [ + "arrayref", + "arrayvec", + "cc", + "cfg-if", + "constant_time_eq", + "cpufeatures 0.3.0", +] + +[[package]] +name = "block" +version = "0.1.6" + +[[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 = "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 2.6.1", + "piper", +] + +[[package]] +name = "borsh" +version = "1.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a88b7ea17d208c4193f2c1e6de3c35fe71f98c96982d5ced308bdcc749ff6e1f" +dependencies = [ + "bytes", + "cfg_aliases", +] + +[[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 = "by_address" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64fa3c856b712db6612c019f14756e64e4bcea13337a6b33b696333a9eaa2d06" + +[[package]] +name = "bytemuck" +version = "1.25.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797" +dependencies = [ + "bytemuck_derive", +] + +[[package]] +name = "bytemuck_derive" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc0e56a716f1e132ff6bf4bdac1c944a3fcdc1cae65f70a4a2a1ac3b401d2d1f" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[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 = "calloop" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b99da2f8558ca23c71f4fd15dc57c906239752dd27ff3c00a1d56b685b7cbfec" +dependencies = [ + "bitflags 2.13.1", + "log", + "polling 3.11.0", + "rustix 0.38.44", + "slab", + "thiserror 1.0.69", +] + +[[package]] +name = "calloop" +version = "0.14.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4dbf9978365bac10f54d1d4b04f7ce4427e51f71d61f2fe15e3fed5166474df7" +dependencies = [ + "bitflags 2.13.1", + "polling 3.11.0", + "rustix 1.1.4", + "slab", + "tracing", +] + +[[package]] +name = "calloop-wayland-source" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95a66a987056935f7efce4ab5668920b5d0dac4a7c99991a67395f13702ddd20" +dependencies = [ + "calloop 0.13.0", + "rustix 0.38.44", + "wayland-backend", + "wayland-client", +] + +[[package]] +name = "calloop-wayland-source" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "138efcf0940a02ebf0cc8d1eff41a1682a46b431630f4c52450d6265876021fa" +dependencies = [ + "calloop 0.14.4", + "rustix 1.1.4", + "wayland-backend", + "wayland-client", +] + +[[package]] +name = "cc" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d262e149917187838d5b42777c8253bcb64500067342904e7d429499a6f277e" +dependencies = [ + "find-msvc-tools", + "jobserver", + "libc", + "shlex 2.0.1", +] + +[[package]] +name = "cesu8" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c" + +[[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.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" + +[[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", +] + +[[package]] +name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common 0.1.7", + "inout", +] + +[[package]] +name = "clang-sys" +version = "1.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "157a8ba7b480713b56f4c09fd13fc3e0a22a5dfab8097ba61cbc5feef950788a" +dependencies = [ + "glob", + "libc", + "libloading", +] + +[[package]] +name = "clipboard-win" +version = "5.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bde03770d3df201d4fb868f2c9c59e66a3e4e2bd06692a0fe701e7103c7e84d4" +dependencies = [ + "error-code", +] + +[[package]] +name = "clru" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "197fd99cb113a8d5d9b6376f3aa817f32c1078f2343b714fff7d2ca44fdf67d5" +dependencies = [ + "hashbrown 0.16.1", +] + +[[package]] +name = "cmov" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a" + +[[package]] +name = "cobs" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fa961b519f0b462e3a3b4a34b64d119eeaca1d59af726fe450bbba07a9fc0a1" +dependencies = [ + "thiserror 2.0.20", +] + +[[package]] +name = "cocoa" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f425db7937052c684daec3bd6375c8abe2d146dca4b8b143d6db777c39138f3a" +dependencies = [ + "bitflags 1.3.2", + "block", + "cocoa-foundation", + "core-foundation 0.9.4", + "core-graphics 0.22.3", + "foreign-types 0.3.2", + "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", + "libc", + "objc", +] + +[[package]] +name = "color_quant" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b" + +[[package]] +name = "combine" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" +dependencies = [ + "bytes", + "memchr", +] + +[[package]] +name = "concurrent-queue" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "const-field-offset" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9713489fbc45a2d113f58d17448c73115d533ed27fd2f2d0e5e5c69663eaf19b" +dependencies = [ + "const-field-offset-macro", + "field-offset", +] + +[[package]] +name = "const-field-offset-macro" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b5dc851b7ea80cc4f69aa28632385ffd3e0f88014d53db3f2e5a781896ca783" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "const-oid" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" + +[[package]] +name = "constant_time_eq" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d52eff69cd5e647efe296129160853a42795992097e8af39800e1060caeea9b" + +[[package]] +name = "convert_case" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "633458d4ef8c78b72454de2d54fd6ab2e60f9e02be22f3c6104cdc8a4e0fceb9" +dependencies = [ + "unicode-segmentation", +] + +[[package]] +name = "copypasta" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e6811e17f81fe246ef2bc553f76b6ee6ab41a694845df1d37e52a92b7bbd38a" +dependencies = [ + "clipboard-win", + "objc2 0.5.2", + "objc2-app-kit 0.2.2", + "objc2-foundation 0.2.2", + "smithay-clipboard", + "x11-clipboard", +] + +[[package]] +name = "cordyceps" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b9ab7e0ca1d179628fa0172b2b97203c7fa0cd81be2448bd446fb9559ca9261" +dependencies = [ + "loom", + "tracing", +] + +[[package]] +name = "core-foundation" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +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.22.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2581bbab3b8ffc6fcbd550bf46c355135d16e9ff2a6ea032ad6b9bf1d7efe4fb" +dependencies = [ + "bitflags 1.3.2", + "core-foundation 0.9.4", + "core-graphics-types", + "foreign-types 0.3.2", + "libc", +] + +[[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", + "foreign-types 0.5.0", + "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_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.1", + "libc", + "objc2-audio-toolbox", + "objc2-core-audio", + "objc2-core-audio-types", + "objc2-core-foundation", +] + +[[package]] +name = "countme" +version = "3.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7704b5fdd17b18ae31c4c1da5a2e0305a2bf17b5249300a9ee9ed7b72114c636" + +[[package]] +name = "cpal" +version = "0.17.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8942da362c0f0d895d7cac616263f2f9424edc5687364dfd1d25ef7eba506d7" +dependencies = [ + "alsa", + "coreaudio-rs", + "dasp_sample", + "jni 0.21.1", + "js-sys", + "libc", + "mach2", + "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", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "windows 0.62.2", +] + +[[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 = "critical-section" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b" + +[[package]] +name = "crossbeam-channel" +version = "0.5.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d85363c37faeca707aef026efa9f3b34d077bce547e48f770770625c6013679e" +dependencies = [ + "crossbeam-utils", +] + +[[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-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-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "crypto-common" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" +dependencies = [ + "hybrid-array", + "rand_core 0.10.1", +] + +[[package]] +name = "ctor" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83cf0d42651b16c6dfe68685716d18480d18a9c39c62d76e8cf3eb6ed5d8bcbf" +dependencies = [ + "dtor", +] + +[[package]] +name = "ctr" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835" +dependencies = [ + "cipher", +] + +[[package]] +name = "ctutils" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d5515a3834141de9eafb9717ad39eea8247b5674e6066c404e8c4b365d2a29e" +dependencies = [ + "cmov", +] + +[[package]] +name = "cursor-icon" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f27ae1dd37df86211c42e150270f82743308803d90a6f6e6651cd730d5e1732f" + +[[package]] +name = "curve25519-dalek" +version = "5.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5eed333089e2e1c1ac8c6c0398e5e2497b4c9926ca6d0365ed1e099afa5bc23" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "curve25519-dalek-derive", + "digest 0.11.3", + "fiat-crypto", + "rand_core 0.10.1", + "rustc_version", + "serde", + "subtle", + "zeroize", +] + +[[package]] +name = "curve25519-dalek-derive" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[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.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4583a4551df46e2792f82ceeac45e850d2e2d5debba0b91f102385cda5b11f06" + +[[package]] +name = "data-encoding-macro" +version = "0.1.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6a127ecbb3c4632e1525380e04c0c3fcf8dcb44d32a79ea290d8a36906edcd8" +dependencies = [ + "data-encoding", + "data-encoding-macro-internal", +] + +[[package]] +name = "data-encoding-macro-internal" +version = "0.1.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c54e03a951783e8b327515db3f2a2fd0e3bed362a96b066f341ce66ed49b4ead" +dependencies = [ + "data-encoding", + "syn 3.0.3", +] + +[[package]] +name = "data-url" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be1e0bca6c3637f992fc1cc7cbc52a78c1ef6db076dbf1059c4323d6a2048376" + +[[package]] +name = "der" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a69dedd701da44b0536442edf09c81a64b0ab97a7a4a5e3d1971f00027cbc63d" +dependencies = [ + "const-oid", + "pem-rfc7468", + "zeroize", +] + +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" + +[[package]] +name = "derivative" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fcc3dd5e9e9c0b295d6e1e4d811fb6f157d5ffd784b8d202fc62eac8035a770b" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[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 2.0.119", + "unicode-xid", +] + +[[package]] +name = "derive_utils" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc05a5d33db20c784f873e84934ad94bb209a090987ac5f62fede2c178234f23" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "diatomic-waker" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab03c107fafeb3ee9f5925686dbb7a73bc76e3932abb0d2b365cb64b169cf04c" + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer 0.10.4", + "crypto-common 0.1.7", +] + +[[package]] +name = "digest" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" +dependencies = [ + "block-buffer 0.12.1", + "crypto-common 0.2.2", +] + +[[package]] +name = "directories" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16f5094c54661b38d03bd7e50df373292118db60b585c08a411c6d840017fe7d" +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.1", + "block2 0.6.2", + "libc", + "objc2 0.6.4", +] + +[[package]] +name = "displaydoc" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "dlib" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab8ecd87370524b461f8557c119c405552c396ed91fc0a8eec68679eab26f94a" +dependencies = [ + "libloading", +] + +[[package]] +name = "dlopen2" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e2c5bd4158e66d1e215c49b837e11d62f3267b30c92f1d171c4d3105e3dc4d4" +dependencies = [ + "libc", + "once_cell", + "winapi", +] + +[[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 = "dpi" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8b14ccef22fc6f5a8f4d7d768562a182c04ce9a3b3157b91390b52ddfdf1a76" + +[[package]] +name = "drm" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "80bc8c5c6c2941f70a55c15f8d9f00f9710ebda3ffda98075f996a0e6c92756f" +dependencies = [ + "bitflags 2.13.1", + "bytemuck", + "drm-ffi", + "drm-fourcc", + "libc", + "rustix 0.38.44", +] + +[[package]] +name = "drm-ffi" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51a91c9b32ac4e8105dec255e849e0d66e27d7c34d184364fb93e469db08f690" +dependencies = [ + "drm-sys", + "rustix 1.1.4", +] + +[[package]] +name = "drm-fourcc" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0aafbcdb8afc29c1a7ee5fbe53b5d62f4565b35a042a662ca9fecd0b54dae6f4" + +[[package]] +name = "drm-sys" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecc8e1361066d91f5ffccff060a3c3be9c3ecde15be2959c1937595f7a82a9f8" +dependencies = [ + "libc", + "linux-raw-sys 0.9.4", +] + +[[package]] +name = "dtor" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edf234dd1594d6dd434a8fb8cada51ddbbc593e40e4a01556a0b31c62da2775b" + +[[package]] +name = "dyn-clone" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" + +[[package]] +name = "ed25519" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29fcf32e6c73d1079f83ab4d782de2d81620346a5f38c6237a86a22f8368980a" +dependencies = [ + "pkcs8", + "serdect", + "signature", +] + +[[package]] +name = "ed25519-dalek" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ebaa1a2bf1290ab3bfe5a7b771d050ebffab2711c19a81691c683a5144a25de" +dependencies = [ + "curve25519-dalek", + "ed25519", + "rand_core 0.10.1", + "serde", + "sha2", + "signature", + "subtle", + "zeroize", +] + +[[package]] +name = "either" +version = "1.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e5e8f6c15a24b9a3ee5efec809ccd006d3b30e8b3bb63c39af737c7f87daa1d" + +[[package]] +name = "embedded-io" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef1a6892d9eef45c8fa6b9e0086428a2cca8491aca8f787c534a3d6d0bcb3ced" + +[[package]] +name = "embedded-io" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edd0f118536f44f5ccd48bcb8b111bdc3de888b58c74639dfb034a357d0f206d" + +[[package]] +name = "encoding_rs" +version = "0.8.35" +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-assoc" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ed8956bd5c1f0415200516e78ff07ec9e16415ade83c056c230d7b7ea0d55b7" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "enumflags2" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1027f7680c853e056ebcec683615fb6fbbc07dbaa13b4d5d9442b146ded4ecef" +dependencies = [ + "enumflags2_derive", + "serde", +] + +[[package]] +name = "enumflags2_derive" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "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 2.0.119", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "error-code" +version = "3.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dea2df4cf52843e0452895c455a1a2cfbb842a1e7329671acf418fdc53ed4c59" + +[[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 = "2.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0206175f82b8d6bf6652ff7d71a1e27fd2e4efde587fd368662814d6ec1d9ce0" + +[[package]] +name = "event-listener" +version = "3.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d93877bcde0eb80ca09131a08d23f0a5c18a620b01db137dba666d18cd9b30c2" +dependencies = [ + "concurrent-queue", + "parking", + "pin-project-lite", +] + +[[package]] +name = "event-listener" +version = "5.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a23add41df1562121a9393cb065eab5146a1242410f23a644851e90cfd669d2" +dependencies = [ + "parking", + "pin-project-lite", +] + +[[package]] +name = "event-listener-strategy" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93" +dependencies = [ + "event-listener 5.4.2", + "pin-project-lite", +] + +[[package]] +name = "exr" +version = "1.74.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "711fe42c9964295e01ee3fba3f9fe0e1d24b98886950d68efe81b1c76e21adf3" +dependencies = [ + "bit_field", + "half", + "lebe", + "miniz_oxide", + "num-complex", + "pulp", + "rayon-core", + "smallvec", + "zune-inflate", +] + +[[package]] +name = "extended" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af9673d8203fcb076b19dfd17e38b3d4ae9f44959416ea532ce72415a6020365" + +[[package]] +name = "fallible-iterator" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649" + +[[package]] +name = "fallible-streaming-iterator" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" + +[[package]] +name = "fastrand" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e51093e27b0797c359783294ca4f0a911c270184cb10f85783b118614a1501be" +dependencies = [ + "instant", +] + +[[package]] +name = "fastrand" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" + +[[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 = "federation-net" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3e690b370c505d153bef214b21a8f2aa55d667367ac1e16bde8bc0de88963c2" +dependencies = [ + "blake3", + "data-encoding", + "futures", + "iroh", + "iroh-base", + "iroh-tickets", + "mainline", + "postcard", + "rand 0.9.5", + "serde", + "thiserror 2.0.20", + "tokio", + "tracing", +] + +[[package]] +name = "femtovg" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f43d05da42e81724c16d34a150fb7fda53d3f786e5a94673ee526ff8602f0f6a" +dependencies = [ + "bitflags 2.13.1", + "bytemuck", + "fnv", + "glow", + "imgref", + "itertools 0.14.0", + "log", + "rgb", + "slotmap", + "swash", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "fiat-crypto" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64cd1e32ddd350061ae6edb1b082d7c54915b5c672c389143b9a63403a109f24" + +[[package]] +name = "field-offset" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38e2275cc4e4fc009b0669731a1e5ab7ebf11f469eaede2bab9309a5b4d6057f" +dependencies = [ + "memoffset 0.9.1", + "rustc_version", +] + +[[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.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26b73573e6edcd2af0cdf47bd6cb58f0b3839491263c314eaad1ccf24430e1de" + +[[package]] +name = "fixed_decimal" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "79c3c892f121fff406e5dd6b28c1b30096b95111c30701a899d4f2b18da6d1bd" +dependencies = [ + "displaydoc", + "smallvec", + "writeable", +] + +[[package]] +name = "flate2" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + +[[package]] +name = "float-cmp" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "98de4bbd547a563b716d8dfa9aad1cb19bfab00f4fa09a6a4ed21dbcf44ce9c4" + +[[package]] +name = "flume" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e139bc46ca777eb5efaf62df0ab8cc5fd400866427e56c68b22e414e53bd3be" +dependencies = [ + "futures-core", + "futures-sink", + "spin 0.9.9", +] + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[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 = "font-types" +version = "0.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0a7299a780854a6d391be2ae1c8521c9368471b559dbfd6a8dbd9f407eaff100" +dependencies = [ + "bytemuck", +] + +[[package]] +name = "fontdb" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "457e789b3d1202543297a350643cf459f836cade38934e7a4cf6a39e7cde2905" +dependencies = [ + "log", + "slotmap", + "tinyvec", + "ttf-parser", +] + +[[package]] +name = "fontique" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "274fa4f0f0a926ae182c7c076c078cce8a38471d15e61a102a02cac984be9813" +dependencies = [ + "hashbrown 0.17.1", + "linebender_resource_handle", + "memmap2", + "objc2 0.6.4", + "objc2-core-foundation", + "objc2-core-text", + "objc2-foundation 0.3.2", + "parlance", + "read-fonts 0.39.2", + "roxmltree", + "smallvec", + "windows 0.62.2", + "windows-core", + "yeslogic-fontconfig-sys", +] + +[[package]] +name = "foreign-types" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" +dependencies = [ + "foreign-types-shared 0.1.1", +] + +[[package]] +name = "foreign-types" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965" +dependencies = [ + "foreign-types-macros", + "foreign-types-shared 0.3.1", +] + +[[package]] +name = "foreign-types-macros" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea5190182e6915eb873ddbc16e23b711b6eb1f9c00a0d0a3a91b5f6228475225" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "foreign-types-shared" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" + +[[package]] +name = "foreign-types-shared" +version = "0.3.1" +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 = "furumi-application" +version = "0.1.0" +dependencies = [ + "furumi-backend-api", + "furumi-domain", +] + +[[package]] +name = "furumi-backend" +version = "0.1.0" +dependencies = [ + "anyhow", + "directories", + "furumi-backend-api", + "furumi-domain", + "furumi-library", + "music-dht", + "rodio", + "rusqlite", + "serde", + "serde_json", + "tokio", + "tokio-util", +] + +[[package]] +name = "furumi-backend-api" +version = "0.1.0" +dependencies = [ + "furumi-domain", +] + +[[package]] +name = "furumi-desktop" +version = "0.1.0" +dependencies = [ + "furumi-backend", + "furumi-ui", +] + +[[package]] +name = "furumi-domain" +version = "0.1.0" + +[[package]] +name = "furumi-library" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f15ab98c65d89ea18e55ed8852abd2f029f63876d357270c4647d73601d0a2" +dependencies = [ + "anyhow", + "blake3", + "directories", + "lofty", + "music-dht", + "rusqlite", + "serde", + "serde_json", + "tracing", +] + +[[package]] +name = "furumi-platform-desktop" +version = "0.1.0" +dependencies = [ + "core-foundation 0.10.1", + "rfd", + "souvlaki", + "windows-sys 0.61.2", +] + +[[package]] +name = "furumi-ui" +version = "0.1.0" +dependencies = [ + "furumi-application", + "furumi-backend", + "furumi-backend-api", + "furumi-domain", + "furumi-platform-desktop", + "image", + "slint", + "slint-build", + "tokio", +] + +[[package]] +name = "futures" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a31d2a3fbaaeb2af2368bbdd904aa8e812d3c04a1ee10d3171f52d556e5d0a3" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-buffered" +version = "0.2.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4421cb78ee172b6b06080093479d3c50f058e7c81b7d577bbb8d118d551d4cd5" +dependencies = [ + "cordyceps", + "diatomic-waker", + "futures-core", + "pin-project-lite", + "spin 0.10.1", +] + +[[package]] +name = "futures-channel" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" + +[[package]] +name = "futures-executor" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "031b47cf1a3c6cc8bc2fc76cd437f521619387907d469316e7c0bc278f1f5432" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed" + +[[package]] +name = "futures-lite" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49a9d51ce47660b1e808d3c990b4709f2f415d928835a17dfd16991515c46bce" +dependencies = [ + "fastrand 1.9.0", + "futures-core", + "futures-io", + "memchr", + "parking", + "pin-project-lite", + "waker-fn", +] + +[[package]] +name = "futures-lite" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad" +dependencies = [ + "fastrand 2.5.0", + "futures-core", + "futures-io", + "parking", + "pin-project-lite", +] + +[[package]] +name = "futures-macro" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "futures-sink" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d" + +[[package]] +name = "futures-task" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" + +[[package]] +name = "futures-util" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[package]] +name = "gbm" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce852e998d3ca5e4a97014fb31c940dc5ef344ec7d364984525fd11e8a547e6a" +dependencies = [ + "bitflags 2.13.1", + "drm", + "drm-fourcc", + "gbm-sys", + "libc", +] + +[[package]] +name = "gbm-sys" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c13a5f2acc785d8fb6bf6b7ab6bfb0ef5dad4f4d97e8e70bb8e470722312f76f" +dependencies = [ + "libc", +] + +[[package]] +name = "generator" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3b854b0e584ead1a33f18b2fcad7cf7be18b3875c78816b753639aa501513ae" +dependencies = [ + "cc", + "cfg-if", + "libc", + "log", + "rustversion", + "windows-link", + "windows-result", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[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", +] + +[[package]] +name = "getopts" +version = "0.2.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfe4fbac503b8d1f88e6676011885f34b7174f46e59956bba534ba83abded4df" +dependencies = [ + "unicode-width", +] + +[[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", + "libc", + "r-efi 5.3.0", + "wasip2", +] + +[[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", +] + +[[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 = "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.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4eba85ea1d0a966a983acd07deee566e67395d2d96b6fb39e62b5a833f1eb0b" + +[[package]] +name = "gloo-timers" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbb143cf96099802033e0d4f4963b19fd2e0b728bcf076cd9cf7f6634f092994" +dependencies = [ + "futures-channel", + "futures-core", + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "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" +version = "0.32.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12124de845cacfebedff80e877bb37b5b75c34c5a4c89e47e1cdd67fb6041325" +dependencies = [ + "bitflags 2.13.1", + "cfg_aliases", + "cgl", + "dispatch2", + "glutin_egl_sys", + "glutin_glx_sys", + "glutin_wgl_sys", + "libloading", + "objc2 0.6.4", + "objc2-app-kit 0.3.2", + "objc2-core-foundation", + "objc2-foundation 0.3.2", + "once_cell", + "raw-window-handle", + "wayland-sys", + "windows-sys 0.52.0", + "x11-dl", +] + +[[package]] +name = "glutin-winit" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85edca7075f8fc728f28cb8fbb111a96c3b89e930574369e3e9c27eb75d3788f" +dependencies = [ + "cfg_aliases", + "glutin", + "raw-window-handle", + "winit", +] + +[[package]] +name = "glutin_egl_sys" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c4680ba6195f424febdc3ba46e7a42a0e58743f2edb115297b86d7f8ecc02d2" +dependencies = [ + "gl_generator", + "windows-sys 0.52.0", +] + +[[package]] +name = "glutin_glx_sys" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7bb2938045a88b612499fbcba375a77198e01306f52272e692f8c1f3751185" +dependencies = [ + "gl_generator", + "x11-dl", +] + +[[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 = "grid" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b40ca9252762c466af32d0b1002e91e4e1bc5398f77455e55474deb466355ff5" + +[[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", + "zerocopy", +] + +[[package]] +name = "harfrust" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d12c7c642d4ce8c2e784b4751a6634bd89583912265add4a679a8882d123fbcd" +dependencies = [ + "bitflags 2.13.1", + "bytemuck", + "read-fonts 0.39.2", + "smallvec", +] + +[[package]] +name = "hash32" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0c35f58762feb77d74ebe43bdbc3210f09be9fe6742234d573bacc26ed92b67" +dependencies = [ + "byteorder", +] + +[[package]] +name = "hashbrown" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" +dependencies = [ + "ahash", +] + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash", +] + +[[package]] +name = "hashlink" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ba4ff7128dee98c7dc9794b6a411377e1404dba1c97deb8d1a55297bd25d8af" +dependencies = [ + "hashbrown 0.14.5", +] + +[[package]] +name = "heapless" +version = "0.7.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdc6457c0eb62c71aac4bc17216026d8410337c4126773b9c5daba343f17964f" +dependencies = [ + "atomic-polyfill", + "hash32", + "rustc_version", + "serde", + "spin 0.9.9", + "stable_deref_trait", +] + +[[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.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d231dfb89cfffdbc30e7fc41579ed6066ad03abda9e567ccafae602b97ec5024" + +[[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 = "hickory-net" +version = "0.26.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2295ed2f9c31e471e1428a8f88a3f0e1f4b27c15049592138d1eebe9c35b183" +dependencies = [ + "async-trait", + "bytes", + "cfg-if", + "data-encoding", + "futures-channel", + "futures-io", + "futures-util", + "h2", + "hickory-proto", + "http", + "idna", + "ipnet", + "jni 0.22.4", + "rand 0.10.2", + "rustls", + "thiserror 2.0.20", + "tinyvec", + "tokio", + "tokio-rustls", + "tracing", + "url", +] + +[[package]] +name = "hickory-proto" +version = "0.26.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bab31817bfb44672a252e97fe81cd0c18d1b2cf892108922f6818820df8c643" +dependencies = [ + "data-encoding", + "idna", + "ipnet", + "jni 0.22.4", + "once_cell", + "prefix-trie", + "rand 0.10.2", + "ring", + "thiserror 2.0.20", + "tinyvec", + "tracing", + "url", +] + +[[package]] +name = "hickory-resolver" +version = "0.26.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0d58d28879ceecde6607729660c2667a081ccdc082e082675042793960f178c" +dependencies = [ + "cfg-if", + "futures-util", + "hickory-net", + "hickory-proto", + "ipconfig", + "ipnet", + "jni 0.22.4", + "moka", + "ndk-context", + "once_cell", + "parking_lot", + "rand 0.10.2", + "resolv-conf", + "rustls", + "smallvec", + "system-configuration", + "thiserror 2.0.20", + "tokio", + "tokio-rustls", + "tracing", +] + +[[package]] +name = "htmlparser" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48ce8546b993eaf241d69ded33b1be6d205dd9857ec879d9d18bd05d3676e144" + +[[package]] +name = "http" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9f41fd6a08e4d4ec69df65976da761afd5ad5e58a9d4acb46bd1c953a9e3ff2" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[package]] +name = "hybrid-array" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "707114b52a152fa7bdb290cd7cd5912d9467273b6d74e21b8d81aca1f8533f6b" +dependencies = [ + "typenum", +] + +[[package]] +name = "hyper" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d22053281f852e11534f5198498373cbb59295120a20771d90f7ed1897490a72" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "h2", + "http", + "http-body", + "httparse", + "httpdate", + "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", + "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 = [ + "base64", + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2 0.6.5", + "tokio", + "tower-service", + "tracing", +] + +[[package]] +name = "i-slint-backend-linuxkms" +version = "1.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68e76f2b13846ca54bba6b8eb3b5454b26879ad987396d0a2d8abedc31a6c469" +dependencies = [ + "bytemuck", + "calloop 0.14.4", + "cfg_aliases", + "drm", + "gbm", + "glutin", + "i-slint-common", + "i-slint-core", + "i-slint-renderer-femtovg", + "i-slint-renderer-software", + "input", + "memmap2", + "nix 0.31.3", + "raw-window-handle", + "xkbcommon", +] + +[[package]] +name = "i-slint-backend-selector" +version = "1.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2247a4590a02a466b2f4916cc789dfe66247f6af826f00eb84a2685259a45c1c" +dependencies = [ + "cfg-if", + "cfg_aliases", + "i-slint-backend-linuxkms", + "i-slint-backend-testing", + "i-slint-backend-winit", + "i-slint-common", + "i-slint-core", + "i-slint-core-macros", + "i-slint-renderer-femtovg", +] + +[[package]] +name = "i-slint-backend-testing" +version = "1.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521e901e3d47ab829c0ef500c63155776208707cd93259e6a7803ed627fa2786" +dependencies = [ + "cfg_aliases", + "i-slint-common", + "i-slint-core", + "i-slint-renderer-software", + "vtable", +] + +[[package]] +name = "i-slint-backend-winit" +version = "1.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a27c7e8f53418d3017362465367b6612b6e439a68097d627d1b91c94b885f0a6" +dependencies = [ + "block2 0.6.2", + "bytemuck", + "cfg-if", + "cfg_aliases", + "copypasta", + "derive_more", + "futures", + "glutin", + "glutin-winit", + "i-slint-common", + "i-slint-core", + "i-slint-core-macros", + "i-slint-renderer-femtovg", + "i-slint-renderer-skia", + "i-slint-renderer-software", + "imgref", + "lyon_path", + "muda", + "objc2 0.6.4", + "objc2-app-kit 0.3.2", + "objc2-foundation 0.3.2", + "objc2-quartz-core 0.3.2", + "objc2-ui-kit 0.3.2", + "pin-weak", + "raw-window-handle", + "rgb", + "scoped-tls-hkt", + "scopeguard", + "softbuffer", + "strum", + "vtable", + "wasm-bindgen", + "web-sys", + "webbrowser", + "windows 0.62.2", + "winit", + "zbus 5.19.0", +] + +[[package]] +name = "i-slint-common" +version = "1.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52e63f8ea55ba21e44567ae67d2b01a4f5d09bb1fab693b1e6c79d96bef9e456" +dependencies = [ + "derive_more", + "fontique", + "htmlparser", + "icu_decimal", + "icu_locale_core", + "icu_provider", + "pulldown-cmark", + "resvg", + "skrifa 0.42.1", +] + +[[package]] +name = "i-slint-compiler" +version = "1.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "45ea275b15a425c7f2f77481151e1f5f8f1ea83feae580273090ef6b9e192218" +dependencies = [ + "annotate-snippets", + "by_address", + "data-url", + "derive_more", + "i-slint-common", + "icu_normalizer", + "image", + "itertools 0.14.0", + "linked_hash_set", + "lyon_extra", + "lyon_path", + "num_enum", + "proc-macro2", + "quote", + "rayon", + "resvg", + "rowan", + "rspolib", + "skrifa 0.42.1", + "smol_str 0.3.6", + "strum", + "swash", + "typed-index-collections", + "unicode-segmentation", + "url", +] + +[[package]] +name = "i-slint-core" +version = "1.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e407dc2bd5385dcbdd9c1cf927213d2e4bacda667545abf8839c60f8290f754" +dependencies = [ + "auto_enums", + "bitflags 2.13.1", + "cfg-if", + "chrono", + "clru", + "const-field-offset", + "derive_more", + "euclid", + "i-slint-common", + "i-slint-core-macros", + "icu_normalizer", + "image", + "lyon_algorithms", + "lyon_extra", + "lyon_geom", + "lyon_path", + "num-traits", + "objc2 0.6.4", + "objc2-app-kit 0.3.2", + "objc2-foundation 0.3.2", + "once_cell", + "parley", + "pin-project", + "pin-weak", + "portable-atomic", + "raw-window-handle", + "resvg", + "rgb", + "scopeguard", + "skrifa 0.42.1", + "slab", + "strum", + "swash", + "sys-locale", + "taffy", + "unicode-linebreak", + "unicode-script", + "unicode-segmentation", + "vtable", + "wasm-bindgen", + "web-sys", + "web-time", + "windows 0.62.2", +] + +[[package]] +name = "i-slint-core-macros" +version = "1.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a842640da4c4d0909d25cc5a51e1f9cde02bd48f7fdfa94df10d595d4e3c80a" +dependencies = [ + "quote", + "serde_json", + "syn 2.0.119", +] + +[[package]] +name = "i-slint-renderer-femtovg" +version = "1.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78d0d99a18d66738b684758fe8460743a0aa0f64a96e05940942231a340085b1" +dependencies = [ + "cfg-if", + "const-field-offset", + "derive_more", + "femtovg", + "glow", + "i-slint-common", + "i-slint-core", + "i-slint-core-macros", + "imgref", + "lyon_path", + "pin-weak", + "rgb", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "i-slint-renderer-skia" +version = "1.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b6eed7f3f0a9a3d3ca6e8b9d4ca233371d989351fdb2a7ab88ec368b99e7b57" +dependencies = [ + "bytemuck", + "cfg-if", + "cfg_aliases", + "clru", + "const-field-offset", + "derive_more", + "glow", + "glutin", + "i-slint-common", + "i-slint-core", + "i-slint-core-macros", + "lyon_path", + "objc2 0.6.4", + "objc2-app-kit 0.3.2", + "objc2-core-foundation", + "objc2-foundation 0.3.2", + "objc2-metal 0.3.2", + "objc2-quartz-core 0.3.2", + "pin-weak", + "raw-window-handle", + "raw-window-metal", + "read-fonts 0.39.2", + "scoped-tls-hkt", + "skia-safe", + "softbuffer", + "unicode-segmentation", + "vtable", + "windows 0.62.2", + "write-fonts", +] + +[[package]] +name = "i-slint-renderer-software" +version = "1.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73496f164f8e471877c35e0fadb996dcc674922712abd9c2be2cbe80c58859bd" +dependencies = [ + "bytemuck", + "clru", + "derive_more", + "euclid", + "i-slint-common", + "i-slint-core", + "integer-sqrt", + "lyon_path", + "num-traits", + "skrifa 0.42.1", + "swash", + "zeno", +] + +[[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", +] + +[[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_decimal" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "288247df2e32aa776ac54fdd64de552149ac43cb840f2761811f0e8d09719dd4" +dependencies = [ + "displaydoc", + "fixed_decimal", + "icu_decimal_data", + "icu_locale", + "icu_locale_core", + "icu_plurals", + "icu_provider", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_decimal_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f14a5ca9e8af29eef62064f269078424283d90dbaffeac5225addf62aaabc22" + +[[package]] +name = "icu_locale" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d5a396343c7208121dc86e35623d3dfe19814a7613cfd14964994cdc9c9a2e26" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_locale_data", + "icu_provider", + "potential_utf", + "tinystr", + "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", + "serde", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_locale_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d5fdcc9ac77c6d74ff5cf6e65ef3181d6af32003b16fce3a77fb451d2f695993" + +[[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_plurals" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a50023f1d49ad5c4333380328a0d4a19e4b9d6d842ec06639affd5ba47c8103" +dependencies = [ + "fixed_decimal", + "icu_locale", + "icu_plurals_data", + "icu_provider", + "zerovec", +] + +[[package]] +name = "icu_plurals_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8485497155dc865f901decb93ecc20d3e467df67bfeceb91e3ba34e2b11e8e1d" + +[[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", + "serde", + "stable_deref_trait", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_segmenter" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c0794db0b1a86193ac9c48768d0e6c52c54448e0870ad87907d456ee0dac964" +dependencies = [ + "icu_collections", + "icu_locale", + "icu_provider", + "icu_segmenter_data", + "potential_utf", + "utf8_iter", + "zerovec", +] + +[[package]] +name = "icu_segmenter_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4a2c462a4d927d512f5f882a033ddd62f33a05bb9f230d98f736ac3dc85938f" + +[[package]] +name = "identity-hash" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfdd7caa900436d8f13b2346fe10257e0c05c1f1f9e351f4f5d57c03bd5f45da" + +[[package]] +name = "idna" +version = "1.1.0" +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 = "igd-next" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de7238d487a9aff61f81b5ab41c0a841532a115a398b5fa92a2fadd0885e2581" +dependencies = [ + "attohttpc", + "bytes", + "futures", + "http", + "http-body-util", + "hyper", + "hyper-util", + "log", + "rand 0.10.2", + "tokio", + "url", + "xmltree", +] + +[[package]] +name = "image" +version = "0.25.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104" +dependencies = [ + "bytemuck", + "byteorder-lite", + "color_quant", + "exr", + "gif", + "image-webp", + "moxcms", + "num-traits", + "png", + "qoi", + "ravif", + "rayon", + "rgb", + "tiff", + "zune-core", + "zune-jpeg", +] + +[[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.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", +] + +[[package]] +name = "inout" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +dependencies = [ + "generic-array", +] + +[[package]] +name = "input" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9793345a65d71317763a33066b5d8351f8760dde8d4930fe9e39b5f14a7959d" +dependencies = [ + "bitflags 2.13.1", + "input-sys", + "libc", + "log", + "udev", +] + +[[package]] +name = "input-sys" +version = "1.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36eee07d8e02bd95bf52b2e642cf13d33701b94c6e4b04fbf1d1fb07e9cb19e7" + +[[package]] +name = "instant" +version = "0.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0242819d153cba4b4b05a5a8f2a7e9bbf97b6055b2a002b395c96b5ff3c0222" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "integer-sqrt" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "276ec31bcb4a9ee45f58bec6f9ec700ae4cf4f4f8f2fa7e06cb406bd5ffdd770" +dependencies = [ + "num-traits", +] + +[[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 2.0.119", +] + +[[package]] +name = "io-lifetimes" +version = "1.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eae7b9aee968036d54dce06cebaefd919e4472e753296daccd6d344e3e2df0c2" +dependencies = [ + "hermit-abi 0.3.9", + "libc", + "windows-sys 0.48.0", +] + +[[package]] +name = "ipconfig" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d40460c0ce33d6ce4b0630ad68ff63d6661961c48b6dba35e5a4d81cfb48222" +dependencies = [ + "socket2 0.6.5", + "widestring", + "windows-registry", + "windows-result", + "windows-sys 0.61.2", +] + +[[package]] +name = "ipnet" +version = "2.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a756c3fac73139e83f14c2d742155dd2b78d3ee56597b419a0579b7bdd6dd78" +dependencies = [ + "serde", +] + +[[package]] +name = "iroh" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "460de6bc52163b41b1646931f2897e5ab986f0966ade444467fec25024751a72" +dependencies = [ + "backon", + "blake3", + "bytes", + "cfg_aliases", + "ctutils", + "data-encoding", + "derive_more", + "ed25519-dalek", + "futures-util", + "getrandom 0.4.3", + "hickory-resolver", + "http", + "ipnet", + "iroh-base", + "iroh-dns", + "iroh-metrics", + "iroh-relay", + "n0-error", + "n0-future", + "n0-watcher", + "netwatch", + "noq", + "noq-proto", + "noq-udp", + "papaya", + "pin-project", + "portable-atomic", + "portmapper", + "rand 0.10.2", + "reqwest", + "rustc-hash 2.1.3", + "rustls", + "rustls-pki-types", + "serde", + "smallvec", + "strum", + "time", + "tokio", + "tokio-stream", + "tokio-util", + "tracing", + "url", + "wasm-bindgen-futures", +] + +[[package]] +name = "iroh-base" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6be73e16ee21c923aca9b3121aaa0db936f7c7ecc156ff47b8dac944c68d59a8" +dependencies = [ + "curve25519-dalek", + "data-encoding", + "data-encoding-macro", + "derive_more", + "ed25519-dalek", + "getrandom 0.4.3", + "n0-error", + "rand 0.10.2", + "serde", + "url", + "zeroize", +] + +[[package]] +name = "iroh-dns" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46f6a9b39d18e6345f5c151afd299f2488e2cb5c520fe41b107b6bd3dc4c3349" +dependencies = [ + "arc-swap", + "cfg_aliases", + "derive_more", + "hickory-resolver", + "iroh-base", + "n0-error", + "n0-future", + "ndk-context", + "portable-atomic", + "rand 0.10.2", + "rustls", + "simple-dns", + "strum", + "tokio", + "tracing", + "url", +] + +[[package]] +name = "iroh-metrics" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291065721ad7c477b972e581bbc528df031dc8eb5e39fe1ff3300ae5dfb157ef" +dependencies = [ + "iroh-metrics-derive", + "itoa", + "n0-error", + "portable-atomic", + "ryu", + "serde", + "tracing", +] + +[[package]] +name = "iroh-metrics-derive" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ae5f0c4405d1fbc9fb16ff422ca40620e93dc36c30ecaba0c2aee3992b7bd48" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "iroh-relay" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24bd586cf927f7b700f56ec3639b53cb5fa901ce284784051ff71092bfbf8193" +dependencies = [ + "blake3", + "bytes", + "cfg_aliases", + "data-encoding", + "derive_more", + "getrandom 0.4.3", + "hickory-resolver", + "http", + "http-body-util", + "hyper", + "hyper-util", + "iroh-base", + "iroh-dns", + "iroh-metrics", + "lru 0.18.2", + "n0-error", + "n0-future", + "noq", + "noq-proto", + "num_enum", + "pin-project", + "postcard", + "rand 0.10.2", + "reqwest", + "rustls", + "rustls-pki-types", + "serde", + "serde_bytes", + "strum", + "tokio", + "tokio-rustls", + "tokio-util", + "tokio-websockets", + "tracing", + "url", + "webpki-roots", + "ws_stream_wasm", +] + +[[package]] +name = "iroh-tickets" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da53233419ca36bf521ed45683b7748366f9b233032891eefc2d70567a84ac54" +dependencies = [ + "data-encoding", + "derive_more", + "iroh-base", + "n0-error", + "postcard", + "serde", +] + +[[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 = "jni" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a87aa2bb7d2af34197c04845522473242e1aa17c12f4935d5856491a7fb8c97" +dependencies = [ + "cesu8", + "cfg-if", + "combine", + "jni-sys 0.3.1", + "log", + "thiserror 1.0.69", + "walkdir", + "windows-sys 0.45.0", +] + +[[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.20", + "walkdir", + "windows-link", +] + +[[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 2.0.119", +] + +[[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 2.0.119", +] + +[[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.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e0c1080212aad755ea003d18543e8768dd432c48819efd73a7bf1e39b7a5a3a" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "keyboard-types" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b750dcadc39a09dbadd74e118f6dd6598df77fa01df0cfcdc52c28dece74528a" +dependencies = [ + "bitflags 2.13.1", + "serde", + "unicode-segmentation", +] + +[[package]] +name = "khronos_api" +version = "3.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2db585e1d738fc771bf08a151420d3ed193d9d895a36df7f6f8a9456b911ddc" + +[[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 = "lebe" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a79a3332a6609480d7d0c9eab957bca6b455b91bb84e66d19f5ff66294b85b8" + +[[package]] +name = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[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", +] + +[[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.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2026a5056764a10b2bf5d56488cba40da507f5493a6a429340e2004d9ed085fa" +dependencies = [ + "bitflags 2.13.1", + "libc", + "plain", + "redox_syscall 0.9.1", +] + +[[package]] +name = "libsqlite3-sys" +version = "0.30.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e99fb7a497b1e3339bc746195567ed8d3e24945ecd636e3619d20b9de9e9149" +dependencies = [ + "cc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "libudev-sys" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c8469b4a23b962c1396b9b451dda50ef5b283e8dd309d69033475fa9b334324" +dependencies = [ + "libc", + "pkg-config", +] + +[[package]] +name = "linebender_resource_handle" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4a5ff6bcca6c4867b1c4fd4ef63e4db7436ef363e0ad7531d1558856bae64f4" + +[[package]] +name = "linked-hash-map" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0717cef1bc8b636c6e1c1bbdefc09e6322da8a9321966e8928ef80d20f7f770f" + +[[package]] +name = "linked_hash_set" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "984fb35d06508d1e69fc91050cceba9c0b748f983e6739fa2c7a9237154c52c8" +dependencies = [ + "linked-hash-map", +] + +[[package]] +name = "linux-raw-sys" +version = "0.3.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef53942eb7bf7ff43a617b3e2c1c4a5ecf5944a7c1bc12d7ee39bbb15e5c1519" + +[[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.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd945864f07fe9f5371a27ad7b52a172b4b499999f1d97574c9fa68373937e12" + +[[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 = "lofty" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca260c51a9c71f823fbfd2e6fbc8eb2ee09834b98c00763d877ca8bfa85cde3e" +dependencies = [ + "byteorder", + "data-encoding", + "flate2", + "lofty_attr", + "log", + "ogg_pager", + "paste", +] + +[[package]] +name = "lofty_attr" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed9983e64b2358522f745c1251924e3ab7252d55637e80f6a0a3de642d6a9efc" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "log" +version = "0.4.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" + +[[package]] +name = "loom" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "419e0dc8046cb947daa77eb95ae174acfbddb7673b4151f56d1eed8e93fbfaca" +dependencies = [ + "cfg-if", + "generator", + "scoped-tls", + "tracing", + "tracing-subscriber", +] + +[[package]] +name = "loop9" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fae87c125b03c1d2c0150c90365d7d6bcc53fb73a9acaef207d2d065860f062" +dependencies = [ + "imgref", +] + +[[package]] +name = "lru" +version = "0.16.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f66e8d5d03f609abc3a39e6f08e4164ebf1447a732906d39eb9b99b7919ef39" + +[[package]] +name = "lru" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d2f2f9b4ba7e6b24d95e7e899329d35be83bcded72c8540cdd5368932d1d90a" +dependencies = [ + "hashbrown 0.17.1", +] + +[[package]] +name = "lru-slab" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" + +[[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_extra" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7755f08423275157ad1680aaecc9ccb7e0cc633da3240fea2d1522935cc15c72" +dependencies = [ + "lyon_path", + "thiserror 2.0.20", +] + +[[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 = "mac-addr" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3d25b0e0b648a86960ac23b7ad4abb9717601dec6f66c165f5b037f3f03065f" + +[[package]] +name = "mach2" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a1b95cd5421ec55b445b5ae102f5ea0e768de1f82bd3001e11f426c269c3aea" +dependencies = [ + "libc", +] + +[[package]] +name = "mainline" +version = "7.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dfad9601b9117218868271c05403853593d4817e7abeab5c95e11eee16382bb" +dependencies = [ + "crc", + "document-features", + "dyn-clone", + "ed25519-dalek", + "flume", + "futures-lite 2.6.1", + "getrandom 0.4.3", + "lru 0.16.4", + "serde", + "serde_bencode", + "serde_bytes", + "sha1_smol", + "thiserror 2.0.20", + "tracing", +] + +[[package]] +name = "malloc_buf" +version = "0.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62bb907fe88d54d8d9ce32a3cceab4218ed2f6b7d35617cafe9adf84e43919cb" +dependencies = [ + "libc", +] + +[[package]] +name = "matchers" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" +dependencies = [ + "regex-automata", +] + +[[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 = "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 = "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.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30d65c71f1ce40ab09135ce117d742b9f8a19ff91a41a8b57ed50bc2de59c427" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "moka" +version = "0.12.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4293f18e7567a1caf3c584855554377025c65e0aa445344d04171f5ad63d19b9" +dependencies = [ + "crossbeam-channel", + "crossbeam-epoch", + "crossbeam-utils", + "equivalent", + "parking_lot", + "portable-atomic", + "smallvec", + "tagptr", + "uuid", +] + +[[package]] +name = "moxcms" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb85c154ba489f01b25c0d36ae69a87e4a1c73a72631fc6c0eb6dde34a73e44b" +dependencies = [ + "num-traits", + "pxfm", +] + +[[package]] +name = "muda" +version = "0.19.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1dd04e60bc0b07438a6771710ee1698f98f6ebbc7f89b61264af1563b8aeb878" +dependencies = [ + "crossbeam-channel", + "dpi", + "keyboard-types", + "objc2 0.6.4", + "objc2-app-kit 0.3.2", + "objc2-core-foundation", + "objc2-foundation 0.3.2", + "once_cell", + "png", + "thiserror 2.0.20", + "windows-sys 0.61.2", +] + +[[package]] +name = "music-dht" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c5b429b90a8f1b0980b3a35a6fa5445d7a275c737eb04db18db4d7f14c81478" +dependencies = [ + "async-trait", + "blake3", + "data-encoding", + "federation-net", + "futures", + "postcard", + "rand 0.9.5", + "rusqlite", + "serde", + "serde_json", + "thiserror 2.0.20", + "tokio", + "tracing", + "unicode-normalization", +] + +[[package]] +name = "n0-error" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c37e81176a83a77d2514528b91bdafc70ef88aab428f0e1b91aebb8d99888895" +dependencies = [ + "n0-error-macros", + "spez", +] + +[[package]] +name = "n0-error-macros" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2acd8b070213b0299282f884b4beba4e7b52d624fdcd504a3ad3665390c11e1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "n0-future" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2ab99dfb861450e68853d34ae665243a88b8c493d01ba957321a1e9b2312bbe" +dependencies = [ + "cfg_aliases", + "derive_more", + "futures-buffered", + "futures-lite 2.6.1", + "futures-util", + "js-sys", + "pin-project", + "send_wrapper", + "tokio", + "tokio-util", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-time", +] + +[[package]] +name = "n0-watcher" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbc618745ad0b7414b149d0517ad8b5573b2fb4d4e2717add3d2446ce1fdd826" +dependencies = [ + "derive_more", + "n0-error", + "n0-future", +] + +[[package]] +name = "natord" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "308d96db8debc727c3fd9744aac51751243420e46edf401010908da7f8d5e57c" + +[[package]] +name = "ndk" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3f42e7bbe13d351b6bead8286a43aac9534b82bd3cc43e47037f012ebfd62d4" +dependencies = [ + "bitflags 2.13.1", + "jni-sys 0.3.1", + "log", + "ndk-sys", + "num_enum", + "raw-window-handle", + "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 = "netdev" +version = "0.45.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "569dfbdd2efd771b24ec9bb57f956e04d4fbfc72f62b2f11961723f9b3f4b020" +dependencies = [ + "block2 0.6.2", + "dispatch2", + "dlopen2", + "ipnet", + "jni 0.21.1", + "libc", + "mac-addr", + "ndk-context", + "netlink-packet-core", + "netlink-packet-route", + "netlink-sys", + "objc2 0.6.4", + "objc2-core-foundation", + "objc2-core-wlan", + "objc2-foundation 0.3.2", + "objc2-system-configuration", + "once_cell", + "plist", + "windows-sys 0.61.2", +] + +[[package]] +name = "netlink-packet-core" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b897d7bd4f0af82e68d40d0344cf37e97f9c97ddf74a098de3e4da05e96ca395" +dependencies = [ + "paste", +] + +[[package]] +name = "netlink-packet-route" +version = "0.31.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2288fcb784eb3defd5fb16f4c4160d5f477de192eac730f43e1d11c24d9a007" +dependencies = [ + "bitflags 2.13.1", + "libc", + "log", + "netlink-packet-core", +] + +[[package]] +name = "netlink-proto" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6f7398dddf5f152d2a91a2921a134c6097056e292c0d4b9906007855e7cece6" +dependencies = [ + "bytes", + "futures-channel", + "futures-util", + "log", + "netlink-packet-core", + "netlink-sys", + "thiserror 2.0.20", +] + +[[package]] +name = "netlink-sys" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd6c30ed10fa69cc491d491b85cc971f6bdeb8e7367b7cde2ee6cc878d583fae" +dependencies = [ + "bytes", + "futures-util", + "libc", + "log", + "tokio", +] + +[[package]] +name = "netwatch" +version = "0.19.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d9cbe01741347ef750d743d6690603f5eed8341e679fb51c8e629337aa11976" +dependencies = [ + "atomic-waker", + "bytes", + "cfg_aliases", + "derive_more", + "ipnet", + "js-sys", + "libc", + "n0-error", + "n0-future", + "n0-watcher", + "netdev", + "netlink-packet-core", + "netlink-packet-route", + "netlink-proto", + "netlink-sys", + "noq-udp", + "objc2-core-foundation", + "objc2-system-configuration", + "pin-project-lite", + "serde", + "socket2 0.6.5", + "time", + "tokio", + "tokio-util", + "tracing", + "web-sys", + "windows 0.62.2", + "windows-result", + "wmi", +] + +[[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", +] + +[[package]] +name = "nix" +version = "0.31.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf20d2fde8ff38632c426f1165ed7436270b44f199fc55284c38276f9db47c3d" +dependencies = [ + "bitflags 2.13.1", + "cfg-if", + "cfg_aliases", + "libc", +] + +[[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 = "noq" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09e4bb6601fa543c110d8957813267d5a8d775a0f8fbaccf1f615d06ba9b10da" +dependencies = [ + "bytes", + "cfg_aliases", + "derive_more", + "noq-proto", + "noq-udp", + "pin-project-lite", + "rustc-hash 2.1.3", + "rustls", + "socket2 0.6.5", + "thiserror 2.0.20", + "tokio", + "tokio-stream", + "tracing", + "web-time", +] + +[[package]] +name = "noq-proto" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baa7b5ccd819a9c68a0d955e67a881032d09b1a17219b1f90b0997a0888e1a15" +dependencies = [ + "aes-gcm", + "bytes", + "derive_more", + "enum-assoc", + "getrandom 0.4.3", + "identity-hash", + "lru-slab", + "rand 0.10.2", + "rand_pcg", + "ring", + "rustc-hash 2.1.3", + "rustls", + "rustls-pki-types", + "slab", + "sorted-index-buffer", + "thiserror 2.0.20", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "noq-udp" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02bba20e097a5a16cd0ad14ec882fae1e80a092a124e9422fc4dddd92e96a647" +dependencies = [ + "cfg_aliases", + "libc", + "socket2 0.6.5", + "tracing", + "windows-sys 0.61.2", +] + +[[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-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-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 2.0.119", +] + +[[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-rational" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824" +dependencies = [ + "num-bigint", + "num-integer", + "num-traits", +] + +[[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_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 3.5.0", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "objc" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "915b1b472bc21c53464d6c8461c9d3af805ba1ef837e1cac254428f4a77177b1" +dependencies = [ + "malloc_buf", +] + +[[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.1", + "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.1", + "block2 0.6.2", + "libc", + "objc2 0.6.4", + "objc2-cloud-kit 0.3.2", + "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.1", + "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 = [ + "objc2 0.6.4", + "objc2-foundation 0.3.2", +] + +[[package]] +name = "objc2-cloud-kit" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "74dd3b56391c7a0596a295029734d3c1c5e7e510a4cb30245f8221ccea96b009" +dependencies = [ + "bitflags 2.13.1", + "block2 0.5.1", + "objc2 0.5.2", + "objc2-core-location", + "objc2-foundation 0.2.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.1", + "objc2 0.6.4", + "objc2-foundation 0.3.2", +] + +[[package]] +name = "objc2-contacts" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5ff520e9c33812fd374d8deecef01d4a840e7b41862d849513de77e44aa4889" +dependencies = [ + "block2 0.5.1", + "objc2 0.5.2", + "objc2-foundation 0.2.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.1", + "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.1", + "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.1", + "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.1", + "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.1", + "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-location" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "000cfee34e683244f284252ee206a27953279d370e309649dc3ee317b37e5781" +dependencies = [ + "block2 0.5.1", + "objc2 0.5.2", + "objc2-contacts", + "objc2-foundation 0.2.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.1", + "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.1", + "objc2 0.6.4", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-io-surface", +] + +[[package]] +name = "objc2-core-wlan" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c71e34919aba0d701380d911702455038a8a3587467fe0141d6a71501e7ffe48" +dependencies = [ + "bitflags 2.13.1", + "objc2 0.6.4", + "objc2-core-foundation", + "objc2-foundation 0.3.2", + "objc2-security", + "objc2-security-foundation", +] + +[[package]] +name = "objc2-encode" +version = "4.1.0" +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.1", + "block2 0.5.1", + "dispatch", + "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.1", + "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.1", + "objc2 0.6.4", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-link-presentation" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1a1ae721c5e35be65f01a03b6d2ac13a54cb4fa70d8a5da293d7b0020261398" +dependencies = [ + "block2 0.5.1", + "objc2 0.5.2", + "objc2-app-kit 0.2.2", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-metal" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd0cba1276f6023976a406a14ffa85e1fdd19df6b0f737b063b95f6c8c7aadd6" +dependencies = [ + "bitflags 2.13.1", + "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.1", + "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.1", + "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.1", + "objc2 0.6.4", + "objc2-core-foundation", + "objc2-foundation 0.3.2", + "objc2-metal 0.3.2", +] + +[[package]] +name = "objc2-security" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe137109bd1e8b5a99390f77a7d8b2961dafc1a1c5db8f2e60329ad6d895a" +dependencies = [ + "bitflags 2.13.1", + "objc2 0.6.4", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-security-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef76382e9cedd18123099f17638715cc3d81dba3637d4c0d39ab69df2ef345a5" +dependencies = [ + "objc2 0.6.4", + "objc2-foundation 0.3.2", +] + +[[package]] +name = "objc2-symbols" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0a684efe3dec1b305badae1a28f6555f6ddd3bb2c2267896782858d5a78404dc" +dependencies = [ + "objc2 0.5.2", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-system-configuration" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7216bd11cbda54ccabcab84d523dc93b858ec75ecfb3a7d89513fa22464da396" +dependencies = [ + "bitflags 2.13.1", + "dispatch2", + "libc", + "objc2 0.6.4", + "objc2-core-foundation", + "objc2-security", +] + +[[package]] +name = "objc2-ui-kit" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8bb46798b20cd6b91cbd113524c490f1686f4c4e8f49502431415f3512e2b6f" +dependencies = [ + "bitflags 2.13.1", + "block2 0.5.1", + "objc2 0.5.2", + "objc2-cloud-kit 0.2.2", + "objc2-core-data 0.2.2", + "objc2-core-image 0.2.2", + "objc2-core-location", + "objc2-foundation 0.2.2", + "objc2-link-presentation", + "objc2-quartz-core 0.2.2", + "objc2-symbols", + "objc2-uniform-type-identifiers", + "objc2-user-notifications", +] + +[[package]] +name = "objc2-ui-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d87d638e33c06f577498cbcc50491496a3ed4246998a7fbba7ccb98b1e7eab22" +dependencies = [ + "bitflags 2.13.1", + "block2 0.6.2", + "objc2 0.6.4", + "objc2-core-foundation", + "objc2-foundation 0.3.2", + "objc2-quartz-core 0.3.2", +] + +[[package]] +name = "objc2-uniform-type-identifiers" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44fa5f9748dbfe1ca6c0b79ad20725a11eca7c2218bceb4b005cb1be26273bfe" +dependencies = [ + "block2 0.5.1", + "objc2 0.5.2", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-user-notifications" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76cfcbf642358e8689af64cee815d139339f3ed8ad05103ed5eaf73db8d84cb3" +dependencies = [ + "bitflags 2.13.1", + "block2 0.5.1", + "objc2 0.5.2", + "objc2-core-location", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "ogg_pager" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d36b1d6964c3ac92b7aea701057e02b6b91143d70d83b20abf75a231a3c0216" +dependencies = [ + "byteorder", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +dependencies = [ + "critical-section", + "portable-atomic", +] + +[[package]] +name = "opaque-debug" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" + +[[package]] +name = "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 = "orbclient" +version = "0.3.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5df339f526ea9a60e371768d50efc2f2508c7203290731565d1f7a6f71d21747" +dependencies = [ + "libc", + "libredox", +] + +[[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 = "owned_ttf_parser" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36820e9051aca1014ddc75770aab4d68bc1e9e632f0f5627c4086bc216fb583b" +dependencies = [ + "ttf-parser", +] + +[[package]] +name = "papaya" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "997ee03cd38c01469a7046643714f0ad28880bcb9e6679ff0666e24817ca19b7" +dependencies = [ + "equivalent", + "seize", +] + +[[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 0.5.18", + "smallvec", + "windows-link", +] + +[[package]] +name = "parlance" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b6937eda350acc1a5d05872c3cbf99fe78619c269096e2be3d4a350058639d5" + +[[package]] +name = "parley" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b1cfcf399c774719fb1fa51bc6b91e86bdf003b03202a0902f1827ba6750746" +dependencies = [ + "fontique", + "harfrust", + "hashbrown 0.17.1", + "icu_normalizer", + "icu_properties", + "icu_segmenter", + "linebender_resource_handle", + "parlance", + "parley_data", + "skrifa 0.42.1", +] + +[[package]] +name = "parley_data" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1d3755e2cc12c0625b0cd2f2773c6a37dd11d1531940c945ade4aeb78f2b145" +dependencies = [ + "icu_properties", +] + +[[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 = "pem-rfc7468" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6305423e0e7738146434843d1694d621cce767262b2a86910beab705e4493d9" +dependencies = [ + "base64ct", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pharos" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9567389417feee6ce15dd6527a8a1ecac205ef62c2932bcf3d9f6fc5b78b414" +dependencies = [ + "futures", + "rustc_version", +] + +[[package]] +name = "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 2.0.119", +] + +[[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 = "pin-weak" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b330c9d1b92dfe68442ca20b009c717d5f0b1e3cf4965e62f704c3c6e95a1305" + +[[package]] +name = "piper" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c835479a4443ded371d6c535cbfd8d31ad92c5d23ae9770a61bc155e4992a3c1" +dependencies = [ + "atomic-waker", + "fastrand 2.5.0", + "futures-io", +] + +[[package]] +name = "pkcs8" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "451913da69c775a56034ea8d9003d27ee8948e12443eae7c038ba100a4f21cb7" +dependencies = [ + "der", + "spki", +] + +[[package]] +name = "pkg-config" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" + +[[package]] +name = "plain" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" + +[[package]] +name = "plist" +version = "1.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da1d65da6dd5d1e44199ac0f58712d241c0f439f80adea8924d832384087f85" +dependencies = [ + "base64", + "indexmap", + "quick-xml", + "serde", + "time", +] + +[[package]] +name = "png" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61" +dependencies = [ + "bitflags 2.13.1", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide", +] + +[[package]] +name = "polling" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b2d323e8ca7996b3e23126511a523f7e62924d93ecd5ae73b333815b0eb3dce" +dependencies = [ + "autocfg", + "bitflags 1.3.2", + "cfg-if", + "concurrent-queue", + "libc", + "log", + "pin-project-lite", + "windows-sys 0.48.0", +] + +[[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 0.5.2", + "pin-project-lite", + "rustix 1.1.4", + "windows-sys 0.61.2", +] + +[[package]] +name = "pollster" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22686f4785f02a4fcc856d3b3bb19bf6c8160d103f7a99cc258bddd0251dc7f2" + +[[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", +] + +[[package]] +name = "portable-atomic" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05c8b63e8d9609db387f0324918f81d68fe27748f084ef092fb35954d0539a85" +dependencies = [ + "critical-section", + "serde", +] + +[[package]] +name = "portmapper" +version = "0.19.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb3713e4977408279158444a18c1a01ac9bf2e7eaf1fbfd1a19ac9cd18d90721" +dependencies = [ + "base64", + "bytes", + "derive_more", + "hyper-util", + "igd-next", + "iroh-metrics", + "libc", + "n0-error", + "n0-future", + "netwatch", + "num_enum", + "rand 0.10.2", + "serde", + "smallvec", + "socket2 0.6.5", + "time", + "tokio", + "tokio-util", + "tower-layer", + "tracing", + "url", +] + +[[package]] +name = "postcard" +version = "1.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6764c3b5dd454e283a30e6dfe78e9b31096d9e32036b5d1eaac7a6119ccb9a24" +dependencies = [ + "cobs", + "embedded-io 0.4.0", + "embedded-io 0.6.1", + "heapless", + "postcard-derive", + "serde", +] + +[[package]] +name = "postcard-derive" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0232bd009a197ceec9cc881ba46f727fcd8060a2d8d6a9dde7a69030a6fe2bb" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "potential_utf" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +dependencies = [ + "serde_core", + "writeable", + "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 = "prefix-trie" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cf6e3177f0684016a5c209b00882e15f8bdd3f3bb48f0491df10cd102d0c6e7" +dependencies = [ + "either", + "ipnet", + "num-traits", +] + +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn 2.0.119", +] + +[[package]] +name = "proc-macro-crate" +version = "1.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f4c021e1093a56626774e81216a4ce732a735e5bad4868a03f3ed65ca0c3919" +dependencies = [ + "once_cell", + "toml_edit 0.19.15", +] + +[[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.13+spec-1.1.0", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +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 2.0.119", +] + +[[package]] +name = "pulldown-cmark" +version = "0.13.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9f068eba8e7071c5f9511831b44f32c740d5adf574e990f946ddb53db2f314e" +dependencies = [ + "bitflags 2.13.1", + "getopts", + "memchr", + "pulldown-cmark-escape", + "unicase", +] + +[[package]] +name = "pulldown-cmark-escape" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "007d8adb5ddab6f8e3f491ac63566a7d5002cc7ed73901f72057943fa71ae1ae" + +[[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 = "quick-error" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3" + +[[package]] +name = "quick-xml" +version = "0.41.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e660451e55124f798a69a5af3f49ccfbefbd41910eefd25caf2393e1f3473ec1" +dependencies = [ + "memchr", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +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 = "rand" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22f6172bdec972074665ed81ed53b71da00bfc44b65a753cfde883ec4c702a1a" +dependencies = [ + "libc", + "rand_chacha 0.3.1", + "rand_core 0.6.4", +] + +[[package]] +name = "rand" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41" +dependencies = [ + "rand_chacha 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 = "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.5", + "rand_chacha 0.9.0", + "simd_helpers", + "thiserror 2.0.20", + "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.1", +] + +[[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 = "read-fonts" +version = "0.39.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4ed38b89c2c77ff968c524145ad65fb010f38af5c7a224b53b81d47ac2daa81" +dependencies = [ + "bytemuck", + "font-types 0.11.3", +] + +[[package]] +name = "read-fonts" +version = "0.41.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "046a7d674daf459825b32f5062056d6882db0d2f5a479fbd76ccfc870ac18709" +dependencies = [ + "bytemuck", + "font-types 0.12.2", + "once_cell", +] + +[[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.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4722d768eff46b75989dd134e5c353f0d6296e5aaa3132e776cbdb56be7731aa" +dependencies = [ + "bitflags 1.3.2", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags 2.13.1", +] + +[[package]] +name = "redox_syscall" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07507be7b4a5f9f26eeb41eeaebb1f5a7ff29dfb29739facc21d35bf8b11c21e" +dependencies = [ + "bitflags 2.13.1", +] + +[[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.20", +] + +[[package]] +name = "regex" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "reqwest" +version = "0.13.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "219c5811de6525e5416c7d5d53bb656d3afdbc6c5af816e0802bcfa42dbdc1c3" +dependencies = [ + "base64", + "bytes", + "futures-core", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "rustls", + "rustls-pki-types", + "rustls-platform-verifier", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tokio-util", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-streams", + "web-sys", +] + +[[package]] +name = "resolv-conf" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e061d1b48cb8d38042de4ae0a7a6401009d6143dc80d2e2d6f31f0bdd6470c7" + +[[package]] +name = "resvg" +version = "0.47.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9be183ad6a216aa96f33e4c8033b0988b8b3ea6fd2359d19af5bac4643fd8e81" +dependencies = [ + "gif", + "image-webp", + "log", + "pico-args", + "rgb", + "svgtypes", + "tiny-skia 0.12.0", + "usvg", + "zune-jpeg", +] + +[[package]] +name = "rfd" +version = "0.15.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef2bee61e6cffa4635c72d7d81a84294e28f0930db0ddcb0f66d10244674ebed" +dependencies = [ + "ashpd", + "block2 0.6.2", + "dispatch2", + "js-sys", + "log", + "objc2 0.6.4", + "objc2-app-kit 0.3.2", + "objc2-core-foundation", + "objc2-foundation 0.3.2", + "pollster 0.4.0", + "raw-window-handle", + "urlencoding", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "windows-sys 0.59.0", +] + +[[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 = "rodio" +version = "0.22.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0a536bb79db59098ef71a4dd4246c02eb87b316deceb1b68e0cde7167ec01eb" +dependencies = [ + "cpal", + "dasp_sample", + "num-rational", + "symphonia", + "thiserror 2.0.20", +] + +[[package]] +name = "rowan" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "417a3a9f582e349834051b8a10c8d71ca88da4211e4093528e36b9845f6b5f21" +dependencies = [ + "countme", + "hashbrown 0.14.5", + "rustc-hash 1.1.0", + "text-size", +] + +[[package]] +name = "roxmltree" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1964b10c76125c36f8afe190065a4bf9a87bf324842c05701330bba9f1cacbb" +dependencies = [ + "memchr", +] + +[[package]] +name = "rspolib" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fda9a7796aff63a7b1b39ccc93fffaaf65e20042984b4843041a49ca4677535" +dependencies = [ + "lazy_static", + "natord", + "snafu", + "unicode-linebreak", + "unicode-width", +] + +[[package]] +name = "rusqlite" +version = "0.32.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7753b721174eb8ff87a9a0e799e2d7bc3749323e773db92e0984debb00019d6e" +dependencies = [ + "bitflags 2.13.1", + "fallible-iterator", + "fallible-streaming-iterator", + "hashlink", + "libsqlite3-sys", + "smallvec", +] + +[[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 = "rustix" +version = "0.37.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "519165d378b97752ca44bbe15047d5d3409e875f39327546b42ac81d7e18c1b6" +dependencies = [ + "bitflags 1.3.2", + "errno", + "io-lifetimes", + "libc", + "linux-raw-sys 0.3.8", + "windows-sys 0.48.0", +] + +[[package]] +name = "rustix" +version = "0.38.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" +dependencies = [ + "bitflags 2.13.1", + "errno", + "libc", + "linux-raw-sys 0.4.15", + "windows-sys 0.59.0", +] + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags 2.13.1", + "errno", + "libc", + "linux-raw-sys 0.12.1", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls" +version = "0.23.43" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0283386ce02abc0151e1761d08802dfe86c173b0b494af5cbc086574e453da06" +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-pki-types" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96" +dependencies = [ + "web-time", + "zeroize", +] + +[[package]] +name = "rustls-platform-verifier" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26d1e2536ce4f35f4846aa13bff16bd0ff40157cdb14cc056c7b14ba41233ba0" +dependencies = [ + "core-foundation 0.10.1", + "core-foundation-sys", + "jni 0.22.4", + "log", + "once_cell", + "rustls", + "rustls-native-certs", + "rustls-platform-verifier-android", + "rustls-webpki", + "security-framework", + "security-framework-sys", + "webpki-root-certs", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls-platform-verifier-android" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f" + +[[package]] +name = "rustls-webpki" +version = "0.103.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0527518605e68109d875e248ea259b6758801cf165e4b2c2733ae3b51f12535a" +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.1", + "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 = "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 = "scoped-tls" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1cf6437eb19a8f4a6cc0f7dca544973b0b78843adbfeb3683d1a94a0024a294" + +[[package]] +name = "scoped-tls-hkt" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9603871ffe5df3ac39cb624790c296dbd47a400d202f56bf3e414045099524d" + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "sctk-adwaita" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6277f0217056f77f1d8f49f2950ac6c278c0d607c45f5ee99328d792ede24ec" +dependencies = [ + "ab_glyph", + "log", + "memmap2", + "smithay-client-toolkit 0.19.2", + "tiny-skia 0.11.4", +] + +[[package]] +name = "security-framework" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags 2.13.1", + "core-foundation 0.10.1", + "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 = "seize" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b55fb86dfd3a2f5f76ea78310a88f96c4ea21a3031f8d212443d56123fd0521" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "send_wrapper" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd0b0ec5f1c1ca621c432a25813d8d60c88abe6d3e08a3eb9cf37d97a0fe3d73" + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_bencode" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a70dfc7b7438b99896e7f8992363ab8e2c4ba26aa5ec675d32d1c3c2c33d413e" +dependencies = [ + "serde", + "serde_bytes", +] + +[[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.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "serde_json" +version = "1.0.151" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_repr" +version = "0.1.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d3b1629de253c70a0508c3899572da79ca359fdab27c7920ff00406df418906" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[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 = "serdect" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66cf8fedced2fcf12406bcb34223dffb92eaf34908ede12fed414c82b7f00b3e" +dependencies = [ + "base16ct", + "serde", +] + +[[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_smol" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbfa15b3dddfee50a0fff136974b3e1bde555604ba463834a7eb7deb6417705d" + +[[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 = "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 = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28d567dcbaf0049cb8ac2608a76cd95ff9e4412e1899d389ee400918ca7537f5" +dependencies = [ + "rand_core 0.10.1", +] + +[[package]] +name = "simd-adler32" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" + +[[package]] +name = "simd_cesu8" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11031e251abf8611c80f460e19dbdeb54a66db918e49c65a7065b46ac7aec520" +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 = "simple-dns" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a75cbde1bf934313596a004973e462f9a82caa814dcf1a5f507bdf51597eeb4" +dependencies = [ + "bitflags 2.13.1", +] + +[[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 = "skia-bindings" +version = "0.99.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e2d1c3ebd697c0cbded0145e9204a38fa6b268446051b7196d0a096414ea7f3" +dependencies = [ + "bindgen", + "cc", + "flate2", + "heck", + "pkg-config", + "regex", + "serde_json", + "tar", + "toml", +] + +[[package]] +name = "skia-safe" +version = "0.99.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f512ac418a64194842dd05566320805dad1c957c521039db2486fd6368865bc" +dependencies = [ + "bitflags 2.13.1", + "skia-bindings", + "windows 0.62.2", +] + +[[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 = "skrifa" +version = "0.44.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819ab7d62b1d3e72d9d9dea5650bac30424f9111364bb94928dbf5ecad1baa68" +dependencies = [ + "bytemuck", + "read-fonts 0.41.0", +] + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "slint" +version = "1.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce12038f57f8f3a423564b0c9301a2ab3fda4246e4cbadd6b97acf9124bdc315" +dependencies = [ + "const-field-offset", + "i-slint-backend-selector", + "i-slint-common", + "i-slint-core", + "i-slint-core-macros", + "i-slint-renderer-femtovg", + "i-slint-renderer-software", + "num-traits", + "once_cell", + "pin-weak", + "slint-macros", + "unicode-segmentation", + "vtable", +] + +[[package]] +name = "slint-build" +version = "1.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76684c11441f775b10bbc7b0e6b3016a39f17efdd659ffca112f17cfd6bdaed1" +dependencies = [ + "derive_more", + "fontique", + "i-slint-compiler", + "spin_on", + "toml_edit 0.25.13+spec-1.1.0", +] + +[[package]] +name = "slint-macros" +version = "1.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f2a763e72e1e53c496f571d36327e777da1eb0d9e58fceb83890c2a83ee7223" +dependencies = [ + "i-slint-compiler", + "proc-macro2", + "quote", + "spin_on", +] + +[[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" + +[[package]] +name = "smithay-client-toolkit" +version = "0.19.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3457dea1f0eb631b4034d61d4d8c32074caa6cd1ab2d59f2327bd8461e2c0016" +dependencies = [ + "bitflags 2.13.1", + "calloop 0.13.0", + "calloop-wayland-source 0.3.0", + "cursor-icon", + "libc", + "log", + "memmap2", + "rustix 0.38.44", + "thiserror 1.0.69", + "wayland-backend", + "wayland-client", + "wayland-csd-frame", + "wayland-cursor", + "wayland-protocols", + "wayland-protocols-wlr", + "wayland-scanner", + "xkeysym", +] + +[[package]] +name = "smithay-client-toolkit" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0512da38f5e2b31201a93524adb8d3136276fa4fe4aafab4e1f727a82b534cc0" +dependencies = [ + "bitflags 2.13.1", + "calloop 0.14.4", + "calloop-wayland-source 0.4.1", + "cursor-icon", + "libc", + "log", + "memmap2", + "rustix 1.1.4", + "thiserror 2.0.20", + "wayland-backend", + "wayland-client", + "wayland-csd-frame", + "wayland-cursor", + "wayland-protocols", + "wayland-protocols-experimental", + "wayland-protocols-misc", + "wayland-protocols-wlr", + "wayland-scanner", + "xkeysym", +] + +[[package]] +name = "smithay-clipboard" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71704c03f739f7745053bde45fa203a46c58d25bc5c4efba1d9a60e9dba81226" +dependencies = [ + "libc", + "smithay-client-toolkit 0.20.0", + "wayland-backend", +] + +[[package]] +name = "smol_str" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd538fb6910ac1099850255cf94a94df6551fbdd602454387d0adb2d1ca6dead" +dependencies = [ + "serde", +] + +[[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 = "snafu" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e84b3f4eacbf3a1ce05eac6763b4d629d60cbc94d632e4092c54ade71f1e1a2" +dependencies = [ + "snafu-derive", +] + +[[package]] +name = "snafu-derive" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1c97747dbf44bb1ca44a561ece23508e99cb592e862f22222dcf42f51d1e451" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "socket2" +version = "0.4.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7916fc008ca5542385b89a3d3ce689953c143e9304a9bf8beec1de48994c0d" +dependencies = [ + "libc", + "winapi", +] + +[[package]] +name = "socket2" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "softbuffer" +version = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aac18da81ebbf05109ab275b157c22a653bb3c12cf884450179942f81bcbf6c3" +dependencies = [ + "as-raw-xcb-connection", + "bytemuck", + "fastrand 2.5.0", + "js-sys", + "memmap2", + "ndk", + "objc2 0.6.4", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-foundation 0.3.2", + "objc2-quartz-core 0.3.2", + "raw-window-handle", + "redox_syscall 0.5.18", + "rustix 1.1.4", + "tiny-xlib", + "tracing", + "wasm-bindgen", + "wayland-backend", + "wayland-client", + "wayland-sys", + "web-sys", + "windows-sys 0.61.2", + "x11rb", +] + +[[package]] +name = "sorted-index-buffer" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea06cc588e43c632923a55450401b8f25e628131571d4e1baea1bdfdb2b5ed06" + +[[package]] +name = "souvlaki" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5855c8f31521af07d896b852eaa9eca974ddd3211fc2ae292e58dda8eb129bc8" +dependencies = [ + "base64", + "block", + "cocoa", + "core-graphics 0.22.3", + "dispatch", + "objc", + "pollster 0.3.0", + "thiserror 1.0.69", + "windows 0.44.0", + "zbus 3.15.2", + "zvariant 3.15.2", +] + +[[package]] +name = "spez" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c87e960f4dca2788eeb86bbdde8dd246be8948790b7618d656e68f9b720a86e8" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "spin" +version = "0.9.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3763264f6b73151db08c50ff20d7d8a0b8796e021cdea7ceedad07b80155fa0e" +dependencies = [ + "lock_api", +] + +[[package]] +name = "spin" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "023a211cb3138dbc438680b32560ad89f699977624c9f8dbb95a47d5b4c07dd3" + +[[package]] +name = "spin_on" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "076e103ed41b9864aa838287efe5f4e3a7a0362dd00671ae62a212e5e4612da2" +dependencies = [ + "pin-utils", +] + +[[package]] +name = "spki" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d9efca8738c78ee9484207732f728b1ef517bbb1833d6fc0879ca898a522f6f" +dependencies = [ + "base64ct", + "der", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + +[[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 = "strum" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9628de9b8791db39ceda2b119bbe13134770b56c138ec1d3af810d045c04f9bd" +dependencies = [ + "strum_macros", +] + +[[package]] +name = "strum_macros" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab85eea0270ee17587ed4156089e10b9e6880ee688791d45a905f5b1ca36f664" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "svgtypes" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "695b5790b3131dafa99b3bbfd25a216edb3d216dad9ca208d4657bfb8f2abc3d" +dependencies = [ + "kurbo", + "siphasher", +] + +[[package]] +name = "swash" +version = "0.2.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c2499c2d826531388872b2268718aed907a39bd785ab0dcfe57fab26283f92e" +dependencies = [ + "skrifa 0.44.0", + "yazi", + "zeno", +] + +[[package]] +name = "symphonia" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5773a4c030a19d9bfaa090f49746ff35c75dfddfa700df7a5939d5e076a57039" +dependencies = [ + "lazy_static", + "symphonia-bundle-flac", + "symphonia-bundle-mp3", + "symphonia-codec-aac", + "symphonia-codec-alac", + "symphonia-codec-pcm", + "symphonia-codec-vorbis", + "symphonia-core", + "symphonia-format-isomp4", + "symphonia-format-ogg", + "symphonia-format-riff", + "symphonia-metadata", +] + +[[package]] +name = "symphonia-bundle-flac" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c91565e180aea25d9b80a910c546802526ffd0072d0b8974e3ebe59b686c9976" +dependencies = [ + "log", + "symphonia-core", + "symphonia-metadata", + "symphonia-utils-xiph", +] + +[[package]] +name = "symphonia-bundle-mp3" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4872dd6bb56bf5eac799e3e957aa1981086c3e613b27e0ac23b176054f7c57ed" +dependencies = [ + "lazy_static", + "log", + "symphonia-core", + "symphonia-metadata", +] + +[[package]] +name = "symphonia-codec-aac" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c263845aa86881416849c1729a54c7f55164f8b96111dba59de46849e73a790" +dependencies = [ + "lazy_static", + "log", + "symphonia-core", +] + +[[package]] +name = "symphonia-codec-alac" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8413fa754942ac16a73634c9dfd1500ed5c61430956b33728567f667fdd393ab" +dependencies = [ + "log", + "symphonia-core", +] + +[[package]] +name = "symphonia-codec-pcm" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e89d716c01541ad3ebe7c91ce4c8d38a7cf266a3f7b2f090b108fb0cb031d95" +dependencies = [ + "log", + "symphonia-core", +] + +[[package]] +name = "symphonia-codec-vorbis" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f025837c309cd69ffef572750b4a2257b59552c5399a5e49707cc5b1b85d1c73" +dependencies = [ + "log", + "symphonia-core", + "symphonia-utils-xiph", +] + +[[package]] +name = "symphonia-core" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea00cc4f79b7f6bb7ff87eddc065a1066f3a43fe1875979056672c9ef948c2af" +dependencies = [ + "arrayvec", + "bitflags 1.3.2", + "bytemuck", + "lazy_static", + "log", +] + +[[package]] +name = "symphonia-format-isomp4" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "243739585d11f81daf8dac8d9f3d18cc7898f6c09a259675fc364b382c30e0a5" +dependencies = [ + "encoding_rs", + "log", + "symphonia-core", + "symphonia-metadata", + "symphonia-utils-xiph", +] + +[[package]] +name = "symphonia-format-ogg" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b4955c67c1ed3aa8ae8428d04ca8397fbef6a19b2b051e73b5da8b1435639cb" +dependencies = [ + "log", + "symphonia-core", + "symphonia-metadata", + "symphonia-utils-xiph", +] + +[[package]] +name = "symphonia-format-riff" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2d7c3df0e7d94efb68401d81906eae73c02b40d5ec1a141962c592d0f11a96f" +dependencies = [ + "extended", + "log", + "symphonia-core", + "symphonia-metadata", +] + +[[package]] +name = "symphonia-metadata" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36306ff42b9ffe6e5afc99d49e121e0bd62fe79b9db7b9681d48e29fa19e6b16" +dependencies = [ + "encoding_rs", + "lazy_static", + "log", + "symphonia-core", +] + +[[package]] +name = "symphonia-utils-xiph" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee27c85ab799a338446b68eec77abf42e1a6f1bb490656e121c6e27bfbab9f16" +dependencies = [ + "symphonia-core", + "symphonia-metadata", +] + +[[package]] +name = "syn" +version = "1.0.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +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 2.0.119", +] + +[[package]] +name = "sys-locale" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8eab9a99a024a169fe8a903cf9d4a3b3601109bcc13bd9e3c6fff259138626c4" +dependencies = [ + "js-sys", + "libc", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "system-configuration" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b" +dependencies = [ + "bitflags 2.13.1", + "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 = "tagptr" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b2093cf4c8eb1e67749a6762251bc9cd836b6fc171623bd0a9d324d37af2417" + +[[package]] +name = "tar" +version = "0.4.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f6221d9a6003c78398e3b239969f352578258df48c8eb051caadae0015bc840" +dependencies = [ + "filetime", + "libc", + "xattr", +] + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand 2.5.0", + "getrandom 0.4.3", + "once_cell", + "rustix 1.1.4", + "windows-sys 0.61.2", +] + +[[package]] +name = "text-size" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f18aa187839b2bdb1ad2fa35ead8c4c2976b64e4363c386d45ac0f7ee85c9233" + +[[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.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f" +dependencies = [ + "thiserror-impl 2.0.20", +] + +[[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 2.0.119", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[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", +] + +[[package]] +name = "time" +version = "0.3.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134" +dependencies = [ + "deranged", + "js-sys", + "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.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e689342a48d2ea927c87ea50cabf8594854bf940e9310208848d680d668ed85" +dependencies = [ + "num-conv", + "time-core", +] + +[[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", + "tiny-skia-path 0.11.4", +] + +[[package]] +name = "tiny-skia" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47ffee5eaaf5527f630fb0e356b90ebdec84d5d18d937c5e440350f88c5a91ea" +dependencies = [ + "arrayref", + "arrayvec", + "bytemuck", + "cfg-if", + "log", + "png", + "tiny-skia-path 0.12.0", +] + +[[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 = "tiny-skia-path" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edca365c3faccca67d06593c5980fa6c57687de727a03131735bb85f01fdeeb9" +dependencies = [ + "arrayref", + "bytemuck", + "strict-num", +] + +[[package]] +name = "tiny-xlib" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a90a0ca3ee6a69f2ad28fd11621a4c3f03b371f366be500b64df260c4ffbafb4" +dependencies = [ + "as-raw-xcb-connection", + "ctor", + "libloading", + "pkg-config", + "tracing", +] + +[[package]] +name = "tinystr" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" +dependencies = [ + "displaydoc", + "serde_core", + "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", + "pin-project-lite", + "signal-hook-registry", + "socket2 0.6.5", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[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-stream" +version = "0.1.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3d06f0b082ba57c26b79407372e57cf2a1e28124f78e9479fe80322cf53420b" +dependencies = [ + "futures-core", + "pin-project-lite", + "tokio", + "tokio-util", +] + +[[package]] +name = "tokio-util" +version = "0.7.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "futures-util", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tokio-websockets" +version = "0.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d52efb639344a7c6adb8e62c6f3d2c19c001ff1b79a5041ba1c6ed42e19c6aa5" +dependencies = [ + "base64", + "bytes", + "futures-core", + "futures-sink", + "getrandom 0.4.3", + "http", + "httparse", + "rand 0.10.2", + "ring", + "rustls-pki-types", + "sha1_smol", + "simdutf8", + "tokio", + "tokio-rustls", + "tokio-util", +] + +[[package]] +name = "toml" +version = "1.1.4+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3aace63f4bbcdfc2c965b059de67119c89c4017a70d633be6c104910f67056f5" +dependencies = [ + "indexmap", + "serde_core", + "serde_spanned", + "toml_datetime 1.1.1+spec-1.1.0", + "toml_parser", + "toml_writer", + "winnow 1.0.4", +] + +[[package]] +name = "toml_datetime" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" + +[[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.19.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b5bb770da30e5cbfde35a2d7b9b8a2c4b8ef89548a7a6aeab5c9a576e3e7421" +dependencies = [ + "indexmap", + "toml_datetime 0.6.11", + "winnow 0.5.40", +] + +[[package]] +name = "toml_edit" +version = "0.25.13+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6975367e4d2ef766d86af01ffad14b622fecc8d4357a998fbc4deb6e9bacaf9b" +dependencies = [ + "indexmap", + "toml_datetime 1.1.1+spec-1.1.0", + "toml_parser", + "toml_writer", + "winnow 1.0.4", +] + +[[package]] +name = "toml_parser" +version = "1.1.3+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d38ac1cf9b95face32296c0a3ede1fdc270627c9d9c02a7274dd6d960dc4d56" +dependencies = [ + "winnow 1.0.4", +] + +[[package]] +name = "toml_writer" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d56353a2a665ad0f41a421187180aab746c8c325620617ad883a99a1cbe66d2" + +[[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-http" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" +dependencies = [ + "bitflags 2.13.1", + "bytes", + "futures-util", + "http", + "http-body", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", + "url", +] + +[[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 2.0.119", +] + +[[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 = [ + "matchers", + "nu-ansi-term", + "once_cell", + "regex-automata", + "sharded-slab", + "smallvec", + "thread_local", + "tracing", + "tracing-core", + "tracing-log", +] + +[[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-index-collections" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "898160f1dfd383b4e92e17f0512a7d62f3c51c44937b23b6ffc3a1614a8eaccd" +dependencies = [ + "bincode", + "serde", +] + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "udev" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af4e37e9ea4401fc841ff54b9ddfc9be1079b1e89434c1a6a865dd68980f7e9f" +dependencies = [ + "io-lifetimes", + "libc", + "libudev-sys", + "pkg-config", +] + +[[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-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-normalization" +version = "0.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8" +dependencies = [ + "tinyvec", +] + +[[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 = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "unty" +version = "0.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d49784317cd0d1ee7ec5c716dd598ec5b4483ea832a2dced265471cc0f690ae" + +[[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", + "serde_derive", +] + +[[package]] +name = "urlencoding" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da" + +[[package]] +name = "usvg" +version = "0.47.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d46cf96c5f498d36b7a9693bc6a7075c0bb9303189d61b2249b0dc3d309c07de" +dependencies = [ + "base64", + "data-url", + "flate2", + "fontdb", + "imagesize", + "kurbo", + "log", + "pico-args", + "roxmltree", + "rustybuzz", + "simplecss", + "siphasher", + "strict-num", + "svgtypes", + "tiny-skia-path 0.12.0", + "ttf-parser", + "unicode-bidi", + "unicode-script", + "unicode-vo", + "xmlwriter", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "uuid" +version = "1.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf3923a6f5c4c6382e0b653c4117f48d631ea17f38ed86e2a828e6f7412f5239" +dependencies = [ + "getrandom 0.4.3", + "js-sys", + "serde_core", + "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 = "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 = "vtable" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ea953f65593feb5287b022b83c35046d1ff49c7417b6f5851a079d8a231fe31" +dependencies = [ + "const-field-offset", + "portable-atomic", + "stable_deref_trait", + "vtable-macro", +] + +[[package]] +name = "vtable-macro" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "482a37f9777480673d3a99c80ac2edc69f92382230440946855fa27ebc47cba6" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[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 = "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.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b70935747edd64d89de3efa29d73789b806c15798f8e7dca4d8ac356b50ce70" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.77" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b7777d5cc23d0e91404e53ce2d5e8ec7acae3026b16233dba62cd3246457950" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77775f8f3f7217702089053b94958f8f54061a3f663417df76e19cbdcca29bc1" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e11d33f857dc2fb11b8bc75aee111aa9cbeb12cd9f25efd3d4c2a3dd4e235284" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 2.0.119", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ef64dbcc55df09c7e5a46182d181c2cfa3e925f3da937ea764728b4bbb9dcbf" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wasm-streams" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d1ec4f6517c9e11ae630e200b2b65d193279042e28edd4a2cda233e46670bbb" +dependencies = [ + "futures-util", + "js-sys", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "wayland-backend" +version = "0.3.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "016ccf01d1c58b6f8999612813e17c9b2390f7d70671428869913310f83f54b8" +dependencies = [ + "cc", + "downcast-rs", + "rustix 1.1.4", + "scoped-tls", + "smallvec", + "wayland-sys", +] + +[[package]] +name = "wayland-client" +version = "0.31.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3c36a0f861ad76d0901f2800b46321410d9f73f2ea88aac0650d86c32688073" +dependencies = [ + "bitflags 2.13.1", + "rustix 1.1.4", + "wayland-backend", + "wayland-scanner", +] + +[[package]] +name = "wayland-csd-frame" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "625c5029dbd43d25e6aa9615e88b829a5cad13b2819c4ae129fdbb7c31ab4c7e" +dependencies = [ + "bitflags 2.13.1", + "cursor-icon", + "wayland-backend", +] + +[[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.1", + "wayland-backend", + "wayland-client", + "wayland-scanner", +] + +[[package]] +name = "wayland-protocols-experimental" +version = "20250721.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40a1f863128dcaaec790d7b4b396cc9b9a7a079e878e18c47e6c2d2c5a8dcbb1" +dependencies = [ + "bitflags 2.13.1", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-scanner", +] + +[[package]] +name = "wayland-protocols-misc" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e9567599ef23e09b8dad6e429e5738d4509dfc46b3b21f32841a304d16b29c8" +dependencies = [ + "bitflags 2.13.1", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "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.1", + "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.1", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-scanner", +] + +[[package]] +name = "wayland-scanner" +version = "0.31.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "338e30461b3a2b67d70eb30a6d89f8e0c93a833e07d2ae89085cd070c4a00ac0" +dependencies = [ + "proc-macro2", + "quick-xml", + "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.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c435338968042f4f59a557f690a253676d47ce13ceb55d70100e7facf6620a30" +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 = "webbrowser" +version = "1.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62c35be770821a214dbc362fc26908c853e776c0004294d0b10b8a6bad582f94" +dependencies = [ + "jni 0.22.4", + "log", + "ndk-context", + "objc2 0.6.4", + "objc2-app-kit 0.3.2", + "objc2-foundation 0.3.2", + "url", + "web-sys", +] + +[[package]] +name = "webpki-root-certs" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b96554aa2acc8ccdb7e1c9a58a7a68dd5d13bccc69cd124cb09406db612a1c9b" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "webpki-roots" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "weezl" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88" + +[[package]] +name = "widestring" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72069c3113ab32ab29e5584db3c6ec55d416895e60715417b5b883a357c3e471" + +[[package]] +name = "winapi" +version = "0.3.9" +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.61.2", +] + +[[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.44.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e745dab35a0c4c77aa3ce42d595e13d2003d6902d6b08c9ef5fc326d08da12b" +dependencies = [ + "windows-targets 0.42.2", +] + +[[package]] +name = "windows" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "527fadee13e0c05939a6a05d5bd6eec6cd2e3dbd648b9f8e447c6518133d8580" +dependencies = [ + "windows-collections", + "windows-core", + "windows-future", + "windows-numerics", +] + +[[package]] +name = "windows-collections" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b2d95af1a8a14a3c7367e1ed4fc9c20e0a26e79551b1454d72583c97cc6610" +dependencies = [ + "windows-core", +] + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-future" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1d6f90251fe18a279739e78025bd6ddc52a7e22f921070ccdc67dde84c605cb" +dependencies = [ + "windows-core", + "windows-link", + "windows-threading", +] + +[[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 2.0.119", +] + +[[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 2.0.119", +] + +[[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.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e2e40844ac143cdb44aead537bbf727de9b044e107a0f1220392177d15b0f26" +dependencies = [ + "windows-core", + "windows-link", +] + +[[package]] +name = "windows-registry" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720" +dependencies = [ + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-sys" +version = "0.45.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75283be5efb2831d37ea142365f009c02ec203cd29a3ebecbc093d52315b66d0" +dependencies = [ + "windows-targets 0.42.2", +] + +[[package]] +name = "windows-sys" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" +dependencies = [ + "windows-targets 0.48.5", +] + +[[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", +] + +[[package]] +name = "windows-targets" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e5180c00cd44c9b1c88adb3693291f1cd93605ded80c250a75d472756b4d071" +dependencies = [ + "windows_aarch64_gnullvm 0.42.2", + "windows_aarch64_msvc 0.42.2", + "windows_i686_gnu 0.42.2", + "windows_i686_msvc 0.42.2", + "windows_x86_64_gnu 0.42.2", + "windows_x86_64_gnullvm 0.42.2", + "windows_x86_64_msvc 0.42.2", +] + +[[package]] +name = "windows-targets" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" +dependencies = [ + "windows_aarch64_gnullvm 0.48.5", + "windows_aarch64_msvc 0.48.5", + "windows_i686_gnu 0.48.5", + "windows_i686_msvc 0.48.5", + "windows_x86_64_gnu 0.48.5", + "windows_x86_64_gnullvm 0.48.5", + "windows_x86_64_msvc 0.48.5", +] + +[[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", + "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-threading" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3949bd5b99cafdf1c7ca86b43ca564028dfe27d66958f2470940f73d86d75b37" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" + +[[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_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f" + +[[package]] +name = "windows_i686_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" + +[[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_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060" + +[[package]] +name = "windows_i686_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" + +[[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_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" + +[[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_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "winit" +version = "0.30.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6755fa58a9f8350bd1e472d4c3fcc25f824ec358933bba33306d0b63df5978d" +dependencies = [ + "ahash", + "android-activity", + "atomic-waker", + "bitflags 2.13.1", + "block2 0.5.1", + "bytemuck", + "calloop 0.13.0", + "cfg_aliases", + "concurrent-queue", + "core-foundation 0.9.4", + "core-graphics 0.23.2", + "cursor-icon", + "dpi", + "js-sys", + "libc", + "memmap2", + "ndk", + "objc2 0.5.2", + "objc2-app-kit 0.2.2", + "objc2-foundation 0.2.2", + "objc2-ui-kit 0.2.2", + "orbclient", + "percent-encoding", + "pin-project", + "raw-window-handle", + "redox_syscall 0.4.1", + "rustix 0.38.44", + "sctk-adwaita", + "smithay-client-toolkit 0.19.2", + "smol_str 0.2.2", + "tracing", + "unicode-segmentation", + "wasm-bindgen", + "wasm-bindgen-futures", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-protocols-plasma", + "web-sys", + "web-time", + "windows-sys 0.52.0", + "x11-dl", + "x11rb", + "xkbcommon-dl", +] + +[[package]] +name = "winnow" +version = "0.5.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f593a95398737aeed53e489c785df13f3618e41dbcd6718c6addbf1395aa6876" +dependencies = [ + "memchr", +] + +[[package]] +name = "winnow" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" +dependencies = [ + "memchr", +] + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "wmi" +version = "0.18.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c81b85c57a57500e56669586496bf2abd5cf082b9d32995251185d105208b64" +dependencies = [ + "chrono", + "futures", + "log", + "serde", + "thiserror 2.0.20", + "windows 0.62.2", + "windows-core", +] + +[[package]] +name = "write-fonts" +version = "0.48.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb731d4c4d93eacc69a1ad2f270f905788a98e4a3438267bcafbe08d3431c8d8" +dependencies = [ + "font-types 0.11.3", + "indexmap", + "kurbo", + "log", + "read-fonts 0.39.2", +] + +[[package]] +name = "writeable" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" + +[[package]] +name = "ws_stream_wasm" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c173014acad22e83f16403ee360115b38846fe754e735c5d9d3803fe70c6abc" +dependencies = [ + "async_io_stream", + "futures", + "js-sys", + "log", + "pharos", + "rustc_version", + "send_wrapper", + "thiserror 2.0.20", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[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 = "x11-dl" +version = "2.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38735924fedd5314a6e548792904ed8c6de6636285cb9fec04d5b1db85c1516f" +dependencies = [ + "libc", + "once_cell", + "pkg-config", +] + +[[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", + "libloading", + "once_cell", + "rustix 1.1.4", + "x11rb-protocol", +] + +[[package]] +name = "x11rb-protocol" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea6fc2961e4ef194dcbfe56bb845534d0dc8098940c7e5c012a258bfec6701bd" + +[[package]] +name = "xattr" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156" +dependencies = [ + "libc", + "rustix 1.1.4", +] + +[[package]] +name = "xcursor" +version = "0.3.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "163b33ed8786455e2fa5d72f554057ce3f3182425434f756cd39c99839d88e23" + +[[package]] +name = "xdg-home" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec1cdab258fb55c0da61328dc52c8764709b249011b2cad0454c72f0bf10a1f6" +dependencies = [ + "libc", + "windows-sys 0.59.0", +] + +[[package]] +name = "xkbcommon" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7a974f48060a14e95705c01f24ad9c3345022f4d97441b8a36beb7ed5c4a02d" +dependencies = [ + "libc", + "memmap2", + "xkeysym", +] + +[[package]] +name = "xkbcommon-dl" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d039de8032a9a8856a6be89cea3e5d12fdd82306ab7c94d74e6deab2460651c5" +dependencies = [ + "bitflags 2.13.1", + "dlib", + "log", + "once_cell", + "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.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7" + +[[package]] +name = "xmltree" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7d8a75eaf6557bb84a65ace8609883db44a29951042ada9b393151532e41fcb" +dependencies = [ + "xml-rs", +] + +[[package]] +name = "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 = "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 2.0.119", + "synstructure", +] + +[[package]] +name = "zbus" +version = "3.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "675d170b632a6ad49804c8cf2105d7c31eddd3312555cffd4b740e08e97c25e6" +dependencies = [ + "async-broadcast 0.5.1", + "async-executor", + "async-fs", + "async-io 1.13.0", + "async-lock 2.8.0", + "async-process 1.8.1", + "async-recursion", + "async-task", + "async-trait", + "blocking", + "byteorder", + "derivative", + "enumflags2", + "event-listener 2.5.3", + "futures-core", + "futures-sink", + "futures-util", + "hex", + "nix 0.26.4", + "once_cell", + "ordered-stream", + "rand 0.8.7", + "serde", + "serde_repr", + "sha1", + "static_assertions", + "tracing", + "uds_windows", + "winapi", + "xdg-home", + "zbus_macros 3.15.2", + "zbus_names 2.6.1", + "zvariant 3.15.2", +] + +[[package]] +name = "zbus" +version = "5.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5db4be7c075cb421e4b7ee645541604239bd243ba7c357511f4ff3a74b555907" +dependencies = [ + "async-broadcast 0.7.2", + "async-executor", + "async-io 2.6.0", + "async-lock 3.4.2", + "async-process 2.5.0", + "async-recursion", + "async-task", + "async-trait", + "blocking", + "enumflags2", + "event-listener 5.4.2", + "futures-core", + "futures-lite 2.6.1", + "hex", + "libc", + "ordered-stream", + "rustix 1.1.4", + "serde", + "serde_repr", + "tracing", + "uds_windows", + "uuid", + "windows-sys 0.61.2", + "winnow 1.0.4", + "zbus_macros 5.19.0", + "zbus_names 4.3.4", + "zvariant 5.14.0", +] + +[[package]] +name = "zbus_macros" +version = "3.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7131497b0f887e8061b430c530240063d33bf9455fa34438f388a245da69e0a5" +dependencies = [ + "proc-macro-crate 1.3.1", + "proc-macro2", + "quote", + "regex", + "syn 1.0.109", + "zvariant_utils 1.0.1", +] + +[[package]] +name = "zbus_macros" +version = "5.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2990635d09ade6df1868f72f8cac69a876a90981e8bd3c40b1be413f8dc88f40" +dependencies = [ + "proc-macro-crate 3.5.0", + "proc-macro2", + "quote", + "syn 3.0.3", + "zbus_names 4.3.4", + "zvariant 5.14.0", + "zvariant_utils 4.0.0", +] + +[[package]] +name = "zbus_names" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "437d738d3750bed6ca9b8d423ccc7a8eb284f6b1d6d4e225a0e4e6258d864c8d" +dependencies = [ + "serde", + "static_assertions", + "zvariant 3.15.2", +] + +[[package]] +name = "zbus_names" +version = "4.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8bf88b4a3ff53e883001e0e0115b297a9d53c31b9c1edd2bfdd853e3428624e" +dependencies = [ + "serde", + "winnow 1.0.4", + "zvariant 5.14.0", +] + +[[package]] +name = "zcheapstr" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1afec51604565183aeb5c54c20aeab286120d4e4460f7f76e3e8bb8c0d99473" +dependencies = [ + "serde", +] + +[[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.56" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "556764e583adb45a9f8d413c2a147fa7e8d821e48e12b14fd560b607998b75eb" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.56" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2ab42fc20575779bd240faa45f94a74256f755c0fa9e89f0ede20d91d0cdfc1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[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 2.0.119", + "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 2.0.119", +] + +[[package]] +name = "zerotrie" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "zerovec" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" +dependencies = [ + "serde", + "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 2.0.119", +] + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" + +[[package]] +name = "zune-core" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d56377fd46368984a170bc5aac5567e52ca5da874caa60bea39fcbca78fb658b" + +[[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.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27bc9d5b815bc103f142aa054f561d9187d191692ec7c2d1e2b4737f8dbd7296" +dependencies = [ + "zune-core", +] + +[[package]] +name = "zvariant" +version = "3.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4eef2be88ba09b358d3b58aca6e41cd853631d44787f319a1383ca83424fb2db" +dependencies = [ + "byteorder", + "enumflags2", + "libc", + "serde", + "static_assertions", + "zvariant_derive 3.15.2", +] + +[[package]] +name = "zvariant" +version = "5.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5e28c25bd8bb8da5a1f3e7065d0c156b9ee9a7973adf78b0e35eaefdf3b1b5c" +dependencies = [ + "endi", + "enumflags2", + "serde", + "url", + "winnow 1.0.4", + "zcheapstr", + "zvariant_derive 5.14.0", + "zvariant_utils 4.0.0", +] + +[[package]] +name = "zvariant_derive" +version = "3.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37c24dc0bed72f5f90d1f8bb5b07228cbf63b3c6e9f82d82559d4bae666e7ed9" +dependencies = [ + "proc-macro-crate 1.3.1", + "proc-macro2", + "quote", + "syn 1.0.109", + "zvariant_utils 1.0.1", +] + +[[package]] +name = "zvariant_derive" +version = "5.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d496a145685283b67e232bd9e47377f6b60ad9d51e3601b23867f77c42477f96" +dependencies = [ + "proc-macro-crate 3.5.0", + "proc-macro2", + "quote", + "syn 3.0.3", + "zvariant_utils 4.0.0", +] + +[[package]] +name = "zvariant_utils" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7234f0d811589db492d16893e3f21e8e2fd282e6d01b0cddee310322062cc200" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "zvariant_utils" +version = "4.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "629d80ece222cad20fe0e8741be493c4ab166acf3b85341bdc2cdbcfd8f3c2d6" +dependencies = [ + "proc-macro2", + "quote", + "serde", + "syn 3.0.3", + "winnow 1.0.4", +] diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..1df8ac5 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,76 @@ +[workspace] +resolver = "2" +members = [ + "apps/desktop", + "crates/application", + "crates/backend", + "crates/backend-api", + "crates/domain", + "crates/platform-desktop", + "crates/ui", +] + +[workspace.package] +version = "0.1.0" +edition = "2024" +rust-version = "1.97" +license = "WTFPL" +authors = ["Furumi contributors"] +description = "A native desktop player for personal music libraries and the Furumi federated network" +readme = "README.md" + +[workspace.dependencies] +anyhow = "1.0.102" +slint = { version = "=1.17.1", default-features = false, features = [ + "backend-winit", + "renderer-femtovg", + "renderer-software", + "compat-1-2", +] } +slint-build = "=1.17.1" +tokio = { version = "=1.52.3", features = ["rt-multi-thread", "sync", "time", "macros", "io-util", "fs"] } +tokio-util = { version = "=0.7.18", features = ["rt"] } +directories = "6.0.0" +image = { version = "0.25.10", default-features = false, features = ["webp"] } +music-dht = "0.4.0" +serde_json = "1.0.150" +serde = { version = "1.0", features = ["derive"] } +rusqlite = { version = "0.32.1", features = ["bundled"] } +rfd = { version = "0.15.4", default-features = false, features = ["xdg-portal"] } +rodio = { version = "0.22.2", default-features = false, features = [ + "playback", + "mp3", + "flac", + "vorbis", + "wav", + "symphonia-aac", + "symphonia-isomp4", + "symphonia-alac", +] } +souvlaki = { version = "0.8.3", default-features = false, features = ["use_zbus"] } +core-foundation = "0.10.1" +windows-sys = { version = "0.61.2", features = ["Win32_System_LibraryLoader", "Win32_UI_WindowsAndMessaging"] } + +furumi-application = { path = "crates/application" } +furumi-backend = { path = "crates/backend" } +furumi-backend-api = { path = "crates/backend-api" } +furumi-domain = { path = "crates/domain" } +furumi-library = "0.1.0" +furumi-platform-desktop = { path = "crates/platform-desktop" } +furumi-ui = { path = "crates/ui" } + +[workspace.lints.rust] +unsafe_code = "deny" + +[workspace.lints.clippy] +all = "warn" +pedantic = "warn" + +[profile.release] +lto = "thin" +codegen-units = 1 +strip = "symbols" + +[patch.crates-io] +# souvlaki 0.8.3 still uses an uninhabited Objective-C FFI marker. +block = { path = "vendor/block" } diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..00cc4a3 --- /dev/null +++ b/LICENSE @@ -0,0 +1,14 @@ + DO WHAT THE FUCK YOU WANT TO PUBLIC LICENSE + Version 2, December 2004 + + Copyright (C) 2026 Furumi contributors + + Everyone is permitted to copy and distribute verbatim or modified + copies of this license document, and changing it is allowed as long + as the name is changed. + + DO WHAT THE FUCK YOU WANT TO PUBLIC LICENSE + TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION + + 0. You just DO WHAT THE FUCK YOU WANT TO. + diff --git a/README.md b/README.md new file mode 100644 index 0000000..c58a33b --- /dev/null +++ b/README.md @@ -0,0 +1,92 @@ +# Furumi Desktop + +![Furumi Desktop](screenshots/furumi-desktop.png) + +**Your music. Your devices. Your network.** + +Furumi Desktop is a native graphical player for a personal music collection +and the Furumi federated network. It plays the library you keep on your own +computer, discovers music shared by other Furumi players, and streams missing +tracks directly from peers without requiring an account or a central catalog. + +Pair your own players and they become one listening environment. Likes, +playlists, the queue, and playback state can move between trusted devices while +each player remains useful on its own. + +Furumi Desktop targets **Linux, macOS, and Windows**. + +## Why Furumi? + +Music you own should not depend on a subscription, a remote account, or one +company's servers. Furumi is built around a different model: + +- your local library stays under your control; +- the player works without a cloud backend; +- federation is optional and has no central search service; +- trusted devices synchronize directly with each other; +- missing music can be streamed from an available peer and retained locally. + +A single player is a private desktop library. Several players form a resilient +personal music network. + +## The player + +The desktop interface is focused entirely on listening: artists, releases, +search, playlists, a reusable queue, and familiar playback controls. Local and +federated results share the same views, with clear availability indicators as +remote tracks arrive or become local. + +Furumi Desktop also supports OS media keys, background playback, cover art, +trusted-device pairing, playback handoff, and control of another active Furumi +device. It uses the same library format and federation protocols as the Furumi +terminal player. + +## Build and run + +Rust 1.97 is pinned by `rust-toolchain.toml`. + +```bash +cargo build --release --locked +./target/release/furumi-desktop +``` + +On Debian or Ubuntu, install the native Linux build dependencies first: + +```bash +sudo apt install libasound2-dev libfontconfig1-dev libxkbcommon-dev pkg-config +``` + +A development shell with the Wayland and X11 dependencies is also provided: + +```bash +nix-shell +cargo run --bin furumi-desktop +``` + +macOS and Windows require no additional system packages. Player settings are +available inside the application and are saved automatically. + +## Architecture + +Furumi Desktop is written in Rust with a reactive Slint interface, SQLite +persistence, Rodio audio playback, and the Frid/Furumi P2P protocol crates. It +ships as one application while keeping domain, application, backend, platform, +and presentation code in separate crates. + +More detail is available in [ARCHITECTURE.md](ARCHITECTURE.md). + +## Contributing + +Bug reports, design discussions, and patches are welcome. Before submitting a +change, run: + +```bash +cargo fmt --all -- --check +cargo clippy --workspace --all-targets -- -D warnings +cargo test --workspace --all-targets +``` + +## License + +Furumi Desktop is released under the +[Do What The Fuck You Want To Public License, Version 2](LICENSE). diff --git a/apps/desktop/Cargo.toml b/apps/desktop/Cargo.toml new file mode 100644 index 0000000..da4c4bf --- /dev/null +++ b/apps/desktop/Cargo.toml @@ -0,0 +1,21 @@ +[package] +name = "furumi-desktop" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +authors.workspace = true +description.workspace = true +readme.workspace = true +publish = false + +[[bin]] +name = "furumi-desktop" +path = "src/main.rs" + +[dependencies] +furumi-backend.workspace = true +furumi-ui.workspace = true + +[lints] +workspace = true diff --git a/apps/desktop/src/main.rs b/apps/desktop/src/main.rs new file mode 100644 index 0000000..7b161a7 --- /dev/null +++ b/apps/desktop/src/main.rs @@ -0,0 +1,5 @@ +fn main() -> Result<(), Box> { + let backend = furumi_backend::spawn_backend()?; + furumi_ui::run(&backend)?; + Ok(()) +} diff --git a/crates/application/Cargo.toml b/crates/application/Cargo.toml new file mode 100644 index 0000000..e30193c --- /dev/null +++ b/crates/application/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "furumi-application" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true + +[dependencies] +furumi-backend-api.workspace = true +furumi-domain.workspace = true + +[lints] +workspace = true + diff --git a/crates/application/src/lib.rs b/crates/application/src/lib.rs new file mode 100644 index 0000000..58f061a --- /dev/null +++ b/crates/application/src/lib.rs @@ -0,0 +1,613 @@ +//! Deterministic frontend state, reducers and UI projections. + +use furumi_backend_api::{BackendCommand, BackendSnapshot, PlaybackStatus, RequestId}; +use furumi_domain::{ArtistKey, QueueItemId, ReleaseKey, TrackKey}; +use std::time::Duration; + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub enum Locale { + #[default] + En, +} + +#[derive(Debug, Clone, Copy)] +pub struct Strings { + pub app_name: &'static str, + pub home: &'static str, + pub search: &'static str, + pub library: &'static str, + pub queue: &'static str, + pub recently_played: &'static str, + pub made_for_listening: &'static str, + pub empty_queue: &'static str, + pub search_placeholder: &'static str, +} + +pub const EN: Strings = Strings { + app_name: "Furumi", + home: "Home", + search: "Search", + library: "Your library", + queue: "Queue", + recently_played: "Recently played", + made_for_listening: "Made for listening", + empty_queue: "Your queue is empty", + search_placeholder: "Artists, albums or tracks", +}; + +impl Locale { + #[must_use] + pub const fn strings(self) -> &'static Strings { + match self { + Self::En => &EN, + } + } +} + +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub enum Screen { + #[default] + Home, + Search, + Library, + Artist(ArtistKey), + Release(ReleaseKey, Option), + Playlist(i64), +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct FrontendState { + pub screen: Screen, + pub navigation_history: Vec, + pub navigation_forward: Vec, + pub queue_open: bool, + pub settings_open: bool, + pub devices_open: bool, + pub search_query: String, + pub locale: Locale, + pub track_info: Option, + pub playlist_picker_track: Option, +} + +impl Default for FrontendState { + fn default() -> Self { + Self { + screen: Screen::Home, + navigation_history: Vec::new(), + navigation_forward: Vec::new(), + queue_open: false, + settings_open: false, + devices_open: false, + search_query: String::new(), + locale: Locale::En, + track_info: None, + playlist_picker_track: None, + } + } +} + +#[derive(Debug, Clone, Default, PartialEq)] +pub struct AppState { + pub frontend: FrontendState, + pub backend: BackendSnapshot, + pub next_request_id: u64, + pub transient_error: Option, +} + +#[derive(Debug, Clone, PartialEq)] +pub enum UiAction { + Navigate(Screen), + Back, + Forward, + ToggleQueue, + ToggleSettings, + ToggleDevices, + TogglePlayback, + Next, + Previous, + Seek(f64), + SetVolume(f32), + PlayRelease { + release_id: ReleaseKey, + start: usize, + }, + PlayTrack(TrackKey), + PlayQueueItem(QueueItemId), + PlayContext { + tracks: Vec, + selected: TrackKey, + }, + AddNext(Vec), + AddToEnd(Vec), + ToggleLike(TrackKey), + ShowPlaylistPicker(TrackKey), + ClosePlaylistPicker, + CreatePlaylist(String), + AddToPlaylist { + playlist_id: i64, + tracks: Vec, + }, + CreateDeviceInvite, + ConnectDevice(String), + AnswerDevicePairing { + request_id: String, + accept: bool, + use_requester_group: bool, + }, + SelectPlaybackDevice(String), + SearchChanged(String), + NetworkIdChanged(String), + DeviceNameChanged(String), + LibraryPathChanged(String), + FederationChanged(bool), + SaveFederatedOnListenChanged(bool), + LanguageChanged(String), + ShowTrackInfo(TrackKey), + CloseTrackInfo, + DismissError, +} + +#[derive(Debug, Clone, PartialEq)] +pub enum AppEvent { + BackendSnapshot(Box), + CommandRejected(String), +} + +#[derive(Debug, Clone, PartialEq)] +pub enum Effect { + Send(BackendCommand), +} + +#[allow( + clippy::too_many_lines, + reason = "central exhaustive reducer keeps state transitions together" +)] +pub fn reduce_action(state: &mut AppState, action: UiAction) -> Vec { + match action { + UiAction::Navigate(screen) => { + if state.frontend.screen != screen { + state + .frontend + .navigation_history + .push(state.frontend.screen.clone()); + state.frontend.navigation_forward.clear(); + } + state.frontend.screen = screen.clone(); + state.next_request_id = state.next_request_id.saturating_add(1); + let request_id = RequestId::new(state.next_request_id); + match screen { + Screen::Artist(key) => find_artist(state, &key).map_or_else(Vec::new, |artist| { + send(BackendCommand::LoadArtist { + request_id, + key, + name: artist.name.clone(), + }) + }), + Screen::Release(key, preferred) => { + find_release(state, &key).map_or_else(Vec::new, |release| { + let selected_artist = preferred + .as_ref() + .and_then(|preferred| { + release + .artists + .iter() + .find(|artist| &artist.key == preferred) + .map(|artist| (artist.key.clone(), artist.name.clone())) + .or_else(|| { + find_artist(state, preferred) + .map(|artist| (artist.key.clone(), artist.name.clone())) + }) + }) + .or_else(|| { + release + .artists + .first() + .map(|artist| (artist.key.clone(), artist.name.clone())) + }); + selected_artist.map_or_else(Vec::new, |(artist_key, artist_name)| { + send(BackendCommand::LoadRelease { + request_id, + key, + artist_key, + artist_name, + }) + }) + }) + } + _ => Vec::new(), + } + } + UiAction::Back => { + if let Some(screen) = state.frontend.navigation_history.pop() { + state + .frontend + .navigation_forward + .push(state.frontend.screen.clone()); + state.frontend.screen = screen; + } + Vec::new() + } + UiAction::Forward => { + if let Some(screen) = state.frontend.navigation_forward.pop() { + state + .frontend + .navigation_history + .push(state.frontend.screen.clone()); + state.frontend.screen = screen; + } + Vec::new() + } + UiAction::ToggleQueue => { + state.frontend.queue_open = !state.frontend.queue_open; + Vec::new() + } + UiAction::ToggleSettings => { + state.frontend.settings_open = !state.frontend.settings_open; + if state.frontend.settings_open { + state.frontend.devices_open = false; + } + Vec::new() + } + UiAction::ToggleDevices => { + state.frontend.devices_open = !state.frontend.devices_open; + if state.frontend.devices_open { + state.frontend.settings_open = false; + } + Vec::new() + } + UiAction::TogglePlayback => send(BackendCommand::TogglePlayback), + UiAction::Next => send(BackendCommand::Next), + UiAction::Previous => send(BackendCommand::Previous), + UiAction::Seek(position_seconds) => send(BackendCommand::Seek { position_seconds }), + UiAction::SetVolume(volume) => send(BackendCommand::SetVolume { volume }), + UiAction::PlayRelease { release_id, start } => { + send(BackendCommand::PlayRelease { release_id, start }) + } + UiAction::PlayTrack(track) => send(BackendCommand::PlayTrack { track }), + UiAction::PlayQueueItem(item_id) => send(BackendCommand::PlayQueueItem { item_id }), + UiAction::PlayContext { tracks, selected } => { + send(BackendCommand::PlayContext { tracks, selected }) + } + UiAction::AddNext(tracks) => send(BackendCommand::AddNext { tracks }), + UiAction::AddToEnd(tracks) => send(BackendCommand::AddToEnd { tracks }), + UiAction::ToggleLike(track) => send(BackendCommand::ToggleLike { track }), + UiAction::ShowPlaylistPicker(track) => { + state.frontend.playlist_picker_track = Some(track); + Vec::new() + } + UiAction::ClosePlaylistPicker => { + state.frontend.playlist_picker_track = None; + Vec::new() + } + UiAction::CreatePlaylist(title) => send(BackendCommand::CreatePlaylist { title }), + UiAction::AddToPlaylist { + playlist_id, + tracks, + } => { + state.frontend.playlist_picker_track = None; + send(BackendCommand::AddToPlaylist { + playlist_id, + tracks, + }) + } + UiAction::CreateDeviceInvite => send(BackendCommand::CreateDeviceInvite), + UiAction::ConnectDevice(invite) => send(BackendCommand::ConnectDevice { invite }), + UiAction::AnswerDevicePairing { + request_id, + accept, + use_requester_group, + } => send(BackendCommand::AnswerDevicePairing { + request_id, + accept, + use_requester_group, + }), + UiAction::SelectPlaybackDevice(device_id) => { + send(BackendCommand::SelectPlaybackDevice { device_id }) + } + UiAction::SearchChanged(query) => { + let previous = active_search_request(&state.backend); + state.frontend.search_query.clone_from(&query); + if state.frontend.screen != Screen::Search { + state + .frontend + .navigation_history + .push(state.frontend.screen.clone()); + state.frontend.navigation_forward.clear(); + state.frontend.screen = Screen::Search; + } + state.next_request_id = state.next_request_id.saturating_add(1); + let request_id = RequestId::new(state.next_request_id); + let mut effects = Vec::with_capacity(2); + if let Some(previous) = previous { + effects.push(Effect::Send(BackendCommand::CancelSearch { + request_id: previous, + })); + } + effects.push(Effect::Send(BackendCommand::Search { request_id, query })); + effects + } + UiAction::NetworkIdChanged(value) => { + state.backend.settings.network_id = value; + send(BackendCommand::UpdateSettings( + state.backend.settings.clone(), + )) + } + UiAction::DeviceNameChanged(value) => { + state.backend.settings.device_name = value; + send(BackendCommand::UpdateSettings( + state.backend.settings.clone(), + )) + } + UiAction::LibraryPathChanged(value) => { + state.backend.settings.library_path = value; + send(BackendCommand::UpdateSettings( + state.backend.settings.clone(), + )) + } + UiAction::FederationChanged(enabled) => { + state.backend.settings.federation_enabled = enabled; + send(BackendCommand::UpdateSettings( + state.backend.settings.clone(), + )) + } + UiAction::SaveFederatedOnListenChanged(enabled) => { + state.backend.settings.save_federated_on_listen = enabled; + send(BackendCommand::UpdateSettings( + state.backend.settings.clone(), + )) + } + UiAction::LanguageChanged(language) => { + state.frontend.locale = Locale::En; + state.backend.settings.language = language; + send(BackendCommand::UpdateSettings( + state.backend.settings.clone(), + )) + } + UiAction::ShowTrackInfo(track) => { + state.frontend.track_info = Some(track); + Vec::new() + } + UiAction::CloseTrackInfo => { + state.frontend.track_info = None; + Vec::new() + } + UiAction::DismissError => { + state.transient_error = None; + Vec::new() + } + } +} + +fn find_artist<'a>(state: &'a AppState, key: &ArtistKey) -> Option<&'a furumi_domain::Artist> { + match &state.backend.library { + furumi_backend_api::RemoteData::Ready(l) => Some(l), + _ => None, + } + .into_iter() + .flat_map(|l| l.artists.iter()) + .chain(state.backend.search.results.artists.iter()) + .find(|a| &a.key == key) +} + +fn find_release<'a>(state: &'a AppState, key: &ReleaseKey) -> Option<&'a furumi_domain::Release> { + match &state.backend.library { + furumi_backend_api::RemoteData::Ready(l) => Some(l), + _ => None, + } + .into_iter() + .flat_map(|l| l.featured_releases.iter()) + .chain(state.backend.search.results.releases.iter()) + .find(|r| &r.key == key) +} + +pub fn reduce_event(state: &mut AppState, event: AppEvent) { + match event { + AppEvent::BackendSnapshot(snapshot) => { + if snapshot.revision >= state.backend.revision { + state.backend = *snapshot; + } + } + AppEvent::CommandRejected(message) => state.transient_error = Some(message), + } +} + +fn active_search_request(snapshot: &BackendSnapshot) -> Option { + snapshot + .search + .federation_pending + .then_some(snapshot.search.request_id) + .flatten() +} + +fn send(command: BackendCommand) -> Vec { + vec![Effect::Send(command)] +} + +#[derive(Debug, Clone, PartialEq)] +pub struct PlayerProjection { + pub has_track: bool, + pub playing: bool, + pub title: String, + pub artist: String, + pub elapsed: String, + pub duration: String, + pub progress: f32, + pub volume: f32, +} + +impl AppState { + #[must_use] + pub fn player_projection(&self) -> PlayerProjection { + let current = self.backend.queue.current().map(|item| &item.track); + let duration = self.backend.playback.duration_seconds.max(0.0); + let elapsed = self.backend.playback.position_seconds.clamp(0.0, duration); + PlayerProjection { + has_track: current.is_some(), + playing: self.backend.playback.status == PlaybackStatus::Playing, + title: current.map_or_else(|| "Nothing playing".into(), |track| track.title.clone()), + artist: current.map_or_else(String::new, |track| track.artist.clone()), + elapsed: duration_label(elapsed), + duration: duration_label(duration), + progress: progress_ratio(elapsed, duration), + volume: self.backend.playback.volume, + } + } +} + +#[must_use] +pub fn duration_label(seconds: f64) -> String { + let seconds = if seconds.is_finite() { + seconds.max(0.0) + } else { + 0.0 + }; + let total = Duration::from_secs_f64(seconds).as_secs(); + format!("{}:{:02}", total / 60, total % 60) +} + +#[allow( + clippy::cast_possible_truncation, + reason = "the clamped unit interval is the precision exposed by Slint" +)] +fn progress_ratio(elapsed: f64, duration: f64) -> f32 { + if duration > 0.0 { + (elapsed / duration).clamp(0.0, 1.0) as f32 + } else { + 0.0 + } +} + +#[cfg(test)] +mod tests { + use super::*; + use furumi_backend_api::{SearchResults, SearchSnapshot}; + use furumi_domain::{Artist, ArtistRef, Artwork, CatalogSource, Release, ReleaseId}; + + #[test] + fn a_new_search_cancels_the_active_request() { + let mut state = AppState::default(); + state.backend.search = SearchSnapshot { + request_id: Some(RequestId::new(9)), + federation_pending: true, + ..SearchSnapshot::default() + }; + + let effects = reduce_action(&mut state, UiAction::SearchChanged("ambient".into())); + assert_eq!(effects.len(), 2); + assert_eq!( + effects[0], + Effect::Send(BackendCommand::CancelSearch { + request_id: RequestId::new(9) + }) + ); + assert!(matches!( + effects[1], + Effect::Send(BackendCommand::Search { .. }) + )); + } + + #[test] + fn stale_backend_snapshots_are_ignored() { + let mut state = AppState::default(); + state.backend.revision = 8; + reduce_event( + &mut state, + AppEvent::BackendSnapshot(Box::new(BackendSnapshot { + revision: 7, + ..BackendSnapshot::default() + })), + ); + assert_eq!(state.backend.revision, 8); + } + + #[test] + fn detail_navigation_returns_to_the_previous_screen() { + let mut state = AppState::default(); + let artist = ArtistKey::Federation { + peer_id: "peer-a".into(), + id: "artist-a".into(), + }; + + reduce_action(&mut state, UiAction::Navigate(Screen::Artist(artist))); + reduce_action(&mut state, UiAction::Back); + + assert_eq!(state.frontend.screen, Screen::Home); + assert!(state.frontend.navigation_history.is_empty()); + } + + #[test] + fn forward_navigation_restores_the_screen_left_by_back() { + let mut state = AppState::default(); + let artist = ArtistKey::Federation { + peer_id: "peer-a".into(), + id: "artist-a".into(), + }; + let artist_screen = Screen::Artist(artist); + + reduce_action(&mut state, UiAction::Navigate(artist_screen.clone())); + reduce_action(&mut state, UiAction::Back); + reduce_action(&mut state, UiAction::Forward); + + assert_eq!(state.frontend.screen, artist_screen); + assert!(state.frontend.navigation_forward.is_empty()); + assert_eq!(state.frontend.navigation_history, vec![Screen::Home]); + } + + #[test] + fn release_lookup_keeps_the_preferred_artist_name_and_key_together() { + let mut state = AppState::default(); + let pasha_key = ArtistKey::Local(furumi_domain::ArtistId::new(1)); + let kunteynir_key = ArtistKey::Local(furumi_domain::ArtistId::new(2)); + let release_key = ReleaseKey::Local(ReleaseId::new(7)); + let artists = [ + (kunteynir_key.clone(), "KUNTEYNIR"), + (pasha_key.clone(), "Паша Техник"), + ]; + state.backend.search.results = SearchResults { + artists: artists + .iter() + .map(|(key, name)| Artist { + key: key.clone(), + source: CatalogSource::Local, + name: (*name).into(), + artwork: Artwork::default(), + release_count: 1, + track_count: 1, + }) + .collect(), + releases: vec![Release { + key: release_key.clone(), + source: CatalogSource::Local, + title: "Порядочный".into(), + artists: artists + .iter() + .map(|(key, name)| ArtistRef { + key: key.clone(), + name: (*name).into(), + }) + .collect(), + featured_artists: Vec::new(), + release_type: "album".into(), + year: None, + artwork: Artwork::default(), + tracks: Vec::new(), + }], + tracks: Vec::new(), + }; + + let effects = reduce_action( + &mut state, + UiAction::Navigate(Screen::Release(release_key, Some(pasha_key.clone()))), + ); + + assert_eq!( + effects, + vec![Effect::Send(BackendCommand::LoadRelease { + request_id: RequestId::new(1), + key: ReleaseKey::Local(ReleaseId::new(7)), + artist_key: pasha_key, + artist_name: "Паша Техник".into(), + })] + ); + } +} diff --git a/crates/backend-api/Cargo.toml b/crates/backend-api/Cargo.toml new file mode 100644 index 0000000..d8ed4b7 --- /dev/null +++ b/crates/backend-api/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "furumi-backend-api" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true + +[dependencies] +furumi-domain.workspace = true + +[lints] +workspace = true + diff --git a/crates/backend-api/src/lib.rs b/crates/backend-api/src/lib.rs new file mode 100644 index 0000000..b663939 --- /dev/null +++ b/crates/backend-api/src/lib.rs @@ -0,0 +1,346 @@ +//! Transport-independent contract between the frontend application and backend. + +use std::fmt; + +use furumi_domain::{Artist, Queue, QueueItemId, Release, ReleaseKey, Track, TrackKey}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub struct RequestId(u64); + +impl RequestId { + #[must_use] + pub const fn new(value: u64) -> Self { + Self(value) + } + + #[must_use] + pub const fn get(self) -> u64 { + self.0 + } +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub enum PlaybackStatus { + #[default] + Stopped, + Playing, + Paused, +} + +#[derive(Debug, Clone, PartialEq)] +pub struct PlaybackSnapshot { + pub status: PlaybackStatus, + pub position_seconds: f64, + pub duration_seconds: f64, + pub volume: f32, +} + +impl Default for PlaybackSnapshot { + fn default() -> Self { + Self { + status: PlaybackStatus::Stopped, + position_seconds: 0.0, + duration_seconds: 0.0, + volume: 0.72, + } + } +} + +#[derive(Debug, Clone, Default, PartialEq)] +pub enum RemoteData { + #[default] + NotRequested, + Loading { + request_id: RequestId, + }, + Ready(T), + Failed { + message: String, + }, +} + +#[derive(Debug, Clone, Default, PartialEq)] +pub struct LibrarySnapshot { + pub artists: Vec, + pub featured_releases: Vec, + pub recently_played: Vec, + pub playlists: Vec, +} + +#[derive(Debug, Clone, Default, PartialEq)] +pub struct PlaylistSnapshot { + pub id: i64, + pub title: String, + pub is_likes: bool, + pub tracks: Vec, +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub enum DevicePlaybackRole { + #[default] + Active, + Control, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct ConnectedDeviceSnapshot { + pub id: String, + pub name: String, + pub client_version: String, + pub is_self: bool, + pub presence: DevicePresence, + pub trust: DeviceTrust, + pub is_active: bool, +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub enum DevicePresence { + #[default] + Offline, + Online, +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub enum DeviceTrust { + #[default] + Trusted, + Revoked, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct PendingPairingSnapshot { + pub request_id: String, + pub device_id: String, + pub name: String, + pub client_version: String, + pub requester_group_id: Option, + pub requester_group_active_devices: usize, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct ConnectedDevicesSnapshot { + pub this_device_id: String, + pub this_device_name: String, + pub group_id: String, + pub role: DevicePlaybackRole, + pub active_device_id: String, + pub active_device_name: String, + pub devices: Vec, + pub pending_pairings: Vec, + pub invite: Option, + pub busy: bool, + pub last_sync: Option, + pub error: Option, +} + +#[derive(Debug, Clone, Default, PartialEq)] +pub struct SearchResults { + pub artists: Vec, + pub releases: Vec, + pub tracks: Vec, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct SearchStats { + pub tracks: usize, + pub artists: usize, + pub peers_queried: usize, + pub duration_ms: u64, +} + +#[derive(Debug, Clone, Default, PartialEq)] +pub struct SearchSnapshot { + pub request_id: Option, + pub results: SearchResults, + pub federation_pending: bool, + pub stats: Option, + pub error: Option, +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub enum FederationOperation { + #[default] + Idle, + Search, + Artist, + Release, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct FederationActivitySnapshot { + pub operation: FederationOperation, + pub pending: bool, + pub stats: Option, + pub error: Option, +} + +/// Lightweight live diagnostics for the federation node. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct FederationDebugSnapshot { + pub running: bool, + pub endpoint_id: String, + pub dht_node_id: String, + pub connected_peers: usize, + pub known_contacts: usize, + pub stored_dht_records: Option, + pub published_items: usize, + pub error: Option, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct VersionEntrySnapshot { + pub name: String, + pub version: String, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct BuildInfoSnapshot { + pub software: Vec, + pub protocols: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SettingsSnapshot { + pub network_id: String, + pub device_name: String, + pub library_path: String, + pub federation_enabled: bool, + pub save_federated_on_listen: bool, + pub language: String, +} + +impl Default for SettingsSnapshot { + fn default() -> Self { + Self { + network_id: "furumi".into(), + device_name: String::new(), + library_path: "~/Music/Furumi".into(), + federation_enabled: true, + save_federated_on_listen: true, + language: "English".into(), + } + } +} + +#[derive(Debug, Clone, Default, PartialEq)] +pub struct BackendSnapshot { + pub revision: u64, + pub library: RemoteData, + pub playback: PlaybackSnapshot, + pub queue: Queue, + pub search: SearchSnapshot, + pub federation_activity: FederationActivitySnapshot, + pub federation_debug: FederationDebugSnapshot, + pub build_info: BuildInfoSnapshot, + pub connected_devices: ConnectedDevicesSnapshot, + pub settings: SettingsSnapshot, + pub playback_error: Option, + pub settings_error: Option, +} + +#[derive(Debug, Clone, PartialEq)] +pub enum BackendCommand { + Initialize, + TogglePlayback, + Play, + Pause, + Stop, + Seek { + position_seconds: f64, + }, + SetVolume { + volume: f32, + }, + PlayRelease { + release_id: ReleaseKey, + start: usize, + }, + PlayTrack { + track: TrackKey, + }, + PlayQueueItem { + item_id: QueueItemId, + }, + PlayContext { + tracks: Vec, + selected: TrackKey, + }, + AddNext { + tracks: Vec, + }, + AddToEnd { + tracks: Vec, + }, + ToggleLike { + track: TrackKey, + }, + CreatePlaylist { + title: String, + }, + RenamePlaylist { + playlist_id: i64, + title: String, + }, + DeletePlaylist { + playlist_id: i64, + }, + AddToPlaylist { + playlist_id: i64, + tracks: Vec, + }, + RemoveFromPlaylist { + playlist_id: i64, + tracks: Vec, + }, + CreateDeviceInvite, + ConnectDevice { + invite: String, + }, + AnswerDevicePairing { + request_id: String, + accept: bool, + use_requester_group: bool, + }, + SelectPlaybackDevice { + device_id: String, + }, + Next, + Previous, + Search { + request_id: RequestId, + query: String, + }, + CancelSearch { + request_id: RequestId, + }, + LoadArtist { + request_id: RequestId, + key: furumi_domain::ArtistKey, + name: String, + }, + LoadRelease { + request_id: RequestId, + key: ReleaseKey, + artist_key: furumi_domain::ArtistKey, + artist_name: String, + }, + UpdateSettings(SettingsSnapshot), + Shutdown, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum SendCommandError { + Busy, + Closed, +} + +impl fmt::Display for SendCommandError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Busy => formatter.write_str("backend command queue is full"), + Self::Closed => formatter.write_str("backend is unavailable"), + } + } +} + +impl std::error::Error for SendCommandError {} diff --git a/crates/backend/Cargo.toml b/crates/backend/Cargo.toml new file mode 100644 index 0000000..62de2b6 --- /dev/null +++ b/crates/backend/Cargo.toml @@ -0,0 +1,23 @@ +[package] +name = "furumi-backend" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true + +[dependencies] +anyhow.workspace = true +furumi-backend-api.workspace = true +furumi-domain.workspace = true +furumi-library.workspace = true +music-dht.workspace = true +directories.workspace = true +rusqlite.workspace = true +rodio.workspace = true +tokio.workspace = true +tokio-util.workspace = true +serde_json.workspace = true +serde.workspace = true + +[lints] +workspace = true diff --git a/crates/backend/build.rs b/crates/backend/build.rs new file mode 100644 index 0000000..bb03578 --- /dev/null +++ b/crates/backend/build.rs @@ -0,0 +1,33 @@ +use std::path::PathBuf; + +fn main() { + let manifest_dir = PathBuf::from(std::env::var_os("CARGO_MANIFEST_DIR").unwrap()); + let lock_path = manifest_dir.join("../../Cargo.lock"); + println!("cargo:rerun-if-changed={}", lock_path.display()); + let lock = std::fs::read_to_string(&lock_path).unwrap_or_default(); + for (package, variable) in [ + ("furumi-library", "FURUMI_LIBRARY_VERSION"), + ("music-dht", "FURUMI_MUSIC_DHT_VERSION"), + ("federation-net", "FURUMI_FEDERATION_NET_VERSION"), + ] { + println!( + "cargo:rustc-env={variable}={}", + package_version(&lock, package).unwrap_or("unknown") + ); + } +} + +fn package_version<'a>(lock: &'a str, package: &str) -> Option<&'a str> { + lock.split("[[package]]").find_map(|section| { + let mut name = None; + let mut version = None; + for line in section.lines().map(str::trim) { + if let Some(value) = line.strip_prefix("name = \"") { + name = value.strip_suffix('"'); + } else if let Some(value) = line.strip_prefix("version = \"") { + version = value.strip_suffix('"'); + } + } + (name == Some(package)).then_some(version).flatten() + }) +} diff --git a/crates/backend/src/actor_devices.rs b/crates/backend/src/actor_devices.rs new file mode 100644 index 0000000..3d1be2f --- /dev/null +++ b/crates/backend/src/actor_devices.rs @@ -0,0 +1,481 @@ +use super::{ + Actor, ArtistKey, ArtistRef, AudioSource, ConnectedDeviceSnapshot, ConnectedDevicesSnapshot, + ContentId, ControlPlaybackAnchor, DeviceOperationResult, DevicePlaybackRole, DevicePresence, + DeviceTrust, Duration, Instant, InternalEvent, PendingControlState, PendingPairingSnapshot, + PlaybackStatus, ReleaseKey, Track, TrackKey, library_track, normalize_device_name, + portable_playback_placeholder, track_to_playback_track, unix_time_ms, volume_percent, +}; + +impl Actor { + pub(super) fn create_device_invite(&mut self) { + let Some(service) = self.device_service.clone() else { + self.state.connected_devices.error = + Some("Federation network is still starting".into()); + self.publish(); + return; + }; + self.state.connected_devices.busy = true; + self.state.connected_devices.error = None; + self.publish(); + let devices = std::sync::Arc::clone(&self.devices); + let internal = self.internal.clone(); + tokio::spawn(async move { + let result = devices + .create_invite(service) + .await + .map(DeviceOperationResult::Invite) + .map_err(|error| format!("{error:#}")); + let _ = internal + .send(InternalEvent::DeviceOperationFinished(result)) + .await; + }); + } + + pub(super) fn connect_device(&mut self, invite: String) { + let Some(service) = self.device_service.clone() else { + self.state.connected_devices.error = + Some("Federation network is still starting".into()); + self.publish(); + return; + }; + self.state.connected_devices.busy = true; + self.state.connected_devices.error = None; + self.publish(); + let devices = std::sync::Arc::clone(&self.devices); + let internal = self.internal.clone(); + tokio::spawn(async move { + let result = devices + .connect_invite(service, &invite) + .await + .map(DeviceOperationResult::Connected) + .map_err(|error| format!("{error:#}")); + let _ = internal + .send(InternalEvent::DeviceOperationFinished(result)) + .await; + }); + } + + pub(super) fn apply_local_device_name(&mut self, name: &str) { + let name = normalize_device_name(name); + if let Err(error) = self.devices.set_device_name(&name, None) { + self.state.connected_devices.error = Some(format!("device name: {error:#}")); + return; + } + if self.active_device_id == self.state.connected_devices.this_device_id { + self.active_device_name = name; + } + self.refresh_connected_devices(); + } + + pub(super) fn schedule_device_name_publish(&self, candidate: String) { + let internal = self.internal.clone(); + tokio::spawn(async move { + tokio::time::sleep(Duration::from_millis(500)).await; + let _ = internal + .send(InternalEvent::DeviceNamePublishDue(candidate)) + .await; + }); + } + + pub(super) fn publish_device_name(&self, name: String) { + let Some(service) = self.device_service.clone() else { + return; + }; + let devices = std::sync::Arc::clone(&self.devices); + let internal = self.internal.clone(); + tokio::spawn(async move { + let result = async { + let ticket = service + .ticket() + .await + .map_err(|error| format!("device name: {error:#}"))?; + devices + .set_device_name(&name, Some(&ticket.to_string())) + .map_err(|error| format!("device name: {error:#}")) + } + .await; + let _ = internal + .send(InternalEvent::DeviceNamePublished(result)) + .await; + }); + } + + pub(super) fn refresh_connected_devices(&mut self) { + let status = self.devices.status(); + let invite = self.state.connected_devices.invite.take(); + let busy = self.state.connected_devices.busy; + let existing_error = self.state.connected_devices.error.take(); + self.state.connected_devices = ConnectedDevicesSnapshot { + this_device_id: status.this_device_id, + this_device_name: status.this_device_name, + group_id: status.group_id, + role: self.device_role, + active_device_id: self.active_device_id.clone(), + active_device_name: self.active_device_name.clone(), + devices: status + .devices + .into_iter() + .filter(|device| !device.revoked) + .map(|device| ConnectedDeviceSnapshot { + is_active: device.id == self.active_device_id, + id: device.id, + name: device.name, + client_version: device.client_version, + is_self: device.is_self, + presence: if device.online { + DevicePresence::Online + } else { + DevicePresence::Offline + }, + trust: if device.revoked { + DeviceTrust::Revoked + } else { + DeviceTrust::Trusted + }, + }) + .collect(), + pending_pairings: status + .pending + .into_iter() + .map(|pending| PendingPairingSnapshot { + request_id: pending.request_id, + device_id: pending.device_id, + name: pending.name, + client_version: pending.client_version, + requester_group_id: pending.requester_group_id, + requester_group_active_devices: pending.requester_group_active_devices, + }) + .collect(), + invite, + busy, + last_sync: status.last_sync, + error: existing_error.or(status.error), + }; + } + + pub(super) fn select_playback_device(&mut self, device_id: &str) { + // Selecting the device that already owns playback must not rebuild or + // restart the current audio stream. + if device_id == self.active_device_id { + return; + } + let target = self + .state + .connected_devices + .devices + .iter() + .find(|device| device.id == device_id && device.trust == DeviceTrust::Trusted) + .cloned(); + let Some(target) = target else { + return; + }; + let wire = self.playback_wire_state(); + let previous = self.active_device_id.clone(); + let mut urgent_targets = Vec::with_capacity(2); + if target.is_self { + if previous != target.id { + let command = music_dht::device_sync::PlaybackCommand::ActiveChanged { + active_device_id: target.id.clone(), + active_device_name: target.name.clone(), + state: wire.clone(), + }; + if let Err(error) = self.devices.record_playback_command(&previous, command) { + self.state.connected_devices.error = Some(format!("device handoff: {error:#}")); + self.publish(); + return; + } + urgent_targets.push(previous.clone()); + } + self.device_role = DevicePlaybackRole::Active; + self.active_device_id = target.id; + self.active_device_name = target.name; + self.control_anchor = None; + self.pending_control = None; + if self.state.playback.status == PlaybackStatus::Playing { + self.play_current(); + } + } else { + let command = music_dht::device_sync::PlaybackCommand::ActiveChanged { + active_device_id: target.id.clone(), + active_device_name: target.name.clone(), + state: wire.clone(), + }; + if let Err(error) = self + .devices + .record_playback_command(&target.id, command.clone()) + { + self.state.connected_devices.error = Some(format!("device handoff: {error:#}")); + self.publish(); + return; + } + urgent_targets.push(target.id.clone()); + if previous != target.id && previous != self.state.connected_devices.this_device_id { + if let Err(error) = self.devices.record_playback_command(&previous, command) { + self.state.connected_devices.error = + Some(format!("previous device handoff: {error:#}")); + } + urgent_targets.push(previous); + } + self.audio.stop(); + self.device_role = DevicePlaybackRole::Control; + self.active_device_id.clone_from(&target.id); + self.active_device_name = target.name; + self.control_anchor = Some(ControlPlaybackAnchor { + device_id: target.id.clone(), + state: wire.clone(), + observed_at: Instant::now(), + }); + self.pending_control = Some(PendingControlState { + device_id: target.id, + state: wire, + seek: true, + sent_at: Instant::now(), + }); + } + self.devices.request_sync(); + if let Some(service) = self.device_service.clone() { + urgent_targets.sort(); + urgent_targets.dedup(); + for target_id in urgent_targets { + let devices = std::sync::Arc::clone(&self.devices); + let service = std::sync::Arc::clone(&service); + tokio::spawn(async move { + let _ = devices.sync_target(service, &target_id).await; + }); + } + } + self.refresh_connected_devices(); + self.publish_device_playback(); + self.publish(); + } + + pub(super) fn playback_wire_state(&self) -> music_dht::device_sync::PlaybackStateWire { + music_dht::device_sync::PlaybackStateWire { + queue: self + .state + .queue + .items() + .iter() + .map(|item| track_to_playback_track(&item.track)) + .collect(), + queue_pos: self.state.queue.current_index().unwrap_or(0), + playing: self.state.playback.status != PlaybackStatus::Stopped, + paused: self.state.playback.status == PlaybackStatus::Paused, + idle_since_ms: (self.state.playback.status != PlaybackStatus::Playing) + .then_some(unix_time_ms()), + position_secs: self.state.playback.position_seconds, + volume: volume_percent(self.state.playback.volume), + shuffle: false, + repeat: music_dht::device_sync::PlaybackRepeat::Off, + } + } + + pub(super) fn publish_device_playback(&self) { + let snapshot = music_dht::device_sync::PlaybackSnapshot { + device_id: self.state.connected_devices.this_device_id.clone(), + device_name: self.state.connected_devices.this_device_name.clone(), + active: self.device_role == DevicePlaybackRole::Active, + updated_at_ms: unix_time_ms(), + state: self.playback_wire_state(), + }; + self.devices.publish_playback(snapshot); + } + + pub(super) fn send_control_state(&mut self, seek: bool) { + if self.device_role != DevicePlaybackRole::Control { + return; + } + let state = self.playback_wire_state(); + let command = music_dht::device_sync::PlaybackCommand::SetState { + state: state.clone(), + seek, + }; + if let Err(error) = self + .devices + .record_playback_command(&self.active_device_id, command) + { + self.state.connected_devices.error = Some(format!("device control: {error:#}")); + } else { + self.pending_control = Some(PendingControlState { + device_id: self.active_device_id.clone(), + state, + seek, + sent_at: Instant::now(), + }); + } + } + + pub(super) fn apply_device_playback_state( + &mut self, + wire: &music_dht::device_sync::PlaybackStateWire, + start_audio: bool, + seek: bool, + ) { + let tracks = wire + .queue + .iter() + .filter_map(|track| self.resolve_playback_track(track)) + .collect::>(); + if !tracks.is_empty() { + self.state.queue.replace_context(tracks, wire.queue_pos); + self.resolve_queue_artwork(); + } + self.state.playback.volume = f32::from(wire.volume.min(100)) / 100.0; + self.state.playback.position_seconds = wire.position_secs.max(0.0); + self.state.playback.duration_seconds = self + .state + .queue + .current() + .map_or(0.0, |item| item.track.duration_seconds); + self.state.playback.status = if !wire.playing { + PlaybackStatus::Stopped + } else if wire.paused { + PlaybackStatus::Paused + } else { + PlaybackStatus::Playing + }; + self.audio.set_volume(self.state.playback.volume); + if !start_audio { + return; + } + if wire.playing { + self.play_current(); + if seek || wire.position_secs > 0.0 { + self.audio + .seek(Duration::from_secs_f64(wire.position_secs.max(0.0))); + self.state.playback.position_seconds = wire.position_secs.max(0.0); + } + if wire.paused { + self.audio.pause(); + self.state.playback.status = PlaybackStatus::Paused; + } + } else { + self.audio.stop(); + } + } + + pub(super) fn apply_device_playback_command( + &mut self, + command: music_dht::device_sync::PlaybackCommand, + ) { + match command { + music_dht::device_sync::PlaybackCommand::SetState { state, seek } => { + self.device_role = DevicePlaybackRole::Active; + self.active_device_id = self.state.connected_devices.this_device_id.clone(); + self.active_device_name = self.state.connected_devices.this_device_name.clone(); + self.control_anchor = None; + self.pending_control = None; + self.apply_device_playback_state(&state, true, seek); + } + music_dht::device_sync::PlaybackCommand::ActiveChanged { + active_device_id, + active_device_name, + state, + } => { + let is_self = active_device_id == self.state.connected_devices.this_device_id; + self.active_device_id = active_device_id; + self.active_device_name = active_device_name; + self.device_role = if is_self { + DevicePlaybackRole::Active + } else { + DevicePlaybackRole::Control + }; + self.pending_control = None; + self.control_anchor = (!is_self).then(|| ControlPlaybackAnchor { + device_id: self.active_device_id.clone(), + state: state.clone(), + observed_at: Instant::now(), + }); + if !is_self { + self.audio.stop(); + } + self.apply_device_playback_state(&state, is_self, true); + } + } + self.refresh_connected_devices(); + self.publish(); + } + + pub(super) fn resolve_playback_track( + &self, + wire: &music_dht::device_sync::PlaybackTrack, + ) -> Option { + if let Some(content_id) = wire + .content_id + .as_deref() + .and_then(|id| ContentId::parse(id).ok()) + { + let key = TrackKey::remote(content_id.clone()); + if let Some(track) = self.track(&key) { + return Some(track.clone()); + } + if let Ok(Some(track)) = self.catalog.track_by_content_id(content_id.as_str()) { + return Some(library_track(track, "")); + } + } + let Some(fed) = wire.fed.as_ref() else { + let content_id = wire + .content_id + .as_deref() + .and_then(|id| ContentId::parse(id).ok())?; + return Some(portable_playback_placeholder(wire, content_id)); + }; + let content_id = ContentId::parse(&fed.content_id).ok()?; + let release_key = ReleaseKey::Federation { + peer_id: fed.owner.clone(), + id: format!( + "name:{}", + music_dht::normalize_name(fed.release_title.as_deref().unwrap_or_default()) + ), + }; + let refs = |names: &[String]| { + names + .iter() + .map(|name| ArtistRef { + key: ArtistKey::Federation { + peer_id: fed.owner.clone(), + id: format!("name:{}", music_dht::normalize_name(name)), + }, + name: name.clone(), + }) + .collect::>() + }; + Some(Track { + key: TrackKey::federation( + fed.owner.clone(), + fed.item_id.clone(), + Some(content_id.clone()), + ), + title: wire.title.clone(), + artist: wire.artist_names.join(", "), + artists: refs(&wire.artist_names), + featured_artists: refs(&wire.featured_artist_names), + release: wire.release_title.clone(), + release_id: release_key, + duration_seconds: wire.duration_seconds, + track_number: wire + .track_number + .and_then(|value| u32::try_from(value).ok()), + disc_number: wire.disc_number.and_then(|value| u32::try_from(value).ok()), + cover_uri: None, + audio_format: wire.audio_format.clone(), + audio_bitrate_kbps: wire + .audio_bitrate + .and_then(|value| u32::try_from(value).ok()), + audio_sample_rate_hz: wire + .audio_sample_rate + .and_then(|value| u32::try_from(value).ok()), + audio_bit_depth: wire + .audio_bit_depth + .and_then(|value| u32::try_from(value).ok()), + file_size_bytes: wire + .file_size_bytes + .and_then(|value| u64::try_from(value).ok()), + liked: false, + audio_source: AudioSource::Federation { + peer_id: fed.owner.clone(), + content_id, + }, + }) + } +} diff --git a/crates/backend/src/audio.rs b/crates/backend/src/audio.rs new file mode 100644 index 0000000..9a3b6e5 --- /dev/null +++ b/crates/backend/src/audio.rs @@ -0,0 +1,281 @@ +//! Real audio output isolated from the backend actor and UI thread. + +use std::fs::File; +use std::io; +use std::path::PathBuf; +use std::sync::Arc; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::mpsc::{Receiver, RecvTimeoutError, Sender}; +use std::time::Duration; + +use rodio::{Decoder, DeviceSinkBuilder, Player, stream::MixerDeviceSink}; + +pub trait TrackReadSeek: std::io::Read + std::io::Seek + Send + Sync {} +impl TrackReadSeek for T where T: std::io::Read + std::io::Seek + Send + Sync {} +pub type TrackReader = Box; + +#[derive(Debug)] +pub enum Event { + Started, + Finished, + Failed(String), +} + +enum Command { + Play { + path: PathBuf, + volume: f32, + }, + PlayStream { + reader: TrackReader, + mime_type: String, + volume: f32, + }, + Pause, + Resume, + Stop, + Seek(Duration), + SetVolume(f32), +} + +#[derive(Debug, Default)] +pub struct Shared { + position_ms: AtomicU64, +} + +impl Shared { + pub fn position_seconds(&self) -> f64 { + Duration::from_millis(self.position_ms.load(Ordering::Relaxed)).as_secs_f64() + } +} + +#[derive(Clone)] +pub struct Controller { + commands: Sender, + pub shared: Arc, +} + +impl Controller { + pub fn play(&self, path: PathBuf, volume: f32) { + self.shared.position_ms.store(0, Ordering::Relaxed); + let _ = self.commands.send(Command::Play { path, volume }); + } + pub fn play_stream(&self, reader: TrackReader, mime_type: String, volume: f32) { + self.shared.position_ms.store(0, Ordering::Relaxed); + let _ = self.commands.send(Command::PlayStream { + reader, + mime_type, + volume, + }); + } + + pub fn pause(&self) { + let _ = self.commands.send(Command::Pause); + } + + pub fn resume(&self) { + let _ = self.commands.send(Command::Resume); + } + + pub fn stop(&self) { + let _ = self.commands.send(Command::Stop); + } + + pub fn seek(&self, position: Duration) { + let millis = u64::try_from(position.as_millis()).unwrap_or(u64::MAX); + self.shared.position_ms.store(millis, Ordering::Relaxed); + let _ = self.commands.send(Command::Seek(position)); + } + + pub fn set_volume(&self, volume: f32) { + let _ = self.commands.send(Command::SetVolume(volume)); + } +} + +pub fn spawn(on_event: impl Fn(Event) + Send + 'static) -> io::Result { + let (commands, receiver) = std::sync::mpsc::channel(); + let shared = Arc::new(Shared::default()); + let thread_shared = Arc::clone(&shared); + std::thread::Builder::new() + .name("furumi-audio".into()) + .spawn(move || run(&receiver, &thread_shared, &on_event))?; + Ok(Controller { commands, shared }) +} + +struct Output { + _device: MixerDeviceSink, + player: Player, +} + +fn run(receiver: &Receiver, shared: &Arc, on_event: &impl Fn(Event)) { + let mut output: Option = None; + let mut track_loaded = false; + let mut previous_queue_len = 0; + + loop { + match receiver.recv_timeout(Duration::from_millis(50)) { + Ok(command) => { + handle(command, shared, &mut output, &mut track_loaded, on_event); + previous_queue_len = output.as_ref().map_or(0, |output| output.player.len()); + } + Err(RecvTimeoutError::Timeout) => { + if let Some(output) = &output { + let millis = + u64::try_from(output.player.get_pos().as_millis()).unwrap_or(u64::MAX); + shared.position_ms.store(millis, Ordering::Relaxed); + let queue_len = output.player.len(); + if track_loaded && queue_len < previous_queue_len { + track_loaded = false; + on_event(Event::Finished); + } + previous_queue_len = queue_len; + } + } + Err(RecvTimeoutError::Disconnected) => return, + } + } +} + +#[allow(clippy::too_many_lines, reason = "exhaustive audio command dispatcher")] +fn handle( + command: Command, + shared: &Arc, + output: &mut Option, + track_loaded: &mut bool, + on_event: &impl Fn(Event), +) { + match command { + Command::Play { path, volume } => { + let output = match ensure_output(output) { + Ok(output) => output, + Err(error) => { + on_event(Event::Failed(format!("cannot open audio output: {error}"))); + return; + } + }; + let file = match File::open(&path) { + Ok(file) => file, + Err(error) => { + on_event(Event::Failed(format!( + "cannot open {}: {error}", + path.display() + ))); + return; + } + }; + let byte_len = file.metadata().ok().map(|metadata| metadata.len()); + let mut decoder = Decoder::builder() + .with_data(file) + .with_seekable(true) + .with_gapless(true); + if let Some(byte_len) = byte_len { + decoder = decoder.with_byte_len(byte_len); + } + match decoder.build() { + Ok(source) => { + output.player.stop(); + output.player.set_volume(amplitude(volume)); + output.player.append(source); + output.player.play(); + shared.position_ms.store(0, Ordering::Relaxed); + *track_loaded = true; + on_event(Event::Started); + } + Err(error) => on_event(Event::Failed(format!( + "cannot decode {}: {error}", + path.display() + ))), + } + } + Command::PlayStream { + reader, + mime_type, + volume, + } => { + let output = match ensure_output(output) { + Ok(output) => output, + Err(error) => { + on_event(Event::Failed(format!("cannot open audio output: {error}"))); + return; + } + }; + match Decoder::builder() + .with_data(reader) + .with_mime_type(&mime_type) + .with_seekable(false) + .with_gapless(true) + .build() + { + Ok(source) => { + output.player.stop(); + output.player.set_volume(amplitude(volume)); + output.player.append(source); + output.player.play(); + shared.position_ms.store(0, Ordering::Relaxed); + *track_loaded = true; + on_event(Event::Started); + } + Err(error) => on_event(Event::Failed(format!( + "cannot decode federated stream: {error}" + ))), + } + } + Command::Pause => { + if let Some(output) = output { + output.player.pause(); + } + } + Command::Resume => { + if let Some(output) = output { + output.player.play(); + } + } + Command::Stop => { + if let Some(output) = output { + output.player.stop(); + } + shared.position_ms.store(0, Ordering::Relaxed); + *track_loaded = false; + } + Command::Seek(position) => { + if let Some(output) = output + && let Err(error) = output.player.try_seek(position) + { + on_event(Event::Failed(format!("cannot seek: {error}"))); + } + } + Command::SetVolume(volume) => { + if let Some(output) = output { + output.player.set_volume(amplitude(volume)); + } + } + } +} + +fn ensure_output(output: &mut Option) -> Result<&Output, rodio::stream::DeviceSinkError> { + if output.is_none() { + let device = DeviceSinkBuilder::open_default_sink()?; + let player = Player::connect_new(device.mixer()); + *output = Some(Output { + _device: device, + player, + }); + } + Ok(output.as_ref().expect("audio output initialized above")) +} + +fn amplitude(volume: f32) -> f32 { + volume.clamp(0.0, 1.0).powi(3) +} + +#[cfg(test)] +mod tests { + use super::amplitude; + + #[test] + fn perceptual_volume_is_clamped_and_cubic() { + assert!(amplitude(-1.0).abs() < f32::EPSILON); + assert!((amplitude(0.5) - 0.125).abs() < f32::EPSILON); + assert!((amplitude(2.0) - 1.0).abs() < f32::EPSILON); + } +} diff --git a/crates/backend/src/devices.rs b/crates/backend/src/devices.rs new file mode 100644 index 0000000..092c606 --- /dev/null +++ b/crates/backend/src/devices.rs @@ -0,0 +1,1129 @@ +//! Trusted personal-device synchronization. +//! +//! The wire format is owned by `music-dht`; this module supplies the durable +//! operation log, materialized `SQLite` state and application integration. + +use std::collections::{BTreeMap, HashMap}; +use std::path::Path; +use std::str::FromStr as _; +use std::sync::{Arc, Mutex, MutexGuard}; +use std::time::Duration; + +use anyhow::{Context as _, Result}; +use music_dht::device_sync::{ + DEFAULT_INVITE_TTL_MS, DEVICE_SYNC_PROTOCOL_VERSION, DeviceProfileWire, InviteWire, + PlaybackCommand, PlaybackSnapshot, SnapshotLike, SnapshotLikeTombstone, SnapshotPlaylist, + SnapshotPlaylistItem, SnapshotPlaylistItemTombstone, SnapshotPlaylistTombstone, SyncOpPayload, + SyncOpWire, SyncSnapshot, SyncedFedTrack, WireMessage, encode_invite, finish_response, + finish_send, hash_secret, parse_invite, random_hex, read_msg, ticket_endpoint_id, write_msg, +}; +use music_dht::{ByteStream, MusicDhtService, PeerTicket, StreamAcceptor}; +use rusqlite::{Connection, OptionalExtension as _, params}; + +use crate::InternalEvent; +mod store; + +const CLIENT_VERSION: &str = env!("CARGO_PKG_VERSION"); +const SYNC_INTERVAL: Duration = Duration::from_secs(2); +const PAIR_RETRY: Duration = Duration::from_secs(1); +const PAIR_WAIT_MS: i64 = 5 * 60 * 1_000; +const RESPONSE_DRAIN: Duration = Duration::from_secs(2); +const ONLINE_TTL_MS: i64 = 15_000; +const MAX_OPS_PER_BATCH: usize = 1_000; +const PLAYBACK_COMMAND_TTL_MS: i64 = 5 * 60 * 1_000; + +#[derive(Debug, Clone, Default)] +pub struct Status { + pub this_device_id: String, + pub this_device_name: String, + pub group_id: String, + pub devices: Vec, + pub pending: Vec, + pub last_sync: Option, + pub error: Option, +} + +#[derive(Debug, Clone, Default)] +pub struct DeviceRow { + pub id: String, + pub name: String, + pub client_version: String, + pub is_self: bool, + pub online: bool, + pub revoked: bool, +} + +#[derive(Debug, Clone, Default)] +pub struct PendingPairing { + pub request_id: String, + pub device_id: String, + pub name: String, + pub client_version: String, + pub requester_group_id: Option, + pub requester_group_active_devices: usize, +} + +#[derive(Debug, Clone)] +struct Identity { + device_id: String, + group_id: String, + name: String, +} + +#[derive(Clone)] +pub struct DeviceSync { + conn: Arc>, + library: Arc, + events: tokio::sync::mpsc::Sender, + playback: Arc>, + sync_requested: Arc, +} + +#[derive(Debug, Default)] +struct PlaybackStore { + local: Option, + remote: HashMap, +} + +impl DeviceSync { + pub fn open( + path: &Path, + library: Arc, + events: tokio::sync::mpsc::Sender, + ) -> Result> { + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent)?; + } + let conn = Connection::open(path) + .with_context(|| format!("opening device sync database {}", path.display()))?; + init_schema(&conn)?; + let sync = Arc::new(Self { + conn: Arc::new(Mutex::new(conn)), + library, + events, + playback: Arc::new(Mutex::new(PlaybackStore::default())), + sync_requested: Arc::new(tokio::sync::Notify::new()), + }); + sync.ensure_identity()?; + Ok(sync) + } + + pub fn status(&self) -> Status { + self.status_inner().unwrap_or_else(|error| Status { + error: Some(format!("{error:#}")), + ..Status::default() + }) + } + + pub fn identity(&self) -> Result<(String, String)> { + let identity = self.ensure_identity()?; + Ok((identity.device_id, identity.name)) + } + + pub fn set_device_name(&self, name: &str, endpoint_ticket: Option<&str>) -> Result<()> { + let name = if name.trim().is_empty() { + "furumi".to_string() + } else { + name.trim().to_string() + }; + let identity = self.ensure_identity()?; + { + let conn = lock(&self.conn); + set_meta(&conn, "device_name", &name)?; + conn.execute( + "UPDATE sync_devices + SET name = ?2, client_version = ?3 + WHERE device_id = ?1", + params![identity.device_id, name, CLIENT_VERSION], + )?; + } + if let Some(ticket) = endpoint_ticket { + self.record_op(SyncOpPayload::DeviceProfileSet { + name, + client_version: CLIENT_VERSION.to_string(), + endpoint_ticket: ticket.to_string(), + endpoint_id: ticket_endpoint_id(ticket).unwrap_or_default(), + })?; + } else { + self.request_sync(); + self.notify(); + } + Ok(()) + } + + pub fn publish_playback(&self, mut snapshot: PlaybackSnapshot) { + if snapshot.updated_at_ms <= 0 { + snapshot.updated_at_ms = now_ms(); + } + lock(&self.playback).local = Some(snapshot); + } + + /// Wake the sync loop immediately after an interactive operation. + pub fn request_sync(&self) { + self.sync_requested.notify_one(); + } + + pub async fn create_invite(&self, service: Arc) -> Result { + let identity = self.ensure_identity()?; + let secret = random_hex(16); + let invite_id = random_hex(8); + let expires_at_ms = now_ms() + DEFAULT_INVITE_TTL_MS; + lock(&self.conn).execute( + "INSERT INTO sync_invites (invite_id, secret_hash, expires_at_ms, created_at_ms) + VALUES (?1, ?2, ?3, ?4)", + params![invite_id, hash_secret(&secret), expires_at_ms, now_ms()], + )?; + encode_invite(&InviteWire { + v: 1, + ticket: service.ticket().await?.to_string(), + device_id: identity.device_id, + invite_id, + secret, + expires_at_ms, + }) + .map_err(Into::into) + } + + pub async fn connect_invite( + &self, + service: Arc, + value: &str, + ) -> Result { + let invite = parse_invite(value)?; + anyhow::ensure!(invite.expires_at_ms >= now_ms(), "device invite expired"); + let ticket = + PeerTicket::from_str(&invite.ticket).context("invalid device invite ticket")?; + let deadline = (now_ms() + PAIR_WAIT_MS).min(invite.expires_at_ms); + loop { + match self + .try_connect_invite(Arc::clone(&service), &invite, ticket.clone()) + .await + { + Ok(PairAttempt::Accepted) => { + return Ok(format!("Connected to {}", short_id(&invite.device_id))); + } + Ok(PairAttempt::Denied(message)) => anyhow::bail!(message), + Ok(PairAttempt::Pending) | Err(_) if now_ms() < deadline => { + tokio::time::sleep(PAIR_RETRY).await; + } + Ok(PairAttempt::Pending) => anyhow::bail!("pairing timed out"), + Err(error) => anyhow::bail!("pairing timed out: {error:#}"), + } + } + } + + async fn try_connect_invite( + &self, + service: Arc, + invite: &InviteWire, + ticket: PeerTicket, + ) -> Result { + let peer = service.connect(ticket).await?; + let identity = self.ensure_identity()?; + let mut stream = service + .open_stream(peer, music_dht::device_sync::SYNC_ALPN) + .await?; + let playback = lock(&self.playback).local.clone(); + write_msg( + &mut stream, + &WireMessage::PairRequest { + invite_id: invite.invite_id.clone(), + secret: invite.secret.clone(), + profile: self.own_profile(&service.ticket().await?.to_string())?, + group_id: Some(identity.group_id), + group_active_devices: self.active_device_count()?, + devices: self.device_profiles()?, + vector: self.vector()?, + ops: self.ops_for_peer(&invite.device_id)?, + snapshot: self.snapshot()?, + playback, + }, + ) + .await?; + finish_send(&mut stream).await?; + match read_msg(&mut stream).await? { + WireMessage::PairResponse { + accepted: true, + group_id: Some(group_id), + profile, + devices, + vector, + ops, + snapshot, + playback, + .. + } => { + self.set_group_id(&group_id)?; + if let Some(profile) = profile { + self.apply_device_profile(&profile, true)?; + } + self.apply_device_profiles(&devices)?; + self.apply_snapshot(snapshot)?; + self.apply_ops(ops)?; + if let Some(playback) = playback { + self.apply_playback_snapshot(playback); + } + self.record_op(SyncOpPayload::DeviceTrusted { + target_device_id: invite.device_id.clone(), + })?; + self.note_peer_vector(&invite.device_id, &vector)?; + self.set_meta("last_sync", &format!("paired · {}", time_label()))?; + self.notify(); + Ok(PairAttempt::Accepted) + } + WireMessage::PairResponse { + accepted: false, + pending: true, + .. + } => Ok(PairAttempt::Pending), + WireMessage::PairResponse { + accepted: false, + error, + .. + } => Ok(PairAttempt::Denied( + error.unwrap_or_else(|| "pairing denied".into()), + )), + _ => anyhow::bail!("unexpected pairing response"), + } + } + + pub fn answer_pairing( + &self, + request_id: &str, + accept: bool, + use_requester_group: bool, + ) -> Result<()> { + let pending = { + let conn = lock(&self.conn); + conn.query_row( + "SELECT device_id, name, client_version, endpoint_id, endpoint_ticket, + requester_group_id, requester_group_devices_json + FROM sync_pending_pairing WHERE request_id = ?1", + [request_id], + |row| { + Ok(( + DeviceProfileWire { + device_id: row.get(0)?, + name: row.get(1)?, + client_version: row.get(2)?, + protocol_version: DEVICE_SYNC_PROTOCOL_VERSION, + endpoint_id: row.get(3)?, + endpoint_ticket: row.get(4)?, + revoked: false, + revoke_cutoff_seq: None, + updated_at_ms: now_ms(), + }, + row.get::<_, Option>(5)?, + row.get::<_, String>(6)?, + )) + }, + ) + .optional()? + }; + let changed = lock(&self.conn).execute( + "UPDATE sync_pending_pairing SET status = ?2, answered_at_ms = ?3, + use_requester_group = ?4 + WHERE request_id = ?1 AND status = 'pending'", + params![ + request_id, + if accept { "accepted" } else { "denied" }, + now_ms(), + i64::from(use_requester_group) + ], + )?; + if accept + && changed > 0 + && let Some((profile, group, devices_json)) = pending + { + if use_requester_group { + if let Some(group) = group.filter(|value| !value.trim().is_empty()) { + self.set_group_id(&group)?; + } + let devices: Vec = serde_json::from_str(&devices_json)?; + self.apply_device_profiles(&devices)?; + } + self.apply_device_profile(&profile, true)?; + self.record_op(SyncOpPayload::DeviceTrusted { + target_device_id: profile.device_id, + })?; + } + self.notify(); + Ok(()) + } + + pub fn record_like( + &self, + content_id: &str, + liked: bool, + fed: Option, + ) -> Result<()> { + let Some(content_id) = music_dht::normalize_content_id(content_id) else { + return Ok(()); + }; + self.record_op(SyncOpPayload::TrackLikeSet { + content_id, + liked, + fed, + }) + } + + pub fn record_playlist_created(&self, id: i64, title: &str) -> Result<()> { + let playlist_id = self.library.ensure_playlist_sync_id(id)?; + self.record_op(SyncOpPayload::PlaylistCreated { + playlist_id, + title: title.to_owned(), + }) + } + + pub fn record_playlist_renamed(&self, id: i64, title: &str) -> Result<()> { + let playlist_id = self.library.ensure_playlist_sync_id(id)?; + self.record_op(SyncOpPayload::PlaylistRenamed { + playlist_id, + title: title.to_owned(), + }) + } + + pub fn record_playlist_deleted(&self, sync_id: String) -> Result<()> { + self.record_op(SyncOpPayload::PlaylistDeleted { + playlist_id: sync_id, + }) + } + + pub fn record_playlist_tracks_added(&self, playlist_id: i64, track_ids: &[i64]) -> Result<()> { + let sync_id = self.library.ensure_playlist_sync_id(playlist_id)?; + for (content_id, position) in self + .library + .playlist_track_content_positions(playlist_id, track_ids)? + { + self.record_op(SyncOpPayload::PlaylistTrackAdded { + playlist_id: sync_id.clone(), + content_id, + position, + fed: None, + })?; + } + Ok(()) + } + + pub fn record_playlist_fed_added( + &self, + playlist_id: i64, + tracks: &[(String, i64, SyncedFedTrack)], + ) -> Result<()> { + let sync_id = self.library.ensure_playlist_sync_id(playlist_id)?; + for (content_id, position, fed) in tracks { + self.record_op(SyncOpPayload::PlaylistTrackAdded { + playlist_id: sync_id.clone(), + content_id: content_id.clone(), + position: *position, + fed: Some(fed.clone()), + })?; + } + Ok(()) + } + + pub fn record_playlist_removed(&self, playlist_id: i64, ids: &[String]) -> Result<()> { + let Some(sync_id) = self.library.playlist_sync_id(playlist_id)? else { + return Ok(()); + }; + for content_id in ids { + if let Some(content_id) = music_dht::normalize_content_id(content_id) { + self.record_op(SyncOpPayload::PlaylistTrackRemoved { + playlist_id: sync_id.clone(), + content_id, + })?; + } + } + Ok(()) + } + + pub fn record_playback_command( + &self, + target_device_id: &str, + command: PlaybackCommand, + ) -> Result<()> { + self.record_op(SyncOpPayload::PlaybackCommand { + target_device_id: target_device_id.to_owned(), + command, + }) + } +} + +pub async fn serve( + mut acceptor: StreamAcceptor, + sync: Arc, + service: Arc, +) { + while let Some(stream) = acceptor.accept().await { + let sync = Arc::clone(&sync); + let service = Arc::clone(&service); + tokio::spawn(async move { + if let Err(error) = serve_one(stream, &sync, &service).await { + let _ = sync.set_meta("last_error", &format!("incoming sync: {error:#}")); + sync.notify(); + } + }); + } +} + +pub async fn sync_loop(sync: Arc, service: Arc) { + let mut interval = tokio::time::interval(SYNC_INTERVAL); + interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); + loop { + tokio::select! { + _ = interval.tick() => {} + () = sync.sync_requested.notified() => {} + } + match sync.sync_once(Arc::clone(&service)).await { + Ok(()) => { + let _ = sync.set_meta("last_error", ""); + } + Err(error) => { + let _ = sync.set_meta("last_error", &format!("{error:#}")); + } + } + sync.notify(); + } +} + +impl DeviceSync { + /// Synchronizes one explicitly selected device without waiting for the + /// periodic all-device pass. + pub async fn sync_target( + &self, + service: Arc, + target_device_id: &str, + ) -> Result<()> { + let device = self + .active_remote_devices()? + .into_iter() + .find(|device| device.id == target_device_id) + .with_context(|| format!("device {} is unavailable", short_id(target_device_id)))?; + let result = self.sync_device(service, &device).await; + if let Err(error) = &result { + let _ = self.set_meta( + "last_error", + &format!("{}: {error:#}", short_id(target_device_id)), + ); + } + self.notify(); + result + } + + async fn sync_once(&self, service: Arc) -> Result<()> { + let devices = self.active_remote_devices()?; + let mut last_error = None; + for device in devices { + if let Err(error) = self.sync_device(Arc::clone(&service), &device).await { + last_error = Some(error); + } + } + if let Some(error) = last_error { + return Err(error); + } + Ok(()) + } + + fn active_remote_devices(&self) -> Result> { + let own = self.ensure_identity()?.device_id; + let conn = lock(&self.conn); + let mut stmt = conn.prepare( + "SELECT device_id, endpoint_id, endpoint_ticket FROM sync_devices + WHERE trusted_at_ms IS NOT NULL AND revoked_at_ms IS NULL + AND device_id != ?1 AND endpoint_ticket != ''", + )?; + Ok(stmt + .query_map([own], |row| { + Ok(StoredDevice { + id: row.get(0)?, + endpoint_id: row.get(1)?, + ticket: row.get(2)?, + }) + })? + .collect::>>()?) + } + + async fn sync_device( + &self, + service: Arc, + device: &StoredDevice, + ) -> Result<()> { + let peer = if let Ok(peer) = device.endpoint_id.parse::() + && (service.is_connected(peer) + || service + .known_peers() + .iter() + .any(|contact| contact.peer_id == peer)) + { + peer + } else { + service + .connect(PeerTicket::from_str(&device.ticket)?) + .await? + }; + let identity = self.ensure_identity()?; + let mut stream = service + .open_stream(peer, music_dht::device_sync::SYNC_ALPN) + .await?; + let playback = lock(&self.playback).local.clone(); + write_msg( + &mut stream, + &WireMessage::Hello { + group_id: identity.group_id, + profile: self.own_profile(&service.ticket().await?.to_string())?, + devices: self.device_profiles()?, + vector: self.vector()?, + ops: self.ops_for_peer(&device.id)?, + snapshot: self.snapshot()?, + playback, + }, + ) + .await?; + finish_send(&mut stream).await?; + match read_msg(&mut stream).await? { + WireMessage::SyncResponse { + accepted: true, + devices, + vector, + ops, + snapshot, + playback, + .. + } => { + self.apply_device_profiles(&devices)?; + self.apply_snapshot(snapshot)?; + self.apply_ops(ops)?; + if let Some(playback) = playback { + self.apply_playback_snapshot(playback); + } + self.note_peer_vector(&device.id, &vector)?; + self.mark_seen(&device.id, &peer.to_string())?; + self.set_meta( + "last_sync", + &format!("{} · {}", time_label(), short_id(&device.id)), + )?; + Ok(()) + } + WireMessage::SyncResponse { + accepted: false, + error, + .. + } => anyhow::bail!(error.unwrap_or_else(|| "sync refused".into())), + _ => anyhow::bail!("unexpected sync response"), + } + } +} + +#[derive(Debug)] +struct StoredDevice { + id: String, + endpoint_id: String, + ticket: String, +} + +async fn serve_one( + mut stream: ByteStream, + sync: &Arc, + service: &Arc, +) -> Result<()> { + match read_msg(&mut stream).await? { + WireMessage::PairRequest { + invite_id, + secret, + mut profile, + group_id, + group_active_devices, + devices, + vector, + ops, + snapshot, + playback, + } => { + profile.endpoint_id = stream.peer_id.to_string(); + handle_pair_request( + stream, + sync, + service, + invite_id, + secret, + profile, + group_id, + group_active_devices, + devices, + vector, + ops, + snapshot, + playback, + ) + .await + } + WireMessage::Hello { + group_id, + mut profile, + devices, + vector, + ops, + snapshot, + playback, + } => { + profile.endpoint_id = stream.peer_id.to_string(); + handle_hello( + stream, sync, service, group_id, profile, devices, vector, ops, snapshot, playback, + ) + .await + } + _ => anyhow::bail!("unexpected first device-sync message"), + } +} + +#[allow(clippy::too_many_arguments)] +async fn handle_pair_request( + mut stream: ByteStream, + sync: &Arc, + service: &Arc, + invite_id: String, + secret: String, + profile: DeviceProfileWire, + requester_group_id: Option, + requester_group_active_devices: usize, + requester_devices: Vec, + vector: BTreeMap, + ops: Vec, + snapshot: SyncSnapshot, + playback: Option, +) -> Result<()> { + let request_id = pair_request_id(&invite_id, &profile.device_id); + if !valid_pair_request(sync, &invite_id, &secret, &request_id)? { + write_msg(&mut stream, &pair_error("invalid or expired invite", false)).await?; + finish_response(&mut stream, RESPONSE_DRAIN).await?; + return Ok(()); + } + let devices_json = serde_json::to_string(&requester_devices)?; + lock(&sync.conn).execute( + "INSERT OR IGNORE INTO sync_pending_pairing + (request_id, device_id, name, client_version, endpoint_id, endpoint_ticket, + invite_id, created_at_ms, status, requester_group_id, + requester_group_active_devices, requester_group_devices_json) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, 'pending', ?9, ?10, ?11)", + params![ + request_id, + profile.device_id, + profile.name, + profile.client_version, + profile.endpoint_id, + profile.endpoint_ticket, + invite_id, + now_ms(), + requester_group_id, + i64::try_from(requester_group_active_devices).unwrap_or(i64::MAX), + devices_json, + ], + )?; + sync.notify(); + let pairing = lock(&sync.conn) + .query_row( + "SELECT status, use_requester_group FROM sync_pending_pairing + WHERE request_id = ?1", + [&request_id], + |row| Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)? != 0)), + ) + .optional()?; + match pairing.as_ref().map(|(status, _)| status.as_str()) { + Some("pending") => { + write_msg(&mut stream, &pair_error("pairing pending", true)).await?; + finish_response(&mut stream, RESPONSE_DRAIN).await?; + return Ok(()); + } + Some("accepted") => {} + _ => { + write_msg(&mut stream, &pair_error("pairing denied", false)).await?; + finish_response(&mut stream, RESPONSE_DRAIN).await?; + return Ok(()); + } + } + let mut response_group = sync.ensure_identity()?.group_id; + if pairing.is_some_and(|(_, use_group)| use_group) + && let Some(group) = requester_group_id.filter(|group| !group.trim().is_empty()) + { + sync.set_group_id(&group)?; + response_group = group; + sync.apply_device_profiles(&requester_devices)?; + } + sync.apply_device_profile(&profile, true)?; + sync.apply_snapshot(snapshot)?; + sync.apply_ops(ops)?; + if let Some(playback) = playback { + sync.apply_playback_snapshot(playback); + } + sync.note_peer_vector(&profile.device_id, &vector)?; + lock(&sync.conn).execute( + "UPDATE sync_invites SET used_at_ms = ?2 WHERE invite_id = ?1", + params![invite_id, now_ms()], + )?; + let own_profile = sync.own_profile(&service.ticket().await?.to_string())?; + let playback = lock(&sync.playback).local.clone(); + write_msg( + &mut stream, + &WireMessage::PairResponse { + accepted: true, + pending: false, + error: None, + group_id: Some(response_group), + profile: Some(own_profile), + devices: sync.device_profiles()?, + vector: sync.vector()?, + ops: sync.ops_for_peer(&profile.device_id)?, + snapshot: sync.snapshot()?, + playback, + }, + ) + .await?; + finish_response(&mut stream, RESPONSE_DRAIN).await?; + sync.set_meta("last_sync", &format!("paired · {}", time_label()))?; + sync.notify(); + Ok(()) +} + +#[allow(clippy::too_many_arguments)] +async fn handle_hello( + mut stream: ByteStream, + sync: &Arc, + service: &Arc, + group_id: String, + profile: DeviceProfileWire, + devices: Vec, + vector: BTreeMap, + ops: Vec, + snapshot: SyncSnapshot, + playback: Option, +) -> Result<()> { + if group_id != sync.ensure_identity()?.group_id || !is_trusted(sync, &profile.device_id)? { + write_msg( + &mut stream, + &WireMessage::SyncResponse { + accepted: false, + error: Some("device group mismatch or device is not trusted".into()), + devices: Vec::new(), + vector: BTreeMap::new(), + ops: Vec::new(), + snapshot: SyncSnapshot::default(), + playback: None, + }, + ) + .await?; + finish_response(&mut stream, RESPONSE_DRAIN).await?; + return Ok(()); + } + sync.apply_device_profile(&profile, false)?; + sync.apply_device_profiles(&devices)?; + sync.apply_snapshot(snapshot)?; + sync.apply_ops(ops)?; + if let Some(playback) = playback { + sync.apply_playback_snapshot(playback); + } + sync.note_peer_vector(&profile.device_id, &vector)?; + sync.mark_seen(&profile.device_id, &stream.peer_id.to_string())?; + let mut profiles = sync.device_profiles()?; + profiles.push(sync.own_profile(&service.ticket().await?.to_string())?); + let playback = lock(&sync.playback).local.clone(); + write_msg( + &mut stream, + &WireMessage::SyncResponse { + accepted: true, + error: None, + devices: profiles, + vector: sync.vector()?, + ops: sync.ops_for_peer(&profile.device_id)?, + snapshot: sync.snapshot()?, + playback, + }, + ) + .await?; + finish_response(&mut stream, RESPONSE_DRAIN).await?; + sync.set_meta( + "last_sync", + &format!("{} · {}", time_label(), short_id(&profile.device_id)), + )?; + sync.notify(); + Ok(()) +} + +fn pair_error(message: &str, pending: bool) -> WireMessage { + WireMessage::PairResponse { + accepted: false, + pending, + error: Some(message.into()), + group_id: None, + profile: None, + devices: Vec::new(), + vector: BTreeMap::new(), + ops: Vec::new(), + snapshot: SyncSnapshot::default(), + playback: None, + } +} + +fn init_schema(conn: &Connection) -> Result<()> { + conn.execute_batch( + r" +PRAGMA journal_mode = WAL; +PRAGMA foreign_keys = ON; +CREATE TABLE IF NOT EXISTS sync_meta ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL +); +CREATE TABLE IF NOT EXISTS sync_devices ( + device_id TEXT PRIMARY KEY, + name TEXT NOT NULL DEFAULT '', + client_version TEXT NOT NULL DEFAULT '', + protocol_version INTEGER NOT NULL DEFAULT 1, + endpoint_id TEXT NOT NULL DEFAULT '', + endpoint_ticket TEXT NOT NULL DEFAULT '', + trusted_at_ms INTEGER, + last_seen_ms INTEGER, + revoked_at_ms INTEGER, + revoked_by TEXT, + revoke_cutoff_seq INTEGER +); +CREATE TABLE IF NOT EXISTS sync_invites ( + invite_id TEXT PRIMARY KEY, + secret_hash TEXT NOT NULL, + expires_at_ms INTEGER NOT NULL, + created_at_ms INTEGER NOT NULL, + used_at_ms INTEGER +); +CREATE TABLE IF NOT EXISTS sync_pending_pairing ( + request_id TEXT PRIMARY KEY, + device_id TEXT NOT NULL, + name TEXT NOT NULL, + client_version TEXT NOT NULL, + endpoint_id TEXT NOT NULL, + endpoint_ticket TEXT NOT NULL, + invite_id TEXT NOT NULL, + created_at_ms INTEGER NOT NULL, + answered_at_ms INTEGER, + status TEXT NOT NULL, + requester_group_id TEXT, + requester_group_active_devices INTEGER NOT NULL DEFAULT 1, + requester_group_devices_json TEXT NOT NULL DEFAULT '[]', + use_requester_group INTEGER NOT NULL DEFAULT 0 +); +CREATE TABLE IF NOT EXISTS sync_ops ( + op_id TEXT PRIMARY KEY, + origin_device_id TEXT NOT NULL, + seq INTEGER NOT NULL, + kind TEXT NOT NULL, + payload_json TEXT NOT NULL, + hlc_ms INTEGER NOT NULL, + received_at_ms INTEGER NOT NULL, + tombstone INTEGER NOT NULL DEFAULT 0 +); +CREATE INDEX IF NOT EXISTS idx_sync_ops_origin_seq + ON sync_ops(origin_device_id, seq); +CREATE TABLE IF NOT EXISTS sync_vectors ( + device_id TEXT PRIMARY KEY, + max_seq INTEGER NOT NULL +); +CREATE TABLE IF NOT EXISTS sync_peer_acks ( + peer_device_id TEXT NOT NULL, + origin_device_id TEXT NOT NULL, + max_seq INTEGER NOT NULL, + updated_at_ms INTEGER NOT NULL, + PRIMARY KEY (peer_device_id, origin_device_id) +); +CREATE TABLE IF NOT EXISTS sync_state_likes ( + content_id TEXT PRIMARY KEY, + liked INTEGER NOT NULL, + hlc_ms INTEGER NOT NULL, + op_id TEXT NOT NULL +); +CREATE TABLE IF NOT EXISTS sync_state_playlists ( + playlist_id TEXT PRIMARY KEY, + title TEXT NOT NULL, + deleted INTEGER NOT NULL DEFAULT 0, + hlc_ms INTEGER NOT NULL, + op_id TEXT NOT NULL +); +CREATE TABLE IF NOT EXISTS sync_state_playlist_items ( + playlist_id TEXT NOT NULL, + content_id TEXT NOT NULL, + present INTEGER NOT NULL, + position INTEGER NOT NULL, + hlc_ms INTEGER NOT NULL, + op_id TEXT NOT NULL, + PRIMARY KEY (playlist_id, content_id) +); +CREATE TABLE IF NOT EXISTS sync_playback_applied ( + op_id TEXT PRIMARY KEY, + applied_at_ms INTEGER NOT NULL +); +", + )?; + Ok(()) +} + +fn lock(mutex: &Mutex) -> MutexGuard<'_, T> { + mutex + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) +} + +fn get_meta(conn: &Connection, key: &str) -> Result> { + Ok(conn + .query_row("SELECT value FROM sync_meta WHERE key = ?1", [key], |row| { + row.get(0) + }) + .optional()?) +} + +fn set_meta(conn: &Connection, key: &str, value: &str) -> Result<()> { + if value.is_empty() { + conn.execute("DELETE FROM sync_meta WHERE key = ?1", [key])?; + } else { + conn.execute( + "INSERT INTO sync_meta (key, value) VALUES (?1, ?2) + ON CONFLICT(key) DO UPDATE SET value = excluded.value", + params![key, value], + )?; + } + Ok(()) +} + +fn now_ms() -> i64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map_or(0, |duration| { + i64::try_from(duration.as_millis()).unwrap_or(i64::MAX) + }) +} + +fn time_label() -> String { + let seconds = (now_ms() / 1_000).max(0); + format!( + "{:02}:{:02}:{:02} UTC", + seconds / 3_600 % 24, + seconds / 60 % 60, + seconds % 60 + ) +} + +fn short_id(value: &str) -> String { + value.chars().take(10).collect() +} + +fn pair_request_id(invite_id: &str, device_id: &str) -> String { + let digest = hash_secret(&format!("{invite_id}:{device_id}")); + format!("pair_{}", &digest[..16]) +} + +fn valid_pair_request( + sync: &DeviceSync, + invite_id: &str, + secret: &str, + request_id: &str, +) -> Result { + let conn = lock(&sync.conn); + let row = conn + .query_row( + "SELECT secret_hash, expires_at_ms, used_at_ms FROM sync_invites + WHERE invite_id = ?1", + [invite_id], + |row| { + Ok(( + row.get::<_, String>(0)?, + row.get::<_, i64>(1)?, + row.get::<_, Option>(2)?, + )) + }, + ) + .optional()?; + let Some((expected, expires, used)) = row else { + return Ok(false); + }; + if expires < now_ms() || expected != hash_secret(secret) { + return Ok(false); + } + if used.is_none() { + return Ok(true); + } + Ok(conn + .query_row( + "SELECT 1 FROM sync_pending_pairing + WHERE request_id = ?1 AND status = 'accepted'", + [request_id], + |row| row.get::<_, i64>(0), + ) + .optional()? + .is_some()) +} + +fn is_trusted(sync: &DeviceSync, device_id: &str) -> Result { + if device_id == sync.ensure_identity()?.device_id { + return Ok(true); + } + Ok(lock(&sync.conn) + .query_row( + "SELECT 1 FROM sync_devices WHERE device_id = ?1 + AND trusted_at_ms IS NOT NULL AND revoked_at_ms IS NULL", + [device_id], + |row| row.get::<_, i64>(0), + ) + .optional()? + .is_some()) +} + +fn payload_kind(payload: &SyncOpPayload) -> &'static str { + match payload { + SyncOpPayload::TrackLikeSet { .. } => "track_like_set", + SyncOpPayload::PlaylistCreated { .. } => "playlist_created", + SyncOpPayload::PlaylistRenamed { .. } => "playlist_renamed", + SyncOpPayload::PlaylistDeleted { .. } => "playlist_deleted", + SyncOpPayload::PlaylistTrackAdded { .. } => "playlist_track_added", + SyncOpPayload::PlaylistTrackRemoved { .. } => "playlist_track_removed", + SyncOpPayload::DeviceProfileSet { .. } => "device_profile_set", + SyncOpPayload::DeviceTrusted { .. } => "device_trusted", + SyncOpPayload::DeviceRevoked { .. } => "device_revoked", + SyncOpPayload::PlaybackCommand { .. } => "playback_command", + SyncOpPayload::ListenRecorded { .. } => "listen_recorded", + } +} + +fn to_library_fed(fed: &SyncedFedTrack) -> furumi_library::FederatedTrack { + furumi_library::FederatedTrack { + item_id: fed.item_id.clone(), + owner: fed.owner.clone(), + own: false, + title: fed.title.clone(), + artist_names: fed.artist_names.clone(), + featured_artist_names: fed.featured_artist_names.clone(), + year: fed.year, + duration_seconds: fed.duration_seconds, + content_id: Some(fed.content_id.clone()), + release_title: fed.release_title.clone(), + track_number: fed.track_number, + disc_number: fed.disc_number, + } +} + +enum PairAttempt { + Accepted, + Pending, + Denied(String), +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn pairing_request_ids_are_stable_and_scoped_to_the_device() { + assert_eq!( + pair_request_id("invite", "device"), + pair_request_id("invite", "device") + ); + assert_ne!( + pair_request_id("invite", "device-a"), + pair_request_id("invite", "device-b") + ); + } +} diff --git a/crates/backend/src/devices/store.rs b/crates/backend/src/devices/store.rs new file mode 100644 index 0000000..ccc4630 --- /dev/null +++ b/crates/backend/src/devices/store.rs @@ -0,0 +1,875 @@ +use super::*; +impl DeviceSync { + pub(super) fn ensure_identity(&self) -> Result { + let conn = lock(&self.conn); + if let (Some(device_id), Some(group_id), Some(name)) = ( + get_meta(&conn, "device_id")?, + get_meta(&conn, "group_id")?, + get_meta(&conn, "device_name")?, + ) { + return Ok(Identity { + device_id, + group_id, + name, + }); + } + let seed = random_hex(32); + let digest = hash_secret(&seed); + let device_id = format!("dev_{}", &digest[..24]); + let group_id = format!("grp_{}", &hash_secret(&device_id)[..24]); + let name = format!("Furumi on {}", std::env::consts::OS); + set_meta(&conn, "device_id", &device_id)?; + set_meta(&conn, "device_secret", &seed)?; + set_meta(&conn, "group_id", &group_id)?; + set_meta(&conn, "device_name", &name)?; + set_meta(&conn, "local_seq", "0")?; + set_meta(&conn, "last_hlc_ms", "0")?; + conn.execute( + "INSERT INTO sync_devices + (device_id, name, client_version, protocol_version, trusted_at_ms, last_seen_ms) + VALUES (?1, ?2, ?3, ?4, ?5, ?5) + ON CONFLICT(device_id) DO UPDATE SET name = excluded.name, + client_version = excluded.client_version, + protocol_version = excluded.protocol_version, + trusted_at_ms = COALESCE(sync_devices.trusted_at_ms, excluded.trusted_at_ms)", + params![ + device_id, + name, + CLIENT_VERSION, + DEVICE_SYNC_PROTOCOL_VERSION, + now_ms() + ], + )?; + Ok(Identity { + device_id, + group_id, + name, + }) + } + + pub(super) fn set_group_id(&self, group_id: &str) -> Result<()> { + let conn = lock(&self.conn); + set_meta(&conn, "group_id", group_id) + } + + pub(super) fn set_meta(&self, key: &str, value: &str) -> Result<()> { + set_meta(&lock(&self.conn), key, value) + } + + pub(super) fn own_profile(&self, ticket: &str) -> Result { + let identity = self.ensure_identity()?; + Ok(DeviceProfileWire { + device_id: identity.device_id, + name: identity.name, + client_version: CLIENT_VERSION.into(), + protocol_version: DEVICE_SYNC_PROTOCOL_VERSION, + endpoint_id: ticket_endpoint_id(ticket).unwrap_or_default(), + endpoint_ticket: ticket.into(), + revoked: false, + revoke_cutoff_seq: None, + updated_at_ms: now_ms(), + }) + } + + pub(super) fn active_device_count(&self) -> Result { + let count = lock(&self.conn).query_row( + "SELECT COUNT(*) FROM sync_devices + WHERE trusted_at_ms IS NOT NULL AND revoked_at_ms IS NULL", + [], + |row| row.get::<_, i64>(0), + )?; + Ok(usize::try_from(count.max(0)).unwrap_or(usize::MAX)) + } + + pub(super) fn status_inner(&self) -> Result { + let identity = self.ensure_identity()?; + let conn = lock(&self.conn); + let now = now_ms(); + let devices = { + let mut stmt = conn.prepare( + "SELECT device_id, name, client_version, last_seen_ms + FROM sync_devices + WHERE trusted_at_ms IS NOT NULL AND revoked_at_ms IS NULL + ORDER BY name COLLATE NOCASE, device_id", + )?; + stmt.query_map([], |row| { + let id: String = row.get(0)?; + let last_seen: Option = row.get(3)?; + Ok(DeviceRow { + is_self: id == identity.device_id, + online: id == identity.device_id + || last_seen.is_some_and(|seen| now.saturating_sub(seen) <= ONLINE_TTL_MS), + id, + name: row.get(1)?, + client_version: row.get(2)?, + revoked: false, + }) + })? + .collect::>>()? + }; + let pending = { + let mut stmt = conn.prepare( + "SELECT request_id, device_id, name, client_version, + requester_group_id, requester_group_active_devices + FROM sync_pending_pairing WHERE status = 'pending' + ORDER BY created_at_ms", + )?; + stmt.query_map([], |row| { + Ok(PendingPairing { + request_id: row.get(0)?, + device_id: row.get(1)?, + name: row.get(2)?, + client_version: row.get(3)?, + requester_group_id: row.get(4)?, + requester_group_active_devices: usize::try_from(row.get::<_, i64>(5)?.max(0)) + .unwrap_or(usize::MAX), + }) + })? + .collect::>>()? + }; + Ok(Status { + this_device_id: identity.device_id, + this_device_name: identity.name, + group_id: identity.group_id, + devices, + pending, + last_sync: get_meta(&conn, "last_sync")?, + error: get_meta(&conn, "last_error")?, + }) + } + + pub(super) fn device_profiles(&self) -> Result> { + let conn = lock(&self.conn); + let mut stmt = conn.prepare( + "SELECT device_id, name, client_version, protocol_version, endpoint_id, + endpoint_ticket, revoked_at_ms IS NOT NULL, revoke_cutoff_seq, + MAX(COALESCE(last_seen_ms, 0), COALESCE(trusted_at_ms, 0), + COALESCE(revoked_at_ms, 0)) + FROM sync_devices WHERE trusted_at_ms IS NOT NULL", + )?; + Ok(stmt + .query_map([], |row| { + Ok(DeviceProfileWire { + device_id: row.get(0)?, + name: row.get(1)?, + client_version: row.get(2)?, + protocol_version: row.get::<_, u16>(3)?, + endpoint_id: row.get(4)?, + endpoint_ticket: row.get(5)?, + revoked: row.get::<_, i64>(6)? != 0, + revoke_cutoff_seq: row.get(7)?, + updated_at_ms: row.get(8)?, + }) + })? + .collect::>>()?) + } + + pub(super) fn apply_device_profiles(&self, profiles: &[DeviceProfileWire]) -> Result<()> { + for profile in profiles { + self.apply_device_profile(profile, false)?; + } + Ok(()) + } + + pub(super) fn apply_device_profile( + &self, + profile: &DeviceProfileWire, + trusted: bool, + ) -> Result<()> { + if profile.device_id == self.ensure_identity()?.device_id { + return Ok(()); + } + let now = now_ms(); + lock(&self.conn).execute( + "INSERT INTO sync_devices + (device_id, name, client_version, protocol_version, endpoint_id, + endpoint_ticket, trusted_at_ms, last_seen_ms, revoked_at_ms, + revoke_cutoff_seq) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10) + ON CONFLICT(device_id) DO UPDATE SET + name = CASE WHEN excluded.last_seen_ms >= COALESCE(sync_devices.last_seen_ms, 0) + THEN excluded.name ELSE sync_devices.name END, + client_version = excluded.client_version, + protocol_version = excluded.protocol_version, + endpoint_id = CASE WHEN excluded.endpoint_id != '' THEN excluded.endpoint_id + ELSE sync_devices.endpoint_id END, + endpoint_ticket = CASE WHEN excluded.endpoint_ticket != '' THEN excluded.endpoint_ticket + ELSE sync_devices.endpoint_ticket END, + trusted_at_ms = COALESCE(sync_devices.trusted_at_ms, excluded.trusted_at_ms), + last_seen_ms = MAX(COALESCE(sync_devices.last_seen_ms, 0), excluded.last_seen_ms), + revoked_at_ms = CASE WHEN excluded.revoked_at_ms IS NOT NULL + THEN excluded.revoked_at_ms ELSE sync_devices.revoked_at_ms END, + revoke_cutoff_seq = COALESCE(excluded.revoke_cutoff_seq, + sync_devices.revoke_cutoff_seq)", + params![ + profile.device_id, + profile.name, + profile.client_version, + profile.protocol_version, + profile.endpoint_id, + profile.endpoint_ticket, + trusted.then_some(now), + profile.updated_at_ms.max(now), + profile.revoked.then_some(profile.updated_at_ms.max(now)), + profile.revoke_cutoff_seq, + ], + )?; + Ok(()) + } + + pub(super) fn mark_seen(&self, device_id: &str, endpoint_id: &str) -> Result<()> { + lock(&self.conn).execute( + "UPDATE sync_devices SET last_seen_ms = ?2, + endpoint_id = CASE WHEN ?3 != '' THEN ?3 ELSE endpoint_id END + WHERE device_id = ?1", + params![device_id, now_ms(), endpoint_id], + )?; + Ok(()) + } + + pub(super) fn record_op(&self, payload: SyncOpPayload) -> Result<()> { + let op = { + let conn = lock(&self.conn); + let identity = Self::ensure_identity_with_conn(&conn)?; + let seq = get_meta(&conn, "local_seq")? + .and_then(|value| value.parse::().ok()) + .unwrap_or(0) + .saturating_add(1); + let previous_hlc = get_meta(&conn, "last_hlc_ms")? + .and_then(|value| value.parse::().ok()) + .unwrap_or(0); + let hlc_ms = now_ms().max(previous_hlc.saturating_add(1)); + set_meta(&conn, "local_seq", &seq.to_string())?; + set_meta(&conn, "last_hlc_ms", &hlc_ms.to_string())?; + SyncOpWire { + op_id: format!("{}:{seq}", identity.device_id), + origin_device_id: identity.device_id, + seq, + hlc_ms, + payload, + } + }; + self.store_and_apply_op(&op)?; + self.request_sync(); + self.notify(); + Ok(()) + } + + pub(super) fn ensure_identity_with_conn(conn: &Connection) -> Result { + Ok(Identity { + device_id: get_meta(conn, "device_id")?.context("missing device id")?, + group_id: get_meta(conn, "group_id")?.context("missing device group")?, + name: get_meta(conn, "device_name")?.context("missing device name")?, + }) + } + + pub(super) fn store_and_apply_op(&self, op: &SyncOpWire) -> Result<()> { + let inserted = lock(&self.conn).execute( + "INSERT OR IGNORE INTO sync_ops + (op_id, origin_device_id, seq, kind, payload_json, hlc_ms, + received_at_ms, tombstone) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)", + params![ + op.op_id, + op.origin_device_id, + op.seq, + payload_kind(&op.payload), + serde_json::to_string(&op.payload)?, + op.hlc_ms, + now_ms(), + i64::from(op.payload.is_tombstone()), + ], + )?; + if inserted == 0 { + return Ok(()); + } + lock(&self.conn).execute( + "INSERT INTO sync_vectors (device_id, max_seq) VALUES (?1, ?2) + ON CONFLICT(device_id) DO UPDATE SET max_seq = MAX(max_seq, excluded.max_seq)", + params![op.origin_device_id, op.seq], + )?; + self.apply_op(op) + } + + pub(super) fn apply_ops(&self, ops: Vec) -> Result<()> { + for op in ops { + if self.should_accept_op(&op)? { + self.store_and_apply_op(&op)?; + } + } + self.notify(); + Ok(()) + } + + pub(super) fn should_accept_op(&self, op: &SyncOpWire) -> Result { + if op.origin_device_id == self.ensure_identity()?.device_id { + return Ok(true); + } + let conn = lock(&self.conn); + let row = conn + .query_row( + "SELECT trusted_at_ms IS NOT NULL, revoked_at_ms IS NOT NULL, + COALESCE(revoke_cutoff_seq, 0) + FROM sync_devices WHERE device_id = ?1", + [&op.origin_device_id], + |row| { + Ok(( + row.get::<_, i64>(0)? != 0, + row.get::<_, i64>(1)? != 0, + row.get::<_, i64>(2)?, + )) + }, + ) + .optional()?; + Ok(row.is_some_and(|(trusted, revoked, cutoff)| trusted && (!revoked || op.seq <= cutoff))) + } + + pub(super) fn vector(&self) -> Result> { + let conn = lock(&self.conn); + let mut stmt = conn.prepare("SELECT device_id, max_seq FROM sync_vectors")?; + Ok(stmt + .query_map([], |row| Ok((row.get(0)?, row.get(1)?)))? + .collect::>>()?) + } + + pub(super) fn ops_for_peer(&self, peer: &str) -> Result> { + let conn = lock(&self.conn); + let mut stmt = conn.prepare( + "SELECT payload_json, op_id, origin_device_id, seq, hlc_ms + FROM sync_ops o + WHERE seq > COALESCE((SELECT max_seq FROM sync_peer_acks a + WHERE a.peer_device_id = ?1 + AND a.origin_device_id = o.origin_device_id), 0) + AND (kind != 'playback_command' OR hlc_ms >= ?2) + ORDER BY received_at_ms, origin_device_id, seq LIMIT ?3", + )?; + Ok(stmt + .query_map( + params![ + peer, + now_ms().saturating_sub(PLAYBACK_COMMAND_TTL_MS), + i64::try_from(MAX_OPS_PER_BATCH).unwrap_or(i64::MAX) + ], + |row| { + let payload: String = row.get(0)?; + Ok(SyncOpWire { + op_id: row.get(1)?, + origin_device_id: row.get(2)?, + seq: row.get(3)?, + hlc_ms: row.get(4)?, + payload: serde_json::from_str(&payload).map_err(|error| { + rusqlite::Error::FromSqlConversionFailure( + 0, + rusqlite::types::Type::Text, + Box::new(error), + ) + })?, + }) + }, + )? + .collect::>>()?) + } + + pub(super) fn note_peer_vector( + &self, + peer: &str, + vector: &BTreeMap, + ) -> Result<()> { + let conn = lock(&self.conn); + for (origin, seq) in vector { + conn.execute( + "INSERT INTO sync_peer_acks + (peer_device_id, origin_device_id, max_seq, updated_at_ms) + VALUES (?1, ?2, ?3, ?4) + ON CONFLICT(peer_device_id, origin_device_id) DO UPDATE SET + max_seq = MAX(max_seq, excluded.max_seq), + updated_at_ms = excluded.updated_at_ms", + params![peer, origin, seq, now_ms()], + )?; + } + Ok(()) + } +} + +impl DeviceSync { + pub(super) fn apply_op(&self, op: &SyncOpWire) -> Result<()> { + match &op.payload { + SyncOpPayload::TrackLikeSet { + content_id, + liked, + fed, + } => self.apply_like(content_id, *liked, fed.as_ref(), op.hlc_ms, &op.op_id)?, + SyncOpPayload::PlaylistCreated { playlist_id, title } + | SyncOpPayload::PlaylistRenamed { playlist_id, title } => { + self.apply_playlist(playlist_id, title, false, op.hlc_ms, &op.op_id)?; + } + SyncOpPayload::PlaylistDeleted { playlist_id } => { + self.apply_playlist(playlist_id, "", true, op.hlc_ms, &op.op_id)?; + } + SyncOpPayload::PlaylistTrackAdded { + playlist_id, + content_id, + position, + fed, + } => self.apply_playlist_item( + playlist_id, + content_id, + true, + *position, + fed.as_ref(), + op.hlc_ms, + &op.op_id, + )?, + SyncOpPayload::PlaylistTrackRemoved { + playlist_id, + content_id, + } => self.apply_playlist_item( + playlist_id, + content_id, + false, + 0, + None, + op.hlc_ms, + &op.op_id, + )?, + SyncOpPayload::DeviceProfileSet { + name, + client_version, + endpoint_ticket, + endpoint_id, + } => self.apply_device_profile( + &DeviceProfileWire { + device_id: op.origin_device_id.clone(), + name: name.clone(), + client_version: client_version.clone(), + protocol_version: DEVICE_SYNC_PROTOCOL_VERSION, + endpoint_id: endpoint_id.clone(), + endpoint_ticket: endpoint_ticket.clone(), + revoked: false, + revoke_cutoff_seq: None, + updated_at_ms: op.hlc_ms, + }, + false, + )?, + SyncOpPayload::DeviceTrusted { target_device_id } => { + lock(&self.conn).execute( + "UPDATE sync_devices SET trusted_at_ms = MAX(COALESCE(trusted_at_ms, 0), ?2), + revoked_at_ms = CASE WHEN COALESCE(revoked_at_ms, 0) <= ?2 + THEN NULL ELSE revoked_at_ms END + WHERE device_id = ?1", + params![target_device_id, op.hlc_ms], + )?; + } + SyncOpPayload::DeviceRevoked { + target_device_id, + target_max_seq_seen, + } => { + lock(&self.conn).execute( + "UPDATE sync_devices SET revoked_at_ms = ?2, revoked_by = ?3, + revoke_cutoff_seq = ?4 WHERE device_id = ?1", + params![ + target_device_id, + op.hlc_ms, + op.origin_device_id, + target_max_seq_seen + ], + )?; + } + SyncOpPayload::PlaybackCommand { + target_device_id, + command, + } => self.apply_playback_command(target_device_id, command, &op.op_id)?, + SyncOpPayload::ListenRecorded { event } => { + self.library + .apply_listen_event(event, &op.origin_device_id)?; + } + } + Ok(()) + } + + pub(super) fn apply_like( + &self, + content_id: &str, + liked: bool, + fed: Option<&SyncedFedTrack>, + hlc_ms: i64, + op_id: &str, + ) -> Result<()> { + let Some(content_id) = music_dht::normalize_content_id(content_id) else { + return Ok(()); + }; + if !self.lww_wins("sync_state_likes", "content_id", &content_id, hlc_ms, op_id)? { + return Ok(()); + } + lock(&self.conn).execute( + "INSERT INTO sync_state_likes (content_id, liked, hlc_ms, op_id) + VALUES (?1, ?2, ?3, ?4) + ON CONFLICT(content_id) DO UPDATE SET liked = excluded.liked, + hlc_ms = excluded.hlc_ms, op_id = excluded.op_id", + params![content_id, i64::from(liked), hlc_ms, op_id], + )?; + if let Some(track_id) = self.library.track_id_by_content_id(&content_id)? { + self.library.set_synced_like(track_id, liked, hlc_ms)?; + if liked { + self.library.remove_fed_like_by_content_id(&content_id)?; + } + } else if liked { + if let Some(fed) = fed { + self.library + .upsert_synced_fed_like(&to_library_fed(fed), hlc_ms)?; + } + } else { + self.library.remove_fed_like_by_content_id(&content_id)?; + } + self.notify_library(); + Ok(()) + } + + pub(super) fn apply_playlist( + &self, + playlist_id: &str, + title: &str, + deleted: bool, + hlc_ms: i64, + op_id: &str, + ) -> Result<()> { + if !self.lww_wins( + "sync_state_playlists", + "playlist_id", + playlist_id, + hlc_ms, + op_id, + )? { + return Ok(()); + } + lock(&self.conn).execute( + "INSERT INTO sync_state_playlists + (playlist_id, title, deleted, hlc_ms, op_id) + VALUES (?1, ?2, ?3, ?4, ?5) + ON CONFLICT(playlist_id) DO UPDATE SET title = excluded.title, + deleted = excluded.deleted, hlc_ms = excluded.hlc_ms, + op_id = excluded.op_id", + params![playlist_id, title, i64::from(deleted), hlc_ms, op_id], + )?; + if deleted { + self.library.delete_playlist_by_sync_id(playlist_id)?; + } else if !title.trim().is_empty() { + self.library.upsert_synced_playlist(playlist_id, title)?; + } + self.notify_library(); + Ok(()) + } + + #[allow(clippy::too_many_arguments)] + pub(super) fn apply_playlist_item( + &self, + playlist_id: &str, + content_id: &str, + present: bool, + position: i64, + fed: Option<&SyncedFedTrack>, + hlc_ms: i64, + op_id: &str, + ) -> Result<()> { + let Some(content_id) = music_dht::normalize_content_id(content_id) else { + return Ok(()); + }; + let current = lock(&self.conn) + .query_row( + "SELECT hlc_ms, op_id FROM sync_state_playlist_items + WHERE playlist_id = ?1 AND content_id = ?2", + params![playlist_id, content_id], + |row| Ok((row.get::<_, i64>(0)?, row.get::<_, String>(1)?)), + ) + .optional()?; + if current.is_some_and(|(current_hlc, current_op)| { + (current_hlc, current_op.as_str()) >= (hlc_ms, op_id) + }) { + return Ok(()); + } + lock(&self.conn).execute( + "INSERT INTO sync_state_playlist_items + (playlist_id, content_id, present, position, hlc_ms, op_id) + VALUES (?1, ?2, ?3, ?4, ?5, ?6) + ON CONFLICT(playlist_id, content_id) DO UPDATE SET + present = excluded.present, position = excluded.position, + hlc_ms = excluded.hlc_ms, op_id = excluded.op_id", + params![ + playlist_id, + content_id, + i64::from(present), + position, + hlc_ms, + op_id + ], + )?; + if present { + self.library + .add_content_id_to_synced_playlist(playlist_id, &content_id)?; + if let Some(fed) = fed { + self.library.upsert_fed_playlist_track( + playlist_id, + &to_library_fed(fed), + position, + )?; + } else if let Some(fed) = self.library.fed_like_by_content_id(&content_id)? { + self.library + .upsert_fed_playlist_track(playlist_id, &fed, position)?; + } + } else { + self.library + .remove_content_id_from_synced_playlist(playlist_id, &content_id)?; + } + self.notify_library(); + Ok(()) + } + + pub(super) fn lww_wins( + &self, + table: &str, + key_name: &str, + key: &str, + hlc_ms: i64, + op_id: &str, + ) -> Result { + let sql = format!("SELECT hlc_ms, op_id FROM {table} WHERE {key_name} = ?1"); + let current = lock(&self.conn) + .query_row(&sql, [key], |row| { + Ok((row.get::<_, i64>(0)?, row.get::<_, String>(1)?)) + }) + .optional()?; + Ok(current.is_none_or(|(current_hlc, current_op)| { + (hlc_ms, op_id) > (current_hlc, current_op.as_str()) + })) + } + + pub(super) fn apply_playback_command( + &self, + target_device_id: &str, + command: &PlaybackCommand, + op_id: &str, + ) -> Result<()> { + if target_device_id != self.ensure_identity()?.device_id { + return Ok(()); + } + let inserted = lock(&self.conn).execute( + "INSERT OR IGNORE INTO sync_playback_applied (op_id, applied_at_ms) + VALUES (?1, ?2)", + params![op_id, now_ms()], + )?; + if inserted > 0 { + let _ = self + .events + .try_send(InternalEvent::DevicePlaybackCommand(command.clone())); + } + Ok(()) + } + + pub(super) fn apply_playback_snapshot(&self, snapshot: PlaybackSnapshot) { + if self + .ensure_identity() + .is_ok_and(|identity| identity.device_id == snapshot.device_id) + { + return; + } + let changed = { + let mut playback = lock(&self.playback); + let changed = playback + .remote + .get(&snapshot.device_id) + .is_none_or(|current| snapshot.updated_at_ms > current.updated_at_ms); + if changed { + playback + .remote + .insert(snapshot.device_id.clone(), snapshot.clone()); + } + changed + }; + if changed { + let _ = self + .events + .try_send(InternalEvent::DevicePlaybackSnapshot(snapshot)); + } + } + + #[allow( + clippy::too_many_lines, + reason = "the snapshot is one transactional projection of all synchronized entities" + )] + pub(super) fn snapshot(&self) -> Result { + let conn = lock(&self.conn); + let mut snapshot = SyncSnapshot::default(); + { + let mut stmt = + conn.prepare("SELECT content_id, liked, hlc_ms, op_id FROM sync_state_likes")?; + let rows = stmt.query_map([], |row| { + Ok(( + row.get::<_, String>(0)?, + row.get::<_, i64>(1)? != 0, + row.get::<_, i64>(2)?, + row.get::<_, String>(3)?, + )) + })?; + for row in rows { + let (content_id, liked, hlc_ms, op_id) = row?; + if liked { + snapshot.likes.push(SnapshotLike { + content_id, + hlc_ms, + op_id, + fed: None, + }); + } else { + snapshot.unlikes.push(SnapshotLikeTombstone { + content_id, + hlc_ms, + op_id, + }); + } + } + } + let mut playlists = BTreeMap::::new(); + { + let mut stmt = conn.prepare( + "SELECT playlist_id, title, deleted, hlc_ms, op_id + FROM sync_state_playlists", + )?; + let rows = stmt.query_map([], |row| { + Ok(( + row.get::<_, String>(0)?, + row.get::<_, String>(1)?, + row.get::<_, i64>(2)? != 0, + row.get::<_, i64>(3)?, + row.get::<_, String>(4)?, + )) + })?; + for row in rows { + let (playlist_id, title, deleted, hlc_ms, op_id) = row?; + if deleted { + snapshot.deleted_playlists.push(SnapshotPlaylistTombstone { + playlist_id, + hlc_ms, + op_id, + }); + } else { + playlists.insert( + playlist_id.clone(), + SnapshotPlaylist { + playlist_id, + title, + hlc_ms, + op_id, + items: Vec::new(), + }, + ); + } + } + } + { + let mut stmt = conn.prepare( + "SELECT playlist_id, content_id, present, position, hlc_ms, op_id + FROM sync_state_playlist_items", + )?; + let rows = stmt.query_map([], |row| { + Ok(( + row.get::<_, String>(0)?, + row.get::<_, String>(1)?, + row.get::<_, i64>(2)? != 0, + row.get::<_, i64>(3)?, + row.get::<_, i64>(4)?, + row.get::<_, String>(5)?, + )) + })?; + for row in rows { + let (playlist_id, content_id, present, position, hlc_ms, op_id) = row?; + if present { + if let Some(playlist) = playlists.get_mut(&playlist_id) { + playlist.items.push(SnapshotPlaylistItem { + content_id, + position, + hlc_ms, + op_id, + fed: None, + }); + } + } else { + snapshot + .removed_playlist_items + .push(SnapshotPlaylistItemTombstone { + playlist_id, + content_id, + hlc_ms, + op_id, + }); + } + } + } + snapshot.playlists = playlists.into_values().collect(); + Ok(snapshot) + } + + pub(super) fn apply_snapshot(&self, snapshot: SyncSnapshot) -> Result<()> { + for like in snapshot.likes { + self.apply_like( + &like.content_id, + true, + like.fed.as_ref(), + like.hlc_ms, + &like.op_id, + )?; + } + for like in snapshot.unlikes { + self.apply_like(&like.content_id, false, None, like.hlc_ms, &like.op_id)?; + } + for playlist in snapshot.playlists { + self.apply_playlist( + &playlist.playlist_id, + &playlist.title, + false, + playlist.hlc_ms, + &playlist.op_id, + )?; + for item in playlist.items { + self.apply_playlist_item( + &playlist.playlist_id, + &item.content_id, + true, + item.position, + item.fed.as_ref(), + item.hlc_ms, + &item.op_id, + )?; + } + } + for playlist in snapshot.deleted_playlists { + self.apply_playlist( + &playlist.playlist_id, + "", + true, + playlist.hlc_ms, + &playlist.op_id, + )?; + } + for item in snapshot.removed_playlist_items { + self.apply_playlist_item( + &item.playlist_id, + &item.content_id, + false, + 0, + None, + item.hlc_ms, + &item.op_id, + )?; + } + self.notify(); + Ok(()) + } + + pub(super) fn notify(&self) { + let _ = self.events.try_send(InternalEvent::DevicesChanged); + } + + pub(super) fn notify_library(&self) { + let _ = self.events.try_send(InternalEvent::DeviceLibraryChanged); + } +} diff --git a/crates/backend/src/federation.rs b/crates/backend/src/federation.rs new file mode 100644 index 0000000..f7805cb --- /dev/null +++ b/crates/backend/src/federation.rs @@ -0,0 +1,858 @@ +//! Federation lifecycle, DHT search and catalog artwork fetching. + +use std::collections::{HashMap, HashSet}; +use std::hash::{DefaultHasher, Hash, Hasher}; +use std::path::PathBuf; +use std::sync::Arc; + +use anyhow::{Context, Result}; +use furumi_backend_api::{FederationDebugSnapshot, SearchResults, SearchStats}; +use furumi_domain::{ + Artist, ArtistKey, ArtistRef, Artwork, AudioSource, CatalogSource, ContentId, Release, + ReleaseKey, Track, TrackKey, +}; +use music_dht::catalog::{ + CATALOG_ALPN, CatalogArtist, CatalogImageHeader, CatalogRequest, CatalogResponse, +}; +use music_dht::{ + EndpointId, ItemKind, ItemSpec, LibraryItem, MusicDhtConfig, MusicDhtService, NetworkId, + RendezvousConfig, +}; +use serde::{Deserialize, Serialize}; +use tokio::io::{AsyncRead, AsyncReadExt as _}; + +pub const AUDIO_ALPN: &[u8] = b"furumi-fd/audio/1"; +pub const AUDIO_PROTOCOL_VERSION: u16 = 1; +const STREAM_BUFFER: u64 = 2 * 1024 * 1024; + +#[derive(Serialize)] +struct AudioRequest { + item_id: String, + offset: u64, + want_cover: bool, + metadata_only: bool, +} +#[derive(Deserialize)] +struct AudioHeader { + ok: bool, + #[serde(default)] + error: Option, + #[serde(default)] + mime_type: String, + #[serde(default)] + total_size: u64, + #[serde(default)] + cover_size: u64, + #[serde(default)] + artist_image_size: u64, + #[serde(default)] + metadata: Option, +} +#[derive(Clone, Default, Deserialize)] +pub struct TrackMetadata { + #[serde(default)] + pub title: String, + #[serde(default)] + pub artists: Vec, + #[serde(default)] + pub featured_artists: Vec, + #[serde(default)] + pub album_artists: Vec, + #[serde(default)] + pub release_title: String, + #[serde(default)] + pub release_type: Option, + pub year: Option, + pub track_number: Option, + pub disc_number: Option, + pub duration_seconds: Option, + pub audio_format: Option, + pub audio_bitrate: Option, + pub audio_sample_rate: Option, + pub audio_bit_depth: Option, +} +pub enum StreamEvent { + Ready(crate::streaming::GrowingFileReader, String), + Complete(PathBuf, Box>), + Failed(String), +} + +const MAX_IMAGE_BYTES: u64 = 16 * 1024 * 1024; +const IMAGE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(4); + +pub struct Client { + service: Arc, + media_dir: PathBuf, +} + +impl Client { + pub async fn start(data_dir: PathBuf, media_dir: PathBuf, network: &str) -> Result> { + tokio::fs::create_dir_all(&data_dir).await?; + tokio::fs::create_dir_all(&media_dir).await?; + let config = MusicDhtConfig::builder() + .data_dir(data_dir) + .network_id(NetworkId::from_name(network)) + .rendezvous(RendezvousConfig::default()) + .stream_protocol(CATALOG_ALPN) + .stream_protocol(AUDIO_ALPN) + .stream_protocol(music_dht::device_sync::SYNC_ALPN_V1) + .stream_protocol(music_dht::device_sync::SYNC_ALPN_V2) + .build() + .context("invalid federation configuration")?; + let (service, mut events) = MusicDhtService::start(config) + .await + .context("starting federation node")?; + tokio::spawn(async move { while events.recv().await.is_some() {} }); + Ok(Arc::new(Self { + service: Arc::new(service), + media_dir, + })) + } + + pub fn service(&self) -> Arc { + Arc::clone(&self.service) + } + + pub async fn debug_snapshot(&self) -> FederationDebugSnapshot { + let stored_dht_records = self.service.dht_record_count().await.ok(); + let published_items = self + .service + .list_local_items() + .await + .map_or(0, |items| items.len()); + FederationDebugSnapshot { + running: true, + endpoint_id: self.service.endpoint_id().to_string(), + dht_node_id: self.service.node_id().to_string(), + connected_peers: self.service.connected_peers().len(), + known_contacts: self.service.known_peers().len(), + stored_dht_records, + published_items, + error: None, + } + } + + /// Resolves album artwork for a queue item independently of the screen + /// from which the track was enqueued. + pub async fn artwork_for_track(&self, track: &Track) -> Option { + let peer_id = match &track.audio_source { + AudioSource::Federation { peer_id, .. } if !peer_id.is_empty() => peer_id.as_str(), + _ => track.key.federation_id()?.0, + }; + let owner = peer_id.parse::().ok()?; + let artist = track + .artists + .first() + .map(|artist| artist.name.as_str()) + .or_else(|| (!track.artist.is_empty()).then_some(track.artist.as_str()))?; + if track.release.is_empty() { + return None; + } + self.cached_image( + owner, + artist, + Some(&track.release), + &format!("release-{peer_id}-{artist}-{}", track.release), + ) + .await + } + + pub async fn search(&self, query: &str) -> Result<(SearchResults, SearchStats)> { + let outcome = self.service.search_network(query).await?; + let own = self.service.endpoint_id(); + let mut results = convert_items(&outcome.network_results, own); + self.fetch_artwork(&mut results).await; + let stats = SearchStats { + tracks: results.tracks.len(), + artists: results.artists.len(), + peers_queried: outcome.queried_nodes, + duration_ms: u64::try_from(outcome.duration.as_millis()).unwrap_or(u64::MAX), + }; + Ok((results, stats)) + } + + /// Resolves a portable queue entry when another connected device only + /// knows its stable audio content id. + pub async fn track_by_content_id(&self, content_id: &str) -> Result { + let outcome = self.service.search_content_id(content_id).await?; + let own = self.service.endpoint_id(); + let items = outcome + .local_results + .into_iter() + .chain(outcome.network_results) + .collect::>(); + convert_items(&items, own) + .tracks + .into_iter() + .next() + .context("no federation peer currently publishes this track") + } + + pub async fn publish(&self, specs: Vec) -> Result<()> { + self.service.sync_library(specs).await?; + Ok(()) + } + + pub async fn artist_card(&self, name: &str) -> Result<(SearchResults, SearchStats)> { + let started = std::time::Instant::now(); + let outcome = self.service.search_network(name).await?; + let own = self.service.endpoint_id(); + let normalized = music_dht::normalize_name(name); + let owners = outcome + .network_results + .iter() + .filter(|item| { + item.owner != own + && ((item.kind == ItemKind::Artist && item.normalized_name == normalized) + || item + .artist_names + .iter() + .chain(item.featured_artist_names.iter()) + .any(|artist| music_dht::normalize_name(artist) == normalized)) + }) + .map(|item| item.owner) + .collect::>(); + let mut catalogs = Vec::new(); + for owner in owners { + if let Ok(Ok(catalog)) = tokio::time::timeout( + std::time::Duration::from_secs(5), + fetch_catalog(&self.service, owner, name), + ) + .await + { + catalogs.push((owner.to_string(), catalog)); + } + } + let mut result = card_results(name, catalogs); + self.fetch_artwork(&mut result).await; + let stats = SearchStats { + tracks: result.tracks.len(), + artists: result.artists.len(), + peers_queried: outcome.queried_nodes, + duration_ms: u64::try_from(started.elapsed().as_millis()).unwrap_or(u64::MAX), + }; + Ok((result, stats)) + } + + pub async fn stream_track( + &self, + peer: &str, + item_id: &str, + directory: &std::path::Path, + stem: &str, + events: tokio::sync::mpsc::Sender, + ) -> Result<()> { + use tokio::io::AsyncWriteExt as _; + let owner: EndpointId = peer.parse().context("invalid peer id")?; + tokio::fs::create_dir_all(directory).await?; + let mut stream = self.service.open_stream(owner, AUDIO_ALPN).await?; + let mut request = serde_json::to_vec(&AudioRequest { + item_id: item_id.into(), + offset: 0, + want_cover: false, + metadata_only: false, + })?; + request.push(b'\n'); + stream.send.write_all(&request).await?; + stream.send.finish()?; + let header: AudioHeader = serde_json::from_slice(&read_line(&mut stream.recv).await?)?; + anyhow::ensure!( + header.ok, + "{}", + header.error.unwrap_or_else(|| "peer refused audio".into()) + ); + anyhow::ensure!( + header.cover_size == 0 && header.artist_image_size == 0, + "unexpected image data in audio stream" + ); + let extension = match header.mime_type.as_str() { + "audio/mpeg" => "mp3", + "audio/flac" | "audio/x-flac" => "flac", + "audio/ogg" => "ogg", + "audio/opus" => "opus", + "audio/wav" | "audio/x-wav" => "wav", + "audio/mp4" | "audio/x-m4a" => "m4a", + "audio/aac" => "aac", + _ => "bin", + }; + let final_path = directory.join(format!("{stem}.{extension}")); + let part_path = directory.join(format!(".{stem}.{extension}.part")); + let mut file = tokio::fs::File::create(&part_path).await?; + let (reader, writer) = crate::streaming::growing_file(&part_path)?; + let mut reader = Some(reader); + let mut started = false; + let mut received = 0u64; + let threshold = header.total_size.clamp(1, STREAM_BUFFER); + let mut chunk = vec![0; 64 * 1024]; + while let Some(n) = stream.recv.read(&mut chunk).await? { + file.write_all(&chunk[..n]).await?; + received += n as u64; + writer.add_available(n as u64); + if !started + && received >= threshold + && let Some(reader) = reader.take() + { + let _ = events + .send(StreamEvent::Ready(reader, header.mime_type.clone())) + .await; + started = true; + } + } + if !started + && received > 0 + && let Some(reader) = reader.take() + { + let _ = events + .send(StreamEvent::Ready(reader, header.mime_type.clone())) + .await; + } + writer.finish(); + file.flush().await?; + drop(file); + anyhow::ensure!( + header.total_size == 0 || received == header.total_size, + "incomplete audio download: {received}/{}", + header.total_size + ); + tokio::fs::rename(&part_path, &final_path).await?; + let _ = events + .send(StreamEvent::Complete(final_path, Box::new(header.metadata))) + .await; + Ok(()) + } + + async fn fetch_artwork(&self, results: &mut SearchResults) { + for artist in &mut results.artists { + let CatalogSource::Federation { peer_id } = &artist.source else { + continue; + }; + let Ok(owner) = peer_id.parse::() else { + continue; + }; + if let Some(path) = self + .cached_image( + owner, + &artist.name, + None, + &format!("artist-{peer_id}-{}", artist.name), + ) + .await + { + artist.artwork.uri = Some(path.to_string_lossy().into_owned()); + } + } + for release in &mut results.releases { + let CatalogSource::Federation { peer_id } = &release.source else { + continue; + }; + let Some(artist) = release.artists.first() else { + continue; + }; + let Ok(owner) = peer_id.parse::() else { + continue; + }; + if let Some(path) = self + .cached_image( + owner, + &artist.name, + Some(&release.title), + &format!("release-{peer_id}-{}-{}", artist.name, release.title), + ) + .await + { + let uri = path.to_string_lossy().into_owned(); + release.artwork.uri = Some(uri.clone()); + for track in &mut release.tracks { + if track.cover_uri.is_none() { + track.cover_uri = Some(uri.clone()); + } + } + for track in &mut results.tracks { + if track.release == release.title + && track + .artists + .first() + .is_some_and(|candidate| candidate.name == artist.name) + { + track.cover_uri = Some(uri.clone()); + } + } + } + } + for track in &mut results.tracks { + if track.cover_uri.is_some() || track.release.is_empty() { + continue; + } + let AudioSource::Federation { peer_id, .. } = &track.audio_source else { + continue; + }; + let Some(artist) = track.artists.first() else { + continue; + }; + let Ok(owner) = peer_id.parse::() else { + continue; + }; + if let Some(path) = self + .cached_image( + owner, + &artist.name, + Some(&track.release), + &format!("release-{peer_id}-{}-{}", artist.name, track.release), + ) + .await + { + track.cover_uri = Some(path.to_string_lossy().into_owned()); + } + } + } + + async fn cached_image( + &self, + owner: EndpointId, + artist: &str, + release: Option<&str>, + cache_key: &str, + ) -> Option { + let mut hasher = DefaultHasher::new(); + cache_key.hash(&mut hasher); + let base = self.media_dir.join(format!("{:016x}", hasher.finish())); + for extension in ["jpg", "png", "webp", "gif", "bmp"] { + let path = base.with_extension(extension); + if path.is_file() { + return Some(path); + } + } + let fetched = tokio::time::timeout( + IMAGE_TIMEOUT, + fetch_image(&self.service, owner, artist, release), + ) + .await + .ok()? + .ok()?; + let (bytes, extension) = fetched?; + let path = base.with_extension(extension); + tokio::fs::write(&path, bytes).await.ok()?; + Some(path) + } +} + +async fn fetch_catalog( + service: &MusicDhtService, + owner: EndpointId, + artist: &str, +) -> Result { + let mut stream = service.open_stream(owner, CATALOG_ALPN).await?; + let mut request = serde_json::to_vec(&CatalogRequest { + artist: artist.into(), + ..CatalogRequest::default() + })?; + request.push(b'\n'); + stream.send.write_all(&request).await?; + stream.send.finish()?; + let mut payload = Vec::new(); + stream + .recv + .take(4 * 1024 * 1024 + 1) + .read_to_end(&mut payload) + .await?; + anyhow::ensure!( + payload.len() <= 4 * 1024 * 1024, + "catalog response is too large" + ); + let response: CatalogResponse = serde_json::from_slice(&payload)?; + anyhow::ensure!( + response.ok, + "{}", + response.error.unwrap_or_else(|| "catalog rejected".into()) + ); + response.artist.context("empty artist catalog") +} + +#[allow( + clippy::too_many_lines, + reason = "wire catalog conversion is clearest as one pass" +)] +fn card_results(name: &str, catalogs: Vec<(String, CatalogArtist)>) -> SearchResults { + let mut results = SearchResults::default(); + if let Some((peer, _)) = catalogs.first() { + results.artists.push(Artist { + key: ArtistKey::Federation { + peer_id: peer.clone(), + id: music_dht::normalize_name(name), + }, + source: CatalogSource::Federation { + peer_id: peer.clone(), + }, + name: name.into(), + artwork: Artwork::default(), + release_count: 0, + track_count: 0, + }); + } + let mut release_slots = HashMap::::new(); + for (peer, catalog) in catalogs { + let artist_key = ArtistKey::Federation { + peer_id: peer.clone(), + id: music_dht::normalize_name(name), + }; + for remote in catalog.releases { + let normalized = music_dht::normalize_name(&remote.title); + let slot = *release_slots.entry(normalized.clone()).or_insert_with(|| { + results.releases.push(Release { + key: ReleaseKey::Federation { + peer_id: peer.clone(), + id: format!("name:{normalized}"), + }, + source: CatalogSource::Federation { + peer_id: peer.clone(), + }, + title: remote.title.clone(), + artists: vec![ArtistRef { + key: artist_key.clone(), + name: name.into(), + }], + featured_artists: Vec::new(), + release_type: remote.release_type.clone(), + year: remote.year, + artwork: Artwork::default(), + tracks: Vec::new(), + }); + results.releases.len() - 1 + }); + let release = &mut results.releases[slot]; + for item in remote.tracks { + let duplicate = release.tracks.iter().any(|track| { + music_dht::normalize_name(&track.title) + == music_dht::normalize_name(&item.title) + }); + if duplicate || item.item_id.is_empty() { + continue; + } + let content_id = item + .content_id + .as_deref() + .and_then(|id| ContentId::parse(id).ok()); + let artists = artist_refs(&peer, &item.artists); + let featured = artist_refs(&peer, &item.featured_artists); + let track = Track { + key: TrackKey::federation( + peer.clone(), + item.item_id.clone(), + content_id.clone(), + ), + title: item.title, + artist: artist_line(&item.artists, &item.featured_artists), + artists, + featured_artists: featured, + release: release.title.clone(), + release_id: release.key.clone(), + duration_seconds: item.duration_seconds.unwrap_or_default(), + track_number: item + .track_number + .and_then(|value| u32::try_from(value).ok()), + disc_number: item.disc_number.and_then(|value| u32::try_from(value).ok()), + cover_uri: release.artwork.uri.clone(), + audio_format: None, + audio_bitrate_kbps: None, + audio_sample_rate_hz: None, + audio_bit_depth: None, + file_size_bytes: None, + liked: false, + audio_source: AudioSource::Federation { + peer_id: peer.clone(), + content_id: content_id.unwrap_or_else(|| { + ContentId::parse(format!("b3:{}", "0".repeat(64))) + .expect("valid fallback") + }), + }, + }; + release.tracks.push(track.clone()); + results.tracks.push(track); + } + } + for appearance in catalog.appears_on { + if appearance.track.item_id.is_empty() { + continue; + } + let content_id = appearance + .track + .content_id + .as_deref() + .and_then(|id| ContentId::parse(id).ok()); + let release_key = ReleaseKey::Federation { + peer_id: peer.clone(), + id: format!( + "name:{}", + music_dht::normalize_name(&appearance.release_title) + ), + }; + results.tracks.push(Track { + key: TrackKey::federation( + peer.clone(), + appearance.track.item_id.clone(), + content_id.clone(), + ), + title: appearance.track.title, + artist: artist_line( + &appearance.track.artists, + &appearance.track.featured_artists, + ), + artists: artist_refs(&peer, &appearance.track.artists), + featured_artists: artist_refs(&peer, &appearance.track.featured_artists), + release: appearance.release_title, + release_id: release_key, + duration_seconds: appearance.track.duration_seconds.unwrap_or_default(), + track_number: appearance + .track + .track_number + .and_then(|value| u32::try_from(value).ok()), + disc_number: appearance + .track + .disc_number + .and_then(|value| u32::try_from(value).ok()), + cover_uri: None, + audio_format: None, + audio_bitrate_kbps: None, + audio_sample_rate_hz: None, + audio_bit_depth: None, + file_size_bytes: None, + liked: false, + audio_source: AudioSource::Federation { + peer_id: peer.clone(), + content_id: content_id.unwrap_or_else(|| { + ContentId::parse(format!("b3:{}", "0".repeat(64))).expect("valid fallback") + }), + }, + }); + } + } + for release in &mut results.releases { + populate_release_contributors(release); + } + if let Some(artist) = results.artists.first_mut() { + artist.release_count = results.releases.len(); + artist.track_count = results.tracks.len(); + } + results +} + +fn populate_release_contributors(release: &mut Release) { + let mut known = release + .artists + .iter() + .chain(release.featured_artists.iter()) + .map(|artist| music_dht::normalize_name(&artist.name)) + .collect::>(); + for artist in release + .tracks + .iter() + .flat_map(|track| track.artists.iter().chain(track.featured_artists.iter())) + { + if known.insert(music_dht::normalize_name(&artist.name)) { + release.featured_artists.push(artist.clone()); + } + } +} + +fn convert_items(items: &[LibraryItem], own: EndpointId) -> SearchResults { + let mut results = SearchResults::default(); + let mut artists = HashMap::<(String, String), Artist>::new(); + let mut releases = HashSet::<(String, String)>::new(); + let mut tracks = HashSet::<(String, String)>::new(); + for item in items.iter().filter(|item| item.owner != own) { + let peer = item.owner.to_string(); + let source = CatalogSource::Federation { + peer_id: peer.clone(), + }; + let refs = artist_refs(&peer, &item.artist_names); + let featured = artist_refs(&peer, &item.featured_artist_names); + for name in item + .artist_names + .iter() + .chain(item.featured_artist_names.iter()) + { + note_artist(&mut artists, &peer, name); + } + match item.kind { + ItemKind::Artist => { + note_artist(&mut artists, &peer, &item.name); + } + ItemKind::Release => { + if releases.insert((peer.clone(), item.id.to_string())) { + results.releases.push(Release { + key: ReleaseKey::Federation { + peer_id: peer.clone(), + id: item.id.to_string(), + }, + source, + title: item.name.clone(), + artists: refs, + featured_artists: Vec::new(), + release_type: item + .release_type + .clone() + .unwrap_or_else(|| "release".into()), + year: item.year, + artwork: Artwork::default(), + tracks: Vec::new(), + }); + } + } + ItemKind::Track => { + if !tracks.insert((peer.clone(), item.id.to_string())) { + continue; + } + let Some(content_id) = item + .content_id + .as_deref() + .and_then(|value| ContentId::parse(value).ok()) + else { + continue; + }; + let release = item.release_title.clone().unwrap_or_default(); + results.tracks.push(Track { + key: TrackKey::federation( + peer.clone(), + item.id.to_string(), + Some(content_id.clone()), + ), + title: item.name.clone(), + artist: artist_line(&item.artist_names, &item.featured_artist_names), + artists: refs.clone(), + featured_artists: featured, + release: release.clone(), + release_id: ReleaseKey::Federation { + peer_id: peer.clone(), + id: format!("name:{}", music_dht::normalize_name(&release)), + }, + duration_seconds: item.duration_seconds.unwrap_or_default(), + track_number: item + .track_number + .and_then(|value| u32::try_from(value).ok()), + disc_number: item.disc_number.and_then(|value| u32::try_from(value).ok()), + cover_uri: None, + audio_format: None, + audio_bitrate_kbps: None, + audio_sample_rate_hz: None, + audio_bit_depth: None, + file_size_bytes: None, + liked: false, + audio_source: AudioSource::Federation { + peer_id: peer, + content_id, + }, + }); + } + } + } + results.artists = artists.into_values().collect(); + results + .artists + .sort_by(|left, right| left.name.cmp(&right.name)); + results +} + +fn note_artist(artists: &mut HashMap<(String, String), Artist>, peer: &str, name: &str) { + let normalized = music_dht::normalize_name(name); + if normalized.is_empty() { + return; + } + artists + .entry((peer.to_owned(), normalized.clone())) + .or_insert_with(|| Artist { + key: ArtistKey::Federation { + peer_id: peer.to_owned(), + id: normalized, + }, + source: CatalogSource::Federation { + peer_id: peer.to_owned(), + }, + name: name.to_owned(), + artwork: Artwork::default(), + release_count: 0, + track_count: 0, + }); +} + +fn artist_refs(peer: &str, names: &[String]) -> Vec { + names + .iter() + .map(|name| ArtistRef { + key: ArtistKey::Federation { + peer_id: peer.to_owned(), + id: music_dht::normalize_name(name), + }, + name: name.clone(), + }) + .collect() +} + +fn artist_line(main: &[String], featured: &[String]) -> String { + let mut line = main.join(", "); + if !featured.is_empty() { + if !line.is_empty() { + line.push_str(" feat. "); + } + line.push_str(&featured.join(", ")); + } + line +} + +async fn fetch_image( + service: &MusicDhtService, + owner: EndpointId, + artist: &str, + release: Option<&str>, +) -> Result, &'static str)>> { + let mut stream = service.open_stream(owner, CATALOG_ALPN).await?; + let request = CatalogRequest { + artist: artist.to_owned(), + want: Some( + if release.is_some() { + "release_cover" + } else { + "artist_image" + } + .into(), + ), + release: release.map(str::to_owned), + cursor: None, + limit: None, + }; + let mut line = serde_json::to_vec(&request)?; + line.push(b'\n'); + stream.send.write_all(&line).await?; + stream.send.finish()?; + let header: CatalogImageHeader = serde_json::from_slice(&read_line(&mut stream.recv).await?)?; + if !header.ok || header.size == 0 { + return Ok(None); + } + anyhow::ensure!( + header.size <= MAX_IMAGE_BYTES, + "federated image exceeds size limit" + ); + let image_size = + usize::try_from(header.size).context("image is too large for this platform")?; + let mut bytes = vec![0; image_size]; + stream.recv.read_exact(&mut bytes).await?; + let extension = match header.mime_type.as_str() { + "image/png" => "png", + "image/webp" => "webp", + "image/gif" => "gif", + "image/bmp" => "bmp", + _ => "jpg", + }; + Ok(Some((bytes, extension))) +} + +async fn read_line(reader: &mut (impl AsyncRead + Unpin)) -> Result> { + let mut line = Vec::new(); + loop { + let byte = reader.read_u8().await?; + if byte == b'\n' { + return Ok(line); + } + anyhow::ensure!(line.len() < 64 * 1024, "catalog header is too large"); + line.push(byte); + } +} diff --git a/crates/backend/src/lib.rs b/crates/backend/src/lib.rs new file mode 100644 index 0000000..7d4a5b4 --- /dev/null +++ b/crates/backend/src/lib.rs @@ -0,0 +1,1816 @@ +//! Backend runtime and replaceable service implementations. + +use std::collections::{HashMap, HashSet}; +use std::fmt; +use std::io; +use std::sync::mpsc as std_mpsc; +use std::thread; +use std::time::{Duration, Instant}; + +use furumi_backend_api::{ + BackendCommand, BackendSnapshot, BuildInfoSnapshot, ConnectedDeviceSnapshot, + ConnectedDevicesSnapshot, DevicePlaybackRole, DevicePresence, DeviceTrust, + FederationActivitySnapshot, FederationDebugSnapshot, FederationOperation, LibrarySnapshot, + PendingPairingSnapshot, PlaybackStatus, PlaylistSnapshot, RemoteData, RequestId, SearchResults, + SearchSnapshot, SearchStats, SendCommandError, SettingsSnapshot, VersionEntrySnapshot, +}; +use furumi_domain::{ + Artist, ArtistId, ArtistKey, ArtistRef, Artwork, AudioSource, CatalogSource, ContentId, + LocalTrackId, Release, ReleaseId, ReleaseKey, Track, TrackKey, +}; +use tokio::sync::{mpsc, watch}; +use tokio_util::sync::CancellationToken; + +mod actor_devices; +mod audio; +mod devices; +mod federation; +mod settings; +mod streaming; +mod support; + +use support::{ + apply_federated_metadata, expand_tilde, extrapolated_control_position, federation_specs, + find_catalog_track, library_snapshot, library_track, local_search_results, + merge_release_preserving_local, merge_search_results, normalize_device_name, + playback_state_acknowledges_command, portable_playback_placeholder, + remote_snapshot_has_authority, runtime_build_info, sanitize_filename, selected_track_position, + spawn_settings_worker, track_is_liked, track_to_library_fed, track_to_playback_track, + track_to_synced_fed, unix_time_ms, volume_percent, +}; + +use settings::SettingsStore; + +const COMMAND_CAPACITY: usize = 64; +const INTERNAL_CAPACITY: usize = 32; +const CONTROL_COMMAND_ACK_TIMEOUT: Duration = Duration::from_secs(12); +const CONTROL_POSITION_ACK_TOLERANCE_SECONDS: f64 = 3.0; + +#[derive(Clone)] +pub struct BackendHandle { + commands: mpsc::Sender, + snapshots: watch::Receiver, +} + +impl BackendHandle { + /// Enqueues a command without blocking the UI thread. + /// + /// # Errors + /// + /// Returns [`SendCommandError::Busy`] when the bounded queue is full and + /// [`SendCommandError::Closed`] after backend shutdown. + pub fn try_send(&self, command: BackendCommand) -> Result<(), SendCommandError> { + self.commands + .try_send(command) + .map_err(|error| match error { + mpsc::error::TrySendError::Full(_) => SendCommandError::Busy, + mpsc::error::TrySendError::Closed(_) => SendCommandError::Closed, + }) + } + + #[must_use] + pub fn subscribe(&self) -> watch::Receiver { + self.snapshots.clone() + } +} + +/// Starts the backend actor and real audio worker. +/// +/// # Errors +/// +/// Returns an I/O error when the runtime or its owner thread cannot be created. +pub fn spawn_backend() -> Result { + let project_dirs = directories::ProjectDirs::from("cy", "hexor", "Furumi") + .ok_or(BackendStartupError::DataDirectoryUnavailable)?; + let settings_path = project_dirs.data_local_dir().join("furumi-desktop.sqlite3"); + let federation_data_dir = project_dirs.data_local_dir().join("federation"); + let federation_media_dir = project_dirs.cache_dir().join("federation-media"); + let devices_db_path = project_dirs + .data_local_dir() + .join("devices") + .join("sync.sqlite3"); + let settings_store = SettingsStore::open(&settings_path)?; + let loaded_settings = settings_store.load()?; + let library_path = furumi_library::default_db_path().map_err(BackendStartupError::Library)?; + let catalog = std::sync::Arc::new( + furumi_library::Library::open(&library_path).map_err(BackendStartupError::Library)?, + ); + let loaded_library = library_snapshot(&catalog).map_err(BackendStartupError::Library)?; + let runtime = tokio::runtime::Builder::new_multi_thread() + .worker_threads(2) + .enable_io() + .enable_time() + .thread_name("furumi-backend") + .build()?; + let (command_tx, command_rx) = mpsc::channel(COMMAND_CAPACITY); + let (snapshot_tx, snapshot_rx) = watch::channel(BackendSnapshot::default()); + let (internal_tx, internal_rx) = mpsc::channel(INTERNAL_CAPACITY); + let devices = devices::DeviceSync::open( + &devices_db_path, + std::sync::Arc::clone(&catalog), + internal_tx.clone(), + ) + .map_err(BackendStartupError::Devices)?; + let audio_events = internal_tx.clone(); + let audio = audio::spawn(move |event| { + let _ = audio_events.blocking_send(InternalEvent::Audio(event)); + })?; + + thread::Builder::new() + .name("furumi-backend-runtime".into()) + .spawn(move || { + runtime.block_on(run_actor(ActorBootstrap { + command_rx, + snapshots: snapshot_tx, + settings_store, + loaded_settings, + catalog, + loaded_library, + internal_tx, + internal_rx, + audio, + federation_data_dir, + federation_media_dir, + devices, + })); + })?; + + Ok(BackendHandle { + commands: command_tx, + snapshots: snapshot_rx, + }) +} + +#[derive(Debug)] +pub enum BackendStartupError { + DataDirectoryUnavailable, + Io(io::Error), + Database(rusqlite::Error), + Library(anyhow::Error), + Devices(anyhow::Error), +} + +impl fmt::Display for BackendStartupError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::DataDirectoryUnavailable => { + formatter.write_str("application data directory is unavailable") + } + Self::Io(error) => write!(formatter, "backend runtime: {error}"), + Self::Database(error) => write!(formatter, "settings database: {error}"), + Self::Library(error) => write!(formatter, "music library: {error:#}"), + Self::Devices(error) => write!(formatter, "connected devices: {error:#}"), + } + } +} + +impl std::error::Error for BackendStartupError {} + +impl From for BackendStartupError { + fn from(error: io::Error) -> Self { + Self::Io(error) + } +} + +impl From for BackendStartupError { + fn from(error: rusqlite::Error) -> Self { + Self::Database(error) + } +} + +enum InternalEvent { + SearchFinished { + request_id: RequestId, + results: Result<(SearchResults, SearchStats), String>, + }, + DetailFinished { + request_id: RequestId, + key: ArtistKey, + name: String, + results: Result<(SearchResults, SearchStats), String>, + release: Option, + }, + FederationStarted(Result, String>), + SettingsPersisted(Result<(), String>), + Audio(audio::Event), + FederatedStreamReady { + key: TrackKey, + reader: streaming::GrowingFileReader, + mime_type: String, + }, + FederatedDownloadComplete { + key: TrackKey, + path: std::path::PathBuf, + keep: bool, + metadata: Option, + }, + FederatedDownloadFailed { + key: TrackKey, + message: String, + }, + FederatedContentResolved { + key: TrackKey, + result: Result, + }, + QueueArtworkResolved { + request_id: String, + keys: Vec, + cover_uri: Option, + }, + FederationDebugUpdated(FederationDebugSnapshot), + DevicesChanged, + DeviceLibraryChanged, + DeviceNamePublishDue(String), + DeviceNamePublished(Result<(), String>), + DeviceOperationFinished(Result), + DevicePlaybackSnapshot(music_dht::device_sync::PlaybackSnapshot), + DevicePlaybackCommand(music_dht::device_sync::PlaybackCommand), +} + +enum DeviceOperationResult { + Invite(String), + Connected(String), +} + +struct Actor { + state: BackendSnapshot, + library: LibrarySnapshot, + snapshots: watch::Sender, + internal: mpsc::Sender, + active_search: Option<(RequestId, CancellationToken)>, + active_detail: Option, + settings: std_mpsc::Sender, + catalog: std::sync::Arc, + audio: audio::Controller, + federation: Option>, + federation_data_dir: std::path::PathBuf, + federation_media_dir: std::path::PathBuf, + ephemeral_audio: Option<(TrackKey, std::path::PathBuf)>, + pending_queue_artwork: HashSet, + federation_debug_pending: bool, + devices: std::sync::Arc, + device_service: Option>, + device_role: DevicePlaybackRole, + active_device_id: String, + active_device_name: String, + control_anchor: Option, + pending_control: Option, +} + +#[derive(Debug, Clone)] +struct ControlPlaybackAnchor { + device_id: String, + state: music_dht::device_sync::PlaybackStateWire, + observed_at: Instant, +} + +#[derive(Debug, Clone)] +struct PendingControlState { + device_id: String, + state: music_dht::device_sync::PlaybackStateWire, + seek: bool, + sent_at: Instant, +} + +struct ActorBootstrap { + command_rx: mpsc::Receiver, + snapshots: watch::Sender, + settings_store: SettingsStore, + loaded_settings: SettingsSnapshot, + catalog: std::sync::Arc, + loaded_library: LibrarySnapshot, + internal_tx: mpsc::Sender, + internal_rx: mpsc::Receiver, + audio: audio::Controller, + federation_data_dir: std::path::PathBuf, + federation_media_dir: std::path::PathBuf, + devices: std::sync::Arc, +} + +struct ActorRuntime { + actor: Actor, + command_rx: mpsc::Receiver, + internal_rx: mpsc::Receiver, +} + +fn initialize_actor(bootstrap: ActorBootstrap) -> ActorRuntime { + let ActorBootstrap { + command_rx, + snapshots, + settings_store, + mut loaded_settings, + catalog, + loaded_library, + internal_tx, + internal_rx, + audio, + federation_data_dir, + federation_media_dir, + devices, + } = bootstrap; + let (settings_tx, settings_rx) = std_mpsc::channel(); + spawn_settings_worker(settings_store, settings_rx, internal_tx.clone()); + let library = loaded_library; + let (active_device_id, mut active_device_name) = devices.identity().unwrap_or_default(); + let mut settings_error = None; + let configured_device_name = if loaded_settings.device_name.trim().is_empty() { + active_device_name.clone() + } else { + normalize_device_name(&loaded_settings.device_name) + }; + if configured_device_name != active_device_name + && let Err(error) = devices.set_device_name(&configured_device_name, None) + { + settings_error = Some(format!("device name: {error:#}")); + } else if !configured_device_name.is_empty() { + active_device_name.clone_from(&configured_device_name); + } + if loaded_settings.device_name != configured_device_name { + loaded_settings.device_name = configured_device_name; + let _ = settings_tx.send(loaded_settings.clone()); + } + let initial_state = BackendSnapshot { + settings: loaded_settings, + build_info: runtime_build_info(), + settings_error, + ..BackendSnapshot::default() + }; + let mut actor = Actor { + state: initial_state, + library, + snapshots, + internal: internal_tx, + active_search: None, + active_detail: None, + settings: settings_tx, + catalog, + audio, + federation: None, + federation_data_dir, + federation_media_dir, + ephemeral_audio: None, + pending_queue_artwork: HashSet::new(), + federation_debug_pending: false, + devices, + device_service: None, + device_role: DevicePlaybackRole::Active, + active_device_id, + active_device_name, + control_anchor: None, + pending_control: None, + }; + actor.refresh_connected_devices(); + ActorRuntime { + actor, + command_rx, + internal_rx, + } +} + +async fn run_actor(bootstrap: ActorBootstrap) { + let ActorRuntime { + mut actor, + mut command_rx, + mut internal_rx, + } = initialize_actor(bootstrap); + let mut playback_tick = tokio::time::interval(Duration::from_millis(250)); + playback_tick.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); + let mut federation_debug_tick = tokio::time::interval(Duration::from_secs(3)); + federation_debug_tick.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); + + loop { + tokio::select! { + command = command_rx.recv() => { + let Some(command) = command else { break }; + if matches!(command, BackendCommand::Shutdown) { + break; + } + actor.handle_command(command); + } + event = internal_rx.recv() => { + if let Some(event) = event { + actor.handle_internal(event); + } + } + _ = playback_tick.tick() => actor.tick_playback(), + _ = federation_debug_tick.tick() => actor.refresh_federation_debug(), + } + } + if let Some((_, token)) = actor.active_search.take() { + token.cancel(); + } + actor.audio.stop(); +} + +impl Actor { + fn start_federation(&mut self) { + if !self.state.settings.federation_enabled + || self.state.settings.network_id.trim().is_empty() + { + self.federation = None; + self.federation_debug_pending = false; + self.state.federation_debug = FederationDebugSnapshot::default(); + return; + } + let data_dir = self.federation_data_dir.clone(); + let media_dir = self.federation_media_dir.clone(); + let network = self.state.settings.network_id.trim().to_owned(); + let internal = self.internal.clone(); + self.state.federation_activity = FederationActivitySnapshot { + operation: FederationOperation::Idle, + pending: true, + stats: None, + error: None, + }; + self.publish(); + tokio::spawn(async move { + let result = federation::Client::start(data_dir, media_dir, &network) + .await + .map_err(|error| format!("federation: {error:#}")); + let _ = internal + .send(InternalEvent::FederationStarted(result)) + .await; + }); + } + + fn refresh_federation_debug(&mut self) { + if self.federation_debug_pending { + return; + } + let Some(client) = self.federation.clone() else { + return; + }; + self.federation_debug_pending = true; + let internal = self.internal.clone(); + tokio::spawn(async move { + let debug = client.debug_snapshot().await; + let _ = internal + .send(InternalEvent::FederationDebugUpdated(debug)) + .await; + }); + } + + fn resolve_queue_artwork(&mut self) { + let missing = self + .state + .queue + .items() + .iter() + .filter(|item| item.track.cover_uri.is_none()) + .map(|item| item.track.clone()) + .collect::>(); + let known = missing + .iter() + .filter_map(|track| { + self.known_cover_uri(track) + .map(|cover| (track.key.clone(), cover)) + }) + .collect::>(); + for (key, cover_uri) in known { + if let Some(mut track) = self + .state + .queue + .items() + .iter() + .find(|item| item.track.key.matches(&key)) + .map(|item| item.track.clone()) + { + track.cover_uri = Some(cover_uri); + self.state.queue.replace_track(&key, &track); + } + } + + let Some(client) = self.federation.clone() else { + return; + }; + let mut requests = HashMap::)>::new(); + for track in self + .state + .queue + .items() + .iter() + .filter(|item| { + item.track.cover_uri.is_none() + && matches!(item.track.audio_source, AudioSource::Federation { .. }) + }) + .map(|item| item.track.clone()) + .collect::>() + { + let Some(request_id) = Self::queue_artwork_request_id(&track) else { + continue; + }; + requests + .entry(request_id) + .and_modify(|(_, keys)| keys.push(track.key.clone())) + .or_insert_with(|| (track.clone(), vec![track.key.clone()])); + } + for (request_id, (track, keys)) in requests { + if !self.pending_queue_artwork.insert(request_id.clone()) { + continue; + } + let internal = self.internal.clone(); + let client = client.clone(); + tokio::spawn(async move { + let cover_uri = client + .artwork_for_track(&track) + .await + .map(|path| path.to_string_lossy().into_owned()); + let _ = internal + .send(InternalEvent::QueueArtworkResolved { + request_id, + keys, + cover_uri, + }) + .await; + }); + } + } + + fn queue_artwork_request_id(track: &Track) -> Option { + let peer = match &track.audio_source { + AudioSource::Federation { peer_id, .. } if !peer_id.is_empty() => peer_id.as_str(), + _ => track.key.federation_id()?.0, + }; + let artist = track + .artists + .first() + .map(|artist| artist.name.as_str()) + .or_else(|| (!track.artist.is_empty()).then_some(track.artist.as_str()))?; + (!track.release.is_empty()).then(|| { + format!( + "{peer}|{}|{}", + music_dht::normalize_name(artist), + music_dht::normalize_name(&track.release) + ) + }) + } + + fn known_cover_uri(&self, track: &Track) -> Option { + let cover_from_release = |release: &Release| { + let same_release = release.key == track.release_id + || (music_dht::normalize_name(&release.title) + == music_dht::normalize_name(&track.release) + && release.tracks.iter().any(|candidate| { + candidate.same_catalog_track(track) + || candidate + .artists + .iter() + .any(|artist| track.artists.contains(artist)) + })); + same_release + .then(|| release.artwork.uri.clone()) + .flatten() + .or_else(|| { + release + .tracks + .iter() + .find(|candidate| candidate.same_catalog_track(track)) + .and_then(|candidate| candidate.cover_uri.clone()) + }) + }; + self.library + .featured_releases + .iter() + .chain(self.state.search.results.releases.iter()) + .find_map(cover_from_release) + .or_else(|| { + let request_id = Self::queue_artwork_request_id(track)?; + self.state + .queue + .items() + .iter() + .find(|item| { + item.track.cover_uri.is_some() + && Self::queue_artwork_request_id(&item.track).as_deref() + == Some(request_id.as_str()) + }) + .and_then(|item| item.track.cover_uri.clone()) + }) + .or_else(|| { + self.library + .playlists + .iter() + .flat_map(|playlist| playlist.tracks.iter()) + .chain(self.library.recently_played.iter()) + .chain(self.state.search.results.tracks.iter()) + .find(|candidate| candidate.same_catalog_track(track)) + .and_then(|candidate| candidate.cover_uri.clone()) + }) + } + + #[allow( + clippy::too_many_lines, + reason = "central exhaustive actor command dispatcher" + )] + fn handle_command(&mut self, command: BackendCommand) { + match command { + BackendCommand::Initialize => { + if let Ok(library) = library_snapshot(&self.catalog) { + self.library = library; + } + self.state.library = RemoteData::Ready(self.library.clone()); + self.publish(); + self.start_federation(); + } + BackendCommand::TogglePlayback => match self.state.playback.status { + PlaybackStatus::Stopped => self.play_current(), + PlaybackStatus::Paused => { + self.state.playback.status = PlaybackStatus::Playing; + if self.device_role == DevicePlaybackRole::Active { + self.audio.resume(); + } else { + self.send_control_state(false); + } + self.publish(); + } + PlaybackStatus::Playing => { + self.state.playback.status = PlaybackStatus::Paused; + if self.device_role == DevicePlaybackRole::Active { + self.audio.pause(); + } else { + self.send_control_state(false); + } + self.publish(); + } + }, + BackendCommand::Play => match self.state.playback.status { + PlaybackStatus::Stopped => self.play_current(), + PlaybackStatus::Paused => { + self.state.playback.status = PlaybackStatus::Playing; + if self.device_role == DevicePlaybackRole::Active { + self.audio.resume(); + } else { + self.send_control_state(false); + } + self.publish(); + } + PlaybackStatus::Playing => {} + }, + BackendCommand::Pause => { + if self.state.playback.status == PlaybackStatus::Playing { + self.state.playback.status = PlaybackStatus::Paused; + if self.device_role == DevicePlaybackRole::Active { + self.audio.pause(); + } else { + self.send_control_state(false); + } + self.publish(); + } + } + BackendCommand::Stop => { + if self.device_role == DevicePlaybackRole::Active { + self.audio.stop(); + } + self.remove_ephemeral_audio(); + self.state.playback.status = PlaybackStatus::Stopped; + self.state.playback.position_seconds = 0.0; + self.send_control_state(true); + self.publish(); + } + BackendCommand::Seek { position_seconds } => { + self.state.playback.position_seconds = + position_seconds.clamp(0.0, self.state.playback.duration_seconds.max(0.0)); + if self.device_role == DevicePlaybackRole::Active { + self.audio.seek(Duration::from_secs_f64( + self.state.playback.position_seconds, + )); + } else { + self.send_control_state(true); + } + self.publish(); + } + BackendCommand::SetVolume { volume } => { + self.state.playback.volume = volume.clamp(0.0, 1.0); + if self.device_role == DevicePlaybackRole::Active { + self.audio.set_volume(self.state.playback.volume); + } else { + self.send_control_state(false); + } + self.publish(); + } + BackendCommand::PlayRelease { release_id, start } => { + if let Some(release) = self.release(&release_id).cloned() { + self.state.queue.replace_context(release.tracks, start); + self.resolve_queue_artwork(); + self.play_current(); + } + } + BackendCommand::PlayTrack { track } => { + if let Some(track) = self.track(&track).cloned() { + self.state.queue.replace_context(vec![track], 0); + self.resolve_queue_artwork(); + self.play_current(); + } + } + BackendCommand::PlayQueueItem { item_id } => { + if self.state.queue.select_item(item_id).is_some() { + self.play_current(); + } + } + BackendCommand::PlayContext { tracks, selected } => { + let tracks = self.resolve_tracks(&tracks); + if !tracks.is_empty() { + let start = selected_track_position(&tracks, &selected); + self.state.queue.replace_context(tracks, start); + self.resolve_queue_artwork(); + self.play_current(); + } + } + BackendCommand::AddNext { tracks } => { + let tracks = self.resolve_tracks(&tracks); + self.state.queue.add_next(tracks); + self.resolve_queue_artwork(); + self.send_control_state(false); + self.publish(); + } + BackendCommand::AddToEnd { tracks } => { + let tracks = self.resolve_tracks(&tracks); + self.state.queue.add_to_end(tracks); + self.resolve_queue_artwork(); + self.send_control_state(false); + self.publish(); + } + BackendCommand::ToggleLike { track } => self.toggle_like(&track), + BackendCommand::CreatePlaylist { title } => { + let title = title.trim(); + if title.is_empty() { + return; + } + match self.catalog.create_playlist(title) { + Ok(playlist) => { + if let Err(error) = self.devices.record_playlist_created(playlist.id, title) + { + self.state.settings_error = Some(format!("playlist sync: {error:#}")); + } + self.refresh_library(); + } + Err(error) => { + self.state.settings_error = Some(format!("create playlist: {error:#}")); + self.publish(); + } + } + } + BackendCommand::RenamePlaylist { playlist_id, title } => { + let title = title.trim(); + if title.is_empty() { + return; + } + let result = self + .catalog + .update_playlist(playlist_id, title, None) + .and_then(|()| self.devices.record_playlist_renamed(playlist_id, title)); + if let Err(error) = result { + self.state.settings_error = Some(format!("rename playlist: {error:#}")); + } + self.refresh_library(); + } + BackendCommand::DeletePlaylist { playlist_id } => { + let result = self + .catalog + .playlist_sync_id(playlist_id) + .and_then(|sync_id| { + self.catalog.delete_playlist(playlist_id)?; + if let Some(sync_id) = sync_id { + self.devices.record_playlist_deleted(sync_id)?; + } + Ok(()) + }); + if let Err(error) = result { + self.state.settings_error = Some(format!("delete playlist: {error:#}")); + } + self.refresh_library(); + } + BackendCommand::AddToPlaylist { + playlist_id, + tracks, + } => self.add_to_playlist(playlist_id, &tracks), + BackendCommand::RemoveFromPlaylist { + playlist_id, + tracks, + } => self.remove_from_playlist(playlist_id, &tracks), + BackendCommand::CreateDeviceInvite => self.create_device_invite(), + BackendCommand::ConnectDevice { invite } => self.connect_device(invite), + BackendCommand::AnswerDevicePairing { + request_id, + accept, + use_requester_group, + } => { + if let Err(error) = + self.devices + .answer_pairing(&request_id, accept, use_requester_group) + { + self.state.connected_devices.error = Some(format!("pairing: {error:#}")); + } + self.refresh_connected_devices(); + self.publish(); + } + BackendCommand::SelectPlaybackDevice { device_id } => { + self.select_playback_device(&device_id); + } + BackendCommand::Next => { + if self.state.queue.advance().is_some() { + self.play_current(); + } + } + BackendCommand::Previous => { + if self.state.playback.position_seconds > 5.0 { + self.state.playback.position_seconds = 0.0; + if self.device_role == DevicePlaybackRole::Active { + self.audio.seek(Duration::ZERO); + } else { + self.send_control_state(true); + } + self.publish(); + } else if self.state.queue.previous().is_some() { + self.play_current(); + } + } + BackendCommand::Search { request_id, query } => self.start_search(request_id, query), + BackendCommand::CancelSearch { request_id } => self.cancel_search(request_id), + BackendCommand::LoadArtist { + request_id, + key, + name, + } => self.load_detail(request_id, key, name, None), + BackendCommand::LoadRelease { + request_id, + key, + artist_key, + artist_name, + } => self.load_detail(request_id, artist_key, artist_name, Some(key)), + BackendCommand::UpdateSettings(settings) => { + let federation_changed = self.state.settings.federation_enabled + != settings.federation_enabled + || self.state.settings.network_id != settings.network_id; + let device_name_changed = self.state.settings.device_name != settings.device_name; + let pending_device_name = settings.device_name.clone(); + self.state.settings = settings.clone(); + self.state.settings_error = None; + self.publish(); + if self.settings.send(settings).is_err() { + self.state.settings_error = Some("settings storage is unavailable".into()); + self.publish(); + } + if federation_changed { + self.federation = None; + self.start_federation(); + } + if device_name_changed { + self.apply_local_device_name(&pending_device_name); + self.schedule_device_name_publish(pending_device_name); + } + } + BackendCommand::Shutdown => {} + } + } + + fn start_search(&mut self, request_id: RequestId, query: String) { + self.active_detail = None; + if let Some((_, token)) = self.active_search.take() { + token.cancel(); + } + let token = CancellationToken::new(); + self.active_search = Some((request_id, token.clone())); + let local = local_search_results(&self.catalog, &query).unwrap_or_default(); + self.state.search = SearchSnapshot { + request_id: Some(request_id), + results: local, + federation_pending: self.state.settings.federation_enabled, + stats: None, + error: None, + }; + self.state.federation_activity = FederationActivitySnapshot { + operation: FederationOperation::Search, + pending: self.state.settings.federation_enabled, + stats: None, + error: None, + }; + self.publish(); + + let federation = self.federation.clone(); + let internal = self.internal.clone(); + tokio::spawn(async move { + tokio::select! { + () = token.cancelled() => {} + () = async {} => { + let results = if let Some(federation) = federation { + federation.search(&query).await.map_err(|error| error.to_string()) + } else { + Ok((SearchResults::default(), SearchStats::default())) + }; + let _ = internal.send(InternalEvent::SearchFinished { request_id, results }).await; + } + } + }); + } + + fn cancel_search(&mut self, request_id: RequestId) { + let matches = self + .active_search + .as_ref() + .is_some_and(|(active, _)| *active == request_id); + if matches { + if let Some((_, token)) = self.active_search.take() { + token.cancel(); + } + self.state.search = SearchSnapshot::default(); + self.publish(); + } + } + + fn load_detail( + &mut self, + request_id: RequestId, + key: ArtistKey, + name: String, + release: Option, + ) { + if !self.state.settings.federation_enabled || name.trim().is_empty() { + return; + } + if let Some((_, token)) = self.active_search.take() { + token.cancel(); + self.state.search.federation_pending = false; + } + self.active_detail = Some(request_id); + self.state.federation_activity = FederationActivitySnapshot { + operation: if release.is_some() { + FederationOperation::Release + } else { + FederationOperation::Artist + }, + pending: true, + stats: None, + error: None, + }; + self.publish(); + let federation = self.federation.clone(); + let internal = self.internal.clone(); + tokio::spawn(async move { + let results = match federation { + Some(client) => client + .artist_card(&name) + .await + .map_err(|error| error.to_string()), + None => Err("federation is still starting".into()), + }; + let _ = internal + .send(InternalEvent::DetailFinished { + request_id, + key, + name, + results, + release, + }) + .await; + }); + } + + #[allow( + clippy::too_many_lines, + reason = "central exhaustive internal event dispatcher" + )] + fn handle_internal(&mut self, event: InternalEvent) { + match event { + InternalEvent::SearchFinished { + request_id, + results, + } => { + if self + .active_search + .as_ref() + .is_some_and(|(active, token)| *active == request_id && !token.is_cancelled()) + { + self.active_search = None; + self.state.search.federation_pending = false; + match results { + Ok((remote, stats)) => { + merge_search_results(&mut self.state.search.results, remote); + self.state.federation_activity.pending = false; + self.state.federation_activity.stats = Some(stats.clone()); + self.state.search.stats = Some(SearchStats { + tracks: self.state.search.results.tracks.len(), + artists: self.state.search.results.artists.len(), + ..stats + }); + } + Err(message) => { + self.state.search.error = Some(message.clone()); + self.state.federation_activity.pending = false; + self.state.federation_activity.error = Some(message); + } + } + self.publish(); + } + } + InternalEvent::DetailFinished { + request_id, + key, + name, + results, + release, + } => { + if self.active_detail == Some(request_id) { + self.active_detail = None; + match results { + Ok((mut remote, stats)) => { + let local_release_baseline = release + .as_ref() + .and_then(|selected| self.release(selected)) + .cloned(); + let selected_name = music_dht::normalize_name(&name); + for artist in &mut remote.artists { + if music_dht::normalize_name(&artist.name) == selected_name { + artist.key = key.clone(); + } + } + for item in &mut remote.releases { + for artist in &mut item.artists { + if music_dht::normalize_name(&artist.name) == selected_name { + artist.key = key.clone(); + } + } + for track in &mut item.tracks { + for artist in track + .artists + .iter_mut() + .chain(track.featured_artists.iter_mut()) + { + if music_dht::normalize_name(&artist.name) == selected_name + { + artist.key = key.clone(); + } + } + } + if let Some(selected) = &release + && music_dht::normalize_name(&item.title) + == self.release(selected).map_or(String::new(), |r| { + music_dht::normalize_name(&r.title) + }) + { + item.key = selected.clone(); + for track in &mut item.tracks { + track.release_id = selected.clone(); + } + } + } + for track in &mut remote.tracks { + for artist in track + .artists + .iter_mut() + .chain(track.featured_artists.iter_mut()) + { + if music_dht::normalize_name(&artist.name) == selected_name { + artist.key = key.clone(); + } + } + } + if let Some(local) = local_release_baseline { + if let Some(existing) = self + .state + .search + .results + .releases + .iter_mut() + .find(|candidate| candidate.key == local.key) + { + let previous = std::mem::replace(existing, local); + merge_release_preserving_local(existing, previous); + } else { + self.state.search.results.releases.push(local); + } + } + merge_search_results(&mut self.state.search.results, remote); + self.state.federation_activity.pending = false; + self.state.federation_activity.stats = Some(stats); + } + Err(message) => { + self.state.search.error = Some(message.clone()); + self.state.federation_activity.pending = false; + self.state.federation_activity.error = Some(message); + } + } + self.publish(); + } + } + InternalEvent::FederationStarted(result) => { + match result { + Ok(client) => { + let service = client.service(); + for alpn in [ + music_dht::device_sync::SYNC_ALPN_V1, + music_dht::device_sync::SYNC_ALPN_V2, + ] { + match service.stream_acceptor(alpn) { + Ok(acceptor) => { + tokio::spawn(devices::serve( + acceptor, + std::sync::Arc::clone(&self.devices), + std::sync::Arc::clone(&service), + )); + } + Err(error) => { + self.state.connected_devices.error = + Some(format!("device listener: {error}")); + } + } + } + tokio::spawn(devices::sync_loop( + std::sync::Arc::clone(&self.devices), + std::sync::Arc::clone(&service), + )); + self.device_service = Some(service); + self.publish_device_name(self.state.settings.device_name.clone()); + if let Ok(specs) = federation_specs(&self.catalog) { + let publisher = client.clone(); + tokio::spawn(async move { + let _ = publisher.publish(specs).await; + }); + } + self.federation = Some(client); + self.state.federation_activity.pending = false; + self.resolve_queue_artwork(); + self.refresh_federation_debug(); + } + Err(message) => { + self.state.settings_error = Some(message.clone()); + self.state.federation_activity.pending = false; + self.state.federation_activity.error = Some(message.clone()); + self.state.federation_debug = FederationDebugSnapshot { + error: Some(message), + ..FederationDebugSnapshot::default() + }; + } + } + self.refresh_connected_devices(); + self.publish(); + } + InternalEvent::SettingsPersisted(result) => { + self.state.settings_error = result.err(); + self.publish(); + } + InternalEvent::Audio(event) => self.handle_audio_event(event), + InternalEvent::FederatedStreamReady { + key, + reader, + mime_type, + } => { + if self + .state + .queue + .current() + .is_some_and(|item| item.track.key.matches(&key)) + { + self.audio + .play_stream(Box::new(reader), mime_type, self.state.playback.volume); + } + } + InternalEvent::FederatedDownloadComplete { + key, + path, + keep, + metadata, + } => { + if keep { + if let Err(error) = + furumi_library::import::read_file(&path).and_then(|mut import| { + apply_federated_metadata(&mut import, metadata); + furumi_library::import::upsert_track(&self.catalog, &import).map(|_| ()) + }) + { + self.state.playback_error = + Some(format!("track played but could not be imported: {error:#}")); + } else if let Ok(library) = library_snapshot(&self.catalog) { + self.library = library.clone(); + self.state.library = RemoteData::Ready(library); + self.reconcile_downloaded_tracks(&key, &path); + if let (Some(client), Ok(specs)) = + (self.federation.clone(), federation_specs(&self.catalog)) + { + tokio::spawn(async move { + let _ = client.publish(specs).await; + }); + } + } + } else if let Some((_, old)) = self.ephemeral_audio.replace((key, path)) { + let _ = std::fs::remove_file(old); + } + self.publish(); + } + InternalEvent::FederatedDownloadFailed { key, message } => { + if self + .state + .queue + .current() + .is_some_and(|item| item.track.key.matches(&key)) + { + self.state.playback.status = PlaybackStatus::Stopped; + self.state.playback_error = Some(message); + self.publish(); + } + } + InternalEvent::DevicesChanged => { + self.refresh_connected_devices(); + self.publish(); + } + InternalEvent::DeviceNamePublishDue(candidate) => { + if self.state.settings.device_name != candidate { + return; + } + let normalized = normalize_device_name(&candidate); + if self.state.settings.device_name != normalized { + self.state.settings.device_name.clone_from(&normalized); + if self.settings.send(self.state.settings.clone()).is_err() { + self.state.settings_error = Some("settings storage is unavailable".into()); + } + } + self.apply_local_device_name(&normalized); + self.publish_device_name(normalized); + self.publish(); + } + InternalEvent::DeviceNamePublished(result) => { + if let Err(message) = result { + self.state.connected_devices.error = Some(message); + } + self.refresh_connected_devices(); + self.publish(); + } + InternalEvent::FederatedContentResolved { key, result } => match result { + Ok(track) => { + let is_current = self + .state + .queue + .current() + .is_some_and(|item| item.track.key.matches(&key)); + self.state.queue.replace_track(&key, &track); + self.resolve_queue_artwork(); + if is_current { + self.play_current(); + } else { + self.publish(); + } + } + Err(message) => { + self.state.playback.status = PlaybackStatus::Stopped; + self.state.playback_error = Some(message); + self.publish(); + } + }, + InternalEvent::QueueArtworkResolved { + request_id, + keys, + cover_uri, + } => { + self.pending_queue_artwork.remove(&request_id); + if let Some(cover_uri) = cover_uri { + for key in keys { + if let Some(mut track) = self + .state + .queue + .items() + .iter() + .find(|item| item.track.key.matches(&key)) + .map(|item| item.track.clone()) + { + track.cover_uri = Some(cover_uri.clone()); + self.state.queue.replace_track(&key, &track); + } + } + self.resolve_queue_artwork(); + self.publish(); + } + } + InternalEvent::FederationDebugUpdated(debug) => { + self.federation_debug_pending = false; + if self.state.federation_debug != debug { + self.state.federation_debug = debug; + self.publish(); + } + } + InternalEvent::DeviceLibraryChanged => { + if let Ok(library) = library_snapshot(&self.catalog) { + self.library = library.clone(); + self.state.library = RemoteData::Ready(library); + self.reconcile_likes(); + } + self.refresh_connected_devices(); + self.publish(); + } + InternalEvent::DeviceOperationFinished(result) => { + self.state.connected_devices.busy = false; + match result { + Ok(DeviceOperationResult::Invite(invite)) => { + self.state.connected_devices.invite = Some(invite); + self.state.connected_devices.error = None; + } + Ok(DeviceOperationResult::Connected(message)) => { + self.state.connected_devices.invite = None; + self.state.connected_devices.last_sync = Some(message); + self.state.connected_devices.error = None; + } + Err(message) => self.state.connected_devices.error = Some(message), + } + self.refresh_connected_devices(); + self.publish(); + } + InternalEvent::DevicePlaybackSnapshot(snapshot) => { + self.apply_remote_playback_snapshot(snapshot); + } + InternalEvent::DevicePlaybackCommand(command) => { + self.apply_device_playback_command(command); + } + } + } + + fn apply_remote_playback_snapshot( + &mut self, + snapshot: music_dht::device_sync::PlaybackSnapshot, + ) { + if !remote_snapshot_has_authority( + self.device_role, + &self.active_device_id, + self.state.playback.status, + self.state.queue.items().is_empty(), + &snapshot, + ) { + return; + } + + let controls_sender = self.device_role == DevicePlaybackRole::Control; + + if controls_sender + && let Some(pending) = self + .pending_control + .as_ref() + .filter(|pending| pending.device_id == snapshot.device_id) + { + let acknowledged = playback_state_acknowledges_command( + &pending.state, + &snapshot.state, + pending.seek, + pending.sent_at.elapsed(), + ); + if !acknowledged && pending.sent_at.elapsed() < CONTROL_COMMAND_ACK_TIMEOUT { + // This snapshot was produced before the active device applied + // our latest command. Letting it through would briefly restore + // the previous track or seek position in the reactive state. + return; + } + self.pending_control = None; + } + + self.device_role = DevicePlaybackRole::Control; + self.active_device_id.clone_from(&snapshot.device_id); + self.active_device_name.clone_from(&snapshot.device_name); + self.control_anchor = Some(ControlPlaybackAnchor { + device_id: snapshot.device_id, + state: snapshot.state.clone(), + observed_at: Instant::now(), + }); + self.audio.stop(); + self.apply_device_playback_state(&snapshot.state, false, false); + self.refresh_connected_devices(); + self.publish(); + } + + fn control_position(&self) -> Option { + let (state, elapsed) = self + .pending_control + .as_ref() + .filter(|pending| pending.device_id == self.active_device_id) + .map(|pending| (&pending.state, pending.sent_at.elapsed())) + .or_else(|| { + self.control_anchor + .as_ref() + .filter(|anchor| anchor.device_id == self.active_device_id) + .map(|anchor| (&anchor.state, anchor.observed_at.elapsed())) + })?; + let position = extrapolated_control_position(state, elapsed); + Some(if self.state.playback.duration_seconds > 0.0 { + position.min(self.state.playback.duration_seconds) + } else { + position + }) + } + + fn tick_playback(&mut self) { + if matches!(self.state.playback.status, PlaybackStatus::Stopped) { + return; + } + if self.device_role == DevicePlaybackRole::Control { + if let Some(position) = self.control_position() { + if (position - self.state.playback.position_seconds).abs() < 0.01 { + return; + } + self.state.playback.position_seconds = position; + self.publish(); + } + return; + } + let position = self + .audio + .shared + .position_seconds() + .clamp(0.0, self.state.playback.duration_seconds.max(0.0)); + if (position - self.state.playback.position_seconds).abs() >= 0.01 { + self.state.playback.position_seconds = position; + self.publish(); + } + } + + fn handle_audio_event(&mut self, event: audio::Event) { + match event { + audio::Event::Started => { + self.state.playback.status = PlaybackStatus::Playing; + self.state.playback_error = None; + self.publish(); + } + audio::Event::Finished => { + self.remove_ephemeral_audio(); + if self.state.queue.advance().is_some() { + self.play_current(); + } else { + self.state.playback.status = PlaybackStatus::Stopped; + self.state.playback.position_seconds = + self.state.playback.duration_seconds.max(0.0); + self.publish(); + } + } + audio::Event::Failed(message) => { + self.state.playback.status = PlaybackStatus::Stopped; + self.state.playback_error = Some(message); + self.publish(); + } + } + } + + fn play_current(&mut self) { + self.remove_ephemeral_audio(); + let Some(track) = self.state.queue.current().map(|item| item.track.clone()) else { + self.state.playback.status = PlaybackStatus::Stopped; + self.publish(); + return; + }; + self.state.playback.position_seconds = 0.0; + self.state.playback.duration_seconds = track.duration_seconds.max(0.0); + self.state.playback_error = None; + if self.device_role == DevicePlaybackRole::Control { + self.state.playback.status = PlaybackStatus::Playing; + self.send_control_state(true); + self.publish(); + return; + } + match track.audio_source.clone() { + AudioSource::LocalFile(path) => { + self.state.playback.status = PlaybackStatus::Playing; + self.audio.play(path, self.state.playback.volume); + } + AudioSource::Federation { + peer_id, + content_id, + } => { + self.start_federated_playback(track, peer_id, content_id); + return; + } + } + self.publish(); + } + + fn start_federated_playback(&mut self, track: Track, peer_id: String, content_id: ContentId) { + let Some(client) = self.federation.clone() else { + self.state.playback_error = Some("federation is still starting".into()); + self.publish(); + return; + }; + self.audio.stop(); + self.state.playback.status = PlaybackStatus::Stopped; + if let Some((_, item_id)) = track.key.federation_id() { + self.spawn_federated_stream(client, &track, peer_id, item_id.to_owned()); + } else { + let key = track.key; + let internal = self.internal.clone(); + tokio::spawn(async move { + let result = client + .track_by_content_id(content_id.as_str()) + .await + .map_err(|error| format!("federated playback lookup failed: {error:#}")); + let _ = internal + .send(InternalEvent::FederatedContentResolved { key, result }) + .await; + }); + } + self.publish(); + } + + fn spawn_federated_stream( + &self, + client: std::sync::Arc, + track: &Track, + peer_id: String, + item_id: String, + ) { + let key = track.key.clone(); + let internal = self.internal.clone(); + let keep = self.state.settings.save_federated_on_listen; + let directory = if keep { + expand_tilde(&self.state.settings.library_path) + } else { + self.federation_media_dir.join("stream-cache") + }; + let stem = format!("fed-{}", sanitize_filename(&track.title)); + tokio::spawn(async move { + let (tx, mut rx) = mpsc::channel(4); + let forward = internal.clone(); + let event_key = key.clone(); + let relay = tokio::spawn(async move { + while let Some(event) = rx.recv().await { + let event = match event { + federation::StreamEvent::Ready(reader, mime_type) => { + InternalEvent::FederatedStreamReady { + key: event_key.clone(), + reader, + mime_type, + } + } + federation::StreamEvent::Complete(path, metadata) => { + InternalEvent::FederatedDownloadComplete { + key: event_key.clone(), + path, + keep, + metadata: *metadata, + } + } + federation::StreamEvent::Failed(message) => { + InternalEvent::FederatedDownloadFailed { + key: event_key.clone(), + message, + } + } + }; + let _ = forward.send(event).await; + } + }); + if let Err(error) = client + .stream_track(&peer_id, &item_id, &directory, &stem, tx.clone()) + .await + { + let _ = tx + .send(federation::StreamEvent::Failed(format!( + "federated playback failed: {error:#}" + ))) + .await; + } + drop(tx); + let _ = relay.await; + }); + } + + fn remove_ephemeral_audio(&mut self) { + if let Some((_, path)) = self.ephemeral_audio.take() { + let _ = std::fs::remove_file(path); + } + } + + fn reconcile_downloaded_tracks( + &mut self, + downloaded_key: &TrackKey, + downloaded_path: &std::path::Path, + ) { + let local_tracks = self + .library + .featured_releases + .iter() + .flat_map(|release| release.tracks.iter()) + .cloned() + .collect::>(); + let downloaded_local = local_tracks.iter().find(|track| { + matches!(&track.audio_source, AudioSource::LocalFile(path) if path == downloaded_path) + }).cloned(); + for local in &local_tracks { + for track in &mut self.state.search.results.tracks { + if track.key.matches(&local.key) { + *track = local.clone(); + } + } + for track in self + .state + .search + .results + .releases + .iter_mut() + .flat_map(|release| release.tracks.iter_mut()) + { + if track.key.matches(&local.key) { + *track = local.clone(); + } + } + self.state.queue.replace_matching_track(local); + } + if let Some(local) = downloaded_local { + for track in &mut self.state.search.results.tracks { + if track.key.matches(downloaded_key) { + *track = local.clone(); + } + } + for track in self + .state + .search + .results + .releases + .iter_mut() + .flat_map(|release| release.tracks.iter_mut()) + { + if track.key.matches(downloaded_key) { + *track = local.clone(); + } + } + self.state.queue.replace_track(downloaded_key, &local); + } + } + + fn release(&self, id: &ReleaseKey) -> Option<&Release> { + self.library + .featured_releases + .iter() + .chain(self.state.search.results.releases.iter()) + .find(|release| &release.key == id) + } + + fn track(&self, key: &TrackKey) -> Option<&Track> { + find_catalog_track(&self.library, &self.state.search.results, key) + } + + fn resolve_tracks(&self, keys: &[TrackKey]) -> Vec { + keys.iter() + .filter_map(|key| self.track(key).cloned()) + .collect() + } + + fn refresh_library(&mut self) { + match library_snapshot(&self.catalog) { + Ok(library) => { + self.library = library.clone(); + self.state.library = RemoteData::Ready(library); + self.reconcile_likes(); + } + Err(error) => { + self.state.settings_error = Some(format!("library: {error:#}")); + } + } + self.publish(); + } + + fn reconcile_likes(&mut self) { + let liked = self + .catalog + .liked_content_ids() + .unwrap_or_default() + .into_iter() + .chain(self.catalog.fed_like_ids().unwrap_or_default()) + .collect::>(); + for track in self.state.search.results.tracks.iter_mut().chain( + self.state + .search + .results + .releases + .iter_mut() + .flat_map(|release| release.tracks.iter_mut()), + ) { + track.liked = track_is_liked(track, &liked); + } + let replacements = self + .library + .featured_releases + .iter() + .flat_map(|release| release.tracks.iter()) + .chain( + self.library + .playlists + .iter() + .flat_map(|playlist| playlist.tracks.iter()), + ) + .cloned() + .collect::>(); + for track in replacements { + self.state.queue.replace_matching_track(&track); + } + } + + fn toggle_like(&mut self, key: &TrackKey) { + let Some(track) = self.track(key).cloned() else { + return; + }; + let Some(content_id) = track.key.content_id().map(|id| id.as_str().to_owned()) else { + self.state.settings_error = Some("This track has no stable content ID".into()); + self.publish(); + return; + }; + let result = if self + .catalog + .track_id_by_content_id(&content_id) + .ok() + .flatten() + .is_some() + { + self.catalog + .toggle_like_by_content_id(&content_id) + .map(|liked| (liked, None)) + } else if let Some(fed) = track_to_library_fed(&track) { + self.catalog + .toggle_fed_like(&fed) + .map(|liked| (liked, track_to_synced_fed(&track))) + } else { + Err(anyhow::anyhow!( + "track cannot be stored as a federated like" + )) + }; + match result { + Ok((liked, fed)) => { + if let Err(error) = self.devices.record_like(&content_id, liked, fed) { + self.state.settings_error = Some(format!("like sync: {error:#}")); + } + self.refresh_library(); + } + Err(error) => { + self.state.settings_error = Some(format!("like: {error:#}")); + self.publish(); + } + } + } + + fn add_to_playlist(&mut self, playlist_id: i64, keys: &[TrackKey]) { + let tracks = self.resolve_tracks(keys); + let local_ids = tracks + .iter() + .filter_map(|track| track.key.local_id().map(LocalTrackId::get)) + .collect::>(); + let remote = tracks + .iter() + .filter_map(track_to_library_fed) + .collect::>(); + let result = (|| -> anyhow::Result<()> { + self.catalog + .add_tracks_to_playlist(playlist_id, &local_ids)?; + self.catalog + .add_fed_tracks_to_playlist(playlist_id, &remote)?; + self.devices + .record_playlist_tracks_added(playlist_id, &local_ids)?; + let mut synced = Vec::new(); + for track in &tracks { + let Some(content_id) = track.key.content_id().map(|id| id.as_str().to_owned()) + else { + continue; + }; + let Some(fed) = track_to_synced_fed(track) else { + continue; + }; + let position = self + .catalog + .playlist_content_position(playlist_id, &content_id)? + .unwrap_or(0); + synced.push((content_id, position, fed)); + } + self.devices + .record_playlist_fed_added(playlist_id, &synced)?; + Ok(()) + })(); + if let Err(error) = result { + self.state.settings_error = Some(format!("add to playlist: {error:#}")); + } + self.refresh_library(); + } + + fn remove_from_playlist(&mut self, playlist_id: i64, keys: &[TrackKey]) { + let tracks = self.resolve_tracks(keys); + let local_ids = tracks + .iter() + .filter_map(|track| track.key.local_id().map(LocalTrackId::get)) + .collect::>(); + let content_ids = tracks + .iter() + .filter_map(|track| track.key.content_id().map(|id| id.as_str().to_owned())) + .collect::>(); + let result = (|| -> anyhow::Result<()> { + self.catalog + .remove_tracks_from_playlist(playlist_id, &local_ids)?; + self.catalog + .remove_content_ids_from_playlist(playlist_id, &content_ids)?; + self.devices + .record_playlist_removed(playlist_id, &content_ids)?; + Ok(()) + })(); + if let Err(error) = result { + self.state.settings_error = Some(format!("remove from playlist: {error:#}")); + } + self.refresh_library(); + } + + fn publish(&mut self) { + self.publish_device_playback(); + self.state.revision = self.state.revision.saturating_add(1); + self.snapshots.send_replace(self.state.clone()); + } +} + +#[cfg(test)] +mod tests; diff --git a/crates/backend/src/settings/mod.rs b/crates/backend/src/settings/mod.rs new file mode 100644 index 0000000..67e8b15 --- /dev/null +++ b/crates/backend/src/settings/mod.rs @@ -0,0 +1,153 @@ +use std::fs; +use std::path::Path; + +use furumi_backend_api::SettingsSnapshot; +use rusqlite::{Connection, OptionalExtension, params}; + +const MIGRATIONS: &[(i64, &str)] = &[ + ( + 1, + r" + CREATE TABLE app_settings ( + singleton_id INTEGER PRIMARY KEY CHECK (singleton_id = 1), + network_id TEXT NOT NULL, + library_path TEXT NOT NULL, + federation_enabled INTEGER NOT NULL CHECK (federation_enabled IN (0, 1)), + language TEXT NOT NULL + ); + INSERT INTO app_settings ( + singleton_id, network_id, library_path, federation_enabled, language + ) VALUES (1, 'furumi', '~/Music/Furumi', 1, 'English'); + ", + ), + ( + 2, + "ALTER TABLE app_settings ADD COLUMN save_federated_on_listen INTEGER NOT NULL DEFAULT 1 CHECK (save_federated_on_listen IN (0, 1));", + ), + ( + 3, + "ALTER TABLE app_settings ADD COLUMN device_name TEXT NOT NULL DEFAULT '';", + ), +]; + +pub struct SettingsStore { + connection: Connection, +} + +impl SettingsStore { + pub fn open(path: &Path) -> rusqlite::Result { + if let Some(parent) = path.parent() { + fs::create_dir_all(parent) + .map_err(|error| rusqlite::Error::ToSqlConversionFailure(Box::new(error)))?; + } + let connection = Connection::open(path)?; + let mut store = Self { connection }; + store.migrate()?; + Ok(store) + } + + #[cfg(test)] + fn in_memory() -> rusqlite::Result { + let connection = Connection::open_in_memory()?; + let mut store = Self { connection }; + store.migrate()?; + Ok(store) + } + + pub fn load(&self) -> rusqlite::Result { + self.connection.query_row( + "SELECT network_id, library_path, federation_enabled, language, + save_federated_on_listen, device_name + FROM app_settings WHERE singleton_id = 1", + [], + |row| { + Ok(SettingsSnapshot { + network_id: row.get(0)?, + library_path: row.get(1)?, + federation_enabled: row.get::<_, i64>(2)? != 0, + language: row.get(3)?, + save_federated_on_listen: row.get::<_, i64>(4)? != 0, + device_name: row.get(5)?, + }) + }, + ) + } + + pub fn save(&self, settings: &SettingsSnapshot) -> rusqlite::Result<()> { + self.connection.execute( + "UPDATE app_settings + SET network_id = ?1, + library_path = ?2, + federation_enabled = ?3, + language = ?4, + save_federated_on_listen = ?5, + device_name = ?6 + WHERE singleton_id = 1", + params![ + settings.network_id, + settings.library_path, + i64::from(settings.federation_enabled), + settings.language, + i64::from(settings.save_federated_on_listen), + settings.device_name, + ], + )?; + Ok(()) + } + + fn migrate(&mut self) -> rusqlite::Result<()> { + self.connection.execute_batch( + "PRAGMA foreign_keys = ON; + CREATE TABLE IF NOT EXISTS schema_migrations ( + version INTEGER PRIMARY KEY, + applied_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP + );", + )?; + + for &(version, sql) in MIGRATIONS { + let applied = self + .connection + .query_row( + "SELECT 1 FROM schema_migrations WHERE version = ?1", + [version], + |_| Ok(()), + ) + .optional()? + .is_some(); + if applied { + continue; + } + let transaction = self.connection.transaction()?; + transaction.execute_batch(sql)?; + transaction.execute( + "INSERT INTO schema_migrations (version) VALUES (?1)", + [version], + )?; + transaction.commit()?; + } + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn migration_creates_defaults_and_settings_round_trip() { + let store = SettingsStore::in_memory().unwrap(); + let mut settings = store.load().unwrap(); + assert_eq!(settings.network_id, "furumi"); + assert!(settings.federation_enabled); + assert!(settings.save_federated_on_listen); + assert!(settings.device_name.is_empty()); + + settings.network_id = "friends".into(); + settings.device_name = "Studio Mac".into(); + settings.library_path = "/music/library".into(); + settings.federation_enabled = false; + store.save(&settings).unwrap(); + + assert_eq!(store.load().unwrap(), settings); + } +} diff --git a/crates/backend/src/streaming.rs b/crates/backend/src/streaming.rs new file mode 100644 index 0000000..b9b79e8 --- /dev/null +++ b/crates/backend/src/streaming.rs @@ -0,0 +1,97 @@ +use std::fs::File; +use std::io::{self, Read, Seek, SeekFrom}; +use std::path::Path; +use std::sync::{Arc, Condvar, Mutex}; + +#[derive(Default)] +struct State { + available: u64, + complete: bool, +} +#[derive(Default)] +struct Shared { + state: Mutex, + changed: Condvar, +} +pub struct GrowingFileReader { + file: File, + pos: u64, + shared: Arc, +} +pub struct GrowingFileWriter { + shared: Arc, +} + +pub fn growing_file(path: &Path) -> io::Result<(GrowingFileReader, GrowingFileWriter)> { + let shared = Arc::new(Shared::default()); + Ok(( + GrowingFileReader { + file: File::open(path)?, + pos: 0, + shared: Arc::clone(&shared), + }, + GrowingFileWriter { shared }, + )) +} +impl GrowingFileWriter { + pub fn add_available(&self, bytes: u64) { + let mut state = self + .shared + .state + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + state.available = state.available.saturating_add(bytes); + self.shared.changed.notify_all(); + } + pub fn finish(&self) { + let mut state = self + .shared + .state + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + state.complete = true; + self.shared.changed.notify_all(); + } +} +impl Drop for GrowingFileWriter { + fn drop(&mut self) { + self.finish(); + } +} +impl Read for GrowingFileReader { + fn read(&mut self, buf: &mut [u8]) -> io::Result { + loop { + let mut state = self + .shared + .state + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + while self.pos >= state.available && !state.complete { + state = self + .shared + .changed + .wait(state) + .unwrap_or_else(std::sync::PoisonError::into_inner); + } + if self.pos >= state.available { + return Ok(0); + } + let n = usize::try_from((state.available - self.pos).min(buf.len() as u64)) + .unwrap_or(buf.len()); + drop(state); + let read = self.file.read(&mut buf[..n])?; + self.pos += read as u64; + if read > 0 { + return Ok(read); + } + } + } +} +impl Seek for GrowingFileReader { + fn seek(&mut self, _: SeekFrom) -> io::Result { + Err(io::Error::new( + io::ErrorKind::Unsupported, + "federated stream is not seekable while downloading", + )) + } +} diff --git a/crates/backend/src/support.rs b/crates/backend/src/support.rs new file mode 100644 index 0000000..972f856 --- /dev/null +++ b/crates/backend/src/support.rs @@ -0,0 +1,955 @@ +use super::{ + Artist, ArtistId, ArtistKey, ArtistRef, Artwork, AudioSource, BuildInfoSnapshot, + CONTROL_POSITION_ACK_TOLERANCE_SECONDS, CatalogSource, ContentId, DevicePlaybackRole, Duration, + HashMap, HashSet, InternalEvent, LibrarySnapshot, LocalTrackId, PlaybackStatus, + PlaylistSnapshot, Release, ReleaseId, ReleaseKey, SearchResults, SettingsSnapshot, + SettingsStore, Track, TrackKey, VersionEntrySnapshot, federation, mpsc, std_mpsc, thread, +}; +pub(super) fn expand_tilde(value: &str) -> std::path::PathBuf { + if value == "~" { + directories::UserDirs::new().map_or_else(|| value.into(), |dirs| dirs.home_dir().into()) + } else if let Some(rest) = value.strip_prefix("~/") { + directories::UserDirs::new().map_or_else(|| value.into(), |dirs| dirs.home_dir().join(rest)) + } else { + value.into() + } +} + +pub(super) fn normalize_device_name(value: &str) -> String { + let value = value.trim(); + if value.is_empty() { + "furumi".into() + } else { + value.into() + } +} + +pub(super) fn selected_track_position(tracks: &[Track], selected: &TrackKey) -> usize { + tracks + .iter() + .position(|track| track.key.matches(selected)) + .unwrap_or(0) +} + +pub(super) fn runtime_build_info() -> BuildInfoSnapshot { + use music_dht::capabilities::{ + CATALOG_ID, CapabilityManifest, DEVICE_SYNC_ID, FEDERATION_NET_ID, MUSIC_DHT_ID, + RENDEZVOUS_ID, TICKET_ID, + }; + + let manifest = CapabilityManifest::frid("furumi-desktop", env!("CARGO_PKG_VERSION")); + let protocol = |name: &str, id: &str| VersionEntrySnapshot { + name: name.into(), + version: manifest + .protocols + .get(id) + .map_or_else(|| "unknown".into(), u16::to_string), + }; + BuildInfoSnapshot { + software: vec![ + VersionEntrySnapshot { + name: "furumi-desktop".into(), + version: env!("CARGO_PKG_VERSION").into(), + }, + VersionEntrySnapshot { + name: "furumi-library".into(), + version: env!("FURUMI_LIBRARY_VERSION").into(), + }, + VersionEntrySnapshot { + name: "music-dht".into(), + version: env!("FURUMI_MUSIC_DHT_VERSION").into(), + }, + VersionEntrySnapshot { + name: "federation-net".into(), + version: env!("FURUMI_FEDERATION_NET_VERSION").into(), + }, + ], + protocols: vec![ + protocol("Federation transport", FEDERATION_NET_ID), + protocol("Peer ticket", TICKET_ID), + protocol("Rendezvous", RENDEZVOUS_ID), + protocol("Music DHT", MUSIC_DHT_ID), + protocol("Catalog", CATALOG_ID), + VersionEntrySnapshot { + name: "Audio transfer".into(), + version: federation::AUDIO_PROTOCOL_VERSION.to_string(), + }, + VersionEntrySnapshot { + name: "Connected devices".into(), + version: format!( + "{} · accepts v1", + manifest + .protocols + .get(DEVICE_SYNC_ID) + .copied() + .unwrap_or_default() + ), + }, + ], + } +} + +pub(super) fn find_catalog_track<'a>( + library: &'a LibrarySnapshot, + search: &'a SearchResults, + key: &TrackKey, +) -> Option<&'a Track> { + library + .featured_releases + .iter() + .flat_map(|release| release.tracks.iter()) + .chain( + library + .playlists + .iter() + .flat_map(|playlist| playlist.tracks.iter()), + ) + .chain( + search + .releases + .iter() + .flat_map(|release| release.tracks.iter()), + ) + .chain(search.tracks.iter()) + .find(|track| track.key.matches(key)) +} + +pub(super) fn portable_playback_placeholder( + wire: &music_dht::device_sync::PlaybackTrack, + content_id: ContentId, +) -> Track { + let peer_id = "content".to_owned(); + let refs = |names: &[String]| { + names + .iter() + .map(|name| ArtistRef { + key: ArtistKey::Federation { + peer_id: peer_id.clone(), + id: format!("name:{}", music_dht::normalize_name(name)), + }, + name: name.clone(), + }) + .collect::>() + }; + Track { + key: TrackKey::remote(content_id.clone()), + title: wire.title.clone(), + artist: playback_artist_line(&wire.artist_names, &wire.featured_artist_names), + artists: refs(&wire.artist_names), + featured_artists: refs(&wire.featured_artist_names), + release: wire.release_title.clone(), + release_id: ReleaseKey::Federation { + peer_id: peer_id.clone(), + id: format!("name:{}", music_dht::normalize_name(&wire.release_title)), + }, + duration_seconds: wire.duration_seconds, + track_number: wire + .track_number + .and_then(|value| u32::try_from(value).ok()), + disc_number: wire.disc_number.and_then(|value| u32::try_from(value).ok()), + cover_uri: None, + audio_format: wire.audio_format.clone(), + audio_bitrate_kbps: wire + .audio_bitrate + .and_then(|value| u32::try_from(value).ok()), + audio_sample_rate_hz: wire + .audio_sample_rate + .and_then(|value| u32::try_from(value).ok()), + audio_bit_depth: wire + .audio_bit_depth + .and_then(|value| u32::try_from(value).ok()), + file_size_bytes: wire + .file_size_bytes + .and_then(|value| u64::try_from(value).ok()), + liked: false, + audio_source: AudioSource::Federation { + peer_id: String::new(), + content_id, + }, + } +} + +pub(super) fn playback_artist_line(main: &[String], featured: &[String]) -> String { + match (main.is_empty(), featured.is_empty()) { + (false, false) => format!("{} feat. {}", main.join(", "), featured.join(", ")), + (false, true) => main.join(", "), + (true, false) => format!("feat. {}", featured.join(", ")), + (true, true) => String::new(), + } +} + +pub(super) fn extrapolated_control_position( + state: &music_dht::device_sync::PlaybackStateWire, + elapsed: Duration, +) -> f64 { + let elapsed = if state.playing && !state.paused { + elapsed.as_secs_f64() + } else { + 0.0 + }; + (state.position_secs + elapsed).max(0.0) +} + +pub(super) fn remote_snapshot_has_authority( + role: DevicePlaybackRole, + active_device_id: &str, + local_status: PlaybackStatus, + local_queue_empty: bool, + snapshot: &music_dht::device_sync::PlaybackSnapshot, +) -> bool { + if !snapshot.active { + return false; + } + match role { + DevicePlaybackRole::Control => snapshot.device_id == active_device_id, + DevicePlaybackRole::Active => local_status == PlaybackStatus::Stopped && local_queue_empty, + } +} + +pub(super) fn playback_state_acknowledges_command( + expected: &music_dht::device_sync::PlaybackStateWire, + actual: &music_dht::device_sync::PlaybackStateWire, + seek: bool, + elapsed: Duration, +) -> bool { + let same_queue = expected.queue.len() == actual.queue.len() + && expected + .queue + .iter() + .zip(&actual.queue) + .all(|(expected, actual)| playback_tracks_equivalent(expected, actual)); + let same_transport = expected.queue_pos == actual.queue_pos + && expected.playing == actual.playing + && expected.paused == actual.paused + && expected.volume == actual.volume + && expected.shuffle == actual.shuffle + && expected.repeat == actual.repeat; + if !same_queue || !same_transport { + return false; + } + if !seek { + return true; + } + let expected_position = extrapolated_control_position(expected, elapsed); + (expected_position - actual.position_secs).abs() <= CONTROL_POSITION_ACK_TOLERANCE_SECONDS +} + +pub(super) fn playback_tracks_equivalent( + left: &music_dht::device_sync::PlaybackTrack, + right: &music_dht::device_sync::PlaybackTrack, +) -> bool { + let content_id = |track: &music_dht::device_sync::PlaybackTrack| { + track + .content_id + .as_deref() + .and_then(music_dht::normalize_content_id) + .or_else(|| { + track + .fed + .as_ref() + .and_then(|fed| music_dht::normalize_content_id(&fed.content_id)) + }) + }; + match (content_id(left), content_id(right)) { + (Some(left), Some(right)) => left == right, + _ => { + music_dht::normalize_name(&left.title) == music_dht::normalize_name(&right.title) + && music_dht::normalize_name(&left.release_title) + == music_dht::normalize_name(&right.release_title) + && left.track_number == right.track_number + && left.disc_number == right.disc_number + && normalized_artist_names(&left.artist_names) + == normalized_artist_names(&right.artist_names) + } + } +} + +pub(super) fn normalized_artist_names(names: &[String]) -> Vec { + names + .iter() + .map(|name| music_dht::normalize_name(name)) + .collect() +} + +pub(super) fn unix_time_ms() -> i64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map_or(0, |duration| { + i64::try_from(duration.as_millis()).unwrap_or(i64::MAX) + }) +} + +#[allow( + clippy::cast_possible_truncation, + clippy::cast_sign_loss, + reason = "the playback volume is clamped to the wire protocol's 0..=100 range" +)] +pub(super) fn volume_percent(volume: f32) -> u8 { + (volume.clamp(0.0, 1.0) * 100.0).round() as u8 +} + +pub(super) fn track_to_synced_fed(track: &Track) -> Option { + let (owner, item_id) = track.key.federation_id()?; + let content_id = track.key.content_id()?.as_str().to_owned(); + Some(music_dht::device_sync::SyncedFedTrack { + item_id: item_id.to_owned(), + owner: owner.to_owned(), + title: track.title.clone(), + artist_names: track + .artists + .iter() + .map(|artist| artist.name.clone()) + .collect(), + featured_artist_names: track + .featured_artists + .iter() + .map(|artist| artist.name.clone()) + .collect(), + year: None, + duration_seconds: (track.duration_seconds.is_finite() && track.duration_seconds > 0.0) + .then(|| { + i64::try_from(Duration::from_secs_f64(track.duration_seconds).as_secs()) + .unwrap_or(i64::MAX) + }), + content_id, + release_title: (!track.release.is_empty()).then(|| track.release.clone()), + track_number: track + .track_number + .and_then(|value| i32::try_from(value).ok()), + disc_number: track + .disc_number + .and_then(|value| i32::try_from(value).ok()), + }) +} + +pub(super) fn track_to_library_fed(track: &Track) -> Option { + let synced = track_to_synced_fed(track)?; + Some(furumi_library::FederatedTrack { + item_id: synced.item_id, + owner: synced.owner, + own: false, + title: synced.title, + artist_names: synced.artist_names, + featured_artist_names: synced.featured_artist_names, + year: synced.year, + duration_seconds: synced.duration_seconds, + content_id: Some(synced.content_id), + release_title: synced.release_title, + track_number: synced.track_number, + disc_number: synced.disc_number, + }) +} + +pub(super) fn track_to_playback_track(track: &Track) -> music_dht::device_sync::PlaybackTrack { + let id = track.key.local_id().map_or(-1, LocalTrackId::get); + let release_id = match track.release_id { + ReleaseKey::Local(id) => id.get(), + ReleaseKey::Federation { .. } => -1, + }; + music_dht::device_sync::PlaybackTrack { + id, + title: track.title.clone(), + track_number: track + .track_number + .and_then(|value| i32::try_from(value).ok()), + disc_number: track + .disc_number + .and_then(|value| i32::try_from(value).ok()), + duration_seconds: track.duration_seconds, + artist_names: track + .artists + .iter() + .map(|artist| artist.name.clone()) + .collect(), + featured_artist_names: track + .featured_artists + .iter() + .map(|artist| artist.name.clone()) + .collect(), + release_id, + release_title: track.release.clone(), + release_year: None, + file_path: String::new(), + content_id: track.key.content_id().map(|id| id.as_str().to_owned()), + audio_format: track.audio_format.clone(), + audio_bitrate: track + .audio_bitrate_kbps + .and_then(|value| i32::try_from(value).ok()), + audio_sample_rate: track + .audio_sample_rate_hz + .and_then(|value| i32::try_from(value).ok()), + audio_bit_depth: track + .audio_bit_depth + .and_then(|value| i32::try_from(value).ok()), + file_size_bytes: track + .file_size_bytes + .and_then(|value| i64::try_from(value).ok()), + play_count: 0, + fed: track_to_synced_fed(track), + } +} + +pub(super) fn sanitize_filename(value: &str) -> String { + let clean: String = value + .chars() + .map(|c| { + if c.is_alphanumeric() || matches!(c, '-' | '_') { + c + } else { + '_' + } + }) + .collect(); + if clean.is_empty() { + "track".into() + } else { + clean + } +} + +pub(super) fn federation_specs( + library: &furumi_library::Library, +) -> anyhow::Result> { + let export = library.federation_export()?; + let mut specs = Vec::new(); + for (id, name) in export.artists { + specs.push(music_dht::ItemSpec { + local_key: format!("artist:{id}"), + kind: music_dht::ItemKind::Artist, + name, + artist_names: Vec::new(), + featured_artist_names: Vec::new(), + year: None, + release_type: None, + release_title: None, + track_number: None, + disc_number: None, + duration_seconds: None, + content_id: None, + }); + } + for release in export.releases { + specs.push(music_dht::ItemSpec { + local_key: format!("release:{}", release.id), + kind: music_dht::ItemKind::Release, + name: release.title, + artist_names: release.artist_names, + featured_artist_names: Vec::new(), + year: release.year, + release_type: Some(release.release_type), + release_title: None, + track_number: None, + disc_number: None, + duration_seconds: None, + content_id: None, + }); + } + for track in export.tracks { + specs.push(music_dht::ItemSpec { + local_key: format!("track:{}", track.id), + kind: music_dht::ItemKind::Track, + name: track.title, + artist_names: track.artist_names, + featured_artist_names: track.featured_artist_names, + year: track.year, + release_type: Some(track.release_type), + release_title: Some(track.release_title), + track_number: track.track_number, + disc_number: track.disc_number, + duration_seconds: (track.duration_seconds > 0.0).then_some(track.duration_seconds), + content_id: track.content_id, + }); + } + Ok(specs) +} + +pub(super) fn apply_federated_metadata( + import: &mut furumi_library::import::TrackImport, + metadata: Option, +) { + let Some(metadata) = metadata else { + return; + }; + if !metadata.title.trim().is_empty() { + import.title = metadata.title; + } + if !metadata.artists.is_empty() { + import.artists = metadata.artists; + } + if !metadata.featured_artists.is_empty() { + import.featured_artists = metadata.featured_artists; + } + if !metadata.album_artists.is_empty() { + import.album_artists = metadata.album_artists; + } + if !metadata.release_title.trim().is_empty() { + import.release_title = metadata.release_title; + } + import.release_type = metadata.release_type.or(import.release_type.take()); + import.year = metadata.year.or(import.year); + import.track_number = metadata.track_number.or(import.track_number); + import.disc_number = metadata.disc_number.or(import.disc_number); + import.duration_seconds = metadata.duration_seconds.unwrap_or(import.duration_seconds); + import.audio_format = metadata.audio_format.or(import.audio_format.take()); + import.audio_bitrate = metadata.audio_bitrate.or(import.audio_bitrate); + import.audio_sample_rate = metadata.audio_sample_rate.or(import.audio_sample_rate); + import.audio_bit_depth = metadata.audio_bit_depth.or(import.audio_bit_depth); +} + +pub(super) fn spawn_settings_worker( + store: SettingsStore, + receiver: std_mpsc::Receiver, + events: mpsc::Sender, +) { + let report = events.clone(); + if let Err(error) = thread::Builder::new() + .name("furumi-settings-storage".into()) + .spawn(move || { + while let Ok(mut settings) = receiver.recv() { + for newer in receiver.try_iter() { + settings = newer; + } + let result = store.save(&settings).map_err(|error| error.to_string()); + if events + .blocking_send(InternalEvent::SettingsPersisted(result)) + .is_err() + { + break; + } + } + }) + { + let _ = report.blocking_send(InternalEvent::SettingsPersisted(Err(format!( + "settings worker: {error}" + )))); + } +} + +pub(super) fn local_search_results( + catalog: &furumi_library::Library, + query: &str, +) -> anyhow::Result { + let found = catalog.search(query, 50)?; + let artists = found + .artists + .into_iter() + .map(|artist| Artist { + key: ArtistKey::local(ArtistId::new(artist.id)), + source: CatalogSource::Local, + name: artist.name, + artwork: Artwork { + uri: artist.image_path, + }, + release_count: usize::try_from(artist.release_count.max(0)).unwrap_or(usize::MAX), + track_count: usize::try_from(artist.track_count.max(0)).unwrap_or(usize::MAX), + }) + .collect(); + let mut releases = Vec::with_capacity(found.releases.len()); + for card in found.releases { + let detail = catalog.release(card.id)?; + releases.push(library_release(detail)); + } + let tracks = found + .tracks + .into_iter() + .map(|track| library_track(track, "")) + .collect(); + Ok(SearchResults { + artists, + releases, + tracks, + }) +} + +pub(super) fn merge_search_results(target: &mut SearchResults, incoming: SearchResults) { + for artist in incoming.artists { + if let Some(existing) = target + .artists + .iter_mut() + .find(|item| item.key == artist.key) + { + existing.release_count = existing.release_count.max(artist.release_count); + existing.track_count = existing.track_count.max(artist.track_count); + if existing.artwork.uri.is_none() && artist.artwork.uri.is_some() { + existing.artwork = artist.artwork; + } + } else { + target.artists.push(artist); + } + } + for mut release in incoming.releases { + populate_release_contributors(&mut release); + sort_release_tracks(&mut release.tracks); + if let Some(existing) = target + .releases + .iter_mut() + .find(|item| item.key == release.key) + { + merge_release_preserving_local(existing, release); + } else { + target.releases.push(release); + } + } + for track in incoming.tracks { + if let Some(existing) = target + .tracks + .iter_mut() + .find(|item| item.same_catalog_track(&track)) + { + if matches!(existing.audio_source, AudioSource::Federation { .. }) + && matches!(track.audio_source, AudioSource::LocalFile(_)) + { + *existing = track; + } + } else { + target.tracks.push(track); + } + } +} + +pub(super) fn merge_release_preserving_local(target: &mut Release, incoming: Release) { + if matches!(target.source, CatalogSource::Federation { .. }) + && matches!(incoming.source, CatalogSource::Local) + { + let previous = std::mem::replace(target, incoming); + merge_release_preserving_local(target, previous); + return; + } + if target.artwork.uri.is_none() && incoming.artwork.uri.is_some() { + target.artwork = incoming.artwork; + } + merge_artist_refs(&mut target.artists, incoming.artists); + merge_artist_refs(&mut target.featured_artists, incoming.featured_artists); + for track in incoming.tracks { + if let Some(current) = target + .tracks + .iter_mut() + .find(|item| tracks_match_within_release(item, &track)) + { + if matches!(current.audio_source, AudioSource::Federation { .. }) + && matches!(track.audio_source, AudioSource::LocalFile(_)) + { + *current = track; + } + } else { + target.tracks.push(track); + } + } + sort_release_tracks(&mut target.tracks); + populate_release_contributors(target); +} + +fn sort_release_tracks(tracks: &mut [Track]) { + tracks.sort_by(|left, right| { + left.disc_number + .unwrap_or(1) + .cmp(&right.disc_number.unwrap_or(1)) + .then_with(|| { + left.track_number + .unwrap_or(u32::MAX) + .cmp(&right.track_number.unwrap_or(u32::MAX)) + }) + .then_with(|| left.title.cmp(&right.title)) + }); +} + +/// Match provider records within an already identified release. Track/disc +/// position is more reliable here than optional remote descriptive metadata. +pub(super) fn tracks_match_within_release(left: &Track, right: &Track) -> bool { + if left.key.matches(&right.key) { + return true; + } + if let (Some(left_number), Some(right_number)) = (left.track_number, right.track_number) + && left_number == right_number + && left.disc_number.unwrap_or(1) == right.disc_number.unwrap_or(1) + { + return true; + } + music_dht::normalize_name(&left.title) == music_dht::normalize_name(&right.title) +} + +pub(super) fn merge_artist_refs(target: &mut Vec, incoming: Vec) { + let mut known = target + .iter() + .map(|artist| music_dht::normalize_name(&artist.name)) + .collect::>(); + target.extend( + incoming + .into_iter() + .filter(|artist| known.insert(music_dht::normalize_name(&artist.name))), + ); +} + +pub(super) fn populate_release_contributors(release: &mut Release) { + let mut known = release + .artists + .iter() + .chain(release.featured_artists.iter()) + .map(|artist| music_dht::normalize_name(&artist.name)) + .collect::>(); + for artist in release + .tracks + .iter() + .flat_map(|track| track.artists.iter().chain(track.featured_artists.iter())) + { + if known.insert(music_dht::normalize_name(&artist.name)) { + release.featured_artists.push(artist.clone()); + } + } +} + +pub(super) fn library_release(detail: furumi_library::ReleaseDetail) -> Release { + let (artists, featured_artists) = release_artist_roles(&detail); + let fallback = artists + .iter() + .map(|artist| artist.name.as_str()) + .collect::>() + .join(", "); + Release { + key: ReleaseKey::local(ReleaseId::new(detail.id)), + source: CatalogSource::Local, + title: detail.title, + artists, + featured_artists, + release_type: detail.release_type, + year: detail.year, + artwork: Artwork { + uri: detail.cover_path, + }, + tracks: detail + .tracks + .into_iter() + .map(|track| library_track(track, &fallback)) + .collect(), + } +} + +pub(super) fn library_snapshot( + catalog: &furumi_library::Library, +) -> anyhow::Result { + let liked_ids = catalog + .liked_content_ids()? + .into_iter() + .chain(catalog.fed_like_ids()?) + .collect::>(); + let artist_cards = catalog.artists( + 1, + i64::MAX, + furumi_library::LibraryFilters { + source_mode: furumi_library::LibrarySourceMode::Local, + ..furumi_library::LibraryFilters::default() + }, + )?; + let artists = artist_cards + .items + .into_iter() + .map(|artist| Artist { + key: ArtistKey::local(ArtistId::new(artist.id)), + source: CatalogSource::Local, + name: artist.name, + artwork: Artwork { + uri: artist.image_path, + }, + release_count: usize::try_from(artist.release_count.max(0)).unwrap_or(usize::MAX), + track_count: usize::try_from(artist.track_count.max(0)).unwrap_or(usize::MAX), + }) + .collect(); + let mut releases = Vec::new(); + for card in catalog.releases()? { + let detail = catalog.release(card.id)?; + let (artist_refs, featured_artist_refs) = release_artist_roles(&detail); + let artist_line = artist_refs + .iter() + .map(|artist| artist.name.as_str()) + .collect::>() + .join(", "); + let mut tracks = detail + .tracks + .into_iter() + .map(|track| library_track(track, &artist_line)) + .collect::>(); + for track in &mut tracks { + track.liked = track_is_liked(track, &liked_ids); + } + releases.push(Release { + key: ReleaseKey::local(ReleaseId::new(detail.id)), + source: CatalogSource::Local, + title: detail.title, + artists: artist_refs, + featured_artists: featured_artist_refs, + release_type: detail.release_type, + year: detail.year, + artwork: Artwork { + uri: detail.cover_path, + }, + tracks, + }); + } + let recently_played = releases + .iter() + .flat_map(|release| release.tracks.iter().cloned()) + .take(12) + .collect(); + let mut playlists = Vec::new(); + for card in catalog.playlists()? { + let detail = catalog.playlist(card.id)?; + let mut tracks = detail + .tracks + .into_iter() + .map(|track| library_track(track, "")) + .collect::>(); + for track in &mut tracks { + track.liked = track_is_liked(track, &liked_ids); + } + playlists.push(PlaylistSnapshot { + id: card.id, + title: detail.title, + is_likes: card.kind == "likes", + tracks, + }); + } + Ok(LibrarySnapshot { + artists, + featured_releases: releases, + recently_played, + playlists, + }) +} + +pub(super) fn track_is_liked(track: &Track, liked_ids: &HashSet) -> bool { + track + .key + .content_id() + .is_some_and(|id| liked_ids.contains(id.as_str())) + || track + .key + .federation_id() + .is_some_and(|(_, item)| liked_ids.contains(item)) +} + +pub(super) fn release_artist_roles( + detail: &furumi_library::ReleaseDetail, +) -> (Vec, Vec) { + let mut main_counts = HashMap::::new(); + let mut featured = HashMap::::new(); + for track in &detail.tracks { + for artist in &track.artists { + *main_counts.entry(artist.id).or_default() += 1; + } + for artist in &track.featured_artists { + featured + .entry(artist.id) + .or_insert_with(|| artist.name.clone()); + } + } + + let mut main_artists = Vec::new(); + let mut featured_artists = Vec::new(); + let mut known = HashSet::new(); + for artist in &detail.artists { + known.insert(artist.id); + let reference = ArtistRef { + key: ArtistKey::local(ArtistId::new(artist.id)), + name: artist.name.clone(), + }; + if main_counts.contains_key(&artist.id) || !featured.contains_key(&artist.id) { + main_artists.push(reference); + } else { + featured_artists.push(reference); + } + } + for track in &detail.tracks { + for artist in &track.artists { + if known.insert(artist.id) { + main_artists.push(ArtistRef { + key: ArtistKey::local(ArtistId::new(artist.id)), + name: artist.name.clone(), + }); + } + } + } + for (id, name) in featured { + if known.insert(id) { + featured_artists.push(ArtistRef { + key: ArtistKey::local(ArtistId::new(id)), + name, + }); + } + } + main_artists.sort_by(|left, right| { + let count = |artist: &ArtistRef| match artist.key { + ArtistKey::Local(id) => main_counts.get(&id.get()).copied().unwrap_or_default(), + ArtistKey::Federation { .. } => 0, + }; + count(right).cmp(&count(left)) + }); + (main_artists, featured_artists) +} + +pub(super) fn library_track(track: furumi_library::TrackItem, fallback_artist: &str) -> Track { + let content_id = track + .content_id + .as_deref() + .and_then(|content_id| ContentId::parse(content_id).ok()); + let local_id = LocalTrackId::new(track.id); + let key = content_id.map_or_else( + || TrackKey::local(local_id), + |content_id| TrackKey::new(local_id, content_id), + ); + let artists = track + .artists + .iter() + .map(|artist| ArtistRef { + key: ArtistKey::local(ArtistId::new(artist.id)), + name: artist.name.clone(), + }) + .collect::>(); + let featured_artists = track + .featured_artists + .iter() + .map(|artist| ArtistRef { + key: ArtistKey::local(ArtistId::new(artist.id)), + name: artist.name.clone(), + }) + .collect::>(); + let artist = { + let value = track.artist_line(); + if value.is_empty() { + fallback_artist.to_string() + } else { + value + } + }; + Track { + key, + title: track.title, + artist, + artists, + featured_artists, + release: track.release_title, + release_id: ReleaseKey::local(ReleaseId::new(track.release_id)), + duration_seconds: track.duration_seconds, + track_number: track + .track_number + .and_then(|value| u32::try_from(value).ok()), + disc_number: track + .disc_number + .and_then(|value| u32::try_from(value).ok()), + cover_uri: track.cover_path, + audio_format: track.audio_format, + audio_bitrate_kbps: track + .audio_bitrate + .and_then(|value| u32::try_from(value).ok()), + audio_sample_rate_hz: track + .audio_sample_rate + .and_then(|value| u32::try_from(value).ok()), + audio_bit_depth: track + .audio_bit_depth + .and_then(|value| u32::try_from(value).ok()), + file_size_bytes: track + .file_size_bytes + .and_then(|value| u64::try_from(value).ok()), + liked: false, + audio_source: AudioSource::LocalFile(track.file_path.into()), + } +} diff --git a/crates/backend/src/tests.rs b/crates/backend/src/tests.rs new file mode 100644 index 0000000..c20233a --- /dev/null +++ b/crates/backend/src/tests.rs @@ -0,0 +1,462 @@ +use super::*; + +fn merge_test_track(key: TrackKey, audio_source: AudioSource) -> Track { + Track { + key, + title: "Track".into(), + artist: "Artist".into(), + artists: vec![ArtistRef { + key: ArtistKey::local(ArtistId::new(1)), + name: "Artist".into(), + }], + featured_artists: Vec::new(), + release: "Album".into(), + release_id: ReleaseKey::local(ReleaseId::new(1)), + duration_seconds: 180.0, + track_number: Some(1), + disc_number: Some(1), + cover_uri: None, + audio_format: Some("flac".into()), + audio_bitrate_kbps: None, + audio_sample_rate_hz: None, + audio_bit_depth: None, + file_size_bytes: None, + liked: false, + audio_source, + } +} + +#[test] +fn merging_search_results_deduplicates_source_keys() { + let artist = Artist { + key: ArtistKey::Federation { + peer_id: "peer".into(), + id: "artist".into(), + }, + source: CatalogSource::Federation { + peer_id: "peer".into(), + }, + name: "Artist".into(), + artwork: Artwork::default(), + release_count: 0, + track_count: 0, + }; + let mut target = SearchResults { + artists: vec![artist.clone()], + ..SearchResults::default() + }; + merge_search_results( + &mut target, + SearchResults { + artists: vec![artist], + ..SearchResults::default() + }, + ); + assert_eq!(target.artists.len(), 1); +} + +#[test] +fn newly_received_release_tracks_are_sorted_by_disc_and_track_number() { + let mut fifth = merge_test_track( + TrackKey::federation("peer".into(), "five".into(), None), + AudioSource::Federation { + peer_id: "peer".into(), + content_id: ContentId::parse(format!("b3:{}", "5".repeat(64))).unwrap(), + }, + ); + fifth.title = "Los".into(); + fifth.track_number = Some(5); + let mut first = merge_test_track( + TrackKey::federation("peer".into(), "one".into(), None), + AudioSource::Federation { + peer_id: "peer".into(), + content_id: ContentId::parse(format!("b3:{}", "1".repeat(64))).unwrap(), + }, + ); + first.title = "Reise, Reise".into(); + + let mut target = SearchResults::default(); + merge_search_results( + &mut target, + SearchResults { + releases: vec![Release { + key: ReleaseKey::Federation { + peer_id: "peer".into(), + id: "reise-reise".into(), + }, + source: CatalogSource::Federation { + peer_id: "peer".into(), + }, + title: "Reise, Reise".into(), + artists: first.artists.clone(), + featured_artists: Vec::new(), + release_type: "album".into(), + year: Some(2004), + artwork: Artwork::default(), + tracks: vec![fifth, first], + }], + ..SearchResults::default() + }, + ); + + assert_eq!( + target.releases[0] + .tracks + .iter() + .map(|track| track.track_number) + .collect::>(), + vec![Some(1), Some(5)] + ); +} + +#[test] +fn selected_track_key_survives_context_filtering_and_reordering() { + let selected = TrackKey::local(LocalTrackId::new(2)); + let resolved = vec![ + merge_test_track( + TrackKey::local(LocalTrackId::new(3)), + AudioSource::LocalFile("three.flac".into()), + ), + merge_test_track(selected.clone(), AudioSource::LocalFile("two.flac".into())), + ]; + + assert_eq!(selected_track_position(&resolved, &selected), 1); +} + +#[test] +fn queue_artwork_requests_are_grouped_per_peer_and_release() { + let first_content = ContentId::parse(format!("b3:{}", "1".repeat(64))).unwrap(); + let second_content = ContentId::parse(format!("b3:{}", "2".repeat(64))).unwrap(); + let mut first = merge_test_track( + TrackKey::federation("peer".into(), "one".into(), Some(first_content.clone())), + AudioSource::Federation { + peer_id: "peer".into(), + content_id: first_content, + }, + ); + let mut second = merge_test_track( + TrackKey::federation("peer".into(), "two".into(), Some(second_content.clone())), + AudioSource::Federation { + peer_id: "peer".into(), + content_id: second_content, + }, + ); + first.title = "One".into(); + second.title = "Two".into(); + + assert_eq!( + Actor::queue_artwork_request_id(&first), + Actor::queue_artwork_request_id(&second) + ); + second.release = "Another album".into(); + assert_ne!( + Actor::queue_artwork_request_id(&first), + Actor::queue_artwork_request_id(&second) + ); +} + +#[test] +fn federated_release_enrichment_never_replaces_a_local_track() { + let release_key = ReleaseKey::local(ReleaseId::new(1)); + let artist = ArtistRef { + key: ArtistKey::local(ArtistId::new(1)), + name: "Artist".into(), + }; + let local_track = merge_test_track( + TrackKey::local(LocalTrackId::new(1)), + AudioSource::LocalFile("track.flac".into()), + ); + let mut target = SearchResults { + releases: vec![Release { + key: release_key.clone(), + source: CatalogSource::Local, + title: "Album".into(), + artists: vec![artist.clone()], + featured_artists: Vec::new(), + release_type: "album".into(), + year: Some(2026), + artwork: Artwork::default(), + tracks: vec![local_track], + }], + ..SearchResults::default() + }; + let content_id = ContentId::parse(format!("b3:{}", "a".repeat(64))).unwrap(); + let remote_track = merge_test_track( + TrackKey::federation( + "peer".into(), + "remote-item".into(), + Some(content_id.clone()), + ), + AudioSource::Federation { + peer_id: "peer".into(), + content_id, + }, + ); + + merge_search_results( + &mut target, + SearchResults { + releases: vec![Release { + key: release_key, + source: CatalogSource::Federation { + peer_id: "peer".into(), + }, + title: "Album".into(), + artists: vec![artist], + featured_artists: Vec::new(), + release_type: "album".into(), + year: Some(2026), + artwork: Artwork::default(), + tracks: vec![remote_track], + }], + ..SearchResults::default() + }, + ); + + let tracks = &target.releases[0].tracks; + assert_eq!(tracks.len(), 1); + assert!(matches!(tracks[0].audio_source, AudioSource::LocalFile(_))); +} + +#[test] +fn incomplete_federated_metadata_cannot_shadow_local_release_metadata() { + let release_key = ReleaseKey::local(ReleaseId::new(1)); + let artist = ArtistRef { + key: ArtistKey::local(ArtistId::new(1)), + name: "Artist".into(), + }; + let mut local_track = merge_test_track( + TrackKey::local(LocalTrackId::new(1)), + AudioSource::LocalFile("track.flac".into()), + ); + local_track.cover_uri = Some("local-cover.png".into()); + local_track.audio_bitrate_kbps = Some(1_411); + let mut target = Release { + key: release_key.clone(), + source: CatalogSource::Local, + title: "Complete local album title".into(), + artists: vec![artist.clone()], + featured_artists: Vec::new(), + release_type: "album".into(), + year: Some(2026), + artwork: Artwork { + uri: Some("local-cover.png".into()), + }, + tracks: vec![local_track], + }; + let content_id = ContentId::parse(format!("b3:{}", "b".repeat(64))).unwrap(); + let mut incomplete_remote = merge_test_track( + TrackKey::federation("peer".into(), "item".into(), Some(content_id.clone())), + AudioSource::Federation { + peer_id: "peer".into(), + content_id, + }, + ); + incomplete_remote.title = String::new(); + incomplete_remote.artist = String::new(); + incomplete_remote.artists.clear(); + incomplete_remote.audio_format = None; + + merge_release_preserving_local( + &mut target, + Release { + key: release_key, + source: CatalogSource::Federation { + peer_id: "peer".into(), + }, + title: String::new(), + artists: vec![artist], + featured_artists: Vec::new(), + release_type: String::new(), + year: None, + artwork: Artwork::default(), + tracks: vec![incomplete_remote], + }, + ); + + assert!(matches!(target.source, CatalogSource::Local)); + assert_eq!(target.title, "Complete local album title"); + assert_eq!(target.tracks.len(), 1); + assert_eq!(target.tracks[0].title, "Track"); + assert_eq!(target.tracks[0].audio_format.as_deref(), Some("flac")); + assert_eq!(target.tracks[0].audio_bitrate_kbps, Some(1_411)); + assert!(matches!( + target.tracks[0].audio_source, + AudioSource::LocalFile(_) + )); +} + +#[test] +fn queue_resolver_keeps_tracks_nested_in_a_federated_release() { + let content_id = ContentId::parse(format!("b3:{}", "c".repeat(64))).unwrap(); + let key = TrackKey::federation( + "peer".into(), + "remote-track".into(), + Some(content_id.clone()), + ); + let track = merge_test_track( + key.clone(), + AudioSource::Federation { + peer_id: "peer".into(), + content_id, + }, + ); + let search = SearchResults { + releases: vec![Release { + key: ReleaseKey::Federation { + peer_id: "peer".into(), + id: "album".into(), + }, + source: CatalogSource::Federation { + peer_id: "peer".into(), + }, + title: "Album".into(), + artists: track.artists.clone(), + featured_artists: Vec::new(), + release_type: "album".into(), + year: Some(2026), + artwork: Artwork::default(), + tracks: vec![track], + }], + ..SearchResults::default() + }; + + let library = LibrarySnapshot::default(); + let resolved = find_catalog_track(&library, &search, &key); + + assert!(resolved.is_some()); + assert!(matches!( + resolved.unwrap().audio_source, + AudioSource::Federation { .. } + )); +} + +#[test] +fn portable_device_queue_keeps_unresolved_content_as_a_federated_placeholder() { + let content_id = ContentId::parse(format!("b3:{}", "d".repeat(64))).unwrap(); + let wire = music_dht::device_sync::PlaybackTrack { + id: 12, + title: "Portable track".into(), + track_number: Some(2), + disc_number: Some(1), + duration_seconds: 123.0, + artist_names: vec!["Artist".into()], + featured_artist_names: vec!["Guest".into()], + release_id: 4, + release_title: "Release".into(), + release_year: Some(2026), + file_path: String::new(), + content_id: Some(content_id.as_str().into()), + audio_format: Some("flac".into()), + audio_bitrate: Some(1_411), + audio_sample_rate: Some(44_100), + audio_bit_depth: Some(16), + file_size_bytes: Some(42), + play_count: 0, + fed: None, + }; + + let placeholder = portable_playback_placeholder(&wire, content_id.clone()); + + assert_eq!(placeholder.key.content_id(), Some(&content_id)); + assert_eq!(placeholder.artist, "Artist feat. Guest"); + assert!(matches!( + placeholder.audio_source, + AudioSource::Federation { ref peer_id, .. } if peer_id.is_empty() + )); +} + +fn playback_test_state(content_byte: char) -> music_dht::device_sync::PlaybackStateWire { + music_dht::device_sync::PlaybackStateWire { + queue: vec![music_dht::device_sync::PlaybackTrack { + id: 1, + title: format!("Track {content_byte}"), + track_number: Some(1), + disc_number: Some(1), + duration_seconds: 180.0, + artist_names: vec!["Artist".into()], + featured_artist_names: Vec::new(), + release_id: 1, + release_title: "Album".into(), + release_year: Some(2026), + file_path: String::new(), + content_id: Some(format!("b3:{}", content_byte.to_string().repeat(64))), + audio_format: Some("flac".into()), + audio_bitrate: None, + audio_sample_rate: None, + audio_bit_depth: None, + file_size_bytes: None, + play_count: 0, + fed: None, + }], + queue_pos: 0, + playing: true, + paused: false, + idle_since_ms: None, + position_secs: 40.0, + volume: 72, + shuffle: false, + repeat: music_dht::device_sync::PlaybackRepeat::Off, + } +} + +#[test] +fn control_device_ignores_snapshots_from_the_previous_active_device() { + let snapshot = music_dht::device_sync::PlaybackSnapshot { + device_id: "old-device".into(), + device_name: "Old".into(), + active: true, + updated_at_ms: 1, + state: playback_test_state('a'), + }; + + assert!(!remote_snapshot_has_authority( + DevicePlaybackRole::Control, + "current-device", + PlaybackStatus::Playing, + false, + &snapshot, + )); + assert!(remote_snapshot_has_authority( + DevicePlaybackRole::Control, + "old-device", + PlaybackStatus::Playing, + false, + &snapshot, + )); +} + +#[test] +fn stale_remote_state_cannot_ack_a_new_control_command() { + let expected = playback_test_state('a'); + let stale_track = playback_test_state('b'); + assert!(!playback_state_acknowledges_command( + &expected, + &stale_track, + true, + Duration::from_secs(2), + )); + + let mut acknowledged = expected.clone(); + acknowledged.position_secs = 42.0; + assert!(playback_state_acknowledges_command( + &expected, + &acknowledged, + true, + Duration::from_secs(2), + )); + + acknowledged.position_secs = 5.0; + assert!(!playback_state_acknowledges_command( + &expected, + &acknowledged, + true, + Duration::from_secs(2), + )); + assert!(playback_state_acknowledges_command( + &expected, + &acknowledged, + false, + Duration::from_secs(2), + )); +} diff --git a/crates/domain/Cargo.toml b/crates/domain/Cargo.toml new file mode 100644 index 0000000..47e03ef --- /dev/null +++ b/crates/domain/Cargo.toml @@ -0,0 +1,10 @@ +[package] +name = "furumi-domain" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true + +[lints] +workspace = true + diff --git a/crates/domain/src/lib.rs b/crates/domain/src/lib.rs new file mode 100644 index 0000000..4d657ac --- /dev/null +++ b/crates/domain/src/lib.rs @@ -0,0 +1,555 @@ +//! Pure Furumi domain types and rules. + +use std::fmt; +use std::path::PathBuf; + +macro_rules! local_id { + ($name:ident) => { + #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] + pub struct $name(i64); + + impl $name { + #[must_use] + pub const fn new(value: i64) -> Self { + Self(value) + } + + #[must_use] + pub const fn get(self) -> i64 { + self.0 + } + } + }; +} + +local_id!(ArtistId); +local_id!(ReleaseId); +local_id!(LocalTrackId); + +/// Origin of catalog metadata. UI and application code render the same +/// entities regardless of which provider supplied them. +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum CatalogSource { + Local, + Federation { peer_id: String }, +} + +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum ArtistKey { + Local(ArtistId), + Federation { peer_id: String, id: String }, +} + +impl ArtistKey { + #[must_use] + pub const fn local(id: ArtistId) -> Self { + Self::Local(id) + } +} + +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum ReleaseKey { + Local(ReleaseId), + Federation { peer_id: String, id: String }, +} + +impl ReleaseKey { + #[must_use] + pub const fn local(id: ReleaseId) -> Self { + Self::Local(id) + } +} + +/// Artwork resolved by a catalog provider. Federation can initially publish +/// `None`, fetch the image into its cache, and then replace the snapshot with +/// the same entity and a local URI. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct Artwork { + pub uri: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ArtistRef { + pub key: ArtistKey, + pub name: String, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Artist { + pub key: ArtistKey, + pub source: CatalogSource, + pub name: String, + pub artwork: Artwork, + pub release_count: usize, + pub track_count: usize, +} + +/// Stable Frid/Furumi audio identity (`b3:<64 lowercase hex>`). +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct ContentId(String); + +impl ContentId { + /// Parses and normalizes an audio content identifier. + /// + /// # Errors + /// + /// Returns [`InvalidContentId`] unless the value is `b3:` followed by 64 + /// hexadecimal characters. + pub fn parse(value: impl Into) -> Result { + let value = value.into().to_ascii_lowercase(); + let hash = value.strip_prefix("b3:").ok_or(InvalidContentId)?; + if hash.len() != 64 || !hash.bytes().all(|byte| byte.is_ascii_hexdigit()) { + return Err(InvalidContentId); + } + Ok(Self(value)) + } + + #[must_use] + pub fn as_str(&self) -> &str { + &self.0 + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct InvalidContentId; + +impl fmt::Display for InvalidContentId { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("content ID must be b3:<64 hex characters>") + } +} + +impl std::error::Error for InvalidContentId {} + +/// A track can be known by its local database ID, stable content ID, or both. +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct TrackKey { + local_id: Option, + content_id: Option, + federation_identity: Option<(String, String)>, +} + +impl TrackKey { + #[must_use] + pub const fn local(id: LocalTrackId) -> Self { + Self { + local_id: Some(id), + content_id: None, + federation_identity: None, + } + } + + #[must_use] + pub const fn new(local_id: LocalTrackId, content_id: ContentId) -> Self { + Self { + local_id: Some(local_id), + content_id: Some(content_id), + federation_identity: None, + } + } + + #[must_use] + pub const fn remote(content_id: ContentId) -> Self { + Self { + local_id: None, + content_id: Some(content_id), + federation_identity: None, + } + } + + #[must_use] + pub fn federation(peer_id: String, item_id: String, content_id: Option) -> Self { + Self { + local_id: None, + content_id, + federation_identity: Some((peer_id, item_id)), + } + } + + #[must_use] + pub const fn local_id(&self) -> Option { + self.local_id + } + + #[must_use] + pub const fn content_id(&self) -> Option<&ContentId> { + self.content_id.as_ref() + } + + #[must_use] + pub fn federation_id(&self) -> Option<(&str, &str)> { + self.federation_identity + .as_ref() + .map(|(peer, item)| (peer.as_str(), item.as_str())) + } + + #[must_use] + pub fn matches(&self, other: &Self) -> bool { + self.content_id + .as_ref() + .zip(other.content_id.as_ref()) + .is_some_and(|(left, right)| left == right) + || self + .local_id + .zip(other.local_id) + .is_some_and(|(left, right)| left == right) + || self + .federation_identity + .as_ref() + .zip(other.federation_identity.as_ref()) + .is_some_and(|(left, right)| left == right) + } +} + +#[derive(Debug, Clone, PartialEq)] +pub struct Track { + pub key: TrackKey, + pub title: String, + pub artist: String, + pub artists: Vec, + pub featured_artists: Vec, + pub release: String, + pub release_id: ReleaseKey, + pub duration_seconds: f64, + pub track_number: Option, + pub disc_number: Option, + pub cover_uri: Option, + pub audio_format: Option, + pub audio_bitrate_kbps: Option, + pub audio_sample_rate_hz: Option, + pub audio_bit_depth: Option, + pub file_size_bytes: Option, + /// Whether this content is present in the listener's shared likes. + pub liked: bool, + pub audio_source: AudioSource, +} + +impl Track { + /// Returns whether two provider records describe the same logical track. + /// Stable content identity wins, with album metadata as a fallback for + /// catalogs that did not publish a content ID. + #[must_use] + pub fn same_catalog_track(&self, other: &Self) -> bool { + if self.key.matches(&other.key) { + return true; + } + if normalized_catalog_text(&self.title) != normalized_catalog_text(&other.title) { + return false; + } + let left_release = normalized_catalog_text(&self.release); + let right_release = normalized_catalog_text(&other.release); + if !left_release.is_empty() && !right_release.is_empty() && left_release != right_release { + return false; + } + if let (Some(left), Some(right)) = (self.track_number, other.track_number) { + return left == right + && self.disc_number.unwrap_or(1) == other.disc_number.unwrap_or(1); + } + self.duration_seconds > 0.0 + && other.duration_seconds > 0.0 + && (self.duration_seconds - other.duration_seconds).abs() < 2.0 + } +} + +fn normalized_catalog_text(value: &str) -> String { + value.trim().to_lowercase() +} + +/// Playback location resolved by a catalog provider. Local files go directly +/// to the audio engine; federated sources are streamed or materialized first +/// and then handed to the same engine. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum AudioSource { + LocalFile(PathBuf), + Federation { + peer_id: String, + content_id: ContentId, + }, +} + +#[derive(Debug, Clone, PartialEq)] +pub struct Release { + pub key: ReleaseKey, + pub source: CatalogSource, + pub title: String, + pub artists: Vec, + pub featured_artists: Vec, + pub release_type: String, + pub year: Option, + pub artwork: Artwork, + pub tracks: Vec, +} + +impl Release { + #[must_use] + pub fn artist_line(&self) -> String { + self.artists + .iter() + .map(|artist| artist.name.as_str()) + .collect::>() + .join(", ") + } + + #[must_use] + pub fn is_album(&self) -> bool { + self.release_type.eq_ignore_ascii_case("album") + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub struct QueueItemId(u64); + +impl QueueItemId { + #[must_use] + pub const fn new(value: u64) -> Self { + Self(value) + } + + #[must_use] + pub const fn get(self) -> u64 { + self.0 + } +} + +#[derive(Debug, Clone, PartialEq)] +pub struct QueueItem { + pub id: QueueItemId, + pub track: Track, +} + +/// Logical queue shared with the other Furumi players. +/// +/// `play_next_end` is the exclusive end of the stable FIFO block created by +/// successive "play next" commands. +#[derive(Debug, Clone, Default, PartialEq)] +pub struct Queue { + items: Vec, + current: Option, + play_next_end: Option, + next_item_id: u64, +} + +impl Queue { + #[must_use] + pub fn items(&self) -> &[QueueItem] { + &self.items + } + + #[must_use] + pub const fn current_index(&self) -> Option { + self.current + } + + #[must_use] + pub fn current(&self) -> Option<&QueueItem> { + self.current.and_then(|index| self.items.get(index)) + } + + /// Selects a concrete queue occurrence without rebuilding the queue. + pub fn select_item(&mut self, id: QueueItemId) -> Option<&QueueItem> { + self.current = self.items.iter().position(|item| item.id == id); + self.play_next_end = None; + self.current() + } + + pub fn replace_context(&mut self, tracks: Vec, start: usize) { + let mut items = Vec::with_capacity(tracks.len()); + for track in tracks { + items.push(self.new_item(track)); + } + self.items = items; + self.current = (!self.items.is_empty()).then(|| start.min(self.items.len() - 1)); + self.play_next_end = None; + } + + pub fn add_to_end(&mut self, tracks: impl IntoIterator) { + let items: Vec<_> = tracks + .into_iter() + .map(|track| self.new_item(track)) + .collect(); + self.items.extend(items); + } + + pub fn add_next(&mut self, tracks: impl IntoIterator) { + let items: Vec<_> = tracks + .into_iter() + .map(|track| self.new_item(track)) + .collect(); + if items.is_empty() { + return; + } + let insertion = self.current.map_or(0, |current| { + self.play_next_end + .filter(|end| *end > current && *end <= self.items.len()) + .unwrap_or((current + 1).min(self.items.len())) + }); + let count = items.len(); + self.items.splice(insertion..insertion, items); + if self.current.is_some_and(|current| insertion <= current) { + self.current = self.current.map(|current| current + count); + } + self.play_next_end = Some(insertion + count); + } + + pub fn replace_matching_track(&mut self, replacement: &Track) { + for item in &mut self.items { + if item.track.key.matches(&replacement.key) { + item.track = replacement.clone(); + } + } + } + + pub fn replace_track(&mut self, key: &TrackKey, replacement: &Track) { + for item in &mut self.items { + if item.track.key.matches(key) { + item.track = replacement.clone(); + } + } + } + + pub fn advance(&mut self) -> Option<&QueueItem> { + let next = match self.current { + None if !self.items.is_empty() => 0, + Some(current) if current + 1 < self.items.len() => current + 1, + _ => return None, + }; + self.current = Some(next); + self.normalize_play_next_block(); + self.current() + } + + pub fn previous(&mut self) -> Option<&QueueItem> { + let previous = self.current?.saturating_sub(1); + self.current = Some(previous); + self.normalize_play_next_block(); + self.current() + } + + fn new_item(&mut self, track: Track) -> QueueItem { + self.next_item_id = self.next_item_id.saturating_add(1); + QueueItem { + id: QueueItemId::new(self.next_item_id), + track, + } + } + + fn normalize_play_next_block(&mut self) { + if self.play_next_end.is_some_and(|end| { + self.current.is_none_or(|current| end <= current) || end > self.items.len() + }) { + self.play_next_end = None; + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn track(id: i64) -> Track { + Track { + key: TrackKey::local(LocalTrackId::new(id)), + title: id.to_string(), + artist: "artist".into(), + artists: vec![ArtistRef { + key: ArtistKey::local(ArtistId::new(1)), + name: "artist".into(), + }], + featured_artists: Vec::new(), + release: "release".into(), + release_id: ReleaseKey::local(ReleaseId::new(1)), + duration_seconds: 180.0, + track_number: Some(u32::try_from(id).unwrap()), + disc_number: Some(1), + cover_uri: None, + audio_format: Some("FLAC".into()), + audio_bitrate_kbps: Some(900), + audio_sample_rate_hz: Some(44_100), + audio_bit_depth: Some(16), + file_size_bytes: None, + liked: false, + audio_source: AudioSource::LocalFile(PathBuf::from(format!("track-{id}.flac"))), + } + } + + #[test] + fn sequential_play_next_additions_form_a_fifo_block() { + let mut queue = Queue::default(); + queue.replace_context((10..=13).map(track).collect(), 0); + queue.add_next([track(1)]); + queue.add_next([track(2)]); + queue.add_next([track(3)]); + + let ids: Vec<_> = queue + .items() + .iter() + .map(|item| item.track.key.local_id().unwrap().get()) + .collect(); + assert_eq!(ids, vec![10, 1, 2, 3, 11, 12, 13]); + } + + #[test] + fn replacing_context_keeps_order_and_starts_at_selected_track() { + let mut queue = Queue::default(); + queue.replace_context((10..=13).map(track).collect(), 2); + + let ids: Vec<_> = queue + .items() + .iter() + .map(|item| item.track.key.local_id().unwrap().get()) + .collect(); + assert_eq!(ids, vec![10, 11, 12, 13]); + assert_eq!(queue.current_index(), Some(2)); + assert_eq!( + queue.current().unwrap().track.key.local_id().unwrap().get(), + 12 + ); + } + + #[test] + fn catalog_track_matching_falls_back_to_album_position() { + let local = track(1); + let content_id = ContentId::parse(format!("b3:{}", "a".repeat(64))).unwrap(); + let mut federated = local.clone(); + federated.key = TrackKey::federation( + "peer".into(), + "remote-item".into(), + Some(content_id.clone()), + ); + federated.audio_source = AudioSource::Federation { + peer_id: "peer".into(), + content_id, + }; + + assert!(local.same_catalog_track(&federated)); + + federated.track_number = Some(2); + assert!(!local.same_catalog_track(&federated)); + } + + #[test] + fn content_id_matches_furumi_wire_format() { + let id = ContentId::parse(format!("B3:{}", "A".repeat(64))).unwrap(); + assert_eq!(id.as_str(), format!("b3:{}", "a".repeat(64))); + assert!(ContentId::parse("track-1").is_err()); + } + + #[test] + fn catalog_keys_keep_local_and_federated_entities_distinct() { + let local = ReleaseKey::local(ReleaseId::new(7)); + let remote = ReleaseKey::Federation { + peer_id: "peer-a".into(), + id: "7".into(), + }; + + assert_ne!(local, remote); + + let content = ContentId::parse(format!("b3:{}", "a".repeat(64))).unwrap(); + let first = TrackKey::federation("peer-a".into(), "item-1".into(), Some(content.clone())); + let same_audio_elsewhere = + TrackKey::federation("peer-b".into(), "item-9".into(), Some(content)); + let other = TrackKey::federation("peer-a".into(), "item-2".into(), None); + assert!(first.matches(&same_audio_elsewhere)); + assert!(!first.matches(&other)); + } +} diff --git a/crates/platform-desktop/Cargo.toml b/crates/platform-desktop/Cargo.toml new file mode 100644 index 0000000..2d2616c --- /dev/null +++ b/crates/platform-desktop/Cargo.toml @@ -0,0 +1,23 @@ +[package] +name = "furumi-platform-desktop" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true + +[dependencies] +rfd.workspace = true +souvlaki.workspace = true + +[target.'cfg(target_os="macos")'.dependencies] +core-foundation.workspace = true + +[target.'cfg(windows)'.dependencies] +windows-sys.workspace = true + +[lints.rust] +unsafe_code = "allow" + +[lints.clippy] +all = "warn" +pedantic = "warn" diff --git a/crates/platform-desktop/src/lib.rs b/crates/platform-desktop/src/lib.rs new file mode 100644 index 0000000..b7a86bc --- /dev/null +++ b/crates/platform-desktop/src/lib.rs @@ -0,0 +1,18 @@ +//! Narrow adapters for operating-system desktop services. + +use std::path::{Path, PathBuf}; + +mod media; +pub use media::{MediaCommand, MediaSession}; + +/// Opens the platform-native directory picker. +/// +/// The dialog is blocking and must be invoked away from the Slint event loop. +#[must_use] +pub fn choose_library_directory(initial: Option<&Path>) -> Option { + let mut dialog = rfd::FileDialog::new().set_title("Choose Furumi library folder"); + if let Some(path) = initial { + dialog = dialog.set_directory(path); + } + dialog.pick_folder() +} diff --git a/crates/platform-desktop/src/media.rs b/crates/platform-desktop/src/media.rs new file mode 100644 index 0000000..12a8f03 --- /dev/null +++ b/crates/platform-desktop/src/media.rs @@ -0,0 +1,185 @@ +//! Cross-platform OS media session backed by souvlaki. + +use std::time::{Duration, Instant}; + +use souvlaki::{ + MediaControlEvent, MediaControls, MediaMetadata, MediaPlayback, MediaPosition, PlatformConfig, +}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum MediaCommand { + Toggle, + Play, + Pause, + Next, + Previous, + Stop, +} + +pub struct MediaSession { + controls: MediaControls, + metadata: Option<(String, String, String, u64)>, + playback: Option<(bool, bool)>, + last_position_update: Option, +} + +impl MediaSession { + pub fn new(on_command: impl Fn(MediaCommand) + Send + 'static) -> Option { + let dbus_name = platform_dbus_name(); + let mut controls = MediaControls::new(PlatformConfig { + display_name: "Furumi Desktop", + dbus_name: &dbus_name, + hwnd: platform_window(), + }) + .ok()?; + controls + .attach(move |event| { + let command = match event { + MediaControlEvent::Toggle => MediaCommand::Toggle, + MediaControlEvent::Play => MediaCommand::Play, + MediaControlEvent::Pause => MediaCommand::Pause, + MediaControlEvent::Next => MediaCommand::Next, + MediaControlEvent::Previous => MediaCommand::Previous, + MediaControlEvent::Stop => MediaCommand::Stop, + _ => return, + }; + on_command(command); + }) + .ok()?; + Some(Self { + controls, + metadata: None, + playback: None, + last_position_update: None, + }) + } + + pub fn update_metadata( + &mut self, + title: &str, + artist: &str, + album: &str, + duration_seconds: f64, + ) { + let duration_ms = duration_millis(duration_seconds); + let next = ( + title.to_owned(), + artist.to_owned(), + album.to_owned(), + duration_ms, + ); + if self.metadata.as_ref() == Some(&next) { + return; + } + let _ = self.controls.set_metadata(MediaMetadata { + title: Some(title), + artist: Some(artist), + album: Some(album), + duration: (duration_ms > 0).then(|| Duration::from_millis(duration_ms)), + cover_url: None, + }); + self.metadata = Some(next); + } + + pub fn update_playback(&mut self, playing: bool, paused: bool, position_seconds: f64) { + let next = (playing, paused); + let state_changed = self.playback != Some(next); + if !state_changed + && self + .last_position_update + .is_some_and(|last| last.elapsed() < Duration::from_secs(2)) + { + return; + } + let progress = Some(MediaPosition(Duration::from_millis(duration_millis( + position_seconds, + )))); + let state = if !playing { + MediaPlayback::Stopped + } else if paused { + MediaPlayback::Paused { progress } + } else { + MediaPlayback::Playing { progress } + }; + let _ = self.controls.set_playback(state); + self.playback = Some(next); + self.last_position_update = Some(Instant::now()); + } +} + +impl Drop for MediaSession { + fn drop(&mut self) { + let _ = self.controls.detach(); + } +} + +fn duration_millis(seconds: f64) -> u64 { + if seconds.is_finite() && seconds > 0.0 { + u64::try_from(Duration::from_secs_f64(seconds).as_millis()).unwrap_or(u64::MAX) + } else { + 0 + } +} + +fn platform_dbus_name() -> String { + #[cfg(all( + unix, + not(any(target_os = "macos", target_os = "ios", target_os = "android")) + ))] + { + return format!("cy.hexor.furumi.desktop.instance{}", std::process::id()); + } + #[allow(unreachable_code)] + "cy.hexor.furumi.desktop".into() +} + +#[cfg(not(windows))] +fn platform_window() -> Option<*mut std::ffi::c_void> { + None +} + +#[cfg(windows)] +fn platform_window() -> Option<*mut std::ffi::c_void> { + use windows_sys::Win32::System::LibraryLoader::GetModuleHandleW; + use windows_sys::Win32::UI::WindowsAndMessaging::{ + CreateWindowExW, DefWindowProcW, RegisterClassW, WNDCLASSW, + }; + unsafe { + let class_name: Vec = "furumi_desktop_media\0".encode_utf16().collect(); + let instance = GetModuleHandleW(std::ptr::null()); + let mut class: WNDCLASSW = core::mem::zeroed(); + class.lpfnWndProc = Some(DefWindowProcW); + class.hInstance = instance; + class.lpszClassName = class_name.as_ptr(); + if RegisterClassW(&class) == 0 { + return None; + } + let window = CreateWindowExW( + 0, + class_name.as_ptr(), + class_name.as_ptr(), + 0, + 0, + 0, + 0, + 0, + std::ptr::null_mut(), + std::ptr::null_mut(), + instance, + std::ptr::null(), + ); + (!window.is_null()).then_some(window.cast()) + } +} + +#[cfg(test)] +mod tests { + use super::duration_millis; + + #[test] + fn invalid_media_durations_are_safe() { + assert_eq!(duration_millis(f64::NAN), 0); + assert_eq!(duration_millis(-1.0), 0); + assert_eq!(duration_millis(1.25), 1_250); + } +} diff --git a/crates/ui/Cargo.toml b/crates/ui/Cargo.toml new file mode 100644 index 0000000..a547c26 --- /dev/null +++ b/crates/ui/Cargo.toml @@ -0,0 +1,23 @@ +[package] +name = "furumi-ui" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +build = "build.rs" + +[dependencies] +furumi-application.workspace = true +furumi-backend.workspace = true +furumi-backend-api.workspace = true +furumi-domain.workspace = true +furumi-platform-desktop.workspace = true +image.workspace = true +slint.workspace = true +tokio.workspace = true + +[build-dependencies] +slint-build.workspace = true + +[lints] +workspace = true diff --git a/crates/ui/assets/add-end.svg b/crates/ui/assets/add-end.svg new file mode 100644 index 0000000..56d7105 --- /dev/null +++ b/crates/ui/assets/add-end.svg @@ -0,0 +1 @@ + diff --git a/crates/ui/assets/back.svg b/crates/ui/assets/back.svg new file mode 100644 index 0000000..122e6a5 --- /dev/null +++ b/crates/ui/assets/back.svg @@ -0,0 +1 @@ + diff --git a/crates/ui/assets/close.svg b/crates/ui/assets/close.svg new file mode 100644 index 0000000..1cd13e9 --- /dev/null +++ b/crates/ui/assets/close.svg @@ -0,0 +1 @@ + diff --git a/crates/ui/assets/devices.svg b/crates/ui/assets/devices.svg new file mode 100644 index 0000000..11d5a84 --- /dev/null +++ b/crates/ui/assets/devices.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/crates/ui/assets/federation.svg b/crates/ui/assets/federation.svg new file mode 100644 index 0000000..7f184c1 --- /dev/null +++ b/crates/ui/assets/federation.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/crates/ui/assets/forward.svg b/crates/ui/assets/forward.svg new file mode 100644 index 0000000..b2416c4 --- /dev/null +++ b/crates/ui/assets/forward.svg @@ -0,0 +1 @@ + diff --git a/crates/ui/assets/heart.svg b/crates/ui/assets/heart.svg new file mode 100644 index 0000000..32aa403 --- /dev/null +++ b/crates/ui/assets/heart.svg @@ -0,0 +1 @@ + diff --git a/crates/ui/assets/home.svg b/crates/ui/assets/home.svg new file mode 100644 index 0000000..7dd773c --- /dev/null +++ b/crates/ui/assets/home.svg @@ -0,0 +1 @@ + diff --git a/crates/ui/assets/info.svg b/crates/ui/assets/info.svg new file mode 100644 index 0000000..d296977 --- /dev/null +++ b/crates/ui/assets/info.svg @@ -0,0 +1 @@ + diff --git a/crates/ui/assets/library.svg b/crates/ui/assets/library.svg new file mode 100644 index 0000000..35e5928 --- /dev/null +++ b/crates/ui/assets/library.svg @@ -0,0 +1 @@ + diff --git a/crates/ui/assets/logo.svg b/crates/ui/assets/logo.svg new file mode 100644 index 0000000..980eca8 --- /dev/null +++ b/crates/ui/assets/logo.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/crates/ui/assets/more.svg b/crates/ui/assets/more.svg new file mode 100644 index 0000000..c778969 --- /dev/null +++ b/crates/ui/assets/more.svg @@ -0,0 +1 @@ + diff --git a/crates/ui/assets/next.svg b/crates/ui/assets/next.svg new file mode 100644 index 0000000..cefccc2 --- /dev/null +++ b/crates/ui/assets/next.svg @@ -0,0 +1 @@ + diff --git a/crates/ui/assets/note.svg b/crates/ui/assets/note.svg new file mode 100644 index 0000000..a72f8c2 --- /dev/null +++ b/crates/ui/assets/note.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/crates/ui/assets/pause.svg b/crates/ui/assets/pause.svg new file mode 100644 index 0000000..8572bf5 --- /dev/null +++ b/crates/ui/assets/pause.svg @@ -0,0 +1 @@ + diff --git a/crates/ui/assets/play-next.svg b/crates/ui/assets/play-next.svg new file mode 100644 index 0000000..a2b4cc2 --- /dev/null +++ b/crates/ui/assets/play-next.svg @@ -0,0 +1 @@ + diff --git a/crates/ui/assets/play.svg b/crates/ui/assets/play.svg new file mode 100644 index 0000000..bde07ae --- /dev/null +++ b/crates/ui/assets/play.svg @@ -0,0 +1 @@ + diff --git a/crates/ui/assets/playlist-add.svg b/crates/ui/assets/playlist-add.svg new file mode 100644 index 0000000..5c5304a --- /dev/null +++ b/crates/ui/assets/playlist-add.svg @@ -0,0 +1 @@ + diff --git a/crates/ui/assets/plus.svg b/crates/ui/assets/plus.svg new file mode 100644 index 0000000..6342222 --- /dev/null +++ b/crates/ui/assets/plus.svg @@ -0,0 +1,3 @@ + + + diff --git a/crates/ui/assets/previous.svg b/crates/ui/assets/previous.svg new file mode 100644 index 0000000..dfc5957 --- /dev/null +++ b/crates/ui/assets/previous.svg @@ -0,0 +1 @@ + diff --git a/crates/ui/assets/queue.svg b/crates/ui/assets/queue.svg new file mode 100644 index 0000000..eab0801 --- /dev/null +++ b/crates/ui/assets/queue.svg @@ -0,0 +1 @@ + diff --git a/crates/ui/assets/search.svg b/crates/ui/assets/search.svg new file mode 100644 index 0000000..2e7c001 --- /dev/null +++ b/crates/ui/assets/search.svg @@ -0,0 +1 @@ + diff --git a/crates/ui/assets/settings.svg b/crates/ui/assets/settings.svg new file mode 100644 index 0000000..332e3b1 --- /dev/null +++ b/crates/ui/assets/settings.svg @@ -0,0 +1 @@ + diff --git a/crates/ui/assets/share.svg b/crates/ui/assets/share.svg new file mode 100644 index 0000000..853a50a --- /dev/null +++ b/crates/ui/assets/share.svg @@ -0,0 +1 @@ + diff --git a/crates/ui/assets/volume.svg b/crates/ui/assets/volume.svg new file mode 100644 index 0000000..caafe06 --- /dev/null +++ b/crates/ui/assets/volume.svg @@ -0,0 +1 @@ + diff --git a/crates/ui/build.rs b/crates/ui/build.rs new file mode 100644 index 0000000..e76141c --- /dev/null +++ b/crates/ui/build.rs @@ -0,0 +1,3 @@ +fn main() { + slint_build::compile("ui/app.slint").expect("failed to compile Slint UI"); +} diff --git a/crates/ui/src/lib.rs b/crates/ui/src/lib.rs new file mode 100644 index 0000000..c7cb6ea --- /dev/null +++ b/crates/ui/src/lib.rs @@ -0,0 +1,728 @@ +//! Slint presentation and the adapter to the application/backend state flow. + +use std::cell::RefCell; +use std::collections::{HashMap, HashSet}; +use std::fmt::Write as _; +use std::path::{Path, PathBuf}; +use std::rc::Rc; +use std::sync::{Arc, Mutex}; +use std::thread; + +use furumi_application::{ + AppEvent, AppState, Effect, Screen, UiAction, duration_label, reduce_action, reduce_event, +}; +use furumi_backend::BackendHandle; +use furumi_backend_api::{ + BackendCommand, DevicePlaybackRole, DevicePresence, DeviceTrust, FederationOperation, + PlaybackStatus, RemoteData, +}; +use furumi_domain::{ + Artist, ArtistKey, ArtistRef, AudioSource, CatalogSource, ContentId, LocalTrackId, QueueItemId, + Release, ReleaseKey, Track, TrackKey, +}; +use furumi_platform_desktop::{MediaCommand, MediaSession}; +use slint::{ + ComponentHandle, Image, ModelRc, Rgba8Pixel, SharedPixelBuffer, SharedString, VecModel, +}; + +slint::include_modules!(); + +mod render; +use render::{ + breadcrumb_screen, contributor_lines, find_artist_key, find_release_key, model, + parse_track_key, release_artist_credits, render, render_catalog, render_current_track, + render_playback, render_queue, render_search, render_shell, render_track_info, + selected_release, track_context, +}; +thread_local! { + /// Decoded images belong to the Slint UI thread. Keeping them here makes + /// every state projection after the first load a memory-only operation. + static ARTWORK_CACHE: RefCell>> = RefCell::new(HashMap::new()); + static MEDIA_SESSION: RefCell> = const { RefCell::new(None) }; +} + +/// Runs the desktop presentation until its main window closes. +/// +/// # Errors +/// +/// Returns a Slint platform error when the window or event loop cannot start. +pub fn run(backend: &BackendHandle) -> Result<(), slint::PlatformError> { + let window = AppWindow::new()?; + let state = Arc::new(Mutex::new(AppState::default())); + + let media_backend = backend.clone(); + MEDIA_SESSION.with_borrow_mut(|session| { + *session = MediaSession::new(move |command| { + let backend_command = match command { + MediaCommand::Toggle => BackendCommand::TogglePlayback, + MediaCommand::Play => BackendCommand::Play, + MediaCommand::Pause => BackendCommand::Pause, + MediaCommand::Next => BackendCommand::Next, + MediaCommand::Previous => BackendCommand::Previous, + MediaCommand::Stop => BackendCommand::Stop, + }; + let _ = media_backend.try_send(backend_command); + }); + }); + + bind_callbacks(&window, &state, backend); + subscribe_to_backend(&window, &state, backend); + with_state(&state, |state| render(&window, state)); + if let Err(error) = backend.try_send(BackendCommand::Initialize) { + dispatch_event( + &window, + &state, + AppEvent::CommandRejected(error.to_string()), + ); + } + + let result = window.run(); + MEDIA_SESSION.with_borrow_mut(Option::take); + let _ = backend.try_send(BackendCommand::Shutdown); + result +} + +fn bind_callbacks(window: &AppWindow, state: &Arc>, backend: &BackendHandle) { + bind_catalog_callbacks(window, state, backend); + window.on_navigate({ + let window = window.as_weak(); + let state = Arc::clone(state); + let backend = backend.clone(); + move |target| { + let screen = match target.as_str() { + "search" => Screen::Search, + "library" => Screen::Library, + _ => Screen::Home, + }; + dispatch_action(&window, &state, &backend, UiAction::Navigate(screen)); + } + }); + window.on_toggle_queue({ + let window = window.as_weak(); + let state = Arc::clone(state); + let backend = backend.clone(); + move || dispatch_action(&window, &state, &backend, UiAction::ToggleQueue) + }); + window.on_toggle_settings({ + let window = window.as_weak(); + let state = Arc::clone(state); + let backend = backend.clone(); + move || dispatch_action(&window, &state, &backend, UiAction::ToggleSettings) + }); + bind_player_callbacks(window, state, backend); + window.on_search_changed({ + let window = window.as_weak(); + let state = Arc::clone(state); + let backend = backend.clone(); + move |query| { + dispatch_action( + &window, + &state, + &backend, + UiAction::SearchChanged(query.to_string()), + ); + } + }); + window.on_track_action({ + let window = window.as_weak(); + let state = Arc::clone(state); + let backend = backend.clone(); + move |action, key| { + let Some(key) = parse_track_key(&key) else { + return; + }; + let action = match action.as_str() { + "like" => UiAction::ToggleLike(key), + "play-next" => UiAction::AddNext(vec![key]), + "add-end" => UiAction::AddToEnd(vec![key]), + "information" => UiAction::ShowTrackInfo(key), + "playlist" => UiAction::ShowPlaylistPicker(key), + // The remaining presentation actions terminate here until + // their backend capabilities are introduced. + _ => return, + }; + dispatch_action(&window, &state, &backend, action); + } + }); + bind_playlist_callbacks(window, state, backend); + bind_device_callbacks(window, state, backend); + bind_settings_callbacks(window, state, backend); + window.on_dismiss_error({ + let window = window.as_weak(); + let state = Arc::clone(state); + move || { + let Some(window) = window.upgrade() else { + return; + }; + with_state(&state, |state| { + let _ = reduce_action(state, UiAction::DismissError); + render(&window, state); + }); + } + }); + window.on_close_track_info({ + let window = window.as_weak(); + let state = Arc::clone(state); + let backend = backend.clone(); + move || dispatch_action(&window, &state, &backend, UiAction::CloseTrackInfo) + }); +} + +fn bind_playlist_callbacks( + window: &AppWindow, + state: &Arc>, + backend: &BackendHandle, +) { + window.on_open_playlist({ + let window = window.as_weak(); + let state = Arc::clone(state); + let backend = backend.clone(); + move |id| { + if let Ok(id) = id.parse::() { + dispatch_action( + &window, + &state, + &backend, + UiAction::Navigate(Screen::Playlist(id)), + ); + } + } + }); + window.on_create_playlist({ + let window = window.as_weak(); + let state = Arc::clone(state); + let backend = backend.clone(); + move |title| { + dispatch_action( + &window, + &state, + &backend, + UiAction::CreatePlaylist(title.to_string()), + ); + } + }); + window.on_add_track_to_playlist({ + let window = window.as_weak(); + let state = Arc::clone(state); + let backend = backend.clone(); + move |playlist_id| { + let Ok(playlist_id) = playlist_id.parse::() else { + return; + }; + let track = with_state(&state, |state| state.frontend.playlist_picker_track.clone()); + if let Some(track) = track { + dispatch_action( + &window, + &state, + &backend, + UiAction::AddToPlaylist { + playlist_id, + tracks: vec![track], + }, + ); + } + } + }); + window.on_close_playlist_picker({ + let window = window.as_weak(); + let state = Arc::clone(state); + let backend = backend.clone(); + move || dispatch_action(&window, &state, &backend, UiAction::ClosePlaylistPicker) + }); + window.on_play_queue_item({ + let window = window.as_weak(); + let state = Arc::clone(state); + let backend = backend.clone(); + move |id| { + if let Ok(id) = id.parse::() { + dispatch_action( + &window, + &state, + &backend, + UiAction::PlayQueueItem(QueueItemId::new(id)), + ); + } + } + }); +} + +fn bind_catalog_callbacks( + window: &AppWindow, + state: &Arc>, + backend: &BackendHandle, +) { + window.on_layout_detail_contributors({ + let window = window.as_weak(); + let state = Arc::clone(state); + move |width| { + let lines = with_state(&state, |state| { + let Some(release) = selected_release(state) else { + return Vec::new(); + }; + let preferred = match &state.frontend.screen { + Screen::Release(_, preferred) => preferred.as_ref(), + _ => None, + }; + let (_, contributors) = release_artist_credits(&release, preferred); + contributor_lines(&contributors, width) + }); + if let Some(window) = window.upgrade() { + window.set_detail_contributor_lines(model(lines)); + } + } + }); + window.on_go_back({ + let window = window.as_weak(); + let state = Arc::clone(state); + let backend = backend.clone(); + move || dispatch_action(&window, &state, &backend, UiAction::Back) + }); + window.on_go_forward({ + let window = window.as_weak(); + let state = Arc::clone(state); + let backend = backend.clone(); + move || dispatch_action(&window, &state, &backend, UiAction::Forward) + }); + window.on_navigate_breadcrumb({ + let window = window.as_weak(); + let state = Arc::clone(state); + let backend = backend.clone(); + move |target| { + let screen = with_state(&state, |state| breadcrumb_screen(state, &target)); + if let Some(screen) = screen { + dispatch_action(&window, &state, &backend, UiAction::Navigate(screen)); + } + } + }); + window.on_open_artist({ + let window = window.as_weak(); + let state = Arc::clone(state); + let backend = backend.clone(); + move |key| { + let artist = with_state(&state, |state| find_artist_key(state, &key)); + if let Some(key) = artist { + dispatch_action( + &window, + &state, + &backend, + UiAction::Navigate(Screen::Artist(key)), + ); + } + } + }); + window.on_open_release({ + let window = window.as_weak(); + let state = Arc::clone(state); + let backend = backend.clone(); + move |key| { + let (release, preferred_artist) = with_state(&state, |state| { + let preferred_artist = match &state.frontend.screen { + Screen::Artist(key) => Some(key.clone()), + _ => None, + }; + (find_release_key(state, &key), preferred_artist) + }); + if let Some(key) = release { + dispatch_action( + &window, + &state, + &backend, + UiAction::Navigate(Screen::Release(key, preferred_artist)), + ); + } + } + }); +} + +fn bind_player_callbacks( + window: &AppWindow, + state: &Arc>, + backend: &BackendHandle, +) { + window.on_toggle_playback({ + let window = window.as_weak(); + let state = Arc::clone(state); + let backend = backend.clone(); + move || dispatch_action(&window, &state, &backend, UiAction::TogglePlayback) + }); + window.on_next({ + let window = window.as_weak(); + let state = Arc::clone(state); + let backend = backend.clone(); + move || dispatch_action(&window, &state, &backend, UiAction::Next) + }); + window.on_previous({ + let window = window.as_weak(); + let state = Arc::clone(state); + let backend = backend.clone(); + move || dispatch_action(&window, &state, &backend, UiAction::Previous) + }); + window.on_seek({ + let window = window.as_weak(); + let state = Arc::clone(state); + let backend = backend.clone(); + move |fraction| { + let duration = with_state(&state, |state| state.backend.playback.duration_seconds); + dispatch_action( + &window, + &state, + &backend, + UiAction::Seek(f64::from(fraction) * duration), + ); + } + }); + window.on_set_volume({ + let window = window.as_weak(); + let state = Arc::clone(state); + let backend = backend.clone(); + move |volume| { + dispatch_action(&window, &state, &backend, UiAction::SetVolume(volume)); + } + }); + window.on_play_release({ + let window = window.as_weak(); + let state = Arc::clone(state); + let backend = backend.clone(); + move |key| { + let release_id = with_state(&state, |state| find_release_key(state, &key)); + if let Some(release_id) = release_id { + dispatch_action( + &window, + &state, + &backend, + UiAction::PlayRelease { + release_id, + start: 0, + }, + ); + } + } + }); + window.on_play_track({ + let window = window.as_weak(); + let state = Arc::clone(state); + let backend = backend.clone(); + move |key| { + if let Some(key) = parse_track_key(&key) { + dispatch_action(&window, &state, &backend, UiAction::PlayTrack(key)); + } + } + }); + window.on_play_track_context({ + let window = window.as_weak(); + let state = Arc::clone(state); + let backend = backend.clone(); + move |context, selected| { + let Some(selected) = parse_track_key(&selected) else { + return; + }; + let tracks = with_state(&state, |state| track_context(state, context.as_str())); + if tracks.iter().any(|track| track.matches(&selected)) { + dispatch_action( + &window, + &state, + &backend, + UiAction::PlayContext { tracks, selected }, + ); + } + } + }); +} + +fn bind_settings_callbacks( + window: &AppWindow, + state: &Arc>, + backend: &BackendHandle, +) { + window.on_network_id_changed({ + let window = window.as_weak(); + let state = Arc::clone(state); + let backend = backend.clone(); + move |value| { + dispatch_action( + &window, + &state, + &backend, + UiAction::NetworkIdChanged(value.to_string()), + ); + } + }); + window.on_device_name_changed({ + let window = window.as_weak(); + let state = Arc::clone(state); + let backend = backend.clone(); + move |value| { + dispatch_action( + &window, + &state, + &backend, + UiAction::DeviceNameChanged(value.to_string()), + ); + } + }); + window.on_library_path_changed({ + let window = window.as_weak(); + let state = Arc::clone(state); + let backend = backend.clone(); + move |value| { + dispatch_action( + &window, + &state, + &backend, + UiAction::LibraryPathChanged(value.to_string()), + ); + } + }); + bind_library_picker(window, state, backend); + window.on_federation_changed({ + let window = window.as_weak(); + let state = Arc::clone(state); + let backend = backend.clone(); + move |enabled| { + dispatch_action( + &window, + &state, + &backend, + UiAction::FederationChanged(enabled), + ); + } + }); + window.on_save_federated_on_listen_changed({ + let window = window.as_weak(); + let state = Arc::clone(state); + let backend = backend.clone(); + move |enabled| { + dispatch_action( + &window, + &state, + &backend, + UiAction::SaveFederatedOnListenChanged(enabled), + ); + } + }); + window.on_language_changed({ + let window = window.as_weak(); + let state = Arc::clone(state); + let backend = backend.clone(); + move |language| { + dispatch_action( + &window, + &state, + &backend, + UiAction::LanguageChanged(language.to_string()), + ); + } + }); +} + +fn bind_library_picker(window: &AppWindow, state: &Arc>, backend: &BackendHandle) { + window.on_choose_library_path({ + let window = window.as_weak(); + let state = Arc::clone(state); + let backend = backend.clone(); + move || { + let initial = with_state(&state, |state| { + PathBuf::from(&state.backend.settings.library_path) + }); + let task_window = window.clone(); + let task_state = Arc::clone(&state); + let task_backend = backend.clone(); + let result = thread::Builder::new() + .name("furumi-folder-picker".into()) + .spawn(move || { + let selected = + furumi_platform_desktop::choose_library_directory(Some(&initial)); + let Some(selected) = selected else { + return; + }; + let selected = selected.to_string_lossy().into_owned(); + let _ = task_window.upgrade_in_event_loop(move |window| { + dispatch_action( + &window.as_weak(), + &task_state, + &task_backend, + UiAction::LibraryPathChanged(selected), + ); + }); + }); + if let Err(error) = result + && let Some(window) = window.upgrade() + { + dispatch_event( + &window, + &state, + AppEvent::CommandRejected(format!("folder picker: {error}")), + ); + } + } + }); +} + +fn bind_device_callbacks( + window: &AppWindow, + state: &Arc>, + backend: &BackendHandle, +) { + window.on_create_device_invite({ + let window = window.as_weak(); + let state = Arc::clone(state); + let backend = backend.clone(); + move || dispatch_action(&window, &state, &backend, UiAction::CreateDeviceInvite) + }); + window.on_connect_device({ + let window = window.as_weak(); + let state = Arc::clone(state); + let backend = backend.clone(); + move |invite| { + dispatch_action( + &window, + &state, + &backend, + UiAction::ConnectDevice(invite.to_string()), + ); + } + }); + window.on_answer_pairing({ + let window = window.as_weak(); + let state = Arc::clone(state); + let backend = backend.clone(); + move |request_id, accept, use_requester_group| { + dispatch_action( + &window, + &state, + &backend, + UiAction::AnswerDevicePairing { + request_id: request_id.to_string(), + accept, + use_requester_group, + }, + ); + } + }); + window.on_select_device({ + let window = window.as_weak(); + let state = Arc::clone(state); + let backend = backend.clone(); + move |device_id| { + dispatch_action( + &window, + &state, + &backend, + UiAction::SelectPlaybackDevice(device_id.to_string()), + ); + } + }); +} + +fn subscribe_to_backend(window: &AppWindow, state: &Arc>, backend: &BackendHandle) { + let mut snapshots = backend.subscribe(); + let bridge_window = window.as_weak(); + let bridge_state = Arc::clone(state); + let result = thread::Builder::new() + .name("furumi-ui-state-bridge".into()) + .spawn(move || { + let runtime = match tokio::runtime::Builder::new_current_thread().build() { + Ok(runtime) => runtime, + Err(error) => { + report_background_error( + &bridge_window, + bridge_state, + format!("UI state bridge: {error}"), + ); + return; + } + }; + runtime.block_on(async move { + while snapshots.changed().await.is_ok() { + let snapshot = snapshots.borrow_and_update().clone(); + let state = Arc::clone(&bridge_state); + let result = bridge_window.upgrade_in_event_loop(move |window| { + dispatch_event( + &window, + &state, + AppEvent::BackendSnapshot(Box::new(snapshot)), + ); + }); + if result.is_err() { + break; + } + } + }); + }); + if let Err(error) = result { + dispatch_event( + window, + state, + AppEvent::CommandRejected(format!("UI state bridge: {error}")), + ); + } +} + +fn report_background_error( + window: &slint::Weak, + state: Arc>, + message: String, +) { + let window = window.clone(); + let _ = window.upgrade_in_event_loop(move |window| { + dispatch_event(&window, &state, AppEvent::CommandRejected(message)); + }); +} + +fn dispatch_action( + window: &slint::Weak, + state: &Arc>, + backend: &BackendHandle, + action: UiAction, +) { + let Some(window) = window.upgrade() else { + return; + }; + let effects = with_state(state, |state| { + let effects = reduce_action(state, action); + render(&window, state); + effects + }); + for Effect::Send(command) in effects { + if let Err(error) = backend.try_send(command) { + dispatch_event(&window, state, AppEvent::CommandRejected(error.to_string())); + } + } +} + +fn dispatch_event(window: &AppWindow, state: &Arc>, event: AppEvent) { + with_state(state, |state| { + let previous = state.backend.clone(); + reduce_event(state, event); + let shell_changed = previous.federation_activity != state.backend.federation_activity + || previous.federation_debug != state.backend.federation_debug + || previous.connected_devices != state.backend.connected_devices + || previous.settings != state.backend.settings + || previous.playback_error != state.backend.playback_error + || previous.settings_error != state.backend.settings_error; + let catalog_changed = previous.library != state.backend.library + || previous.search != state.backend.search + || previous.queue != state.backend.queue; + let queue_changed = previous.queue != state.backend.queue; + if shell_changed { + render_shell(window, state); + } + if catalog_changed { + render_catalog(window, state); + render_search(window, state); + render_track_info(window, state); + } + if queue_changed { + render_queue(window, state); + render_current_track(window, state); + } + render_playback(window, state); + }); +} + +fn with_state(state: &Arc>, operation: impl FnOnce(&mut AppState) -> T) -> T { + let mut guard = state + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + operation(&mut guard) +} diff --git a/crates/ui/src/render.rs b/crates/ui/src/render.rs new file mode 100644 index 0000000..d4aebb5 --- /dev/null +++ b/crates/ui/src/render.rs @@ -0,0 +1,1643 @@ +use super::*; +pub(super) fn render(window: &AppWindow, state: &AppState) { + render_shell(window, state); + render_catalog(window, state); + render_search(window, state); + render_queue(window, state); + render_current_track(window, state); + render_playback(window, state); +} + +pub(super) fn render_shell(window: &AppWindow, state: &AppState) { + let strings = state.frontend.locale.strings(); + window.set_app_name(strings.app_name.into()); + window.set_home_label(strings.home.into()); + window.set_search_label(strings.search.into()); + window.set_library_label(strings.library.into()); + window.set_queue_label(strings.queue.into()); + window.set_recent_label(strings.recently_played.into()); + window.set_featured_label(strings.made_for_listening.into()); + window.set_search_placeholder(strings.search_placeholder.into()); + window.set_active_screen( + match &state.frontend.screen { + Screen::Home => "home", + Screen::Search => "search", + Screen::Library => "library", + Screen::Artist(_) => "artist", + Screen::Release(_, _) => "release", + Screen::Playlist(_) => "playlist", + } + .into(), + ); + window.set_can_go_back(!state.frontend.navigation_history.is_empty()); + window.set_can_go_forward(!state.frontend.navigation_forward.is_empty()); + window.set_breadcrumbs(model(breadcrumbs(state))); + window.set_queue_open(state.frontend.queue_open); + window.set_settings_open(state.frontend.settings_open); + window.set_playlist_picker_open(state.frontend.playlist_picker_track.is_some()); + window.set_network_id(state.backend.settings.network_id.clone().into()); + window.set_device_name(state.backend.settings.device_name.clone().into()); + window.set_library_path(state.backend.settings.library_path.clone().into()); + window.set_federation_enabled(state.backend.settings.federation_enabled); + window.set_save_federated_on_listen(state.backend.settings.save_federated_on_listen); + window.set_selected_language(state.backend.settings.language.clone().into()); + window.set_available_languages(model(vec![SharedString::from("English")])); + window.set_search_query(state.frontend.search_query.clone().into()); + render_playlist_shell(window, state); + render_device_shell(window, state); + let (network_busy, network_text) = federation_status(state); + window.set_federation_status_busy(network_busy); + window.set_federation_status_text(network_text.into()); + render_federation_debug(window, state); + render_build_info(window, state); + window.set_error_message( + state + .transient_error + .clone() + .or_else(|| state.backend.playback_error.clone()) + .or_else(|| state.backend.settings_error.clone()) + .unwrap_or_default() + .into(), + ); + render_track_info(window, state); +} + +fn render_federation_debug(window: &AppWindow, state: &AppState) { + let debug = &state.backend.federation_debug; + window.set_federation_debug_node( + if debug.running { + "Running" + } else if debug.error.is_some() { + "Error" + } else { + "Stopped" + } + .into(), + ); + window.set_federation_debug_peers( + format!( + "{} connected · {} known", + debug.connected_peers, debug.known_contacts + ) + .into(), + ); + window.set_federation_debug_dht( + debug + .stored_dht_records + .map_or_else(|| "n/a".into(), |records| format!("{records} records")) + .into(), + ); + window.set_federation_debug_published(format!("{} items", debug.published_items).into()); + window.set_federation_debug_endpoint( + federation_node_ids(&debug.endpoint_id, &debug.dht_node_id).into(), + ); +} + +fn render_build_info(window: &AppWindow, state: &AppState) { + window.set_software_versions(model( + state + .backend + .build_info + .software + .iter() + .map(|entry| VersionView { + name: entry.name.clone().into(), + version: entry.version.clone().into(), + }) + .collect(), + )); + window.set_protocol_versions(model( + state + .backend + .build_info + .protocols + .iter() + .map(|entry| VersionView { + name: entry.name.clone().into(), + version: entry.version.clone().into(), + }) + .collect(), + )); +} + +fn render_playlist_shell(window: &AppWindow, state: &AppState) { + let playlists = ready_library(state) + .map(|library| { + library + .playlists + .iter() + .map(|playlist| PlaylistView { + id: playlist.id.to_string().into(), + title: playlist.title.clone().into(), + details: format!( + "{} {}", + playlist.tracks.len(), + if playlist.tracks.len() == 1 { + "track" + } else { + "tracks" + } + ) + .into(), + is_likes: playlist.is_likes, + }) + .collect() + }) + .unwrap_or_default(); + window.set_playlists(model(playlists)); +} + +fn render_device_shell(window: &AppWindow, state: &AppState) { + let devices = &state.backend.connected_devices; + window.set_connected_devices(model( + devices + .devices + .iter() + .filter(|device| device.trust != DeviceTrust::Revoked) + .map(|device| DeviceView { + id: device.id.clone().into(), + name: device.name.clone().into(), + details: format!( + "{} · {}", + if device.presence == DevicePresence::Online { + "online" + } else { + "offline" + }, + if device.trust == DeviceTrust::Revoked { + "revoked".to_string() + } else if device.client_version.is_empty() { + "Furumi".to_string() + } else { + format!("v{}", device.client_version) + } + ) + .into(), + is_self: device.is_self, + online: device.presence == DevicePresence::Online, + active: device.is_active, + revoked: device.trust == DeviceTrust::Revoked, + }) + .collect(), + )); + window.set_pending_pairings(model( + devices + .pending_pairings + .iter() + .map(|pending| PairingView { + request_id: pending.request_id.clone().into(), + name: pending.name.clone().into(), + details: format!("Furumi {} · {}", pending.client_version, pending.device_id) + .into(), + group_conflict: pending.requester_group_id.is_some() + && pending.requester_group_active_devices > 1, + }) + .collect(), + )); + window.set_device_role_label( + match devices.role { + DevicePlaybackRole::Active => "Active device", + DevicePlaybackRole::Control => "Control device", + } + .into(), + ); + window.set_device_is_active(devices.role == DevicePlaybackRole::Active); + window.set_active_device_label(devices.active_device_name.clone().into()); + window.set_device_group_label(format!("Network · {}", devices.group_id).into()); + window.set_device_invite(devices.invite.clone().unwrap_or_default().into()); + window.set_device_busy(devices.busy); + window.set_device_status( + devices + .error + .clone() + .or_else(|| devices.last_sync.clone()) + .unwrap_or_default() + .into(), + ); +} + +pub(super) fn render_track_info(window: &AppWindow, state: &AppState) { + let track = state + .frontend + .track_info + .as_ref() + .and_then(|key| find_track(state, key)); + window.set_track_info_open(track.is_some()); + window.set_track_info_title( + track + .map_or_else(String::new, |track| track.title.clone()) + .into(), + ); + window.set_track_info_artist_links(model(track.map_or_else(Vec::new, track_artist_links))); + window.set_track_info_release( + track + .map_or_else(String::new, |track| { + let mut label = track.release.clone(); + if let Some(disc) = track.disc_number { + let _ = write!(label, " · disc {disc}"); + } + if let Some(number) = track.track_number { + let _ = write!(label, " · track {number}"); + } + label + }) + .into(), + ); + window.set_track_info_duration( + track + .map_or_else(String::new, |track| { + format!( + "{} ({:.2} s)", + duration_label(track.duration_seconds), + track.duration_seconds + ) + }) + .into(), + ); + window.set_track_info_format( + track + .and_then(|track| track.audio_format.clone()) + .unwrap_or_default() + .to_uppercase() + .into(), + ); + window.set_track_info_quality(track.map(track_quality_label).unwrap_or_default().into()); + window.set_track_info_source( + track + .map_or_else(String::new, |track| match track.audio_source { + AudioSource::LocalFile(_) => "Local library".into(), + AudioSource::Federation { .. } => "Federation".into(), + }) + .into(), + ); + window.set_track_info_content_id( + track + .and_then(|track| track.key.content_id()) + .map_or_else(String::new, |id| id.as_str().to_owned()) + .into(), + ); + window.set_track_info_path( + track + .map_or_else(String::new, |track| match &track.audio_source { + AudioSource::LocalFile(path) => path.to_string_lossy().into_owned(), + AudioSource::Federation { peer_id, .. } => format!("peer {peer_id}"), + }) + .into(), + ); + let (artwork, has_artwork) = load_artwork(track.and_then(|track| track.cover_uri.as_deref())); + window.set_track_info_artwork(artwork); + window.set_track_info_has_artwork(has_artwork); +} + +fn track_quality_label(track: &Track) -> String { + let mut fields = Vec::new(); + if let Some(bitrate) = track.audio_bitrate_kbps { + fields.push(format!("{bitrate} kbps")); + } + if let Some(rate) = track.audio_sample_rate_hz { + fields.push(format!("{:.1} kHz", f64::from(rate) / 1_000.0)); + } + if let Some(depth) = track.audio_bit_depth { + fields.push(format!("{depth} bit")); + } + if let Some(size) = track.file_size_bytes { + let whole = size / 1_048_576; + let decimal = size % 1_048_576 * 10 / 1_048_576; + fields.push(format!("{whole}.{decimal} MiB")); + } + fields.join(" · ") +} + +fn sync_media_session(state: &AppState) { + MEDIA_SESSION.with_borrow_mut(|session| { + let Some(session) = session.as_mut() else { + return; + }; + if let Some(track) = state.backend.queue.current().map(|item| &item.track) { + session.update_metadata( + &track.title, + &track.artist, + &track.release, + track.duration_seconds, + ); + } + session.update_playback( + state.backend.playback.status != PlaybackStatus::Stopped, + state.backend.playback.status == PlaybackStatus::Paused, + state.backend.playback.position_seconds, + ); + }); +} + +fn federation_status(state: &AppState) -> (bool, String) { + if !state.backend.settings.federation_enabled { + return (false, "Federation disabled".into()); + } + let activity = &state.backend.federation_activity; + let operation = match activity.operation { + FederationOperation::Idle => "Connecting to federation", + FederationOperation::Search => "Searching federation", + FederationOperation::Artist => "Loading artist", + FederationOperation::Release => "Loading release", + }; + if activity.pending { + return (true, format!("{operation}…")); + } + if let Some(error) = &activity.error { + return (false, format!("Network error · {error}")); + } + if let Some(stats) = &activity.stats { + return ( + false, + format!( + "{} tracks · {} artists · {} peers · {}", + stats.tracks, + stats.artists, + stats.peers_queried, + search_duration_label(stats.duration_ms) + ), + ); + } + (false, "Federation ready".into()) +} + +fn federation_node_ids(endpoint_id: &str, dht_node_id: &str) -> String { + fn short(value: &str) -> &str { + value.get(..12).unwrap_or(value) + } + match (endpoint_id.is_empty(), dht_node_id.is_empty()) { + (false, false) => format!("ep {} · dht {}", short(endpoint_id), short(dht_node_id)), + (false, true) => format!("ep {}", short(endpoint_id)), + (true, false) => format!("dht {}", short(dht_node_id)), + (true, true) => "n/a".into(), + } +} + +fn ready_library(state: &AppState) -> Option<&furumi_backend_api::LibrarySnapshot> { + match &state.backend.library { + RemoteData::Ready(library) => Some(library), + _ => None, + } +} + +fn find_track<'a>(state: &'a AppState, key: &TrackKey) -> Option<&'a Track> { + state + .backend + .queue + .items() + .iter() + .map(|item| &item.track) + .chain(state.backend.search.results.tracks.iter()) + .chain( + state + .backend + .search + .results + .releases + .iter() + .flat_map(|release| release.tracks.iter()), + ) + .chain( + ready_library(state) + .into_iter() + .flat_map(|library| library.featured_releases.iter()) + .flat_map(|release| release.tracks.iter()), + ) + .chain( + ready_library(state) + .into_iter() + .flat_map(|library| library.playlists.iter()) + .flat_map(|playlist| playlist.tracks.iter()), + ) + .find(|track| track.key.matches(key)) +} + +fn selected_artist(state: &AppState) -> Option<&Artist> { + let Screen::Artist(key) = &state.frontend.screen else { + return None; + }; + state + .backend + .search + .results + .artists + .iter() + .chain( + ready_library(state) + .into_iter() + .flat_map(|library| library.artists.iter()), + ) + .find(|artist| &artist.key == key) +} + +pub(super) fn selected_release(state: &AppState) -> Option { + let Screen::Release(key, _) = &state.frontend.screen else { + return None; + }; + let mut candidates = ready_library(state) + .into_iter() + .flat_map(|library| library.featured_releases.iter()) + .chain(state.backend.search.results.releases.iter()) + .filter(|release| &release.key == key) + .cloned(); + let mut selected = candidates.next()?; + for candidate in candidates { + merge_release_availability(&mut selected, candidate); + } + sort_release_tracks(&mut selected.tracks); + Some(selected) +} + +fn selected_playlist(state: &AppState) -> Option<&furumi_backend_api::PlaylistSnapshot> { + let Screen::Playlist(id) = state.frontend.screen else { + return None; + }; + ready_library(state)? + .playlists + .iter() + .find(|playlist| playlist.id == id) +} + +pub(super) fn track_context(state: &AppState, context: &str) -> Vec { + let tracks: Vec = match context { + "release" => selected_release(state).map_or_else(Vec::new, |release| release.tracks), + "search" => state.backend.search.results.tracks.clone(), + "recent" => ready_library(state) + .into_iter() + .flat_map(|library| library.recently_played.iter()) + .cloned() + .collect(), + "artist-featured" => selected_artist(state).map_or_else(Vec::new, |artist| { + ready_library(state) + .into_iter() + .flat_map(|library| library.featured_releases.iter()) + .flat_map(|release| release.tracks.iter()) + .chain(state.backend.search.results.tracks.iter()) + .filter(|track| { + track + .featured_artists + .iter() + .any(|candidate| candidate.key == artist.key) + }) + .cloned() + .collect() + }), + "playlist" => selected_playlist(state) + .into_iter() + .flat_map(|playlist| playlist.tracks.iter()) + .cloned() + .collect(), + _ => Vec::new(), + }; + tracks.into_iter().map(|track| track.key).collect() +} + +pub(super) fn breadcrumb_screen(state: &AppState, target: &SharedString) -> Option { + match target.as_str() { + "home" => Some(Screen::Home), + "search" => Some(Screen::Search), + "library" => Some(Screen::Library), + value if value.starts_with("playlist:") => value + .trim_start_matches("playlist:") + .parse::() + .ok() + .map(Screen::Playlist), + _ => find_artist_key(state, target).map(Screen::Artist), + } +} + +fn breadcrumbs(state: &AppState) -> Vec { + let crumb = |label: String, target: String, trailing: bool| BreadcrumbView { + label: if trailing { + format!("{label} › ") + } else { + label + } + .into(), + target: target.into(), + }; + match &state.frontend.screen { + Screen::Home => vec![crumb("Home".into(), String::new(), false)], + Screen::Search => vec![ + crumb("Home".into(), "home".into(), true), + crumb("Search".into(), String::new(), false), + ], + Screen::Library => vec![ + crumb("Home".into(), "home".into(), true), + crumb("Library".into(), String::new(), false), + ], + Screen::Artist(_) => vec![ + crumb("Home".into(), "home".into(), true), + crumb( + selected_artist(state) + .map_or("Artist", |artist| artist.name.as_str()) + .to_owned(), + String::new(), + false, + ), + ], + Screen::Release(_, preferred) => { + let release = selected_release(state); + let release = release.as_ref(); + let artist = preferred + .as_ref() + .and_then(|key| { + release.and_then(|release| { + release.artists.iter().find(|artist| &artist.key == key) + }) + }) + .or_else(|| release.and_then(|release| release.artists.first())); + let mut result = vec![crumb("Home".into(), "home".into(), true)]; + if let Some(artist) = artist { + result.push(crumb( + artist.name.clone(), + format_artist_key(&artist.key), + true, + )); + } + result.push(crumb( + release + .map_or("Release", |release| release.title.as_str()) + .to_owned(), + String::new(), + false, + )); + result + } + Screen::Playlist(_) => vec![ + crumb("Home".into(), "home".into(), true), + crumb( + selected_playlist(state) + .map_or("Playlist", |playlist| playlist.title.as_str()) + .to_owned(), + String::new(), + false, + ), + ], + } +} + +pub(super) fn render_catalog(window: &AppWindow, state: &AppState) { + let library = ready_library(state); + let releases = library.map_or_else(Vec::new, |library| { + library + .featured_releases + .iter() + .map(release_to_view) + .collect() + }); + window.set_releases(model(releases)); + let artists = library.map_or_else(Vec::new, |library| { + library.artists.iter().map(artist_to_view).collect() + }); + window.set_artists(model(artists)); + + let selected_artist = match &state.frontend.screen { + Screen::Artist(key) => state + .backend + .search + .results + .artists + .iter() + .chain( + library + .into_iter() + .flat_map(|library| library.artists.iter()), + ) + .find(|artist| &artist.key == key), + _ => None, + }; + let artist_releases = selected_artist.map_or_else(Vec::new, |artist| { + let releases = library + .into_iter() + .flat_map(|library| library.featured_releases.iter()) + .chain(state.backend.search.results.releases.iter()) + .filter(|release| { + release + .artists + .iter() + .any(|candidate| candidate.key == artist.key) + }) + .cloned(); + let mut releases = merge_artist_releases(releases); + sort_releases_newest_first(&mut releases); + releases + }); + window.set_artist_albums(model( + artist_releases + .iter() + .filter(|release| release.is_album()) + .map(release_to_view) + .collect(), + )); + window.set_artist_other_releases(model( + artist_releases + .iter() + .filter(|release| !release.is_album()) + .map(release_to_view) + .collect(), + )); + window.set_artist_featured_tracks(model(selected_artist.map_or_else(Vec::new, |artist| { + let tracks = library + .into_iter() + .flat_map(|library| { + library + .featured_releases + .iter() + .flat_map(|release| release.tracks.iter()) + }) + .chain(state.backend.search.results.tracks.iter()) + .filter(|track| { + track + .featured_artists + .iter() + .any(|candidate| candidate.key == artist.key) + }) + .cloned() + .collect::>(); + tracks_to_views(&tracks, state) + }))); + + render_catalog_detail(window, state, selected_artist); + + let recent = library.map_or_else(Vec::new, |library| { + tracks_to_views(&library.recently_played, state) + }); + window.set_tracks(model(recent)); + let playlist = selected_playlist(state); + window.set_playlist_title( + playlist + .map_or_else(String::new, |playlist| playlist.title.clone()) + .into(), + ); + window.set_playlist_tracks(model(playlist.map_or_else(Vec::new, |playlist| { + tracks_to_views(&playlist.tracks, state) + }))); +} + +fn render_catalog_detail(window: &AppWindow, state: &AppState, selected_artist: Option<&Artist>) { + let selected_release = selected_release(state); + let selected_release = selected_release.as_ref(); + let detail_artwork = selected_artist + .and_then(|artist| artist.artwork.uri.as_deref()) + .or_else(|| selected_release.and_then(|release| release.artwork.uri.as_deref())); + let (detail_artwork, detail_has_artwork) = load_artwork(detail_artwork); + window.set_detail_artwork(detail_artwork); + window.set_detail_has_artwork(detail_has_artwork); + window.set_detail_title( + selected_artist + .map(|artist| artist.name.as_str()) + .or_else(|| selected_release.map(|release| release.title.as_str())) + .unwrap_or_default() + .into(), + ); + let preferred_artist = match &state.frontend.screen { + Screen::Release(_, preferred) => preferred.as_ref(), + _ => None, + }; + window.set_detail_subtitle( + selected_artist + .map(|artist| { + format!( + "{} releases · {} tracks", + artist.release_count, artist.track_count + ) + }) + .unwrap_or_default() + .into(), + ); + window.set_detail_release_type( + selected_release + .map(|release| release_type_label(&release.release_type)) + .unwrap_or_default() + .into(), + ); + let (main_artists, contributors) = selected_release.map_or_else( + || (Vec::new(), Vec::new()), + |release| release_artist_credits(release, preferred_artist), + ); + window.set_detail_main_artists(model( + main_artists + .iter() + .enumerate() + .map(|(index, artist)| artist_link_view(artist, index)) + .collect(), + )); + window.set_detail_contributor_lines(model(contributor_lines(&contributors, 420.0))); + window.set_detail_tracks(model(selected_release.map_or_else(Vec::new, |release| { + release_tracks_to_views(&release.tracks, state) + }))); + window.set_detail_federation_state(selected_release.map_or(0, release_federation_state)); +} + +pub(super) fn render_queue(window: &AppWindow, state: &AppState) { + let queue = state + .backend + .queue + .items() + .iter() + .enumerate() + .map(|(index, item)| { + let (artwork, has_artwork) = load_artwork(item.track.cover_uri.as_deref()); + QueueView { + key: item.id.get().to_string().into(), + title: item.track.title.clone().into(), + artist: item.track.artist.clone().into(), + artist_key: item + .track + .artists + .first() + .map(|artist| format_artist_key(&artist.key)) + .unwrap_or_default() + .into(), + release: item.track.release.clone().into(), + release_key: format_release_key(&item.track.release_id).into(), + active: state.backend.queue.current_index() == Some(index), + artwork, + has_artwork, + } + }) + .collect(); + window.set_queue_items(model(queue)); +} + +pub(super) fn render_playback(window: &AppWindow, state: &AppState) { + sync_media_session(state); + let player = state.player_projection(); + window.set_has_track(player.has_track); + window.set_playing(player.playing); + window.set_elapsed(player.elapsed.into()); + window.set_duration(player.duration.into()); + window.set_progress(player.progress); + window.set_volume(player.volume); +} + +pub(super) fn render_current_track(window: &AppWindow, state: &AppState) { + let current = state.backend.queue.current().map(|item| &item.track); + window.set_current_title( + current + .map(|track| track.title.clone()) + .unwrap_or_default() + .into(), + ); + window.set_current_artists(model(current.map_or_else(Vec::new, track_artist_links))); + window.set_current_metadata(current.map(track_metadata_label).unwrap_or_default().into()); + window.set_current_release( + current + .map(|track| track.release.clone()) + .unwrap_or_default() + .into(), + ); + window.set_current_release_key( + current + .map(|track| format_release_key(&track.release_id)) + .unwrap_or_default() + .into(), + ); + let (current_artwork, current_has_artwork) = + load_artwork(current.and_then(|track| track.cover_uri.as_deref())); + window.set_current_artwork(current_artwork); + window.set_current_has_artwork(current_has_artwork); +} + +pub(super) fn render_search(window: &AppWindow, state: &AppState) { + let search = &state.backend.search; + window.set_search_artists(model( + search.results.artists.iter().map(artist_to_view).collect(), + )); + window.set_search_releases(model( + search + .results + .releases + .iter() + .map(release_to_view) + .collect(), + )); + window.set_search_results(model(tracks_to_views(&search.results.tracks, state))); +} + +fn search_duration_label(milliseconds: u64) -> String { + if milliseconds < 1_000 { + format!("{milliseconds} ms") + } else { + format!( + "{}.{:01} s", + milliseconds / 1_000, + milliseconds % 1_000 / 100 + ) + } +} + +fn tracks_to_views(tracks: &[Track], state: &AppState) -> Vec { + tracks_to_views_with_numbers(tracks, state, false) +} + +fn release_tracks_to_views(tracks: &[Track], state: &AppState) -> Vec { + tracks_to_views_with_numbers(tracks, state, true) +} + +fn tracks_to_views_with_numbers( + tracks: &[Track], + state: &AppState, + use_metadata_number: bool, +) -> Vec { + let current = state.backend.queue.current().map(|item| &item.track.key); + tracks + .iter() + .enumerate() + .map(|(index, track)| { + let (artwork, has_artwork) = load_artwork(track.cover_uri.as_deref()); + let artist_links = track_artist_links(track); + TrackView { + key: format_track_key(&track.key).into(), + number: track_number_label(track, index, use_metadata_number).into(), + title: track.title.clone().into(), + artists: model(artist_links), + artists_label: track_artist_label(track).into(), + artist_line_break: track_artist_line_break(track), + release: track.release.clone().into(), + release_key: format_release_key(&track.release_id).into(), + duration: duration_label(track.duration_seconds).into(), + active: current.is_some_and(|key| key.matches(&track.key)), + artwork, + has_artwork, + federated: matches!(track.audio_source, AudioSource::Federation { .. }), + liked: track.liked, + } + }) + .collect() +} + +fn track_number_label(track: &Track, index: usize, use_metadata_number: bool) -> String { + if use_metadata_number { + track + .track_number + .map_or_else(|| "—".into(), |number| number.to_string()) + } else { + (index + 1).to_string() + } +} + +fn track_artist_links(track: &Track) -> Vec { + let mut links = Vec::with_capacity(track.artists.len() + track.featured_artists.len()); + for (index, artist) in track.artists.iter().enumerate() { + links.push(ArtistLinkView { + key: format_artist_key(&artist.key).into(), + name: artist.name.clone().into(), + prefix: if index == 0 { "" } else { ", " }.into(), + }); + } + for (index, artist) in track.featured_artists.iter().enumerate() { + links.push(ArtistLinkView { + key: format_artist_key(&artist.key).into(), + name: artist.name.clone().into(), + prefix: if index == 0 { " feat. " } else { ", " }.into(), + }); + } + links +} + +fn track_artist_label(track: &Track) -> String { + let mut label = track + .artists + .iter() + .map(|artist| artist.name.as_str()) + .collect::>() + .join(", "); + if !track.featured_artists.is_empty() { + if !label.is_empty() { + label.push_str(" feat. "); + } + label.push_str( + &track + .featured_artists + .iter() + .map(|artist| artist.name.as_str()) + .collect::>() + .join(", "), + ); + } + if label.is_empty() { + track.artist.clone() + } else { + label + } +} + +fn track_artist_line_break(track: &Track) -> i32 { + let segments = track + .artists + .iter() + .map(|artist| artist.name.chars().count() + 2) + .chain( + track + .featured_artists + .iter() + .map(|artist| artist.name.chars().count() + 2), + ) + .collect::>(); + if segments.len() < 2 { + return 0; + } + + let total = segments.iter().sum::(); + let mut prefix = 0_usize; + let mut best_index = 1_usize; + let mut best_delta = usize::MAX; + for (index, width) in segments.iter().take(segments.len() - 1).enumerate() { + prefix = prefix.saturating_add(*width); + let delta = prefix.abs_diff(total.saturating_sub(prefix)); + if delta < best_delta { + best_delta = delta; + best_index = index + 1; + } + } + i32::try_from(best_index).unwrap_or(i32::MAX) +} + +fn track_metadata_label(track: &Track) -> String { + let mut parts = Vec::with_capacity(2); + if let Some(format) = track + .audio_format + .as_deref() + .filter(|value| !value.is_empty()) + { + parts.push(format.to_uppercase()); + } + if let Some(bitrate) = track.audio_bitrate_kbps { + parts.push(format!("{bitrate} kbps")); + } + parts.join(" · ") +} + +fn artist_link_view(artist: &ArtistRef, index: usize) -> ArtistLinkView { + ArtistLinkView { + key: format_artist_key(&artist.key).into(), + name: artist.name.clone().into(), + prefix: if index == 0 { "" } else { ", " }.into(), + } +} + +pub(super) fn contributor_lines(artists: &[ArtistRef], width: f32) -> Vec { + let width = width.max(220.0); + let mut lines = Vec::>::new(); + let mut current = Vec::new(); + let mut current_width = 0.0_f32; + + for (index, artist) in artists.iter().enumerate() { + // Eleven-pixel system text averages a little under seven logical + // pixels per glyph. The conservative estimate keeps every artist on + // one line while still packing short names naturally. + let has_trailing_comma = index + 1 < artists.len(); + let glyph_count = u16::try_from(artist.name.chars().count()).unwrap_or(u16::MAX); + let name_width = f32::from(glyph_count) * 7.0 + if has_trailing_comma { 3.0 } else { 0.0 }; + let separator_width = if current.is_empty() { 0.0 } else { 9.0 }; + if !current.is_empty() && current_width + separator_width + name_width > width { + lines.push(std::mem::take(&mut current)); + current_width = 0.0; + } + let prefix = if current.is_empty() { "" } else { " " }; + current.push(ArtistLinkView { + key: format_artist_key(&artist.key).into(), + name: if has_trailing_comma { + format!("{},", artist.name) + } else { + artist.name.clone() + } + .into(), + prefix: prefix.into(), + }); + current_width += (if prefix.is_empty() { 0.0 } else { 9.0 }) + name_width; + + if index + 1 == artists.len() && !current.is_empty() { + lines.push(std::mem::take(&mut current)); + } + } + + lines + .into_iter() + .map(|artists| ArtistLinkLineView { + artists: model(artists), + }) + .collect() +} + +fn artist_to_view(artist: &Artist) -> ArtistView { + let (artwork, has_artwork) = load_artwork(artist.artwork.uri.as_deref()); + ArtistView { + key: format_artist_key(&artist.key).into(), + name: artist.name.clone().into(), + details: format!( + "{} releases · {} tracks", + artist.release_count, artist.track_count + ) + .into(), + artwork, + has_artwork, + federated: matches!(artist.source, CatalogSource::Federation { .. }), + } +} + +fn release_to_view(release: &Release) -> ReleaseView { + let (artwork, has_artwork) = load_artwork(release.artwork.uri.as_deref()); + ReleaseView { + key: format_release_key(&release.key).into(), + title: release.title.clone().into(), + artist: release.artist_line().into(), + artist_key: release + .artists + .first() + .map(|artist| format_artist_key(&artist.key)) + .unwrap_or_default() + .into(), + year: release + .year + .map_or_else(String::new, |year| year.to_string()) + .into(), + release_type: release_type_label(&release.release_type).into(), + artwork, + has_artwork, + federation_state: release_federation_state(release), + } +} + +fn release_federation_state(release: &Release) -> i32 { + if release.tracks.is_empty() { + return i32::from(matches!(release.source, CatalogSource::Federation { .. })); + } + let local = release + .tracks + .iter() + .filter(|track| matches!(track.audio_source, AudioSource::LocalFile(_))) + .count(); + match local { + 0 => 1, + count if count == release.tracks.len() => 0, + _ => 2, + } +} + +fn release_type_label(release_type: &str) -> String { + let release_type = release_type.trim(); + if release_type.is_empty() { + return "Release".into(); + } + if release_type.eq_ignore_ascii_case("ep") { + return "EP".into(); + } + let mut characters = release_type.chars(); + let Some(first) = characters.next() else { + return "Release".into(); + }; + first.to_uppercase().chain(characters).collect() +} + +pub(super) fn release_artist_credits( + release: &Release, + preferred: Option<&ArtistKey>, +) -> (Vec, Vec) { + let all_artists = || { + release + .artists + .iter() + .chain(release.featured_artists.iter()) + .chain( + release + .tracks + .iter() + .flat_map(|track| track.artists.iter().chain(track.featured_artists.iter())), + ) + }; + let primary = preferred + .and_then(|preferred| all_artists().find(|artist| &artist.key == preferred)) + .or_else(|| release.artists.first()) + .or_else(|| { + release + .tracks + .iter() + .find_map(|track| track.artists.first()) + }) + .cloned(); + let mut known = HashSet::new(); + let mut main = Vec::new(); + if let Some(primary) = primary { + known.insert(artist_name_key(&primary.name)); + main.push(primary); + } + let contributors = all_artists() + .filter(|artist| known.insert(artist_name_key(&artist.name))) + .cloned() + .collect(); + (main, contributors) +} + +fn artist_name_key(name: &str) -> String { + name.trim().to_lowercase() +} + +fn merge_artist_releases(releases: impl IntoIterator) -> Vec { + let mut merged = Vec::::new(); + let mut slots = HashMap::<(String, String, Option), usize>::new(); + for release in releases { + let identity = ( + release.title.trim().to_lowercase(), + release.release_type.trim().to_lowercase(), + release.year, + ); + if let Some(index) = slots.get(&identity).copied() { + merge_release_availability(&mut merged[index], release); + } else { + slots.insert(identity, merged.len()); + merged.push(release); + } + } + merged +} + +fn sort_releases_newest_first(releases: &mut [Release]) { + releases.sort_by(|left, right| { + right + .year + .unwrap_or(i32::MIN) + .cmp(&left.year.unwrap_or(i32::MIN)) + .then_with(|| left.release_type.cmp(&right.release_type)) + .then_with(|| left.title.cmp(&right.title)) + }); +} + +fn merge_release_availability(target: &mut Release, incoming: Release) { + if matches!(target.source, CatalogSource::Federation { .. }) + && matches!(incoming.source, CatalogSource::Local) + { + let previous = std::mem::replace(target, incoming); + merge_release_availability(target, previous); + return; + } + if target.artwork.uri.is_none() && incoming.artwork.uri.is_some() { + target.artwork = incoming.artwork; + } + merge_credit_refs(&mut target.artists, incoming.artists); + merge_credit_refs(&mut target.featured_artists, incoming.featured_artists); + for track in incoming.tracks { + if let Some(existing) = target + .tracks + .iter_mut() + .find(|existing| tracks_match_within_release(existing, &track)) + { + if matches!(existing.audio_source, AudioSource::Federation { .. }) + && matches!(track.audio_source, AudioSource::LocalFile(_)) + { + *existing = track; + } + } else { + target.tracks.push(track); + } + } + sort_release_tracks(&mut target.tracks); +} + +fn sort_release_tracks(tracks: &mut [Track]) { + tracks.sort_by(|left, right| { + left.disc_number + .unwrap_or(1) + .cmp(&right.disc_number.unwrap_or(1)) + .then_with(|| { + left.track_number + .unwrap_or(u32::MAX) + .cmp(&right.track_number.unwrap_or(u32::MAX)) + }) + .then_with(|| left.title.cmp(&right.title)) + }); +} + +fn tracks_match_within_release(left: &Track, right: &Track) -> bool { + if left.key.matches(&right.key) { + return true; + } + if let (Some(left_number), Some(right_number)) = (left.track_number, right.track_number) + && left_number == right_number + && left.disc_number.unwrap_or(1) == right.disc_number.unwrap_or(1) + { + return true; + } + left.title.trim().eq_ignore_ascii_case(right.title.trim()) +} + +fn merge_credit_refs(target: &mut Vec, incoming: Vec) { + let mut known = target + .iter() + .map(|artist| artist_name_key(&artist.name)) + .collect::>(); + target.extend( + incoming + .into_iter() + .filter(|artist| known.insert(artist_name_key(&artist.name))), + ); +} + +fn load_artwork(uri: Option<&str>) -> (Image, bool) { + let Some(uri) = uri.filter(|uri| !uri.trim().is_empty()) else { + return (Image::default(), false); + }; + let path = PathBuf::from(uri.strip_prefix("file://").unwrap_or(uri)); + let cached = ARTWORK_CACHE.with(|cache| cache.borrow().get(&path).cloned()); + let image = cached.unwrap_or_else(|| { + let image = decode_artwork(&path).ok(); + ARTWORK_CACHE.with(|cache| { + cache.borrow_mut().insert(path.clone(), image.clone()); + }); + image + }); + image.map_or_else(|| (Image::default(), false), |image| (image, true)) +} + +fn decode_artwork(path: &Path) -> Result { + if path + .extension() + .and_then(|extension| extension.to_str()) + .is_some_and(|extension| extension.eq_ignore_ascii_case("webp")) + { + let bytes = std::fs::read(path).map_err(|error| error.to_string())?; + decode_webp(&bytes) + } else { + Image::load_from_path(path).map_err(|error| error.to_string()) + } +} + +fn decode_webp(bytes: &[u8]) -> Result { + let rgba = image::load_from_memory_with_format(bytes, image::ImageFormat::WebP) + .map_err(|error| error.to_string())? + .into_rgba8(); + let buffer = SharedPixelBuffer::::clone_from_slice( + rgba.as_raw(), + rgba.width(), + rgba.height(), + ); + Ok(Image::from_rgba8(buffer)) +} + +fn format_artist_key(key: &ArtistKey) -> String { + match key { + ArtistKey::Local(id) => format!("local-artist:{}", id.get()), + ArtistKey::Federation { peer_id, id } => format!("fed-artist:{peer_id}:{id}"), + } +} + +fn format_release_key(key: &ReleaseKey) -> String { + match key { + ReleaseKey::Local(id) => format!("local-release:{}", id.get()), + ReleaseKey::Federation { peer_id, id } => format!("fed-release:{peer_id}:{id}"), + } +} + +pub(super) fn find_artist_key(state: &AppState, value: &SharedString) -> Option { + let local = match &state.backend.library { + RemoteData::Ready(library) => library.artists.as_slice(), + _ => &[], + }; + local + .iter() + .chain(state.backend.search.results.artists.iter()) + .find(|artist| format_artist_key(&artist.key) == value.as_str()) + .map(|artist| artist.key.clone()) +} + +pub(super) fn find_release_key(state: &AppState, value: &SharedString) -> Option { + let local = match &state.backend.library { + RemoteData::Ready(library) => library.featured_releases.as_slice(), + _ => &[], + }; + local + .iter() + .chain(state.backend.search.results.releases.iter()) + .find(|release| format_release_key(&release.key) == value.as_str()) + .map(|release| release.key.clone()) +} + +fn format_track_key(key: &TrackKey) -> String { + if let Some(local) = key.local_id() { + format!("local:{}", local.get()) + } else if let Some((peer, item)) = key.federation_id() { + format!( + "fed-track:{peer}:{item}:{}", + key.content_id().map_or("", ContentId::as_str) + ) + } else if let Some(content) = key.content_id() { + content.as_str().to_owned() + } else { + String::new() + } +} + +pub(super) fn parse_track_key(value: &SharedString) -> Option { + if let Some(local) = value.strip_prefix("local:") { + return local + .parse::() + .ok() + .map(|id| TrackKey::local(LocalTrackId::new(id))); + } + if let Some(federated) = value.strip_prefix("fed-track:") { + let mut parts = federated.splitn(3, ':'); + let peer = parts.next()?.to_owned(); + let item = parts.next()?.to_owned(); + let content = parts + .next() + .filter(|value| !value.is_empty()) + .and_then(|value| ContentId::parse(value).ok()); + return Some(TrackKey::federation(peer, item, content)); + } + furumi_domain::ContentId::parse(value.to_string()) + .ok() + .map(TrackKey::remote) +} + +pub(super) fn model(items: Vec) -> ModelRc { + ModelRc::from(Rc::new(VecModel::from(items))) +} + +#[cfg(test)] +mod tests { + use image::ImageEncoder as _; + + use super::*; + + #[test] + fn webp_is_converted_to_a_slint_image() { + let mut encoded = Vec::new(); + image::codecs::webp::WebPEncoder::new_lossless(&mut encoded) + .write_image(&[20, 40, 60, 255], 1, 1, image::ExtendedColorType::Rgba8) + .unwrap(); + + let decoded = decode_webp(&encoded).unwrap(); + + assert_eq!(decoded.size().width, 1); + assert_eq!(decoded.size().height, 1); + } + + #[test] + fn release_credits_include_artists_found_only_on_tracks() { + let pasha = ArtistRef { + key: ArtistKey::local(furumi_domain::ArtistId::new(1)), + name: "Паша Техник".into(), + }; + let kunteynir = ArtistRef { + key: ArtistKey::local(furumi_domain::ArtistId::new(2)), + name: "KUNTEYNIR".into(), + }; + let metox = ArtistRef { + key: ArtistKey::local(furumi_domain::ArtistId::new(3)), + name: "Metox".into(), + }; + let release = Release { + key: ReleaseKey::local(furumi_domain::ReleaseId::new(7)), + source: CatalogSource::Local, + title: "Порядочный".into(), + artists: vec![pasha.clone()], + featured_artists: Vec::new(), + release_type: "album".into(), + year: None, + artwork: furumi_domain::Artwork::default(), + tracks: vec![Track { + key: TrackKey::local(LocalTrackId::new(10)), + title: "12 шагов до рая".into(), + artist: "KUNTEYNIR feat. Metox".into(), + artists: vec![kunteynir], + featured_artists: vec![metox], + release: "Порядочный".into(), + release_id: ReleaseKey::local(furumi_domain::ReleaseId::new(7)), + duration_seconds: 219.0, + track_number: Some(1), + disc_number: Some(1), + cover_uri: None, + audio_format: Some("mp3".into()), + audio_bitrate_kbps: Some(320), + audio_sample_rate_hz: Some(44_100), + audio_bit_depth: None, + file_size_bytes: None, + liked: false, + audio_source: AudioSource::LocalFile(PathBuf::from("track.mp3")), + }], + }; + + let (main, contributors) = release_artist_credits(&release, Some(&pasha.key)); + + assert_eq!( + main.iter() + .map(|artist| artist.name.as_str()) + .collect::>(), + vec!["Паша Техник"] + ); + assert_eq!( + contributors + .iter() + .map(|artist| artist.name.as_str()) + .collect::>(), + vec!["KUNTEYNIR", "Metox"] + ); + } + + #[test] + fn artist_release_tile_reports_mixed_local_and_federated_tracks() { + let artist = ArtistRef { + key: ArtistKey::local(furumi_domain::ArtistId::new(1)), + name: "Artist".into(), + }; + let local = Release { + key: ReleaseKey::local(furumi_domain::ReleaseId::new(7)), + source: CatalogSource::Local, + title: "Album".into(), + artists: vec![artist.clone()], + featured_artists: Vec::new(), + release_type: "album".into(), + year: Some(2026), + artwork: furumi_domain::Artwork::default(), + tracks: vec![Track { + key: TrackKey::local(LocalTrackId::new(10)), + title: "Local".into(), + artist: "Artist".into(), + artists: vec![artist.clone()], + featured_artists: Vec::new(), + release: "Album".into(), + release_id: ReleaseKey::local(furumi_domain::ReleaseId::new(7)), + duration_seconds: 100.0, + track_number: Some(1), + disc_number: Some(1), + cover_uri: None, + audio_format: Some("flac".into()), + audio_bitrate_kbps: None, + audio_sample_rate_hz: None, + audio_bit_depth: None, + file_size_bytes: None, + liked: false, + audio_source: AudioSource::LocalFile(PathBuf::from("local.flac")), + }], + }; + let content_id = ContentId::parse(format!("b3:{}", "a".repeat(64))).unwrap(); + let mut remote_track = local.tracks[0].clone(); + remote_track.key = TrackKey::federation( + "peer".into(), + "remote-track".into(), + Some(content_id.clone()), + ); + remote_track.title = "Remote".into(); + remote_track.track_number = Some(2); + remote_track.audio_source = AudioSource::Federation { + peer_id: "peer".into(), + content_id, + }; + let remote = Release { + key: ReleaseKey::Federation { + peer_id: "peer".into(), + id: "album".into(), + }, + source: CatalogSource::Federation { + peer_id: "peer".into(), + }, + title: "Album".into(), + artists: vec![artist], + featured_artists: Vec::new(), + release_type: "album".into(), + year: Some(2026), + artwork: furumi_domain::Artwork::default(), + tracks: vec![remote_track], + }; + + let merged = merge_artist_releases([local, remote]); + + assert_eq!(merged.len(), 1); + assert!(matches!(merged[0].key, ReleaseKey::Local(_))); + assert_eq!(release_federation_state(&merged[0]), 2); + } + + #[test] + fn artist_releases_are_sorted_newest_first_with_unknown_years_last() { + let artist = ArtistRef { + key: ArtistKey::local(furumi_domain::ArtistId::new(1)), + name: "Artist".into(), + }; + let release = |title: &str, year| Release { + key: ReleaseKey::Federation { + peer_id: "peer".into(), + id: title.into(), + }, + source: CatalogSource::Federation { + peer_id: "peer".into(), + }, + title: title.into(), + artists: vec![artist.clone()], + featured_artists: Vec::new(), + release_type: "single".into(), + year, + artwork: furumi_domain::Artwork::default(), + tracks: Vec::new(), + }; + let mut releases = vec![ + release("Old", Some(1999)), + release("Unknown", None), + release("New", Some(2026)), + ]; + + sort_releases_newest_first(&mut releases); + + assert_eq!( + releases + .iter() + .map(|release| release.title.as_str()) + .collect::>(), + vec!["New", "Old", "Unknown"] + ); + } + + #[test] + fn release_rows_use_metadata_track_numbers_instead_of_visual_positions() { + let mut track = Track { + key: TrackKey::local(LocalTrackId::new(1)), + title: "Track".into(), + artist: "Artist".into(), + artists: Vec::new(), + featured_artists: Vec::new(), + release: "Release".into(), + release_id: ReleaseKey::local(furumi_domain::ReleaseId::new(1)), + duration_seconds: 1.0, + track_number: Some(9), + disc_number: Some(1), + cover_uri: None, + audio_format: None, + audio_bitrate_kbps: None, + audio_sample_rate_hz: None, + audio_bit_depth: None, + file_size_bytes: None, + liked: false, + audio_source: AudioSource::LocalFile(PathBuf::from("track.flac")), + }; + + assert_eq!(track_number_label(&track, 0, true), "9"); + assert_eq!(track_number_label(&track, 0, false), "1"); + track.track_number = None; + assert_eq!(track_number_label(&track, 8, true), "—"); + } + + #[test] + fn full_artist_label_keeps_main_and_featured_artists() { + let mut track = Track { + key: TrackKey::local(LocalTrackId::new(1)), + title: "Track".into(), + artist: String::new(), + artists: vec![ArtistRef { + key: ArtistKey::local(furumi_domain::ArtistId::new(1)), + name: "Main".into(), + }], + featured_artists: vec![ + ArtistRef { + key: ArtistKey::local(furumi_domain::ArtistId::new(2)), + name: "Guest A".into(), + }, + ArtistRef { + key: ArtistKey::local(furumi_domain::ArtistId::new(3)), + name: "Guest B".into(), + }, + ], + release: "Release".into(), + release_id: ReleaseKey::local(furumi_domain::ReleaseId::new(1)), + duration_seconds: 1.0, + track_number: None, + disc_number: None, + cover_uri: None, + audio_format: None, + audio_bitrate_kbps: None, + audio_sample_rate_hz: None, + audio_bit_depth: None, + file_size_bytes: None, + liked: false, + audio_source: AudioSource::LocalFile(PathBuf::from("track.flac")), + }; + + assert_eq!(track_artist_label(&track), "Main feat. Guest A, Guest B"); + track.artists.clear(); + assert_eq!(track_artist_label(&track), "Guest A, Guest B"); + } + + #[test] + fn contributors_wrap_into_compact_comma_separated_lines() { + use slint::Model as _; + + let artists = ["Alpha Alpha", "Beta Beta", "Gamma Gamma"] + .into_iter() + .zip(1_i64..) + .map(|(name, id)| ArtistRef { + key: ArtistKey::local(furumi_domain::ArtistId::new(id)), + name: name.into(), + }) + .collect::>(); + + let lines = contributor_lines(&artists, 80.0); + + assert_eq!(lines.len(), 2); + assert_eq!(lines[0].artists.row_count(), 2); + assert_eq!(lines[1].artists.row_count(), 1); + assert_eq!(lines[0].artists.row_data(0).unwrap().prefix, ""); + assert_eq!(lines[0].artists.row_data(0).unwrap().name, "Alpha Alpha,"); + assert_eq!(lines[0].artists.row_data(1).unwrap().prefix, " "); + assert_eq!(lines[1].artists.row_data(0).unwrap().prefix, ""); + } +} diff --git a/crates/ui/ui/app.slint b/crates/ui/ui/app.slint new file mode 100644 index 0000000..74c348b --- /dev/null +++ b/crates/ui/ui/app.slint @@ -0,0 +1,822 @@ +import { Button, CheckBox, ComboBox, LineEdit, ScrollView, Slider } from "std-widgets.slint"; +import { ArtistLinkLineView, ArtistLinkView, ArtistView, BreadcrumbView, DeviceView, PairingView, PlaylistView, QueueView, ReleaseView, TrackView, VersionView } from "models.slint"; +import { AlbumGrid, ArtistGrid, ArtistLinksRow, BuildInfoCard, DeviceRow, FederationBadge, IconButton, InfoField, LinkText, MarqueeText, NavButton, PlaylistNavButton, SearchArtistGrid, SearchField, SearchReleaseGrid, StaticArtistList, TrackInfoArtists, TrackList } from "components.slint"; + +export component AppWindow inherits Window { + title: "Furumi Desktop"; + preferred-width: 1180px; + preferred-height: 760px; + min-width: 920px; + min-height: 620px; + background: #090b10; + default-font-family: "sans-serif"; + + in property app-name: "Furumi"; + in property home-label: "Home"; + in property search-label: "Search"; + in property library-label: "Your library"; + in property queue-label: "Queue"; + in property recent-label: "Recently played"; + in property featured-label: "Made for listening"; + in property search-placeholder: "Artists, albums or tracks"; + in-out property search-query; + in property active-screen: "home"; + in property queue-open; + in property settings-open; + in property network-id; + in property device-name; + in property library-path; + in property federation-enabled; + in property save-federated-on-listen; + in property selected-language: "English"; + in property <[string]> available-languages; + in property <[ReleaseView]> releases; + in property <[ArtistView]> artists; + in property <[ReleaseView]> artist-albums; + in property <[ReleaseView]> artist-other-releases; + in property <[TrackView]> artist-featured-tracks; + in property <[TrackView]> detail-tracks; + in property detail-title; + in property detail-subtitle; + in property <[ArtistLinkView]> detail-main-artists; + in property <[ArtistLinkLineView]> detail-contributor-lines; + in property detail-release-type; + in property detail-artwork; + in property detail-has-artwork; + in property <[TrackView]> tracks; + in property <[QueueView]> queue-items; + in property <[PlaylistView]> playlists; + in property <[TrackView]> playlist-tracks; + in property playlist-title; + in property playlist-picker-open; + in property <[DeviceView]> connected-devices; + in property <[PairingView]> pending-pairings; + in property device-role-label; + in property device-is-active; + in property active-device-label; + in property device-group-label; + in property device-invite; + in property device-status; + in property device-busy; + in property <[TrackView]> search-results; + in property <[ArtistView]> search-artists; + in property <[ReleaseView]> search-releases; + in property federation-status-busy; + in property federation-status-text: "Federation ready"; + in property federation-debug-node: "Stopped"; + in property federation-debug-peers: "0 connected · 0 known"; + in property federation-debug-dht: "n/a"; + in property federation-debug-published: "0 items"; + in property federation-debug-endpoint; + in property <[VersionView]> software-versions; + in property <[VersionView]> protocol-versions; + in property <[BreadcrumbView]> breadcrumbs; + in property can-go-back; + in property can-go-forward; + in property track-info-open; + in property track-info-title; + in property <[ArtistLinkView]> track-info-artist-links; + in property track-info-release; + in property track-info-duration; + in property track-info-format; + in property track-info-quality; + in property track-info-source; + in property track-info-content-id; + in property track-info-path; + in property track-info-artwork; + in property track-info-has-artwork; + in property detail-federation-state; + in property has-track; + in property playing; + in property current-title; + in property <[ArtistLinkView]> current-artists; + in property current-metadata; + in property current-release; + in property current-release-key; + in property current-artwork; + in property current-has-artwork; + in property elapsed; + in property duration; + in property progress; + in property volume; + in property error-message; + + callback navigate(string); + callback go-back; + callback go-forward; + callback navigate-breadcrumb(string); + callback open-artist(string); + callback open-release(string); + callback layout-detail-contributors(float); + callback toggle-queue; + callback toggle-settings; + callback toggle-playback; + callback next; + callback previous; + callback seek(float); + callback set-volume(float); + callback play-release(string); + callback play-track(string); + callback play-track-context(string, string); + callback play-queue-item(string); + callback track-action(string, string); + callback open-playlist(string); + callback create-playlist(string); + callback add-track-to-playlist(string); + callback close-playlist-picker; + callback create-device-invite; + callback connect-device(string); + callback answer-pairing(string, bool, bool); + callback select-device(string); + callback search-changed(string); + callback network-id-changed(string); + callback device-name-changed(string); + callback library-path-changed(string); + callback choose-library-path; + callback federation-changed(bool); + callback save-federated-on-listen-changed(bool); + callback language-changed(string); + callback dismiss-error; + callback close-track-info; + + Rectangle { + background: #090b10; + HorizontalLayout { + x: 0px; y: 0px; width: parent.width; height: parent.height - 90px; + spacing: 0px; + sidebar := Rectangle { + width: 218px; + background: #101218; + border-color: #232630; + border-width: 0px; + VerticalLayout { + padding: 16px; + spacing: 7px; + HorizontalLayout { + height: 52px; + spacing: 11px; + Rectangle { + width: 36px; + background: transparent; + Image { source: @image-url("../assets/logo.svg"); width: 36px; height: 36px; y: (parent.height - self.height) / 2; } + } + Text { text: root.app-name; color: #f5f6fa; font-size: 18px; font-weight: 800; vertical-alignment: center; } + } + NavButton { label: root.home-label; icon-source: @image-url("../assets/home.svg"); active: root.active-screen == "home"; clicked => root.navigate("home"); } + NavButton { label: root.search-label; icon-source: @image-url("../assets/search.svg"); active: root.active-screen == "search"; clicked => root.navigate("search"); } + NavButton { label: root.library-label; icon-source: @image-url("../assets/library.svg"); active: root.active-screen == "library"; clicked => root.navigate("library"); } + Rectangle { height: 12px; background: transparent; } + HorizontalLayout { + height: 28px; + Text { horizontal-stretch: 1; text: "PLAYLISTS"; color: #656b7b; font-size: 10px; font-weight: 700; letter-spacing: 1.2px; vertical-alignment: center; } + Rectangle { + width: 28px; height: 28px; border-radius: 6px; + background: add-playlist-touch.has-hover ? #282c38 : transparent; + Image { source: @image-url("../assets/plus.svg"); width: 16px; height: 16px; x: 6px; y: 6px; } + add-playlist-touch := TouchArea { clicked => new-playlist-popup.show(); } + } + } + ScrollView { + vertical-stretch: 1; + min-height: 0px; + horizontal-scrollbar-policy: ScrollBarPolicy.always-off; + vertical-scrollbar-policy: ScrollBarPolicy.always-off; + VerticalLayout { + spacing: 2px; + for playlist in root.playlists: PlaylistNavButton { + item: playlist; + active: root.active-screen == "playlist" && root.playlist-title == playlist.title; + clicked(id) => root.open-playlist(id); + } + } + } + NavButton { label: "Settings"; icon-source: @image-url("../assets/settings.svg"); active: root.settings-open; clicked => root.toggle-settings(); } + Rectangle { + height: 58px; border-radius: 9px; background: #171a22; + HorizontalLayout { + padding: 10px; spacing: 10px; + Rectangle { width: 34px; height: 34px; border-radius: 17px; background: #353a49; Text { text: "F"; color: #fff; font-weight: 700; horizontal-alignment: center; vertical-alignment: center; } } + VerticalLayout { alignment: center; Text { text: "Local library"; color: #e9eaf0; font-size: 12px; font-weight: 600; } Text { text: "Desktop player"; color: #777d8d; font-size: 10px; } } + } + } + + } + new-playlist-popup := PopupWindow { + x: 16px; y: 250px; width: 250px; height: 132px; + close-policy: close-on-click-outside; + Rectangle { + border-radius: 10px; background: #20242e; border-width: 1px; border-color: #3a3f4d; + VerticalLayout { + padding: 14px; spacing: 10px; + Text { text: "New playlist"; color: #f5f6fa; font-size: 14px; font-weight: 700; } + new-playlist-name := LineEdit { placeholder-text: "Playlist name"; } + Button { text: "Create"; clicked => { root.create-playlist(new-playlist-name.text); new-playlist-name.text = ""; new-playlist-popup.close(); } } + } + } + } + } + + content := Rectangle { + horizontal-stretch: 1; + background: #0c0e14; + VerticalLayout { + padding-left: 26px; + padding-right: 26px; + padding-top: 18px; + padding-bottom: 16px; + spacing: 17px; + HorizontalLayout { + height: 42px; + IconButton { enabled: root.can-go-back; icon-source: @image-url("../assets/back.svg"); clicked => root.go-back(); } + IconButton { enabled: root.can-go-forward; icon-source: @image-url("../assets/forward.svg"); clicked => root.go-forward(); } + Rectangle { horizontal-stretch: 1; background: transparent; } + SearchField { + width: 280px; + text <=> root.search-query; + placeholder-text: root.search-placeholder; + edited(value) => root.search-changed(value); + } + } + + HorizontalLayout { + height: 26px; + Rectangle { + horizontal-stretch: 1; background: transparent; clip: true; + HorizontalLayout { + spacing: 0px; + alignment: start; + for crumb in root.breadcrumbs: Text { + horizontal-stretch: 0; + text: crumb.label; + color: crumb.target == "" ? #aeb4c2 : crumb-touch.has-hover ? #f1f2f6 : #777e8f; + font-size: 13px; font-weight: crumb.target == "" ? 650 : 500; vertical-alignment: center; + crumb-touch := TouchArea { enabled: crumb.target != ""; clicked => root.navigate-breadcrumb(crumb.target); } + } + } + } + Rectangle { + height: 24px; + width: 280px; + border-radius: 12px; + background: root.federation-status-busy ? #20352f : #191d26; + border-color: root.federation-status-busy ? #3d7865 : #303541; + border-width: 1px; + HorizontalLayout { + padding-left: 10px; padding-right: 10px; spacing: 6px; + Text { text: root.federation-status-busy ? "◌" : "●"; color: root.federation-status-busy ? #55dbaa : #697080; font-size: 10px; vertical-alignment: center; } + status-label := Text { text: root.federation-status-text; color: #9da3b2; font-size: 10px; overflow: elide; vertical-alignment: center; } + } + } + } + + if root.active-screen == "home": ScrollView { + viewport-width: self.width; + horizontal-scrollbar-policy: ScrollBarPolicy.always-off; + vertical-scrollbar-policy: ScrollBarPolicy.always-off; + VerticalLayout { + width: parent.width; + spacing: 17px; + Text { text: "Good evening"; color: #f5f6fa; font-size: 28px; font-weight: 800; } + Text { text: "Artists"; color: #f5f6fa; font-size: 18px; font-weight: 750; } + ArtistGrid { artists: root.artists; open(key) => root.open-artist(key); } + Rectangle { height: 8px; background: transparent; } + Text { text: root.recent-label; color: #f5f6fa; font-size: 18px; font-weight: 750; } + TrackList { tracks: root.tracks; play(key) => root.play-track-context("recent", key); action(action, key) => root.track-action(action, key); open-artist(key) => root.open-artist(key); open-release(key) => root.open-release(key); } + } + } + + if root.active-screen == "library": ScrollView { + viewport-width: self.width; + horizontal-scrollbar-policy: ScrollBarPolicy.always-off; + vertical-scrollbar-policy: ScrollBarPolicy.always-off; + VerticalLayout { + width: parent.width; spacing: 17px; + Text { text: root.library-label; color: #f5f6fa; font-size: 28px; font-weight: 800; } + AlbumGrid { releases: root.releases; play(key) => root.play-release(key); open(key) => root.open-release(key); open-artist(key) => root.open-artist(key); } + } + } + + if root.active-screen == "playlist": ScrollView { + viewport-width: self.width; + horizontal-scrollbar-policy: ScrollBarPolicy.always-off; + vertical-scrollbar-policy: ScrollBarPolicy.always-off; + VerticalLayout { + width: parent.width; spacing: 18px; alignment: start; + Text { height: 46px; vertical-stretch: 0; text: root.playlist-title; color: #f5f6fa; font-size: 30px; font-weight: 800; vertical-alignment: center; } + Text { height: 18px; vertical-stretch: 0; text: root.playlist-tracks.length + (root.playlist-tracks.length == 1 ? " track" : " tracks"); color: #858b9c; font-size: 12px; vertical-alignment: center; } + TrackList { tracks: root.playlist-tracks; show-artwork: true; play(key) => root.play-track-context("playlist", key); action(action, key) => root.track-action(action, key); open-artist(key) => root.open-artist(key); open-release(key) => root.open-release(key); } + } + } + + if root.active-screen == "artist": ScrollView { + viewport-width: self.width; + horizontal-scrollbar-policy: ScrollBarPolicy.always-off; + vertical-scrollbar-policy: ScrollBarPolicy.always-off; + VerticalLayout { + width: parent.width; spacing: 20px; alignment: start; + HorizontalLayout { + height: 188px; spacing: 22px; + Rectangle { + width: 188px; height: 188px; border-radius: 30px; background: #292d38; clip: true; + Image { source: @image-url("../assets/note.svg"); width: 54px; height: 54px; x: (parent.width - self.width) / 2; y: (parent.height - self.height) / 2; } + if root.detail-has-artwork: Image { source: root.detail-artwork; image-fit: ImageFit.cover; width: parent.width; height: parent.height; } + } + VerticalLayout { alignment: end; spacing: 8px; Text { text: "ARTIST"; color: #9298a8; font-size: 11px; font-weight: 700; } Text { text: root.detail-title; color: #f5f6fa; font-size: 34px; font-weight: 800; overflow: elide; } Text { text: root.detail-subtitle; color: #858b9c; font-size: 13px; } } + } + if root.artist-albums.length > 0: Text { height: 28px; vertical-stretch: 0; text: "Albums"; color: #f5f6fa; font-size: 20px; font-weight: 750; vertical-alignment: center; } + if root.artist-albums.length > 0: AlbumGrid { releases: root.artist-albums; show-artists: false; play(key) => root.play-release(key); open(key) => root.open-release(key); open-artist(key) => root.open-artist(key); } + if root.artist-other-releases.length > 0: Text { height: 28px; vertical-stretch: 0; text: "Singles, EPs and other releases"; color: #f5f6fa; font-size: 20px; font-weight: 750; vertical-alignment: center; } + if root.artist-other-releases.length > 0: AlbumGrid { releases: root.artist-other-releases; show-artists: false; show-release-types: true; play(key) => root.play-release(key); open(key) => root.open-release(key); open-artist(key) => root.open-artist(key); } + if root.artist-featured-tracks.length > 0: Text { height: 28px; vertical-stretch: 0; text: "Featured on"; color: #f5f6fa; font-size: 20px; font-weight: 750; vertical-alignment: center; } + if root.artist-featured-tracks.length > 0: TrackList { tracks: root.artist-featured-tracks; show-artwork: true; play(key) => root.play-track-context("artist-featured", key); action(action, key) => root.track-action(action, key); open-artist(key) => root.open-artist(key); open-release(key) => root.open-release(key); } + } + } + + if root.active-screen == "release": ScrollView { + viewport-width: self.width; + horizontal-scrollbar-policy: ScrollBarPolicy.always-off; + vertical-scrollbar-policy: ScrollBarPolicy.always-off; + VerticalLayout { + width: parent.width; spacing: 20px; + HorizontalLayout { + height: 188px; spacing: 22px; + Rectangle { + width: 188px; height: 188px; border-radius: 9px; background: #292d38; clip: true; + Image { source: @image-url("../assets/note.svg"); width: 54px; height: 54px; x: (parent.width - self.width) / 2; y: (parent.height - self.height) / 2; } + if root.detail-has-artwork: Image { source: root.detail-artwork; image-fit: ImageFit.cover; width: parent.width; height: parent.height; } + } + Rectangle { + horizontal-stretch: 1; + height: 188px; + background: transparent; + VerticalLayout { + x: 0px; y: 68px; width: parent.width; height: 111px; + spacing: 2px; alignment: start; + HorizontalLayout { + height: 18px; spacing: 8px; + Text { text: root.detail-release-type; color: #9298a8; font-size: 11px; font-weight: 700; vertical-alignment: center; } + if root.detail-federation-state > 0: FederationBadge { partial: root.detail-federation-state == 2; } + Rectangle { horizontal-stretch: 1; background: transparent; } + } + Text { width: parent.width; height: 43px; text: root.detail-title; color: #f5f6fa; font-size: 34px; font-weight: 800; overflow: elide; vertical-alignment: center; } + ArtistLinksRow { width: parent.width; height: 20px; artists: root.detail-main-artists; text-size: 13px; open-artist(key) => root.open-artist(key); } + } + } + } + if root.detail-contributor-lines.length > 0: StaticArtistList { + width: parent.width / 2; + lines: root.detail-contributor-lines; + heading: "Featuring"; + open-artist(key) => root.open-artist(key); + changed width => { + root.layout-detail-contributors(self.width / 1px); + } + } + TrackList { tracks: root.detail-tracks; play(key) => root.play-track-context("release", key); action(action, key) => root.track-action(action, key); open-artist(key) => root.open-artist(key); open-release(key) => root.open-release(key); } + } + } + + if root.active-screen == "search": ScrollView { + viewport-width: self.width; + horizontal-scrollbar-policy: ScrollBarPolicy.always-off; + vertical-scrollbar-policy: ScrollBarPolicy.always-off; + VerticalLayout { + width: parent.width; spacing: 14px; alignment: start; + HorizontalLayout { + height: 34px; + Text { text: root.search-query == "" ? "Search your music" : "Results for “" + root.search-query + "”"; color: #f5f6fa; font-size: 25px; font-weight: 800; vertical-alignment: center; } + Rectangle { horizontal-stretch: 1; background: transparent; } + } + if root.search-artists.length > 0: Text { height: 24px; text: "Artists"; color: #f5f6fa; font-size: 18px; font-weight: 750; vertical-alignment: center; } + if root.search-artists.length > 0: SearchArtistGrid { artists: root.search-artists; open(key) => root.open-artist(key); } + if root.search-releases.length > 0: Text { height: 24px; text: "Releases"; color: #f5f6fa; font-size: 18px; font-weight: 750; vertical-alignment: center; } + if root.search-releases.length > 0: SearchReleaseGrid { releases: root.search-releases; open(key) => root.open-release(key); } + if root.search-results.length > 0: Text { height: 24px; text: "Tracks"; color: #f5f6fa; font-size: 18px; font-weight: 750; vertical-alignment: center; } + TrackList { tracks: root.search-results; show-artwork: true; play(key) => root.play-track-context("search", key); action(action, key) => root.track-action(action, key); open-artist(key) => root.open-artist(key); open-release(key) => root.open-release(key); } + } + } + } + } + + if root.queue-open: queue-panel := Rectangle { + width: 292px; + background: #11141b; + border-color: #252936; + border-width: 1px; + VerticalLayout { + padding: 17px; + spacing: 12px; + HorizontalLayout { + height: 34px; + Text { text: root.queue-label; color: #f5f6fa; font-size: 18px; font-weight: 750; vertical-alignment: center; } + Rectangle { horizontal-stretch: 1; background: transparent; } + IconButton { icon-source: @image-url("../assets/close.svg"); clicked => root.toggle-queue(); } + } + Text { text: "Now playing and up next"; color: #777d8d; font-size: 11px; } + VerticalLayout { + spacing: 4px; + for item in root.queue-items: Rectangle { + height: 52px; border-radius: 7px; background: item.active ? #242a35 : transparent; + TouchArea { double-clicked => root.play-queue-item(item.key); } + HorizontalLayout { + padding: 7px; spacing: 10px; + Rectangle { width: 38px; height: 38px; border-radius: 5px; background: #292d38; Image { source: @image-url("../assets/note.svg"); width: 21px; height: 21px; x: 8px; y: 8px; } if item.has-artwork: Image { source: item.artwork; image-fit: ImageFit.cover; width: parent.width; height: parent.height; } } + Rectangle { + width: 190px; height: parent.height; background: transparent; clip: true; + VerticalLayout { + x: 0px; y: 0px; width: parent.width; height: parent.height; alignment: center; + MarqueeText { width: parent.width; height: 18px; label: item.title; text-color: item.active ? #55dbaa : #e7e9ef; text-size: 12px; text-weight: 600; } + LinkText { width: parent.width; label: item.artist; base-color: #777d8d; text-size: 10px; height: 15px; clicked => root.open-artist(item.artist-key); } + } + } + } + } + } + Rectangle { vertical-stretch: 1; background: transparent; } + } + } + } + + player := Rectangle { + x: 0px; y: parent.height - 90px; width: parent.width; height: 90px; + background: #11131a; + border-color: #282b35; + border-width: 1px; + HorizontalLayout { + padding-left: 18px; padding-right: 18px; padding-top: 12px; padding-bottom: 10px; + spacing: 18px; + HorizontalLayout { + width: 260px; spacing: 11px; + Rectangle { + width: 54px; height: 54px; border-radius: 6px; background: #292d38; clip: true; + Image { source: @image-url("../assets/note.svg"); width: 28px; height: 28px; x: 13px; y: 13px; } + if root.current-has-artwork: Image { source: root.current-artwork; image-fit: ImageFit.cover; width: parent.width; height: parent.height; } + if root.current-has-artwork: TouchArea { clicked => artwork-popup.show(); } + } + VerticalLayout { + width: 195px; + alignment: center; + Text { width: 195px; text: root.current-title; color: #f3f4f8; font-size: 13px; font-weight: 650; overflow: elide; } + ArtistLinksRow { width: 195px; height: 16px; artists: root.current-artists; row-color: #818797; open-artist(key) => root.open-artist(key); } + Text { width: 195px; text: root.current-metadata; color: #656b7b; font-size: 10px; overflow: elide; vertical-alignment: center; } + } + } + VerticalLayout { + horizontal-stretch: 1; spacing: 7px; + HorizontalLayout { + alignment: center; spacing: 8px; + IconButton { icon-source: @image-url("../assets/previous.svg"); clicked => root.previous(); } + Rectangle { + width: 42px; height: 42px; border-radius: 21px; background: #f4f5f8; + if root.playing: Image { source: @image-url("../assets/pause.svg"); width: 19px; height: 19px; x: 11px; y: 11px; } + if !root.playing: Image { source: @image-url("../assets/play.svg"); width: 19px; height: 19px; x: 12px; y: 11px; } + TouchArea { clicked => root.toggle-playback(); } + } + IconButton { icon-source: @image-url("../assets/next.svg"); clicked => root.next(); } + } + HorizontalLayout { + spacing: 9px; + Text { text: root.elapsed; color: #818797; font-size: 10px; width: 34px; horizontal-alignment: right; vertical-alignment: center; } + Slider { horizontal-stretch: 1; height: 18px; minimum: 0; maximum: 1; value: root.progress; changed(value) => root.seek(value); } + Text { text: root.duration; color: #818797; font-size: 10px; width: 34px; vertical-alignment: center; } + } + } + Rectangle { + width: 238px; background: transparent; + IconButton { x: 0px; y: (parent.height - self.height) / 2; icon-source: @image-url("../assets/queue.svg"); clicked => root.toggle-queue(); } + Image { x: 48px; y: (parent.height - self.height) / 2; source: @image-url("../assets/volume.svg"); width: 18px; height: 18px; } + Slider { x: 75px; y: (parent.height - self.height) / 2; width: 100px; height: 18px; minimum: 0; maximum: 1; value: root.volume; changed(value) => root.set-volume(value); } + IconButton { + x: 190px; y: (parent.height - self.height) / 2; + icon-source: @image-url("../assets/devices.svg"); + icon-color: root.device-is-active ? #e58bd7 : transparent; + clicked => devices-popup.show(); + } + devices-popup := PopupWindow { + x: -130px; y: -474px; width: 368px; height: 464px; + close-policy: close-on-click-outside; + Rectangle { + border-radius: 12px; background: #1b1f28; border-width: 1px; border-color: #3a3f4d; + VerticalLayout { + padding: 16px; spacing: 10px; + HorizontalLayout { + height: 36px; + VerticalLayout { + Text { text: "Connected devices"; color: #f5f6fa; font-size: 17px; font-weight: 750; } + Text { text: root.device-role-label + " · " + root.active-device-label; color: #7f8797; font-size: 10px; } + } + Rectangle { horizontal-stretch: 1; background: transparent; } + IconButton { icon-source: @image-url("../assets/close.svg"); clicked => devices-popup.close(); } + } + Text { text: root.device-group-label; color: #697080; font-size: 10px; overflow: elide; } + Rectangle { height: 1px; background: #303541; } + Text { height: 16px; text: "ACTIVE DEVICE"; color: #697080; font-size: 9px; font-weight: 750; letter-spacing: 0.8px; vertical-alignment: center; } + for device in root.connected-devices: DeviceRow { + visible: device.active; + height: device.active ? 48px : 0px; + item: device; + selectable: false; + } + Text { height: 16px; text: "AVAILABLE DEVICES"; color: #697080; font-size: 9px; font-weight: 750; letter-spacing: 0.8px; vertical-alignment: center; } + ScrollView { + vertical-stretch: 1; + horizontal-scrollbar-policy: ScrollBarPolicy.always-off; + vertical-scrollbar-policy: ScrollBarPolicy.always-off; + VerticalLayout { + spacing: 4px; + for device in root.connected-devices: DeviceRow { + visible: !device.active; + height: !device.active ? 48px : 0px; + item: device; + selectable: !device.revoked && (device.online || device.is-self); + selected(id) => root.select-device(id); + } + for pairing in root.pending-pairings: Rectangle { + height: 76px; border-radius: 7px; background: #252a35; + VerticalLayout { + padding: 8px; spacing: 5px; + Text { text: pairing.name + " wants to connect"; color: #f0f1f5; font-size: 11px; font-weight: 650; overflow: elide; } + Text { text: pairing.details; color: #7f8797; font-size: 9px; overflow: elide; } + HorizontalLayout { + spacing: 7px; + Button { text: "Accept"; clicked => root.answer-pairing(pairing.request-id, true, pairing.group-conflict); } + Button { text: "Deny"; clicked => root.answer-pairing(pairing.request-id, false, false); } + } + } + } + } + } + Rectangle { height: 1px; background: #303541; } + if root.device-invite != "": Text { text: root.device-invite; color: #9ca4b4; font-size: 9px; wrap: word-wrap; max-height: 36px; overflow: elide; } + HorizontalLayout { + spacing: 8px; + device-link := LineEdit { horizontal-stretch: 1; placeholder-text: "Paste frid://i/… invite"; } + Button { text: "Connect"; enabled: !root.device-busy && device-link.text != ""; clicked => root.connect-device(device-link.text); } + } + HorizontalLayout { + Button { text: root.device-invite == "" ? "Create invite" : "New invite"; enabled: !root.device-busy; clicked => root.create-device-invite(); } + Rectangle { horizontal-stretch: 1; background: transparent; } + Text { text: root.device-status; color: root.device-status == "" ? transparent : #8c93a3; font-size: 9px; vertical-alignment: center; overflow: elide; } + } + } + } + } + } + } + } + } + + if root.playlist-picker-open: Rectangle { + background: #05070ab8; + TouchArea { clicked => root.close-playlist-picker(); } + Rectangle { + width: 360px; height: min(430px, parent.height - 80px); + x: (parent.width - self.width) / 2; y: (parent.height - self.height) / 2; + border-radius: 12px; background: #1b1f28; border-width: 1px; border-color: #3a3f4d; + TouchArea { } + VerticalLayout { + padding: 18px; spacing: 12px; + HorizontalLayout { + height: 34px; + Text { horizontal-stretch: 1; text: "Add to playlist"; color: #f5f6fa; font-size: 18px; font-weight: 750; vertical-alignment: center; } + IconButton { icon-source: @image-url("../assets/close.svg"); clicked => root.close-playlist-picker(); } + } + Rectangle { height: 1px; background: #303541; } + ScrollView { + horizontal-scrollbar-policy: ScrollBarPolicy.always-off; + vertical-scrollbar-policy: ScrollBarPolicy.always-off; + VerticalLayout { + spacing: 4px; + for playlist in root.playlists: Rectangle { + visible: !playlist.is-likes; + height: !playlist.is-likes ? 48px : 0px; border-radius: 7px; background: picker-touch.has-hover ? #292e3a : transparent; + VerticalLayout { + padding-left: 10px; padding-right: 10px; alignment: center; + Text { text: playlist.title; color: #e8eaf0; font-size: 12px; font-weight: 650; overflow: elide; } + Text { text: playlist.details; color: #757c8c; font-size: 10px; } + } + picker-touch := TouchArea { clicked => root.add-track-to-playlist(playlist.id); } + } + } + } + } + } + } + + if root.settings-open: Rectangle { + background: #05070ad9; + TouchArea { clicked => root.toggle-settings(); } + settings-card := Rectangle { + width: min(620px, parent.width - 48px); + height: min(650px, parent.height - 48px); + x: (parent.width - self.width) / 2; + y: (parent.height - self.height) / 2; + border-radius: 14px; + background: #171a22; + border-color: #303541; + border-width: 1px; + // Consume clicks anywhere inside the card so they do not reach the + // modal backdrop. Interactive controls below remain on top. + TouchArea { } + VerticalLayout { + padding: 24px; + spacing: 14px; + HorizontalLayout { + height: 38px; + VerticalLayout { + Text { text: "Settings"; color: #f5f6fa; font-size: 23px; font-weight: 800; } + Text { text: "Desktop player preferences"; color: #7f8595; font-size: 11px; } + } + Rectangle { horizontal-stretch: 1; background: transparent; } + IconButton { icon-source: @image-url("../assets/close.svg"); clicked => root.toggle-settings(); } + } + Rectangle { height: 1px; background: #2a2e38; } + ScrollView { + vertical-stretch: 1; + horizontal-scrollbar-policy: ScrollBarPolicy.always-off; + vertical-scrollbar-policy: ScrollBarPolicy.always-off; + VerticalLayout { + width: parent.width; spacing: 14px; alignment: start; + VerticalLayout { + spacing: 7px; + Text { text: "Network ID"; color: #e8eaf0; font-size: 13px; font-weight: 650; } + Text { text: "Peers with the same value form one logical federation network."; color: #818797; font-size: 11px; } + LineEdit { + text: root.network-id; + placeholder-text: "furumi"; + edited => root.network-id-changed(self.text); + } + } + VerticalLayout { + spacing: 7px; + Text { text: "Device name"; color: #e8eaf0; font-size: 13px; font-weight: 650; } + Text { text: "Shown to your other players when connecting devices or transferring playback."; color: #818797; font-size: 11px; } + LineEdit { + text: root.device-name; + placeholder-text: "Furumi on this computer"; + edited => root.device-name-changed(self.text); + } + } + VerticalLayout { + spacing: 7px; + Text { text: "Library path"; color: #e8eaf0; font-size: 13px; font-weight: 650; } + Text { text: "Music and downloaded tracks will be stored in this directory."; color: #818797; font-size: 11px; } + HorizontalLayout { + spacing: 8px; + LineEdit { + text: root.library-path; + placeholder-text: "~/Music/Furumi"; + horizontal-stretch: 1; + edited => root.library-path-changed(self.text); + } + Button { text: "Choose…"; clicked => root.choose-library-path(); } + } + } + HorizontalLayout { + height: 52px; + VerticalLayout { + Text { text: "Federation"; color: #e8eaf0; font-size: 13px; font-weight: 650; } + Text { text: "Discover and play music shared by peers."; color: #818797; font-size: 11px; } + } + Rectangle { horizontal-stretch: 1; background: transparent; } + CheckBox { + text: "Enabled"; + checked: root.federation-enabled; + toggled => root.federation-changed(self.checked); + } + } + Rectangle { + min-height: 104px; max-height: 104px; vertical-stretch: 0; + border-radius: 9px; background: #11151d; border-width: 1px; border-color: #2c3240; + VerticalLayout { + padding: 11px; spacing: 6px; + HorizontalLayout { + height: 18px; + Text { horizontal-stretch: 1; text: "Federation diagnostics"; color: #dfe2ea; font-size: 11px; font-weight: 700; vertical-alignment: center; } + Text { text: root.federation-debug-node; color: root.federation-enabled ? #55dbaa : #777d8d; font-size: 10px; vertical-alignment: center; } + } + HorizontalLayout { + height: 18px; spacing: 14px; + Text { width: 250px; text: "Peers " + root.federation-debug-peers; color: #8f96a6; font-size: 10px; overflow: elide; vertical-alignment: center; } + Text { horizontal-stretch: 1; text: "DHT " + root.federation-debug-dht; color: #8f96a6; font-size: 10px; overflow: elide; vertical-alignment: center; } + } + HorizontalLayout { + height: 18px; spacing: 14px; + Text { width: 250px; text: "Published " + root.federation-debug-published; color: #8f96a6; font-size: 10px; overflow: elide; vertical-alignment: center; } + Text { horizontal-stretch: 1; text: "Endpoint " + root.federation-debug-endpoint; color: #697080; font-size: 9px; overflow: elide; vertical-alignment: center; } + } + } + } + HorizontalLayout { + height: 52px; + VerticalLayout { + Text { text: "Keep federated tracks"; color: #e8eaf0; font-size: 13px; font-weight: 650; } + Text { text: "Import music played from peers and make it available to the federation."; color: #818797; font-size: 11px; } + } + Rectangle { horizontal-stretch: 1; background: transparent; } + CheckBox { + text: "Enabled"; + checked: root.save-federated-on-listen; + toggled => root.save-federated-on-listen-changed(self.checked); + } + } + HorizontalLayout { + height: 52px; + VerticalLayout { + Text { text: "Language"; color: #e8eaf0; font-size: 13px; font-weight: 650; } + Text { text: "Interface language."; color: #818797; font-size: 11px; } + } + Rectangle { horizontal-stretch: 1; background: transparent; } + ComboBox { + width: 180px; + model: root.available-languages; + current-value: root.selected-language; + selected(value) => root.language-changed(value); + } + } + BuildInfoCard { + width: parent.width; + software: root.software-versions; + protocols: root.protocol-versions; + } + } + } + } + } + } + + if root.track-info-open: Rectangle { + background: #05070ad9; + TouchArea { clicked => root.close-track-info(); } + info-card := Rectangle { + width: min(680px, parent.width - 48px); + height: 430px; + x: (parent.width - self.width) / 2; + y: (parent.height - self.height) / 2; + border-radius: 12px; + background: #171a22; + border-color: #3a3f4d; + border-width: 2px; + TouchArea { } + VerticalLayout { + padding: 22px; spacing: 16px; + HorizontalLayout { + height: 38px; + Text { text: "Track information"; color: #f5f6fa; font-size: 21px; font-weight: 800; vertical-alignment: center; } + Rectangle { horizontal-stretch: 1; background: transparent; } + IconButton { icon-source: @image-url("../assets/close.svg"); clicked => root.close-track-info(); } + } + Rectangle { height: 1px; background: #2a2e38; } + HorizontalLayout { + spacing: 22px; + Rectangle { + width: 210px; height: 210px; border-radius: 8px; background: #292d38; clip: true; + Image { source: @image-url("../assets/note.svg"); width: 56px; height: 56px; x: (parent.width - self.width) / 2; y: (parent.height - self.height) / 2; } + if root.track-info-has-artwork: Image { source: root.track-info-artwork; image-fit: ImageFit.contain; width: parent.width; height: parent.height; } + } + VerticalLayout { + spacing: 2px; + Text { height: 34px; text: root.track-info-title; color: #f5f6fa; font-size: 20px; font-weight: 750; overflow: elide; vertical-alignment: center; } + TrackInfoArtists { artists: root.track-info-artist-links; open-artist(key) => root.open-artist(key); } + InfoField { label: "Release"; value: root.track-info-release; } + InfoField { label: "Duration"; value: root.track-info-duration; } + InfoField { label: "Format"; value: root.track-info-format; } + InfoField { label: "Audio quality"; value: root.track-info-quality; } + InfoField { label: "Source"; value: root.track-info-source; } + } + } + InfoField { label: "Content ID"; value: root.track-info-content-id; } + InfoField { label: "File / peer"; value: root.track-info-path; } + } + } + } + + if root.error-message != "": Rectangle { + x: parent.width - self.width - 18px; + y: 18px; + width: 320px; + height: 62px; + border-radius: 9px; + background: #3b2028; + Text { text: root.error-message; color: #ffd9e0; font-size: 12px; x: 14px; width: parent.width - 48px; vertical-alignment: center; wrap: word-wrap; } + Text { text: "×"; color: #ffd9e0; x: parent.width - 30px; width: 20px; horizontal-alignment: center; vertical-alignment: center; } + TouchArea { clicked => root.dismiss-error(); } + } + + artwork-popup := PopupWindow { + x: (root.width - self.width) / 2; + y: (root.height - self.height) / 2; + width: 560px; + height: 560px; + close-policy: close-on-click-outside; + Rectangle { + width: parent.width; height: parent.height; + border-radius: 8px; + background: #171a22; + border-color: #3a3f4d; + border-width: 2px; + clip: true; + Image { x: 8px; y: 8px; source: root.current-artwork; width: parent.width - 16px; height: parent.height - 16px; image-fit: ImageFit.contain; } + } + } +} diff --git a/crates/ui/ui/components.slint b/crates/ui/ui/components.slint new file mode 100644 index 0000000..b796d2e --- /dev/null +++ b/crates/ui/ui/components.slint @@ -0,0 +1,702 @@ +import { ScrollView } from "std-widgets.slint"; +import { ArtistLinkLineView, ArtistLinkView, ArtistView, DeviceView, PlaylistView, ReleaseView, TrackView, VersionView } from "models.slint"; +export component IconButton inherits Rectangle { + in property icon-source; + in property icon-color: transparent; + in property tooltip; + in property enabled: true; + callback clicked; + width: 38px; + height: 38px; + border-radius: 19px; + opacity: root.enabled ? 1 : 0.32; + background: root.enabled && touch.has-hover ? #282c38 : transparent; + Image { source: root.icon-source; colorize: root.icon-color; width: 19px; height: 19px; x: (parent.width - self.width) / 2; y: (parent.height - self.height) / 2; } + touch := TouchArea { enabled: root.enabled; clicked => root.clicked(); } +} + +export component DeviceRow inherits Rectangle { + in property item; + in property selectable: false; + callback selected(string); + height: 48px; + border-radius: 7px; + opacity: item.revoked ? 0.45 : 1; + background: item.active ? #24483d : selectable && touch.has-hover ? #292e3a : transparent; + HorizontalLayout { + padding-left: 10px; padding-right: 10px; spacing: 10px; + Rectangle { + width: 8px; background: transparent; + Rectangle { + width: 8px; height: 8px; border-radius: 4px; + y: (parent.height - self.height) / 2; + background: root.item.online ? #55dbaa : #555c6c; + } + } + VerticalLayout { + horizontal-stretch: 1; alignment: center; + Text { text: root.item.name + (root.item.is-self ? " · this device" : ""); color: #e8eaf0; font-size: 12px; font-weight: 650; overflow: elide; } + Text { text: root.item.details; color: #757c8c; font-size: 10px; overflow: elide; } + } + if root.item.active: Text { text: "ACTIVE"; color: #55dbaa; font-size: 9px; font-weight: 750; vertical-alignment: center; } + } + touch := TouchArea { enabled: root.selectable; clicked => root.selected(root.item.id); } +} + +export component FederationBadge inherits Rectangle { + in property partial: false; + width: 24px; + height: 18px; + border-radius: 5px; + background: root.partial ? @linear-gradient(90deg, #4bd4a1 0%, #4bd4a1 50%, #e1b84b 50%, #e1b84b 100%) : #4bd4a1; + Image { + source: @image-url("../assets/federation.svg"); + width: 14px; + height: 14px; + x: (parent.width - self.width) / 2; + y: (parent.height - self.height) / 2; + } +} + +export component SearchField inherits Rectangle { + in-out property text <=> input.text; + in property placeholder-text; + callback edited(string); + forward-focus: input; + height: 38px; + border-radius: 19px; + background: #1b1e27; + border-width: 1px; + border-color: input.has-focus ? #697182 : #1b1e27; + clip: true; + + TouchArea { clicked => input.focus(); } + Image { + source: @image-url("../assets/search.svg"); + x: 14px; + y: (parent.height - self.height) / 2; + width: 17px; + height: 17px; + } + input-viewport := Rectangle { + x: 41px; + y: 1px; + width: parent.width - 54px; + height: parent.height - 2px; + background: transparent; + clip: true; + if input.text == "": Text { + text: root.placeholder-text; + color: #858c9c; + font-size: 12px; + vertical-alignment: center; + overflow: elide; + } + input := TextInput { + private property cursor-margin: 2px; + width: max(parent.width, self.preferred-width); + height: parent.height; + color: #e8eaf0; + font-size: 12px; + vertical-alignment: center; + single-line: true; + input-type: search; + selection-background-color: #52617a; + selection-foreground-color: #ffffff; + edited => root.edited(self.text); + cursor-position-changed(cursor-position) => { + if cursor-position.x + self.x < cursor-margin { + self.x = -cursor-position.x + cursor-margin; + } else if cursor-position.x + self.x > parent.width - cursor-margin - self.text-cursor-width { + self.x = parent.width - cursor-position.x - cursor-margin - self.text-cursor-width; + } + } + } + } +} + +export component NavButton inherits Rectangle { + in property label; + in property icon-source; + in property active; + callback clicked; + height: 44px; + border-radius: 8px; + background: root.active ? #252936 : touch.has-hover ? #1a1d27 : transparent; + HorizontalLayout { + padding-left: 13px; + padding-right: 13px; + spacing: 12px; + Rectangle { + width: 19px; + background: transparent; + Image { source: root.icon-source; width: 19px; height: 19px; y: (parent.height - self.height) / 2; } + } + Text { text: root.label; color: root.active ? #ffffff : #b5bac8; font-size: 14px; font-weight: root.active ? 700 : 500; vertical-alignment: center; } + } + touch := TouchArea { clicked => root.clicked(); } +} + +export component PlaylistNavButton inherits Rectangle { + in property item; + in property active; + callback clicked(string); + height: 34px; + border-radius: 6px; + background: root.active ? #252936 : touch.has-hover ? #1a1d27 : transparent; + HorizontalLayout { + padding-left: 10px; padding-right: 8px; spacing: 9px; + Image { + source: root.item.is-likes ? @image-url("../assets/heart.svg") : @image-url("../assets/playlist-add.svg"); + width: 15px; height: 15px; + } + Text { horizontal-stretch: 1; text: root.item.title; color: root.active ? #f5f6fa : #a4a9b7; font-size: 12px; overflow: elide; vertical-alignment: center; } + } + touch := TouchArea { clicked => root.clicked(root.item.id); } +} + +export component MarqueeText inherits Rectangle { + in property label; + in property text-color: #858b9c; + in property text-size: 11px; + in property text-weight: 400; + private property travel: max(0px, marquee-label.preferred-width - root.width); + private property scrolling: root.travel > 1px; + // Calling animation-tick() for text that already fits keeps the whole + // window repainting for no visual reason. + private property phase: root.scrolling ? mod(animation-tick(), 10s) : 0ms; + clip: true; + background: transparent; + marquee-label := Text { + x: !root.scrolling || root.phase < 1500ms ? 0px + : root.phase < 4500ms ? -root.travel * ((root.phase - 1500ms) / 3000ms) + : root.phase < 6000ms ? -root.travel + : root.phase < 9000ms ? -root.travel * (1 - ((root.phase - 6000ms) / 3000ms)) + : 0px; + y: 0px; + width: self.preferred-width; + height: root.height; + text: root.label; + color: root.text-color; + font-size: root.text-size; + font-weight: root.text-weight; + vertical-alignment: center; + horizontal-alignment: left; + } +} + +export component LinkText inherits Rectangle { + in property label; + in property base-color: #858b9c; + in property text-size: 11px; + in property text-weight: 400; + callback clicked; + height: 17px; + horizontal-stretch: 1; + background: transparent; + MarqueeText { + x: 0px; y: 0px; width: parent.width; height: parent.height; + label: root.label; + text-color: touch.has-hover ? #f1f2f6 : root.base-color; + text-size: root.text-size; + text-weight: root.text-weight; + } + touch := TouchArea { clicked => root.clicked(); } +} + +export component InlineArtistLink inherits Text { + in property key; + in property base-color: #858b9c; + callback clicked(string); + color: touch.has-hover ? #f1f2f6 : root.base-color; + font-size: 11px; + vertical-alignment: center; + touch := TouchArea { clicked => root.clicked(root.key); } +} + +export component ArtistLinksRow inherits Rectangle { + in property <[ArtistLinkView]> artists; + in property leading-label; + in property row-color: #858b9c; + in property text-size: 11px; + in property start-index: 0; + in property end-index: artists.length; + in property animate-overflow: true; + callback open-artist(string); + height: 18px; + background: transparent; + clip: true; + private property travel: max(0px, links.preferred-width - root.width); + private property phase: root.animate-overflow && root.travel > 1px ? mod(animation-tick(), 10s) : 0ms; + links := HorizontalLayout { + x: !root.animate-overflow || root.travel <= 1px || root.phase < 1500ms ? 0px + : root.phase < 4500ms ? -root.travel * ((root.phase - 1500ms) / 3000ms) + : root.phase < 6000ms ? -root.travel + : root.phase < 9000ms ? -root.travel * (1 - ((root.phase - 6000ms) / 3000ms)) + : 0px; + y: 0px; width: max(root.width, self.preferred-width); height: root.height; + spacing: 0px; alignment: start; + if root.leading-label != "": Text { text: root.leading-label; color: root.row-color; font-size: root.text-size; vertical-alignment: center; } + for artist[index] in root.artists: HorizontalLayout { + visible: index >= root.start-index && index < root.end-index; + spacing: 0px; + if artist.prefix != "": Text { + text: index == root.start-index && artist.prefix == ", " ? "" + : index == root.start-index && artist.prefix == " feat. " ? "feat. " + : artist.prefix; + color: root.row-color; font-size: root.text-size; vertical-alignment: center; + } + InlineArtistLink { + key: artist.key; text: artist.name; base-color: root.row-color; + font-size: root.text-size; clicked(key) => root.open-artist(key); + } + } + } +} + +export component TrackArtistsCell inherits Rectangle { + in property <[ArtistLinkView]> artists; + in property label; + in property line-break; + callback open-artist(string); + clip: true; + background: transparent; + private property use-two-lines: root.line-break > 0 && measure.preferred-width > root.width * 1.85; + measure := Text { visible: false; text: root.label; font-size: 11px; } + if !root.use-two-lines: ArtistLinksRow { + width: parent.width; height: parent.height; + artists: root.artists; + open-artist(key) => root.open-artist(key); + } + if root.use-two-lines: Rectangle { + x: 0px; y: 4px; width: parent.width; height: parent.height - 8px; + background: transparent; clip: true; + ArtistLinksRow { + x: 0px; y: 0px; width: parent.width; height: parent.height / 2; + artists: root.artists; end-index: root.line-break; animate-overflow: false; + open-artist(key) => root.open-artist(key); + } + ArtistLinksRow { + x: 0px; y: parent.height / 2; width: parent.width; height: parent.height / 2; + artists: root.artists; start-index: root.line-break; animate-overflow: false; + open-artist(key) => root.open-artist(key); + } + } +} + +export component StaticArtistList inherits Rectangle { + in property <[ArtistLinkLineView]> lines; + in property heading; + callback open-artist(string); + height: (heading == "" ? 0px : 20px) + lines.length * 19px; + background: transparent; + if root.heading != "": Text { + x: 0px; y: 0px; width: parent.width; height: 18px; + text: root.heading; color: #707687; font-size: 11px; vertical-alignment: center; + } + for line[index] in root.lines: Rectangle { + x: 0px; + y: (root.heading == "" ? 0px : 20px) + index * 19px; + width: parent.width; + height: 19px; + background: transparent; + HorizontalLayout { + spacing: 0px; alignment: start; + for artist in line.artists: HorizontalLayout { + spacing: 0px; + if artist.prefix != "": Text { + text: artist.prefix; color: #858b9c; font-size: 11px; vertical-alignment: center; + } + InlineArtistLink { + key: artist.key; text: artist.name; base-color: #858b9c; + clicked(key) => root.open-artist(key); + } + } + } + } +} + +export component TrackInfoArtists inherits Rectangle { + in property <[ArtistLinkView]> artists; + callback open-artist(string); + height: min(62px, max(28px, artists.length * 20px)); + background: transparent; + Text { x: 0px; width: 112px; height: 28px; text: "Artists"; color: #707687; font-size: 11px; vertical-alignment: center; } + ScrollView { + x: 124px; width: parent.width - 124px; height: parent.height; + horizontal-scrollbar-policy: ScrollBarPolicy.always-off; + vertical-scrollbar-policy: ScrollBarPolicy.always-off; + VerticalLayout { + spacing: 0px; + for artist in root.artists: Rectangle { + height: 20px; background: transparent; + Text { + width: parent.width; height: parent.height; + text: artist.name; + color: artist-touch.has-hover ? #f1f2f6 : #dfe2ea; + font-size: 11px; overflow: elide; vertical-alignment: center; + } + artist-touch := TouchArea { clicked => root.open-artist(artist.key); } + } + } + } +} + +export component VersionRows inherits Rectangle { + in property <[VersionView]> items; + height: ceil(items.length / 2) * 21px; + background: transparent; + for item[index] in items: Rectangle { + x: mod(index, 2) * parent.width / 2; + y: floor(index / 2) * 21px; + width: parent.width / 2 - (mod(index, 2) == 0 ? 8px : 0px); + height: 21px; + background: transparent; + Text { + x: 0px; width: parent.width - 82px; height: parent.height; + text: item.name; color: #8f96a6; font-size: 10px; + overflow: elide; vertical-alignment: center; + } + Text { + x: parent.width - 78px; width: 78px; height: parent.height; + text: "v" + item.version; color: #d7dae3; font-size: 10px; + font-weight: 650; overflow: elide; horizontal-alignment: right; + vertical-alignment: center; + } + } +} + +export component BuildInfoCard inherits Rectangle { + in property <[VersionView]> software; + in property <[VersionView]> protocols; + min-height: 82px + ceil(software.length / 2) * 21px + ceil(protocols.length / 2) * 21px; + max-height: 82px + ceil(software.length / 2) * 21px + ceil(protocols.length / 2) * 21px; + vertical-stretch: 0; + border-radius: 9px; background: #11151d; border-width: 1px; border-color: #2c3240; + VerticalLayout { + padding: 11px; spacing: 5px; + Text { height: 18px; text: "Build and compatibility"; color: #dfe2ea; font-size: 11px; font-weight: 700; vertical-alignment: center; } + Text { height: 14px; text: "SOFTWARE"; color: #697080; font-size: 9px; font-weight: 750; letter-spacing: 0.7px; vertical-alignment: center; } + VersionRows { items: root.software; } + Text { height: 14px; text: "PROTOCOLS"; color: #697080; font-size: 9px; font-weight: 750; letter-spacing: 0.7px; vertical-alignment: center; } + VersionRows { items: root.protocols; } + } +} + +export component AlbumTile inherits Rectangle { + in property item; + in property show-artist: true; + in property show-release-type: false; + callback play(string); + callback open(string); + callback open-artist(string); + private property hovered: touch.has-hover || play-touch.has-hover; + width: 154px; + height: 216px; + border-radius: 10px; + background: root.hovered ? #20242e : #171a22; + touch := TouchArea { clicked => root.open(item.key); } + VerticalLayout { + padding: 11px; + spacing: 9px; + cover := Rectangle { + height: 132px; + border-radius: 7px; + clip: true; + background: #292d38; + Image { source: @image-url("../assets/note.svg"); width: 44px; height: 44px; x: (parent.width - self.width) / 2; y: (parent.height - self.height) / 2; } + if item.has-artwork: Image { source: item.artwork; image-fit: ImageFit.cover; width: parent.width; height: parent.height; } + if item.federation-state > 0: FederationBadge { partial: item.federation-state == 2; x: parent.width - self.width - 4px; y: 4px; } + Rectangle { + x: 92px; y: 80px; width: 38px; height: 38px; + border-radius: 19px; + background: play-touch.has-hover ? #62e6b4 : #4bd4a1; + opacity: root.hovered ? 1 : 0; + Image { source: @image-url("../assets/play.svg"); width: 18px; height: 18px; x: 10px; y: 10px; } + play-touch := TouchArea { clicked => root.play(item.key); } + } + } + Text { text: item.title; color: #f4f5f8; font-size: 14px; font-weight: 700; overflow: elide; } + if root.show-artist: LinkText { label: item.artist + " · " + item.year; text-size: 12px; clicked => root.open-artist(item.artist-key); } + if !root.show-artist: Text { + height: 17px; + text: item.year + (root.show-release-type && item.release-type != "" ? " · " + item.release-type : ""); + color: #858b9c; font-size: 12px; overflow: elide; vertical-alignment: center; + } + } +} + +export component ArtistTile inherits Rectangle { + in property item; + callback open(string); + width: 154px; + height: 210px; + border-radius: 10px; + background: touch.has-hover ? #20242e : #171a22; + VerticalLayout { + padding: 11px; + spacing: 9px; + Rectangle { + height: 132px; + border-radius: 66px; + clip: true; + background: #292d38; + Image { source: @image-url("../assets/note.svg"); width: 42px; height: 42px; x: (parent.width - self.width) / 2; y: (parent.height - self.height) / 2; } + if item.has-artwork: Image { source: item.artwork; image-fit: ImageFit.cover; width: parent.width; height: parent.height; } + } + Text { text: item.name; color: #f4f5f8; font-size: 14px; font-weight: 700; overflow: elide; } + Text { text: item.details; color: #858b9c; font-size: 11px; overflow: elide; } + } + touch := TouchArea { clicked => root.open(item.key); } +} + +export component SearchArtistTile inherits Rectangle { + in property item; + callback open(string); + width: 120px; height: 150px; border-radius: 8px; + background: touch.has-hover ? #20242e : #171a22; + VerticalLayout { + padding: 9px; spacing: 7px; + Rectangle { + height: 102px; border-radius: 10px; background: #292d38; clip: true; + Image { source: @image-url("../assets/note.svg"); width: 32px; height: 32px; x: (parent.width - self.width) / 2; y: (parent.height - self.height) / 2; } + if item.has-artwork: Image { source: item.artwork; image-fit: ImageFit.cover; width: parent.width; height: parent.height; } + if item.federated: FederationBadge { x: parent.width - self.width - 4px; y: 4px; } + } + Text { text: item.name; color: #f4f5f8; font-size: 12px; font-weight: 700; overflow: elide; } + } + touch := TouchArea { clicked => root.open(item.key); } +} + +export component SearchReleaseTile inherits Rectangle { + in property item; + callback open(string); + width: 120px; height: 150px; border-radius: 8px; + background: touch.has-hover ? #20242e : #171a22; + VerticalLayout { + padding: 9px; spacing: 7px; + Rectangle { + height: 102px; border-radius: 5px; background: #292d38; clip: true; + Image { source: @image-url("../assets/note.svg"); width: 32px; height: 32px; x: (parent.width - self.width) / 2; y: (parent.height - self.height) / 2; } + if item.has-artwork: Image { source: item.artwork; image-fit: ImageFit.cover; width: parent.width; height: parent.height; } + if item.federation-state > 0: FederationBadge { partial: item.federation-state == 2; x: parent.width - self.width - 4px; y: 4px; } + } + Text { text: item.title; color: #f4f5f8; font-size: 12px; font-weight: 700; overflow: elide; } + } + touch := TouchArea { clicked => root.open(item.key); } +} + +export component SearchArtistGrid inherits Rectangle { + in property <[ArtistView]> artists; + callback open(string); + property columns: max(1, floor((self.width + 12px) / 132px)); + height: artists.length > 0 ? ceil(artists.length / columns) * 162px - 12px : 0px; + for artist[index] in artists: SearchArtistTile { x: mod(index, root.columns) * 132px; y: floor(index / root.columns) * 162px; item: artist; open(key) => root.open(key); } +} + +export component SearchReleaseGrid inherits Rectangle { + in property <[ReleaseView]> releases; + callback open(string); + property columns: max(1, floor((self.width + 12px) / 132px)); + height: releases.length > 0 ? ceil(releases.length / columns) * 162px - 12px : 0px; + for release[index] in releases: SearchReleaseTile { x: mod(index, root.columns) * 132px; y: floor(index / root.columns) * 162px; item: release; open(key) => root.open(key); } +} + +export component TrackActionButton inherits Rectangle { + in property icon-source; + in property active: false; + callback clicked; + width: 28px; + height: 28px; + border-radius: 6px; + background: root.active ? #255845 : touch.has-hover ? #353a47 : transparent; + Image { source: root.icon-source; width: 16px; height: 16px; x: 6px; y: 6px; } + touch := TouchArea { clicked => root.clicked(); } +} + +export component TrackMenuItem inherits Rectangle { + in property icon-source; + in property label; + callback clicked; + height: 34px; + border-radius: 5px; + background: touch.has-hover ? #303541 : transparent; + HorizontalLayout { + padding-left: 9px; padding-right: 9px; spacing: 9px; + Rectangle { + width: 16px; background: transparent; + Image { source: root.icon-source; width: 16px; height: 16px; y: (parent.height - self.height) / 2; } + } + Text { text: root.label; color: #d9dce5; font-size: 11px; vertical-alignment: center; } + } + touch := TouchArea { clicked => root.clicked(); } +} + +export component InfoField inherits Rectangle { + in property label; + in property value; + height: 28px; + background: transparent; + HorizontalLayout { + spacing: 12px; + Text { width: 112px; text: root.label; color: #707687; font-size: 11px; vertical-alignment: center; } + Text { horizontal-stretch: 1; text: root.value == "" ? "—" : root.value; color: #dfe2ea; font-size: 11px; overflow: elide; vertical-alignment: center; } + } +} + +export component TrackRow inherits Rectangle { + in property item; + in property show-artwork: false; + callback play(string); + callback action(string, string); + callback open-artist(string); + callback open-release(string); + height: 48px; + border-radius: 6px; + background: item.active ? #282d3a : touch.has-hover ? #20242e : transparent; + touch := TouchArea { double-clicked => root.play(item.key); } + HorizontalLayout { + padding-left: 12px; + padding-right: 12px; + spacing: 12px; + Text { text: item.number; color: #777d8d; width: 24px; vertical-alignment: center; horizontal-alignment: center; } + if root.show-artwork: Rectangle { + width: 34px; height: 34px; border-radius: 4px; background: #292d38; clip: true; + Image { source: @image-url("../assets/note.svg"); width: 18px; height: 18px; x: 8px; y: 8px; } + if item.has-artwork: Image { source: item.artwork; image-fit: ImageFit.cover; width: parent.width; height: parent.height; } + } + Rectangle { + horizontal-stretch: 1; + height: parent.height; + background: transparent; + clip: true; + Rectangle { + x: 0px; width: parent.width * 0.40; height: parent.height; background: transparent; clip: true; + Text { + x: 0px; y: 0px; + // Keep identical text geometry for local and federated + // rows. The badge is an overlay at the trailing edge. + width: parent.width - 34px; height: parent.height; + text: item.title; + color: item.active ? #55dbaa : #f1f2f6; + font-size: 13px; font-weight: 600; overflow: elide; + horizontal-alignment: left; + vertical-alignment: center; + } + if item.federated: FederationBadge { + x: parent.width - self.width - 8px; + y: (parent.height - self.height) / 2; + } + } + Rectangle { + x: parent.width * 0.40; width: parent.width * 0.30; height: parent.height; background: transparent; clip: true; + TrackArtistsCell { + x: 0px; y: 0px; width: parent.width - 12px; height: parent.height; + artists: item.artists; label: item.artists-label; + line-break: item.artist-line-break; + open-artist(key) => root.open-artist(key); + } + } + Rectangle { + x: parent.width * 0.70; width: parent.width * 0.30; height: parent.height; background: transparent; clip: true; + LinkText { x: 0px; y: 0px; width: parent.width - 8px; height: parent.height; label: item.release; base-color: #949aaa; text-size: 12px; clicked => root.open-release(item.release-key); } + } + } + Text { text: item.duration; color: #949aaa; width: 46px; font-size: 12px; horizontal-alignment: right; vertical-alignment: center; } + Rectangle { + width: 124px; + background: transparent; + TrackActionButton { x: 0px; y: (parent.height - self.height) / 2; active: root.item.liked; icon-source: @image-url("../assets/heart.svg"); clicked => root.action("like", root.item.key); } + TrackActionButton { x: 32px; y: (parent.height - self.height) / 2; icon-source: @image-url("../assets/play-next.svg"); clicked => root.action("play-next", root.item.key); } + TrackActionButton { x: 64px; y: (parent.height - self.height) / 2; icon-source: @image-url("../assets/add-end.svg"); clicked => root.action("add-end", root.item.key); } + TrackActionButton { x: 96px; y: (parent.height - self.height) / 2; icon-source: @image-url("../assets/more.svg"); clicked => { menu.show(); } } + } + } + menu := PopupWindow { + x: root.width - self.width - 8px; + y: 42px; + width: 184px; + height: 118px; + close-policy: close-on-click-outside; + Rectangle { + border-radius: 8px; + background: #222631; + border-color: #3a3f4d; + border-width: 1px; + drop-shadow-color: #00000080; + drop-shadow-blur: 14px; + drop-shadow-offset-y: 4px; + VerticalLayout { + padding: 6px; spacing: 1px; + TrackMenuItem { icon-source: @image-url("../assets/info.svg"); label: "Track information"; clicked => { menu.close(); root.action("information", root.item.key); } } + TrackMenuItem { icon-source: @image-url("../assets/share.svg"); label: "Share"; clicked => { menu.close(); root.action("share", root.item.key); } } + TrackMenuItem { icon-source: @image-url("../assets/playlist-add.svg"); label: "Add to playlist"; clicked => { menu.close(); root.action("playlist", root.item.key); } } + } + } + } +} + +export component TrackList inherits Rectangle { + in property <[TrackView]> tracks; + in property show-artwork: false; + callback play(string); + callback action(string, string); + callback open-artist(string); + callback open-release(string); + property content-height: tracks.length > 0 ? tracks.length * 50px - 2px : 0px; + min-height: content-height; + preferred-height: content-height; + max-height: content-height; + vertical-stretch: 0; + background: transparent; + for track[index] in tracks: TrackRow { + x: 0px; y: index * 50px; width: root.width; + item: track; + show-artwork: root.show-artwork; + play(key) => root.play(key); + action(action, key) => root.action(action, key); + open-artist(key) => root.open-artist(key); + open-release(key) => root.open-release(key); + } +} + +export component AlbumGrid inherits Rectangle { + in property <[ReleaseView]> releases; + in property show-artists: true; + in property show-release-types: false; + callback play(string); + callback open(string); + callback open-artist(string); + property fitting-column-count: max(1, floor((self.width + 13px) / 167px)); + property column-count: releases.length > 0 && releases.length < fitting-column-count ? releases.length : fitting-column-count; + property column-gap: column-count > 1 ? min(25px, max(13px, (self.width - column-count * 154px) / (column-count - 1))) : 0px; + height: releases.length > 0 ? ceil(releases.length / column-count) * 229px - 13px : 0px; + background: transparent; + + for release[index] in releases: AlbumTile { + x: mod(index, root.column-count) * (154px + root.column-gap); + y: floor(index / root.column-count) * 229px; + item: release; + show-artist: root.show-artists; + show-release-type: root.show-release-types; + play(key) => root.play(key); + open(key) => root.open(key); + open-artist(key) => root.open-artist(key); + } +} + +export component ArtistGrid inherits Rectangle { + in property <[ArtistView]> artists; + callback open(string); + property fitting-column-count: max(1, floor((self.width + 13px) / 167px)); + property column-count: artists.length > 0 && artists.length < fitting-column-count ? artists.length : fitting-column-count; + property column-gap: column-count > 1 ? min(25px, max(13px, (self.width - column-count * 154px) / (column-count - 1))) : 0px; + height: artists.length > 0 ? ceil(artists.length / column-count) * 223px - 13px : 0px; + background: transparent; + for artist[index] in artists: ArtistTile { + x: mod(index, root.column-count) * (154px + root.column-gap); + y: floor(index / root.column-count) * 223px; + item: artist; + open(key) => root.open(key); + } +} diff --git a/crates/ui/ui/models.slint b/crates/ui/ui/models.slint new file mode 100644 index 0000000..7800fe7 --- /dev/null +++ b/crates/ui/ui/models.slint @@ -0,0 +1,90 @@ +export struct ReleaseView { + key: string, + title: string, + artist: string, + artist-key: string, + year: string, + release-type: string, + artwork: image, + has-artwork: bool, + federation-state: int, +} + +export struct BreadcrumbView { label: string, target: string } + +export struct ArtistView { + key: string, + name: string, + details: string, + artwork: image, + has-artwork: bool, + federated: bool, +} + +export struct ArtistLinkView { + key: string, + name: string, + prefix: string, +} + +export struct ArtistLinkLineView { + artists: [ArtistLinkView], +} + +export struct VersionView { + name: string, + version: string, +} + +export struct TrackView { + key: string, + number: string, + title: string, + artists: [ArtistLinkView], + artists-label: string, + artist-line-break: int, + release: string, + release-key: string, + duration: string, + active: bool, + artwork: image, + has-artwork: bool, + federated: bool, + liked: bool, +} + +export struct QueueView { + key: string, + title: string, + artist: string, + artist-key: string, + release: string, + release-key: string, + active: bool, + artwork: image, + has-artwork: bool, +} + +export struct PlaylistView { + id: string, + title: string, + details: string, + is-likes: bool, +} + +export struct DeviceView { + id: string, + name: string, + details: string, + is-self: bool, + online: bool, + active: bool, + revoked: bool, +} + +export struct PairingView { + request-id: string, + name: string, + details: string, + group-conflict: bool, +} diff --git a/rust-toolchain.toml b/rust-toolchain.toml new file mode 100644 index 0000000..efab296 --- /dev/null +++ b/rust-toolchain.toml @@ -0,0 +1,5 @@ +[toolchain] +channel = "1.97.0" +profile = "minimal" +components = ["clippy", "rustfmt"] + diff --git a/rustfmt.toml b/rustfmt.toml new file mode 100644 index 0000000..3380770 --- /dev/null +++ b/rustfmt.toml @@ -0,0 +1,2 @@ +edition = "2024" + diff --git a/screenshots/furumi-desktop.png b/screenshots/furumi-desktop.png new file mode 100644 index 0000000..5c2e0da Binary files /dev/null and b/screenshots/furumi-desktop.png differ diff --git a/shell.nix b/shell.nix new file mode 100644 index 0000000..ebe445c --- /dev/null +++ b/shell.nix @@ -0,0 +1,38 @@ +{ pkgs ? import {} }: + +pkgs.mkShell { + nativeBuildInputs = with pkgs; [ + rustup + pkg-config + cmake + clang + mold + ]; + + buildInputs = with pkgs; [ + fontconfig + freetype + libxkbcommon + wayland + vulkan-loader + xorg.libX11 + xorg.libXcursor + xorg.libXi + xorg.libXrandr + ]; + + LD_LIBRARY_PATH = pkgs.lib.makeLibraryPath (with pkgs; [ + fontconfig + freetype + libxkbcommon + wayland + vulkan-loader + xorg.libX11 + xorg.libXcursor + xorg.libXi + xorg.libXrandr + ]); + + RUST_BACKTRACE = "1"; +} + diff --git a/vendor/block/Cargo.toml b/vendor/block/Cargo.toml new file mode 100644 index 0000000..d2abc29 --- /dev/null +++ b/vendor/block/Cargo.toml @@ -0,0 +1,24 @@ +[package] +name = "block" +version = "0.1.6" +authors = ["Steven Sheldon"] + +description = "Rust interface for Apple's C language extension of blocks." +keywords = ["blocks", "osx", "ios", "objective-c"] +readme = "README.md" +repository = "http://github.com/SSheldon/rust-block" +documentation = "http://ssheldon.github.io/rust-objc/block/" +license = "MIT" + +exclude = [ + ".gitignore", + ".travis.yml", + "travis_install.sh", + "travis_test.sh", + "tests-ios/**", +] + +[dev-dependencies.objc_test_utils] +version = "0.0" +path = "test_utils" + diff --git a/vendor/block/src/lib.rs b/vendor/block/src/lib.rs new file mode 100644 index 0000000..0328cd1 --- /dev/null +++ b/vendor/block/src/lib.rs @@ -0,0 +1,400 @@ +/*! +A Rust interface for Objective-C blocks. + +For more information on the specifics of the block implementation, see +Clang's documentation: http://clang.llvm.org/docs/Block-ABI-Apple.html + +# Invoking blocks + +The `Block` struct is used for invoking blocks from Objective-C. For example, +consider this Objective-C function: + +``` objc +int32_t sum(int32_t (^block)(int32_t, int32_t)) { + return block(5, 8); +} +``` + +We could write it in Rust as the following: + +``` +# use block::Block; +unsafe fn sum(block: &Block<(i32, i32), i32>) -> i32 { + block.call((5, 8)) +} +``` + +Note the extra parentheses in the `call` method, since the arguments must be +passed as a tuple. + +# Creating blocks + +Creating a block to pass to Objective-C can be done with the `ConcreteBlock` +struct. For example, to create a block that adds two `i32`s, we could write: + +``` +# use block::ConcreteBlock; +let block = ConcreteBlock::new(|a: i32, b: i32| a + b); +let block = block.copy(); +assert!(unsafe { block.call((5, 8)) } == 13); +``` + +It is important to copy your block to the heap (with the `copy` method) before +passing it to Objective-C; this is because our `ConcreteBlock` is only meant +to be copied once, and we can enforce this in Rust, but if Objective-C code +were to copy it twice we could have a double free. +*/ + +#[cfg(test)] +mod test_utils; + +use std::marker::PhantomData; +use std::mem; +use std::ops::{Deref, DerefMut}; +use std::os::raw::{c_int, c_ulong, c_void}; +use std::ptr; + +#[repr(C)] +struct Class { + _private: [u8; 0], +} + +#[cfg_attr(any(target_os = "macos", target_os = "ios"), + link(name = "System", kind = "dylib"))] +#[cfg_attr(not(any(target_os = "macos", target_os = "ios")), + link(name = "BlocksRuntime", kind = "dylib"))] +extern "C" { + static _NSConcreteStackBlock: Class; + + fn _Block_copy(block: *const c_void) -> *mut c_void; + fn _Block_release(block: *const c_void); +} + +/// Types that may be used as the arguments to an Objective-C block. +pub trait BlockArguments: Sized { + /// Calls the given `Block` with self as the arguments. + /// + /// Unsafe because `block` must point to a valid `Block` and this invokes + /// foreign code whose safety the compiler cannot verify. + unsafe fn call_block(self, block: *mut Block) -> R; +} + +macro_rules! block_args_impl { + ($($a:ident : $t:ident),*) => ( + impl<$($t),*> BlockArguments for ($($t,)*) { + unsafe fn call_block(self, block: *mut Block) -> R { + let invoke: unsafe extern "C" fn(*mut Block $(, $t)*) -> R = { + let base = block as *mut BlockBase; + mem::transmute((*base).invoke) + }; + let ($($a,)*) = self; + invoke(block $(, $a)*) + } + } + ); +} + +block_args_impl!(); +block_args_impl!(a: A); +block_args_impl!(a: A, b: B); +block_args_impl!(a: A, b: B, c: C); +block_args_impl!(a: A, b: B, c: C, d: D); +block_args_impl!(a: A, b: B, c: C, d: D, e: E); +block_args_impl!(a: A, b: B, c: C, d: D, e: E, f: F); +block_args_impl!(a: A, b: B, c: C, d: D, e: E, f: F, g: G); +block_args_impl!(a: A, b: B, c: C, d: D, e: E, f: F, g: G, h: H); +block_args_impl!(a: A, b: B, c: C, d: D, e: E, f: F, g: G, h: H, i: I); +block_args_impl!(a: A, b: B, c: C, d: D, e: E, f: F, g: G, h: H, i: I, j: J); +block_args_impl!(a: A, b: B, c: C, d: D, e: E, f: F, g: G, h: H, i: I, j: J, k: K); +block_args_impl!(a: A, b: B, c: C, d: D, e: E, f: F, g: G, h: H, i: I, j: J, k: K, l: L); + +#[repr(C)] +struct BlockBase { + isa: *const Class, + flags: c_int, + _reserved: c_int, + invoke: unsafe extern "C" fn(*mut Block, ...) -> R, +} + +/// An Objective-C block that takes arguments of `A` when called and +/// returns a value of `R`. +#[repr(C)] +pub struct Block { + _base: PhantomData>, +} + +impl Block where A: BlockArguments { + /// Call self with the given arguments. + /// + /// Unsafe because this invokes foreign code that the caller must verify + /// doesn't violate any of Rust's safety rules. For example, if this block + /// is shared with multiple references, the caller must ensure that calling + /// it will not cause a data race. + pub unsafe fn call(&self, args: A) -> R { + args.call_block(self as *const _ as *mut _) + } +} + +/// A reference-counted Objective-C block. +pub struct RcBlock { + ptr: *mut Block, +} + +impl RcBlock { + /// Construct an `RcBlock` for the given block without copying it. + /// The caller must ensure the block has a +1 reference count. + /// + /// Unsafe because `ptr` must point to a valid `Block` and must have a +1 + /// reference count or it will be overreleased when the `RcBlock` is + /// dropped. + pub unsafe fn new(ptr: *mut Block) -> Self { + RcBlock { ptr: ptr } + } + + /// Constructs an `RcBlock` by copying the given block. + /// + /// Unsafe because `ptr` must point to a valid `Block`. + pub unsafe fn copy(ptr: *mut Block) -> Self { + let ptr = _Block_copy(ptr as *const c_void) as *mut Block; + RcBlock { ptr: ptr } + } +} + +impl Clone for RcBlock { + fn clone(&self) -> RcBlock { + unsafe { + RcBlock::copy(self.ptr) + } + } +} + +impl Deref for RcBlock { + type Target = Block; + + fn deref(&self) -> &Block { + unsafe { &*self.ptr } + } +} + +impl Drop for RcBlock { + fn drop(&mut self) { + unsafe { + _Block_release(self.ptr as *const c_void); + } + } +} + +/// Types that may be converted into a `ConcreteBlock`. +pub trait IntoConcreteBlock: Sized where A: BlockArguments { + /// The return type of the resulting `ConcreteBlock`. + type Ret; + + /// Consumes self to create a `ConcreteBlock`. + fn into_concrete_block(self) -> ConcreteBlock; +} + +macro_rules! concrete_block_impl { + ($f:ident) => ( + concrete_block_impl!($f,); + ); + ($f:ident, $($a:ident : $t:ident),*) => ( + impl<$($t,)* R, X> IntoConcreteBlock<($($t,)*)> for X + where X: Fn($($t,)*) -> R { + type Ret = R; + + fn into_concrete_block(self) -> ConcreteBlock<($($t,)*), R, X> { + unsafe extern "C" fn $f<$($t,)* R, X>( + block_ptr: *mut ConcreteBlock<($($t,)*), R, X> + $(, $a: $t)*) -> R + where X: Fn($($t,)*) -> R { + let block = &*block_ptr; + (block.closure)($($a),*) + } + + let f: unsafe extern "C" fn(*mut ConcreteBlock<($($t,)*), R, X> $(, $a: $t)*) -> R = $f; + unsafe { + ConcreteBlock::with_invoke(mem::transmute(f), self) + } + } + } + ); +} + +concrete_block_impl!(concrete_block_invoke_args0); +concrete_block_impl!(concrete_block_invoke_args1, a: A); +concrete_block_impl!(concrete_block_invoke_args2, a: A, b: B); +concrete_block_impl!(concrete_block_invoke_args3, a: A, b: B, c: C); +concrete_block_impl!(concrete_block_invoke_args4, a: A, b: B, c: C, d: D); +concrete_block_impl!(concrete_block_invoke_args5, a: A, b: B, c: C, d: D, e: E); +concrete_block_impl!(concrete_block_invoke_args6, a: A, b: B, c: C, d: D, e: E, f: F); +concrete_block_impl!(concrete_block_invoke_args7, a: A, b: B, c: C, d: D, e: E, f: F, g: G); +concrete_block_impl!(concrete_block_invoke_args8, a: A, b: B, c: C, d: D, e: E, f: F, g: G, h: H); +concrete_block_impl!(concrete_block_invoke_args9, a: A, b: B, c: C, d: D, e: E, f: F, g: G, h: H, i: I); +concrete_block_impl!(concrete_block_invoke_args10, a: A, b: B, c: C, d: D, e: E, f: F, g: G, h: H, i: I, j: J); +concrete_block_impl!(concrete_block_invoke_args11, a: A, b: B, c: C, d: D, e: E, f: F, g: G, h: H, i: I, j: J, k: K); +concrete_block_impl!(concrete_block_invoke_args12, a: A, b: B, c: C, d: D, e: E, f: F, g: G, h: H, i: I, j: J, k: K, l: L); + +/// An Objective-C block whose size is known at compile time and may be +/// constructed on the stack. +#[repr(C)] +pub struct ConcreteBlock { + base: BlockBase, + descriptor: Box>>, + closure: F, +} + +impl ConcreteBlock + where A: BlockArguments, F: IntoConcreteBlock { + /// Constructs a `ConcreteBlock` with the given closure. + /// When the block is called, it will return the value that results from + /// calling the closure. + pub fn new(closure: F) -> Self { + closure.into_concrete_block() + } +} + +impl ConcreteBlock { + /// Constructs a `ConcreteBlock` with the given invoke function and closure. + /// Unsafe because the caller must ensure the invoke function takes the + /// correct arguments. + unsafe fn with_invoke(invoke: unsafe extern "C" fn(*mut Self, ...) -> R, + closure: F) -> Self { + ConcreteBlock { + base: BlockBase { + isa: &_NSConcreteStackBlock, + // 1 << 25 = BLOCK_HAS_COPY_DISPOSE + flags: 1 << 25, + _reserved: 0, + invoke: mem::transmute(invoke), + }, + descriptor: Box::new(BlockDescriptor::new()), + closure: closure, + } + } +} + +impl ConcreteBlock where F: 'static { + /// Copy self onto the heap as an `RcBlock`. + pub fn copy(self) -> RcBlock { + unsafe { + let mut block = self; + let copied = RcBlock::copy(&mut *block); + // At this point, our copy helper has been run so the block will + // be moved to the heap and we can forget the original block + // because the heap block will drop in our dispose helper. + mem::forget(block); + copied + } + } +} + +impl Clone for ConcreteBlock where F: Clone { + fn clone(&self) -> Self { + unsafe { + ConcreteBlock::with_invoke(mem::transmute(self.base.invoke), + self.closure.clone()) + } + } +} + +impl Deref for ConcreteBlock { + type Target = Block; + + fn deref(&self) -> &Block { + unsafe { &*(&self.base as *const _ as *const Block) } + } +} + +impl DerefMut for ConcreteBlock { + fn deref_mut(&mut self) -> &mut Block { + unsafe { &mut *(&mut self.base as *mut _ as *mut Block) } + } +} + +unsafe extern "C" fn block_context_dispose(block: &mut B) { + // Read the block onto the stack and let it drop + ptr::read(block); +} + +unsafe extern "C" fn block_context_copy(_dst: &mut B, _src: &B) { + // The runtime memmoves the src block into the dst block, nothing to do +} + +#[repr(C)] +struct BlockDescriptor { + _reserved: c_ulong, + block_size: c_ulong, + copy_helper: unsafe extern "C" fn(&mut B, &B), + dispose_helper: unsafe extern "C" fn(&mut B), +} + +impl BlockDescriptor { + fn new() -> BlockDescriptor { + BlockDescriptor { + _reserved: 0, + block_size: mem::size_of::() as c_ulong, + copy_helper: block_context_copy::, + dispose_helper: block_context_dispose::, + } + } +} + +#[cfg(test)] +mod tests { + use test_utils::*; + use super::{ConcreteBlock, RcBlock}; + + #[test] + fn test_call_block() { + let block = get_int_block_with(13); + unsafe { + assert!(block.call(()) == 13); + } + } + + #[test] + fn test_call_block_args() { + let block = get_add_block_with(13); + unsafe { + assert!(block.call((2,)) == 15); + } + } + + #[test] + fn test_create_block() { + let block = ConcreteBlock::new(|| 13); + let result = invoke_int_block(&block); + assert!(result == 13); + } + + #[test] + fn test_create_block_args() { + let block = ConcreteBlock::new(|a: i32| a + 5); + let result = invoke_add_block(&block, 6); + assert!(result == 11); + } + + #[test] + fn test_concrete_block_copy() { + let s = "Hello!".to_string(); + let expected_len = s.len() as i32; + let block = ConcreteBlock::new(move || s.len() as i32); + assert!(invoke_int_block(&block) == expected_len); + + let copied = block.copy(); + assert!(invoke_int_block(&copied) == expected_len); + } + + #[test] + fn test_concrete_block_stack_copy() { + fn make_block() -> RcBlock<(), i32> { + let x = 7; + let block = ConcreteBlock::new(move || x); + block.copy() + } + + let block = make_block(); + assert!(invoke_int_block(&block) == 7); + } +} +