diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 732858c..817eac0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -23,11 +23,11 @@ jobs: - uses: Swatinem/rust-cache@v2.9.1 - # libmpv for pstr-player. - - name: Install libmpv + # libmpv for pstr-player, WebKitGTK for the CAPTCHA window. + - name: Install libmpv and WebKitGTK run: | sudo apt-get update - sudo apt-get install -y libmpv-dev + sudo apt-get install -y libmpv-dev libwebkit2gtk-4.1-dev - run: cargo fmt --all -- --check diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index a169402..49acb8f 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -76,7 +76,7 @@ jobs: - name: Install build dependencies run: | sudo apt-get update - sudo apt-get install -y libmpv-dev rpm + sudo apt-get install -y libmpv-dev libwebkit2gtk-4.1-dev rpm - name: Gate run: | diff --git a/Cargo.lock b/Cargo.lock index 94260a3..e0164af 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -321,7 +321,7 @@ dependencies = [ "serde", "serde_derive", "unicode-ident", - "winnow", + "winnow 1.0.4", ] [[package]] @@ -453,6 +453,29 @@ dependencies = [ "syn 3.0.3", ] +[[package]] +name = "atk" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "241b621213072e993be4f6f3a9e4b45f65b7e6faad43001be957184b7bb1824b" +dependencies = [ + "atk-sys", + "glib", + "libc", +] + +[[package]] +name = "atk-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c5e48b684b0ca77d2bbadeef17424c2ea3c897d44d566a1617e7e8f30614d086" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + [[package]] name = "atomic-waker" version = "1.1.2" @@ -534,15 +557,30 @@ dependencies = [ "zeroize", ] +[[package]] +name = "bit-set" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" +dependencies = [ + "bit-vec 0.8.0", +] + [[package]] name = "bit-set" version = "0.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "34ddef2995421ab6a5c779542c81ee77c115206f4ad9d5a8e05f4ff49716a3dd" dependencies = [ - "bit-vec", + "bit-vec 0.9.1", ] +[[package]] +name = "bit-vec" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" + [[package]] name = "bit-vec" version = "0.9.1" @@ -749,6 +787,31 @@ dependencies = [ "libbz2-rs-sys", ] +[[package]] +name = "cairo-rs" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ca26ef0159422fb77631dc9d17b102f253b876fe1586b03b803e63a309b4ee2" +dependencies = [ + "bitflags 2.13.1", + "cairo-sys-rs", + "glib", + "libc", + "once_cell", + "thiserror 1.0.69", +] + +[[package]] +name = "cairo-sys-rs" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "685c9fa8e590b8b3d678873528d83411db17242a73fccaed827770ea0fedda51" +dependencies = [ + "glib-sys", + "libc", + "system-deps", +] + [[package]] name = "calloop" version = "0.13.0" @@ -888,6 +951,16 @@ dependencies = [ "cipher 0.4.4", ] +[[package]] +name = "cfg-expr" +version = "0.15.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d067ad48b8650848b989a59a86c6c36a995d02d2bf778d45c3c5d57bc2718f02" +dependencies = [ + "smallvec", + "target-lexicon", +] + [[package]] name = "cfg-if" version = "1.0.4" @@ -982,7 +1055,7 @@ version = "4.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d012d2b9d65aca7f18f4d9878a045bc17899bba951561ba5ec3c2ba1eed9a061" dependencies = [ - "heck", + "heck 0.5.0", "proc-macro2", "quote", "syn 3.0.3", @@ -1054,7 +1127,7 @@ dependencies = [ "bitflags 1.3.2", "block", "core-foundation 0.9.4", - "core-graphics-types", + "core-graphics-types 0.1.3", "libc", "objc", ] @@ -1140,6 +1213,16 @@ dependencies = [ "unicode-segmentation", ] +[[package]] +name = "cookie" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a373e3602691c3cdea496d2f0ee5935151e6168fe87739483c463db1b2f2f87" +dependencies = [ + "time", + "version_check", +] + [[package]] name = "core-foundation" version = "0.9.4" @@ -1174,7 +1257,7 @@ checksum = "2581bbab3b8ffc6fcbd550bf46c355135d16e9ff2a6ea032ad6b9bf1d7efe4fb" dependencies = [ "bitflags 1.3.2", "core-foundation 0.9.4", - "core-graphics-types", + "core-graphics-types 0.1.3", "foreign-types 0.3.2", "libc", ] @@ -1187,7 +1270,20 @@ checksum = "c07782be35f9e1140080c6b96f0d44b739e2278479f64e02fdab4e32dfd8b081" dependencies = [ "bitflags 1.3.2", "core-foundation 0.9.4", - "core-graphics-types", + "core-graphics-types 0.1.3", + "foreign-types 0.5.0", + "libc", +] + +[[package]] +name = "core-graphics" +version = "0.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "064badf302c3194842cf2c5d61f56cc88e54a759313879cdf03abdd27d0c3b97" +dependencies = [ + "bitflags 2.13.1", + "core-foundation 0.10.1", + "core-graphics-types 0.2.0", "foreign-types 0.5.0", "libc", ] @@ -1203,6 +1299,17 @@ dependencies = [ "libc", ] +[[package]] +name = "core-graphics-types" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d44a101f213f6c4cdc1853d4b78aef6db6bdfa3468798cc1d9912f4735013eb" +dependencies = [ + "bitflags 2.13.1", + "core-foundation 0.10.1", + "libc", +] + [[package]] name = "cpubits" version = "0.1.1" @@ -1242,6 +1349,15 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "crossbeam-channel" +version = "0.5.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "98b0cc327b5bc766e7fda9c9260cc0fa81b43a8e240440422dff70788e3f9ef1" +dependencies = [ + "crossbeam-utils", +] + [[package]] name = "crossbeam-utils" version = "0.8.22" @@ -1288,6 +1404,29 @@ dependencies = [ "rand_core 0.10.1", ] +[[package]] +name = "cssparser" +version = "0.37.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c9cdaae01d5ed7882b04d795e7f752f46ff52d2fa3b50a20d28c464510bba98" +dependencies = [ + "cssparser-macros", + "dtoa-short", + "itoa", + "phf", + "smallvec", +] + +[[package]] +name = "cssparser-macros" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d045de693cb712d0b22c6a64be5b953f67b3ce00ab5ad3dd5d8b441886ab8e1a" +dependencies = [ + "quote", + "syn 3.0.3", +] + [[package]] name = "ctr" version = "0.9.2" @@ -1458,6 +1597,12 @@ dependencies = [ "zeroize", ] +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" + [[package]] name = "derive_builder" version = "0.20.2" @@ -1551,7 +1696,16 @@ version = "5.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9a49173b84e034382284f27f1af4dcbbd231ffa358c0fe316541a7337f376a35" dependencies = [ - "dirs-sys", + "dirs-sys 0.4.1", +] + +[[package]] +name = "dirs" +version = "7.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d57d423b3c82e89b9a24ca3091fee61f456a26edbd28d26c65906f4bc1dcd8f" +dependencies = [ + "dirs-sys 0.5.0", ] [[package]] @@ -1562,10 +1716,22 @@ checksum = "520f05a5cbd335fae5a99ff7a6ab8627577660ee5cfd6a94a6a929b52ff0321c" dependencies = [ "libc", "option-ext", - "redox_users", + "redox_users 0.4.6", "windows-sys 0.48.0", ] +[[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 0.5.3", + "windows-sys 0.61.2", +] + [[package]] name = "dispatch" version = "0.2.0" @@ -1579,6 +1745,8 @@ 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", ] @@ -1602,6 +1770,29 @@ dependencies = [ "libloading", ] +[[package]] +name = "dlopen2" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e2c5bd4158e66d1e215c49b837e11d62f3267b30c92f1d171c4d3105e3dc4d4" +dependencies = [ + "dlopen2_derive", + "libc", + "once_cell", + "winapi", +] + +[[package]] +name = "dlopen2_derive" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fbbb781877580993a8707ec48672673ec7b81eeba04cfd2310bd28c08e47c8f" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "document-features" version = "0.2.12" @@ -1611,6 +1802,21 @@ dependencies = [ "litrs", ] +[[package]] +name = "dom_query" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fac5fca71e65e94cc718a6e2af65d6e0f9c6027751c2aa562fbb5087fda639bc" +dependencies = [ + "bit-set 0.8.0", + "cssparser", + "foldhash", + "html5ever", + "precomputed-hash", + "selectors", + "tendril", +] + [[package]] name = "downcast-rs" version = "1.2.1" @@ -1639,6 +1845,21 @@ dependencies = [ "zeroize", ] +[[package]] +name = "dtoa" +version = "1.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c3cf4824e2d5f025c7b531afcb2325364084a16806f6d47fbc1f5fbd9960590" + +[[package]] +name = "dtoa-short" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd1511a7b6a56299bd043a9c167a6d2bfb37bf84a6dfceaba651168adfb43c87" +dependencies = [ + "dtoa", +] + [[package]] name = "dunce" version = "1.0.5" @@ -1875,7 +2096,7 @@ dependencies = [ "cc", "memchr", "rustc_version", - "toml", + "toml 1.1.4+spec-1.1.0", "vswhom", "winreg", ] @@ -2055,6 +2276,16 @@ version = "0.2.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" +[[package]] +name = "field-offset" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38e2275cc4e4fc009b0669731a1e5ab7ebf11f469eaede2bab9309a5b4d6057f" +dependencies = [ + "memoffset", + "rustc_version", +] + [[package]] name = "find-msvc-tools" version = "0.1.9" @@ -2266,6 +2497,78 @@ dependencies = [ "slab", ] +[[package]] +name = "gdk" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9f245958c627ac99d8e529166f9823fb3b838d1d41fd2b297af3075093c2691" +dependencies = [ + "cairo-rs", + "gdk-pixbuf", + "gdk-sys", + "gio", + "glib", + "libc", + "pango", +] + +[[package]] +name = "gdk-pixbuf" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50e1f5f1b0bfb830d6ccc8066d18db35c487b1b2b1e8589b5dfe9f07e8defaec" +dependencies = [ + "gdk-pixbuf-sys", + "gio", + "glib", + "libc", + "once_cell", +] + +[[package]] +name = "gdk-pixbuf-sys" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9839ea644ed9c97a34d129ad56d38a25e6756f99f3a88e15cd39c20629caf7" +dependencies = [ + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "gdk-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c2d13f38594ac1e66619e188c6d5a1adb98d11b2fcf7894fc416ad76aa2f3f7" +dependencies = [ + "cairo-sys-rs", + "gdk-pixbuf-sys", + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "pango-sys", + "pkg-config", + "system-deps", +] + +[[package]] +name = "gdkwayland-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "140071d506d223f7572b9f09b5e155afbd77428cd5cc7af8f2694c41d98dfe69" +dependencies = [ + "gdk-sys", + "glib-sys", + "gobject-sys", + "libc", + "pkg-config", + "system-deps", +] + [[package]] name = "generic-array" version = "0.14.7" @@ -2345,6 +2648,38 @@ dependencies = [ "polyval 0.7.3", ] +[[package]] +name = "gio" +version = "0.18.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4fc8f532f87b79cbc51a79748f16a6828fb784be93145a322fa14d06d354c73" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-util", + "gio-sys", + "glib", + "libc", + "once_cell", + "pin-project-lite", + "smallvec", + "thiserror 1.0.69", +] + +[[package]] +name = "gio-sys" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37566df850baf5e4cb0dfb78af2e4b9898d817ed9263d1090a2df958c64737d2" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps", + "winapi", +] + [[package]] name = "gl_generator" version = "0.14.0" @@ -2356,6 +2691,53 @@ dependencies = [ "xml-rs", ] +[[package]] +name = "glib" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "233daaf6e83ae6a12a52055f568f9d7cf4671dabb78ff9560ab6da230ce00ee5" +dependencies = [ + "bitflags 2.13.1", + "futures-channel", + "futures-core", + "futures-executor", + "futures-task", + "futures-util", + "gio-sys", + "glib-macros", + "glib-sys", + "gobject-sys", + "libc", + "memchr", + "once_cell", + "smallvec", + "thiserror 1.0.69", +] + +[[package]] +name = "glib-macros" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bb0228f477c0900c880fd78c8759b95c7636dbd7842707f49e132378aa2acdc" +dependencies = [ + "heck 0.4.1", + "proc-macro-crate 2.0.2", + "proc-macro-error", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "glib-sys" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "063ce2eb6a8d0ea93d2bf8ba1957e78dbab6be1c2220dd3daca57d5a9d869898" +dependencies = [ + "libc", + "system-deps", +] + [[package]] name = "glifo" version = "0.1.1" @@ -2456,6 +2838,17 @@ dependencies = [ "gl_generator", ] +[[package]] +name = "gobject-sys" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0850127b514d1c4a4654ead6dedadb18198999985908e6ffe4436f53c785ce44" +dependencies = [ + "glib-sys", + "libc", + "system-deps", +] + [[package]] name = "goblin" version = "0.8.2" @@ -2478,6 +2871,58 @@ dependencies = [ "subtle", ] +[[package]] +name = "gtk" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd56fb197bfc42bd5d2751f4f017d44ff59fbb58140c6b49f9b3b2bdab08506a" +dependencies = [ + "atk", + "cairo-rs", + "field-offset", + "futures-channel", + "gdk", + "gdk-pixbuf", + "gio", + "glib", + "gtk-sys", + "gtk3-macros", + "libc", + "pango", + "pkg-config", +] + +[[package]] +name = "gtk-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f29a1c21c59553eb7dd40e918be54dccd60c52b049b75119d5d96ce6b624414" +dependencies = [ + "atk-sys", + "cairo-sys-rs", + "gdk-pixbuf-sys", + "gdk-sys", + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "pango-sys", + "system-deps", +] + +[[package]] +name = "gtk3-macros" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ff3c5b21f14f0736fed6dcfc0bfb4225ebf5725f3c0209edeec181e4d73e9d" +dependencies = [ + "proc-macro-crate 1.3.1", + "proc-macro-error", + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "guillotiere" version = "0.7.0" @@ -2566,6 +3011,12 @@ dependencies = [ "hashbrown 0.14.5", ] +[[package]] +name = "heck" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8" + [[package]] name = "heck" version = "0.5.0" @@ -2626,6 +3077,16 @@ dependencies = [ "digest 0.11.3", ] +[[package]] +name = "html5ever" +version = "0.39.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46a1761807faccc9a19e86944bbf40610014066306f96edcdedc2fb714bcb7b8" +dependencies = [ + "log", + "markup5ever", +] + [[package]] name = "http" version = "1.5.0" @@ -2960,6 +3421,29 @@ version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" +[[package]] +name = "javascriptcore-rs" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca5671e9ffce8ffba57afc24070e906da7fc4b1ba66f2cabebf61bf2ea257fcc" +dependencies = [ + "bitflags 1.3.2", + "glib", + "javascriptcore-rs-sys", +] + +[[package]] +name = "javascriptcore-rs-sys" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af1be78d14ffa4b75b66df31840478fef72b51f8c2465d4ca7c194da9f7a5124" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + [[package]] name = "jni" version = "0.21.1" @@ -3265,6 +3749,17 @@ dependencies = [ "libc", ] +[[package]] +name = "markup5ever" +version = "0.39.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7122d987ec5f704ee56f6e5b41a7d93722e9aae27ae07cafa4036c4d3f9757de" +dependencies = [ + "log", + "tendril", + "web_atoms", +] + [[package]] name = "matchers" version = "0.2.0" @@ -3368,7 +3863,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b2bf919621e7975acb27d881bae2fb993e0d45c8e0446e85e6272971e00dc8df" dependencies = [ "arrayvec", - "bit-set", + "bit-set 0.9.1", "bitflags 2.13.1", "cfg-if", "cfg_aliases", @@ -3416,6 +3911,12 @@ dependencies = [ "jni-sys 0.3.1", ] +[[package]] +name = "new_debug_unreachable" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" + [[package]] name = "nix" version = "0.29.0" @@ -3523,6 +4024,12 @@ dependencies = [ "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-integer" version = "0.1.46" @@ -3579,7 +4086,7 @@ version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "680998035259dcfcafe653688bf2aa6d3e2dc05e98be6ab46afb089dc84f1df8" dependencies = [ - "proc-macro-crate", + "proc-macro-crate 3.5.0", "proc-macro2", "quote", "syn 2.0.119", @@ -3617,6 +4124,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3a12a8ed07aefc768292f076dc3ac8c48f3781c8f2d5851dd3d98950e8c5a89f" dependencies = [ "objc2-encode", + "objc2-exception-helper", ] [[package]] @@ -3629,10 +4137,10 @@ dependencies = [ "block2 0.5.1", "libc", "objc2 0.5.2", - "objc2-core-data", - "objc2-core-image", + "objc2-core-data 0.2.2", + "objc2-core-image 0.2.2", "objc2-foundation 0.2.2", - "objc2-quartz-core", + "objc2-quartz-core 0.2.2", ] [[package]] @@ -3657,10 +4165,21 @@ dependencies = [ "bitflags 2.13.1", "block2 0.5.1", "objc2 0.5.2", - "objc2-core-location", + "objc2-core-location 0.2.2", "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" @@ -3684,6 +4203,16 @@ dependencies = [ "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 = [ + "objc2 0.6.4", + "objc2-foundation 0.3.2", +] + [[package]] name = "objc2-core-foundation" version = "0.3.2" @@ -3720,6 +4249,16 @@ dependencies = [ "objc2-metal", ] +[[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" @@ -3732,12 +4271,43 @@ dependencies = [ "objc2-foundation 0.2.2", ] +[[package]] +name = "objc2-core-location" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca347214e24bc973fc025fd0d36ebb179ff30536ed1f80252706db19ee452009" +dependencies = [ + "objc2 0.6.4", + "objc2-foundation 0.3.2", +] + +[[package]] +name = "objc2-core-text" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cde0dfb48d25d2b4862161a4d5fcc0e3c24367869ad306b0c9ec0073bfed92d" +dependencies = [ + "bitflags 2.13.1", + "objc2 0.6.4", + "objc2-core-foundation", + "objc2-core-graphics", +] + [[package]] name = "objc2-encode" version = "4.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" +[[package]] +name = "objc2-exception-helper" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7a1c5fbb72d7735b076bb47b578523aedc40f3c439bea6dfd595c089d79d98a" +dependencies = [ + "cc", +] + [[package]] name = "objc2-foundation" version = "0.2.2" @@ -3811,6 +4381,18 @@ dependencies = [ "objc2-metal", ] +[[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", +] + [[package]] name = "objc2-symbols" version = "0.2.2" @@ -3830,16 +4412,16 @@ dependencies = [ "bitflags 2.13.1", "block2 0.5.1", "objc2 0.5.2", - "objc2-cloud-kit", - "objc2-core-data", - "objc2-core-image", - "objc2-core-location", + "objc2-cloud-kit 0.2.2", + "objc2-core-data 0.2.2", + "objc2-core-image 0.2.2", + "objc2-core-location 0.2.2", "objc2-foundation 0.2.2", "objc2-link-presentation", - "objc2-quartz-core", + "objc2-quartz-core 0.2.2", "objc2-symbols", "objc2-uniform-type-identifiers", - "objc2-user-notifications", + "objc2-user-notifications 0.2.2", ] [[package]] @@ -3849,9 +4431,18 @@ 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-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-location 0.3.2", + "objc2-core-text", "objc2-foundation 0.3.2", + "objc2-quartz-core 0.3.2", + "objc2-user-notifications 0.3.2", ] [[package]] @@ -3874,10 +4465,34 @@ dependencies = [ "bitflags 2.13.1", "block2 0.5.1", "objc2 0.5.2", - "objc2-core-location", + "objc2-core-location 0.2.2", "objc2-foundation 0.2.2", ] +[[package]] +name = "objc2-user-notifications" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9df9128cbbfef73cda168416ccf7f837b62737d748333bfe9ab71c245d76613e" +dependencies = [ + "objc2 0.6.4", + "objc2-foundation 0.3.2", +] + +[[package]] +name = "objc2-web-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2e5aaab980c433cf470df9d7af96a7b46a9d892d521a2cbbb2f8a4c16751e7f" +dependencies = [ + "bitflags 2.13.1", + "block2 0.6.2", + "objc2 0.6.4", + "objc2-app-kit 0.3.2", + "objc2-core-foundation", + "objc2-foundation 0.3.2", +] + [[package]] name = "ocb3" version = "0.1.0" @@ -3978,6 +4593,31 @@ dependencies = [ "sha2 0.10.9", ] +[[package]] +name = "pango" +version = "0.18.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ca27ec1eb0457ab26f3036ea52229edbdb74dee1edd29063f5b9b010e7ebee4" +dependencies = [ + "gio", + "glib", + "libc", + "once_cell", + "pango-sys", +] + +[[package]] +name = "pango-sys" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "436737e391a843e5933d6d9aa102cb126d501e815b83601365a948a518555dc5" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + [[package]] name = "parking" version = "2.2.1" @@ -4116,6 +4756,59 @@ dependencies = [ "zeroize", ] +[[package]] +name = "phf" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf" +dependencies = [ + "phf_macros", + "phf_shared", + "serde", +] + +[[package]] +name = "phf_codegen" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49aa7f9d80421bca176ca8dbfebe668cc7a2684708594ec9f3c0db0805d5d6e1" +dependencies = [ + "phf_generator", + "phf_shared", +] + +[[package]] +name = "phf_generator" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "135ace3a761e564ec88c03a77317a7c6b80bb7f7135ef2544dbe054243b89737" +dependencies = [ + "fastrand", + "phf_shared", +] + +[[package]] +name = "phf_macros" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "812f032b54b1e759ccd5f8b6677695d5268c588701effba24601f6932f8269ef" +dependencies = [ + "phf_generator", + "phf_shared", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "phf_shared" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e57fef6bc5981e38c2ce2d63bfa546861309f875b8a75f092d1d54ae2d64f266" +dependencies = [ + "siphasher", +] + [[package]] name = "pin-project" version = "1.1.13" @@ -4269,6 +4962,12 @@ dependencies = [ "zerovec", ] +[[package]] +name = "powerfmt" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a6394b9e965e73d0a289ee54f589087e2c676aedf60885baf52c76b771e4958" + [[package]] name = "ppv-lite86" version = "0.2.21" @@ -4278,6 +4977,12 @@ dependencies = [ "zerocopy", ] +[[package]] +name = "precomputed-hash" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c" + [[package]] name = "primeorder" version = "0.13.6" @@ -4287,13 +4992,57 @@ dependencies = [ "elliptic-curve", ] +[[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 = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b00f26d3400549137f92511a46ac1cd8ce37cb5598a96d382381458b992a5d24" +dependencies = [ + "toml_datetime 0.6.3", + "toml_edit 0.20.2", +] + [[package]] name = "proc-macro-crate" version = "3.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" dependencies = [ - "toml_edit", + "toml_edit 0.25.13+spec-1.1.0", +] + +[[package]] +name = "proc-macro-error" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da25490ff9892aab3fcf7c36f08cfb902dd3e71ca0f9f9517bea02a73a5ce38c" +dependencies = [ + "proc-macro-error-attr", + "proc-macro2", + "quote", + "syn 1.0.109", + "version_check", +] + +[[package]] +name = "proc-macro-error-attr" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1be40180e52ecc98ad80b184934baf3d0d29f979574e439af5a55274b35f869" +dependencies = [ + "proc-macro2", + "quote", + "version_check", ] [[package]] @@ -4416,11 +5165,13 @@ dependencies = [ "sha2 0.10.9", "skrifa", "souvlaki", + "tao", "tauri-winrt-notification", "tokio", "tracing", "tracing-subscriber", "windows-sys 0.61.2", + "wry", ] [[package]] @@ -4721,6 +5472,16 @@ dependencies = [ "thiserror 1.0.69", ] +[[package]] +name = "redox_users" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60dc65c0ff1a7ae1294b0c67b9f14baf70b644404010370171787bfac1038fc0" +dependencies = [ + "libredox", + "thiserror 2.0.19", +] + [[package]] name = "regex" version = "1.13.1" @@ -5148,6 +5909,25 @@ dependencies = [ "libc", ] +[[package]] +name = "selectors" +version = "0.38.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8adfa1c298912827b8a28b223b3b874357397ae706e6190acd9bf28cee99114d" +dependencies = [ + "bitflags 2.13.1", + "cssparser", + "derive_more", + "log", + "new_debug_unreachable", + "phf", + "phf_codegen", + "precomputed-hash", + "rustc-hash 2.1.3", + "servo_arc", + "smallvec", +] + [[package]] name = "self_cell" version = "1.3.0" @@ -5218,6 +5998,15 @@ dependencies = [ "syn 3.0.3", ] +[[package]] +name = "serde_spanned" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" +dependencies = [ + "serde", +] + [[package]] name = "serde_spanned" version = "1.1.1" @@ -5259,6 +6048,15 @@ dependencies = [ "serde", ] +[[package]] +name = "servo_arc" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "170fb83ab34de17dc69aa7c67482b22218ddb85da56546f9bd6b929e32a05930" +dependencies = [ + "stable_deref_trait", +] + [[package]] name = "sha1" version = "0.10.7" @@ -5511,7 +6309,7 @@ version = "0.9.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "287f59010008f0d7cf5e3b03196d666c1acc46c8d3e9cf34c28a1a7157601e72" dependencies = [ - "heck", + "heck 0.5.0", "proc-macro2", "quote", "syn 2.0.119", @@ -5527,6 +6325,32 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "soup3" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "471f924a40f31251afc77450e781cb26d55c0b650842efafc9c6cbd2f7cc4f9f" +dependencies = [ + "futures-channel", + "gio", + "glib", + "libc", + "soup3-sys", +] + +[[package]] +name = "soup3-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ebe8950a680a12f24f15ebe1bf70db7af98ad242d9db43596ad3108aab86c27" +dependencies = [ + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + [[package]] name = "souvlaki" version = "0.8.3" @@ -5573,6 +6397,30 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" +[[package]] +name = "string_cache" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a18596f8c785a729f2819c0f6a7eae6ebeebdfffbfe4214ae6b087f690e31901" +dependencies = [ + "new_debug_unreachable", + "parking_lot", + "phf_shared", + "precomputed-hash", +] + +[[package]] +name = "string_cache_codegen" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "585635e46db231059f76c5849798146164652513eb9e8ab2685939dd90f29b69" +dependencies = [ + "phf_generator", + "phf_shared", + "proc-macro2", + "quote", +] + [[package]] name = "strsim" version = "0.11.1" @@ -5585,6 +6433,16 @@ version = "2.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" +[[package]] +name = "syn" +version = "1.0.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +dependencies = [ + "proc-macro2", + "unicode-ident", +] + [[package]] name = "syn" version = "2.0.119" @@ -5627,12 +6485,79 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "system-deps" +version = "6.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3e535eb8dded36d55ec13eddacd30dec501792ff23a0b1682c38601b8cf2349" +dependencies = [ + "cfg-expr", + "heck 0.5.0", + "pkg-config", + "toml 0.8.2", + "version-compare", +] + +[[package]] +name = "tao" +version = "0.37.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f37f381f4e048e6cdf038b5705f8cf14ad108279d46eb968140a7b291aba9400" +dependencies = [ + "bitflags 2.13.1", + "block2 0.6.2", + "core-foundation 0.10.1", + "core-graphics 0.25.0", + "crossbeam-channel", + "dispatch2", + "dlopen2", + "dpi", + "gdkwayland-sys", + "gtk", + "jni 0.21.1", + "libc", + "log", + "ndk", + "ndk-context", + "ndk-sys", + "objc2 0.6.4", + "objc2-app-kit 0.3.2", + "objc2-foundation 0.3.2", + "objc2-ui-kit 0.3.2", + "parking_lot", + "percent-encoding", + "raw-window-handle", + "tao-macros", + "unicode-segmentation", + "url", + "windows 0.62.2", + "windows-core", + "windows-version", +] + +[[package]] +name = "tao-macros" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f7eeb6d99155545da6150a1795945f16ac9c178deb2a5f2e74d776107bd5849" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "tap" version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369" +[[package]] +name = "target-lexicon" +version = "0.12.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c41af27dd6d1e27b1b16b489db798443478cef1f06a660c96db617ba5de3b1" + [[package]] name = "tauri-winrt-notification" version = "0.8.1" @@ -5657,6 +6582,15 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "tendril" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fed54709c5b3a53d09bb1c113ea4f5ceafd1e772ddcb0030a82e1d56c087b08" +dependencies = [ + "new_debug_unreachable", +] + [[package]] name = "textwrap" version = "0.16.2" @@ -5729,6 +6663,36 @@ dependencies = [ "zune-jpeg", ] +[[package]] +name = "time" +version = "0.3.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134" +dependencies = [ + "deranged", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" + +[[package]] +name = "time-macros" +version = "0.2.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e689342a48d2ea927c87ea50cabf8594854bf940e9310208848d680d668ed85" +dependencies = [ + "num-conv", + "time-core", +] + [[package]] name = "tinystr" version = "0.8.3" @@ -5804,6 +6768,18 @@ dependencies = [ "tokio", ] +[[package]] +name = "toml" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "185d8ab0dfbb35cf1399a6344d8484209c088f75f8f68230da55d48d95d43e3d" +dependencies = [ + "serde", + "serde_spanned 0.6.9", + "toml_datetime 0.6.3", + "toml_edit 0.20.2", +] + [[package]] name = "toml" version = "1.1.4+spec-1.1.0" @@ -5812,11 +6788,20 @@ checksum = "3aace63f4bbcdfc2c965b059de67119c89c4017a70d633be6c104910f67056f5" dependencies = [ "indexmap", "serde_core", - "serde_spanned", - "toml_datetime", + "serde_spanned 1.1.1", + "toml_datetime 1.1.1+spec-1.1.0", "toml_parser", "toml_writer", - "winnow", + "winnow 1.0.4", +] + +[[package]] +name = "toml_datetime" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cda73e2f1397b1262d6dfdcef8aafae14d1de7748d66822d3bfeeb6d03e5e4b" +dependencies = [ + "serde", ] [[package]] @@ -5828,6 +6813,30 @@ 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.3", + "winnow 0.5.40", +] + +[[package]] +name = "toml_edit" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "396e4d48bbb2b7554c944bde63101b5ae446cff6ec4a24227428f15eb72ef338" +dependencies = [ + "indexmap", + "serde", + "serde_spanned 0.6.9", + "toml_datetime 0.6.3", + "winnow 0.5.40", +] + [[package]] name = "toml_edit" version = "0.25.13+spec-1.1.0" @@ -5835,9 +6844,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6975367e4d2ef766d86af01ffad14b622fecc8d4357a998fbc4deb6e9bacaf9b" dependencies = [ "indexmap", - "toml_datetime", + "toml_datetime 1.1.1+spec-1.1.0", "toml_parser", - "winnow", + "winnow 1.0.4", ] [[package]] @@ -5846,7 +6855,7 @@ version = "1.1.3+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d38ac1cf9b95face32296c0a3ede1fdc270627c9d9c02a7274dd6d960dc4d56" dependencies = [ - "winnow", + "winnow 1.0.4", ] [[package]] @@ -6073,13 +7082,13 @@ dependencies = [ "fs-err", "glob", "goblin", - "heck", + "heck 0.5.0", "indexmap", "once_cell", "serde", "tempfile", "textwrap", - "toml", + "toml 1.1.4+spec-1.1.0", "uniffi_internal_macros", "uniffi_meta", "uniffi_pipeline", @@ -6124,7 +7133,7 @@ dependencies = [ "quote", "serde", "syn 2.0.119", - "toml", + "toml 1.1.4+spec-1.1.0", "uniffi_meta", ] @@ -6147,7 +7156,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3f8201bb1907ed8a42d80e11cbc25c8a033e7a31c3cff1d911f56eedb81d4948" dependencies = [ "anyhow", - "heck", + "heck 0.5.0", "indexmap", "tempfile", "uniffi_internal_macros", @@ -6261,6 +7270,12 @@ dependencies = [ "vello_common", ] +[[package]] +name = "version-compare" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03c2856837ef78f57382f06b2b8563a2f512f7185d732608fd9176cb3b8edf0e" + [[package]] name = "version_check" version = "0.9.5" @@ -6544,6 +7559,18 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "web_atoms" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba8b815c1b593dc0baf78dd0f4fc8fdb2de53198fb1163738093e9a311c33fb3" +dependencies = [ + "phf", + "phf_codegen", + "string_cache", + "string_cache_codegen", +] + [[package]] name = "webbrowser" version = "1.2.2" @@ -6560,6 +7587,50 @@ dependencies = [ "web-sys", ] +[[package]] +name = "webkit2gtk" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1027150013530fb2eaf806408df88461ae4815a45c541c8975e61d6f2fc4793" +dependencies = [ + "bitflags 1.3.2", + "cairo-rs", + "gdk", + "gdk-sys", + "gio", + "gio-sys", + "glib", + "glib-sys", + "gobject-sys", + "gtk", + "gtk-sys", + "javascriptcore-rs", + "libc", + "once_cell", + "soup3", + "webkit2gtk-sys", +] + +[[package]] +name = "webkit2gtk-sys" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "916a5f65c2ef0dfe12fff695960a2ec3d4565359fdbb2e9943c974e06c734ea5" +dependencies = [ + "bitflags 1.3.2", + "cairo-sys-rs", + "gdk-sys", + "gio-sys", + "glib-sys", + "gobject-sys", + "gtk-sys", + "javascriptcore-rs-sys", + "libc", + "pkg-config", + "soup3-sys", + "system-deps", +] + [[package]] name = "webpki-root-certs" version = "1.0.9" @@ -6578,6 +7649,40 @@ dependencies = [ "rustls-pki-types", ] +[[package]] +name = "webview2-com" +version = "0.39.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f89fca7a704cee10dcb3654c1dbb8941d1783132f1917358af75bec37a7d7e6" +dependencies = [ + "webview2-com-macros", + "webview2-com-sys", + "windows 0.62.2", + "windows-core", +] + +[[package]] +name = "webview2-com-macros" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67a921c1b6914c367b2b823cd4cde6f96beec77d30a939c8199bb377cf9b9b54" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "webview2-com-sys" +version = "0.39.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3a07132775117d6065853d9d1178157b8c90e228de47129d6bce2c7edebedfb" +dependencies = [ + "thiserror 2.0.19", + "windows 0.62.2", + "windows-core", +] + [[package]] name = "weedle2" version = "5.0.0" @@ -6628,8 +7733,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2f519832254e56965a9940c4af57dcb75f702b6f6fa4a0b172f685395843a4d7" dependencies = [ "arrayvec", - "bit-set", - "bit-vec", + "bit-set 0.9.1", + "bit-vec 0.9.1", "bitflags 2.13.1", "bytemuck", "cfg_aliases", @@ -6706,6 +7811,22 @@ dependencies = [ "web-sys", ] +[[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" @@ -6715,6 +7836,12 @@ 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" @@ -7191,6 +8318,15 @@ dependencies = [ "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" @@ -7222,6 +8358,48 @@ version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" +[[package]] +name = "wry" +version = "0.57.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a819957a01b3119af85e638a38d242af76dbc87d130dca67bfd0441072e21ff0" +dependencies = [ + "base64 0.22.1", + "block2 0.6.2", + "cookie", + "crossbeam-channel", + "dirs", + "dom_query", + "dpi", + "dunce", + "gtk", + "http", + "javascriptcore-rs", + "jni 0.21.1", + "libc", + "ndk", + "objc2 0.6.4", + "objc2-app-kit 0.3.2", + "objc2-core-foundation", + "objc2-foundation 0.3.2", + "objc2-ui-kit 0.3.2", + "objc2-web-kit", + "once_cell", + "percent-encoding", + "raw-window-handle", + "sha2 0.10.9", + "soup3", + "tao-macros", + "thiserror 2.0.19", + "url", + "webkit2gtk", + "webkit2gtk-sys", + "webview2-com", + "windows 0.62.2", + "windows-core", + "windows-version", +] + [[package]] name = "wyz" version = "0.5.1" @@ -7377,7 +8555,7 @@ version = "4.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "267db9407081e90bbfa46d841d3cbc60f59c0351838c4bc65199ecd79ab1983e" dependencies = [ - "proc-macro-crate", + "proc-macro-crate 3.5.0", "proc-macro2", "quote", "syn 2.0.119", @@ -7535,7 +8713,7 @@ version = "4.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "73e2ba546bda683a90652bac4a279bc146adad1386f25379cf73200d2002c449" dependencies = [ - "proc-macro-crate", + "proc-macro-crate 3.5.0", "proc-macro2", "quote", "syn 2.0.119", diff --git a/Cargo.toml b/Cargo.toml index e6772cb..698b21b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -107,6 +107,12 @@ windows-sys = "0.61" tauri-winrt-notification = "0.8" # The exe's own icon resource, which is what Explorer and a taskbar pin draw. embed-resource = "3" +# Proton's CAPTCHA page, which reports success by posting a message to its +# host, so it needs a webview this app controls. Run in a child process of its +# own (`pstr-app::verify`): tao and eframe's winit cannot share an event loop. +# WebKitGTK on Linux, WebView2 on Windows, WKWebView on macOS. +wry = { version = "0.57", default-features = false, features = ["os-webview"] } +tao = { version = "0.37", default-features = false, features = ["rwh_06"] } rpassword = "7" libloading = "0.8" lru = "0.18" diff --git a/README.md b/README.md index 08d7de9..e608d09 100644 --- a/README.md +++ b/README.md @@ -83,7 +83,9 @@ Rust 2024, MSRV 1.96. cargo build --release ``` -`pstr-player` needs libmpv development headers (`mpv` ≥ 2.0). On Windows those +`pstr-player` needs libmpv development headers (`mpv` ≥ 2.0), and on Linux +`pstr-app` needs WebKitGTK 4.1's, for the window Proton's CAPTCHA is shown in. +On Windows the libmpv ones come from the separate **mpv-dev** archive rather than mpv's player build, and `libmpv-2.dll` ships next to the executable. The Proton SDK crates come from crates.io; nothing here builds against a sibling checkout. diff --git a/android/app/src/main/java/io/narl/protonstream/MainActivity.kt b/android/app/src/main/java/io/narl/protonstream/MainActivity.kt index a177b9c..29f53a6 100644 --- a/android/app/src/main/java/io/narl/protonstream/MainActivity.kt +++ b/android/app/src/main/java/io/narl/protonstream/MainActivity.kt @@ -33,6 +33,7 @@ import io.narl.protonstream.playback.MpvPlaybackState import io.narl.protonstream.playback.NativeMpvHost import io.narl.protonstream.playback.PlaybackService import io.narl.protonstream.settings.SettingsStore +import io.narl.protonstream.sync.WatchSyncWorker import io.narl.protonstream.ui.ProtonStreamApp import io.narl.protonstream.ui.theme.AppearanceState import io.narl.protonstream.ui.theme.ProtonStreamTheme @@ -207,6 +208,9 @@ class MainActivity : ComponentActivity() { override fun onStop() { super.onStop() + // Where the viewer stopped is what their other devices want next, and + // a process in the background may not live to the next timed sync. + if (!isChangingConfigurations) WatchSyncWorker.enqueue(this) if ( !isChangingConfigurations && !isInPictureInPictureMode && diff --git a/android/app/src/main/java/io/narl/protonstream/sync/WatchSyncWorker.kt b/android/app/src/main/java/io/narl/protonstream/sync/WatchSyncWorker.kt new file mode 100644 index 0000000..7167bb3 --- /dev/null +++ b/android/app/src/main/java/io/narl/protonstream/sync/WatchSyncWorker.kt @@ -0,0 +1,56 @@ +package io.narl.protonstream.sync + +import android.content.Context +import androidx.work.BackoffPolicy +import androidx.work.Constraints +import androidx.work.CoroutineWorker +import androidx.work.ExistingWorkPolicy +import androidx.work.NetworkType +import androidx.work.OneTimeWorkRequestBuilder +import androidx.work.WorkManager +import androidx.work.WorkerParameters +import io.narl.protonstream.native.NativeRuntime +import uniffi.pstr_android.AccountState +import java.util.concurrent.TimeUnit + +/** + * One watch-history sync after the app leaves the screen. + * + * The model's timer only runs while the app is alive, and Android is free to + * kill a process in the background without notice — so the position the + * viewer stopped at would wait for the next launch to reach their other + * devices. Handed to WorkManager instead, it goes as soon as there is a + * network, even if the process is gone by then. + */ +class WatchSyncWorker( + appContext: Context, + parameters: WorkerParameters, +) : CoroutineWorker(appContext, parameters) { + override suspend fun doWork(): Result { + val engine = NativeRuntime.engine() + return runCatching { engine.syncWatchHistory() }.fold( + onSuccess = { Result.success() }, + onFailure = { + // Signed out by the engine — the session ended — is not + // something a retry fixes; a dropped connection is. + val signedOut = runCatching { engine.accountState() }.getOrNull() == AccountState.SignedOut + if (signedOut || runAttemptCount >= MAX_ATTEMPTS) Result.failure() else Result.retry() + }, + ) + } + + companion object { + private const val WORK_NAME = "watch-history-sync" + private const val MAX_ATTEMPTS = 3 + + /** Replacing: one pending sync covers every position saved before it runs. */ + fun enqueue(context: Context) { + val request = OneTimeWorkRequestBuilder() + .setConstraints(Constraints.Builder().setRequiredNetworkType(NetworkType.CONNECTED).build()) + .setBackoffCriteria(BackoffPolicy.EXPONENTIAL, 30, TimeUnit.SECONDS) + .build() + WorkManager.getInstance(context.applicationContext) + .enqueueUniqueWork(WORK_NAME, ExistingWorkPolicy.REPLACE, request) + } + } +} diff --git a/android/app/src/main/java/io/narl/protonstream/ui/AccountUi.kt b/android/app/src/main/java/io/narl/protonstream/ui/AccountUi.kt index 6ebd997..94b1e5c 100644 --- a/android/app/src/main/java/io/narl/protonstream/ui/AccountUi.kt +++ b/android/app/src/main/java/io/narl/protonstream/ui/AccountUi.kt @@ -194,13 +194,16 @@ internal fun SignInDialog(account: AccountUiState, actions: AccountActions, onDi text = { Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { when (state) { - AccountState.SecondFactor -> OutlinedTextField( - code, - { code = it }, - label = { Text("Two-factor code") }, - singleLine = true, - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.NumberPassword), - ) + AccountState.SecondFactor -> { + OutlinedTextField( + code, + { code = it }, + label = { Text("Code from your authenticator app") }, + singleLine = true, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.NumberPassword), + ) + Text(SECURITY_KEY_NOTE, style = MaterialTheme.typography.bodySmall) + } AccountState.MailboxPassword -> { Text( "This account has a separate mailbox password. It unlocks your files.", @@ -329,6 +332,11 @@ private fun VerificationDialog(url: String, onToken: (String) -> Unit, onDismiss } } +/** Security keys need WebAuthn, which the sign-in here does not speak. */ +private const val SECURITY_KEY_NOTE = + "Security keys are not supported. If your account has only a security key, add an " + + "authenticator app in your Proton account settings to sign in here." + /** Installed once per page, at start and again at finish in case the first ran too early. */ private const val VERIFICATION_BRIDGE = """ if (!window.__pstrVerification) { diff --git a/android/app/src/main/java/io/narl/protonstream/ui/SharesScreen.kt b/android/app/src/main/java/io/narl/protonstream/ui/SharesScreen.kt index d075cde..4db8fcf 100644 --- a/android/app/src/main/java/io/narl/protonstream/ui/SharesScreen.kt +++ b/android/app/src/main/java/io/narl/protonstream/ui/SharesScreen.kt @@ -136,15 +136,15 @@ internal fun SharesScreen( ) } removing?.let { share -> - // Asked, because it cannot be undone: the link's secret, its offline - // files and its watch positions all go (`Catalog::remove_share`). + // Asked, because it cannot be undone: the link's secret and its + // offline files go. Watch positions stay (`Catalog::remove_share`). AlertDialog( onDismissRequest = { removing = null }, title = { Text("Remove ${share.name}?") }, text = { Text( - "Its titles leave the library, and its offline episodes and watch " + - "progress are deleted. Adding the link again starts it fresh.", + "Its titles leave the library and its offline episodes are deleted. " + + "Watch progress is kept, so adding the link again picks up where you left off.", ) }, confirmButton = { diff --git a/crates/pstr-android/src/lib.rs b/crates/pstr-android/src/lib.rs index e6e3612..a24ba14 100644 --- a/crates/pstr-android/src/lib.rs +++ b/crates/pstr-android/src/lib.rs @@ -3809,11 +3809,12 @@ mod tests { assert!(engine.remove_share(share.id.clone()).is_err()); assert_eq!(engine.share_generation.load(Ordering::Acquire), 2); assert!(engine.shares().expect("shares").is_empty()); + // Kept, as through a recrawl: sync would bring it back regardless. assert!( engine .watch_state(share.id, "episode".to_owned()) .expect("watch state") - .is_none() + .is_some() ); drop(engine); diff --git a/crates/pstr-app/Cargo.toml b/crates/pstr-app/Cargo.toml index 7580863..63e7597 100644 --- a/crates/pstr-app/Cargo.toml +++ b/crates/pstr-app/Cargo.toml @@ -33,6 +33,8 @@ serde_json.workspace = true directories.workspace = true chrono.workspace = true souvlaki.workspace = true +wry.workspace = true +tao.workspace = true [build-dependencies] embed-resource.workspace = true diff --git a/crates/pstr-app/src/app.rs b/crates/pstr-app/src/app.rs index 2377d81..386edf2 100644 --- a/crates/pstr-app/src/app.rs +++ b/crates/pstr-app/src/app.rs @@ -2118,6 +2118,9 @@ impl eframe::App for App { self.playback = None; self.engine .shutdown_downloads(std::time::Duration::from_secs(3)); + // After the player: its last position is saved on the way out. + self.engine + .sync_before_exit(std::time::Duration::from_secs(3)); } } diff --git a/crates/pstr-app/src/engine.rs b/crates/pstr-app/src/engine.rs index 63b536d..309df27 100644 --- a/crates/pstr-app/src/engine.rs +++ b/crates/pstr-app/src/engine.rs @@ -35,6 +35,7 @@ use pstr_core::library::{Library, Title, TitleKind}; use pstr_core::metadata::{EpisodeGuide, MetadataConfig, MetadataRecord, ProviderId}; use pstr_core::prefs::PlaybackPrefs; use pstr_core::proton_drive_rs::ThumbnailType; +use pstr_core::proton_sdk::api::HumanVerificationCredential; use pstr_core::proton_sdk::ids::{LinkId, NodeUid, VolumeId}; use pstr_core::shares::AccountFolder; use pstr_core::sync::{SyncReport, WatchSync}; @@ -704,17 +705,38 @@ impl Engine { pub fn sign_in(&self, username: String, password: String) { let engine = self.clone(); self.runtime.spawn(async move { - match engine.accounts.sign_in(&username, &password, None).await { - Ok(step) => engine.sign_in_step(step), - Err(pstr_core::Error::HumanVerification(_)) => { - engine.emit(Event::SignInFailed( - "Proton wants a CAPTCHA before it accepts this sign-in, and this app \ - cannot show one. Try again later, or sign in once from the Android app." - .to_owned(), - )); + let mut verification = None; + // Twice at most: once as asked, and once more with a solved + // CAPTCHA if Proton wanted one. A second gate after that is + // Proton refusing the answer, not a puzzle worth repeating. + for _ in 0..2 { + match engine + .accounts + .sign_in(&username, &password, verification.take()) + .await + { + Ok(step) => return engine.sign_in_step(step), + Err(pstr_core::Error::HumanVerification(challenge)) => { + let url = challenge.verification_url(); + let token = tokio::task::spawn_blocking(move || crate::verify::solve(&url)) + .await + .ok() + .flatten(); + let Some(token) = token else { + return engine.emit(Event::SignInFailed( + "Proton wants you to confirm you are human before it accepts \ + this sign-in. Sign in again to solve the puzzle." + .to_owned(), + )); + }; + verification = Some(HumanVerificationCredential::captcha(token)); + } + Err(error) => return engine.emit(Event::SignInFailed(error.to_string())), } - Err(error) => engine.emit(Event::SignInFailed(error.to_string())), } + engine.emit(Event::SignInFailed( + "Proton did not accept the verification. Try again in a while.".to_owned(), + )); }); } @@ -841,6 +863,27 @@ impl Engine { }); } + /// Sync once more before the app exits, waiting at most `timeout`. + /// + /// Where the viewer stopped is what another device wants next, and the + /// timer would not get to it. Bounded, because quitting must not hang on + /// a slow network; a sync cut short loses nothing, the next launch sends + /// the same table. + pub fn sync_before_exit(&self, timeout: std::time::Duration) { + let (Some(account), Some(sync)) = (self.account.lock().clone(), self.watch_sync.clone()) + else { + return; + }; + let catalog = Arc::clone(&self.catalog); + self.runtime.block_on(async move { + match tokio::time::timeout(timeout, sync.sync(&account, &catalog)).await { + Ok(Ok(_)) => {} + Ok(Err(error)) => tracing::warn!("sync watch history on exit: {error}"), + Err(_) => tracing::warn!("sync watch history on exit: timed out"), + } + }); + } + fn sync_periodically(&self) { let engine = self.clone(); self.runtime.spawn(async move { diff --git a/crates/pstr-app/src/lib.rs b/crates/pstr-app/src/lib.rs index 18d51c7..4bb765f 100644 --- a/crates/pstr-app/src/lib.rs +++ b/crates/pstr-app/src/lib.rs @@ -30,5 +30,6 @@ pub mod pacing; pub mod playback; pub mod theme; pub mod ui; +pub mod verify; pub mod video; pub mod window; diff --git a/crates/pstr-app/src/main.rs b/crates/pstr-app/src/main.rs index c1261bf..491787d 100644 --- a/crates/pstr-app/src/main.rs +++ b/crates/pstr-app/src/main.rs @@ -16,6 +16,12 @@ use anyhow::Context; use pstr_core::config::AppDirs; fn main() -> anyhow::Result<()> { + // Before anything else: a verification window is a child process of the + // app, and must not claim the single instance or open a library. + if let Some(code) = pstr_app::verify::run_if_requested() { + std::process::exit(code); + } + tracing_subscriber::fmt() .with_env_filter( tracing_subscriber::EnvFilter::try_from_default_env() diff --git a/crates/pstr-app/src/ui/account.rs b/crates/pstr-app/src/ui/account.rs index d35b618..839ba64 100644 --- a/crates/pstr-app/src/ui/account.rs +++ b/crates/pstr-app/src/ui/account.rs @@ -207,6 +207,12 @@ fn code_form(ui: &mut egui::Ui, panel: &mut AccountPanel, actions: &mut Vec Option { + let mut args = std::env::args().skip(1); + if args.next().as_deref() != Some(ARG) { + return None; + } + let Some(url) = args.next().filter(|url| is_proton_page(url)) else { + eprintln!("{ARG} needs a verification page on proton.me"); + return Some(2); + }; + Some(match window(&url) { + Ok(()) => 0, + Err(error) => { + eprintln!("the verification window failed: {error}"); + 1 + } + }) +} + +/// Present `url` in the verification window and wait for the token it yields. +/// +/// `None` when the viewer closed the window, or it could not be shown. +/// Blocking: call it off the UI thread and outside the async runtime. +pub fn solve(url: &str) -> Option { + let exe = std::env::current_exe() + .inspect_err(|error| tracing::warn!("locate this executable: {error}")) + .ok()?; + let mut child = std::process::Command::new(exe) + .arg(ARG) + .arg(url) + .stdin(std::process::Stdio::null()) + .stdout(std::process::Stdio::piped()) + .spawn() + .inspect_err(|error| tracing::warn!("open the verification window: {error}")) + .ok()?; + let mut out = String::new(); + if let Some(mut stdout) = child.stdout.take() { + let _ = stdout.read_to_string(&mut out); + } + let status = child.wait().ok()?; + if !status.success() { + tracing::warn!("the verification window exited with {status}"); + return None; + } + out.lines() + .map(str::trim) + .rfind(|line| !line.is_empty()) + .map(str::to_owned) +} + +fn window(url: &str) -> anyhow::Result<()> { + use tao::event::{Event, WindowEvent}; + use tao::event_loop::{ControlFlow, EventLoopBuilder}; + use tao::window::WindowBuilder; + use wry::{NewWindowResponse, WebViewBuilder}; + + let event_loop = EventLoopBuilder::::with_user_event().build(); + let window = WindowBuilder::new() + .with_title("Confirm you are human — Proton Stream") + .with_inner_size(tao::dpi::LogicalSize::new(440.0, 680.0)) + .build(&event_loop)?; + + let proxy = event_loop.create_proxy(); + let builder = WebViewBuilder::new() + .with_url(url) + .with_initialization_script(BRIDGE) + .with_ipc_handler(move |request| { + // Every message the page posts lands here, most of them its own + // chatter; only a completion carries a token. + if let Some(token) = pstr_core::account::verification_token(request.body()) { + let _ = proxy.send_event(token); + } + }) + // The bridge above is reachable by whatever the page shows, so the + // page stays on Proton. + .with_navigation_handler(|url| is_allowed_navigation(&url)) + .with_new_window_req_handler(|_, _| NewWindowResponse::Deny); + + #[cfg(not(any(target_os = "windows", target_os = "macos")))] + let _webview = { + use tao::platform::unix::WindowExtUnix as _; + use wry::WebViewBuilderExtUnix as _; + let vbox = window + .default_vbox() + .ok_or_else(|| anyhow::anyhow!("the window has no GTK container"))?; + builder.build_gtk(vbox)? + }; + #[cfg(any(target_os = "windows", target_os = "macos"))] + let _webview = builder.build(&window)?; + + let mut answered = false; + event_loop.run(move |event, _, control_flow| { + *control_flow = ControlFlow::Wait; + match event { + Event::UserEvent(token) if !answered => { + answered = true; + let mut stdout = std::io::stdout().lock(); + let _ = writeln!(stdout, "{token}"); + let _ = stdout.flush(); + *control_flow = ControlFlow::Exit; + } + Event::WindowEvent { + event: WindowEvent::CloseRequested, + .. + } => *control_flow = ControlFlow::Exit, + _ => {} + } + }) +} + +/// Whether `url` is a page of Proton's own, over HTTPS. +fn is_proton_page(url: &str) -> bool { + let Some(rest) = url.strip_prefix("https://") else { + return false; + }; + let authority = rest.split(['/', '?', '#']).next().unwrap_or_default(); + // A user-info part would let `proton.me@evil.example` pass. + if authority.contains('@') { + return false; + } + let host = authority.split(':').next().unwrap_or_default(); + host == "proton.me" || host.ends_with(".proton.me") +} + +/// Whether the webview may go to `url`: Proton's pages, and the documents a +/// page makes for itself, which have no origin of their own to leave to. +fn is_allowed_navigation(url: &str) -> bool { + is_proton_page(url) || url == "about:blank" || url.starts_with("about:srcdoc") +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn only_proton_pages_over_https_are_proton_pages() { + assert!(is_proton_page( + "https://verify.proton.me/?embed=1&methods=captcha&token=x" + )); + assert!(is_proton_page("https://proton.me")); + assert!(!is_proton_page("http://verify.proton.me/")); + assert!(!is_proton_page("https://proton.me.evil.example/")); + assert!(!is_proton_page("https://evilproton.me/")); + assert!(!is_proton_page("https://proton.me@evil.example/")); + assert!(!is_proton_page( + "https://evil.example/?next=https://proton.me" + )); + } + + #[test] + fn the_window_stays_on_proton() { + assert!(is_allowed_navigation( + "https://verify-api.proton.me/core/v4/captcha" + )); + assert!(is_allowed_navigation("about:blank")); + assert!(!is_allowed_navigation("https://example.com/")); + assert!(!is_allowed_navigation("file:///etc/passwd")); + } +} diff --git a/crates/pstr-cli/src/main.rs b/crates/pstr-cli/src/main.rs index 5de460d..de76b76 100644 --- a/crates/pstr-cli/src/main.rs +++ b/crates/pstr-cli/src/main.rs @@ -1084,7 +1084,6 @@ async fn play( Ok(()) } -/// Unix seconds, for the "when was this asked" columns. /// Walk an interactive sign-in through whatever the account asks for. async fn login(accounts: &AccountStore, username: &str) -> Result<()> { let password = read_password("Password: ")?; @@ -1092,10 +1091,12 @@ async fn login(accounts: &AccountStore, username: &str) -> Result<()> { Ok(step) => step, Err(pstr_core::Error::HumanVerification(challenge)) => { // Solving a CAPTCHA needs a browser engine that can hand the - // token back, which a terminal does not have. + // token back, which a terminal does not have. The desktop app has + // one, and keeps its session in the same keyring entry. anyhow::bail!( "Proton wants a CAPTCHA before it accepts this sign-in. Sign in once in the \ - desktop or Android app, or try again later. ({})", + desktop app on this computer, which shows it and shares its session with \ + pstr, or try again later. ({})", challenge.verification_url() ); } @@ -1108,7 +1109,8 @@ async fn login(accounts: &AccountStore, username: &str) -> Result<()> { return Ok(()); } SignIn::SecondFactor(pending) => { - let code = read_password("Two-factor code: ")?; + // Security keys need WebAuthn, which this sign-in does not speak. + let code = read_password("Code from your authenticator app: ")?; pending .second_factor(&code) .await diff --git a/crates/pstr-core/src/catalog.rs b/crates/pstr-core/src/catalog.rs index 8b5f912..3a86da0 100644 --- a/crates/pstr-core/src/catalog.rs +++ b/crates/pstr-core/src/catalog.rs @@ -513,19 +513,17 @@ impl Catalog { Ok(rows.collect::>()?) } - /// Drop everything a share contributed, watch positions included. + /// Drop everything a share contributed except where it was watched. /// - /// Unlike a recrawl — which replaces rows and deliberately leaves watch - /// state alone — this is the viewer saying they are done with the share. - /// Keeping positions for files they can no longer reach would only leave - /// the "continue watching" row pointing at nothing. + /// Watch state stays, as through a recrawl. It is history, and not only + /// this device's: with an account signed in, the next sync would bring it + /// straight back from the other devices' files, so deleting it here would + /// only make it flicker. Nothing shows it — the library pairs positions + /// with files, and the files are gone — until the share is added again, + /// when it picks up where it left off. pub fn remove_share(&mut self, share_id: &str) -> Result<()> { let tx = self.conn.transaction()?; tx.execute("DELETE FROM nodes WHERE share_id = ?1", params![share_id])?; - tx.execute( - "DELETE FROM watch_state WHERE share_id = ?1", - params![share_id], - )?; tx.execute( "DELETE FROM offline_files WHERE share_id = ?1", params![share_id], @@ -2430,6 +2428,38 @@ mod tests { assert_eq!(rows[0].link_id, "film"); } + #[test] + fn removing_a_share_keeps_its_watch_state_out_of_sight() { + let mut catalog = Catalog::in_memory().expect("open"); + catalog + .replace_share("s1", &[file("s1", "a", "Show S01E01.mkv")]) + .expect("crawl"); + let state = WatchState { + position_secs: 120.0, + duration_secs: Some(1440.0), + watched: false, + updated_at: 10, + }; + catalog.set_watch_state("s1", "a", &state).expect("set"); + + catalog.remove_share("s1").expect("remove"); + let library = crate::library::Library::build( + catalog.all_files().expect("files"), + &catalog.all_watch_states().expect("watch"), + ); + assert!(library.titles.is_empty()); + assert_eq!(catalog.watch_state("s1", "a").expect("read"), Some(state)); + + catalog + .replace_share("s1", &[file("s1", "a", "Show S01E01.mkv")]) + .expect("re-add"); + let library = crate::library::Library::build( + catalog.all_files().expect("files"), + &catalog.all_watch_states().expect("watch"), + ); + assert_eq!(library.titles[0].seasons[0].episodes[0].watch, Some(state)); + } + #[test] fn merging_takes_only_states_newer_than_the_recorded_ones() { let mut catalog = Catalog::in_memory().expect("open"); diff --git a/crates/pstr-core/src/shares.rs b/crates/pstr-core/src/shares.rs index 528003c..099fb23 100644 --- a/crates/pstr-core/src/shares.rs +++ b/crates/pstr-core/src/shares.rs @@ -55,19 +55,26 @@ pub trait SecretStore: Send + Sync { } /// The desktop OS credential store. +/// +/// A value too long for one entry is split across several; see +/// [`ENTRY_UNITS`]. #[cfg(not(target_os = "android"))] #[derive(Debug, Default)] pub struct KeyringSecretStore; +/// One credential-store entry per key, with no splitting. #[cfg(not(target_os = "android"))] -impl KeyringSecretStore { +struct KeyringEntries; + +#[cfg(not(target_os = "android"))] +impl KeyringEntries { fn entry(key: &str) -> Result { Ok(keyring::Entry::new(KEYRING_SERVICE, key)?) } } #[cfg(not(target_os = "android"))] -impl SecretStore for KeyringSecretStore { +impl SecretStore for KeyringEntries { fn set(&self, key: &str, value: &str) -> Result<()> { Self::entry(key)?.set_password(value)?; Ok(()) @@ -89,6 +96,205 @@ impl SecretStore for KeyringSecretStore { } } +#[cfg(not(target_os = "android"))] +impl SecretStore for KeyringSecretStore { + fn set(&self, key: &str, value: &str) -> Result<()> { + chunked::set(&KeyringEntries, key, value) + } + + fn get(&self, key: &str) -> Result> { + chunked::get(&KeyringEntries, key) + } + + fn delete(&self, key: &str) -> Result<()> { + chunked::delete(&KeyringEntries, key) + } +} + +/// The most UTF-16 code units one credential-store entry is given. +/// +/// Windows caps a credential's blob at 2560 bytes, and `keyring` stores it as +/// UTF-16: 1280 units. A share secret is far below that, but the account +/// session — tokens, scopes, the mailbox password and a salt per key — can +/// pass it, and failing to store a refreshed token signs the viewer out at +/// the next launch. The margin is for the header. +#[cfg(any(not(target_os = "android"), test))] +const ENTRY_UNITS: usize = 1200; + +/// Splitting a long secret across several entries of an underlying store. +/// +/// A short value is one entry, exactly as before splitting existed, so every +/// secret already stored still reads. A long one is written as `key#0`, +/// `key#1`, … with `key` itself holding [`HEADER`] and the count. The parts go +/// first and the header last, so a reader never finds a header naming parts +/// that are not there yet. +#[cfg(any(not(target_os = "android"), test))] +mod chunked { + use super::{ENTRY_UNITS, SecretStore}; + use crate::error::Result; + + /// Marks a split value. A short value that happens to start with it is + /// split too, so it cannot be mistaken for a header. + const HEADER: &str = "pstr-chunked:"; + + fn part(key: &str, index: usize) -> String { + format!("{key}#{index}") + } + + fn parts_of(header: &str) -> Option { + header.strip_prefix(HEADER)?.parse().ok() + } + + /// `value` cut into pieces of at most [`ENTRY_UNITS`] UTF-16 units, never + /// inside a character. + fn split(value: &str) -> Vec<&str> { + let mut pieces = Vec::new(); + let mut start = 0; + let mut units = 0; + for (at, ch) in value.char_indices() { + if units + ch.len_utf16() > ENTRY_UNITS { + pieces.push(&value[start..at]); + start = at; + units = 0; + } + units += ch.len_utf16(); + } + pieces.push(&value[start..]); + pieces + } + + pub(super) fn set(store: &dyn SecretStore, key: &str, value: &str) -> Result<()> { + let before = store.get(key)?.as_deref().and_then(parts_of).unwrap_or(0); + let fits = value.encode_utf16().count() <= ENTRY_UNITS && !value.starts_with(HEADER); + let written = if fits { + store.set(key, value)?; + 0 + } else { + let pieces = split(value); + for (index, piece) in pieces.iter().enumerate() { + store.set(&part(key, index), piece)?; + } + store.set(key, &format!("{HEADER}{}", pieces.len()))?; + pieces.len() + }; + for index in written..before { + store.delete(&part(key, index))?; + } + Ok(()) + } + + pub(super) fn get(store: &dyn SecretStore, key: &str) -> Result> { + let Some(value) = store.get(key)? else { + return Ok(None); + }; + let Some(count) = parts_of(&value) else { + return Ok(Some(value)); + }; + let mut joined = String::new(); + for index in 0..count { + // A part gone missing is a value that no longer exists whole; + // half of a token is worse than none. + let Some(piece) = store.get(&part(key, index))? else { + return Ok(None); + }; + joined.push_str(&piece); + } + Ok(Some(joined)) + } + + pub(super) fn delete(store: &dyn SecretStore, key: &str) -> Result<()> { + let count = store.get(key)?.as_deref().and_then(parts_of).unwrap_or(0); + store.delete(key)?; + for index in 0..count { + store.delete(&part(key, index))?; + } + Ok(()) + } + + #[cfg(test)] + mod tests { + use std::collections::BTreeMap; + + use parking_lot::Mutex; + + use super::*; + + /// An underlying store that refuses what Windows would. + #[derive(Default)] + struct Limited(Mutex>); + + impl SecretStore for Limited { + fn set(&self, key: &str, value: &str) -> Result<()> { + assert!( + value.encode_utf16().count() <= 1280, + "{key} is too long for one entry" + ); + self.0.lock().insert(key.to_owned(), value.to_owned()); + Ok(()) + } + fn get(&self, key: &str) -> Result> { + Ok(self.0.lock().get(key).cloned()) + } + fn delete(&self, key: &str) -> Result<()> { + self.0.lock().remove(key); + Ok(()) + } + } + + #[test] + fn a_short_secret_is_one_entry_as_it_always_was() { + let store = Limited::default(); + set(&store, "share-a", "fragment").unwrap(); + assert_eq!(store.0.lock().len(), 1); + assert_eq!(get(&store, "share-a").unwrap().as_deref(), Some("fragment")); + } + + #[test] + fn a_secret_longer_than_an_entry_round_trips() { + let store = Limited::default(); + let long: String = "aé😀".repeat(1500); + set(&store, "proton-account", &long).unwrap(); + assert!(store.0.lock().len() > 2); + assert_eq!(get(&store, "proton-account").unwrap(), Some(long)); + } + + #[test] + fn a_shorter_value_leaves_no_parts_of_the_longer_one_behind() { + let store = Limited::default(); + set(&store, "k", &"x".repeat(5000)).unwrap(); + set(&store, "k", &"y".repeat(1300)).unwrap(); + assert_eq!(store.0.lock().len(), 3); + set(&store, "k", "short").unwrap(); + assert_eq!(store.0.lock().len(), 1); + assert_eq!(get(&store, "k").unwrap().as_deref(), Some("short")); + } + + #[test] + fn a_value_that_looks_like_a_header_is_not_taken_for_one() { + let store = Limited::default(); + set(&store, "k", "pstr-chunked:3").unwrap(); + assert_eq!(get(&store, "k").unwrap().as_deref(), Some("pstr-chunked:3")); + } + + #[test] + fn deleting_a_split_secret_deletes_every_part() { + let store = Limited::default(); + set(&store, "k", &"x".repeat(5000)).unwrap(); + delete(&store, "k").unwrap(); + assert!(store.0.lock().is_empty()); + assert_eq!(get(&store, "k").unwrap(), None); + } + + #[test] + fn a_split_secret_missing_a_part_reads_as_absent() { + let store = Limited::default(); + set(&store, "k", &"x".repeat(5000)).unwrap(); + store.delete("k#1").unwrap(); + assert_eq!(get(&store, "k").unwrap(), None); + } + } +} + /// What the app records about a share, minus its secrets. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct Share { diff --git a/crates/pstr-core/src/sync.rs b/crates/pstr-core/src/sync.rs index 1b231a1..611e5d8 100644 --- a/crates/pstr-core/src/sync.rs +++ b/crates/pstr-core/src/sync.rs @@ -32,7 +32,7 @@ use std::collections::HashMap; use parking_lot::Mutex; -use proton_drive_rs::{DeviceType, NodeKind}; +use proton_drive_rs::{DeviceType, NodeKind, ProtonDriveClient}; use proton_sdk::ids::NodeUid; use serde::{Deserialize, Serialize}; @@ -120,12 +120,133 @@ pub struct SyncReport { pub uploaded: bool, /// Other installations whose history was read. pub devices: usize, + /// Files of installations gone quiet that were moved to the trash, their + /// history now carried in this installation's own file. + pub retired: usize, } -/// A history file as last read, by the revision it was read at. +/// How long an installation's file may go unwritten before another +/// installation retires it. +/// +/// Reinstalling — routine on Android, where uninstalling wipes the config — +/// starts a new id, and every file ever written would otherwise be read by +/// every device forever. Retiring is safe at any age: a file is only trashed +/// after its entries were merged and this installation's file, which then +/// holds them too, is written. An installation that was merely idle finds its +/// file gone on its next sync and writes it again. +const RETIRE_AFTER_SECS: i64 = 90 * 24 * 60 * 60; + +/// One history file in Drive, as listed. +#[derive(Debug, Clone)] +pub(crate) struct RemoteFile { + pub uid: NodeUid, + pub name: String, + /// The active revision, which moves with every write. + pub revision: Option, + /// Last modification, epoch seconds. + pub modified: i64, +} + +/// The few Drive operations sync needs: the account's Drive in the app, an +/// in-memory one in the tests. +pub(crate) trait HistoryDrive: Sync { + /// The history folders, creating the device and folder the first time. + /// See [`WatchSync::folders`]. + fn history_folders(&self) -> impl Future>> + Send; + /// The untrashed `.json` files directly in `folder`. + fn list(&self, folder: &NodeUid) -> impl Future>> + Send; + fn download(&self, uid: &NodeUid) -> impl Future>> + Send; + fn create( + &self, + folder: &NodeUid, + name: &str, + bytes: &[u8], + ) -> impl Future> + Send; + fn replace(&self, uid: &NodeUid, bytes: &[u8]) -> impl Future> + Send; + fn trash(&self, uids: &[NodeUid]) -> impl Future> + Send; +} + +impl HistoryDrive for ProtonDriveClient { + /// Two installations signing in at the same moment can each create the + /// device before seeing the other's. Every device of that name is read + /// from so neither's history is lost; the oldest is the one written to, + /// which is the same one on every installation. + async fn history_folders(&self) -> Result> { + let mut devices: Vec<_> = self + .enumerate_devices() + .await? + .into_iter() + .filter(|device| device.name.as_deref().ok() == Some(DEVICE_NAME)) + .collect(); + devices.sort_by_key(|device| device.creation_time); + if devices.is_empty() { + devices.push(self.create_device(DEVICE_NAME, device_type()).await?); + } + let mut folders = Vec::with_capacity(devices.len()); + for device in &devices { + folders.push( + self.create_folder_path(&device.root_folder_uid, FOLDER) + .await?, + ); + } + Ok(folders) + } + + async fn list(&self, folder: &NodeUid) -> Result> { + let uids = self.enumerate_folder_children_node_uids(folder).await?; + if uids.is_empty() { + return Ok(Vec::new()); + } + Ok(self + .enumerate_nodes(&uids) + .await? + .into_iter() + .filter_map(|node| { + let NodeKind::File { + active_revision_id, .. + } = node.kind + else { + return None; + }; + (!node.trashed && node.name.ends_with(".json")).then_some(RemoteFile { + uid: node.uid, + name: node.name, + revision: active_revision_id, + modified: node.modification_time, + }) + }) + .collect()) + } + + async fn download(&self, uid: &NodeUid) -> Result> { + Ok(self.download_file(uid).await?) + } + + async fn create(&self, folder: &NodeUid, name: &str, bytes: &[u8]) -> Result<()> { + self.upload_file(folder, name, "application/json", bytes) + .await?; + Ok(()) + } + + async fn replace(&self, uid: &NodeUid, bytes: &[u8]) -> Result<()> { + Ok(self.upload_new_revision(uid, bytes).await?) + } + + async fn trash(&self, uids: &[NodeUid]) -> Result<()> { + for (uid, outcome) in self.trash_nodes(uids).await? { + if let Err(error) = outcome { + tracing::warn!(%uid, %error, "a retired watch-history file was not trashed"); + } + } + Ok(()) + } +} + +/// A history file as last read, by the revision it was read at. `None` +/// entries for one this build could not read. struct Seen { revision: Option, - entries: Vec, + entries: Option>, } /// Syncs the catalog's watch table with the account's Drive. @@ -139,7 +260,7 @@ pub struct WatchSync { /// the catalog so an unchanged history is not uploaded as a new revision. own: Mutex>>, /// The history folders, resolved once per account. Usually one; see - /// [`WatchSync::folders`]. + /// [`HistoryDrive::history_folders`]. folders: Mutex>>, /// One sync at a time: two would race on creating the device and the /// installation's file. @@ -182,59 +303,69 @@ impl WatchSync { *self.folders.lock() = None; } - /// Pull every other installation's history into `catalog`, then write - /// this one's if it changed. + /// Pull every other installation's history into `catalog`, write this + /// one's if it changed, then retire the files of installations gone quiet. /// /// The catalog is locked only around its own reads and writes, never /// across a request, so playback can keep saving its position while this /// runs. pub async fn sync(&self, account: &Account, catalog: &Mutex) -> Result { + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map_or(0, |since| since.as_secs() as i64); + self.sync_with(account.drive(), catalog, now).await + } + + pub(crate) async fn sync_with( + &self, + drive: &D, + catalog: &Mutex, + now: i64, + ) -> Result { let _running = self.running.lock().await; - let drive = account.drive(); - let folders = self.folders(account).await?; + let folders = self.folders(drive).await?; let own_name = format!("{}.json", self.installation); let mut own_file = None; - let mut remote = Vec::new(); - let mut devices = 0; + let mut others = Vec::new(); for folder in &folders { - let uids = drive.enumerate_folder_children_node_uids(folder).await?; - if uids.is_empty() { - continue; - } - for node in drive.enumerate_nodes(&uids).await? { - let NodeKind::File { - active_revision_id, .. - } = &node.kind - else { - continue; - }; - if node.trashed || !node.name.ends_with(".json") { - continue; + for file in drive.list(folder).await? { + if file.name == own_name { + own_file.get_or_insert(file); + } else { + others.push(file); } - if node.name == own_name { - if own_file.is_none() { - own_file = Some((node.uid.clone(), active_revision_id.clone())); - } - continue; - } - devices += 1; - remote.extend( - self.read(account, &node.uid, active_revision_id.clone()) - .await, - ); } } + let mut remote = Vec::new(); + // Files whose every entry has been read, and so can be retired once + // this installation's file carries them. + let mut readable = Vec::new(); + for file in &others { + if let Some(entries) = self.read(drive, file).await { + remote.extend(entries); + readable.push(file); + } + } + + // What this installation wrote before is history too: after a + // reinstall that kept the config, or a catalog that was deleted. One + // that cannot be read now is left alone rather than overwritten with + // less than it holds, and read again next time. + let mut own_unreadable = false; if self.own.lock().is_none() { let entries = match &own_file { - Some((uid, revision)) => self.read(account, uid, revision.clone()).await, - None => Vec::new(), + Some(file) => self.read(drive, file).await, + None => Some(Vec::new()), }; - // What this installation wrote before is history too: after a - // reinstall that kept the config, or a catalog that was deleted. - remote.extend(entries.iter().cloned()); - *self.own.lock() = Some(entries); + match entries { + Some(entries) => { + remote.extend(entries.iter().cloned()); + *self.own.lock() = Some(entries); + } + None => own_unreadable = true, + } } let incoming: Vec<(String, String, WatchState)> = remote @@ -254,8 +385,12 @@ impl WatchSync { .collect(); entries.sort_by(|a, b| (&a.share, &a.link).cmp(&(&b.share, &b.link))); - let unchanged = self.own.lock().as_ref() == Some(&entries); - let uploaded = if unchanged || (entries.is_empty() && own_file.is_none()) { + // A file that is missing is written even when what it would hold has + // not changed: another installation retired it, or it was deleted by + // hand, and either way the history it carried lives only here now. + let unchanged = own_file.is_some() && self.own.lock().as_ref() == Some(&entries); + let uploaded = if own_unreadable || unchanged || (entries.is_empty() && own_file.is_none()) + { false } else { let file = HistoryFile { @@ -266,94 +401,91 @@ impl WatchSync { let bytes = serde_json::to_vec(&file) .map_err(|e| Error::Config(format!("serialize watch history: {e}")))?; match &own_file { - Some((uid, _)) => drive.upload_new_revision(uid, &bytes).await?, - None => { - drive - .upload_file(&folders[0], &own_name, "application/json", &bytes) - .await?; - } + Some(file) => drive.replace(&file.uid, &bytes).await?, + None => drive.create(&folders[0], &own_name, &bytes).await?, } *self.own.lock() = Some(entries); true }; + // Only now: every entry of a retired file was merged above, and this + // installation's file — just written, or already up to date — holds + // the merged table. + let retired: Vec = readable + .into_iter() + .filter(|file| !own_unreadable && now - file.modified >= RETIRE_AFTER_SECS) + .map(|file| file.uid.clone()) + .collect(); + if !retired.is_empty() { + match drive.trash(&retired).await { + Ok(()) => { + let mut seen = self.seen.lock(); + for uid in &retired { + seen.remove(uid.link_id.as_str()); + } + } + Err(error) => { + tracing::warn!(%error, "retiring old watch-history files failed"); + } + } + } + Ok(SyncReport { applied, uploaded, - devices, + devices: others.len(), + retired: retired.len(), }) } /// The entries of one history file, from the cache when its revision has - /// not moved. + /// not moved, or `None` when it cannot be read. /// - /// A file that will not download or parse is logged and read as empty: it - /// is another device's, and one bad file must not stop the rest syncing. - async fn read( + /// A file that will not download or parse is logged and skipped: it is + /// another device's, and one bad file must not stop the rest syncing. + async fn read( &self, - account: &Account, - uid: &NodeUid, - revision: Option, - ) -> Vec { - let key = uid.link_id.as_str().to_owned(); + drive: &D, + file: &RemoteFile, + ) -> Option> { + let key = file.uid.link_id.as_str().to_owned(); if let Some(seen) = self.seen.lock().get(&key) - && revision.is_some() - && seen.revision == revision + && file.revision.is_some() + && seen.revision == file.revision { return seen.entries.clone(); } - let entries = match account.drive().download_file(uid).await { - Ok(bytes) => match parse(&bytes) { - Some(entries) => entries, - None => { + let uid = &file.uid; + let entries = match drive.download(uid).await { + Ok(bytes) => { + let entries = parse(&bytes); + if entries.is_none() { tracing::warn!(%uid, "skipping a watch-history file this build cannot read"); - Vec::new() } - }, + entries + } Err(error) => { + // Not cached: a download that failed is tried again next time. tracing::warn!(%uid, %error, "a watch-history file could not be downloaded"); - return Vec::new(); + return None; } }; self.seen.lock().insert( key, Seen { - revision, + revision: file.revision.clone(), entries: entries.clone(), }, ); entries } - /// The history folders, creating the device and folder the first time. - /// - /// Two installations signing in at the same moment can each create the - /// device before seeing the other's. Every device of that name is read - /// from so neither's history is lost; the oldest is the one written to, - /// which is the same one on every installation. - async fn folders(&self, account: &Account) -> Result> { + /// The history folders, resolved once per account. + async fn folders(&self, drive: &D) -> Result> { if let Some(folders) = self.folders.lock().clone() { return Ok(folders); } - let drive = account.drive(); - let mut devices: Vec<_> = drive - .enumerate_devices() - .await? - .into_iter() - .filter(|device| device.name.as_deref().ok() == Some(DEVICE_NAME)) - .collect(); - devices.sort_by_key(|device| device.creation_time); - if devices.is_empty() { - devices.push(drive.create_device(DEVICE_NAME, device_type()).await?); - } - let mut folders = Vec::with_capacity(devices.len()); - for device in &devices { - folders.push( - drive - .create_folder_path(&device.root_folder_uid, FOLDER) - .await?, - ); - } + let folders = drive.history_folders().await?; *self.folders.lock() = Some(folders.clone()); Ok(folders) } @@ -398,6 +530,383 @@ mod tests { } } + /// One history folder in memory, standing in for the account's Drive. + #[derive(Default)] + struct FakeDrive { + files: Mutex>, + /// What the next write is stamped with, epoch seconds. + clock: std::sync::atomic::AtomicI64, + /// Downloads fail while set, as on a dropped connection. + offline: std::sync::atomic::AtomicBool, + uploads: std::sync::atomic::AtomicUsize, + } + + struct FakeFile { + uid: NodeUid, + name: String, + bytes: Vec, + revision: u32, + modified: i64, + trashed: bool, + } + + fn uid(link: &str) -> NodeUid { + NodeUid::new("volume".to_owned().into(), link.to_owned().into()) + } + + impl FakeDrive { + fn at(&self, now: i64) { + self.clock.store(now, std::sync::atomic::Ordering::SeqCst); + } + + fn names(&self) -> Vec { + let mut names: Vec<_> = self + .files + .lock() + .iter() + .filter(|file| !file.trashed) + .map(|file| file.name.clone()) + .collect(); + names.sort(); + names + } + + fn uploads(&self) -> usize { + self.uploads.load(std::sync::atomic::Ordering::SeqCst) + } + + fn put(&self, name: &str, bytes: Vec) { + let modified = self.clock.load(std::sync::atomic::Ordering::SeqCst); + let mut files = self.files.lock(); + let link = format!("file-{}", files.len()); + files.push(FakeFile { + uid: uid(&link), + name: name.to_owned(), + bytes, + revision: 1, + modified, + trashed: false, + }); + } + } + + impl HistoryDrive for FakeDrive { + async fn history_folders(&self) -> Result> { + Ok(vec![uid("folder")]) + } + + async fn list(&self, _folder: &NodeUid) -> Result> { + Ok(self + .files + .lock() + .iter() + .filter(|file| !file.trashed) + .map(|file| RemoteFile { + uid: file.uid.clone(), + name: file.name.clone(), + revision: Some(file.revision.to_string()), + modified: file.modified, + }) + .collect()) + } + + async fn download(&self, uid: &NodeUid) -> Result> { + if self.offline.load(std::sync::atomic::Ordering::SeqCst) { + return Err(Error::NotFound("offline".to_owned())); + } + self.files + .lock() + .iter() + .find(|file| &file.uid == uid) + .map(|file| file.bytes.clone()) + .ok_or_else(|| Error::NotFound(uid.to_string())) + } + + async fn create(&self, _folder: &NodeUid, name: &str, bytes: &[u8]) -> Result<()> { + self.uploads + .fetch_add(1, std::sync::atomic::Ordering::SeqCst); + self.put(name, bytes.to_vec()); + Ok(()) + } + + async fn replace(&self, uid: &NodeUid, bytes: &[u8]) -> Result<()> { + self.uploads + .fetch_add(1, std::sync::atomic::Ordering::SeqCst); + let modified = self.clock.load(std::sync::atomic::Ordering::SeqCst); + let mut files = self.files.lock(); + let file = files + .iter_mut() + .find(|file| &file.uid == uid) + .ok_or_else(|| Error::NotFound(uid.to_string()))?; + file.bytes = bytes.to_vec(); + file.revision += 1; + file.modified = modified; + Ok(()) + } + + async fn trash(&self, uids: &[NodeUid]) -> Result<()> { + for file in self.files.lock().iter_mut() { + if uids.contains(&file.uid) { + file.trashed = true; + } + } + Ok(()) + } + } + + /// An installation: its sync and its catalog. + fn device(id: &str) -> (WatchSync, Mutex) { + ( + WatchSync::with_installation(id.to_owned()), + Mutex::new(Catalog::in_memory().unwrap()), + ) + } + + fn watched_at(position_secs: f64, updated_at: i64) -> WatchState { + WatchState { + position_secs, + duration_secs: Some(1440.0), + watched: false, + updated_at, + } + } + + const DAY: i64 = 24 * 60 * 60; + + #[tokio::test] + async fn a_position_saved_on_one_device_resumes_on_another() { + let drive = FakeDrive::default(); + let (laptop, laptop_catalog) = device("laptop"); + let (phone, phone_catalog) = device("phone"); + laptop_catalog + .lock() + .set_watch_state("share-a", "ep1", &watched_at(600.0, 100)) + .unwrap(); + + let first = laptop + .sync_with(&drive, &laptop_catalog, 100) + .await + .unwrap(); + assert!(first.uploaded); + let second = phone.sync_with(&drive, &phone_catalog, 110).await.unwrap(); + + assert_eq!(second.applied, 1); + assert_eq!(second.devices, 1); + assert_eq!( + phone_catalog.lock().watch_state("share-a", "ep1").unwrap(), + Some(watched_at(600.0, 100)) + ); + assert_eq!(drive.names(), ["laptop.json", "phone.json"]); + } + + #[tokio::test] + async fn the_newer_position_wins_whichever_device_syncs_first() { + let drive = FakeDrive::default(); + let (laptop, laptop_catalog) = device("laptop"); + let (phone, phone_catalog) = device("phone"); + laptop_catalog + .lock() + .set_watch_state("share-a", "ep1", &watched_at(300.0, 100)) + .unwrap(); + phone_catalog + .lock() + .set_watch_state("share-a", "ep1", &watched_at(900.0, 200)) + .unwrap(); + + laptop + .sync_with(&drive, &laptop_catalog, 300) + .await + .unwrap(); + phone.sync_with(&drive, &phone_catalog, 300).await.unwrap(); + laptop + .sync_with(&drive, &laptop_catalog, 300) + .await + .unwrap(); + + for catalog in [&laptop_catalog, &phone_catalog] { + assert_eq!( + catalog.lock().watch_state("share-a", "ep1").unwrap(), + Some(watched_at(900.0, 200)) + ); + } + } + + #[tokio::test] + async fn an_unchanged_history_is_not_uploaded_again() { + let drive = FakeDrive::default(); + let (laptop, catalog) = device("laptop"); + catalog + .lock() + .set_watch_state("share-a", "ep1", &watched_at(60.0, 100)) + .unwrap(); + + laptop.sync_with(&drive, &catalog, 100).await.unwrap(); + let again = laptop.sync_with(&drive, &catalog, 200).await.unwrap(); + + assert!(!again.uploaded); + assert_eq!(drive.uploads(), 1); + } + + #[tokio::test] + async fn nothing_is_uploaded_before_anything_was_watched() { + let drive = FakeDrive::default(); + let (laptop, catalog) = device("laptop"); + let report = laptop.sync_with(&drive, &catalog, 100).await.unwrap(); + assert!(!report.uploaded); + assert!(drive.names().is_empty()); + } + + #[tokio::test] + async fn a_file_that_went_missing_is_written_again() { + let drive = FakeDrive::default(); + let (laptop, catalog) = device("laptop"); + catalog + .lock() + .set_watch_state("share-a", "ep1", &watched_at(60.0, 100)) + .unwrap(); + laptop.sync_with(&drive, &catalog, 100).await.unwrap(); + + let own: Vec = drive.files.lock().iter().map(|f| f.uid.clone()).collect(); + drive.trash(&own).await.unwrap(); + let report = laptop.sync_with(&drive, &catalog, 200).await.unwrap(); + + assert!(report.uploaded); + assert_eq!(drive.names(), ["laptop.json"]); + } + + #[tokio::test] + async fn history_this_installation_wrote_before_comes_back_into_an_empty_catalog() { + let drive = FakeDrive::default(); + let (laptop, catalog) = device("laptop"); + catalog + .lock() + .set_watch_state("share-a", "ep1", &watched_at(60.0, 100)) + .unwrap(); + laptop.sync_with(&drive, &catalog, 100).await.unwrap(); + + // The catalog was deleted; the config, and with it the id, was not. + let (relaunched, empty) = device("laptop"); + let report = relaunched.sync_with(&drive, &empty, 200).await.unwrap(); + + assert_eq!(report.applied, 1); + assert!(!report.uploaded); + assert_eq!( + empty.lock().watch_state("share-a", "ep1").unwrap(), + Some(watched_at(60.0, 100)) + ); + } + + #[tokio::test] + async fn an_own_file_that_cannot_be_read_is_not_overwritten() { + let drive = FakeDrive::default(); + let (laptop, catalog) = device("laptop"); + catalog + .lock() + .set_watch_state("share-a", "ep1", &watched_at(60.0, 100)) + .unwrap(); + laptop.sync_with(&drive, &catalog, 100).await.unwrap(); + + let (relaunched, other) = device("laptop"); + other + .lock() + .set_watch_state("share-a", "ep2", &watched_at(5.0, 150)) + .unwrap(); + drive + .offline + .store(true, std::sync::atomic::Ordering::SeqCst); + let report = relaunched.sync_with(&drive, &other, 200).await.unwrap(); + assert!(!report.uploaded); + + drive + .offline + .store(false, std::sync::atomic::Ordering::SeqCst); + relaunched.sync_with(&drive, &other, 300).await.unwrap(); + let state = other.lock().all_watch_states().unwrap(); + assert_eq!(state.len(), 2, "both episodes survive: {state:?}"); + } + + #[tokio::test] + async fn a_file_from_a_newer_build_is_neither_merged_nor_retired() { + let drive = FakeDrive::default(); + let future = HistoryFile { + version: FORMAT_VERSION + 1, + installation: "future".to_owned(), + entries: vec![entry("ep1", 60.0, 100)], + }; + drive.put("future.json", serde_json::to_vec(&future).unwrap()); + let (laptop, catalog) = device("laptop"); + catalog + .lock() + .set_watch_state("share-a", "ep2", &watched_at(5.0, 100)) + .unwrap(); + + let report = laptop + .sync_with(&drive, &catalog, RETIRE_AFTER_SECS + DAY) + .await + .unwrap(); + + assert_eq!(report.applied, 0); + assert_eq!(report.retired, 0); + assert_eq!(drive.names(), ["future.json", "laptop.json"]); + } + + #[tokio::test] + async fn a_quiet_installations_file_is_retired_once_its_history_is_carried() { + let drive = FakeDrive::default(); + let (old_phone, old_catalog) = device("old-phone"); + old_catalog + .lock() + .set_watch_state("share-a", "ep1", &watched_at(600.0, 100)) + .unwrap(); + drive.at(100); + old_phone + .sync_with(&drive, &old_catalog, 100) + .await + .unwrap(); + + let (phone, catalog) = device("phone"); + let later = 100 + RETIRE_AFTER_SECS; + drive.at(later); + let report = phone.sync_with(&drive, &catalog, later).await.unwrap(); + + assert_eq!(report.retired, 1); + assert_eq!(drive.names(), ["phone.json"]); + // Nothing was lost: the newcomer's file carries it now. + let (fresh, fresh_catalog) = device("fresh"); + fresh + .sync_with(&drive, &fresh_catalog, later) + .await + .unwrap(); + assert_eq!( + fresh_catalog.lock().watch_state("share-a", "ep1").unwrap(), + Some(watched_at(600.0, 100)) + ); + } + + #[tokio::test] + async fn a_recent_installations_file_is_kept() { + let drive = FakeDrive::default(); + let (laptop, laptop_catalog) = device("laptop"); + laptop_catalog + .lock() + .set_watch_state("share-a", "ep1", &watched_at(600.0, 100)) + .unwrap(); + drive.at(100); + laptop + .sync_with(&drive, &laptop_catalog, 100) + .await + .unwrap(); + + let (phone, catalog) = device("phone"); + let report = phone + .sync_with(&drive, &catalog, 100 + RETIRE_AFTER_SECS - DAY) + .await + .unwrap(); + + assert_eq!(report.retired, 0); + assert_eq!(drive.names(), ["laptop.json", "phone.json"]); + } + #[test] fn an_installation_keeps_the_id_it_was_given() { let root = diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 9abeb9a..f59c1c9 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -108,9 +108,18 @@ an edge case. session — tokens, the mailbox password that unlocks the key chain, and the key salts a refreshed token can no longer fetch — is one blob in the same credential store, under `proton-account`. Refresh tokens are single-use, so - every refresh is written back. The account writes exactly one thing: the - watch-history files on its own `proton-stream` device (`pstr-core::sync`). - Folders of the account added as shares are only ever read. + every refresh is written back. On the desktop the blob can outgrow one + credential entry — Windows caps one at 1280 UTF-16 units — so + `KeyringSecretStore` splits a long value across `key#0`, `key#1`, … under a + header in `key`. The account writes exactly one kind of thing: the + watch-history files on its own `proton-stream` device (`pstr-core::sync`), + including trashing the file of an installation quiet for 90 days once its + history is merged into this one's. Folders of the account added as shares + are only ever read. +- A CAPTCHA in front of a sign-in is shown in a webview, because the page + reports success by posting a message to its host. On the desktop that is a + child process of the app (`pstr-app::verify`, wry): WebKitGTK on Linux, + WebView2 on Windows. It may only navigate to `https://*.proton.me`. - Signature verification is absent on the visitor path by design — an anonymous visitor cannot read `core/v4/keys/all` to resolve a signer. Decryption still proves the bytes were encrypted to the node key. Nodes come back with nothing @@ -125,10 +134,10 @@ an edge case. | Share list | config dir, `shares.json` | **No** — atomic writes, never overwritten when unparseable | | Share secrets | OS credential store | **No** | | Catalog | data dir, `catalog.db` | Yes — recrawl | -| Watch state | same DB, own table | **No** — deliberately survives a recrawl; with an account, mirrored to its Drive | +| Watch state | same DB, own table | **No** — deliberately survives a recrawl and removing the share; with an account, mirrored to its Drive | | Proton session | OS credential store, `proton-account` | No — sign in again | | Sync identity | config dir, `sync.json` | A new id if missing — the old history file is still read | -| Watch history in Drive | device `proton-stream`, `watch-history/.json` | One file per installation; merged per episode, newer wins | +| Watch history in Drive | device `proton-stream`, `watch-history/.json` | One file per installation; merged per episode, newer wins. A file unwritten for 90 days is retired by the next installation to sync, after merging it; a live installation whose file was retired writes it again | | Block cache | cache dir, `blocks/` | Yes — must work correctly after deletion | | Poster thumbnails | cache dir, `thumbs/` | Yes — refetched per file, one small decrypt each; stored scaled to 640 px | | Provider artwork | cache dir, `posters/` | Yes — refetched by URL; stored scaled to 640 px | diff --git a/packaging/PKGBUILD b/packaging/PKGBUILD index 2c75ba8..d6b1e4b 100644 --- a/packaging/PKGBUILD +++ b/packaging/PKGBUILD @@ -23,7 +23,7 @@ license=('MIT') # mpv carries libmpv.so plus its headers on Arch, so it is both the runtime and # the build dependency. libsecret is the Secret Service side of the keyring — # the share fragment and custom password never touch shares.json. -depends=('mpv' 'libsecret' 'hicolor-icon-theme') +depends=('mpv' 'libsecret' 'webkit2gtk-4.1' 'hicolor-icon-theme') makedepends=('cargo') optdepends=('gnome-keyring: credential storage via Secret Service' 'kwallet: credential storage via Secret Service on KDE') diff --git a/packaging/README.md b/packaging/README.md index 87917e0..9ffc687 100644 --- a/packaging/README.md +++ b/packaging/README.md @@ -40,6 +40,7 @@ are not Arch. |---|---| | **libmpv ≥ 0.34** | The player. `pstr-player` links it directly and drives it through `stream_cb`; nothing here shells out to `mpv(1)`, so packages depend on the library, not the player binary. | | **libsecret / Secret Service** | The share URL fragment and the custom password are secrets and live in the OS credential store, never in `shares.json`. | +| **WebKitGTK 4.1** | The window Proton's CAPTCHA is shown in when it gates a sign-in (`pstr-app::verify`). The page reports success by posting a message to its host, so the system browser cannot stand in for it. | | **libGL, libxkbcommon, libX11, libwayland-client** | eframe on glow, with both display backends compiled in. | Not dependencies, deliberately: **SQLite** (rusqlite is `bundled`) and @@ -47,11 +48,13 @@ Not dependencies, deliberately: **SQLite** (rusqlite is `bundled`) and Per packager that is: -- Debian — `libmpv2 | libmpv1` (trixie | bookworm), `libsecret-1-0`, `libgl1`, - `libxkbcommon0`, `libx11-6`, `libwayland-client0`. -- RPM — `mpv-libs`, `libsecret`, `mesa-libGL`, `libxkbcommon`, `libX11`, - `libwayland-client`. -- Arch — `mpv` (which carries both `libmpv.so` and its headers), `libsecret`. +- Debian — `libmpv2 | libmpv1` (trixie | bookworm), `libsecret-1-0`, + `libwebkit2gtk-4.1-0`, `libgl1`, `libxkbcommon0`, `libx11-6`, + `libwayland-client0`. +- RPM — `mpv-libs`, `libsecret`, `webkit2gtk4.1`, `mesa-libGL`, `libxkbcommon`, + `libX11`, `libwayland-client`. +- Arch — `mpv` (which carries both `libmpv.so` and its headers), `libsecret`, + `webkit2gtk-4.1`. The `.deb` and `.rpm` are built from binaries compiled on the packaging host, so they inherit its glibc. A package built on Arch installs on Fedora rawhide but @@ -88,6 +91,13 @@ There is no static libmpv worth shipping, so **"standalone exe" means the exe plus `libmpv-2.dll` beside it** — that pair, plus `pstr.exe` and the README, is what the portable `.zip` contains and what the `.msi` installs. +The cross build adds `WebView2Loader.dll` to that set. The CAPTCHA window runs +on WebView2, and under `-gnu` `webview2-com-sys` links its loader as a DLL — +without it beside the exe, the app does not start at all. `build-windows.sh` +takes it from that crate's build output. An `-msvc` build links the loader +statically and needs no DLL. The WebView2 *runtime* is part of Windows 10 and +11 and is not shipped. + The MSI needs the WiX toolset; the script installs it (`dotnet tool install --global wix`) and adds the UI extension if they are missing. It installs to `Program Files\Proton Stream`, puts a Start Menu shortcut on the GUI, and adds diff --git a/packaging/proton-stream.spec b/packaging/proton-stream.spec index 1abfbf7..c6b4894 100644 --- a/packaging/proton-stream.spec +++ b/packaging/proton-stream.spec @@ -31,6 +31,7 @@ ExclusiveArch: x86_64 # `bundled`) and TLS is rustls, so neither shows up here. Requires: mpv-libs >= 0.34 Requires: libsecret +Requires: webkit2gtk4.1 Requires: mesa-libGL Requires: libxkbcommon Requires: libX11 diff --git a/packaging/windows/proton-stream.wixl.wxs b/packaging/windows/proton-stream.wixl.wxs index 59f8189..8e278a0 100644 --- a/packaging/windows/proton-stream.wixl.wxs +++ b/packaging/windows/proton-stream.wixl.wxs @@ -149,6 +149,13 @@ + + + + + @@ -162,6 +169,7 @@ + diff --git a/scripts/build-windows.sh b/scripts/build-windows.sh index 387ebc0..ddfd48d 100755 --- a/scripts/build-windows.sh +++ b/scripts/build-windows.sh @@ -232,9 +232,16 @@ stage() { [[ -f "$rel/$f" ]] || die "$f missing from $rel — drop --skip-build" done + # The CAPTCHA window's WebView2 loader. Under -msvc it links statically; + # under -gnu, webview2-com-sys links the DLL, which it ships and copies into + # its build output, so the exe does not start without it beside it. + local loader + loader="$(ls -t "$rel"/build/webview2-com-sys-*/out/x64/WebView2Loader.dll 2>/dev/null | head -n1)" + [[ -n "$loader" ]] || die "WebView2Loader.dll missing from $rel/build — drop --skip-build" + rm -rf "$OUTDIR" mkdir -p "$OUTDIR" - install -m755 "$rel/$GUI_BIN" "$rel/$CLI_BIN" "$MPV_DLL" "$OUTDIR/" + install -m755 "$rel/$GUI_BIN" "$rel/$CLI_BIN" "$MPV_DLL" "$loader" "$OUTDIR/" install -m644 "$ROOT/README.md" "$OUTDIR/" if [[ -n "$SIGN_CERT" ]]; then @@ -295,6 +302,7 @@ make_msi() { -D "Version=$VERSION" \ -D "BinDir=$OUTDIR" \ -D "MpvDll=$OUTDIR/libmpv-2.dll" \ + -D "WebViewLoader=$OUTDIR/WebView2Loader.dll" \ -D "IconFile=$PKG/proton-stream.ico" \ -D "ReadMe=$OUTDIR/README.md" \ -o "$out" "$PKG/proton-stream.wixl.wxs" diff --git a/scripts/build.sh b/scripts/build.sh index 03fa31b..14f80ac 100755 --- a/scripts/build.sh +++ b/scripts/build.sh @@ -229,7 +229,7 @@ Priority: optional Architecture: $arch Maintainer: Nils Pukropp Installed-Size: $size -Depends: libc6, libmpv2 | libmpv1, libsecret-1-0, libgl1, libxkbcommon0, libx11-6, libwayland-client0 +Depends: libc6, libmpv2 | libmpv1, libsecret-1-0, libwebkit2gtk-4.1-0, libgl1, libxkbcommon0, libx11-6, libwayland-client0 Recommends: gnome-keyring Homepage: https://github.com/narrrl/proton-stream Description: Netflix-style desktop client for Proton Drive public links