From 7671b655cccdd7caf182890c1d5890b2691950d8 Mon Sep 17 00:00:00 2001 From: zhom <2717306+zhom@users.noreply.github.com> Date: Wed, 26 Aug 2026 13:49:54 +0400 Subject: [PATCH] refactor: better cookie import experience --- e2e/app/Cargo.lock | 1341 ++++++------ e2e/coverage-map.mjs | 3 +- e2e/tests/entities.test.mjs | 65 +- src-tauri/Cargo.toml | 4 + src-tauri/src/api_server.rs | 14 +- src-tauri/src/cookie_manager.rs | 1048 ++++++--- src-tauri/src/cookie_paste.rs | 2173 +++++++++++++++++++ src-tauri/src/default_browser.rs | 56 +- src-tauri/src/lib.rs | 68 +- src/components/cookie-management-dialog.tsx | 317 +-- src/components/cookie-paste-panel.tsx | 512 +++++ src/generated/licenses.json | 116 +- src/i18n/locales/en.json | 107 +- src/i18n/locales/es.json | 107 +- src/i18n/locales/fr.json | 107 +- src/i18n/locales/ja.json | 107 +- src/i18n/locales/ko.json | 107 +- src/i18n/locales/pt.json | 107 +- src/i18n/locales/ru.json | 107 +- src/i18n/locales/tr.json | 107 +- src/i18n/locales/vi.json | 107 +- src/i18n/locales/zh.json | 107 +- src/lib/backend-errors.ts | 12 + src/lib/profile-search.test.mjs | 2 +- src/types.ts | 47 + 25 files changed, 5409 insertions(+), 1439 deletions(-) create mode 100644 src-tauri/src/cookie_paste.rs create mode 100644 src/components/cookie-paste-panel.tsx diff --git a/e2e/app/Cargo.lock b/e2e/app/Cargo.lock index 5f3fee0..2dcf86d 100644 --- a/e2e/app/Cargo.lock +++ b/e2e/app/Cargo.lock @@ -41,9 +41,9 @@ dependencies = [ [[package]] name = "aes" -version = "0.9.1" +version = "0.9.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f1fc76eaeac4c9164506c466d4ffdd8ec9d0c5bf57ee97177c4d8eceb3a0e138" +checksum = "f8eb277bec05f56a0e0591f155a484cbd0f4f07ff2905051a48c72f004f7ed58" dependencies = [ "cipher 0.5.2", "cpubits", @@ -66,34 +66,23 @@ dependencies = [ [[package]] name = "aes-gcm" -version = "0.11.0" +version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fdf011db2e21ce0d575593d749db5554b47fed37aff429e4dc50bc91ac93a028" +checksum = "7f2b8006a0c83f52b62ba44a97b58bf76fe2f70a329e588f67f89691d93d498f" dependencies = [ "aead 0.6.1", - "aes 0.9.1", + "aes 0.9.2", "cipher 0.5.2", "ctr 0.10.1", + "ctutils", "ghash 0.6.0", - "subtle", -] - -[[package]] -name = "ahash" -version = "0.7.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "891477e0c6a8957309ee5c45a6368af3ae14bb510732d2684ffa19af310920f9" -dependencies = [ - "getrandom 0.2.17", - "once_cell", - "version_check", ] [[package]] name = "aho-corasick" -version = "1.1.4" +version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba" dependencies = [ "memchr", ] @@ -150,9 +139,9 @@ dependencies = [ [[package]] name = "android_system_properties" -version = "0.1.5" +version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +checksum = "ae221649c9976a6f6c56ae1facf410f3ddb33cc661c4b7b61020a912d4237fbc" dependencies = [ "libc", ] @@ -209,9 +198,9 @@ dependencies = [ [[package]] name = "anyhow" -version = "1.0.103" +version = "1.0.104" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2a4385e2e34eb35d6b3efe798b9eb88096925d87726c0798709bf56d9ed84af3" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" [[package]] name = "arbitrary" @@ -248,7 +237,7 @@ checksum = "0ae92a5119aa49cdbcf6b9f893fe4e1d98b04ccbf82ee0584ad948a44a734dea" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -377,7 +366,7 @@ checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -416,13 +405,13 @@ checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de" [[package]] name = "async-trait" -version = "0.1.89" +version = "0.1.92" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 3.0.4", ] [[package]] @@ -475,7 +464,7 @@ dependencies = [ "num-traits", "pastey", "rayon", - "thiserror 2.0.18", + "thiserror 2.0.20", "v_frame", "y4m", ] @@ -529,10 +518,10 @@ dependencies = [ "serde_json", "serde_path_to_error", "serde_urlencoded", - "sha1", + "sha1 0.10.7", "sync_wrapper", "tokio", - "tokio-tungstenite", + "tokio-tungstenite 0.29.0", "tower", "tower-layer", "tower-service", @@ -611,9 +600,9 @@ checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" [[package]] name = "bitflags" -version = "2.13.0" +version = "2.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8" +checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" dependencies = [ "serde_core", ] @@ -627,18 +616,6 @@ dependencies = [ "no_std_io2", ] -[[package]] -name = "bitvec" -version = "1.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ddcec3d12c579d40898fe0a9a358a803c23e9c52ca3c425707f81c9436211837" -dependencies = [ - "funty", - "radium", - "tap", - "wyz", -] - [[package]] name = "blake2" version = "0.10.6" @@ -650,11 +627,10 @@ dependencies = [ [[package]] name = "blake3" -version = "1.8.5" +version = "1.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0aa83c34e62843d924f905e0f5c866eb1dd6545fc4d719e803d9ba6030371fce" +checksum = "6d9e454fc11f76977dc803893aff6304ed33d6a26efae8696573bea74baa27ae" dependencies = [ - "arrayref", "arrayvec", "cc", "cfg-if", @@ -709,9 +685,9 @@ dependencies = [ [[package]] name = "blocking" -version = "1.6.2" +version = "1.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e83f8d02be6967315521be875afa792a316e28d57b5a2d401897e2a7921b7f21" +checksum = "a70e4329df6cb94385eed412ec92375c3cdd8a6e502493d1229b6414e4036dfa" dependencies = [ "async-channel", "async-task", @@ -745,30 +721,6 @@ dependencies = [ "x25519-dalek", ] -[[package]] -name = "borsh" -version = "1.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f3f6da4992df95bbcd9af42a6c7dcb994498fc9048230405f3b36ff7cd3f145" -dependencies = [ - "borsh-derive", - "bytes", - "cfg_aliases", -] - -[[package]] -name = "borsh-derive" -version = "1.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3ae8fb4fb5740e4b2c4884ff95f5f32f5e8479db1e8fd8eb49ddbe09eb09bb7c" -dependencies = [ - "once_cell", - "proc-macro-crate 3.5.0", - "proc-macro2", - "quote", - "syn 2.0.118", -] - [[package]] name = "brotli" version = "8.0.4" @@ -801,9 +753,9 @@ dependencies = [ [[package]] name = "bstr" -version = "1.12.3" +version = "1.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5cee35f73844aa3014bb606320a6c1f010249dbdf43342fe54b5a4f6a8ed4b79" +checksum = "6bb31b46c14244e20ee9984b11bf5c992b91fb6939fea616e3512c8baecdbe5f" dependencies = [ "memchr", "serde_core", @@ -821,18 +773,6 @@ version = "3.20.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" -[[package]] -name = "byte-unit" -version = "5.2.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4a813de7f2bbedb7dce265b64f1cf5908ebe4d56281ece8d847e98113788b9b0" -dependencies = [ - "rust_decimal", - "schemars 1.2.1", - "serde", - "utf8-width", -] - [[package]] name = "byte_string" version = "1.0.0" @@ -840,33 +780,25 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "11aade7a05aa8c3a351cedc44c3fc45806430543382fcc4743a9b757a2a0b4ed" [[package]] -name = "bytecheck" -version = "0.6.12" +name = "bytemuck" +version = "1.25.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "23cdc57ce23ac53c931e88a43d06d070a6fd142f2617be5855eb75efc9beb1c2" +checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797" dependencies = [ - "bytecheck_derive", - "ptr_meta", - "simdutf8", + "bytemuck_derive", ] [[package]] -name = "bytecheck_derive" -version = "0.6.12" +name = "bytemuck_derive" +version = "1.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3db406d29fbcd95542e92559bed4d8ad92636d1ca8b3b72ede10b4bcc010e659" +checksum = "fc0e56a716f1e132ff6bf4bdac1c944a3fcdc1cae65f70a4a2a1ac3b401d2d1f" dependencies = [ "proc-macro2", "quote", - "syn 1.0.109", + "syn 3.0.4", ] -[[package]] -name = "bytemuck" -version = "1.25.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec" - [[package]] name = "byteorder" version = "1.5.0" @@ -881,9 +813,9 @@ checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495" [[package]] name = "bytes" -version = "1.12.0" +version = "1.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ae3f5d315924270530207e2a68396c3cc547f6dca3fbdca317cfb1a51edb593" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" dependencies = [ "serde", ] @@ -915,7 +847,7 @@ version = "0.18.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8ca26ef0159422fb77631dc9d17b102f253b876fe1586b03b803e63a309b4ee2" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.13.1", "cairo-sys-rs", "glib 0.18.5", "libc", @@ -936,9 +868,9 @@ dependencies = [ [[package]] name = "camino" -version = "1.2.4" +version = "1.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f2d30e4173c4026932d51d31d6b0613b1fd3014bf3f9f8943d4ba139c437ba0" +checksum = "bb1307f12aa967b5a58416e87b3653360e0fd614a016b6e970db08fecbb1b80d" dependencies = [ "serde_core", ] @@ -963,7 +895,7 @@ dependencies = [ "semver", "serde", "serde_json", - "thiserror 2.0.18", + "thiserror 2.0.20", ] [[package]] @@ -996,9 +928,9 @@ dependencies = [ [[package]] name = "cc" -version = "1.2.66" +version = "1.4.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f5d6cac793997bd970000024b2934968efe83b382de4fdcf4fcb46b6ee4ad996" +checksum = "0ad534f4357a5264cce5019c989cf66a4f0dc4e0d1b1d15f8aacec0ff7360273" dependencies = [ "find-msvc-tools", "jobserver", @@ -1062,9 +994,9 @@ checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" [[package]] name = "cfg_aliases" -version = "0.2.1" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" +checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" [[package]] name = "chacha20" @@ -1216,9 +1148,9 @@ checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" [[package]] name = "combine" -version = "4.6.7" +version = "4.6.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" +checksum = "cfc320937d09e6de266b31b9afb480f197d7a861be86be7cb2ea7e5d1bfffc5e" dependencies = [ "bytes", "memchr", @@ -1273,9 +1205,9 @@ checksum = "3d52eff69cd5e647efe296129160853a42795992097e8af39800e1060caeea9b" [[package]] name = "cookie" -version = "0.18.1" +version = "0.18.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4ddef33a339a91ea89fb53151bd0a4689cfce27055c291dfa69945475d22c747" +checksum = "1a373e3602691c3cdea496d2f0ee5935151e6168fe87739483c463db1b2f2f87" dependencies = [ "time", "version_check", @@ -1313,7 +1245,7 @@ version = "0.25.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "064badf302c3194842cf2c5d61f56cc88e54a759313879cdf03abdd27d0c3b97" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.13.1", "core-foundation 0.10.1", "core-graphics-types", "foreign-types 0.5.0", @@ -1326,20 +1258,11 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d44a101f213f6c4cdc1853d4b78aef6db6bdfa3468798cc1d9912f4735013eb" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.13.1", "core-foundation 0.10.1", "libc", ] -[[package]] -name = "core_maths" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "77745e017f5edba1a9c1d854f6f3a52dac8a12dd5af5d2f54aecf61e43d80d30" -dependencies = [ - "libm", -] - [[package]] name = "cpubits" version = "0.1.1" @@ -1381,9 +1304,9 @@ checksum = "217698eaf96b4a3f0bc4f3662aaa55bdf913cd54d7204591faa790070c6d0853" [[package]] name = "crc32fast" -version = "1.5.0" +version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +checksum = "8498c871161e1742aaa9d52551b2d6ebdd4c3d45a3be423e3728f33b955be550" dependencies = [ "cfg-if", ] @@ -1470,7 +1393,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "13b588ba4ac1a99f7f2964d24b3d896ddc6bf847ee3855dbd4366f058cfcd331" dependencies = [ "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -1539,7 +1462,7 @@ checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -1562,7 +1485,7 @@ dependencies = [ "proc-macro2", "quote", "strsim", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -1573,14 +1496,14 @@ checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" dependencies = [ "darling_core", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] name = "data-encoding" -version = "2.11.0" +version = "2.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8" +checksum = "4583a4551df46e2792f82ceeac45e850d2e2d5debba0b91f102385cda5b11f06" [[package]] name = "data-url" @@ -1599,15 +1522,6 @@ dependencies = [ "windows-sys 0.61.2", ] -[[package]] -name = "defmt" -version = "0.3.100" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f0963443817029b2024136fc4dd07a5107eb8f977eaf18fcd1fdeb11306b64ad" -dependencies = [ - "defmt 1.1.1", -] - [[package]] name = "defmt" version = "1.1.1" @@ -1627,7 +1541,7 @@ dependencies = [ "defmt-parser", "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -1636,7 +1550,7 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "10d60334b3b2e7c9d91ef8150abfb6fa4c1c39ebbcf4a81c2e346aad939fee3e" dependencies = [ - "thiserror 2.0.18", + "thiserror 2.0.20", ] [[package]] @@ -1676,7 +1590,7 @@ dependencies = [ "proc-macro2", "quote", "rustc_version", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -1737,7 +1651,7 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.13.1", "block2", "libc", "objc2", @@ -1745,13 +1659,13 @@ dependencies = [ [[package]] name = "displaydoc" -version = "0.2.6" +version = "0.2.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f" +checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 3.0.4", ] [[package]] @@ -1774,7 +1688,7 @@ checksum = "0fbbb781877580993a8707ec48672673ec7b81eeba04cfd2310bd28c08e47c8f" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -1805,13 +1719,13 @@ dependencies = [ name = "donutbrowser" version = "0.29.6" dependencies = [ - "aes 0.9.1", - "aes-gcm 0.11.0", + "aes 0.9.2", + "aes-gcm 0.11.1", "argon2", "async-socks5", "async-trait", "axum", - "base64 0.22.1", + "base64 0.23.1", "blake3", "boringtun", "bzip2", @@ -1843,7 +1757,7 @@ dependencies = [ "objc2", "objc2-app-kit", "once_cell", - "quick-xml", + "quick-xml 0.42.0", "rand 0.10.2", "regex-lite", "reqwest", @@ -1875,11 +1789,11 @@ dependencies = [ "tauri-plugin-single-instance", "tauri-plugin-window-state", "tempfile", - "thiserror 2.0.18", + "thiserror 2.0.20", "tokio", - "tokio-tungstenite", + "tokio-tungstenite 0.30.0", "tokio-util", - "toml 1.1.2+spec-1.1.0", + "toml 1.1.4+spec-1.1.0", "tower", "tower-http 0.7.0", "url", @@ -1974,7 +1888,7 @@ checksum = "5ac0893f103ae7bf50181323d748d505f27b6255777c28b502030bdaf8749f4c" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -1989,9 +1903,9 @@ dependencies = [ [[package]] name = "either" -version = "1.16.0" +version = "1.18.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e" +checksum = "252afb9ae5eaa683babdc6a068b3f5726eb19e05070c731f9b2a23a7c3e8ed34" [[package]] name = "embed-resource" @@ -2002,7 +1916,7 @@ dependencies = [ "cc", "memchr", "rustc_version", - "toml 1.1.2+spec-1.1.0", + "toml 1.1.4+spec-1.1.0", "vswhom", "winreg 0.55.0", ] @@ -2046,7 +1960,7 @@ checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -2099,7 +2013,7 @@ checksum = "44f23cf4b44bfce11a86ace86f8a73ffdec849c9fd00a386a53d278bd9e81fb3" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -2131,9 +2045,9 @@ dependencies = [ [[package]] name = "error-code" -version = "3.3.2" +version = "3.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dea2df4cf52843e0452895c455a1a2cfbb842a1e7329671acf418fdc53ed4c59" +checksum = "0b5343afd4a8365a643ac588dab4cf234a190c7f6c88c9f6dd6ffe00837661b7" [[package]] name = "euclid" @@ -2146,11 +2060,10 @@ dependencies = [ [[package]] name = "event-listener" -version = "5.4.1" +version = "5.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab" +checksum = "5a23add41df1562121a9393cb065eab5146a1242410f23a644851e90cfd669d2" dependencies = [ - "concurrent-queue", "parking", "pin-project-lite", ] @@ -2167,14 +2080,16 @@ dependencies = [ [[package]] name = "exr" -version = "1.74.0" +version = "1.74.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4300e043a56aa2cb633c01af81ca8f699a321879a7854d3896a0ba89056363be" +checksum = "711fe42c9964295e01ee3fba3f9fe0e1d24b98886950d68efe81b1c76e21adf3" dependencies = [ "bit_field", "half", "lebe", "miniz_oxide", + "num-complex", + "pulp", "rayon-core", "smallvec", "zune-inflate", @@ -2194,9 +2109,9 @@ checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" [[package]] name = "fastrand" -version = "2.4.1" +version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" [[package]] name = "fax" @@ -2250,9 +2165,9 @@ dependencies = [ [[package]] name = "find-msvc-tools" -version = "0.1.9" +version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" +checksum = "d45db016d36b838f563236e9193d0ee6ce38f3f68b6c94e914b4929c96bbb890" [[package]] name = "fixedbitset" @@ -2294,6 +2209,15 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" +[[package]] +name = "font-types" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e64eb721ca85a34323425f4041adc5d82704d3782d5f8f03793bc012419dce23" +dependencies = [ + "bytemuck", +] + [[package]] name = "fontconfig-parser" version = "0.5.8" @@ -2305,16 +2229,15 @@ dependencies = [ [[package]] name = "fontdb" -version = "0.23.0" +version = "0.24.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "457e789b3d1202543297a350643cf459f836cade38934e7a4cf6a39e7cde2905" +checksum = "2660c5e9157bf76d2db1294e4a9feba604ef610819a3b591088d0d8392a3290f" dependencies = [ "fontconfig-parser", "log", "memmap2", "slotmap", "tinyvec", - "ttf-parser", ] [[package]] @@ -2338,13 +2261,13 @@ dependencies = [ [[package]] name = "foreign-types-macros" -version = "0.2.3" +version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1a5c6c585bc94aaf2c7b51dd4c2ba22680844aba4c687be581871a6f518c5742" +checksum = "ea5190182e6915eb873ddbc16e23b711b6eb1f9c00a0d0a3a91b5f6228475225" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 3.0.4", ] [[package]] @@ -2368,17 +2291,11 @@ dependencies = [ "percent-encoding", ] -[[package]] -name = "funty" -version = "2.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c" - [[package]] name = "futures" -version = "0.3.32" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d" +checksum = "9a31d2a3fbaaeb2af2368bbdd904aa8e812d3c04a1ee10d3171f52d556e5d0a3" dependencies = [ "futures-channel", "futures-core", @@ -2407,9 +2324,9 @@ checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" [[package]] name = "futures-executor" -version = "0.3.32" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" +checksum = "031b47cf1a3c6cc8bc2fc76cd437f521619387907d469316e7c0bc278f1f5432" dependencies = [ "futures-core", "futures-task", @@ -2645,7 +2562,7 @@ version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2eecf2d5dc9b66b732b97707a0210906b1d30523eb773193ab777c0c84b3e8d5" dependencies = [ - "polyval 0.7.1", + "polyval 0.7.3", ] [[package]] @@ -2709,7 +2626,7 @@ version = "0.18.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "233daaf6e83ae6a12a52055f568f9d7cf4671dabb78ff9560ab6da230ce00ee5" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.13.1", "futures-channel", "futures-core", "futures-executor", @@ -2732,7 +2649,7 @@ version = "0.21.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "16de123c2e6c90ce3b573b7330de19be649080ec612033d397d72da265f1bd8b" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.13.1", "futures-channel", "futures-core", "futures-executor", @@ -2758,7 +2675,7 @@ dependencies = [ "proc-macro-error", "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -2771,7 +2688,7 @@ dependencies = [ "proc-macro-crate 3.5.0", "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -2796,15 +2713,15 @@ dependencies = [ [[package]] name = "glob" -version = "0.3.3" +version = "0.3.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" +checksum = "e4eba85ea1d0a966a983acd07deee566e67395d2d96b6fb39e62b5a833f1eb0b" [[package]] name = "globset" -version = "0.4.18" +version = "0.4.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52dfc19153a48bde0cbd630453615c8151bce3a5adfac7a0aebfbf0a1e1f57e3" +checksum = "07c34a9410465b45bd9787443bc7370f37735bad04b0f0cd57ff1a3186c98988" dependencies = [ "aho-corasick", "bstr", @@ -2884,14 +2801,14 @@ dependencies = [ "proc-macro-error", "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] name = "h2" -version = "0.4.18" +version = "0.4.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "839c0e8a181239723652be9062bb56ca5bf5f64011f73b623f6f4fc59086a228" +checksum = "ef8e5e5a340588f4452631496976cf8636d4a7ecf600239fdc27615d2530bc16" dependencies = [ "atomic-waker", "bytes", @@ -2917,6 +2834,18 @@ dependencies = [ "zerocopy", ] +[[package]] +name = "harfrust" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c03d949a14aa089bbb282f7dd76a498a7f684428e4257202efc119ec010376f9" +dependencies = [ + "bitflags 2.13.1", + "bytemuck", + "read-fonts", + "smallvec", +] + [[package]] name = "hash32" version = "0.3.1" @@ -2931,9 +2860,6 @@ name = "hashbrown" version = "0.12.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" -dependencies = [ - "ahash", -] [[package]] name = "hashbrown" @@ -3041,9 +2967,9 @@ dependencies = [ [[package]] name = "http" -version = "1.4.2" +version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6970f50e31d6fc17d3fa27329444bfa74e196cf62e95052a3f6fee181dba6425" +checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0" dependencies = [ "bytes", "itoa", @@ -3051,9 +2977,9 @@ dependencies = [ [[package]] name = "http-body" -version = "1.0.1" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c" dependencies = [ "bytes", "http", @@ -3061,9 +2987,9 @@ dependencies = [ [[package]] name = "http-body-util" -version = "0.1.3" +version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +checksum = "23169fe34a5fbcdd3f3862e78fb9b6fccd5f02a6dc6f732547005d45631ce71c" dependencies = [ "bytes", "futures-core", @@ -3086,18 +3012,18 @@ checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" [[package]] name = "hybrid-array" -version = "0.4.13" +version = "0.4.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "818356c5132c1fede50f837ca96afbe78ff42413047f4abb886217845e1b6c8c" +checksum = "707114b52a152fa7bdb290cd7cd5912d9467273b6d74e21b8d81aca1f8533f6b" dependencies = [ "typenum", ] [[package]] name = "hyper" -version = "1.10.1" +version = "1.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "55281c53a1894c864990125767da440a4e630446785086f52523b20033b74498" +checksum = "d22053281f852e11534f5198498373cbb59295120a20771d90f7ed1897490a72" dependencies = [ "atomic-waker", "bytes", @@ -3208,9 +3134,9 @@ dependencies = [ [[package]] name = "icu_collections" -version = "2.2.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" +checksum = "fa68d21081c4a05d5a901a1c62add574c77048b6a1c67be3b50ce0b60d4ca513" dependencies = [ "displaydoc", "potential_utf", @@ -3222,9 +3148,9 @@ dependencies = [ [[package]] name = "icu_locale_core" -version = "2.2.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" +checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb" dependencies = [ "displaydoc", "litemap", @@ -3235,9 +3161,9 @@ dependencies = [ [[package]] name = "icu_normalizer" -version = "2.2.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" +checksum = "12f9cf5f235641ed274641dd81c3f28d870e276763d0797aeeab72317b1c646f" dependencies = [ "icu_collections", "icu_normalizer_data", @@ -3249,16 +3175,17 @@ dependencies = [ [[package]] name = "icu_normalizer_data" -version = "2.2.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" +checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0" [[package]] name = "icu_properties" -version = "2.2.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" +checksum = "7e7ca276ad3145661a65914e6daf131ca5120cd3dcee8f8f3214b8875184a148" dependencies = [ + "displaydoc", "icu_collections", "icu_locale_core", "icu_properties_data", @@ -3269,15 +3196,15 @@ dependencies = [ [[package]] name = "icu_properties_data" -version = "2.2.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" +checksum = "e590f038c1464a96894fd6d10127e90a8be4509f56ff7ecef851b15cee0b7caa" [[package]] name = "icu_provider" -version = "2.2.0" +version = "2.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" +checksum = "d27bbb9d3abbefac45d55f647c9de1d44aafcd1186eb91879afef17c396c3e73" dependencies = [ "displaydoc", "icu_locale_core", @@ -3351,9 +3278,9 @@ dependencies = [ [[package]] name = "imagesize" -version = "0.14.0" +version = "0.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09e54e57b4c48b40f7aec75635392b12b3421fa26fe8b4332e63138ed278459c" +checksum = "65b27460c2c92b037f3f94c538ed9a3342f3fdf923606781629ccb35f82d042a" [[package]] name = "imgref" @@ -3421,7 +3348,7 @@ checksum = "c34819042dc3d3971c46c2190835914dfbe0c3c13f61449b2997f4e9722dfa60" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -3448,9 +3375,9 @@ checksum = "8e537132deb99c0eb4b752f0346b6a836200eaaa3516dd7e5514b63930a09e5d" [[package]] name = "ipnet" -version = "2.12.0" +version = "2.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" +checksum = "6a756c3fac73139e83f14c2d742155dd2b78d3ee56597b419a0579b7bdd6dd78" [[package]] name = "ipnetwork" @@ -3523,27 +3450,55 @@ dependencies = [ [[package]] name = "jiff" -version = "0.2.31" +version = "0.2.35" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ccfe6121cbe750cf81efa362d85c0bde7ea298ec43092d3a193baca59cdbd634" +checksum = "668b7183bd07af9a4885f5c35b0cc5c83c4607a913c16b7e17291832910d2dcc" dependencies = [ - "defmt 1.1.1", + "defmt", + "jiff-core", "jiff-static", + "jiff-tzdb-platform", "log", "portable-atomic", "portable-atomic-util", "serde_core", + "windows-link 0.2.1", +] + +[[package]] +name = "jiff-core" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7feca88439efe53da3754500c1851dedf3cb36c524dd5cf8225cc0794de95d09" +dependencies = [ + "defmt", ] [[package]] name = "jiff-static" -version = "0.2.31" +version = "0.2.35" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e165e897f662d428f3cd3828a919dbe067c2d42bb1031eede74ef9d27ecdedd2" +checksum = "3a69dcb3a21cfb32ce1cd056169337ca284af0766dd766e7878819b251a49204" dependencies = [ + "jiff-core", "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", +] + +[[package]] +name = "jiff-tzdb" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "142bd39932ad231f10513df9ab62661fead8719872150b7ad02a2df79f4e141e" + +[[package]] +name = "jiff-tzdb-platform" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "875a5a69ac2bab1a891711cf5eccbec1ce0341ea805560dcd90b7a2e925132e8" +dependencies = [ + "jiff-tzdb", ] [[package]] @@ -3587,7 +3542,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" dependencies = [ "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -3602,9 +3557,9 @@ dependencies = [ [[package]] name = "js-sys" -version = "0.3.103" +version = "0.3.104" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "53b44bfcdb3f8d5837a46dae1ca9660a837176eee74a28b229bc626816589102" +checksum = "0e0c1080212aad755ea003d18543e8768dd432c48819efd73a7bf1e39b7a5a3a" dependencies = [ "cfg-if", "futures-util", @@ -3639,7 +3594,7 @@ version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b750dcadc39a09dbadd74e118f6dd6598df77fa01df0cfcdc52c28dece74528a" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.13.1", "serde", "unicode-segmentation", ] @@ -3700,9 +3655,9 @@ checksum = "34b357333733e8260735ba5894eb928c02ecc69c78715f01a8019e7fa7f2db4c" [[package]] name = "libc" -version = "0.2.186" +version = "0.2.189" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" [[package]] name = "libdbus-sys" @@ -3741,18 +3696,18 @@ checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" [[package]] name = "libredox" -version = "0.1.18" +version = "0.1.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c943259e342f1e06ff2da7a83eabdfe7f92ce10262688dbf1895ff0b3e6e4652" +checksum = "28d0a00925a9f930d679b6789b721e3a7f9ed110f41b86d2497caa780c3a070a" dependencies = [ "libc", ] [[package]] name = "libsqlite3-sys" -version = "0.38.1" +version = "0.38.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f6c19a05435c21ac299d71b6a9c13db3e3f47c520517d58990a462a1397a61db" +checksum = "f1d20bef17f513b9b3004532233187769cd072d790971f4e4da0e346eb6401e8" dependencies = [ "cc", "pkg-config", @@ -3767,9 +3722,9 @@ checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" [[package]] name = "litemap" -version = "0.8.2" +version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" +checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" [[package]] name = "lock_api" @@ -3782,12 +3737,9 @@ dependencies = [ [[package]] name = "log" -version = "0.4.33" +version = "0.4.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" -dependencies = [ - "value-bag", -] +checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" [[package]] name = "loop9" @@ -3858,15 +3810,14 @@ checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" [[package]] name = "maxminddb" -version = "0.29.0" +version = "0.30.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "65e84ef32bcbf18a95548989e880db4af6fafd563463753afb4b9a149fb2782c" +checksum = "6649a1829998559f076107dfff337f1d05195eec8d01964d96e53570cc8f8d75" dependencies = [ "ipnetwork", - "log", "memchr", "serde", - "thiserror 2.0.18", + "thiserror 2.0.20", ] [[package]] @@ -3891,9 +3842,9 @@ dependencies = [ [[package]] name = "memchr" -version = "2.8.2" +version = "2.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "88904434abc2901f197fe8cc55f0445e7ded921dba5911dad2e2b39b48e663c4" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" [[package]] name = "memmap2" @@ -3941,9 +3892,9 @@ dependencies = [ [[package]] name = "mio" -version = "1.2.1" +version = "1.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "02bd0af71c67b473010cbbc60715ee815645a4dc942899111f494b4b737d6fda" +checksum = "30d65c71f1ce40ab09135ce117d742b9f8a19ff91a41a8b57ed50bc2de59c427" dependencies = [ "libc", "wasi", @@ -3981,7 +3932,7 @@ dependencies = [ "cab", "cfb 0.10.0", "msi", - "thiserror 2.0.18", + "thiserror 2.0.20", ] [[package]] @@ -4001,7 +3952,7 @@ dependencies = [ "once_cell", "png 0.18.1", "serde", - "thiserror 2.0.18", + "thiserror 2.0.20", "windows-sys 0.61.2", ] @@ -4028,7 +3979,7 @@ version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c3f42e7bbe13d351b6bead8286a43aac9534b82bd3cc43e47037f012ebfd62d4" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.13.1", "jni-sys 0.3.1", "log", "ndk-sys", @@ -4058,7 +4009,7 @@ version = "0.31.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf20d2fde8ff38632c426f1165ed7436270b44f199fc55284c38276f9db47c3d" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.13.1", "cfg-if", "cfg_aliases", "libc", @@ -4136,6 +4087,7 @@ version = "0.4.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495" dependencies = [ + "bytemuck", "num-traits", ] @@ -4153,14 +4105,14 @@ checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] name = "num-integer" -version = "0.1.46" +version = "0.1.47" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +checksum = "7ce2d95d4b3734dc35aa2f45e1aa22cd416814592a4f9d9205e11affd5b8e10b" dependencies = [ "num-traits", ] @@ -4214,7 +4166,7 @@ dependencies = [ "proc-macro-crate 3.5.0", "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -4242,7 +4194,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.13.1", "block2", "libc", "objc2", @@ -4263,7 +4215,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "73ad74d880bb43877038da939b7427bba67e9dd42004a18b809ba7d87cee241c" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.13.1", "objc2", "objc2-foundation", ] @@ -4274,7 +4226,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b402a653efbb5e82ce4df10683b6b28027616a2715e90009947d50b8dd298fa" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.13.1", "objc2", "objc2-foundation", ] @@ -4285,7 +4237,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.13.1", "dispatch2", "objc2", ] @@ -4296,7 +4248,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.13.1", "dispatch2", "objc2", "objc2-core-foundation", @@ -4329,7 +4281,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0cde0dfb48d25d2b4862161a4d5fcc0e3c24367869ad306b0c9ec0073bfed92d" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.13.1", "objc2", "objc2-core-foundation", "objc2-core-graphics", @@ -4341,7 +4293,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d425caf1df73233f29fd8a5c3e5edbc30d2d4307870f802d18f00d83dc5141a6" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.13.1", "objc2", "objc2-core-foundation", "objc2-core-graphics", @@ -4369,7 +4321,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.13.1", "block2", "libc", "objc2", @@ -4392,7 +4344,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.13.1", "objc2", "objc2-core-foundation", ] @@ -4424,7 +4376,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "96c1358452b371bf9f104e21ec536d37a650eb10f7ee379fff67d2e08d537f1f" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.13.1", "objc2", "objc2-core-foundation", "objc2-foundation", @@ -4436,7 +4388,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "709fe137109bd1e8b5a99390f77a7d8b2961dafc1a1c5db8f2e60329ad6d895a" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.13.1", "objc2", "objc2-core-foundation", ] @@ -4447,7 +4399,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d87d638e33c06f577498cbcc50491496a3ed4246998a7fbba7ccb98b1e7eab22" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.13.1", "block2", "objc2", "objc2-cloud-kit", @@ -4478,7 +4430,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b2e5aaab980c433cf470df9d7af96a7b46a9d892d521a2cbbb2f8a4c16751e7f" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.13.1", "block2", "objc2", "objc2-app-kit", @@ -4508,9 +4460,9 @@ checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" [[package]] name = "open" -version = "5.3.6" +version = "5.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cd8d3b65c44123a56e0133d2cd06ce4361bd3ca99d41198b2f25e3c3db9b8b4a" +checksum = "ade3be4664bc1ef537ce133015f04c176b737815c2ba9fd60edf212d6e90dd55" dependencies = [ "dunce", "is-wsl", @@ -4523,7 +4475,7 @@ version = "0.10.81" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "77823a27f0babb03091cb9ed9ef80af3b39dbc82f97e8fa530374b7dafd87a45" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.13.1", "cfg-if", "foreign-types 0.3.2", "libc", @@ -4539,7 +4491,7 @@ checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -4740,7 +4692,7 @@ dependencies = [ "phf_shared 0.13.1", "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -4784,7 +4736,7 @@ checksum = "c96395f0a926bc13b1c17622aaddda1ecb55d49c8f1bf9777e4d877800a43f8b" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -4816,9 +4768,9 @@ dependencies = [ [[package]] name = "pkg-config" -version = "0.3.33" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" +checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548" [[package]] name = "plist" @@ -4828,7 +4780,7 @@ checksum = "7da1d65da6dd5d1e44199ac0f58712d241c0f439f80adea8924d832384087f85" dependencies = [ "base64 0.22.1", "indexmap 2.14.0", - "quick-xml", + "quick-xml 0.41.0", "serde", "time", ] @@ -4852,7 +4804,7 @@ version = "0.18.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.13.1", "crc32fast", "fdeflate", "flate2", @@ -4907,9 +4859,9 @@ dependencies = [ [[package]] name = "polyval" -version = "0.7.1" +version = "0.7.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7dfc63250416fea14f5749b90725916a6c903f599d51cb635aa7a52bfd03eede" +checksum = "f0fa31d631f2b2cb2a544d0aa321ce847a94764d701ca2becc411138b93d49cd" dependencies = [ "cpubits", "cpufeatures 0.3.0", @@ -4918,9 +4870,9 @@ dependencies = [ [[package]] name = "portable-atomic" -version = "1.13.1" +version = "1.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" +checksum = "05c8b63e8d9609db387f0324918f81d68fe27748f084ef092fb35954d0539a85" [[package]] name = "portable-atomic-util" @@ -4933,9 +4885,9 @@ dependencies = [ [[package]] name = "potential_utf" -version = "0.1.5" +version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661" dependencies = [ "zerovec", ] @@ -4987,7 +4939,7 @@ version = "3.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" dependencies = [ - "toml_edit 0.25.12+spec-1.1.0", + "toml_edit 0.25.13+spec-1.1.0", ] [[package]] @@ -5016,9 +4968,9 @@ dependencies = [ [[package]] name = "proc-macro2" -version = "1.0.106" +version = "1.0.107" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" dependencies = [ "unicode-ident", ] @@ -5052,28 +5004,31 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4488a4a36b9a4ba6b9334a32a39971f77c1436ec82c38707bce707699cc3bbcb" dependencies = [ "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] -name = "ptr_meta" -version = "0.1.4" +name = "pulp" +version = "0.22.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0738ccf7ea06b608c10564b31debd4f5bc5e197fc8bfe088f68ae5ce81e7a4f1" +checksum = "046aa45b989642ec2e4717c8e72d677b13edd831a4d3b6cf37d9a3e54912496a" dependencies = [ - "ptr_meta_derive", + "bytemuck", + "cfg-if", + "libm", + "num-complex", + "paste", + "pulp-wasm-simd-flag", + "raw-cpuid", + "reborrow", + "version_check", ] [[package]] -name = "ptr_meta_derive" -version = "0.1.4" +name = "pulp-wasm-simd-flag" +version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "16b845dbfca988fa33db069c0e230574d15a3088f147a87b64c7589eb662c9ac" -dependencies = [ - "proc-macro2", - "quote", - "syn 1.0.109", -] +checksum = "1d8f70e07b9c3962945a74e59ca1c511bba65b6419468acc217c457d93f3c740" [[package]] name = "pxfm" @@ -5101,6 +5056,15 @@ name = "quick-xml" version = "0.41.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e660451e55124f798a69a5af3f49ccfbefbd41910eefd25caf2393e1f3473ec1" +dependencies = [ + "memchr", +] + +[[package]] +name = "quick-xml" +version = "0.42.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41b1177fdf999d2321d3fb46ff47159d9c1fb9ad66a4879f8c50a0b504615e9b" dependencies = [ "memchr", "serde", @@ -5108,9 +5072,9 @@ dependencies = [ [[package]] name = "quote" -version = "1.0.46" +version = "1.0.47" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dfbc457d0c7a0759a614551b11a6409e5951f6c7537be1f1b7682b9ae9230368" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" dependencies = [ "proc-macro2", ] @@ -5127,30 +5091,13 @@ version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" -[[package]] -name = "radium" -version = "0.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc33ff2d4973d518d823d61aa239014831e521c75da58e3df4840d3f47749d09" - [[package]] name = "rand" -version = "0.8.6" +version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a" +checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41" dependencies = [ - "libc", - "rand_chacha 0.3.1", - "rand_core 0.6.4", -] - -[[package]] -name = "rand" -version = "0.9.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea" -dependencies = [ - "rand_chacha 0.9.0", + "rand_chacha", "rand_core 0.9.5", ] @@ -5165,16 +5112,6 @@ dependencies = [ "rand_core 0.10.1", ] -[[package]] -name = "rand_chacha" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" -dependencies = [ - "ppv-lite86", - "rand_core 0.6.4", -] - [[package]] name = "rand_chacha" version = "0.9.0" @@ -5236,10 +5173,10 @@ dependencies = [ "num-traits", "paste", "profiling", - "rand 0.9.4", - "rand_chacha 0.9.0", + "rand 0.9.5", + "rand_chacha", "simd_helpers", - "thiserror 2.0.18", + "thiserror 2.0.20", "v_frame", "wasm-bindgen", ] @@ -5259,6 +5196,15 @@ dependencies = [ "rgb", ] +[[package]] +name = "raw-cpuid" +version = "11.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "498cd0dc59d73224351ee52a95fee0f1a617a2eae0e7d9d720cc622c73a54186" +dependencies = [ + "bitflags 2.13.1", +] + [[package]] name = "raw-window-handle" version = "0.6.2" @@ -5285,13 +5231,30 @@ dependencies = [ "crossbeam-utils", ] +[[package]] +name = "read-fonts" +version = "0.41.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "046a7d674daf459825b32f5062056d6882db0d2f5a479fbd76ccfc870ac18709" +dependencies = [ + "bytemuck", + "font-types", + "once_cell", +] + +[[package]] +name = "reborrow" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03251193000f4bd3b042892be858ee50e8b3719f2b08e5833ac4353724632430" + [[package]] name = "redox_syscall" version = "0.5.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.13.1", ] [[package]] @@ -5302,34 +5265,34 @@ checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" dependencies = [ "getrandom 0.2.17", "libredox", - "thiserror 2.0.18", + "thiserror 2.0.20", ] [[package]] name = "ref-cast" -version = "1.0.25" +version = "1.0.27" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f354300ae66f76f1c85c5f84693f0ce81d747e2c3f21a45fef496d89c960bf7d" +checksum = "7e440fb4e4b4147295338efb76001ab9e4efc0e5839df2c47fc5ac2381d365c3" dependencies = [ "ref-cast-impl", ] [[package]] name = "ref-cast-impl" -version = "1.0.25" +version = "1.0.27" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7186006dcb21920990093f30e3dea63b7d6e977bf1256be20c3563a5db070da" +checksum = "92ecd8964f8453721699a1ed72037b0db49ce2f5a5138486ee89bed6f67cdf3a" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 3.0.4", ] [[package]] name = "regex" -version = "1.12.4" +version = "1.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f1292b7759ae1cb9ec195452d1390a074f0cd8541ab7a5a8c31cd6db45d4a6ba" +checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" dependencies = [ "aho-corasick", "memchr", @@ -5339,9 +5302,9 @@ dependencies = [ [[package]] name = "regex-automata" -version = "0.4.14" +version = "0.4.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" dependencies = [ "aho-corasick", "memchr", @@ -5360,15 +5323,6 @@ version = "0.8.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" -[[package]] -name = "rend" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "71fe3824f5629716b1589be05dacd749f6aa084c87e00e016714a8cdfccc997c" -dependencies = [ - "bytecheck", -] - [[package]] name = "reqwest" version = "0.13.4" @@ -5413,10 +5367,11 @@ dependencies = [ [[package]] name = "resvg" -version = "0.47.0" +version = "0.48.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9be183ad6a216aa96f33e4c8033b0988b8b3ea6fd2359d19af5bac4643fd8e81" +checksum = "67e3803f97b999e80cbf7c6ecdd07a8102204d92e1633cf48783720c521196bd" dependencies = [ + "bytemuck", "gif", "image-webp", "log", @@ -5488,35 +5443,6 @@ dependencies = [ "ring", ] -[[package]] -name = "rkyv" -version = "0.7.46" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2297bf9c81a3f0dc96bc9521370b88f054168c29826a75e89c55ff196e7ed6a1" -dependencies = [ - "bitvec", - "bytecheck", - "bytes", - "hashbrown 0.12.3", - "ptr_meta", - "rend", - "rkyv_derive", - "seahash", - "tinyvec", - "uuid", -] - -[[package]] -name = "rkyv_derive" -version = "0.7.46" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "84d7b42d4b8d06048d3ac8db0eb31bcb942cbeb709f0b5f2b2ebde398d3038f5" -dependencies = [ - "proc-macro2", - "quote", - "syn 1.0.109", -] - [[package]] name = "roxmltree" version = "0.20.0" @@ -5539,16 +5465,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c51c9ae4df8a7fba42103df5c621fa3c37eccf3a3c650879e90fc48b11cc192c" dependencies = [ "hashbrown 0.16.1", - "thiserror 2.0.18", + "thiserror 2.0.20", ] [[package]] name = "rusqlite" -version = "0.40.1" +version = "0.40.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "11438310b19e3109b6446c33d1ed5e889428cf2e278407bc7896bc4aaea43323" +checksum = "23f2a97da3e3873c73cb2a2e71b35c40ff95e0b1eefa8d72d8499a6928c3b5b3" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.13.1", "fallible-iterator", "fallible-streaming-iterator", "hashlink", @@ -5567,23 +5493,6 @@ dependencies = [ "ordered-multimap", ] -[[package]] -name = "rust_decimal" -version = "1.42.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "be2a24f50780bc85f09cc6ac299bdf1424302742d77221106859c9d8b102126a" -dependencies = [ - "arrayvec", - "borsh", - "bytes", - "num-traits", - "rand 0.8.6", - "rkyv", - "serde", - "serde_json", - "wasm-bindgen", -] - [[package]] name = "rustc-hash" version = "2.1.3" @@ -5605,7 +5514,7 @@ version = "1.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.13.1", "errno", "libc", "linux-raw-sys", @@ -5614,9 +5523,9 @@ dependencies = [ [[package]] name = "rustls" -version = "0.23.41" +version = "0.23.43" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6b92b125634d9b795e7beca796cc790df15a7fb38323bf3196fda83292d06b1f" +checksum = "0283386ce02abc0151e1761d08802dfe86c173b0b494af5cbc086574e453da06" dependencies = [ "once_cell", "rustls-pki-types", @@ -5627,18 +5536,18 @@ dependencies = [ [[package]] name = "rustls-pki-types" -version = "1.15.0" +version = "1.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "764899a24af3980067ee14bc143654f297b22eaebfe3c7b6b211920a5a59b046" +checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96" dependencies = [ "zeroize", ] [[package]] name = "rustls-webpki" -version = "0.103.13" +version = "0.103.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" +checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2" dependencies = [ "ring", "rustls-pki-types", @@ -5651,24 +5560,6 @@ version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" -[[package]] -name = "rustybuzz" -version = "0.20.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fd3c7c96f8a08ee34eff8857b11b49b07d71d1c3f4e88f8a88d4c9e9f90b1702" -dependencies = [ - "bitflags 2.13.0", - "bytemuck", - "core_maths", - "log", - "smallvec", - "ttf-parser", - "unicode-bidi-mirroring", - "unicode-ccc", - "unicode-properties", - "unicode-script", -] - [[package]] name = "ryu" version = "1.0.23" @@ -5722,9 +5613,9 @@ dependencies = [ [[package]] name = "schemars" -version = "1.2.1" +version = "1.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a2b42f36aa1cd011945615b92222f6bf73c599a102a300334cd7f8dbeec726cc" +checksum = "687274d293b6cdc6e73e0fee520bf2049650090d7164f87672d212a3c530cf4a" dependencies = [ "dyn-clone", "ref-cast", @@ -5741,7 +5632,7 @@ dependencies = [ "proc-macro2", "quote", "serde_derive_internals", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -5750,12 +5641,6 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" -[[package]] -name = "seahash" -version = "4.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1c107b6f4780854c8b126e228ea8869f4d7b71260f962fefb57b996b8959ba6b" - [[package]] name = "sealed" version = "0.6.0" @@ -5764,7 +5649,7 @@ checksum = "22f968c5ea23d555e670b449c1c5e7b2fc399fdaec1d304a17cd48e288abc107" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -5792,7 +5677,7 @@ version = "3.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.13.1", "core-foundation 0.10.1", "core-foundation-sys", "libc", @@ -5815,7 +5700,7 @@ version = "0.36.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c5d9c0c92a92d33f08817311cf3f2c29a3538a8240e94a6a3c622ce652d7e00c" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.13.1", "cssparser", "derive_more", "log", @@ -5850,9 +5735,9 @@ dependencies = [ [[package]] name = "serde" -version = "1.0.228" +version = "1.0.229" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" dependencies = [ "serde_core", "serde_derive", @@ -5872,22 +5757,22 @@ dependencies = [ [[package]] name = "serde_core" -version = "1.0.228" +version = "1.0.229" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" dependencies = [ "serde_derive", ] [[package]] name = "serde_derive" -version = "1.0.228" +version = "1.0.229" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 3.0.4", ] [[package]] @@ -5898,14 +5783,14 @@ checksum = "18d26a20a969b9e3fdf2fc2d9f21eda6c40e2de84c9408bb5d3b05d499aae711" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] name = "serde_json" -version = "1.0.150" +version = "1.0.151" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" dependencies = [ "itoa", "memchr", @@ -5927,13 +5812,13 @@ dependencies = [ [[package]] name = "serde_repr" -version = "0.1.20" +version = "0.1.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "175ee3e80ae9982737ca543e96133087cbd9a485eecc3bc4de9c1a37b47ea59c" +checksum = "8d3b1629de253c70a0508c3899572da79ca359fdab27c7920ff00406df418906" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 3.0.4", ] [[package]] @@ -5968,9 +5853,9 @@ dependencies = [ [[package]] name = "serde_with" -version = "3.21.0" +version = "3.22.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "76a5c54c7310e7b8b9577c286d7e399ddd876c3e12b3ed917a8aabc4b96e9e8c" +checksum = "ee78f1fbe43ac4a0e47aadb3dbd357b69eb0d3793e948624cd03dd2750ab1c0a" dependencies = [ "base64 0.22.1", "bs58", @@ -5978,8 +5863,9 @@ dependencies = [ "hex", "indexmap 1.9.3", "indexmap 2.14.0", + "jiff", "schemars 0.9.0", - "schemars 1.2.1", + "schemars 1.2.2", "serde_core", "serde_json", "serde_with_macros", @@ -5988,14 +5874,14 @@ dependencies = [ [[package]] name = "serde_with_macros" -version = "3.21.0" +version = "3.22.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "84d57bc0c8b9a17920c178daa6bb924850d54a9c97ab45194bb8c17ad66bb660" +checksum = "8705578779c2b6bd90d84d66eb2e206b708b1a4d7b9f17641b293545bf1c7e46" dependencies = [ "darling", "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -6030,7 +5916,7 @@ checksum = "772ee033c0916d670af7860b6e1ef7d658a4629a6d0b4c8c3e67f09b3765b75d" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -6044,15 +5930,26 @@ dependencies = [ [[package]] name = "sha1" -version = "0.10.6" +version = "0.10.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" +checksum = "a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8" dependencies = [ "cfg-if", "cpufeatures 0.2.17", "digest 0.10.7", ] +[[package]] +name = "sha1" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aacc4cc499359472b4abe1bf11d0b12e688af9a805fa5e3016f9a386dc2d0214" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "digest 0.11.3", +] + [[package]] name = "sha2" version = "0.10.9" @@ -6100,7 +5997,7 @@ dependencies = [ "shadowsocks-crypto", "socket2", "spin", - "thiserror 2.0.18", + "thiserror 2.0.20", "tokio", "tokio-tfo", "trait-variant", @@ -6119,9 +6016,9 @@ dependencies = [ "chacha20poly1305", "hkdf", "md-5", - "rand 0.9.4", + "rand 0.9.5", "ring-compat", - "sha1", + "sha1 0.10.7", ] [[package]] @@ -6189,9 +6086,9 @@ checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" [[package]] name = "simd-adler32" -version = "0.3.9" +version = "0.3.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" +checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" [[package]] name = "simd_helpers" @@ -6202,12 +6099,6 @@ dependencies = [ "quote", ] -[[package]] -name = "simdutf8" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" - [[package]] name = "simplecss" version = "0.2.2" @@ -6223,6 +6114,16 @@ version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649" +[[package]] +name = "skrifa" +version = "0.44.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819ab7d62b1d3e72d9d9dea5650bac30424f9111364bb94928dbf5ecad1baa68" +dependencies = [ + "bytemuck", + "read-fonts", +] + [[package]] name = "slab" version = "0.4.12" @@ -6246,23 +6147,23 @@ checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" [[package]] name = "smoltcp" -version = "0.13.1" +version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f73d40463bba65efc9adc6370b56df76d563cc46e2482bba58351b4afb7535e" +checksum = "b6f8b28ad56c6e35524a37dd492af5d1a47e31e1a4d175cd12f89c075f01980f" dependencies = [ "bitflags 1.3.2", "byteorder", "cfg-if", - "defmt 0.3.100", + "defmt", "heapless", "managed", ] [[package]] name = "socket2" -version = "0.6.4" +version = "0.6.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51" +checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" dependencies = [ "libc", "windows-sys 0.61.2", @@ -6318,9 +6219,9 @@ dependencies = [ [[package]] name = "spin" -version = "0.10.0" +version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d5fe4ccb98d9c292d56fec89a5e07da7fc4cf0dc11e156b41793132775d3e591" +checksum = "023a211cb3138dbc438680b32560ad89f699977624c9f8dbb95a47d5b4c07dd3" dependencies = [ "lock_api", ] @@ -6410,9 +6311,9 @@ dependencies = [ [[package]] name = "swift-rs" -version = "1.0.7" +version = "1.0.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4057c98e2e852d51fdcfca832aac7b571f6b351ad159f9eda5db1655f8d0c4d7" +checksum = "e45c444e496845d3f2a351146bff59aae4975b2280238df1dfaa0c7d1846f38e" dependencies = [ "base64 0.21.7", "serde", @@ -6426,15 +6327,14 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" dependencies = [ "proc-macro2", - "quote", "unicode-ident", ] [[package]] name = "syn" -version = "2.0.118" +version = "2.0.119" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1b9ae57f904213ebb649ce6895b8a66c66f0203b9319718f69a5612a065b1422" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" dependencies = [ "proc-macro2", "quote", @@ -6469,7 +6369,7 @@ checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -6483,9 +6383,9 @@ dependencies = [ [[package]] name = "sysinfo" -version = "0.39.5" +version = "0.39.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2c8bd2130a9b60bee2581bf82cfe89ee836424d1f37dcfa4ce21509611684673" +checksum = "d2071df9448915b71c4fe6d25deaf1c22f12bd234f01540b77312bb8e41361e6" dependencies = [ "libc", "memchr", @@ -6502,7 +6402,7 @@ version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.13.1", "core-foundation 0.9.4", "system-configuration-sys", ] @@ -6539,7 +6439,7 @@ dependencies = [ "cfg-expr 0.20.9", "heck 0.5.0", "pkg-config", - "toml 1.1.2+spec-1.1.0", + "toml 1.1.4+spec-1.1.0", "version-compare", ] @@ -6549,7 +6449,7 @@ version = "0.35.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d1c93047acf68669466a34690ac58cca7010bd1b201e1ec86f1fd0a75d3dd4a9" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.13.1", "block2", "core-foundation 0.10.1", "core-graphics", @@ -6585,21 +6485,15 @@ dependencies = [ [[package]] name = "tao-macros" -version = "0.1.3" +version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f4e16beb8b2ac17db28eab8bca40e62dbfbb34c0fcdc6d9826b11b7b5d047dfd" +checksum = "5f7eeb6d99155545da6150a1795945f16ac9c178deb2a5f2e74d776107bd5849" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "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 = "tar" version = "0.4.46" @@ -6665,7 +6559,7 @@ dependencies = [ "tauri-runtime", "tauri-runtime-wry", "tauri-utils", - "thiserror 2.0.18", + "thiserror 2.0.20", "tokio", "tray-icon", "url", @@ -6714,9 +6608,9 @@ dependencies = [ "serde", "serde_json", "sha2 0.10.9", - "syn 2.0.118", + "syn 2.0.119", "tauri-utils", - "thiserror 2.0.18", + "thiserror 2.0.20", "time", "url", "uuid", @@ -6732,7 +6626,7 @@ dependencies = [ "heck 0.5.0", "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", "tauri-codegen", "tauri-utils", ] @@ -6765,7 +6659,7 @@ dependencies = [ "serde_json", "tauri", "tauri-plugin", - "thiserror 2.0.18", + "thiserror 2.0.20", ] [[package]] @@ -6782,7 +6676,7 @@ dependencies = [ "tauri", "tauri-plugin", "tauri-utils", - "thiserror 2.0.18", + "thiserror 2.0.20", "tracing", "url", "windows-registry 0.5.3", @@ -6791,9 +6685,9 @@ dependencies = [ [[package]] name = "tauri-plugin-dialog" -version = "2.7.1" +version = "2.7.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "65981abb771e74e571a38196c3baa11c459379164791eba0e67abc1a5fac9884" +checksum = "b2d3c1dbe38037e7f590cdf2492594d5ceebe031e7bc7e827509b22a999d2940" dependencies = [ "log", "raw-window-handle", @@ -6803,7 +6697,7 @@ dependencies = [ "tauri", "tauri-plugin", "tauri-plugin-fs", - "thiserror 2.0.18", + "thiserror 2.0.20", "url", ] @@ -6826,19 +6720,18 @@ dependencies = [ "tauri", "tauri-plugin", "tauri-utils", - "thiserror 2.0.18", - "toml 1.1.2+spec-1.1.0", + "thiserror 2.0.20", + "toml 1.1.4+spec-1.1.0", "url", ] [[package]] name = "tauri-plugin-log" -version = "2.8.0" +version = "2.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7545bd67f070a4500432c826e2e0682146a1d6712aee22a2786490156b574d93" +checksum = "6792296e6f389268016c77db21ebae1fc0568f2fccf88b1ec7e2ea71330afb4c" dependencies = [ "android_logger", - "byte-unit", "fern", "log", "objc2", @@ -6849,7 +6742,7 @@ dependencies = [ "swift-rs", "tauri", "tauri-plugin", - "thiserror 2.0.18", + "thiserror 2.0.20", "time", ] @@ -6865,7 +6758,7 @@ dependencies = [ "serde", "tauri", "tauri-plugin", - "thiserror 2.0.18", + "thiserror 2.0.20", ] [[package]] @@ -6884,7 +6777,7 @@ dependencies = [ "serde_json", "tauri", "tauri-plugin", - "thiserror 2.0.18", + "thiserror 2.0.20", "url", "windows 0.61.3", "zbus", @@ -6907,20 +6800,21 @@ dependencies = [ "shared_child", "tauri", "tauri-plugin", - "thiserror 2.0.18", + "thiserror 2.0.20", "tokio", ] [[package]] name = "tauri-plugin-single-instance" -version = "2.4.2" +version = "2.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c8f29386f5e9fdc699182388a33ee80a56de436d91b67459e86afef426282af" +checksum = "b3214becf9ef5783c0ae99a3bb25adf5353a7a16ebf53e74b909e29205735c6c" dependencies = [ "serde", "serde_json", "tauri", - "thiserror 2.0.18", + "thiserror 2.0.20", + "tokio", "tracing", "windows-sys 0.60.2", "zbus", @@ -6932,13 +6826,13 @@ version = "2.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "73736611e14142408d15353e21e3cca2f12a3cfb523ad0ce85999b6d2ef1a704" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.13.1", "log", "serde", "serde_json", "tauri", "tauri-plugin", - "thiserror 2.0.18", + "thiserror 2.0.20", ] [[package]] @@ -6959,7 +6853,7 @@ dependencies = [ "serde", "serde_json", "tauri-utils", - "thiserror 2.0.18", + "thiserror 2.0.20", "url", "webkit2gtk", "webview2-com", @@ -7022,8 +6916,8 @@ dependencies = [ "serde_json", "serde_with", "swift-rs", - "thiserror 2.0.18", - "toml 1.1.2+spec-1.1.0", + "thiserror 2.0.20", + "toml 1.1.4+spec-1.1.0", "url", "urlpattern", "uuid", @@ -7058,7 +6952,7 @@ dependencies = [ "tauri", "tauri-plugin", "tempfile", - "thiserror 2.0.18", + "thiserror 2.0.20", "tokio", "tracing", "tracing-subscriber", @@ -7077,7 +6971,7 @@ checksum = "cc65d45c68858bfe420dd29e834b5d15dbecf8a07a8a16cf4d532c7b1f69d4b6" dependencies = [ "dunce", "embed-resource", - "toml 1.1.2+spec-1.1.0", + "toml 1.1.4+spec-1.1.0", ] [[package]] @@ -7113,11 +7007,11 @@ dependencies = [ [[package]] name = "thiserror" -version = "2.0.18" +version = "2.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f" dependencies = [ - "thiserror-impl 2.0.18", + "thiserror-impl 2.0.20", ] [[package]] @@ -7128,18 +7022,18 @@ checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] name = "thiserror-impl" -version = "2.0.18" +version = "2.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 3.0.4", ] [[package]] @@ -7167,9 +7061,9 @@ dependencies = [ [[package]] name = "time" -version = "0.3.53" +version = "0.3.55" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "18dfaaeddcb932337b5e7866ee7d0ce9b76d2fd092997146f187ec09b4558a50" +checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134" dependencies = [ "deranged", "libc", @@ -7189,9 +7083,9 @@ checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" [[package]] name = "time-macros" -version = "0.2.31" +version = "0.2.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c431b87111666e491a90baa837f914fb45cd5dc3c268591b0220ff5057f2085f" +checksum = "7e689342a48d2ea927c87ea50cabf8594854bf940e9310208848d680d668ed85" dependencies = [ "num-conv", "time-core", @@ -7234,9 +7128,9 @@ dependencies = [ [[package]] name = "tinystr" -version = "0.8.3" +version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" +checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643" dependencies = [ "displaydoc", "zerovec", @@ -7244,9 +7138,9 @@ dependencies = [ [[package]] name = "tinyvec" -version = "1.11.0" +version = "1.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3" +checksum = "bb4ebadaa0af04fab11ae01eb5f9fdb5f9c5b875506e210e71c07873528baa7f" dependencies = [ "tinyvec_macros", ] @@ -7259,9 +7153,9 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" [[package]] name = "tokio" -version = "1.53.0" +version = "1.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d988bcd52dbe076d3d46903332f58c912b87a2c49b1428419a5845154762ffee" +checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed" dependencies = [ "bytes", "libc", @@ -7277,13 +7171,13 @@ dependencies = [ [[package]] name = "tokio-macros" -version = "2.7.0" +version = "2.7.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" +checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 3.0.4", ] [[package]] @@ -7328,24 +7222,37 @@ name = "tokio-tungstenite" version = "0.29.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f72a05e828585856dacd553fba484c242c46e391fb0e58917c942ee9202915c" +dependencies = [ + "futures-util", + "log", + "tokio", + "tungstenite 0.29.0", +] + +[[package]] +name = "tokio-tungstenite" +version = "0.30.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "17a073bfed563fa236697a068031408a93cd9522e08abf9933ead3e73411bd71" dependencies = [ "futures-util", "log", "native-tls", "tokio", "tokio-native-tls", - "tungstenite", + "tungstenite 0.30.0", ] [[package]] name = "tokio-util" -version = "0.7.18" +version = "0.7.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" +checksum = "494815d09bf52b5548659851081238f0ca39ff638363907596da739561c62c52" dependencies = [ "bytes", "futures-core", "futures-sink", + "libc", "pin-project-lite", "tokio", ] @@ -7379,9 +7286,9 @@ dependencies = [ [[package]] name = "toml" -version = "1.1.2+spec-1.1.0" +version = "1.1.4+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "81f3d15e84cbcd896376e6730314d59fb5a87f31e4b038454184435cd57defee" +checksum = "3aace63f4bbcdfc2c965b059de67119c89c4017a70d633be6c104910f67056f5" dependencies = [ "indexmap 2.14.0", "serde_core", @@ -7389,7 +7296,7 @@ dependencies = [ "toml_datetime 1.1.1+spec-1.1.0", "toml_parser", "toml_writer", - "winnow 1.0.3", + "winnow 1.0.4", ] [[package]] @@ -7445,30 +7352,30 @@ dependencies = [ [[package]] name = "toml_edit" -version = "0.25.12+spec-1.1.0" +version = "0.25.13+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d2153edc6955a6c354fad8f5efd38b6a8769bdccf9fe50f8e1329f81b0baa5d7" +checksum = "6975367e4d2ef766d86af01ffad14b622fecc8d4357a998fbc4deb6e9bacaf9b" dependencies = [ "indexmap 2.14.0", "toml_datetime 1.1.1+spec-1.1.0", "toml_parser", - "winnow 1.0.3", + "winnow 1.0.4", ] [[package]] name = "toml_parser" -version = "1.1.2+spec-1.1.0" +version = "1.1.3+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" +checksum = "1d38ac1cf9b95face32296c0a3ede1fdc270627c9d9c02a7274dd6d960dc4d56" dependencies = [ - "winnow 1.0.3", + "winnow 1.0.4", ] [[package]] name = "toml_writer" -version = "1.1.1+spec-1.1.0" +version = "1.1.2+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "756daf9b1013ebe47a8776667b466417e2d4c5679d441c26230efd9ef78692db" +checksum = "7d56353a2a665ad0f41a421187180aab746c8c325620617ad883a99a1cbe66d2" [[package]] name = "tower" @@ -7492,7 +7399,7 @@ version = "0.6.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.13.1", "bytes", "futures-util", "http", @@ -7510,7 +7417,7 @@ version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b11f75e912b0c2be01b63d8cf8057b8c3f97cf34abb3d431a3a4c8675498e233" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.13.1", "bytes", "http", "percent-encoding", @@ -7551,7 +7458,7 @@ checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -7608,20 +7515,20 @@ dependencies = [ [[package]] name = "trait-variant" -version = "0.1.2" +version = "0.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "70977707304198400eb4835a78f6a9f928bf41bba420deb8fdb175cd965d77a7" +checksum = "b19a4867a870f6edc4c283f2b455804b1879c0baf0e642f26b03ed8ee262d9d3" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] name = "tray-icon" -version = "0.24.1" +version = "0.24.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "65ba1e5f6b9ef9fd87e21b9c6f351554dbd717960089168fcfdef854686961dc" +checksum = "045979e3f037cd18ad1cb2a419dfda133c5c29c9f3453370079f2255d46c257e" dependencies = [ "crossbeam-channel", "dirs", @@ -7635,7 +7542,7 @@ dependencies = [ "once_cell", "png 0.18.1", "serde", - "thiserror 2.0.18", + "thiserror 2.0.20", "windows-sys 0.61.2", ] @@ -7656,20 +7563,27 @@ version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" -[[package]] -name = "ttf-parser" -version = "0.25.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d2df906b07856748fa3f6e0ad0cbaa047052d4a7dd609e231c4f72cee8c36f31" -dependencies = [ - "core_maths", -] - [[package]] name = "tungstenite" version = "0.29.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6c01152af293afb9c7c2a57e4b559c5620b421f6d133261c60dd2d0cdb38e6b8" +dependencies = [ + "bytes", + "data-encoding", + "http", + "httparse", + "log", + "rand 0.9.5", + "sha1 0.10.7", + "thiserror 2.0.20", +] + +[[package]] +name = "tungstenite" +version = "0.30.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e48ac77174b19c110a50ab2128b24215ac9cb40e0e12e093fb602d175c569d22" dependencies = [ "bytes", "data-encoding", @@ -7677,9 +7591,9 @@ dependencies = [ "httparse", "log", "native-tls", - "rand 0.9.4", - "sha1", - "thiserror 2.0.18", + "rand 0.10.2", + "sha1 0.11.0", + "thiserror 2.0.20", ] [[package]] @@ -7764,30 +7678,12 @@ version = "0.3.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5" -[[package]] -name = "unicode-bidi-mirroring" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5dfa6e8c60bb66d49db113e0125ee8711b7647b5579dc7f5f19c42357ed039fe" - -[[package]] -name = "unicode-ccc" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ce61d488bcdc9bc8b5d1772c404828b17fc481c0a582b5581e95fb233aef503e" - [[package]] name = "unicode-ident" version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" -[[package]] -name = "unicode-properties" -version = "0.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d" - [[package]] name = "unicode-script" version = "0.5.8" @@ -7871,38 +7767,32 @@ dependencies = [ [[package]] name = "usvg" -version = "0.47.0" +version = "0.48.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d46cf96c5f498d36b7a9693bc6a7075c0bb9303189d61b2249b0dc3d309c07de" +checksum = "977d0a4abdef933f424a99fe09f95576e089b90aebc6f016a3bc813762493e91" dependencies = [ - "base64 0.22.1", + "base64 0.23.1", "data-url", "flate2", "fontdb", + "harfrust", "imagesize", "kurbo", "log", "pico-args", "roxmltree 0.21.1", - "rustybuzz", "simplecss", "siphasher", + "skrifa", "strict-num", "svgtypes", "tiny-skia-path", - "ttf-parser", "unicode-bidi", "unicode-script", "unicode-vo", "xmlwriter", ] -[[package]] -name = "utf8-width" -version = "0.1.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "159a7cadce548703edd50d24069bc294c5415ecab0a480e0cd1ca06d112dc94a" - [[package]] name = "utf8_iter" version = "1.0.4" @@ -7949,14 +7839,14 @@ dependencies = [ "proc-macro2", "quote", "regex", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] name = "uuid" -version = "1.24.0" +version = "1.25.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bf3923a6f5c4c6382e0b653c4117f48d631ea17f38ed86e2a828e6f7412f5239" +checksum = "f053576934f05a761a402421fbbe3d425d9366f75f978806a037b3ca481abecc" dependencies = [ "getrandom 0.4.3", "js-sys", @@ -7981,12 +7871,6 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" -[[package]] -name = "value-bag" -version = "1.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7ba6f5989077681266825251a52748b8c1d8a4ad098cc37e440103d0ea717fc0" - [[package]] name = "vcpkg" version = "0.2.15" @@ -8061,9 +7945,9 @@ dependencies = [ [[package]] name = "wasm-bindgen" -version = "0.2.126" +version = "0.2.127" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4b067c0c11094aef6b7a801c1e34a26affafdf3d051dba08456b868789aaf9a4" +checksum = "1b70935747edd64d89de3efa29d73789b806c15798f8e7dca4d8ac356b50ce70" dependencies = [ "cfg-if", "once_cell", @@ -8074,9 +7958,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-futures" -version = "0.4.76" +version = "0.4.77" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c62df1340f32221cb9c54d6a27b030e3dba64361d4a95bed55f9aacb44da291d" +checksum = "6b7777d5cc23d0e91404e53ce2d5e8ec7acae3026b16233dba62cd3246457950" dependencies = [ "js-sys", "wasm-bindgen", @@ -8084,9 +7968,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.126" +version = "0.2.127" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "167ce5e579f6bcf889c4f7175a8a5a585de84e8ff93976ce393efa5f2837aab1" +checksum = "77775f8f3f7217702089053b94958f8f54061a3f663417df76e19cbdcca29bc1" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -8094,22 +7978,22 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.126" +version = "0.2.127" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f3997c7839262f4ef12cf90b818d6340c18e80f263f1a94bf157d0ec4420380e" +checksum = "e11d33f857dc2fb11b8bc75aee111aa9cbeb12cd9f25efd3d4c2a3dd4e235284" dependencies = [ "bumpalo", "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", "wasm-bindgen-shared", ] [[package]] name = "wasm-bindgen-shared" -version = "0.2.126" +version = "0.2.127" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc1b4cb0cc549fcf58d7dfc081778139b3d283a081644e833e84682ad71cea24" +checksum = "7ef64dbcc55df09c7e5a46182d181c2cfa3e925f3da937ea764728b4bbb9dcbf" dependencies = [ "unicode-ident", ] @@ -8129,9 +8013,9 @@ dependencies = [ [[package]] name = "wayland-backend" -version = "0.3.15" +version = "0.3.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2857dd20b54e916ec7253b3d6b4d5c4d7d4ca2c33c2e11c6c76a99bd8744755d" +checksum = "38a91b4eaddff87b1cd1074985e3713da4af2c49742d1b356b2c01670a67a078" dependencies = [ "cc", "downcast-rs", @@ -8142,11 +8026,11 @@ dependencies = [ [[package]] name = "wayland-client" -version = "0.31.14" +version = "0.31.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "645c7c96bb74690c3189b5c9cb4ca1627062bb23693a4fad9d8c3de958260144" +checksum = "e3c36a0f861ad76d0901f2800b46321410d9f73f2ea88aac0650d86c32688073" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.13.1", "rustix", "wayland-backend", "wayland-scanner", @@ -8158,7 +8042,7 @@ version = "0.32.13" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "23d0c813de3daa2ed6520af85a3bd49b0e722a3078506899aa9686fea58dc4b6" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.13.1", "wayland-backend", "wayland-client", "wayland-scanner", @@ -8170,7 +8054,7 @@ version = "0.3.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "eb04e52f7836d7c7976c78ca0250d61e33873c34156a2a1fc9474828ec268234" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.13.1", "wayland-backend", "wayland-client", "wayland-protocols", @@ -8184,7 +8068,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "338e30461b3a2b67d70eb30a6d89f8e0c93a833e07d2ae89085cd070c4a00ac0" dependencies = [ "proc-macro2", - "quick-xml", + "quick-xml 0.41.0", "quote", ] @@ -8199,9 +8083,9 @@ dependencies = [ [[package]] name = "web-sys" -version = "0.3.103" +version = "0.3.104" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8622dcb61c0bcc9fffa6938bed81210af2da9a7e4a1a834b2e37a59b6dfb6141" +checksum = "c435338968042f4f59a557f690a253676d47ce13ceb55d70100e7facf6620a30" dependencies = [ "js-sys", "wasm-bindgen", @@ -8209,9 +8093,9 @@ dependencies = [ [[package]] name = "web_atoms" -version = "0.2.5" +version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "075474b12bcb3d2e3d4546580e9de478eeeead668a1761e2a8860c836b7ef297" +checksum = "ba8b815c1b593dc0baf78dd0f4fc8fdb2de53198fb1163738093e9a311c33fb3" dependencies = [ "phf 0.13.1", "phf_codegen", @@ -8285,7 +8169,7 @@ checksum = "67a921c1b6914c367b2b823cd4cde6f96beec77d30a939c8199bb377cf9b9b54" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -8294,7 +8178,7 @@ version = "0.38.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "381336cfffd772377d291702245447a5251a2ffa5bad679c99e61bc48bacbf9c" dependencies = [ - "thiserror 2.0.18", + "thiserror 2.0.20", "windows 0.61.3", "windows-core 0.61.2", ] @@ -8450,7 +8334,7 @@ checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -8461,7 +8345,7 @@ checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -8829,9 +8713,9 @@ checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" [[package]] name = "winnow" -version = "1.0.3" +version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0592e1c9d151f854e6fd382574c3a0855250e1d9b2f99d9281c6e6391af352f1" +checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" dependencies = [ "memchr", ] @@ -8872,7 +8756,7 @@ dependencies = [ "log", "os_pipe", "rustix", - "thiserror 2.0.18", + "thiserror 2.0.20", "tree_magic_mini", "wayland-backend", "wayland-client", @@ -8882,9 +8766,9 @@ dependencies = [ [[package]] name = "writeable" -version = "0.6.3" +version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" +checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" [[package]] name = "wry" @@ -8919,7 +8803,7 @@ dependencies = [ "sha2 0.10.9", "soup3", "tao-macros", - "thiserror 2.0.18", + "thiserror 2.0.20", "url", "webkit2gtk", "webkit2gtk-sys", @@ -8930,15 +8814,6 @@ dependencies = [ "x11-dl", ] -[[package]] -name = "wyz" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "05f360fc0b24296329c78fda852a1e9ae82de9cf7b27dae4b7f62f118f77b9ed" -dependencies = [ - "tap", -] - [[package]] name = "x11" version = "2.21.0" @@ -9030,15 +8905,15 @@ checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", "synstructure", ] [[package]] name = "zbus" -version = "5.17.0" +version = "5.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a28b97f866896a4be7aefd2b5a8e01bb6773d19a775d54ab28b4d094b9a4480e" +checksum = "5db4be7c075cb421e4b7ee645541604239bd243ba7c357511f4ff3a74b555907" dependencies = [ "async-broadcast", "async-executor", @@ -9064,7 +8939,7 @@ dependencies = [ "uds_windows", "uuid", "windows-sys 0.61.2", - "winnow 1.0.3", + "winnow 1.0.4", "zbus_macros", "zbus_names", "zvariant", @@ -9072,14 +8947,14 @@ dependencies = [ [[package]] name = "zbus_macros" -version = "5.17.0" +version = "5.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e05ad887425eecf5e8384dc2406a4a9313eb73468712fc1cdea362eb4fe0469" +checksum = "2990635d09ade6df1868f72f8cac69a876a90981e8bd3c40b1be413f8dc88f40" dependencies = [ "proc-macro-crate 3.5.0", "proc-macro2", "quote", - "syn 2.0.118", + "syn 3.0.4", "zbus_names", "zvariant", "zvariant_utils", @@ -9087,33 +8962,42 @@ dependencies = [ [[package]] name = "zbus_names" -version = "4.3.3" +version = "4.3.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1039ca249fee9559680f3a9f05b55e0761fee51af4f6c1e7d8c1f31e549721d2" +checksum = "d8bf88b4a3ff53e883001e0e0115b297a9d53c31b9c1edd2bfdd853e3428624e" dependencies = [ "serde", - "winnow 1.0.3", + "winnow 1.0.4", "zvariant", ] [[package]] -name = "zerocopy" -version = "0.8.53" +name = "zcheapstr" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75726053136156d419e285b9b7eddaaea9e3fea6ce32eed44a89901f0bd98de1" +checksum = "d1afec51604565183aeb5c54c20aeab286120d4e4460f7f76e3e8bb8c0d99473" +dependencies = [ + "serde", +] + +[[package]] +name = "zerocopy" +version = "0.8.56" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "556764e583adb45a9f8d413c2a147fa7e8d821e48e12b14fd560b607998b75eb" dependencies = [ "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.8.53" +version = "0.8.56" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4714fd92cf900833d49538023a9b3915155210801d1c1169eba513b2addefd71" +checksum = "f2ab42fc20575779bd240faa45f94a74256f755c0fa9e89f0ede20d91d0cdfc1" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -9133,7 +9017,7 @@ checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", "synstructure", ] @@ -9154,14 +9038,14 @@ checksum = "3c50655cbb0fe3fc43170059e702f1ce5e19b84cec58dc87b037a09935c2f328" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] name = "zerotrie" -version = "0.2.4" +version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" +checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f" dependencies = [ "displaydoc", "yoke", @@ -9170,9 +9054,9 @@ dependencies = [ [[package]] name = "zerovec" -version = "0.11.6" +version = "0.11.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" +checksum = "bb0464e17806c1d976d5cba29399c7f08e516e279e2ba493f63123b5fca67dd8" dependencies = [ "yoke", "zerofrom", @@ -9181,13 +9065,13 @@ dependencies = [ [[package]] name = "zerovec-derive" -version = "0.11.3" +version = "0.11.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" +checksum = "34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 3.0.4", ] [[package]] @@ -9205,15 +9089,15 @@ dependencies = [ [[package]] name = "zmij" -version = "1.0.21" +version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" [[package]] name = "zune-core" -version = "0.5.1" +version = "0.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cb8a0807f7c01457d0379ba880ba6322660448ddebc890ce29bb64da71fb40f9" +checksum = "d56377fd46368984a170bc5aac5567e52ca5da874caa60bea39fcbca78fb658b" [[package]] name = "zune-inflate" @@ -9235,40 +9119,41 @@ dependencies = [ [[package]] name = "zvariant" -version = "5.13.0" +version = "5.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7cf057bb00bf5c9ad77abb6147b0ca4818236a1858416e9d988e40d6322fefa7" +checksum = "c1d34c27cc6cdd1f458427519dd6b8612f7b7e3f7b9a0b2355d041dda9869147" dependencies = [ "endi", "enumflags2", "serde", - "winnow 1.0.3", + "winnow 1.0.4", + "zcheapstr", "zvariant_derive", "zvariant_utils", ] [[package]] name = "zvariant_derive" -version = "5.13.0" +version = "5.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8118ca6bda77bfc0ab51d660db0c955f2505eef854c9a449435bccb616933b31" +checksum = "864155e69b4352db0c7f374917bf45d1e0c8d17659c8b3dbf9795f3673f8c497" dependencies = [ "proc-macro-crate 3.5.0", "proc-macro2", "quote", - "syn 2.0.118", + "syn 3.0.4", "zvariant_utils", ] [[package]] name = "zvariant_utils" -version = "3.5.0" +version = "4.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "90cb9383f9b45290407a1258b202d3f8f01db719eb60b4e4055c6375af4fc7c7" +checksum = "bad0294361a320b694a328460dc73add56c306150f5cb6bfafc44446120008a3" dependencies = [ "proc-macro2", "quote", "serde", - "syn 2.0.118", - "winnow 1.0.3", + "syn 3.0.4", + "winnow 1.0.4", ] diff --git a/e2e/coverage-map.mjs b/e2e/coverage-map.mjs index f560f9a..5cd1e10 100644 --- a/e2e/coverage-map.mjs +++ b/e2e/coverage-map.mjs @@ -129,7 +129,8 @@ export const commandCoverage = { "read_profile_cookies", "get_profile_cookie_stats", "copy_profile_cookies", - "import_cookies_from_file", + "analyze_pasted_cookies", + "import_pasted_cookies", "export_profile_cookies", "set_profile_password", "change_profile_password", diff --git a/e2e/tests/entities.test.mjs b/e2e/tests/entities.test.mjs index 52e3d57..d189210 100644 --- a/e2e/tests/entities.test.mjs +++ b/e2e/tests/entities.test.mjs @@ -766,11 +766,16 @@ test("cookie import/copy/export, profile encryption, and traffic-stat read/clear expirationDate: 2_000_000_000, }, ]); - const imported = await app.invoke("import_cookies_from_file", { + const imported = await app.invoke("import_pasted_cookies", { profileId: source.id, content: cookieJson, + site: null, + mode: "merge", + includeExpired: false, }); - assert.equal(imported.cookies_imported, 1); + assert.equal(imported.added, 1); + assert.equal(imported.overwritten, 0); + assert.equal(imported.deleted, 0); const cookies = await app.invoke("read_profile_cookies", { profileId: source.id, }); @@ -803,6 +808,62 @@ test("cookie import/copy/export, profile encryption, and traffic-stat read/clear /fixture\.local/, ); + const paste = [ + "# Netscape HTTP Cookie File", + "#HttpOnly_.fixture.local\tTRUE\t/\tFALSE\t2000000000\tpasted\tpasted-value", + ].join("\n"); + const analysis = await app.invoke("analyze_pasted_cookies", { + profileId: target.id, + content: paste, + site: null, + }); + assert.equal(analysis.format, "netscape"); + assert.equal(analysis.cookies.length, 1); + assert.equal(analysis.cookies[0].name, "pasted"); + assert.equal(analysis.cookies[0].isHttpOnly, true); + assert.equal( + analysis.cookies[0].value, + undefined, + "the preview must never carry the cookie value", + ); + assert.equal(analysis.siteRequired, false); + assert.equal(analysis.expiredCount, 0); + assert.equal(analysis.blockedBy, null); + // The copied fixture.local cookie is the one row replace mode would clear. + assert.equal(analysis.replaceDeleteCount, 1); + + const merged = await app.invoke("import_pasted_cookies", { + profileId: target.id, + content: paste, + site: null, + mode: "merge", + includeExpired: false, + }); + assert.equal(merged.added, 1); + assert.equal(merged.deleted, 0); + assert.equal(merged.skipped, 0); + assert.equal( + (await app.invoke("get_profile_cookie_stats", { profileId: target.id })) + .total_count, + 2, + ); + + // Both spellings of the pasted site go, and only they do. + const replacedPaste = await app.invoke("import_pasted_cookies", { + profileId: target.id, + content: paste, + site: null, + mode: "replaceMatchingSites", + includeExpired: false, + }); + assert.equal(replacedPaste.deleted, 2); + assert.equal(replacedPaste.added, 1); + const afterReplace = await app.invoke("read_profile_cookies", { + profileId: target.id, + }); + assert.equal(afterReplace.total_count, 1); + assert.equal(afterReplace.domains[0].cookies[0].name, "pasted"); + await app.invoke("set_profile_password", { profileId: source.id, password: "correct horse battery staple", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 1d4b72d..83f8bc6 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -148,6 +148,10 @@ windows = { version = "0.62", features = [ "Win32_Storage_FileSystem", "Win32_System_Registry", "Win32_UI_Shell", + # SendMessageTimeoutW, for the association-change broadcast in + # default_browser.rs. Going through the crate rather than a hand-written + # `extern "system"` block is what keeps `lpdwResult` typed as DWORD_PTR. + "Win32_UI_WindowsAndMessaging", # CryptUnprotectData, for unwrapping the source browser's os_crypt key from # `Local State` during profile import. "Win32_Security_Cryptography", diff --git a/src-tauri/src/api_server.rs b/src-tauri/src/api_server.rs index 3197bc5..c290d60 100644 --- a/src-tauri/src/api_server.rs +++ b/src-tauri/src/api_server.rs @@ -4253,7 +4253,7 @@ async fn import_profiles_api( (status = 400, description = "Invalid cookie file or unsupported browser"), (status = 401, description = "Unauthorized"), (status = 404, description = "Profile not found"), - (status = 409, description = "Browser is currently running"), + (status = 409, description = "Browser is running, the profile is password-protected, or a remote session owns it"), (status = 500, description = "Internal server error") ), security( @@ -4300,10 +4300,16 @@ async fn import_profile_cookies( })) } Err(e) => { - let msg = e.to_lowercase(); - if msg.contains("running") { + // The importer speaks in `{"code":…}` strings now; match those, and keep + // the substring checks for the messages that are still plain text. + if e.contains("COOKIE_IMPORT_BROWSER_RUNNING") + || e.contains("COOKIE_IMPORT_PROFILE_PROTECTED") + || e.contains("COOKIE_IMPORT_REMOTE_SESSION") + { Err(StatusCode::CONFLICT) - } else if msg.contains("no valid cookies") || msg.contains("unsupported browser") { + } else if e.contains("COOKIE_IMPORT_NO_COOKIES") + || e.to_lowercase().contains("unsupported browser") + { Err(StatusCode::BAD_REQUEST) } else { Err(StatusCode::INTERNAL_SERVER_ERROR) diff --git a/src-tauri/src/cookie_manager.rs b/src-tauri/src/cookie_manager.rs index 770ad61..2e26c43 100644 --- a/src-tauri/src/cookie_manager.rs +++ b/src-tauri/src/cookie_manager.rs @@ -184,6 +184,95 @@ pub struct CookieImportResult { pub errors: Vec, } +/// What a write does to cookies the profile already has. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum CookieWriteMode { + /// Update the rows the incoming cookies match, insert the rest, delete + /// nothing. + #[default] + Merge, + /// Clear every cookie the profile holds for the sites named in this write + /// before applying it, so the result is exactly what was pasted. Subdomains + /// and every other site are left alone. + ReplaceMatchingSites, +} + +#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CookieWriteCounts { + pub added: usize, + pub overwritten: usize, + pub deleted: usize, +} + +/// One accepted cookie, as the paste preview shows it. +/// +/// Deliberately has no `value`: the value IS the credential, and a preview that +/// carries it puts every pasted session token into the frontend's state, its +/// logs and any screenshot of the dialog. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PastedCookiePreview { + pub name: String, + pub domain: String, + pub path: String, + pub expires: i64, + pub is_secure: bool, + pub is_http_only: bool, + pub same_site: i32, +} + +impl From<&UnifiedCookie> for PastedCookiePreview { + fn from(cookie: &UnifiedCookie) -> Self { + Self { + name: cookie.name.clone(), + domain: cookie.domain.clone(), + path: cookie.path.clone(), + expires: cookie.expires, + is_secure: cookie.is_secure, + is_http_only: cookie.is_http_only, + same_site: cookie.same_site, + } + } +} + +/// Everything the paste dialog needs to describe a paste before writing it. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CookiePasteAnalysis { + pub format: Option, + pub cookies: Vec, + pub issues: Vec, + pub site_required: bool, + pub expired_count: usize, + /// How many stored rows [`CookieWriteMode::ReplaceMatchingSites`] would + /// delete for the sites this paste names. `None` when the store cannot be + /// read — a profile that has never launched, a locked database, a browser + /// with no supported cookie store. + pub replace_delete_count: Option, + /// The profile wipes its browsing data when the browser exits, so an import + /// here lives exactly one session. Not an error, but silently losing a pasted + /// login is the worst thing this feature can do. + pub clears_on_close: bool, + /// The `{"code":…}` string explaining why an import would be refused right + /// now, ready for `translateBackendError`. `None` when the import can run. + pub blocked_by: Option, +} + +/// Result of writing a paste into a profile. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CookiePasteImportResult { + pub added: usize, + pub overwritten: usize, + pub deleted: usize, + /// Cookies that parsed cleanly but were not written, i.e. expired ones the + /// caller chose to leave out. + pub skipped: usize, + pub issues: Vec, +} + pub struct CookieManager; impl CookieManager { @@ -391,19 +480,32 @@ impl CookieManager { /// the os_crypt key derivation between Wayfern's runtime and an external /// writer is not guaranteed to match, and a ciphertext Chromium can't /// decrypt silently produces an empty cookie value at runtime. + /// + /// The whole write runs in one transaction, so a row that fails cannot leave + /// half a paste applied — the half that would matter is the half holding the + /// login. fn write_chrome_cookies( db_path: &Path, cookies: &[UnifiedCookie], - ) -> Result<(usize, usize), String> { - let conn = Connection::open(db_path).map_err(|e| format!("Failed to open database: {e}"))?; + mode: CookieWriteMode, + ) -> Result { + let mut conn = + Connection::open(db_path).map_err(|e| format!("Failed to open database: {e}"))?; + let tx = conn + .transaction() + .map_err(|e| format!("Failed to start cookie transaction: {e}"))?; - let mut copied = 0; - let mut replaced = 0; + let mut counts = CookieWriteCounts::default(); - let now = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap() - .as_secs() as i64; + if mode == CookieWriteMode::ReplaceMatchingSites { + for host in Self::replace_host_keys(cookies) { + counts.deleted += tx + .execute("DELETE FROM cookies WHERE host_key = ?1", params![host]) + .map_err(|e| format!("Failed to clear existing cookies: {e}"))?; + } + } + + let now = Self::now_secs(); // Session cookies get 30 days of persistence so they survive restart. // routinely exported as a session cookie; writing it as memory-only // (is_persistent = 0) makes Chromium drop it on the next flush, so the @@ -424,43 +526,46 @@ impl CookieManager { let source_port: i32 = if cookie.is_secure { 443 } else { 80 }; let source_scheme: i32 = if cookie.is_secure { 2 } else { 1 }; - let existing: Option = conn + // Four columns, matching the store's own unique index. A three-column + // probe collapses every top-frame partition of one host/name/path into a + // single arbitrary row: the rest keep their old values and the write + // still reports one replacement. + let existing: Option = tx .query_row( - "SELECT rowid FROM cookies WHERE host_key = ?1 AND name = ?2 AND path = ?3", + "SELECT rowid FROM cookies + WHERE host_key = ?1 AND top_frame_site_key = '' AND name = ?2 AND path = ?3", params![&cookie.domain, &cookie.name, &cookie.path], |row| row.get(0), ) .ok(); - if existing.is_some() { - conn - .execute( - "UPDATE cookies SET value = ?1, encrypted_value = x'', expires_utc = ?2, is_secure = ?3, + if let Some(rowid) = existing { + // creation_utc is deliberately not written: Chromium evicts by it, so + // refreshing a cookie must not make it look brand new. + tx.execute( + "UPDATE cookies SET value = ?1, encrypted_value = x'', expires_utc = ?2, is_secure = ?3, is_httponly = ?4, samesite = ?5, last_access_utc = ?6, last_update_utc = ?7, has_expires = ?8, is_persistent = ?9, source_scheme = ?10, source_port = ?11 - WHERE host_key = ?12 AND name = ?13 AND path = ?14", - params![ - &cookie.value, - Self::unix_to_chrome_time(expires), - cookie.is_secure as i32, - cookie.is_http_only as i32, - cookie.same_site, - Self::unix_to_chrome_time(cookie.last_accessed), - Self::unix_to_chrome_time(now), - has_expires, - is_persistent, - source_scheme, - source_port, - &cookie.domain, - &cookie.name, - &cookie.path, - ], - ) - .map_err(|e| format!("Failed to update cookie: {e}"))?; - replaced += 1; + WHERE rowid = ?12", + params![ + &cookie.value, + Self::unix_to_chrome_time(expires), + cookie.is_secure as i32, + cookie.is_http_only as i32, + cookie.same_site, + Self::unix_to_chrome_time(cookie.last_accessed), + Self::unix_to_chrome_time(now), + has_expires, + is_persistent, + source_scheme, + source_port, + rowid, + ], + ) + .map_err(|e| format!("Failed to update cookie: {e}"))?; + counts.overwritten += 1; } else { - conn - .execute( + tx.execute( "INSERT INTO cookies (creation_utc, host_key, top_frame_site_key, name, value, encrypted_value, path, expires_utc, is_secure, is_httponly, last_access_utc, has_expires, @@ -486,11 +591,53 @@ impl CookieManager { ], ) .map_err(|e| format!("Failed to insert cookie: {e}"))?; - copied += 1; + counts.added += 1; } } - Ok((copied, replaced)) + tx.commit() + .map_err(|e| format!("Failed to commit cookies: {e}"))?; + + Ok(counts) + } + + /// Which `host_key` values a replace-mode write clears out first. + /// + /// `.example.com` and `example.com` are one site to the person pasting and + /// two distinct host keys to Chromium, so replacing one without the other + /// leaves the stale half of the login behind. Subdomains stay untouched: + /// nothing in the paste says anything about them. + fn replace_host_keys(cookies: &[UnifiedCookie]) -> Vec { + let mut hosts = std::collections::BTreeSet::new(); + for cookie in cookies { + let bare = cookie.domain.trim_start_matches('.'); + if bare.is_empty() { + continue; + } + hosts.insert(bare.to_string()); + hosts.insert(format!(".{bare}")); + } + hosts.into_iter().collect() + } + + /// How many stored rows a replace-mode write of `cookies` would delete. + /// + /// Reads the snapshot view so a running browser's lock does not turn the + /// preview into an error. + fn count_replaceable_rows(db_path: &Path, cookies: &[UnifiedCookie]) -> Result { + let conn = Self::open_cookie_db_readonly(db_path)?; + let mut total = 0usize; + for host in Self::replace_host_keys(cookies) { + let count: i64 = conn + .query_row( + "SELECT COUNT(*) FROM cookies WHERE host_key = ?1", + params![host], + |row| row.get(0), + ) + .map_err(|e| crate::backend_error_with_detail("COOKIE_DB_UNAVAILABLE", e))?; + total += count as usize; + } + Ok(total) } /// Public API: Read cookies from a profile @@ -760,7 +907,9 @@ impl CookieManager { }; let write_result = match target.browser.as_str() { - "wayfern" => Self::write_chrome_cookies(&target_db_path, &cookies_to_copy), + "wayfern" => { + Self::write_chrome_cookies(&target_db_path, &cookies_to_copy, CookieWriteMode::Merge) + } _ => { results.push(CookieCopyResult { target_profile_id: target_id.clone(), @@ -773,11 +922,11 @@ impl CookieManager { }; match write_result { - Ok((copied, replaced)) => { + Ok(counts) => { results.push(CookieCopyResult { target_profile_id: target_id.clone(), - cookies_copied: copied, - cookies_replaced: replaced, + cookies_copied: counts.added, + cookies_replaced: counts.overwritten, errors: vec![], }); } @@ -795,152 +944,12 @@ impl CookieManager { Ok(results) } - /// Parse Netscape format cookies from text content - fn parse_netscape_cookies(content: &str) -> (Vec, Vec) { - let mut cookies = Vec::new(); - let mut errors = Vec::new(); - let now = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap() - .as_secs() as i64; - - for (i, line) in content.lines().enumerate() { - let line = line.trim(); - if line.is_empty() || line.starts_with('#') { - continue; - } - - let fields: Vec<&str> = line.split('\t').collect(); - if fields.len() < 7 { - errors.push(format!( - "Line {}: expected 7 tab-separated fields, got {}", - i + 1, - fields.len() - )); - continue; - } - - let domain = fields[0].to_string(); - let path = fields[2].to_string(); - let is_secure = fields[3].eq_ignore_ascii_case("TRUE"); - let expires = fields[4].parse::().unwrap_or(0); - let name = fields[5].to_string(); - let value = fields[6].to_string(); - - cookies.push(UnifiedCookie { - name, - value, - domain, - path, - expires, - is_secure, - is_http_only: false, - same_site: 0, - creation_time: now, - last_accessed: now, - }); - } - - (cookies, errors) - } - - /// Parse JSON format cookies (array of cookie objects, e.g. from browser extensions) - fn parse_json_cookies(content: &str) -> (Vec, Vec) { - let mut cookies = Vec::new(); - let mut errors = Vec::new(); - let now = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap() - .as_secs() as i64; - - let arr: Vec = match serde_json::from_str(content) { - Ok(v) => v, - Err(e) => { - errors.push(format!("Failed to parse JSON: {e}")); - return (cookies, errors); - } - }; - - for (i, obj) in arr.iter().enumerate() { - let name = match obj.get("name").and_then(|v| v.as_str()) { - Some(s) => s.to_string(), - None => { - errors.push(format!("Cookie {}: missing 'name' field", i + 1)); - continue; - } - }; - let value = obj - .get("value") - .and_then(|v| v.as_str()) - .unwrap_or("") - .to_string(); - let domain = match obj.get("domain").and_then(|v| v.as_str()) { - Some(s) => s.to_string(), - None => { - errors.push(format!("Cookie {}: missing 'domain' field", i + 1)); - continue; - } - }; - let path = obj - .get("path") - .and_then(|v| v.as_str()) - .unwrap_or("/") - .to_string(); - let is_secure = obj.get("secure").and_then(|v| v.as_bool()).unwrap_or(false); - let is_http_only = obj - .get("httpOnly") - .and_then(|v| v.as_bool()) - .unwrap_or(false); - let is_session = obj - .get("session") - .and_then(|v| v.as_bool()) - .unwrap_or(false); - let expires = if is_session { - 0 - } else { - obj - .get("expirationDate") - .and_then(|v| v.as_f64()) - .map(|f| f as i64) - .unwrap_or(0) - }; - let same_site = obj - .get("sameSite") - .and_then(|v| v.as_str()) - .map(|s| match s { - "lax" => 1, - "strict" => 2, - _ => 0, // "no_restriction" or unrecognized - }) - .unwrap_or(0); - - cookies.push(UnifiedCookie { - name, - value, - domain, - path, - expires, - is_secure, - is_http_only, - same_site, - creation_time: now, - last_accessed: now, - }); - } - - (cookies, errors) - } - - /// Auto-detect cookie format and parse - fn parse_cookies(content: &str) -> (Vec, Vec) { - let trimmed = content.trim(); - if trimmed.starts_with('[') && serde_json::from_str::>(trimmed).is_ok() { - return Self::parse_json_cookies(trimmed); - } - Self::parse_netscape_cookies(content) - } - /// Format cookies as Netscape TXT + /// + /// http-only cookies carry the `#HttpOnly_` host prefix curl and the + /// cookies.txt extensions use. The plain format has no field for the flag, so + /// without the prefix an export of a login cookie re-imports as a cookie any + /// page script can read. pub fn format_netscape_cookies(cookies: &[UnifiedCookie]) -> String { let mut lines = Vec::new(); lines.push("# Netscape HTTP Cookie File".to_string()); @@ -951,9 +960,14 @@ impl CookieManager { "FALSE" }; let secure = if cookie.is_secure { "TRUE" } else { "FALSE" }; + let host = if cookie.is_http_only { + format!("#HttpOnly_{}", cookie.domain) + } else { + cookie.domain.clone() + }; lines.push(format!( "{}\t{}\t{}\t{}\t{}\t{}\t{}", - cookie.domain, flag, cookie.path, secure, cookie.expires, cookie.name, cookie.value + host, flag, cookie.path, secure, cookie.expires, cookie.name, cookie.value )); } lines.join("\n") @@ -964,10 +978,16 @@ impl CookieManager { let arr: Vec = cookies .iter() .map(|c| { + // -1 is Chromium's "unspecified", which is what every cookie that never + // carried a SameSite attribute stores. Exporting it as + // "no_restriction" turned it into an explicit SameSite=None on the way + // back in, and Chromium refuses to send those unless they are also + // Secure — so a re-imported login simply stopped being sent. let same_site_str = match c.same_site { + 0 => "no_restriction", 1 => "lax", 2 => "strict", - _ => "no_restriction", + _ => "unspecified", }; serde_json::json!({ "name": c.name, @@ -986,57 +1006,198 @@ impl CookieManager { serde_json::to_string_pretty(&arr).unwrap_or_else(|_| "[]".to_string()) } - /// Public API: Import cookies with auto-format detection + /// The profile a cookie write targets, or the code saying it does not exist. + fn paste_target(profile_id: &str) -> Result { + ProfileManager::instance() + .list_profiles() + .map_err(|e| crate::backend_error_with_detail("COOKIE_DB_UNAVAILABLE", e))? + .into_iter() + .find(|p| p.id.to_string() == profile_id) + .ok_or_else(|| crate::backend_error("PROFILE_NOT_FOUND")) + } + + /// Why writing cookies into this profile would be refused right now, as the + /// `{"code":…}` string the frontend translates. `None` means go ahead. + async fn paste_blocker(app_handle: &AppHandle, profile: &BrowserProfile) -> Option { + let is_running = ProfileManager::instance() + .check_browser_status(app_handle.clone(), profile) + .await + .unwrap_or(false); + if is_running { + return Some(crate::backend_error("COOKIE_IMPORT_BROWSER_RUNNING")); + } + // The profile directory on disk is ciphertext while locked; a plaintext + // SQLite write into it is not a cookie, it is corruption. + if profile.password_protected { + return Some(crate::backend_error("COOKIE_IMPORT_PROFILE_PROTECTED")); + } + // A remote session owns this profile until its work has been pulled back. + // Writing locally makes every local mtime newer, so the next sync uploads + // the pre-session copy and deletes the session's cookies. See + // `remote_handoff`. + if crate::remote_handoff::state_for(&profile.id.to_string()).is_some() { + return Some(crate::backend_error("COOKIE_IMPORT_REMOTE_SESSION")); + } + None + } + + /// Whether a successful import into this profile would be wiped on browser + /// exit. Mirrors the condition `browser_runner` actually applies, which lets + /// ephemeral and password-protected profiles take their own paths first. + fn clears_cookies_on_close(profile: &BrowserProfile) -> bool { + profile.clear_on_close && !profile.ephemeral && !profile.password_protected + } + + /// Public API: describe a pasted blob without writing anything. + pub async fn analyze_paste( + app_handle: &AppHandle, + profile_id: &str, + content: &str, + site: Option<&str>, + ) -> Result { + let profile = Self::paste_target(profile_id)?; + let parsed = crate::cookie_paste::parse_paste(content, site); + let blocked_by = Self::paste_blocker(app_handle, &profile).await; + + // Counted over every accepted cookie, so this is the number of rows the + // sites in the paste currently hold — the ceiling on what replace mode + // removes, whatever the caller later decides about expired cookies. + let replace_delete_count = if parsed.cookies.is_empty() { + Some(0) + } else { + Self::get_cookie_db_path(&profile, &ProfileManager::instance().get_profiles_dir()) + .ok() + .and_then(|db_path| Self::count_replaceable_rows(&db_path, &parsed.cookies).ok()) + }; + + Ok(CookiePasteAnalysis { + format: parsed.format, + cookies: parsed + .cookies + .iter() + .map(PastedCookiePreview::from) + .collect(), + issues: parsed.issues, + site_required: parsed.site_required, + expired_count: parsed.expired_count, + replace_delete_count, + clears_on_close: Self::clears_cookies_on_close(&profile), + blocked_by, + }) + } + + /// Public API: write a pasted blob into a profile's cookie store. + pub async fn import_paste( + app_handle: &AppHandle, + profile_id: &str, + content: &str, + site: Option<&str>, + mode: CookieWriteMode, + include_expired: bool, + ) -> Result { + let profile = Self::paste_target(profile_id)?; + if let Some(blocker) = Self::paste_blocker(app_handle, &profile).await { + return Err(blocker); + } + + let parsed = crate::cookie_paste::parse_paste(content, site); + if parsed.cookies.is_empty() { + return Err(crate::backend_error("COOKIE_IMPORT_NO_COOKIES")); + } + + let now = Self::now_secs(); + let total = parsed.cookies.len(); + let to_write: Vec = if include_expired { + parsed.cookies + } else { + parsed + .cookies + .into_iter() + .filter(|c| c.expires == 0 || c.expires > now) + .collect() + }; + let skipped = total - to_write.len(); + + let counts = Self::write_paste(&profile, &to_write, mode).await?; + + Ok(CookiePasteImportResult { + added: counts.added, + overwritten: counts.overwritten, + deleted: counts.deleted, + skipped, + issues: parsed.issues, + }) + } + + /// The SQLite half of a paste write, off the async runtime. + /// + /// `check_browser_status` is async and every caller must await it before + /// getting here; only this synchronous tail belongs on a blocking thread. + async fn write_paste( + profile: &BrowserProfile, + cookies: &[UnifiedCookie], + mode: CookieWriteMode, + ) -> Result { + if profile.browser.as_str() != "wayfern" { + return Err(crate::backend_error_with_detail( + "COOKIE_DB_UNAVAILABLE", + format!("unsupported browser: {}", profile.browser), + )); + } + + // A profile that has never launched has no Cookies file yet. + let db_path = + Self::ensure_cookie_db_path(profile, &ProfileManager::instance().get_profiles_dir()) + .map_err(|e| crate::backend_error_with_detail("COOKIE_DB_UNAVAILABLE", e))?; + let owned: Vec = cookies.to_vec(); + + tokio::task::spawn_blocking(move || Self::write_chrome_cookies(&db_path, &owned, mode)) + .await + .map_err(|e| crate::backend_error_with_detail("COOKIE_DB_UNAVAILABLE", e))? + .map_err(|e| crate::backend_error_with_detail("COOKIE_DB_UNAVAILABLE", e)) + } + + fn now_secs() -> i64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_secs() as i64) + .unwrap_or(0) + } + + /// Public API: Import cookies from a file's contents, with auto-detection. + /// + /// The paste parser backs this too, so a `cookies.txt` dragged into the + /// dialog and one pasted into the textarea are read by the same code. pub async fn import_cookies( app_handle: &AppHandle, profile_id: &str, content: &str, ) -> Result { - let profile_manager = ProfileManager::instance(); - let profiles_dir = profile_manager.get_profiles_dir(); - let profiles = profile_manager - .list_profiles() - .map_err(|e| format!("Failed to list profiles: {e}"))?; + let result = Self::import_paste( + app_handle, + profile_id, + content, + None, + CookieWriteMode::Merge, + true, + ) + .await?; - let profile = profiles - .iter() - .find(|p| p.id.to_string() == profile_id) - .ok_or_else(|| format!("Profile not found: {profile_id}"))?; - - let is_running = profile_manager - .check_browser_status(app_handle.clone(), profile) - .await - .unwrap_or(false); - - if is_running { - return Err(format!( - "Cannot import cookies while browser is running for profile: {}", - profile.name - )); - } - - let (cookies, parse_errors) = Self::parse_cookies(content); - - if cookies.is_empty() { - return Err("No valid cookies found in the file".to_string()); - } - - // Profile may have never been launched yet — create an empty DB on demand. - let db_path = Self::ensure_cookie_db_path(profile, &profiles_dir)?; - - let write_result = match profile.browser.as_str() { - "wayfern" => Self::write_chrome_cookies(&db_path, &cookies), - _ => return Err(format!("Unsupported browser type: {}", profile.browser)), - }; - - match write_result { - Ok((imported, replaced)) => Ok(CookieImportResult { - cookies_imported: imported, - cookies_replaced: replaced, - errors: parse_errors, - }), - Err(e) => Err(format!("Failed to write cookies: {e}")), - } + Ok(CookieImportResult { + cookies_imported: result.added, + cookies_replaced: result.overwritten, + // The file dialog has no room for a per-line report, so it gets the codes + // of the things that went wrong and nothing else. + errors: result + .issues + .into_iter() + .filter(|i| !matches!(i.severity, crate::cookie_paste::IssueSeverity::Info)) + .map(|i| match i.source { + Some(source) => format!("{source}: {}", i.code), + None => i.code, + }) + .collect(), + }) } /// Public API: Export cookies from a profile in the specified format @@ -1074,110 +1235,6 @@ mod tests { .collect() } - #[test] - fn test_parse_netscape_cookies_valid() { - let content = "# Netscape HTTP Cookie File\n\ - .example.com\tTRUE\t/\tTRUE\t1700000000\tsession_id\tabc123\n\ - example.com\tFALSE\t/path\tFALSE\t0\ttoken\txyz"; - let (cookies, errors) = CookieManager::parse_netscape_cookies(content); - assert_eq!(cookies.len(), 2); - assert!(errors.is_empty()); - - assert_eq!(cookies[0].domain, ".example.com"); - assert_eq!(cookies[0].name, "session_id"); - assert_eq!(cookies[0].value, "abc123"); - assert_eq!(cookies[0].path, "/"); - assert!(cookies[0].is_secure); - assert_eq!(cookies[0].expires, 1700000000); - - assert_eq!(cookies[1].domain, "example.com"); - assert!(!cookies[1].is_secure); - assert_eq!(cookies[1].expires, 0); - } - - #[test] - fn test_parse_netscape_cookies_skips_comments_and_blanks() { - let content = "# Comment line\n\n \n# Another comment\n\ - .test.com\tTRUE\t/\tFALSE\t0\tname\tvalue\n"; - let (cookies, errors) = CookieManager::parse_netscape_cookies(content); - assert_eq!(cookies.len(), 1); - assert!(errors.is_empty()); - } - - #[test] - fn test_parse_netscape_cookies_malformed_lines() { - let content = "not\tenough\tfields\n\ - .ok.com\tTRUE\t/\tFALSE\t0\tname\tvalue\n"; - let (cookies, errors) = CookieManager::parse_netscape_cookies(content); - assert_eq!(cookies.len(), 1); - assert_eq!(errors.len(), 1); - assert!(errors[0].contains("expected 7 tab-separated fields")); - } - - #[test] - fn test_parse_json_cookies_valid() { - let content = r#"[ - { - "name": "sid", - "value": "abc", - "domain": ".example.com", - "path": "/", - "secure": true, - "httpOnly": true, - "sameSite": "lax", - "expirationDate": 1700000000, - "session": false - } - ]"#; - let (cookies, errors) = CookieManager::parse_json_cookies(content); - assert_eq!(cookies.len(), 1); - assert!(errors.is_empty()); - assert_eq!(cookies[0].name, "sid"); - assert_eq!(cookies[0].domain, ".example.com"); - assert!(cookies[0].is_secure); - assert!(cookies[0].is_http_only); - assert_eq!(cookies[0].same_site, 1); - assert_eq!(cookies[0].expires, 1700000000); - } - - #[test] - fn test_parse_json_cookies_session() { - let content = r#"[{"name": "s", "value": "v", "domain": ".d.com", "session": true, "expirationDate": 9999}]"#; - let (cookies, errors) = CookieManager::parse_json_cookies(content); - assert_eq!(cookies.len(), 1); - assert!(errors.is_empty()); - assert_eq!(cookies[0].expires, 0); - } - - #[test] - fn test_parse_json_cookies_same_site_mapping() { - let content = r#"[ - {"name": "a", "value": "", "domain": ".d.com", "sameSite": "no_restriction"}, - {"name": "b", "value": "", "domain": ".d.com", "sameSite": "lax"}, - {"name": "c", "value": "", "domain": ".d.com", "sameSite": "strict"} - ]"#; - let (cookies, _) = CookieManager::parse_json_cookies(content); - assert_eq!(cookies[0].same_site, 0); - assert_eq!(cookies[1].same_site, 1); - assert_eq!(cookies[2].same_site, 2); - } - - #[test] - fn test_parse_cookies_auto_detect_json() { - let content = r#"[{"name": "x", "value": "y", "domain": ".test.com"}]"#; - let (cookies, _) = CookieManager::parse_cookies(content); - assert_eq!(cookies.len(), 1); - assert_eq!(cookies[0].name, "x"); - } - - #[test] - fn test_parse_cookies_auto_detect_netscape() { - let content = ".test.com\tTRUE\t/\tFALSE\t0\tname\tvalue"; - let (cookies, _) = CookieManager::parse_cookies(content); - assert_eq!(cookies.len(), 1); - assert_eq!(cookies[0].name, "name"); - } - #[test] fn test_format_netscape_cookies() { let cookies = vec![UnifiedCookie { @@ -1197,6 +1254,29 @@ mod tests { assert!(output.contains(".example.com\tTRUE\t/\tTRUE\t1700000000\tsid\tabc")); } + #[test] + fn test_format_netscape_cookies_marks_http_only() { + let cookies = vec![UnifiedCookie { + name: "sid".to_string(), + value: "abc".to_string(), + domain: ".example.com".to_string(), + path: "/".to_string(), + expires: 1700000000, + is_secure: true, + is_http_only: true, + same_site: -1, + creation_time: 0, + last_accessed: 0, + }]; + let output = CookieManager::format_netscape_cookies(&cookies); + assert!(output.contains("#HttpOnly_.example.com\tTRUE\t/\tTRUE\t1700000000\tsid\tabc")); + + let parsed = crate::cookie_paste::parse_paste(&output, None); + assert_eq!(parsed.cookies.len(), 1); + assert_eq!(parsed.cookies[0].domain, ".example.com"); + assert!(parsed.cookies[0].is_http_only); + } + #[test] fn test_format_json_cookies() { let cookies = vec![UnifiedCookie { @@ -1249,8 +1329,7 @@ mod tests { }, ]; let formatted = CookieManager::format_netscape_cookies(&cookies); - let (parsed, errors) = CookieManager::parse_netscape_cookies(&formatted); - assert!(errors.is_empty()); + let parsed = crate::cookie_paste::parse_paste(&formatted, None).cookies; assert_eq!(parsed.len(), 2); assert_eq!(parsed[0].name, "a"); assert_eq!(parsed[0].domain, ".d.com"); @@ -1274,8 +1353,7 @@ mod tests { last_accessed: 0, }]; let formatted = CookieManager::format_json_cookies(&cookies); - let (parsed, errors) = CookieManager::parse_json_cookies(&formatted); - assert!(errors.is_empty()); + let parsed = crate::cookie_paste::parse_paste(&formatted, None).cookies; assert_eq!(parsed.len(), 1); assert_eq!(parsed[0].name, "tok"); assert_eq!(parsed[0].domain, ".site.org"); @@ -1286,6 +1364,34 @@ mod tests { assert_eq!(parsed[0].expires, 1700000000); } + /// Every Netscape import now lands as -1 (unspecified), which used to export + /// as "no_restriction" and come back as an explicit SameSite=None — a cookie + /// Chromium then refuses to send unless it is also Secure. + #[test] + fn test_json_roundtrip_preserves_unspecified_same_site() { + let cookies = vec![UnifiedCookie { + name: "sid".to_string(), + value: "v".to_string(), + domain: ".site.org".to_string(), + path: "/".to_string(), + expires: 1900000000, + is_secure: false, + is_http_only: false, + same_site: -1, + creation_time: 0, + last_accessed: 0, + }]; + let formatted = CookieManager::format_json_cookies(&cookies); + assert_eq!( + serde_json::from_str::>(&formatted).unwrap()[0]["sameSite"], + "unspecified" + ); + + let parsed = crate::cookie_paste::parse_paste(&formatted, None).cookies; + assert_eq!(parsed.len(), 1); + assert_eq!(parsed[0].same_site, -1); + } + #[test] fn test_chrome_time_to_unix() { assert_eq!(CookieManager::chrome_time_to_unix(0), 0); @@ -1356,9 +1462,10 @@ mod tests { last_accessed: 1700000000, }]; - let (inserted, replaced) = CookieManager::write_chrome_cookies(&tmp, &cookies).unwrap(); - assert_eq!(inserted, 1); - assert_eq!(replaced, 0); + let counts = + CookieManager::write_chrome_cookies(&tmp, &cookies, CookieWriteMode::Merge).unwrap(); + assert_eq!(counts.added, 1); + assert_eq!(counts.overwritten, 0); let conn = Connection::open(&tmp).unwrap(); let (value, encrypted, has_expires, is_persistent, source_scheme, source_port): ( @@ -1417,7 +1524,7 @@ mod tests { last_accessed: 1700000000, }]; - CookieManager::write_chrome_cookies(&tmp, &cookies).unwrap(); + CookieManager::write_chrome_cookies(&tmp, &cookies, CookieWriteMode::Merge).unwrap(); let conn = Connection::open(&tmp).unwrap(); let (has_expires, is_persistent, expires_utc, source_scheme, source_port): ( @@ -1474,16 +1581,24 @@ mod tests { last_accessed: 1700000000, }; - let (inserted, _) = - CookieManager::write_chrome_cookies(&tmp, std::slice::from_ref(&cookie)).unwrap(); - assert_eq!(inserted, 1); + let counts = CookieManager::write_chrome_cookies( + &tmp, + std::slice::from_ref(&cookie), + CookieWriteMode::Merge, + ) + .unwrap(); + assert_eq!(counts.added, 1); let mut updated = cookie.clone(); updated.value = "v2".to_string(); - let (inserted, replaced) = - CookieManager::write_chrome_cookies(&tmp, std::slice::from_ref(&updated)).unwrap(); - assert_eq!(inserted, 0); - assert_eq!(replaced, 1); + let counts = CookieManager::write_chrome_cookies( + &tmp, + std::slice::from_ref(&updated), + CookieWriteMode::Merge, + ) + .unwrap(); + assert_eq!(counts.added, 0); + assert_eq!(counts.overwritten, 1); let conn = Connection::open(&tmp).unwrap(); let (value, encrypted): (String, Vec) = conn @@ -1499,6 +1614,226 @@ mod tests { let _ = std::fs::remove_file(&tmp); } + fn cookie(domain: &str, name: &str, value: &str) -> UnifiedCookie { + UnifiedCookie { + name: name.to_string(), + value: value.to_string(), + domain: domain.to_string(), + path: "/".to_string(), + expires: 1900000000, + is_secure: true, + is_http_only: false, + same_site: -1, + creation_time: 1700000000, + last_accessed: 1700000000, + } + } + + fn host_keys(db_path: &Path) -> Vec { + let conn = Connection::open(db_path).unwrap(); + let mut stmt = conn + .prepare("SELECT host_key || '|' || name FROM cookies ORDER BY host_key, name") + .unwrap(); + let rows: Vec = stmt + .query_map([], |row| row.get(0)) + .unwrap() + .collect::>() + .unwrap(); + rows + } + + /// Replace mode clears both spellings of the pasted site and nothing else: + /// not a subdomain, not another site. + #[test] + fn test_write_chrome_cookies_replace_matching_sites_scope() { + let tmp = std::env::temp_dir().join(format!("donut_cookie_test_{}.db", uuid::Uuid::new_v4())); + create_chrome_cookies_db(&tmp); + + let seeded = vec![ + cookie(".example.com", "dotted", "old"), + cookie("example.com", "undotted", "old"), + cookie("app.example.com", "subdomain", "keep"), + cookie(".other.com", "elsewhere", "keep"), + ]; + CookieManager::write_chrome_cookies(&tmp, &seeded, CookieWriteMode::Merge).unwrap(); + + // A paste that names only the undotted form must still clear the dotted one. + let counts = CookieManager::write_chrome_cookies( + &tmp, + &[cookie("example.com", "fresh", "new")], + CookieWriteMode::ReplaceMatchingSites, + ) + .unwrap(); + assert_eq!(counts.deleted, 2); + assert_eq!(counts.added, 1); + assert_eq!(counts.overwritten, 0); + + assert_eq!( + host_keys(&tmp), + vec![ + ".other.com|elsewhere".to_string(), + "app.example.com|subdomain".to_string(), + "example.com|fresh".to_string(), + ] + ); + + let _ = std::fs::remove_file(&tmp); + } + + #[test] + fn test_write_chrome_cookies_merge_deletes_nothing() { + let tmp = std::env::temp_dir().join(format!("donut_cookie_test_{}.db", uuid::Uuid::new_v4())); + create_chrome_cookies_db(&tmp); + + CookieManager::write_chrome_cookies( + &tmp, + &[cookie(".example.com", "existing", "old")], + CookieWriteMode::Merge, + ) + .unwrap(); + let counts = CookieManager::write_chrome_cookies( + &tmp, + &[cookie("example.com", "fresh", "new")], + CookieWriteMode::Merge, + ) + .unwrap(); + + assert_eq!(counts.deleted, 0); + assert_eq!(counts.added, 1); + assert_eq!(host_keys(&tmp).len(), 2); + + let _ = std::fs::remove_file(&tmp); + } + + /// The store's unique index is four columns wide. A three-column probe made + /// one partitioned cookie stand in for all of them, so the others silently + /// kept their stale values. + #[test] + fn test_write_chrome_cookies_probe_is_partition_aware() { + let tmp = std::env::temp_dir().join(format!("donut_cookie_test_{}.db", uuid::Uuid::new_v4())); + create_chrome_cookies_db(&tmp); + + let conn = Connection::open(&tmp).unwrap(); + conn + .execute( + "INSERT INTO cookies + (creation_utc, host_key, top_frame_site_key, name, value, encrypted_value, path, + expires_utc, is_secure, is_httponly, last_access_utc) + VALUES (1, '.example.com', 'https://partner.example', 'sid', 'partitioned', x'', '/', + 0, 1, 0, 0)", + [], + ) + .unwrap(); + drop(conn); + + let counts = CookieManager::write_chrome_cookies( + &tmp, + &[cookie(".example.com", "sid", "unpartitioned")], + CookieWriteMode::Merge, + ) + .unwrap(); + assert_eq!(counts.added, 1, "the partitioned row is a different cookie"); + assert_eq!(counts.overwritten, 0); + + let conn = Connection::open(&tmp).unwrap(); + let partitioned: String = conn + .query_row( + "SELECT value FROM cookies WHERE top_frame_site_key = 'https://partner.example'", + [], + |row| row.get(0), + ) + .unwrap(); + assert_eq!(partitioned, "partitioned"); + + let _ = std::fs::remove_file(&tmp); + } + + /// Chromium evicts by creation_utc, so refreshing a cookie must not reset it. + #[test] + fn test_write_chrome_cookies_preserves_creation_time() { + let tmp = std::env::temp_dir().join(format!("donut_cookie_test_{}.db", uuid::Uuid::new_v4())); + create_chrome_cookies_db(&tmp); + + CookieManager::write_chrome_cookies( + &tmp, + &[cookie(".example.com", "sid", "v1")], + CookieWriteMode::Merge, + ) + .unwrap(); + let original = CookieManager::unix_to_chrome_time(1700000000); + + let mut refreshed = cookie(".example.com", "sid", "v2"); + refreshed.creation_time = 1800000000; + CookieManager::write_chrome_cookies(&tmp, &[refreshed], CookieWriteMode::Merge).unwrap(); + + let conn = Connection::open(&tmp).unwrap(); + let (creation, value): (i64, String) = conn + .query_row( + "SELECT creation_utc, value FROM cookies WHERE name = 'sid'", + [], + |row| Ok((row.get(0)?, row.get(1)?)), + ) + .unwrap(); + assert_eq!(creation, original); + assert_eq!(value, "v2"); + + let _ = std::fs::remove_file(&tmp); + } + + /// A write that fails partway must leave the store exactly as it was, and in + /// replace mode that includes the deletes it had already issued. + #[test] + fn test_write_chrome_cookies_rolls_back_on_failure() { + let tmp = std::env::temp_dir().join(format!("donut_cookie_test_{}.db", uuid::Uuid::new_v4())); + create_chrome_cookies_db(&tmp); + CookieManager::write_chrome_cookies( + &tmp, + &[cookie(".example.com", "existing", "keep")], + CookieWriteMode::Merge, + ) + .unwrap(); + + // NOT NULL on `path` is what makes the second insert fail; the first one + // and the replace-mode deletes have to unwind with it. + let mut broken = cookie(".example.com", "second", "v"); + broken.path = String::new(); + let conn = Connection::open(&tmp).unwrap(); + conn + .execute( + "CREATE UNIQUE INDEX no_empty_path ON cookies(path) WHERE path = ''", + [], + ) + .unwrap(); + conn + .execute( + "INSERT INTO cookies + (creation_utc, host_key, top_frame_site_key, name, value, encrypted_value, path, + expires_utc, is_secure, is_httponly, last_access_utc) + VALUES (1, '.blocker.com', '', 'blocker', 'v', x'', '', 0, 0, 0, 0)", + [], + ) + .unwrap(); + drop(conn); + + let result = CookieManager::write_chrome_cookies( + &tmp, + &[cookie(".example.com", "first", "v"), broken], + CookieWriteMode::ReplaceMatchingSites, + ); + assert!(result.is_err()); + + assert_eq!( + host_keys(&tmp), + vec![ + ".blocker.com|blocker".to_string(), + ".example.com|existing".to_string(), + ], + "neither the delete nor the first insert may survive a failed write" + ); + + let _ = std::fs::remove_file(&tmp); + } + #[test] #[cfg(target_os = "macos")] fn test_decrypt_v10_cookie_with_synthetic_vector() { @@ -1570,9 +1905,10 @@ mod tests { creation_time: 1700000000, last_accessed: 1700000000, }]; - let (inserted, replaced) = CookieManager::write_chrome_cookies(&db_path, &cookies).unwrap(); - assert_eq!(inserted, 1); - assert_eq!(replaced, 0); + let counts = + CookieManager::write_chrome_cookies(&db_path, &cookies, CookieWriteMode::Merge).unwrap(); + assert_eq!(counts.added, 1); + assert_eq!(counts.overwritten, 0); let read = CookieManager::read_chrome_cookies(&db_path, None).unwrap(); assert_eq!(read.len(), 1); diff --git a/src-tauri/src/cookie_paste.rs b/src-tauri/src/cookie_paste.rs new file mode 100644 index 0000000..0bc314d --- /dev/null +++ b/src-tauri/src/cookie_paste.rs @@ -0,0 +1,2173 @@ +//! Parsing for the single "paste your cookies here" textarea. +//! +//! Whatever a user has on the clipboard is one of three shapes: a JSON export +//! (Cookie-Editor, CDP, Puppeteer, Playwright `storageState`, AdsPower), a +//! Netscape `cookies.txt` (curl, wget, yt-dlp, "Get cookies.txt"), or a raw +//! `Cookie:` / `Set-Cookie:` header copied out of DevTools. This module detects +//! which one it is and reports every repair and every rejection as a structured +//! issue, because the failure mode that actually hurts is a login cookie that +//! disappears without anyone being told. + +use crate::cookie_manager::UnifiedCookie; +use chrono::{DateTime, NaiveDateTime}; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use std::collections::{BTreeMap, HashMap}; + +/// Largest expiry we accept as already being in seconds (roughly year 5000). +/// Anything above this is a millisecond timestamp that someone forgot to +/// convert, so it gets divided down rather than stored as a cookie that never +/// expires. +const MAX_PLAUSIBLE_EXPIRY_SECS: i64 = 95_617_584_000; + +/// How many times the unit sniffer will divide by 1000. Two steps covers the +/// millisecond and microsecond epochs; a third is pure paranoia. +const MAX_UNIT_SNIFF_STEPS: u32 = 3; + +/// Ceiling for an expiry as it was written, before the unit sniffer decides +/// whether it is seconds, milliseconds or microseconds. It leaves room for a +/// microsecond epoch while keeping every parsed expiry far from the ends of the +/// i64 range, where an `f64` cast saturates to `i64::MIN` and arithmetic on the +/// result stops behaving. +const MAX_RAW_EXPIRY: i64 = MAX_PLAUSIBLE_EXPIRY_SECS * 1_000_000; + +/// RFC 2616 separators, plus HTAB. SP and the CTLs are handled separately. +const TOKEN_SEPARATORS: &str = "()<>@,;:\\\"/[]?={}\t"; + +/// Names that mean "attribute" rather than "cookie" inside a `Set-Cookie` +/// line. Only consulted once a real name=value pair has been seen on the same +/// line, so a paste that starts with `domain=...` stays a cookie named +/// `domain`. +const SET_COOKIE_ATTRIBUTES: [&str; 11] = [ + "domain", + "path", + "expires", + "max-age", + "samesite", + "secure", + "httponly", + "partitioned", + "priority", + "version", + "comment", +]; + +/// The attributes whose value we read and act on. Everything else in +/// `SET_COOKIE_ATTRIBUTES` has its value discarded, so a `name=value` pair that +/// lands on one of those has to be reported rather than vanish. +const ATTRIBUTES_WITH_A_USED_VALUE: [&str; 5] = + ["domain", "path", "expires", "max-age", "samesite"]; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum IssueSeverity { + Error, + Warning, + Info, +} + +/// One thing that happened to the paste. `code` is a stable SCREAMING_SNAKE +/// identifier the frontend maps to a translated string; `source` locates it +/// ("line 4", "cookie 12") and `params` carries the substitutions. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CookieIssue { + pub code: String, + pub severity: IssueSeverity, + pub source: Option, + pub params: BTreeMap, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum PasteFormat { + Json, + Netscape, + NameValue, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ParsedPaste { + pub format: Option, + pub cookies: Vec, + pub issues: Vec, + /// True when the paste is a bare `name=value` list and no site was supplied, + /// so there is nothing to attach the cookies to. The caller asks for a site + /// and parses again. + pub site_required: bool, + pub expired_count: usize, +} + +fn issue( + code: &str, + severity: IssueSeverity, + source: Option<&str>, + params: &[(&str, &str)], +) -> CookieIssue { + CookieIssue { + code: code.to_string(), + severity, + source: source.map(str::to_string), + params: params + .iter() + .map(|(k, v)| ((*k).to_string(), (*v).to_string())) + .collect(), + } +} + +/// The site the user typed next to the textarea, split into the parts the +/// parsers need. +struct SiteHint { + host: String, + /// Only an explicit `https://` marks the origin as secure. A bare hostname + /// says nothing about the scheme, so we do not invent one. + secure: bool, +} + +/// A cookie as one of the format parsers understood it, before the shared +/// normalisation runs. Fields that the source format could not express are +/// `None` so `finalize` can tell "absent" from "explicitly set". +struct CookieDraft { + name: String, + value: String, + /// `None` falls back to the pasted site. + domain: Option, + /// Explicit host-only intent, from JSON `hostOnly` or the Netscape + /// include-subdomains column. `None` leaves the leading dot to decide. + host_only: Option, + path: Option, + /// `None` is a session cookie. + expires: Option, + is_secure: bool, + is_http_only: bool, + same_site: i32, + source: String, +} + +fn now_secs() -> i64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_secs() as i64) + .unwrap_or(0) +} + +fn strip_bom(input: &str) -> &str { + input.strip_prefix('\u{feff}').unwrap_or(input) +} + +fn is_token(s: &str) -> bool { + !s.is_empty() + && s + .chars() + .all(|c| !c.is_control() && c != ' ' && !TOKEN_SEPARATORS.contains(c)) +} + +/// RFC 6265 cookie-value: no CTLs (which covers HTAB, LF and CR) and no +/// semicolon, since a semicolon would re-split the pair on the way back out. +fn is_valid_value(s: &str) -> bool { + s.chars().all(|c| !c.is_control() && c != ';') +} + +fn parse_site(raw: &str) -> Option { + let raw = raw.trim(); + if raw.is_empty() { + return None; + } + let (secure, rest) = match raw.split_once("://") { + Some((scheme, rest)) => (scheme.eq_ignore_ascii_case("https"), rest), + None => (false, raw), + }; + let rest = rest.split(['/', '?', '#']).next().unwrap_or(""); + // Drop userinfo and any port; a cookie domain carries neither. + let rest = rest.rsplit('@').next().unwrap_or(rest); + let host = match rest.rsplit_once(':') { + Some((head, tail)) if !head.is_empty() && tail.chars().all(|c| c.is_ascii_digit()) => head, + _ => rest, + }; + let host = normalize_domain(host)?; + Some(SiteHint { host, secure }) +} + +/// Lowercase, drop the trailing root dot, and reject anything that is not a +/// hostname. The leading dot survives: it is how the Netscape and JSON formats +/// spell "include subdomains". +fn normalize_domain(raw: &str) -> Option { + let mut domain = raw.trim().to_ascii_lowercase(); + while domain.len() > 1 && domain.ends_with('.') { + domain.pop(); + } + if domain.is_empty() || domain == "." { + return None; + } + let bare = domain.strip_prefix('.').unwrap_or(&domain); + if bare.is_empty() { + return None; + } + let rejected = |c: char| c.is_control() || c.is_whitespace() || "/\\?#@;,:[]{}<>\"'".contains(c); + if bare.chars().any(rejected) { + return None; + } + Some(domain) +} + +fn same_site_from_token(raw: &str) -> Option { + match raw.trim().to_ascii_lowercase().as_str() { + "strict" => Some(2), + "lax" => Some(1), + "none" | "no_restriction" | "norestriction" => Some(0), + "unspecified" | "default" | "null" | "" => Some(-1), + other => other.parse::().ok().filter(|n| (-1..=2).contains(n)), + } +} + +/// Accepts the shapes a `Set-Cookie` `Expires` attribute actually arrives in, +/// plus a bare unix timestamp for the people who paste one. +fn parse_http_date(raw: &str) -> Option { + let trimmed = raw.trim().trim_matches('"').trim(); + if trimmed.is_empty() { + return None; + } + if let Ok(n) = trimmed.parse::() { + return Some(n.clamp(-MAX_RAW_EXPIRY, MAX_RAW_EXPIRY)); + } + if let Ok(dt) = DateTime::parse_from_rfc2822(trimmed) { + return Some(dt.timestamp()); + } + if let Ok(dt) = DateTime::parse_from_rfc3339(trimmed) { + return Some(dt.timestamp()); + } + const FORMATS: [&str; 7] = [ + "%a, %d %b %Y %H:%M:%S GMT", + "%a, %d-%b-%Y %H:%M:%S GMT", + "%A, %d-%b-%y %H:%M:%S GMT", + "%a, %d %b %Y %H:%M:%S UTC", + "%a, %d %b %Y %H:%M:%S", + "%a %b %e %H:%M:%S %Y", + "%Y-%m-%d %H:%M:%S", + ]; + for format in FORMATS { + if let Ok(dt) = NaiveDateTime::parse_from_str(trimmed, format) { + return Some(dt.and_utc().timestamp()); + } + } + None +} + +/// Pull the cookie array out of whichever JSON wrapper the exporter used. +fn json_cookie_array(value: &Value) -> Option> { + match value { + Value::Array(items) => Some(items.clone()), + Value::Object(map) => { + for key in ["cookies", "Cookies"] { + if let Some(Value::Array(items)) = map.get(key) { + return Some(items.clone()); + } + } + // A single cookie object, which is what copying one row out of DevTools + // gives you. + if map.get("name").and_then(Value::as_str).is_some() { + return Some(vec![value.clone()]); + } + None + } + _ => None, + } +} + +/// A `cookies.txt` row is six or seven tab-separated fields carrying a +/// TRUE/FALSE column among the first few — the same columns `parse_netscape` +/// keys off. Demanding the whole shape rather than the mere presence of a tab +/// keeps one stray tab in a wrapped `Cookie:` header from routing the entire +/// paste into the Netscape parser. +fn is_netscape_row(line: &str) -> bool { + let fields: Vec<&str> = line.split('\t').collect(); + if fields.len() != 6 && fields.len() != 7 { + return false; + } + fields[1..=3].iter().any(|field| { + let field = field.trim(); + field.eq_ignore_ascii_case("TRUE") || field.eq_ignore_ascii_case("FALSE") + }) +} + +pub fn detect_format(input: &str) -> Option { + let trimmed = strip_bom(input).trim(); + if trimmed.is_empty() { + return None; + } + + if let Ok(value) = serde_json::from_str::(trimmed) { + if json_cookie_array(&value).is_some() { + return Some(PasteFormat::Json); + } + } + // Broken JSON is still JSON, and a truncated clipboard is the commonest paste + // failure there is. Routing it to the JSON parser turns "we do not recognise + // this" into serde naming the line and column that went wrong. + if trimmed.starts_with('[') || trimmed.starts_with('{') { + return Some(PasteFormat::Json); + } + + let mut seen_content = false; + for raw in trimmed.lines() { + let line = raw.trim_end_matches('\r').trim_start(); + if line.starts_with("#HttpOnly_") { + return Some(PasteFormat::Netscape); + } + if !seen_content + && (line.starts_with("# Netscape HTTP Cookie File") || line.starts_with("# HTTP Cookie File")) + { + return Some(PasteFormat::Netscape); + } + if line.starts_with('#') { + continue; + } + if line.trim_end().is_empty() { + continue; + } + seen_content = true; + if is_netscape_row(line) { + return Some(PasteFormat::Netscape); + } + } + + let has_pair = trimmed.lines().any(|raw| { + let line = raw.trim(); + !line.is_empty() && line.contains('=') + }); + if has_pair { + return Some(PasteFormat::NameValue); + } + + None +} + +pub fn parse_paste(input: &str, site: Option<&str>) -> ParsedPaste { + let mut issues: Vec = Vec::new(); + let cleaned = strip_bom(input); + let now = now_secs(); + + if cleaned.trim().is_empty() { + issues.push(issue("EMPTY_INPUT", IssueSeverity::Error, None, &[])); + return ParsedPaste { + format: None, + cookies: Vec::new(), + issues, + site_required: false, + expired_count: 0, + }; + } + + let hint = site.and_then(parse_site); + if let Some(raw) = site { + if !raw.trim().is_empty() && hint.is_none() { + issues.push(issue( + "SITE_INVALID", + IssueSeverity::Warning, + None, + &[("site", raw.trim())], + )); + } + } + + let format = match detect_format(cleaned) { + Some(format) => format, + None => { + issues.push(issue( + "UNRECOGNIZED_FORMAT", + IssueSeverity::Error, + None, + &[], + )); + return ParsedPaste { + format: None, + cookies: Vec::new(), + issues, + site_required: false, + expired_count: 0, + }; + } + }; + + let mut site_required = false; + let drafts = match format { + PasteFormat::Json => parse_json(cleaned, &mut issues), + PasteFormat::Netscape => parse_netscape(cleaned, &mut issues), + PasteFormat::NameValue => match hint.as_ref() { + Some(hint) => parse_name_value(cleaned, hint, now, &mut issues), + None => { + site_required = true; + issues.push(issue("SITE_REQUIRED", IssueSeverity::Error, None, &[])); + Vec::new() + } + }, + }; + + let mut finalized: Vec<(UnifiedCookie, String)> = Vec::new(); + for draft in drafts { + let source = draft.source.clone(); + if let Some(cookie) = finalize(draft, hint.as_ref(), now, &mut issues) { + finalized.push((cookie, source)); + } + } + + let cookies = dedupe(finalized, &mut issues); + // A paste that yields nothing has to say why. A header-only cookies.txt or a + // JSON array of one unusable entry would otherwise come back as a clean, + // empty, unexplained success. + if cookies.is_empty() + && !site_required + && !issues + .iter() + .any(|i| matches!(i.severity, IssueSeverity::Error)) + { + issues.push(issue("NO_COOKIES_FOUND", IssueSeverity::Error, None, &[])); + } + + let expired_count = cookies + .iter() + .filter(|c| c.expires != 0 && c.expires <= now) + .count(); + + ParsedPaste { + format: Some(format), + cookies, + issues, + site_required, + expired_count, + } +} + +/// Shared normalisation. Everything that is true of a cookie regardless of the +/// format it was pasted in happens here, in one place, so the three parsers +/// cannot drift apart. +fn finalize( + draft: CookieDraft, + site: Option<&SiteHint>, + now: i64, + issues: &mut Vec, +) -> Option { + let source = draft.source.as_str(); + + let name = draft.name.trim().to_string(); + if name.is_empty() { + issues.push(issue("NAME_EMPTY", IssueSeverity::Error, Some(source), &[])); + return None; + } + if !is_token(&name) { + issues.push(issue( + "NAME_INVALID", + IssueSeverity::Error, + Some(source), + &[("name", &name)], + )); + return None; + } + if !is_valid_value(&draft.value) { + issues.push(issue( + "VALUE_INVALID", + IssueSeverity::Error, + Some(source), + &[("name", &name)], + )); + return None; + } + + let raw_domain = match draft + .domain + .as_deref() + .map(str::trim) + .filter(|d| !d.is_empty()) + { + Some(domain) => domain.to_string(), + None => match site { + Some(hint) => { + issues.push(issue( + "DOMAIN_FROM_SITE", + IssueSeverity::Info, + Some(source), + &[("name", &name), ("domain", &hint.host)], + )); + hint.host.clone() + } + None => { + issues.push(issue( + "DOMAIN_MISSING", + IssueSeverity::Error, + Some(source), + &[("name", &name)], + )); + return None; + } + }, + }; + let mut domain = match normalize_domain(&raw_domain) { + Some(domain) => domain, + None => { + issues.push(issue( + "DOMAIN_INVALID", + IssueSeverity::Error, + Some(source), + &[("name", &name), ("domain", raw_domain.trim())], + )); + return None; + } + }; + + // The leading dot and the host-only flag say the same thing twice, and + // exporters disagree about which one is authoritative. The explicit flag + // wins, and the correction is reported rather than applied in silence. + match draft.host_only { + Some(false) if !domain.starts_with('.') => { + issues.push(issue( + "HOST_ONLY_MISMATCH", + IssueSeverity::Warning, + Some(source), + &[("name", &name), ("domain", &domain), ("hostOnly", "false")], + )); + domain.insert(0, '.'); + } + Some(true) if domain.starts_with('.') => { + issues.push(issue( + "HOST_ONLY_MISMATCH", + IssueSeverity::Warning, + Some(source), + &[("name", &name), ("domain", &domain), ("hostOnly", "true")], + )); + domain.remove(0); + } + _ => {} + } + + let path = match draft + .path + .as_deref() + .map(str::trim) + .filter(|p| !p.is_empty()) + { + Some(path) if path.starts_with('/') && is_valid_value(path) => path.to_string(), + Some(path) => { + issues.push(issue( + "PATH_REPAIRED", + IssueSeverity::Warning, + Some(source), + &[("name", &name), ("path", path)], + )); + if is_valid_value(path) { + format!("/{path}") + } else { + "/".to_string() + } + } + None => "/".to_string(), + }; + + let expires = match draft.expires { + None => 0, + // Playwright spells a session cookie `"expires": -1`, and 0 is already the + // session sentinel, so neither is worth complaining about. Taking every + // non-positive expiry here also keeps the sniffer below off `i64::MIN`, + // where negating to compare a magnitude overflows. + Some(raw) if raw <= 0 => 0, + Some(raw) => { + let mut expiry = raw; + let mut steps = 0; + while expiry > MAX_PLAUSIBLE_EXPIRY_SECS && steps < MAX_UNIT_SNIFF_STEPS { + expiry /= 1000; + steps += 1; + } + if steps > 0 { + issues.push(issue( + "EXPIRY_MILLISECONDS", + IssueSeverity::Warning, + Some(source), + &[("name", &name), ("expires", &raw.to_string())], + )); + } + if expiry > MAX_PLAUSIBLE_EXPIRY_SECS { + issues.push(issue( + "EXPIRY_CLAMPED", + IssueSeverity::Warning, + Some(source), + &[("name", &name), ("expires", &raw.to_string())], + )); + expiry = MAX_PLAUSIBLE_EXPIRY_SECS; + } + expiry + } + }; + + let same_site = if (-1..=2).contains(&draft.same_site) { + draft.same_site + } else { + -1 + }; + if same_site == 0 && !draft.is_secure { + issues.push(issue( + "SAME_SITE_NONE_INSECURE", + IssueSeverity::Warning, + Some(source), + &[("name", &name), ("domain", &domain)], + )); + } + + Some(UnifiedCookie { + name, + value: draft.value, + domain, + path, + expires, + is_secure: draft.is_secure, + is_http_only: draft.is_http_only, + same_site, + creation_time: now, + last_accessed: now, + }) +} + +/// A cookie store is keyed on (domain, name, path), so a paste that repeats one +/// is a self-overwrite. The last copy wins, matching what a browser would do +/// replaying the same set in order, and the shadowed one is reported. +fn dedupe( + finalized: Vec<(UnifiedCookie, String)>, + issues: &mut Vec, +) -> Vec { + let mut seen: HashMap<(String, String, String), usize> = HashMap::new(); + let mut shadowed = vec![false; finalized.len()]; + + for (index, (cookie, _)) in finalized.iter().enumerate() { + let key = ( + cookie.domain.clone(), + cookie.name.clone(), + cookie.path.clone(), + ); + if let Some(previous) = seen.insert(key, index) { + shadowed[previous] = true; + let (earlier, earlier_source) = &finalized[previous]; + issues.push(issue( + "DUPLICATE_COOKIE", + IssueSeverity::Warning, + Some(earlier_source), + &[ + ("name", &earlier.name), + ("domain", &earlier.domain), + ("path", &earlier.path), + ], + )); + } + } + + finalized + .into_iter() + .zip(shadowed) + .filter(|(_, shadowed)| !shadowed) + .map(|((cookie, _), _)| cookie) + .collect() +} + +fn read_bool( + object: &serde_json::Map, + keys: &[&str], + source: &str, + issues: &mut Vec, +) -> Option { + let (key, value) = keys + .iter() + .find_map(|key| object.get(*key).filter(|v| !v.is_null()).map(|v| (*key, v)))?; + + match value { + Value::Bool(b) => Some(*b), + Value::String(s) => { + let coerced = match s.trim().to_ascii_lowercase().as_str() { + "true" | "yes" | "1" => Some(true), + "false" | "no" | "0" => Some(false), + _ => None, + }; + match coerced { + Some(b) => { + issues.push(issue( + "BOOL_COERCED_FROM_STRING", + IssueSeverity::Warning, + Some(source), + &[("field", key), ("value", s)], + )); + Some(b) + } + None => { + issues.push(issue( + "BOOL_INVALID", + IssueSeverity::Warning, + Some(source), + &[("field", key), ("value", s)], + )); + None + } + } + } + Value::Number(n) => match n.as_i64() { + Some(0) => Some(false), + Some(1) => Some(true), + _ => { + issues.push(issue( + "BOOL_INVALID", + IssueSeverity::Warning, + Some(source), + &[("field", key), ("value", &n.to_string())], + )); + None + } + }, + other => { + issues.push(issue( + "BOOL_INVALID", + IssueSeverity::Warning, + Some(source), + &[("field", key), ("value", &other.to_string())], + )); + None + } + } +} + +/// A float-to-integer cast in Rust saturates, so `-1e30` arrives as `i64::MIN` +/// and every later bound check on it is meaningless. Bounding the float first +/// keeps a nonsense expiry a nonsense expiry instead of an extreme the rest of +/// the module has to defend against. +fn clamp_raw_expiry(seconds: f64) -> i64 { + let bound = MAX_RAW_EXPIRY as f64; + seconds.trunc().clamp(-bound, bound) as i64 +} + +/// Returns `None` for a session cookie. `session: true` wins outright; +/// otherwise the first present expiry key decides, whichever dialect it came +/// from, because reading only `expirationDate` turned every CDP, Puppeteer, +/// Playwright and WebDriver export into session cookies. +fn read_expiry( + object: &serde_json::Map, + source: &str, + issues: &mut Vec, +) -> Option { + if read_bool(object, &["session"], source, issues) == Some(true) { + return None; + } + + let (key, value) = ["expirationDate", "expires", "expiry"] + .iter() + .find_map(|key| object.get(*key).filter(|v| !v.is_null()).map(|v| (*key, v)))?; + + match value { + Value::Number(n) => n.as_f64().map(clamp_raw_expiry), + Value::String(s) => match s.trim().parse::() { + Ok(f) => Some(clamp_raw_expiry(f)), + Err(_) => match parse_http_date(s) { + Some(t) => Some(t), + None => { + issues.push(issue( + "EXPIRY_INVALID", + IssueSeverity::Warning, + Some(source), + &[("field", key), ("value", s.trim())], + )); + None + } + }, + }, + other => { + issues.push(issue( + "EXPIRY_INVALID", + IssueSeverity::Warning, + Some(source), + &[("field", key), ("value", &other.to_string())], + )); + None + } + } +} + +fn read_same_site( + object: &serde_json::Map, + source: &str, + issues: &mut Vec, +) -> i32 { + let Some(value) = ["sameSite", "samesite", "same_site"] + .iter() + .find_map(|key| object.get(*key).filter(|v| !v.is_null())) + else { + return -1; + }; + + let token = match value { + Value::String(s) => s.clone(), + Value::Number(n) => n.to_string(), + other => other.to_string(), + }; + match same_site_from_token(&token) { + Some(resolved) => resolved, + None => { + issues.push(issue( + "SAME_SITE_UNRECOGNIZED", + IssueSeverity::Warning, + Some(source), + &[("value", token.trim())], + )); + -1 + } + } +} + +fn parse_json(input: &str, issues: &mut Vec) -> Vec { + let value: Value = match serde_json::from_str(input.trim()) { + Ok(value) => value, + Err(e) => { + issues.push(issue( + "JSON_PARSE_FAILED", + IssueSeverity::Error, + None, + &[("message", &e.to_string())], + )); + return Vec::new(); + } + }; + let Some(items) = json_cookie_array(&value) else { + issues.push(issue( + "JSON_NOT_COOKIE_LIST", + IssueSeverity::Error, + None, + &[], + )); + return Vec::new(); + }; + + let mut drafts = Vec::new(); + for (index, item) in items.iter().enumerate() { + let source = format!("cookie {}", index + 1); + let Some(object) = item.as_object() else { + issues.push(issue( + "JSON_ENTRY_NOT_OBJECT", + IssueSeverity::Error, + Some(&source), + &[], + )); + continue; + }; + + let name = match object.get("name") { + None | Some(Value::Null) => { + issues.push(issue( + "NAME_MISSING", + IssueSeverity::Error, + Some(&source), + &[], + )); + continue; + } + Some(Value::String(s)) => s.clone(), + Some(other) => { + issues.push(issue( + "NAME_INVALID", + IssueSeverity::Error, + Some(&source), + &[("name", &other.to_string())], + )); + continue; + } + }; + + let value = match object.get("value") { + None | Some(Value::Null) => String::new(), + Some(Value::String(s)) => s.clone(), + Some(other) => { + // The name goes in the issue and the value never does: issue params are + // rendered in the dialog and survive into the import result, and the + // value IS the credential. + issues.push(issue( + "VALUE_COERCED", + IssueSeverity::Warning, + Some(&source), + &[("name", &name)], + )); + other.to_string() + } + }; + + let domain = match object.get("domain") { + None | Some(Value::Null) => None, + Some(Value::String(s)) => Some(s.clone()), + Some(other) => { + issues.push(issue( + "DOMAIN_INVALID", + IssueSeverity::Error, + Some(&source), + &[("name", &name), ("domain", &other.to_string())], + )); + continue; + } + }; + + let path = object + .get("path") + .and_then(Value::as_str) + .map(str::to_string); + + drafts.push(CookieDraft { + name, + value, + domain, + host_only: read_bool( + object, + &["hostOnly", "hostonly", "host_only"], + &source, + issues, + ), + path, + expires: read_expiry(object, &source, issues), + is_secure: read_bool(object, &["secure", "isSecure"], &source, issues).unwrap_or(false), + is_http_only: read_bool( + object, + &["httpOnly", "httponly", "http_only", "isHttpOnly"], + &source, + issues, + ) + .unwrap_or(false), + same_site: read_same_site(object, &source, issues), + source, + }); + } + + drafts +} + +fn parse_netscape(input: &str, issues: &mut Vec) -> Vec { + let mut drafts = Vec::new(); + + for (index, raw) in input.lines().enumerate() { + let source = format!("line {}", index + 1); + let line = raw.trim_end_matches('\r').trim_start(); + + // This prefix has to be tested before the comment test, or every cookie + // curl, wget, yt-dlp and the cookies.txt extensions mark HttpOnly — which + // is to say every login cookie — is thrown away as a comment. + let (body, prefixed_http_only) = match line.strip_prefix("#HttpOnly_") { + Some(rest) => (rest, true), + None => { + if line.starts_with('#') || line.trim_end().is_empty() { + continue; + } + (line, false) + } + }; + + let mut fields: Vec<&str> = body.split('\t').collect(); + if fields.len() == 6 + && (fields[2].eq_ignore_ascii_case("TRUE") || fields[2].eq_ignore_ascii_case("FALSE")) + { + // curl omits the path column when it is the default one. + fields.insert(2, "/"); + issues.push(issue( + "NETSCAPE_PATH_OMITTED", + IssueSeverity::Warning, + Some(&source), + &[], + )); + } + if fields.len() != 7 { + issues.push(issue( + "NETSCAPE_FIELD_COUNT", + IssueSeverity::Error, + Some(&source), + &[("expected", "7"), ("actual", &fields.len().to_string())], + )); + continue; + } + + let host_only = if fields[1].eq_ignore_ascii_case("TRUE") { + Some(false) + } else if fields[1].eq_ignore_ascii_case("FALSE") { + Some(true) + } else { + issues.push(issue( + "NETSCAPE_INCLUDE_SUBDOMAINS_INVALID", + IssueSeverity::Warning, + Some(&source), + &[("value", fields[1].trim())], + )); + None + }; + + let is_secure = if fields[3].eq_ignore_ascii_case("TRUE") { + true + } else if fields[3].eq_ignore_ascii_case("FALSE") { + false + } else { + issues.push(issue( + "NETSCAPE_SECURE_INVALID", + IssueSeverity::Warning, + Some(&source), + &[("value", fields[3].trim())], + )); + false + }; + + let raw_expiry = fields[4].trim(); + let expires = if raw_expiry.is_empty() || raw_expiry == "0" { + None + } else { + let (whole, fraction) = match raw_expiry.split_once('.') { + Some((whole, fraction)) => (whole, Some(fraction)), + None => (raw_expiry, None), + }; + let numeric = !whole.is_empty() + && whole.chars().all(|c| c.is_ascii_digit()) + && fraction.is_none_or(|f| !f.is_empty() && f.chars().all(|c| c.is_ascii_digit())); + if !numeric { + // Garbage here used to become 0, which meant "session" — a live + // credential conjured out of a malformed line, with nothing reported. + issues.push(issue( + "NETSCAPE_EXPIRY_INVALID", + IssueSeverity::Error, + Some(&source), + &[("value", raw_expiry)], + )); + continue; + } + match whole.parse::() { + Ok(seconds) => Some(seconds), + Err(_) => { + // All digits but wider than i64: a typo, not a timestamp. Pinning it + // to the ceiling keeps the cookie without inventing an epoch. + issues.push(issue( + "EXPIRY_CLAMPED", + IssueSeverity::Warning, + Some(&source), + &[("value", raw_expiry)], + )); + Some(MAX_PLAUSIBLE_EXPIRY_SECS) + } + } + }; + + drafts.push(CookieDraft { + name: fields[5].to_string(), + value: fields[6].to_string(), + domain: Some(fields[0].to_string()), + host_only, + path: Some(fields[2].to_string()), + expires, + is_secure, + is_http_only: prefixed_http_only, + // The format has no column for it, so claiming anything else would be an + // invention. -1 is Chromium's "unspecified". + same_site: -1, + source, + }); + } + + drafts +} + +fn parse_name_value( + input: &str, + site: &SiteHint, + now: i64, + issues: &mut Vec, +) -> Vec { + let mut drafts = Vec::new(); + + for (index, raw) in input.lines().enumerate() { + let source = format!("line {}", index + 1); + let mut line = raw.trim_end_matches('\r').trim(); + if line.is_empty() { + continue; + } + let mut is_request_header = false; + for prefix in ["set-cookie:", "cookie:"] { + // Compared as bytes: `line` may start mid-way through a multi-byte + // character, which slicing by the prefix length would split. + let bytes = line.as_bytes(); + if bytes.len() >= prefix.len() + && bytes[..prefix.len()].eq_ignore_ascii_case(prefix.as_bytes()) + { + is_request_header = prefix == "cookie:"; + line = line[prefix.len()..].trim(); + break; + } + } + if line.is_empty() { + continue; + } + + let mut pairs: Vec<(String, String)> = Vec::new(); + let mut attributes: HashMap = HashMap::new(); + + for segment in line.split(';') { + let segment = segment.trim(); + if segment.is_empty() { + continue; + } + // Split at the FIRST '=' so a base64 value keeps its padding. + let (raw_name, raw_value) = match segment.find('=') { + Some(at) => (&segment[..at], &segment[at + 1..]), + None => (segment, ""), + }; + let name = raw_name.trim(); + let value = raw_value.trim(); + + // A `Cookie:` request header carries no attributes at all, so a segment + // named `priority` or `secure` there is a cookie with that name. Reading + // it as a directive drops it and, worse, applies it to its neighbours. + let is_attribute = !is_request_header + && !pairs.is_empty() + && SET_COOKIE_ATTRIBUTES + .iter() + .any(|attribute| name.eq_ignore_ascii_case(attribute)); + if is_attribute { + let lowered = name.to_ascii_lowercase(); + // Nothing may disappear in silence: an attribute we act on shows up in + // the previewed cookie, but one we only parse and drop would not. + if !value.is_empty() && !ATTRIBUTES_WITH_A_USED_VALUE.contains(&lowered.as_str()) { + issues.push(issue( + "PAIR_TREATED_AS_ATTRIBUTE", + IssueSeverity::Info, + Some(&source), + &[("name", name)], + )); + } + attributes.insert(lowered, value.to_string()); + continue; + } + if name.is_empty() { + issues.push(issue( + "NAME_EMPTY", + IssueSeverity::Error, + Some(&source), + &[], + )); + continue; + } + pairs.push((name.to_string(), value.to_string())); + } + + if pairs.is_empty() { + issues.push(issue( + "NAME_VALUE_NO_PAIR", + IssueSeverity::Warning, + Some(&source), + &[], + )); + continue; + } + + if let Some(domain) = attributes.get("domain") { + // Compared with the leading dot intact: `.example.com` and `example.com` + // are different scopes, and dropping that difference is worth saying. + let stated = domain.trim().to_ascii_lowercase(); + if !stated.is_empty() && stated != site.host { + issues.push(issue( + "DOMAIN_ATTRIBUTE_IGNORED", + IssueSeverity::Info, + Some(&source), + &[("domain", domain.trim()), ("site", &site.host)], + )); + } + } + + let mut expires = None; + let mut deleted = false; + if let Some(max_age) = attributes.get("max-age") { + match max_age.trim().parse::() { + Ok(seconds) if seconds > 0 => expires = Some(now.saturating_add(seconds)), + // RFC 6265: a non-positive Max-Age deletes the cookie. Importing a + // deletion would be importing nothing at all. + Ok(_) => deleted = true, + Err(_) => issues.push(issue( + "MAX_AGE_INVALID", + IssueSeverity::Warning, + Some(&source), + &[("value", max_age.trim())], + )), + } + } + if deleted { + for (name, _) in &pairs { + issues.push(issue( + "MAX_AGE_DELETION", + IssueSeverity::Warning, + Some(&source), + &[("name", name)], + )); + } + continue; + } + if expires.is_none() { + if let Some(raw_expires) = attributes.get("expires") { + match parse_http_date(raw_expires) { + Some(timestamp) => expires = Some(timestamp), + None => issues.push(issue( + "EXPIRES_INVALID", + IssueSeverity::Warning, + Some(&source), + &[("value", raw_expires.trim())], + )), + } + } + } + + let path = attributes.get("path").cloned(); + let is_secure = attributes.contains_key("secure") || site.secure; + let is_http_only = attributes.contains_key("httponly"); + let same_site = match attributes.get("samesite") { + Some(raw_same_site) => match same_site_from_token(raw_same_site) { + Some(resolved) => resolved, + None => { + issues.push(issue( + "SAME_SITE_UNRECOGNIZED", + IssueSeverity::Warning, + Some(&source), + &[("value", raw_same_site.trim())], + )); + -1 + } + }, + None => -1, + }; + + for (name, value) in pairs { + if value.len() >= 2 && value.starts_with('"') && value.ends_with('"') { + // RFC 6265 4.1.1 makes the quotes part of the value, which is almost + // never what someone pasting a header meant. + issues.push(issue( + "QUOTED_VALUE", + IssueSeverity::Info, + Some(&source), + &[("name", &name)], + )); + } + drafts.push(CookieDraft { + name, + value, + // There is no domain to infer from a bare pair, so the site the user + // named is the only honest answer. + domain: Some(site.host.clone()), + host_only: None, + path: path.clone(), + expires, + is_secure, + is_http_only, + same_site, + source: source.clone(), + }); + } + } + + drafts +} + +#[cfg(test)] +mod tests { + use super::*; + + fn tsv(fields: &[&str]) -> String { + fields.join("\t") + } + + fn codes(parsed: &ParsedPaste) -> Vec<&str> { + parsed.issues.iter().map(|i| i.code.as_str()).collect() + } + + fn has_code(parsed: &ParsedPaste, code: &str) -> bool { + parsed.issues.iter().any(|i| i.code == code) + } + + fn cookie<'a>(parsed: &'a ParsedPaste, name: &str) -> &'a UnifiedCookie { + parsed + .cookies + .iter() + .find(|c| c.name == name) + .unwrap_or_else(|| panic!("no cookie named {name}, issues: {:?}", codes(parsed))) + } + + #[test] + fn test_detect_format_is_deterministic_per_shape() { + assert_eq!(detect_format("[]"), Some(PasteFormat::Json)); + assert_eq!( + detect_format(r#"{"cookies":[],"origins":[]}"#), + Some(PasteFormat::Json) + ); + assert_eq!( + detect_format(r#"{"name":"sid","value":"1","domain":"a.com"}"#), + Some(PasteFormat::Json) + ); + assert_eq!( + detect_format(&tsv(&[".a.com", "TRUE", "/", "FALSE", "0", "sid", "1"])), + Some(PasteFormat::Netscape) + ); + assert_eq!( + detect_format("# Netscape HTTP Cookie File\n"), + Some(PasteFormat::Netscape) + ); + assert_eq!( + detect_format("sid=1; other=2"), + Some(PasteFormat::NameValue) + ); + assert_eq!(detect_format(""), None); + assert_eq!(detect_format(" \n\t\n "), None); + assert_eq!(detect_format("just some prose"), None); + // A comment-only header still reads as Netscape, so the paste is reported + // as an empty cookies.txt rather than as an unknown format. + assert_eq!( + detect_format("# HTTP Cookie File\n# nothing else\n"), + Some(PasteFormat::Netscape) + ); + // A BOM in front of a JSON export must not change the answer. + assert_eq!(detect_format("\u{feff}[]"), Some(PasteFormat::Json)); + } + + #[test] + fn test_http_only_prefixed_lines_survive() { + // Exactly what `curl -c` writes for a login session. + let paste = format!( + "# Netscape HTTP Cookie File\n# This file was generated by libcurl!\n{}\n{}\n{}\n", + tsv(&[ + "#HttpOnly_.example.com", + "TRUE", + "/", + "TRUE", + "2145916800", + "session_id", + "abc123" + ]), + tsv(&[ + "#HttpOnly_www.example.com", + "FALSE", + "/app", + "TRUE", + "2145916800", + "csrf", + "tok" + ]), + tsv(&[ + ".example.com", + "TRUE", + "/", + "FALSE", + "2145916800", + "theme", + "dark" + ]), + ); + + let parsed = parse_paste(&paste, None); + assert_eq!(parsed.format, Some(PasteFormat::Netscape)); + assert_eq!(parsed.cookies.len(), 3, "issues: {:?}", codes(&parsed)); + + let session = cookie(&parsed, "session_id"); + assert!(session.is_http_only); + assert!(session.is_secure); + assert_eq!(session.domain, ".example.com"); + assert_eq!(session.expires, 2_145_916_800); + assert_eq!(session.same_site, -1); + + let csrf = cookie(&parsed, "csrf"); + assert!(csrf.is_http_only); + assert_eq!(csrf.domain, "www.example.com"); + assert_eq!(csrf.path, "/app"); + + assert!(!cookie(&parsed, "theme").is_http_only); + assert!(!has_code(&parsed, "NETSCAPE_FIELD_COUNT")); + } + + #[test] + fn test_netscape_six_field_curl_tolerance() { + let paste = tsv(&[".example.com", "TRUE", "FALSE", "2145916800", "sid", "v"]); + let parsed = parse_paste(&paste, None); + + assert_eq!(parsed.cookies.len(), 1, "issues: {:?}", codes(&parsed)); + let sid = cookie(&parsed, "sid"); + assert_eq!(sid.path, "/"); + assert!(!sid.is_secure); + assert_eq!(sid.expires, 2_145_916_800); + assert!(has_code(&parsed, "NETSCAPE_PATH_OMITTED")); + } + + #[test] + fn test_netscape_wrong_field_count_is_dropped_and_reported() { + let paste = format!( + "{}\n{}\n", + tsv(&[".a.com", "TRUE", "/", "FALSE", "0"]), + tsv(&[".a.com", "TRUE", "/", "FALSE", "0", "ok", "1"]), + ); + let parsed = parse_paste(&paste, None); + + assert_eq!(parsed.cookies.len(), 1); + let reported = parsed + .issues + .iter() + .find(|i| i.code == "NETSCAPE_FIELD_COUNT") + .expect("field count issue"); + assert_eq!(reported.source.as_deref(), Some("line 1")); + assert_eq!(reported.params.get("actual").map(String::as_str), Some("5")); + } + + #[test] + fn test_non_numeric_expiry_is_dropped_not_turned_into_a_live_cookie() { + let paste = format!( + "{}\n{}\n", + tsv(&[ + ".example.com", + "TRUE", + "/", + "FALSE", + "not-a-number", + "auth", + "secret" + ]), + tsv(&[".example.com", "TRUE", "/", "FALSE", "0", "keep", "1"]), + ); + let parsed = parse_paste(&paste, None); + + assert!( + parsed.cookies.iter().all(|c| c.name != "auth"), + "a malformed expiry must never become a session cookie" + ); + assert_eq!(parsed.cookies.len(), 1); + assert_eq!(cookie(&parsed, "keep").expires, 0); + let reported = parsed + .issues + .iter() + .find(|i| i.code == "NETSCAPE_EXPIRY_INVALID") + .expect("expiry issue"); + assert_eq!(reported.source.as_deref(), Some("line 1")); + } + + #[test] + fn test_netscape_fractional_expiry_truncates_and_empty_name_drops() { + let paste = format!( + "{}\n{}\n", + tsv(&[".a.com", "TRUE", "/", "FALSE", "2145916800.75", "sid", "v"]), + tsv(&[".a.com", "TRUE", "/", "FALSE", "0", "", "orphan"]), + ); + let parsed = parse_paste(&paste, None); + + assert_eq!(parsed.cookies.len(), 1); + assert_eq!(cookie(&parsed, "sid").expires, 2_145_916_800); + assert!(has_code(&parsed, "NAME_EMPTY")); + } + + #[test] + fn test_playwright_storage_state_wrapper() { + let paste = r#"{ + "cookies": [ + { + "name": "sid", + "value": "abc", + "domain": ".example.com", + "path": "/", + "expires": -1, + "httpOnly": true, + "secure": true, + "sameSite": "Lax" + }, + { + "name": "pref", + "value": "x", + "domain": "example.com", + "path": "/", + "expires": 2145916800, + "httpOnly": false, + "secure": false, + "sameSite": "None" + } + ], + "origins": [] + }"#; + + let parsed = parse_paste(paste, None); + assert_eq!(parsed.format, Some(PasteFormat::Json)); + assert_eq!(parsed.cookies.len(), 2, "issues: {:?}", codes(&parsed)); + + let sid = cookie(&parsed, "sid"); + assert_eq!(sid.expires, 0, "expires:-1 is Playwright for 'session'"); + assert_eq!(sid.same_site, 1); + assert!(sid.is_http_only && sid.is_secure); + + let pref = cookie(&parsed, "pref"); + assert_eq!(pref.expires, 2_145_916_800); + assert_eq!(pref.same_site, 0); + // SameSite=None without Secure is a cookie Chromium will refuse to send. + assert!(has_code(&parsed, "SAME_SITE_NONE_INSECURE")); + } + + #[test] + fn test_cdp_dump_with_capitalised_same_site() { + let paste = r#"[ + {"name":"a","value":"1","domain":".example.com","path":"/","expires":2145916800,"size":8,"httpOnly":true,"secure":true,"session":false,"sameSite":"Strict"}, + {"name":"b","value":"2","domain":".example.com","path":"/","expires":2145916800,"httpOnly":false,"secure":true,"session":false,"sameSite":"Lax"}, + {"name":"c","value":"3","domain":".example.com","path":"/","expires":-1,"httpOnly":false,"secure":true,"session":true,"sameSite":"None"} + ]"#; + + let parsed = parse_paste(paste, None); + assert_eq!(parsed.cookies.len(), 3, "issues: {:?}", codes(&parsed)); + assert_eq!(cookie(&parsed, "a").same_site, 2); + assert_eq!(cookie(&parsed, "b").same_site, 1); + assert_eq!(cookie(&parsed, "c").same_site, 0); + assert_eq!(cookie(&parsed, "c").expires, 0); + assert!(!has_code(&parsed, "SAME_SITE_UNRECOGNIZED")); + // Every one of these is Secure, so nothing should warn about None. + assert!(!has_code(&parsed, "SAME_SITE_NONE_INSECURE")); + } + + #[test] + fn test_puppeteer_expires_key_is_read() { + let paste = r#"[{"name":"sid","value":"v","domain":".example.com","path":"/","expires":2145916800,"httpOnly":true,"secure":true,"sameSite":"Lax"}]"#; + let parsed = parse_paste(paste, None); + + assert_eq!( + cookie(&parsed, "sid").expires, + 2_145_916_800, + "reading only expirationDate turned every Puppeteer export into a session cookie" + ); + } + + #[test] + fn test_webdriver_expiry_key_is_read() { + let paste = r#"[{"name":"sid","value":"v","domain":"example.com","path":"/","expiry":2145916800,"secure":false,"httpOnly":false}]"#; + let parsed = parse_paste(paste, None); + assert_eq!(cookie(&parsed, "sid").expires, 2_145_916_800); + } + + #[test] + fn test_adspower_shaped_payload() { + // AdsPower's documented shape: string booleans, a numeric-string expiry, + // and Cookie-Editor's sameSite spelling. + let paste = r#"[ + { + "domain": ".example.com", + "expirationDate": "2145916800", + "hostOnly": "false", + "httpOnly": "true", + "name": "session_id", + "path": "/", + "sameSite": "no_restriction", + "secure": "true", + "session": "false", + "value": "abcdef" + }, + { + "domain": "example.com", + "hostOnly": "true", + "name": "lang", + "path": "/", + "sameSite": "unspecified", + "session": "true", + "value": "en" + } + ]"#; + + let parsed = parse_paste(paste, None); + assert_eq!(parsed.cookies.len(), 2, "issues: {:?}", codes(&parsed)); + + let session = cookie(&parsed, "session_id"); + assert!(session.is_http_only && session.is_secure); + assert_eq!(session.expires, 2_145_916_800); + assert_eq!(session.same_site, 0); + assert_eq!(session.domain, ".example.com"); + + let lang = cookie(&parsed, "lang"); + assert_eq!(lang.expires, 0); + assert_eq!(lang.same_site, -1); + assert_eq!(lang.domain, "example.com"); + + // The string booleans were coerced, and that was reported rather than + // being silently read as false. + assert!(has_code(&parsed, "BOOL_COERCED_FROM_STRING")); + } + + #[test] + fn test_host_only_reconciliation_both_directions() { + let paste = r#"[ + {"name":"wide","value":"1","domain":"example.com","hostOnly":false,"path":"/"}, + {"name":"narrow","value":"2","domain":".example.com","hostOnly":true,"path":"/"}, + {"name":"quiet","value":"3","domain":".example.com","hostOnly":false,"path":"/"} + ]"#; + let parsed = parse_paste(paste, None); + + assert_eq!(cookie(&parsed, "wide").domain, ".example.com"); + assert_eq!(cookie(&parsed, "narrow").domain, "example.com"); + assert_eq!(cookie(&parsed, "quiet").domain, ".example.com"); + assert_eq!( + parsed + .issues + .iter() + .filter(|i| i.code == "HOST_ONLY_MISMATCH") + .count(), + 2, + "only the two contradictions are reported" + ); + } + + #[test] + fn test_netscape_include_subdomains_column_reconciles_like_host_only() { + let paste = format!( + "{}\n{}\n", + tsv(&["example.com", "TRUE", "/", "FALSE", "0", "wide", "1"]), + tsv(&[".example.com", "FALSE", "/", "FALSE", "0", "narrow", "2"]), + ); + let parsed = parse_paste(&paste, None); + + assert_eq!(cookie(&parsed, "wide").domain, ".example.com"); + assert_eq!(cookie(&parsed, "narrow").domain, "example.com"); + } + + #[test] + fn test_cookie_request_header() { + let parsed = parse_paste( + "Cookie: sid=abc; theme=dark; _ga=GA1.2.3", + Some("https://example.com/login"), + ); + + assert_eq!(parsed.format, Some(PasteFormat::NameValue)); + assert_eq!(parsed.cookies.len(), 3, "issues: {:?}", codes(&parsed)); + for c in &parsed.cookies { + assert_eq!(c.domain, "example.com"); + assert_eq!(c.path, "/"); + assert_eq!(c.expires, 0); + assert_eq!(c.same_site, -1); + assert!(!c.is_http_only); + assert!(c.is_secure, "an https site marks the cookies secure"); + } + assert_eq!(cookie(&parsed, "sid").value, "abc"); + } + + #[test] + fn test_request_header_pairs_are_never_read_as_attributes() { + // `priority`, `version` and `comment` are real application cookie names, + // and a request header has no attributes for them to be mistaken for. + let parsed = parse_paste( + "Cookie: sid=1; priority=high; version=3; comment=hi; theme=dark", + Some("example.com"), + ); + + assert_eq!(parsed.cookies.len(), 5, "issues: {:?}", codes(&parsed)); + assert_eq!(cookie(&parsed, "priority").value, "high"); + assert_eq!(cookie(&parsed, "comment").value, "hi"); + + // A colliding name must not leak its value onto the line's other cookies. + let secure = parse_paste("Cookie: sid=1; secure=yes", Some("example.com")); + assert_eq!(secure.cookies.len(), 2, "issues: {:?}", codes(&secure)); + assert_eq!(cookie(&secure, "secure").value, "yes"); + assert!( + !cookie(&secure, "sid").is_secure, + "a cookie named `secure` must not promote its neighbours" + ); + + let path = parse_paste("Cookie: sid=1; path=/admin", Some("example.com")); + assert_eq!(cookie(&path, "sid").path, "/"); + assert_eq!(cookie(&path, "path").value, "/admin"); + } + + #[test] + fn test_set_cookie_attribute_that_swallows_a_value_is_reported() { + // `Priority`'s value is parsed and then dropped, so consuming a pair on it + // has to leave a trace; `Path`'s value visibly shapes the cookie, so it + // does not. + let parsed = parse_paste("sid=1; Priority=High; Path=/app", Some("example.com")); + + assert_eq!(parsed.cookies.len(), 1); + assert_eq!(cookie(&parsed, "sid").path, "/app"); + let reported = parsed + .issues + .iter() + .find(|i| i.code == "PAIR_TREATED_AS_ATTRIBUTE") + .expect("swallowed pair issue"); + assert_eq!( + reported.params.get("name").map(String::as_str), + Some("Priority") + ); + assert_eq!( + parsed + .issues + .iter() + .filter(|i| i.code == "PAIR_TREATED_AS_ATTRIBUTE") + .count(), + 1, + "an attribute whose value is used is not a swallowed pair" + ); + + // Valueless attributes are not pairs and stay quiet. + let flags = parse_paste("sid=1; Secure; HttpOnly", Some("example.com")); + assert!(!has_code(&flags, "PAIR_TREATED_AS_ATTRIBUTE")); + } + + #[test] + fn test_a_stray_tab_does_not_reroute_a_header_into_netscape() { + let parsed = parse_paste("Cookie: sid=abc;\ttheme=dark", Some("a.com")); + + assert_eq!(parsed.format, Some(PasteFormat::NameValue)); + assert_eq!(parsed.cookies.len(), 2, "issues: {:?}", codes(&parsed)); + assert!(!has_code(&parsed, "NETSCAPE_FIELD_COUNT")); + } + + #[test] + fn test_broken_json_is_diagnosed_as_json() { + // A trailing comma from a truncated clipboard used to come back as + // "unrecognized format", or worse, be parsed as name=value pairs. + let parsed = parse_paste(r#"[{"name":"a","value":"b",}]"#, None); + + assert_eq!(parsed.format, Some(PasteFormat::Json)); + assert!(parsed.cookies.is_empty()); + let reported = parsed + .issues + .iter() + .find(|i| i.code == "JSON_PARSE_FAILED") + .expect("parse failure issue"); + assert!( + reported + .params + .get("message") + .is_some_and(|m| m.contains("line")), + "serde's line and column are what make this actionable" + ); + assert!(!has_code(&parsed, "UNRECOGNIZED_FORMAT")); + + let misshapen = parse_paste(r#"{"cookies":"nope"}"#, None); + assert_eq!(misshapen.format, Some(PasteFormat::Json)); + assert!(has_code(&misshapen, "JSON_NOT_COOKIE_LIST")); + } + + #[test] + fn test_extreme_expiries_are_bounded_and_never_overflow() { + let inputs = [ + r#"[{"name":"sid","value":"v","domain":"a.com","expirationDate":-9223372036854775808}]"# + .to_string(), + r#"[{"name":"sid","value":"v","domain":"a.com","expirationDate":-1e30}]"#.to_string(), + r#"[{"name":"sid","value":"v","domain":"a.com","expires":"-9223372036854775808"}]"# + .to_string(), + r#"[{"name":"sid","value":"v","domain":"a.com","expirationDate":1e30}]"#.to_string(), + ]; + + for input in &inputs { + let parsed = parse_paste(input, None); + let sid = cookie(&parsed, "sid"); + assert!( + (0..=MAX_PLAUSIBLE_EXPIRY_SECS).contains(&sid.expires), + "{input} produced {}", + sid.expires + ); + } + + // The same magnitudes arriving through a `Set-Cookie` date attribute. + let header = parse_paste("sid=1; Expires=-9223372036854775808", Some("example.com")); + assert_eq!(cookie(&header, "sid").expires, 0); + } + + #[test] + fn test_issue_params_never_carry_a_cookie_value() { + let paste = r#"[ + {"name":"sid","value":9223372036854775,"domain":"example.com"}, + {"name":"flag","value":true,"domain":"example.com"} + ]"#; + let parsed = parse_paste(paste, None); + + assert!(has_code(&parsed, "VALUE_COERCED")); + for reported in &parsed.issues { + for value in reported.params.values() { + assert!( + !value.contains("9223372036854775"), + "a pasted credential must never reach the issue list: {reported:?}" + ); + } + } + } + + #[test] + fn test_bare_host_site_does_not_invent_a_scheme() { + let parsed = parse_paste("sid=abc", Some("example.com")); + assert!(!cookie(&parsed, "sid").is_secure); + assert_eq!(cookie(&parsed, "sid").domain, "example.com"); + } + + #[test] + fn test_name_value_without_a_site_asks_for_one() { + let parsed = parse_paste("sid=abc; theme=dark", None); + + assert_eq!(parsed.format, Some(PasteFormat::NameValue)); + assert!(parsed.site_required); + assert!( + parsed.cookies.is_empty(), + "guessing a domain is not allowed" + ); + assert!(has_code(&parsed, "SITE_REQUIRED")); + } + + #[test] + fn test_set_cookie_line_with_attributes() { + let parsed = parse_paste( + "set-cookie: sid=abc123; Path=/app; Domain=.example.com; Expires=Wed, 21 Oct 2015 07:28:00 GMT; HttpOnly; Secure; SameSite=Strict", + Some("example.com"), + ); + + assert_eq!(parsed.cookies.len(), 1, "issues: {:?}", codes(&parsed)); + let sid = cookie(&parsed, "sid"); + assert_eq!(sid.value, "abc123"); + assert_eq!(sid.path, "/app"); + assert!(sid.is_http_only && sid.is_secure); + assert_eq!(sid.same_site, 2); + assert_eq!(sid.expires, 1_445_412_480); + assert_eq!(parsed.expired_count, 1, "2015 is long gone"); + // The Domain attribute is reported, never used to widen the scope behind + // the user's back. + assert!(has_code(&parsed, "DOMAIN_ATTRIBUTE_IGNORED")); + assert_eq!(sid.domain, "example.com"); + } + + #[test] + fn test_line_starting_with_domain_is_a_cookie_named_domain() { + let parsed = parse_paste("domain=eu; sid=1", Some("example.com")); + + assert_eq!(parsed.cookies.len(), 2, "issues: {:?}", codes(&parsed)); + assert_eq!(cookie(&parsed, "domain").value, "eu"); + assert_eq!(cookie(&parsed, "sid").value, "1"); + assert!(!has_code(&parsed, "DOMAIN_ATTRIBUTE_IGNORED")); + } + + #[test] + fn test_attribute_after_a_pair_is_an_attribute() { + let parsed = parse_paste("sid=1; domain=other.test", Some("example.com")); + assert_eq!(parsed.cookies.len(), 1); + assert_eq!(cookie(&parsed, "sid").domain, "example.com"); + assert!(has_code(&parsed, "DOMAIN_ATTRIBUTE_IGNORED")); + } + + #[test] + fn test_base64_values_keep_their_padding() { + let parsed = parse_paste( + "token=YWJjZGVmZ2g=; wide=YWJjZA==; nested=a=b=c", + Some("example.com"), + ); + + assert_eq!(cookie(&parsed, "token").value, "YWJjZGVmZ2g="); + assert_eq!(cookie(&parsed, "wide").value, "YWJjZA=="); + assert_eq!(cookie(&parsed, "nested").value, "a=b=c"); + } + + #[test] + fn test_quoted_value_keeps_quotes_and_says_so() { + let parsed = parse_paste("sid=\"abc\"", Some("example.com")); + assert_eq!(cookie(&parsed, "sid").value, "\"abc\""); + assert!(has_code(&parsed, "QUOTED_VALUE")); + } + + #[test] + fn test_max_age_zero_drops_the_cookie() { + let parsed = parse_paste("sid=deleted; Max-Age=0; Path=/", Some("example.com")); + assert!(parsed.cookies.is_empty()); + assert!(has_code(&parsed, "MAX_AGE_DELETION")); + + let negative = parse_paste("sid=deleted; Max-Age=-1", Some("example.com")); + assert!(negative.cookies.is_empty()); + assert!(has_code(&negative, "MAX_AGE_DELETION")); + } + + #[test] + fn test_max_age_wins_over_expires() { + let now = now_secs(); + let parsed = parse_paste( + "sid=1; Max-Age=3600; Expires=Wed, 21 Oct 2015 07:28:00 GMT", + Some("example.com"), + ); + + let expires = cookie(&parsed, "sid").expires; + assert!( + (expires - (now + 3600)).abs() <= 5, + "expected roughly now+3600, got {expires}" + ); + assert_eq!(parsed.expired_count, 0); + } + + #[test] + fn test_multiple_lines_cover_multiple_sites_worth_of_pairs() { + let parsed = parse_paste("a=1; b=2\nc=3", Some("example.com")); + assert_eq!(parsed.cookies.len(), 3); + assert_eq!( + parsed + .issues + .iter() + .filter(|i| i.code == "NAME_VALUE_NO_PAIR") + .count(), + 0 + ); + } + + #[test] + fn test_millisecond_expiry_is_converted() { + let paste = + r#"[{"name":"sid","value":"v","domain":"example.com","expirationDate":2145916800000}]"#; + let parsed = parse_paste(paste, None); + + assert_eq!(cookie(&parsed, "sid").expires, 2_145_916_800); + assert!(has_code(&parsed, "EXPIRY_MILLISECONDS")); + } + + #[test] + fn test_domain_falls_back_to_the_site_only_when_one_was_given() { + let paste = r#"[{"name":"sid","value":"v","path":"/"}]"#; + + let with_site = parse_paste(paste, Some("https://example.com")); + assert_eq!(cookie(&with_site, "sid").domain, "example.com"); + assert!(has_code(&with_site, "DOMAIN_FROM_SITE")); + + let without_site = parse_paste(paste, None); + assert!(without_site.cookies.is_empty()); + assert!(has_code(&without_site, "DOMAIN_MISSING")); + } + + #[test] + fn test_json_name_problems_get_distinct_codes() { + let paste = r#"[ + {"value":"1","domain":"a.com"}, + {"name":"","value":"2","domain":"a.com"}, + {"name":"bad name","value":"3","domain":"a.com"}, + {"name":42,"value":"4","domain":"a.com"}, + "not an object" + ]"#; + let parsed = parse_paste(paste, None); + + assert!(parsed.cookies.is_empty()); + assert!(has_code(&parsed, "NAME_MISSING")); + assert!(has_code(&parsed, "NAME_EMPTY")); + assert!(has_code(&parsed, "NAME_INVALID")); + assert!(has_code(&parsed, "JSON_ENTRY_NOT_OBJECT")); + let missing = parsed + .issues + .iter() + .find(|i| i.code == "NAME_MISSING") + .expect("name missing issue"); + assert_eq!(missing.source.as_deref(), Some("cookie 1")); + } + + #[test] + fn test_path_is_repaired_and_reported() { + let paste = r#"[{"name":"sid","value":"v","domain":"a.com","path":"app"}]"#; + let parsed = parse_paste(paste, None); + + assert_eq!(cookie(&parsed, "sid").path, "/app"); + assert!(has_code(&parsed, "PATH_REPAIRED")); + } + + #[test] + fn test_unrecognized_same_site_falls_back_to_unspecified() { + let paste = r#"[{"name":"sid","value":"v","domain":"a.com","sameSite":"sometimes"}]"#; + let parsed = parse_paste(paste, None); + + assert_eq!( + cookie(&parsed, "sid").same_site, + -1, + "an unknown dialect must not become an explicit None" + ); + assert!(has_code(&parsed, "SAME_SITE_UNRECOGNIZED")); + } + + #[test] + fn test_raw_integer_same_site_is_accepted_verbatim() { + let paste = r#"[ + {"name":"a","value":"1","domain":"a.com","sameSite":2}, + {"name":"b","value":"1","domain":"a.com","sameSite":-1}, + {"name":"c","value":"1","domain":"a.com","sameSite":9} + ]"#; + let parsed = parse_paste(paste, None); + + assert_eq!(cookie(&parsed, "a").same_site, 2); + assert_eq!(cookie(&parsed, "b").same_site, -1); + assert_eq!(cookie(&parsed, "c").same_site, -1); + assert!(has_code(&parsed, "SAME_SITE_UNRECOGNIZED")); + } + + #[test] + fn test_invalid_value_is_rejected() { + let paste = r#"[{"name":"sid","value":"a;b","domain":"a.com"}]"#; + let parsed = parse_paste(paste, None); + + assert!(parsed.cookies.is_empty()); + assert!(has_code(&parsed, "VALUE_INVALID")); + } + + #[test] + fn test_domain_is_normalised_and_malformed_ones_are_rejected() { + let paste = r#"[ + {"name":"a","value":"1","domain":"EXAMPLE.COM."}, + {"name":"b","value":"1","domain":"http://example.com/x"}, + {"name":"c","value":"1","domain":" "} + ]"#; + let parsed = parse_paste(paste, None); + + assert_eq!(cookie(&parsed, "a").domain, "example.com"); + assert!(parsed.cookies.iter().all(|c| c.name != "b")); + assert!(has_code(&parsed, "DOMAIN_INVALID")); + // A blank domain is "absent", so with no site it is the missing-domain case. + assert!(has_code(&parsed, "DOMAIN_MISSING")); + } + + #[test] + fn test_duplicates_keep_the_last_and_report_the_first() { + let paste = r#"[ + {"name":"sid","value":"old","domain":"a.com","path":"/"}, + {"name":"sid","value":"new","domain":"a.com","path":"/"}, + {"name":"sid","value":"other-path","domain":"a.com","path":"/x"} + ]"#; + let parsed = parse_paste(paste, None); + + assert_eq!(parsed.cookies.len(), 2); + assert_eq!(cookie(&parsed, "sid").value, "new"); + let duplicate = parsed + .issues + .iter() + .find(|i| i.code == "DUPLICATE_COOKIE") + .expect("duplicate issue"); + assert_eq!(duplicate.source.as_deref(), Some("cookie 1")); + } + + #[test] + fn test_expired_cookies_are_counted_but_still_returned() { + let paste = r#"[ + {"name":"old","value":"1","domain":"a.com","expirationDate":1000000000}, + {"name":"live","value":"1","domain":"a.com","expirationDate":2145916800}, + {"name":"session","value":"1","domain":"a.com"} + ]"#; + let parsed = parse_paste(paste, None); + + assert_eq!(parsed.cookies.len(), 3); + assert_eq!(parsed.expired_count, 1); + } + + #[test] + fn test_empty_input() { + for input in ["", " ", "\n\n", "\u{feff}"] { + let parsed = parse_paste(input, Some("example.com")); + assert_eq!(parsed.format, None); + assert!(parsed.cookies.is_empty()); + assert!(!parsed.site_required); + assert_eq!(parsed.expired_count, 0); + assert!(has_code(&parsed, "EMPTY_INPUT"), "input: {input:?}"); + } + } + + #[test] + fn test_unrecognized_input_is_reported_never_silently_empty() { + let parsed = parse_paste("this is just a sentence", None); + assert_eq!(parsed.format, None); + assert!(parsed.cookies.is_empty()); + assert!(has_code(&parsed, "UNRECOGNIZED_FORMAT")); + } + + #[test] + fn test_malformed_input_of_every_shape_never_panics() { + let inputs = [ + "[", + "{", + "[{}]", + "[null]", + "[1,2,3]", + "{\"cookies\":\"nope\"}", + "{\"cookies\":[[]]}", + "\t\t\t\t\t\t", + "\t", + "a\tb", + "#HttpOnly_", + "#HttpOnly_\t\t\t\t\t\t", + "# Netscape HTTP Cookie File", + "=", + ";;;;", + "=;=;=", + "Cookie:", + "Cookie: ", + "sid", + "sid=", + "=value", + "\u{0}\u{1}\u{2}", + "sid=\u{7f}bad", + "домен=значение", + &"a".repeat(10_000), + &format!("sid={}", "x".repeat(10_000)), + &tsv(&["", "", "", "", "", "", ""]), + &tsv(&[".a.com", "MAYBE", "x", "MAYBE", "-5", "n", "v"]), + &tsv(&[ + ".a.com", + "TRUE", + "/", + "TRUE", + "99999999999999999999", + "n", + "v", + ]), + "[{\"name\":\"a\",\"domain\":{},\"value\":[]}]", + "[{\"name\":\"a\",\"domain\":\"a.com\",\"expires\":{}}]", + "[{\"name\":\"a\",\"domain\":\"a.com\",\"sameSite\":{}}]", + "[{\"name\":\"a\",\"domain\":\"a.com\",\"secure\":{}}]", + // Every route an expiry at the ends of the i64 range can take in. + "[{\"name\":\"a\",\"domain\":\"a.com\",\"expirationDate\":-9223372036854775808}]", + "[{\"name\":\"a\",\"domain\":\"a.com\",\"expirationDate\":9223372036854775807}]", + "[{\"name\":\"a\",\"domain\":\"a.com\",\"expirationDate\":-1e30}]", + "[{\"name\":\"a\",\"domain\":\"a.com\",\"expirationDate\":1e30}]", + "[{\"name\":\"a\",\"domain\":\"a.com\",\"expires\":\"-9223372036854775808\"}]", + "[{\"name\":\"a\",\"domain\":\"a.com\",\"expiry\":\"nan\"}]", + "sid=1; Expires=-9223372036854775808", + "sid=1; Expires=9223372036854775807", + "sid=1; Max-Age=9223372036854775807", + "[{\"name\":\"a\",\"value\":\"b\",}]", + "{\"cookies\":[", + "Cookie: sid=1; secure=yes; path=x; expires=nope", + ]; + + for input in inputs { + for site in [None, Some("example.com"), Some("https://a.b/c"), Some(" ")] { + let parsed = parse_paste(input, site); + // Whatever happens, the result stays internally consistent: nothing is + // reported as parsed without a format, and nothing is silently empty. + if parsed.cookies.is_empty() && parsed.issues.is_empty() { + panic!("silent empty result for {input:?} / {site:?}"); + } + assert!(parsed.expired_count <= parsed.cookies.len()); + if !parsed.cookies.is_empty() { + assert!(parsed.format.is_some()); + } + for cookie in &parsed.cookies { + assert!(!cookie.name.is_empty()); + assert!(!cookie.domain.is_empty()); + assert!(cookie.path.starts_with('/')); + assert!((-1..=2).contains(&cookie.same_site)); + assert!(cookie.expires >= 0); + assert!(cookie.expires <= MAX_PLAUSIBLE_EXPIRY_SECS); + } + } + // The detector must agree with itself no matter how odd the input is. + assert_eq!(detect_format(input), detect_format(input)); + } + } + + #[test] + fn test_huge_netscape_expiry_is_clamped_not_overflowed() { + let paste = tsv(&[ + ".a.com", + "TRUE", + "/", + "TRUE", + "99999999999999999999", + "sid", + "v", + ]); + let parsed = parse_paste(&paste, None); + + assert_eq!(cookie(&parsed, "sid").expires, MAX_PLAUSIBLE_EXPIRY_SECS); + assert!(has_code(&parsed, "EXPIRY_CLAMPED")); + } + + #[test] + fn test_single_json_object_is_treated_as_one_cookie() { + let parsed = parse_paste( + r#"{"name":"sid","value":"v","domain":"example.com","path":"/","secure":true}"#, + None, + ); + assert_eq!(parsed.cookies.len(), 1); + assert!(cookie(&parsed, "sid").is_secure); + } +} diff --git a/src-tauri/src/default_browser.rs b/src-tauri/src/default_browser.rs index fcd22a0..426e69c 100644 --- a/src-tauri/src/default_browser.rs +++ b/src-tauri/src/default_browser.rs @@ -358,38 +358,44 @@ mod windows { Ok(()) } + /// Tell the shell that the association it has cached is stale. + /// + /// `SHChangeNotify` is the documented announcement for an association change, + /// and the `WM_SETTINGCHANGE` broadcast is what the shell's own settings UI + /// sends alongside it, so both go out. + /// + /// This used to hand-declare `SendMessageTimeoutA` with `lpdwResult` typed as + /// `*mut u32` and pass it a `u32`. The real parameter is `PDWORD_PTR`, eight + /// bytes on x64, so every call wrote four bytes past a stack slot. The result + /// was a corrupted stack at the exact moment a user set Donut as their default + /// browser, and the process died with nothing in the log. Go through the + /// `windows` crate instead, which types the out-parameter correctly and cannot + /// drift from the real ABI. fn notify_system_of_changes() { - // Use Windows API to notify the system of association changes - // This helps refresh the system's understanding of the changes + use windows::core::w; + use windows::Win32::Foundation::{LPARAM, WPARAM}; + use windows::Win32::UI::Shell::{SHChangeNotify, SHCNE_ASSOCCHANGED, SHCNF_IDLIST}; + use windows::Win32::UI::WindowsAndMessaging::{ + SendMessageTimeoutW, HWND_BROADCAST, SMTO_ABORTIFHUNG, WM_SETTINGCHANGE, + }; + unsafe { - use std::ffi::c_void; + SHChangeNotify(SHCNE_ASSOCCHANGED, SHCNF_IDLIST, None, None); - const HWND_BROADCAST: *mut c_void = 0xffff as *mut c_void; - const WM_SETTINGCHANGE: u32 = 0x001A; - const SMTO_ABORTIFHUNG: u32 = 0x0002; - - extern "system" { - fn SendMessageTimeoutA( - hWnd: *mut c_void, - Msg: u32, - wParam: usize, - lParam: isize, - fuFlags: u32, - uTimeout: u32, - lpdwResult: *mut u32, - ) -> isize; - } - - let mut result: u32 = 0; - - SendMessageTimeoutA( + // The broadcast is best-effort: a hung top-level window elsewhere on the + // desktop must not hold up the click that triggered this, hence the + // timeout and SMTO_ABORTIFHUNG. `WM_SETTINGCHANGE`'s lParam string is + // marshalled cross-process by the window manager, and this one is + // 'static, so it stays valid for the whole call. + let mut result: usize = 0; + SendMessageTimeoutW( HWND_BROADCAST, WM_SETTINGCHANGE, - 0, - c"Software\\Classes".as_ptr() as isize, + WPARAM(0), + LPARAM(w!("Software\\Classes").as_ptr() as isize), SMTO_ABORTIFHUNG, 1000, - &mut result, + Some(&mut result), ); } } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 0255bb7..a4f8a4e 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -105,6 +105,7 @@ mod cloud_errors; mod commercial_license; mod cookie_bot; mod cookie_manager; +mod cookie_paste; pub mod events; mod mcp_integrations; mod mcp_server; @@ -476,29 +477,57 @@ async fn copy_profile_cookies( Ok(results) } +/// Push a profile's freshly written cookies to the cloud, if it syncs at all. +fn queue_profile_cookie_sync(profile_id: &str) { + let Some(scheduler) = crate::sync::get_global_scheduler() else { + return; + }; + let Ok(profiles) = profile::manager::ProfileManager::instance().list_profiles() else { + return; + }; + let syncs = profiles + .iter() + .any(|p| p.id.to_string() == profile_id && p.is_sync_enabled()); + if !syncs { + return; + } + let pid = profile_id.to_string(); + tauri::async_runtime::spawn(async move { + scheduler.queue_profile_sync(pid).await; + }); +} + #[tauri::command] -async fn import_cookies_from_file( +async fn analyze_pasted_cookies( app_handle: tauri::AppHandle, profile_id: String, content: String, -) -> Result { - let result = - cookie_manager::CookieManager::import_cookies(&app_handle, &profile_id, &content).await?; + site: Option, +) -> Result { + cookie_manager::CookieManager::analyze_paste(&app_handle, &profile_id, &content, site.as_deref()) + .await +} - // Trigger sync for the profile if sync is enabled - if let Some(scheduler) = crate::sync::get_global_scheduler() { - let profile_manager = profile::manager::ProfileManager::instance(); - if let Ok(profiles) = profile_manager.list_profiles() { - if let Some(profile) = profiles.iter().find(|p| p.id.to_string() == profile_id) { - if profile.is_sync_enabled() { - let pid = profile_id.clone(); - tauri::async_runtime::spawn(async move { - scheduler.queue_profile_sync(pid).await; - }); - } - } - } - } +#[tauri::command] +async fn import_pasted_cookies( + app_handle: tauri::AppHandle, + profile_id: String, + content: String, + site: Option, + mode: cookie_manager::CookieWriteMode, + include_expired: bool, +) -> Result { + let result = cookie_manager::CookieManager::import_paste( + &app_handle, + &profile_id, + &content, + site.as_deref(), + mode, + include_expired, + ) + .await?; + + queue_profile_cookie_sync(&profile_id); Ok(result) } @@ -2821,7 +2850,8 @@ pub fn run_with_builder( read_profile_cookies, get_profile_cookie_stats, copy_profile_cookies, - import_cookies_from_file, + analyze_pasted_cookies, + import_pasted_cookies, export_profile_cookies, check_wayfern_terms_accepted, check_wayfern_downloaded, diff --git a/src/components/cookie-management-dialog.tsx b/src/components/cookie-management-dialog.tsx index 37efe76..7537a62 100644 --- a/src/components/cookie-management-dialog.tsx +++ b/src/components/cookie-management-dialog.tsx @@ -5,9 +5,11 @@ import { save } from "@tauri-apps/plugin-dialog"; import { writeTextFile } from "@tauri-apps/plugin-fs"; import { useCallback, useEffect, useMemo, useState } from "react"; import { useTranslation } from "react-i18next"; -import { LuChevronRight, LuUpload } from "react-icons/lu"; +import { LuChevronRight } from "react-icons/lu"; import { toast } from "sonner"; +import { CookiePastePanel, IssueRow } from "@/components/cookie-paste-panel"; import { LoadingButton } from "@/components/loading-button"; +import { Alert, AlertDescription } from "@/components/ui/alert"; import { AnimatedDisclosureChevron, AnimatedDisclosureContent, @@ -31,19 +33,17 @@ import { SelectValue, } from "@/components/ui/select"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { translateBackendError } from "@/lib/backend-errors"; import type { BrowserProfile, + CookieAnalysis, + CookiePasteImportResult, CookieReadResult, + CookieWriteMode, DomainCookies, UnifiedCookie, } from "@/types"; -interface CookieImportResult { - cookies_imported: number; - cookies_replaced: number; - errors: string[]; -} - interface CookieManagementDialogProps { isOpen: boolean; onClose: () => void; @@ -59,21 +59,8 @@ type SelectionState = Record< } >; -const countCookies = (content: string): number => { - const trimmed = content.trim(); - if (trimmed.startsWith("[")) { - try { - const arr = JSON.parse(trimmed); - if (Array.isArray(arr)) return arr.length; - } catch { - // Fall through to Netscape counting - } - } - return content.split("\n").filter((line) => { - const l = line.trim(); - return l && !l.startsWith("#"); - }).length; -}; +/** Long enough that a paste is not re-parsed on every keystroke of a fix. */ +const ANALYZE_DEBOUNCE_MS = 250; function formatJsonCookies(cookies: UnifiedCookie[]): string { const arr = cookies.map((c) => { @@ -130,13 +117,16 @@ export function CookieManagementDialog({ }: CookieManagementDialogProps) { const { t } = useTranslation(); // Import state - const [fileContent, setFileContent] = useState(null); - const [fileName, setFileName] = useState(null); - const [cookieCount, setCookieCount] = useState(0); + const [pasteContent, setPasteContent] = useState(""); + const [pasteSite, setPasteSite] = useState(""); + const [writeMode, setWriteMode] = useState("merge"); + const [includeExpired, setIncludeExpired] = useState(false); + const [analysis, setAnalysis] = useState(null); + const [isAnalyzing, setIsAnalyzing] = useState(false); + const [importError, setImportError] = useState(null); const [isImporting, setIsImporting] = useState(false); - const [importResult, setImportResult] = useState( - null, - ); + const [importResult, setImportResult] = + useState(null); // Export state const [format, setFormat] = useState<"netscape" | "json">("json"); @@ -179,7 +169,7 @@ export function CookieManagementDialog({ } catch (err) { toast.error( t("cookies.management.loadFailed", { - error: err instanceof Error ? err.message : String(err), + error: translateBackendError(t, err), }), ); } finally { @@ -196,9 +186,13 @@ export function CookieManagementDialog({ }, [activeTab, profile, exportCookieData, loadExportCookies]); const resetImportState = useCallback(() => { - setFileContent(null); - setFileName(null); - setCookieCount(0); + setPasteContent(""); + setPasteSite(""); + setWriteMode("merge"); + setIncludeExpired(false); + setAnalysis(null); + setIsAnalyzing(false); + setImportError(null); setIsImporting(false); setImportResult(null); }, []); @@ -229,41 +223,82 @@ export function CookieManagementDialog({ [resetImportState, resetExportState], ); - const handleFileRead = useCallback( - (file: File) => { - const reader = new FileReader(); - reader.onload = (e) => { - const content = e.target?.result as string; - setFileContent(content); - setFileName(file.name); - setCookieCount(countCookies(content)); - }; - reader.onerror = () => { - toast.error(t("cookies.management.fileReadError")); - }; - reader.readAsText(file); - }, - [t], - ); + const profileId = profile?.id; + + useEffect(() => { + if (!isOpen || !profileId || importResult) return; + if (pasteContent.trim() === "") { + setAnalysis(null); + setIsAnalyzing(false); + return; + } + + // Editing the paste is the user acting on the last failure, so retire it. + setImportError(null); + setIsAnalyzing(true); + let cancelled = false; + const timer = setTimeout(() => { + void invoke("analyze_pasted_cookies", { + profileId, + content: pasteContent, + site: pasteSite.trim() === "" ? null : pasteSite, + }) + .then((result) => { + if (!cancelled) setAnalysis(result); + }) + .catch((error: unknown) => { + if (cancelled) return; + setAnalysis(null); + setImportError(translateBackendError(t, error)); + }) + .finally(() => { + if (!cancelled) setIsAnalyzing(false); + }); + }, ANALYZE_DEBOUNCE_MS); + + return () => { + cancelled = true; + clearTimeout(timer); + }; + }, [isOpen, profileId, pasteContent, pasteSite, importResult, t]); const handleImport = useCallback(async () => { - if (!fileContent || !profile) return; + if (!profileId) return; setIsImporting(true); + setImportError(null); try { - const result = await invoke( - "import_cookies_from_file", + const result = await invoke( + "import_pasted_cookies", { - profileId: profile.id, - content: fileContent, + profileId, + content: pasteContent, + site: pasteSite.trim() === "" ? null : pasteSite, + mode: writeMode, + includeExpired, }, ); setImportResult(result); } catch (error) { - toast.error(error instanceof Error ? error.message : String(error)); + // Kept inside the dialog rather than toasted: a toast would take the + // failure away while leaving the user with a paste they cannot fix. + setImportError(translateBackendError(t, error)); } finally { setIsImporting(false); } - }, [fileContent, profile]); + }, [profileId, pasteContent, pasteSite, writeMode, includeExpired, t]); + + const importBlockedReason = useMemo(() => { + if (pasteContent.trim() === "") return t("cookies.paste.disabledEmpty"); + if (isAnalyzing || !analysis) return null; + if (analysis.blockedBy) { + return translateBackendError(t, analysis.blockedBy); + } + if (analysis.siteRequired) return t("cookies.paste.disabledSite"); + if (analysis.cookies.length === 0) { + return t("cookies.paste.disabledNoCookies"); + } + return null; + }, [pasteContent, isAnalyzing, analysis, t]); const getSelectedCookies = useCallback((): UnifiedCookie[] => { if (!exportCookieData) return []; @@ -312,7 +347,7 @@ export function CookieManagementDialog({ toast.success(t("cookies.export.success")); handleClose(); } catch (error) { - toast.error(error instanceof Error ? error.message : String(error)); + toast.error(translateBackendError(t, error)); } finally { setIsExporting(false); } @@ -415,92 +450,102 @@ export function CookieManagementDialog({ - {!fileContent && ( -
+ {!importResult && ( + <>

{t("cookies.management.importDescription")}

-
- document.getElementById("cookie-file-input")?.click() - } - onKeyDown={(e) => { - if (e.key === "Enter" || e.key === " ") { - e.preventDefault(); - document.getElementById("cookie-file-input")?.click(); - } - }} - > - -

- {t("cookies.management.dropPrompt")} -
- - {t("cookies.management.fileFormats")} - -

- { - const file = e.target.files?.[0]; - if (file) handleFileRead(file); - e.target.value = ""; - }} - /> -
-
- )} - {fileContent && !importResult && ( -
-
-
-
{fileName}
-
- {t("cookies.management.cookiesFound", { - count: cookieCount, - })} -
+ + +
+ {/* Sits with the button, not at the top of the dialog: the + panel is taller than the viewport, so a failure announced + above the description is a failure nobody sees. */} + {importError && ( + + {importError} + + )} +
+ + {t("common.buttons.cancel")} + + void handleImport()} + disabled={ + isAnalyzing || + analysis === null || + importBlockedReason !== null + } + > + {t("common.buttons.import")} +
+ {importBlockedReason && ( +

+ {importBlockedReason} +

+ )}
-
- - {t("cookies.management.backButton")} - - void handleImport()} - disabled={cookieCount === 0} - > - {t("cookies.management.importButton")} - -
-
+ )} {importResult && (
-
-
- {t("cookies.management.importedSuccess", { - imported: importResult.cookies_imported, - replaced: importResult.cookies_replaced, - })} -
- {importResult.errors.length > 0 && ( -
- {t("cookies.management.linesSkipped", { - count: importResult.errors.length, - })} -
- )} +
+ + + +
+ + {importResult.issues.length > 0 && ( +
+ + +
+ {importResult.issues.map((issue, index) => ( + + ))} +
+
+
+ )} +
{t("cookies.management.doneButton")} @@ -605,6 +650,16 @@ export function CookieManagementDialog({ ); } +/** Zeros are shown too: "deleted 0" is the reassurance replace mode needs. */ +function ResultCounter({ label, value }: { label: string; value: number }) { + return ( +
+
{label}
+
{value}
+
+ ); +} + interface ExportDomainRowProps { domain: DomainCookies; selection: SelectionState; diff --git a/src/components/cookie-paste-panel.tsx b/src/components/cookie-paste-panel.tsx new file mode 100644 index 0000000..e4860f0 --- /dev/null +++ b/src/components/cookie-paste-panel.tsx @@ -0,0 +1,512 @@ +"use client"; + +import type { TFunction } from "i18next"; +import { useCallback, useMemo, useRef, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { LuTriangleAlert } from "react-icons/lu"; +import { Alert, AlertDescription } from "@/components/ui/alert"; +import { Badge } from "@/components/ui/badge"; +import { Checkbox } from "@/components/ui/checkbox"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { Textarea } from "@/components/ui/textarea"; +import type { + CookieAnalysis, + CookieIssue, + CookiePasteFormat, + CookieWriteMode, + PastedCookiePreview, +} from "@/types"; + +/** Issue codes `cookie_paste.rs` can emit, mapped to their translation key. */ +const ISSUE_KEYS: Record = { + EMPTY_INPUT: "emptyInput", + SITE_INVALID: "siteInvalid", + UNRECOGNIZED_FORMAT: "unrecognizedFormat", + SITE_REQUIRED: "siteRequired", + NO_COOKIES_FOUND: "noCookiesFound", + NAME_EMPTY: "nameEmpty", + NAME_INVALID: "nameInvalid", + NAME_MISSING: "nameMissing", + VALUE_INVALID: "valueInvalid", + VALUE_COERCED: "valueCoerced", + DOMAIN_FROM_SITE: "domainFromSite", + DOMAIN_MISSING: "domainMissing", + DOMAIN_INVALID: "domainInvalid", + DOMAIN_ATTRIBUTE_IGNORED: "domainAttributeIgnored", + HOST_ONLY_MISMATCH: "hostOnlyMismatch", + PATH_REPAIRED: "pathRepaired", + EXPIRY_MILLISECONDS: "expiryMilliseconds", + EXPIRY_CLAMPED: "expiryClamped", + EXPIRY_INVALID: "expiryInvalid", + EXPIRES_INVALID: "expiresInvalid", + MAX_AGE_INVALID: "maxAgeInvalid", + MAX_AGE_DELETION: "maxAgeDeletion", + SAME_SITE_NONE_INSECURE: "sameSiteNoneInsecure", + SAME_SITE_UNRECOGNIZED: "sameSiteUnrecognized", + DUPLICATE_COOKIE: "duplicateCookie", + BOOL_COERCED_FROM_STRING: "boolCoercedFromString", + BOOL_INVALID: "boolInvalid", + QUOTED_VALUE: "quotedValue", + JSON_PARSE_FAILED: "jsonParseFailed", + JSON_NOT_COOKIE_LIST: "jsonNotCookieList", + JSON_ENTRY_NOT_OBJECT: "jsonEntryNotObject", + NETSCAPE_PATH_OMITTED: "netscapePathOmitted", + NETSCAPE_FIELD_COUNT: "netscapeFieldCount", + NETSCAPE_INCLUDE_SUBDOMAINS_INVALID: "netscapeIncludeSubdomainsInvalid", + NETSCAPE_SECURE_INVALID: "netscapeSecureInvalid", + NETSCAPE_EXPIRY_INVALID: "netscapeExpiryInvalid", + NAME_VALUE_NO_PAIR: "nameValueNoPair", + PAIR_TREATED_AS_ATTRIBUTE: "pairTreatedAsAttribute", +}; + +const FORMAT_KEYS: Record = { + json: "cookies.paste.formatJson", + netscape: "cookies.paste.formatNetscape", + nameValue: "cookies.paste.formatNameValue", +}; + +const VISIBLE_ISSUES = 5; + +const RELATIVE_UNITS: [Intl.RelativeTimeFormatUnit, number][] = [ + ["year", 31_536_000], + ["month", 2_592_000], + ["day", 86_400], + ["hour", 3600], + ["minute", 60], +]; + +export interface CookiePastePanelProps { + content: string; + onContentChange: (content: string) => void; + site: string; + onSiteChange: (site: string) => void; + mode: CookieWriteMode; + onModeChange: (mode: CookieWriteMode) => void; + includeExpired: boolean; + onIncludeExpiredChange: (includeExpired: boolean) => void; + analysis: CookieAnalysis | null; + isAnalyzing: boolean; + disabled: boolean; +} + +export function CookiePastePanel({ + content, + onContentChange, + site, + onSiteChange, + mode, + onModeChange, + includeExpired, + onIncludeExpiredChange, + analysis, + isAnalyzing, + disabled, +}: CookiePastePanelProps) { + const { t, i18n } = useTranslation(); + const fileInputRef = useRef(null); + const [showAllIssues, setShowAllIssues] = useState(false); + const [fileError, setFileError] = useState(null); + + const relativeFormatter = useMemo( + () => new Intl.RelativeTimeFormat(i18n.language, { numeric: "auto" }), + [i18n.language], + ); + + const readFile = useCallback( + (file: File) => { + const reader = new FileReader(); + reader.onload = (event) => { + setFileError(null); + onContentChange(String(event.target?.result ?? "")); + }; + reader.onerror = () => { + setFileError(t("cookies.management.fileReadError")); + }; + reader.readAsText(file); + }, + [onContentChange, t], + ); + + const handleDrop = useCallback( + (event: React.DragEvent) => { + const file = event.dataTransfer.files[0]; + if (!file) return; + event.preventDefault(); + readFile(file); + }, + [readFile], + ); + + // A JSON or Netscape entry with no domain of its own needs the site as much + // as a bare pair does: without the field, "carries no domain and no site was + // given" is a dead end with no control anywhere that answers it. The last + // clause keeps the field once anything has been typed into it, because every + // other condition stops being true the moment the site is accepted, which + // would yank the input out from under the cursor. + const siteVisible = + analysis !== null && + (analysis.siteRequired || + analysis.format === "nameValue" || + analysis.issues.some((issue) => issue.code === "DOMAIN_MISSING") || + site.trim() !== ""); + + const scopeDomains = useMemo(() => { + if (!analysis) return []; + return [...new Set(analysis.cookies.map((cookie) => cookie.domain))]; + }, [analysis]); + + const issues = analysis?.issues ?? []; + const shownIssues = showAllIssues ? issues : issues.slice(0, VISIBLE_ISSUES); + + const formatExpiry = useCallback( + (expires: number) => { + if (expires === 0) return t("cookies.paste.session"); + const delta = expires - Math.floor(Date.now() / 1000); + for (const [unit, seconds] of RELATIVE_UNITS) { + if (Math.abs(delta) >= seconds) { + return relativeFormatter.format(Math.trunc(delta / seconds), unit); + } + } + return relativeFormatter.format(delta, "second"); + }, + [relativeFormatter, t], + ); + + return ( +
+
+ +