diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 47757e39..8a483aea 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -206,7 +206,7 @@ jobs: - name: Inject updater public key from secrets if: startsWith(github.ref, 'refs/tags/') run: | - node -e "const fs=require('node:fs');const p='src-tauri/tauri.conf.json';const key=(process.env.TAURI_UPDATER_PUBKEY||'').trim();if(!key){console.error('TAURI_UPDATER_PUBKEY secret is required for tagged releases');process.exit(1);}const c=JSON.parse(fs.readFileSync(p,'utf8'));c.plugins??={};c.plugins.updater??={};c.plugins.updater.pubkey=key;fs.writeFileSync(p,JSON.stringify(c,null,2)+'\n');console.log('Injected updater pubkey into tauri.conf.json for this workflow run.');" + node -e "const fs=require('node:fs');const p='src-tauri/tauri.conf.json';const key=(process.env.TAURI_UPDATER_PUBKEY||'').trim();if(!key){console.error('TAURI_UPDATER_PUBKEY secret is required for tagged releases');process.exit(1);}const c=JSON.parse(fs.readFileSync(p,'utf8'));c.plugins??={};c.plugins.updater??={};c.plugins.updater.pubkey=key;c.bundle??={};c.bundle.createUpdaterArtifacts=true;fs.writeFileSync(p,JSON.stringify(c,null,2)+'\n');console.log('Enabled updater artifacts + injected pubkey into tauri.conf.json for this workflow run.');" env: TAURI_UPDATER_PUBKEY: ${{ secrets.TAURI_UPDATER_PUBKEY }} diff --git a/package.json b/package.json index 78ac84df..5318c6d2 100644 --- a/package.json +++ b/package.json @@ -46,14 +46,13 @@ "@tauri-apps/plugin-clipboard-manager": "^2.3.2", "@tauri-apps/plugin-deep-link": "^2.4.9", "@tauri-apps/plugin-dialog": "^2.7.1", + "@tauri-apps/plugin-notification": "^2.3.1", "@vueuse/core": "^14.3.0", - "class-variance-authority": "^0.7.1", - "clsx": "^2.1.1", + "cnfast": "^0.0.8", "eventemitter3": "^5.0.4", "motion-v": "^2.3.0", "qrcode": "^1.5.4", "reka-ui": "^2.10.1", - "tailwind-merge": "^3.6.0", "tw-animate-css": "^1.4.0", "vue-sonner": "^2.0.9" }, @@ -69,7 +68,6 @@ "chalk": "^5.6.2", "i18next": "^26.3.4", "lodash": "^4.18.1", - "normalize.css": "^8.0.1", "pinia": "^3.0.4", "tailwindcss": "^4.3.2", "terser": "^5.48.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index cdd195f1..c7d2a858 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -26,15 +26,15 @@ importers: '@tauri-apps/plugin-dialog': specifier: ^2.7.1 version: 2.7.1 + '@tauri-apps/plugin-notification': + specifier: ^2.3.1 + version: 2.3.3 '@vueuse/core': specifier: ^14.3.0 version: 14.3.0(vue@3.5.39(typescript@6.0.3)) - class-variance-authority: - specifier: ^0.7.1 - version: 0.7.1 - clsx: - specifier: ^2.1.1 - version: 2.1.1 + cnfast: + specifier: ^0.0.8 + version: 0.0.8 eventemitter3: specifier: ^5.0.4 version: 5.0.4 @@ -47,9 +47,6 @@ importers: reka-ui: specifier: ^2.10.1 version: 2.10.1(vue@3.5.39(typescript@6.0.3)) - tailwind-merge: - specifier: ^3.6.0 - version: 3.6.0 tw-animate-css: specifier: ^1.4.0 version: 1.4.0 @@ -90,9 +87,6 @@ importers: lodash: specifier: ^4.18.1 version: 4.18.1 - normalize.css: - specifier: ^8.0.1 - version: 8.0.1 pinia: specifier: ^3.0.4 version: 3.0.4(typescript@6.0.3)(vue@3.5.39(typescript@6.0.3)) @@ -627,6 +621,9 @@ packages: '@tauri-apps/plugin-dialog@2.7.1': resolution: {integrity: sha512-OK1UBXYt+ojcmxMktzzuyonYIFta8CmAASpX+CA+DTGK24KlHjhYI6x2iOJ/TjZF4N7/ACK1oFmEOjIY9IhzOQ==} + '@tauri-apps/plugin-notification@2.3.3': + resolution: {integrity: sha512-Zw+ZH18RJb41G4NrfHgIuofJiymusqN+q8fGUIIV7vyCH+5sSn5coqRv/MWB9qETsUs97vmU045q7OyseCV3Qg==} + '@tybys/wasm-util@0.10.3': resolution: {integrity: sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==} @@ -811,15 +808,12 @@ packages: resolution: {integrity: sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==} engines: {node: '>= 20.19.0'} - class-variance-authority@0.7.1: - resolution: {integrity: sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==} - cliui@6.0.0: resolution: {integrity: sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==} - clsx@2.1.1: - resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} - engines: {node: '>=6'} + cnfast@0.0.8: + resolution: {integrity: sha512-EjXKMfGfdwtV4AcNSQ6AwQaVzpC1B7IxeiwA3FlhTXz+YFlMKVi4c1JX9tgD2QOlahQXjB8KUXrBaYG+3v871Q==} + hasBin: true color-convert@2.0.1: resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} @@ -1229,9 +1223,6 @@ packages: resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==} engines: {node: '>=0.10.0'} - normalize.css@8.0.1: - resolution: {integrity: sha512-qizSNPO93t1YUuUhP22btGOo3chcvDFqFaj2TRybP0DMxkHOCTYwp3n34fel4a31ORXy4m1Xq0Gyqpb5m33qIg==} - ohash@2.0.11: resolution: {integrity: sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ==} @@ -1378,9 +1369,6 @@ packages: resolution: {integrity: sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==} engines: {node: '>=10'} - tailwind-merge@3.6.0: - resolution: {integrity: sha512-uxL7qAVQriqRQPAyK3pj66VqskWqoZ37PW94jwOTwNfq/z9oyu1V+eqrZqtR2+fCiXdYOZe/Modt8GtvqNzu+w==} - tailwindcss@4.3.2: resolution: {integrity: sha512-WtctNNSH8A9jlMIqxzuYumOHU5uGZyRv0Q5svQl+oEPy5w84YpBxdb7MdqyiSPQge5jTJ6zFQLq0PFygdccSBA==} @@ -1953,6 +1941,10 @@ snapshots: dependencies: '@tauri-apps/api': 2.11.1 + '@tauri-apps/plugin-notification@2.3.3': + dependencies: + '@tauri-apps/api': 2.11.1 + '@tybys/wasm-util@0.10.3': dependencies: tslib: 2.8.1 @@ -2194,17 +2186,13 @@ snapshots: dependencies: readdirp: 5.0.0 - class-variance-authority@0.7.1: - dependencies: - clsx: 2.1.1 - cliui@6.0.0: dependencies: string-width: 4.2.3 strip-ansi: 6.0.1 wrap-ansi: 6.2.0 - clsx@2.1.1: {} + cnfast@0.0.8: {} color-convert@2.0.1: dependencies: @@ -2531,8 +2519,6 @@ snapshots: normalize-path@3.0.0: {} - normalize.css@8.0.1: {} - ohash@2.0.11: {} p-limit@2.3.0: @@ -2682,8 +2668,6 @@ snapshots: dependencies: has-flag: 4.0.0 - tailwind-merge@3.6.0: {} - tailwindcss@4.3.2: {} tapable@2.3.3: {} diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index d1ab0308..8febf5e5 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -89,6 +89,24 @@ dependencies = [ "memchr", ] +[[package]] +name = "aligned" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee4508988c62edf04abd8d92897fca0c2995d907ce1dfeaf369dac3716a40685" +dependencies = [ + "as-slice", +] + +[[package]] +name = "aligned-vec" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc890384c8602f339876ded803c97ad529f3842aba97f6392b3dba0dd171769b" +dependencies = [ + "equator", +] + [[package]] name = "alloc-no-stdlib" version = "2.0.4" @@ -186,6 +204,12 @@ dependencies = [ "security-framework", ] +[[package]] +name = "arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1" + [[package]] name = "arboard" version = "3.6.1" @@ -216,6 +240,17 @@ dependencies = [ "rustversion", ] +[[package]] +name = "arg_enum_proc_macro" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ae92a5119aa49cdbcf6b9f893fe4e1d98b04ccbf82ee0584ad948a44a734dea" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + [[package]] name = "argon2" version = "0.6.0-rc.8" @@ -240,6 +275,15 @@ version = "0.7.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56" +[[package]] +name = "as-slice" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "516b6b4f0e40d50dcda9365d53964ec74560ad4284da2e7fc97122cd83174516" +dependencies = [ + "stable_deref_trait", +] + [[package]] name = "ashpd" version = "0.11.1" @@ -543,6 +587,49 @@ version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" +[[package]] +name = "av-scenechange" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f321d77c20e19b92c39e7471cf986812cbb46659d2af674adc4331ef3f18394" +dependencies = [ + "aligned", + "anyhow", + "arg_enum_proc_macro", + "arrayvec", + "log", + "num-rational", + "num-traits", + "pastey", + "rayon", + "thiserror 2.0.18", + "v_frame", + "y4m", +] + +[[package]] +name = "av1-grain" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8cfddb07216410377231960af4fcab838eaa12e013417781b78bd95ee22077f8" +dependencies = [ + "anyhow", + "arrayvec", + "log", + "nom 8.0.0", + "num-rational", + "v_frame", +] + +[[package]] +name = "avif-serialize" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7178fe5f7d460b13895ebb9dcb28a3a6216d2df2574a0806cb51b555d297f38" +dependencies = [ + "arrayvec", +] + [[package]] name = "axum" version = "0.8.9" @@ -693,6 +780,12 @@ dependencies = [ "serde", ] +[[package]] +name = "bit_field" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e4b40c7323adcfc0a41c4b88143ed58346ff65a288fc144329c5c45e05d70c6" + [[package]] name = "bitflags" version = "1.3.2" @@ -708,6 +801,15 @@ dependencies = [ "serde_core", ] +[[package]] +name = "bitstream-io" +version = "4.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7eff00be299a18769011411c9def0d827e8f2d7bf0c3dbf53633147a8867fd1f" +dependencies = [ + "no_std_io2", +] + [[package]] name = "blake2" version = "0.11.0-rc.6" @@ -840,6 +942,12 @@ dependencies = [ "serde_core", ] +[[package]] +name = "built" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c0e531d93d39c34eef561e929e8a7f86d77a5af08aac4f6d6e39976c51858e9" + [[package]] name = "bumpalo" version = "3.20.3" @@ -965,6 +1073,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e228eec9be7c17ccb640b59b36a5cd805ea2a564a4c5e162c2f659fea30d3b96" dependencies = [ "find-msvc-tools", + "jobserver", + "libc", "shlex", ] @@ -1096,6 +1206,21 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" +[[package]] +name = "clipboard-rs" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "afede46921767868c5c7f8f55202bdd8bec0bab6bc9605174200f45924f93c62" +dependencies = [ + "clipboard-win", + "image", + "objc2", + "objc2-app-kit", + "objc2-foundation", + "windows 0.59.0", + "x11rb", +] + [[package]] name = "clipboard-win" version = "5.4.1" @@ -1103,6 +1228,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bde03770d3df201d4fb868f2c9c59e66a3e4e2bd06692a0fe701e7103c7e84d4" dependencies = [ "error-code", + "windows-win", ] [[package]] @@ -1120,6 +1246,12 @@ dependencies = [ "thiserror 2.0.18", ] +[[package]] +name = "color_quant" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b" + [[package]] name = "colorchoice" version = "1.0.5" @@ -1350,6 +1482,16 @@ dependencies = [ "crossbeam-utils", ] +[[package]] +name = "crossbeam-deque" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", +] + [[package]] name = "crossbeam-epoch" version = "0.9.18" @@ -2198,6 +2340,26 @@ dependencies = [ "log", ] +[[package]] +name = "equator" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4711b213838dfee0117e3be6ac926007d7f433d7bbe33595975d4190cb07e6fc" +dependencies = [ + "equator-macro", +] + +[[package]] +name = "equator-macro" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44f23cf4b44bfce11a86ace86f8a73ffdec849c9fd00a386a53d278bd9e81fb3" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + [[package]] name = "equivalent" version = "1.0.2" @@ -2252,6 +2414,21 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "exr" +version = "1.74.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4300e043a56aa2cb633c01af81ca8f699a321879a7854d3896a0ba89056363be" +dependencies = [ + "bit_field", + "half", + "lebe", + "miniz_oxide", + "rayon-core", + "smallvec", + "zune-inflate", +] + [[package]] name = "eyre" version = "0.6.12" @@ -2794,6 +2971,16 @@ dependencies = [ "polyval 0.7.1", ] +[[package]] +name = "gif" +version = "0.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee8cfcc411d9adbbaba82fb72661cc1bcca13e8bba98b364e62b2dba8f960159" +dependencies = [ + "color_quant", + "weezl", +] + [[package]] name = "gio" version = "0.18.4" @@ -3540,12 +3727,38 @@ checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104" dependencies = [ "bytemuck", "byteorder-lite", + "color_quant", + "exr", + "gif", + "image-webp", "moxcms", "num-traits", "png 0.18.1", + "qoi", + "ravif", + "rayon", + "rgb", "tiff", + "zune-core", + "zune-jpeg", +] + +[[package]] +name = "image-webp" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "525e9ff3e1a4be2fbea1fdf0e98686a6d98b4d8f937e1bf7402245af1909e8c3" +dependencies = [ + "byteorder-lite", + "quick-error", ] +[[package]] +name = "imgref" +version = "1.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89194689a993ab15268672e99e7b0e19da2da3268ac682e8f02d29d4d1434cd7" + [[package]] name = "indenter" version = "0.3.4" @@ -3625,6 +3838,17 @@ dependencies = [ "rand_core 0.10.1", ] +[[package]] +name = "interpolate_name" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c34819042dc3d3971c46c2190835914dfbe0c3c13f61449b2997f4e9722dfa60" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + [[package]] name = "ipconfig" version = "0.3.4" @@ -3954,6 +4178,15 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" +[[package]] +name = "itertools" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" +dependencies = [ + "either", +] + [[package]] name = "itoa" version = "1.0.18" @@ -4082,6 +4315,16 @@ dependencies = [ "syn 2.0.118", ] +[[package]] +name = "jobserver" +version = "0.1.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" +dependencies = [ + "getrandom 0.3.4", + "libc", +] + [[package]] name = "js-sys" version = "0.3.103" @@ -4184,6 +4427,12 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" +[[package]] +name = "lebe" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a79a3332a6609480d7d0c9eab957bca6b455b91bb84e66d19f5ff66294b85b8" + [[package]] name = "libappindicator" version = "0.9.0" @@ -4223,6 +4472,16 @@ dependencies = [ "pkg-config", ] +[[package]] +name = "libfuzzer-sys" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9fd2f41a1cba099f79a0b6b6c35656cf7c03351a7bae8ff0f28f25270f929d2" +dependencies = [ + "arbitrary", + "cc", +] + [[package]] name = "libloading" version = "0.7.4" @@ -4325,6 +4584,15 @@ dependencies = [ "tracing-subscriber", ] +[[package]] +name = "loop9" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fae87c125b03c1d2c0150c90365d7d6bcc53fb73a9acaef207d2d065860f062" +dependencies = [ + "imgref", +] + [[package]] name = "lru" version = "0.18.0" @@ -4356,6 +4624,20 @@ version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d3d25b0e0b648a86960ac23b7ad4abb9717601dec6f66c165f5b037f3f03065f" +[[package]] +name = "mac-notification-sys" +version = "0.6.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd604973958ddcc11b561193c0fb96ba146506ef2f231ef2e7c35fd2cbc9beca" +dependencies = [ + "cc", + "log", + "objc2", + "objc2-foundation", + "time", + "uuid", +] + [[package]] name = "markup5ever" version = "0.38.0" @@ -4382,6 +4664,16 @@ version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" +[[package]] +name = "maybe-rayon" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ea1f30cedd69f0a2954655f7188c6a834246d2bcf1e315e2ac40c4b24dc9519" +dependencies = [ + "cfg-if", + "rayon", +] + [[package]] name = "md-5" version = "0.11.0" @@ -4583,9 +4875,9 @@ dependencies = [ [[package]] name = "napi" -version = "3.10.1" +version = "3.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c0c00e5dfe26391abeed44a9c41ae583c3045694d6ae865e2019826869dd5aa0" +checksum = "8ef2a2b7d3216aa19b4ef0e3071ac5ea55a887dc553d71648baf4f2cca705a15" dependencies = [ "bitflags 2.13.0", "ctor 1.0.7", @@ -4825,6 +5117,15 @@ dependencies = [ "libc", ] +[[package]] +name = "no_std_io2" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "418abd1b6d34fbf6cae440dc874771b0525a604428704c76e48b29a5e67b8003" +dependencies = [ + "memchr", +] + [[package]] name = "nohash-hasher" version = "0.2.0" @@ -4850,6 +5151,12 @@ dependencies = [ "memchr", ] +[[package]] +name = "noop_proc_macro" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0676bb32a98c1a483ce53e500a81ad9c3d5b3f7c920c28c24e9cb0980d0b5bc8" + [[package]] name = "noq" version = "1.0.1" @@ -4914,6 +5221,20 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "notify-rust" +version = "4.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c5b4c1b4f2aa9f25f63a7a49d3dd0ed567b3670da15330a66b29434be899b891" +dependencies = [ + "futures-lite", + "log", + "mac-notification-sys", + "serde", + "tauri-winrt-notification", + "zbus", +] + [[package]] name = "nu-ansi-term" version = "0.50.3" @@ -4962,6 +5283,17 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" +[[package]] +name = "num-derive" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + [[package]] name = "num-integer" version = "0.1.46" @@ -5051,10 +5383,17 @@ checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c" dependencies = [ "bitflags 2.13.0", "block2", + "libc", "objc2", + "objc2-cloud-kit", + "objc2-core-data", "objc2-core-foundation", "objc2-core-graphics", + "objc2-core-image", + "objc2-core-text", + "objc2-core-video", "objc2-foundation", + "objc2-quartz-core", ] [[package]] @@ -5074,6 +5413,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b402a653efbb5e82ce4df10683b6b28027616a2715e90009947d50b8dd298fa" dependencies = [ + "bitflags 2.13.0", "objc2", "objc2-foundation", ] @@ -5136,6 +5476,19 @@ dependencies = [ "objc2-core-graphics", ] +[[package]] +name = "objc2-core-video" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d425caf1df73233f29fd8a5c3e5edbc30d2d4307870f802d18f00d83dc5141a6" +dependencies = [ + "bitflags 2.13.0", + "objc2", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-io-surface", +] + [[package]] name = "objc2-core-wlan" version = "0.3.2" @@ -5505,6 +5858,12 @@ version = "1.0.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" +[[package]] +name = "pastey" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35fb2e5f958ec131621fdd531e9fc186ed768cbe395337403ae56c17a74c68ec" + [[package]] name = "pbkdf2" version = "0.13.0" @@ -6029,6 +6388,25 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "profiling" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d595e54a326bc53c1c197b32d295e14b169e3cfeaa8dc82b529f947fba6bcf5" +dependencies = [ + "profiling-procmacros", +] + +[[package]] +name = "profiling-procmacros" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4488a4a36b9a4ba6b9334a32a39971f77c1436ec82c38707bce707699cc3bbcb" +dependencies = [ + "quote", + "syn 2.0.118", +] + [[package]] name = "psl-types" version = "2.0.11" @@ -6051,6 +6429,15 @@ version = "0.1.29" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e0c5ccf5294c6ccd63a74f1565028353830a9c2f5eb0c682c355c471726a6e3f" +[[package]] +name = "qoi" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f6d64c71eb498fe9eae14ce4ec935c555749aef511cca85b5568910d6e48001" +dependencies = [ + "bytemuck", +] + [[package]] name = "quick-error" version = "2.0.1" @@ -6175,6 +6562,56 @@ dependencies = [ "smallvec", ] +[[package]] +name = "rav1e" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43b6dd56e85d9483277cde964fd1bdb0428de4fec5ebba7540995639a21cb32b" +dependencies = [ + "aligned-vec", + "arbitrary", + "arg_enum_proc_macro", + "arrayvec", + "av-scenechange", + "av1-grain", + "bitstream-io", + "built", + "cfg-if", + "interpolate_name", + "itertools", + "libc", + "libfuzzer-sys", + "log", + "maybe-rayon", + "new_debug_unreachable", + "noop_proc_macro", + "num-derive", + "num-traits", + "paste", + "profiling", + "rand 0.9.4", + "rand_chacha", + "simd_helpers", + "thiserror 2.0.18", + "v_frame", + "wasm-bindgen", +] + +[[package]] +name = "ravif" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e52310197d971b0f5be7fe6b57530dcd27beb35c1b013f29d66c1ad73fbbcc45" +dependencies = [ + "avif-serialize", + "imgref", + "loop9", + "quick-error", + "rav1e", + "rayon", + "rgb", +] + [[package]] name = "raw-window-handle" version = "0.6.2" @@ -6182,12 +6619,23 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539" [[package]] -name = "rc4" -version = "0.2.0" +name = "rayon" +version = "1.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "840038b674daa9f7a7957440d937951d15c0143c056e631e529141fd780e0c92" +checksum = "fb39b166781f92d482534ef4b4b1b2568f42613b53e5b6c160e24cfbfa30926d" dependencies = [ - "cipher 0.5.2", + "either", + "rayon-core", +] + +[[package]] +name = "rayon-core" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91" +dependencies = [ + "crossbeam-deque", + "crossbeam-utils", ] [[package]] @@ -6384,6 +6832,12 @@ dependencies = [ "windows-sys 0.60.2", ] +[[package]] +name = "rgb" +version = "0.8.53" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b34b781b31e5d73e9fbc8689c70551fd1ade9a19e3e28cfec8580a79290cc4" + [[package]] name = "ring" version = "0.17.14" @@ -6411,7 +6865,9 @@ dependencies = [ "jni 0.22.4", "keyring-core", "ndk-context", + "objc2-app-kit", "open", + "percent-encoding", "risuko-bt", "risuko-cli", "risuko-cookies", @@ -6426,9 +6882,11 @@ dependencies = [ "tauri-build", "tauri-plugin-autostart", "tauri-plugin-barcode-scanner", + "tauri-plugin-clipboard", "tauri-plugin-clipboard-manager", "tauri-plugin-deep-link", "tauri-plugin-dialog", + "tauri-plugin-notification", "tauri-plugin-shell", "tauri-plugin-single-instance", "tauri-plugin-store", @@ -6438,7 +6896,6 @@ dependencies = [ "tracing-appender", "tracing-subscriber", "trash", - "urlencoding", "windows-native-keyring-store", "windows-sys 0.61.2", ] @@ -6459,7 +6916,6 @@ dependencies = [ "percent-encoding", "quick-xml 0.40.1", "rand 0.10.2", - "rc4", "risuko-http", "serde", "sha1 0.11.0", @@ -6546,7 +7002,6 @@ dependencies = [ "rand 0.10.2", "regex", "risuko-bt", - "risuko-cookies", "risuko-http", "russh", "russh-sftp", @@ -7469,6 +7924,15 @@ dependencies = [ "simdutf8", ] +[[package]] +name = "simd_helpers" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95890f873bec569a0362c235787f3aca6e1e887302ba4840839bcc6459c42da6" +dependencies = [ + "quote", +] + [[package]] name = "simdutf8" version = "0.1.5" @@ -8081,6 +8545,21 @@ dependencies = [ "thiserror 2.0.18", ] +[[package]] +name = "tauri-plugin-clipboard" +version = "2.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f811a26526cad7291414c526be85b2d1317681c60949bbe8c10902898b7c48d7" +dependencies = [ + "base64 0.22.1", + "clipboard-rs", + "image", + "serde", + "tauri", + "tauri-plugin", + "thiserror 1.0.69", +] + [[package]] name = "tauri-plugin-clipboard-manager" version = "2.3.2" @@ -8159,6 +8638,25 @@ dependencies = [ "url", ] +[[package]] +name = "tauri-plugin-notification" +version = "2.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01fc2c5ff41105bd1f7242d8201fdf3efd70749b82fa013a17f2126357d194cc" +dependencies = [ + "log", + "notify-rust", + "rand 0.9.4", + "serde", + "serde_json", + "serde_repr", + "tauri", + "tauri-plugin", + "thiserror 2.0.18", + "time", + "url", +] + [[package]] name = "tauri-plugin-shell" version = "2.3.5" @@ -8311,6 +8809,17 @@ dependencies = [ "toml 1.1.2+spec-1.1.0", ] +[[package]] +name = "tauri-winrt-notification" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed071c670382e85fc2f48ae706492d8c338f4f89bf72520d32f8abfe880aade" +dependencies = [ + "thiserror 2.0.18", + "windows 0.61.3", + "windows-version", +] + [[package]] name = "tempfile" version = "3.27.0" @@ -9074,6 +9583,17 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "v_frame" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "666b7727c8875d6ab5db9533418d7c764233ac9c0cff1d469aec8fa127597be2" +dependencies = [ + "aligned-vec", + "num-traits", + "wasm-bindgen", +] + [[package]] name = "valuable" version = "0.1.1" @@ -9528,6 +10048,16 @@ dependencies = [ "windows-targets 0.52.6", ] +[[package]] +name = "windows" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f919aee0a93304be7f62e8e5027811bbba96bcb1de84d6618be56e43f8a32a1" +dependencies = [ + "windows-core 0.59.0", + "windows-targets 0.53.5", +] + [[package]] name = "windows" version = "0.61.3" @@ -9583,6 +10113,19 @@ dependencies = [ "windows-targets 0.52.6", ] +[[package]] +name = "windows-core" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "810ce18ed2112484b0d4e15d022e5f598113e220c53e373fb31e67e21670c1ce" +dependencies = [ + "windows-implement 0.59.0", + "windows-interface 0.59.3", + "windows-result 0.3.4", + "windows-strings 0.3.1", + "windows-targets 0.53.5", +] + [[package]] name = "windows-core" version = "0.61.2" @@ -9642,6 +10185,17 @@ dependencies = [ "syn 2.0.118", ] +[[package]] +name = "windows-implement" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83577b051e2f49a058c308f17f273b570a6a758386fc291b5f6a934dd84e48c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + [[package]] name = "windows-implement" version = "0.60.2" @@ -9769,6 +10323,15 @@ dependencies = [ "windows-link 0.2.1", ] +[[package]] +name = "windows-strings" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87fa48cc5d406560701792be122a10132491cff9d0aeb23583cc2dcafc847319" +dependencies = [ + "windows-link 0.1.3", +] + [[package]] name = "windows-strings" version = "0.4.2" @@ -9907,6 +10470,15 @@ dependencies = [ "windows-link 0.2.1", ] +[[package]] +name = "windows-win" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "58e23e33622b3b52f948049acbec9bcc34bf6e26d74176b88941f213c75cf2dc" +dependencies = [ + "error-code", +] + [[package]] name = "windows_aarch64_gnullvm" version = "0.42.2" @@ -10267,6 +10839,12 @@ dependencies = [ "xml-rs", ] +[[package]] +name = "y4m" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a5a4b21e1a62b67a2970e6831bc091d7b87e119e7f9791aef9702e3bef04448" + [[package]] name = "yasna" version = "0.6.0" @@ -10468,6 +11046,15 @@ version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cb8a0807f7c01457d0379ba880ba6322660448ddebc890ce29bb64da71fb40f9" +[[package]] +name = "zune-inflate" +version = "0.2.54" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73ab332fe2f6680068f3582b16a24f90ad7096d5d39b974d1c0aff0125116f02" +dependencies = [ + "simd-adler32", +] + [[package]] name = "zune-jpeg" version = "0.5.15" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 965dcbc9..faf0c031 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -32,6 +32,9 @@ edition.workspace = true name = "risuko_lib" crate-type = ["lib", "cdylib", "staticlib"] +[lints.rust] +linker_messages = "allow" + [build-dependencies] tauri-build = { version = "2", features = [] } @@ -75,8 +78,8 @@ tracing-appender = "0.2" risuko-http = { workspace = true } risuko-cookies = { workspace = true } base64 = "0.22" +percent-encoding = "2" sha1 = "0.11" -urlencoding = "2" keyring-core = "1" fs4 = "1" tempfile = "3" @@ -84,6 +87,9 @@ tempfile = "3" [target.'cfg(any(target_os = "macos", target_os = "ios"))'.dependencies] apple-native-keyring-store = { version = "1", features = ["keychain"] } +[target.'cfg(target_os = "macos")'.dependencies] +objc2-app-kit = { version = "0.3", features = ["NSWorkspace", "NSRunningApplication", "libc"] } + [target.'cfg(target_os = "windows")'.dependencies] windows-native-keyring-store = "1" windows-sys = { version = "0.61", features = [ @@ -100,6 +106,8 @@ dbus-secret-service-keyring-store = { version = "1", features = ["crypto-rust"] trash = "5" tauri-plugin-autostart = "2" tauri-plugin-single-instance = "2" +tauri-plugin-clipboard = "2.1.11" +tauri-plugin-notification = "2" [target.'cfg(target_os = "android")'.dependencies] jni = "0.22" diff --git a/src-tauri/capabilities/clip-prompt.json b/src-tauri/capabilities/clip-prompt.json new file mode 100644 index 00000000..5fb36a0d --- /dev/null +++ b/src-tauri/capabilities/clip-prompt.json @@ -0,0 +1,12 @@ +{ + "identifier": "clip-prompt", + "description": "Minimal capabilities for the clipboard-watcher prompt window.", + "windows": ["clip-prompt"], + "permissions": [ + "core:default", + "core:window:allow-show", + "core:window:allow-hide", + "core:window:allow-set-focus", + "core:window:allow-start-dragging" + ] +} diff --git a/src-tauri/capabilities/desktop.json b/src-tauri/capabilities/desktop.json index e0531c12..bad8caa9 100644 --- a/src-tauri/capabilities/desktop.json +++ b/src-tauri/capabilities/desktop.json @@ -3,5 +3,5 @@ "description": "Desktop-only capabilities for Risuko", "windows": ["main"], "platforms": ["macOS", "windows", "linux"], - "permissions": ["autostart:default"] + "permissions": ["autostart:default", "notification:default"] } diff --git a/src-tauri/risuko-bt/Cargo.toml b/src-tauri/risuko-bt/Cargo.toml index efcdc709..ff65c75f 100644 --- a/src-tauri/risuko-bt/Cargo.toml +++ b/src-tauri/risuko-bt/Cargo.toml @@ -30,7 +30,6 @@ httparse = "1" network-interface = "2" serde = { version = "1", features = ["derive"] } num-bigint = "0.4" -rc4 = "0.2" [dev-dependencies] tempfile = "3" diff --git a/src-tauri/risuko-bt/src/peer/connection.rs b/src-tauri/risuko-bt/src/peer/connection.rs index c4eb2170..6e2e4868 100644 --- a/src-tauri/risuko-bt/src/peer/connection.rs +++ b/src-tauri/risuko-bt/src/peer/connection.rs @@ -15,39 +15,28 @@ use tokio::time::timeout; use super::super::core::Id20; use super::super::wire::mse::{self, DhKeys, MseRc4, DH_LEN}; use super::super::wire::{Handshake, Message, MessageDecoder, MessageEncoder, HANDSHAKE_LEN}; -use rc4::StreamCipher; /// Encryption behaviour for a single peer connection #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] pub enum EncryptionPolicy { - /// Only plaintext BEP-3 handshakes. Reject MSE PlaintextOnly, - /// Try plaintext first; if the remote rejects it, fall back to MSE - /// For inbound: accept either (auto-detect by first byte) #[default] Prefer, - /// Only accept/initiate encrypted connections RequireEncryption, } -/// Events emitted by the peer task toward the torrent #[derive(Debug)] pub enum PeerEvent { - /// Handshake finished successfully Handshook { peer_id: Id20, reserved: [u8; 8], - /// Info hash negotiated during the handshake. For outbound - /// connections this matches the requested hash; for inbound ones it - /// identifies which torrent the peer wants info_hash: Id20, - /// `true` if the wire is encrypted under MSE/PE encrypted: bool, }, - /// Decoded peer message Message(Message), - /// Connection ended (clean EOF or error) - Disconnected { reason: String }, + Disconnected { + reason: String, + }, } /// Commands sent from the torrent to the peer writer task diff --git a/src-tauri/risuko-bt/src/wire.rs b/src-tauri/risuko-bt/src/wire.rs index c8edf4aa..62543c14 100644 --- a/src-tauri/risuko-bt/src/wire.rs +++ b/src-tauri/risuko-bt/src/wire.rs @@ -8,6 +8,7 @@ pub mod extended; pub mod handshake; pub mod message; pub mod mse; +pub mod rc4; pub use handshake::{Handshake, HANDSHAKE_LEN, PROTOCOL}; pub use message::{Message, MessageDecoder, MessageEncoder, MAX_MESSAGE_BYTES}; diff --git a/src-tauri/risuko-bt/src/wire/mse.rs b/src-tauri/risuko-bt/src/wire/mse.rs index ccb88911..5ba8b868 100644 --- a/src-tauri/risuko-bt/src/wire/mse.rs +++ b/src-tauri/risuko-bt/src/wire/mse.rs @@ -1,51 +1,23 @@ //! Message Stream Encryption (MSE) / Protocol Encryption (PE) -//! -//! BitTorrent MSE/PE handshake (BEP 8, Vuze spec). Sits under the BEP-3 -//! peer handshake and encrypts the whole TCP stream with RC4 after a -//! Diffie-Hellman exchange -//! -//! The handshake proceeds, in abbreviated form: -//! -//! ```text -//! A -> B: Ya || PadA -//! B -> A: Yb || PadB -//! A -> B: HASH('req1', S) || HASH('req2', SKEY) XOR HASH('req3', S) -//! || ENCRYPT(VC || crypto_provide || len(PadC) || PadC || len(IA) || IA) -//! B -> A: ENCRYPT(VC || crypto_select || len(PadD) || PadD) -//! ``` -//! -//! Where `S` is the DH shared secret, `SKEY` is the torrent info-hash, `VC` -//! is the 8-byte verification constant (all zeros), and the RC4 stream keys -//! are derived from `HASH('keyA', S, SKEY)` for A->B and -//! `HASH('keyB', S, SKEY)` for B->A. Both RC4 instances discard the first -//! 1024 bytes of keystream per spec -//! -//! Only crypto primitives and byte-level handshake encode/decode here; the -//! async wire upgrade lives in `peer/connection.rs` use std::io; use num_bigint::BigUint; use rand::{Rng, RngExt}; -use rc4::{KeyInit, Rc4, StreamCipher}; use sha1::{Digest, Sha1}; -/// BEP-8 MSE prime P (768-bit Diffie-Hellman modulus, hex-encoded in spec) +use super::rc4::Rc4; + const P_HEX: &str = concat!( "FFFFFFFFFFFFFFFFC90FDAA22168C234C4C6628B80DC1CD1", "29024E088A67CC74020BBEA63B139B22514A08798E3404DD", "EF9519B3CD3A431B302B0A6DF25F14374FE1356D6D51C245", "E485B576625E7EC6F44C42E9A63A3620FFFFFFFFFFFFFFFF" ); -/// BEP-8 generator const G: u64 = 2; -/// Length in bytes of Y_a / Y_b / S (96 bytes == 768 bits) pub const DH_LEN: usize = 96; -/// Verification constant (8 zero bytes) pub const VC: [u8; 8] = [0u8; 8]; -/// Crypto selection bitfield. Peers advertise which they support; selection -/// picks exactly one pub mod crypto { pub const PLAINTEXT: u32 = 0x01; pub const RC4: u32 = 0x02; @@ -152,14 +124,10 @@ pub fn rc4_key(tag: &[u8; 4], s: &[u8; DH_LEN], skey: &[u8; 20]) -> [u8; 20] { sha1_many(&[tag, s, skey]) } -/// RC4 instance used by MSE. rc4 0.2 accepts variable-length keys via -/// `new_from_slice`, which we use with the 20-byte SHA1 output from BEP 8 pub type MseRc4 = Rc4; -/// Initialise an RC4 stream with the given 20-byte key, and discard the -/// first 1024 bytes of keystream per spec pub fn init_rc4(key: &[u8; 20]) -> MseRc4 { - let mut c = MseRc4::new_from_slice(key).expect("20-byte key is valid for RC4"); + let mut c = Rc4::new(key); let mut sink = [0u8; 1024]; c.apply_keystream(&mut sink); c diff --git a/src-tauri/risuko-bt/src/wire/rc4.rs b/src-tauri/risuko-bt/src/wire/rc4.rs new file mode 100644 index 00000000..0b50a27e --- /dev/null +++ b/src-tauri/risuko-bt/src/wire/rc4.rs @@ -0,0 +1,71 @@ +//! RC4 stream cipher for MSE/PE +pub struct Rc4 { + s: [u8; 256], + i: u8, + j: u8, +} + +impl Rc4 { + pub fn new(key: &[u8]) -> Self { + let mut s = [0u8; 256]; + for (idx, b) in s.iter_mut().enumerate() { + *b = idx as u8; + } + if !key.is_empty() { + let mut j = 0u8; + for i in 0..256 { + j = j.wrapping_add(s[i]).wrapping_add(key[i % key.len()]); + s.swap(i, j as usize); + } + } + Self { s, i: 0, j: 0 } + } + + pub fn apply_keystream(&mut self, data: &mut [u8]) { + for byte in data.iter_mut() { + self.i = self.i.wrapping_add(1); + self.j = self.j.wrapping_add(self.s[self.i as usize]); + self.s.swap(self.i as usize, self.j as usize); + let k = self.s[self.s[self.i as usize].wrapping_add(self.s[self.j as usize]) as usize]; + *byte ^= k; + } + } +} + +#[cfg(test)] +mod tests { + use super::Rc4; + + #[test] + fn known_answer_vector() { + let mut c = Rc4::new(b"Key"); + let mut buf = *b"Plaintext"; + c.apply_keystream(&mut buf); + assert_eq!(buf, [0xBB, 0xF3, 0x16, 0xE8, 0xD9, 0x40, 0xAF, 0x0A, 0xD3]); + } + + #[test] + fn keystream_is_contiguous_across_calls() { + let mut whole = Rc4::new(b"secret"); + let mut a = [1u8; 8]; + whole.apply_keystream(&mut a); + + let mut split = Rc4::new(b"secret"); + let mut b = [1u8; 8]; + split.apply_keystream(&mut b[..3]); + split.apply_keystream(&mut b[3..]); + assert_eq!(a, b); + } + + #[test] + fn roundtrips() { + let key = b"another-key"; + let mut enc = Rc4::new(key); + let mut dec = Rc4::new(key); + let mut buf = *b"the quick brown fox"; + enc.apply_keystream(&mut buf); + assert_ne!(&buf, b"the quick brown fox"); + dec.apply_keystream(&mut buf); + assert_eq!(&buf, b"the quick brown fox"); + } +} diff --git a/src-tauri/risuko-engine/Cargo.toml b/src-tauri/risuko-engine/Cargo.toml index 7c639081..d2d99c5f 100644 --- a/src-tauri/risuko-engine/Cargo.toml +++ b/src-tauri/risuko-engine/Cargo.toml @@ -9,6 +9,11 @@ license = "MIT" [lib] name = "risuko_engine" +# md-5's lib name is `md5`, so cargo-machete's name scan false-flags it as +# unused; it IS used in engine/hasher.rs for checksum verification +[package.metadata.cargo-machete] +ignored = ["md-5"] + [dependencies] serde = { version = "1", features = ["derive"] } serde_json = "1" @@ -33,7 +38,6 @@ futures-util = "0.3" rand = "0.10" tower-http = { version = "0.7", features = ["cors"] } risuko-http = { workspace = true } -risuko-cookies = { workspace = true } tokio-util = { version = "0.7", features = ["io"] } async-trait = "0.1" bytes = "1" diff --git a/src-tauri/risuko-engine/src/config/defaults.rs b/src-tauri/risuko-engine/src/config/defaults.rs index 90a48c94..98885c83 100644 --- a/src-tauri/risuko-engine/src/config/defaults.rs +++ b/src-tauri/risuko-engine/src/config/defaults.rs @@ -165,6 +165,22 @@ pub fn user_defaults() -> Map { m.insert("run-mode".into(), json!(1)); m.insert("show-progress-bar".into(), json!(true)); m.insert("task-notification".into(), json!(true)); + m.insert("legal-accepted".into(), json!(false)); + #[cfg(not(target_os = "android"))] + { + m.insert("clipboard-watch".into(), json!(true)); + m.insert("clipboard-watch-notice-seen".into(), json!(false)); + m.insert( + "clipboard-watch-extensions".into(), + json!([ + "zip", "7z", "rar", "tar", "gz", "tgz", "bz2", "xz", "zst", "iso", "img", "dmg", + "pkg", "exe", "msi", "apk", "apks", "xapk", "deb", "rpm", "appimage", "bin", "mp4", + "mkv", "avi", "mov", "webm", "flv", "wmv", "m4v", "mp3", "flac", "wav", "m4a", + "aac", "ogg", "opus", "pdf", "epub", "mobi", "azw3", "docx", "xlsx", "pptx", "odt", + "jar", "torrent", "meta4", "metalink" + ]), + ); + } m.insert("theme".into(), json!("auto")); m.insert( "tracker-source".into(), diff --git a/src-tauri/risuko-engine/src/engine/ed2k/download.rs b/src-tauri/risuko-engine/src/engine/ed2k/download.rs index 1d2b453a..ac7721ff 100644 --- a/src-tauri/risuko-engine/src/engine/ed2k/download.rs +++ b/src-tauri/risuko-engine/src/engine/ed2k/download.rs @@ -30,10 +30,9 @@ pub async fn run_ed2k_download( cancel_token: CancellationToken, ) -> Result { let file_hash = file_link.file_hash_bytes; - let file_path = PathBuf::from(dir).join(&file_link.file_name); + let safe = crate::engine::util::safe_filename(&file_link.file_name, "ed2k-download"); + let file_path = PathBuf::from(dir).join(safe); - // Part request/answer opcodes use 32-bit offsets - // Reject files over 4 GiB until the eMule 64-bit extension is implemented if file_link.file_size > u32::MAX as u64 { return Err(format!( "ed2k file too large ({} bytes): files over 4 GiB require the 64-bit \ @@ -47,11 +46,9 @@ pub async fn run_ed2k_download( let chunks = ChunkManager::new(file_path.clone(), file_link.file_size); chunks.init_file().await?; - // Shared mutable state for the download let chunks = Arc::new(Mutex::new(chunks)); let peer_count = Arc::new(AtomicU32::new(0)); - // Generate a random client hash for this session let client_hash: [u8; 16] = rand::random(); let servers = server_list(&ed2k_servers); diff --git a/src-tauri/risuko-engine/src/engine/manager.rs b/src-tauri/risuko-engine/src/engine/manager.rs index 689008bc..4dc99b10 100644 --- a/src-tauri/risuko-engine/src/engine/manager.rs +++ b/src-tauri/risuko-engine/src/engine/manager.rs @@ -3809,16 +3809,12 @@ mod tests { #[tokio::test] async fn metalink_all_cancelled_does_not_complete() { - // every in-flight file cancelled (pause/stop) must not be reported as done let mut task = DownloadTask::new_metalink( "gm".into(), "/dl".into(), None, Map::new(), - vec![ - mk_metalink_file("1", 100, 0), - mk_metalink_file("2", 100, 0), - ], + vec![mk_metalink_file("1", 100, 0), mk_metalink_file("2", 100, 0)], ); task.status = TaskStatus::Active; let mgr = make_test_manager(vec![task]); diff --git a/src-tauri/src/commands/clipboard_cmds.rs b/src-tauri/src/commands/clipboard_cmds.rs new file mode 100644 index 00000000..2b880c13 --- /dev/null +++ b/src-tauri/src/commands/clipboard_cmds.rs @@ -0,0 +1,227 @@ +//! Clipboard Watcher +use tauri::{AppHandle, State}; + +use crate::state::AppState; + +pub fn is_download_candidate(uri: &str, exts: &[String]) -> bool { + use risuko_engine::engine; + let uri = uri.trim(); + if uri.is_empty() { + return false; + } + let lower = uri.to_ascii_lowercase(); + if engine::torrent::is_magnet_uri(uri) + || engine::ed2k::is_ed2k_uri(uri) + || engine::ftp::is_ftp_uri(uri) + || engine::adc::is_adc_uri(uri) + || engine::gnutella::is_gnutella_uri(uri) + || engine::g2::is_g2_uri(uri) + || engine::gift::is_gift_uri(uri) + || engine::m3u8::is_m3u8_uri(uri) + || lower.starts_with("thunder://") + { + return true; + } + if lower.starts_with("http://") || lower.starts_with("https://") { + if engine::media::is_media_uri(uri) { + return false; + } + return path_has_allowed_ext(uri, exts); + } + false +} + +fn path_has_allowed_ext(uri: &str, exts: &[String]) -> bool { + let after_scheme = uri.split_once("://").map(|(_, rest)| rest).unwrap_or(uri); + let path = after_scheme.split(['?', '#']).next().unwrap_or(""); + let last = path.rsplit('/').next().unwrap_or(""); + let Some(dot) = last.rfind('.') else { + return false; + }; + let ext = last[dot + 1..].to_ascii_lowercase(); + if ext.is_empty() { + return false; + } + exts.iter() + .any(|e| e.trim_start_matches('.').eq_ignore_ascii_case(&ext)) +} + +pub fn watch_enabled(state: &AppState) -> bool { + let Ok(cfg) = state.config.lock() else { + return false; + }; + cfg.get_merged_config() + .get("clipboard-watch") + .and_then(|v| v.as_bool()) + .unwrap_or(true) +} + +fn watch_exts(state: &AppState) -> Vec { + let Ok(cfg) = state.config.lock() else { + return Vec::new(); + }; + cfg.get_merged_config() + .get("clipboard-watch-extensions") + .and_then(|v| v.as_array()) + .map(|arr| { + arr.iter() + .filter_map(|x| x.as_str().map(str::to_string)) + .collect() + }) + .unwrap_or_default() +} + +#[cfg(not(target_os = "android"))] +pub fn on_clipboard_update(app: &AppHandle) { + use tauri::Manager; + use tauri_plugin_clipboard::Clipboard; + + let state = app.state::(); + if !watch_enabled(&state) { + return; + } + let clipboard = app.state::(); + if !clipboard.has_text().unwrap_or(false) { + return; + } + let Ok(text) = clipboard.read_text() else { + return; + }; + let text = text.trim().to_string(); + if text.is_empty() { + return; + } + if let Ok(mut sw) = state.last_clipboard_self_write.lock() { + if sw.as_deref() == Some(text.as_str()) { + *sw = None; + return; + } + } + if let Ok(mut seen) = state.last_clipboard_seen.lock() { + if seen.as_deref() == Some(text.as_str()) { + return; + } + *seen = Some(text.clone()); + } + if !is_download_candidate(&text, &watch_exts(&state)) { + return; + } + crate::managers::clip_prompt::show_clip_prompt(app, &text); +} + +#[tauri::command] +pub fn mark_clipboard_self_write(state: State<'_, AppState>, text: String) { + if let Ok(mut sw) = state.last_clipboard_self_write.lock() { + *sw = Some(text); + } +} + +#[tauri::command] +pub fn start_clipboard_watch(app: AppHandle) -> Result<(), String> { + #[cfg(not(target_os = "android"))] + { + use tauri::Manager; + use tauri_plugin_clipboard::Clipboard; + let clipboard = app.state::(); + if !clipboard.is_monitor_running() { + clipboard.start_monitor(app.clone())?; + } + } + #[cfg(target_os = "android")] + let _ = app; + Ok(()) +} + +#[tauri::command] +pub fn stop_clipboard_watch(app: AppHandle) -> Result<(), String> { + #[cfg(not(target_os = "android"))] + { + use tauri::Manager; + use tauri_plugin_clipboard::Clipboard; + let clipboard = app.state::(); + if clipboard.is_monitor_running() { + clipboard.stop_monitor(app.clone())?; + } + } + #[cfg(target_os = "android")] + let _ = app; + Ok(()) +} + +#[tauri::command] +pub fn get_clip_prompt_uri(state: State<'_, AppState>) -> Option { + state.pending_clip_uri.lock().ok().and_then(|g| g.clone()) +} + +#[tauri::command] +pub fn clip_prompt_accept(app: AppHandle, state: State<'_, AppState>) -> Result<(), String> { + let uri = state + .pending_clip_uri + .lock() + .ok() + .and_then(|mut g| g.take()); + #[cfg(not(target_os = "android"))] + crate::managers::clip_prompt::hide_clip_prompt(&app); + if let Some(uri) = uri { + crate::commands::app_cmds::show_main_window(&app)?; + if let Some(main) = tauri::Manager::get_webview_window(&app, "main") { + use tauri::Emitter; + let _ = main.emit("clipboard-download", uri); + } + } + Ok(()) +} + +#[tauri::command] +pub fn clip_prompt_dismiss(app: AppHandle, state: State<'_, AppState>) { + if let Ok(mut g) = state.pending_clip_uri.lock() { + *g = None; + } + #[cfg(not(target_os = "android"))] + crate::managers::clip_prompt::hide_clip_prompt(&app); + #[cfg(target_os = "macos")] + let _ = app.run_on_main_thread(crate::managers::clip_prompt::restore_prev_focus); + #[cfg(target_os = "android")] + let _ = app; +} + +#[cfg(test)] +mod tests { + use super::is_download_candidate; + + fn exts() -> Vec { + ["iso", "zip", "torrent"] + .iter() + .map(|s| s.to_string()) + .collect() + } + + #[test] + fn classifies_download_candidates() { + let e = exts(); + assert!(is_download_candidate("magnet:?xt=urn:btih:0123abc", &e)); + assert!(is_download_candidate("thunder://QUFodHRwOi8v", &e)); + assert!(is_download_candidate( + "https://mirror.example.com/ubuntu.iso", + &e + )); + assert!(is_download_candidate( + "https://example.com/a/b.torrent?dl=1", + &e + )); + assert!(!is_download_candidate( + "https://example.com/blog/post-123", + &e + )); + assert!(!is_download_candidate("https://github.com/user/repo", &e)); + assert!(!is_download_candidate("just some copied text", &e)); + assert!(!is_download_candidate("", &e)); + assert!(!is_download_candidate("https://example.com/page.html", &e)); + // empty list: scheme-based links still match, extension-based don't + assert!(is_download_candidate("magnet:?xt=urn:btih:0123abc", &[])); + assert!(!is_download_candidate( + "https://mirror.example.com/ubuntu.iso", + &[] + )); + } +} diff --git a/src-tauri/src/commands/engine_cmds.rs b/src-tauri/src/commands/engine_cmds.rs index 2a66267b..ff11955b 100644 --- a/src-tauri/src/commands/engine_cmds.rs +++ b/src-tauri/src/commands/engine_cmds.rs @@ -164,7 +164,6 @@ fn infer_out_from_uri_inner(uri: &str) -> String { return format!("{}.ts", stem); } - // ED2K links: parse filename from ed2k://|file||||/ if lower.starts_with("ed2k://") { let body = raw .trim_start_matches("ed2k://|file|") @@ -172,7 +171,7 @@ fn infer_out_from_uri_inner(uri: &str) -> String { .trim_end_matches("|/"); let parts: Vec<&str> = body.split('|').collect(); if !parts.is_empty() { - let decoded = urlencoding::decode(parts[0]).unwrap_or_default(); + let decoded = crate::commands::file_cmds::percent_decode_strict(parts[0]); let name = decoded.replace('_', " "); if !name.is_empty() { return name; diff --git a/src-tauri/src/commands/file_cmds.rs b/src-tauri/src/commands/file_cmds.rs index abf30962..d0dfc527 100644 --- a/src-tauri/src/commands/file_cmds.rs +++ b/src-tauri/src/commands/file_cmds.rs @@ -918,7 +918,16 @@ fn extract_btih_token(input: &str) -> Option { } pub(crate) fn percent_decode_lossy(input: &str) -> String { - String::from_utf8_lossy(&urlencoding::decode_binary(input.as_bytes())).to_string() + percent_encoding::percent_decode(input.as_bytes()) + .decode_utf8_lossy() + .into_owned() +} + +pub(crate) fn percent_decode_strict(input: &str) -> String { + percent_encoding::percent_decode(input.as_bytes()) + .decode_utf8() + .map(|s| s.into_owned()) + .unwrap_or_default() } fn valid_normalized_info_hash(raw: &str) -> Option { @@ -1260,6 +1269,14 @@ mod tests { assert_eq!(percent_decode_lossy("hello"), "hello"); } + #[test] + fn decode_strict_rejects_invalid_utf8() { + // %FF%FE is not valid UTF-8 -> strict decode yields "" (old + // urlencoding::decode(..).unwrap_or_default() behaviour) + assert_eq!(percent_decode_strict("%FF%FE"), ""); + assert_eq!(percent_decode_strict("%E4%B8%AD"), "中"); + } + // -- inspect_torrent_metadata -- #[test] diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index d14d5736..89d5334f 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -1,6 +1,7 @@ #[cfg(target_os = "android")] pub mod android_intent; pub mod app_cmds; +pub mod clipboard_cmds; pub mod completion_script_cmds; pub mod config_cmds; pub mod cookie_cmds; diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 636f5e51..5e4f2265 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -5,16 +5,15 @@ mod managers; mod state; mod utils; -// Re-export risuko_engine modules for use by commands and other app code pub use risuko_engine::config; pub use risuko_engine::engine; use std::sync::atomic::Ordering; use std::sync::Arc; -#[cfg(not(target_os = "android"))] -use tauri::Emitter; use tauri::Manager; +#[cfg(not(target_os = "android"))] +use tauri::{Emitter, Listener}; #[cfg(not(target_os = "android"))] use tauri_plugin_autostart::{MacosLauncher, ManagerExt}; @@ -41,6 +40,8 @@ fn with_desktop_plugins(builder: tauri::Builder) -> tauri: } } })) + .plugin(tauri_plugin_clipboard::init()) + .plugin(tauri_plugin_notification::init()) } #[cfg(target_os = "android")] @@ -334,10 +335,28 @@ pub fn run() { managers::flyout::setup_flyout(app)?; - // Start RSS background polling + managers::clip_prompt::setup_clip_prompt(app)?; + + #[cfg(not(target_os = "android"))] + { + let watch_handle = app.handle().clone(); + app.listen_any( + "plugin:clipboard://clipboard-monitor/update", + move |_event| { + commands::clipboard_cmds::on_clipboard_update(&watch_handle); + }, + ); + let state = app.state::(); + if commands::clipboard_cmds::watch_enabled(&state) { + use tauri_plugin_clipboard::Clipboard; + let clipboard = app.state::(); + if let Err(e) = clipboard.start_monitor(app.handle().clone()) { + tracing::warn!("[Risuko] failed to start clipboard monitor: {}", e); + } + } + } + let rss = app.state::().rss.clone(); - // Must spawn into Tauri's async runtime so the tokio reactor is - // available for the inner tokio::spawn call tauri::async_runtime::spawn(async move { std::mem::drop(RssManager::start_polling(rss)); }); @@ -376,12 +395,23 @@ pub fn run() { managers::flyout::demote_if_ui_hidden(window.app_handle()); let _ = window.hide(); } + + #[cfg(not(target_os = "android"))] + if window.label() == managers::clip_prompt::CLIP_PROMPT_LABEL { + managers::clip_prompt::hide_clip_prompt(window.app_handle()); + } } }) .invoke_handler(tauri::generate_handler![ commands::config_cmds::get_app_config, commands::config_cmds::save_preference, commands::config_cmds::prepare_preference_patch, + commands::clipboard_cmds::mark_clipboard_self_write, + commands::clipboard_cmds::start_clipboard_watch, + commands::clipboard_cmds::stop_clipboard_watch, + commands::clipboard_cmds::get_clip_prompt_uri, + commands::clipboard_cmds::clip_prompt_accept, + commands::clipboard_cmds::clip_prompt_dismiss, commands::app_cmds::relaunch_app, commands::app_cmds::quit_app, commands::app_cmds::show_window, diff --git a/src-tauri/src/managers/clip_prompt.rs b/src-tauri/src/managers/clip_prompt.rs new file mode 100644 index 00000000..d990fdc3 --- /dev/null +++ b/src-tauri/src/managers/clip_prompt.rs @@ -0,0 +1,184 @@ +//! Clipboard Watcher prompt +#[cfg(not(target_os = "android"))] +use tauri::{Emitter, Manager, PhysicalPosition, WebviewUrl, WebviewWindow, WebviewWindowBuilder}; + +#[cfg(not(target_os = "android"))] +pub const CLIP_PROMPT_LABEL: &str = "clip-prompt"; + +#[cfg(not(target_os = "android"))] +const PROMPT_WIDTH: f64 = 384.0; +#[cfg(not(target_os = "android"))] +const PROMPT_HEIGHT: f64 = 156.0; + +#[cfg(not(target_os = "android"))] +pub fn setup_clip_prompt(app: &tauri::App) -> Result<(), Box> { + let handle = app.handle(); + if handle.get_webview_window(CLIP_PROMPT_LABEL).is_some() { + return Ok(()); + } + + let mut builder = WebviewWindowBuilder::new( + app, + CLIP_PROMPT_LABEL, + WebviewUrl::App("clip-prompt.html".into()), + ) + .title("Risuko") + .inner_size(PROMPT_WIDTH, PROMPT_HEIGHT) + .decorations(false) + .always_on_top(true) + .skip_taskbar(true) + .resizable(false) + .visible(false) + .focused(false) + .transparent(true) + .shadow(false) + .accept_first_mouse(true); + + #[cfg(target_os = "macos")] + { + builder = builder.visible_on_all_workspaces(true); + } + + builder.build()?; + Ok(()) +} + +#[cfg(target_os = "android")] +pub fn setup_clip_prompt(_app: &tauri::App) -> Result<(), Box> { + Ok(()) +} + +#[cfg(not(target_os = "android"))] +pub fn show_clip_prompt(app: &tauri::AppHandle, uri: &str) { + #[cfg(target_os = "macos")] + { + let already_open = app + .get_webview_window(CLIP_PROMPT_LABEL) + .and_then(|w| w.is_visible().ok()) + .unwrap_or(false); + if !already_open { + prev_focus::remember(); + } + } + if let Some(state) = app.try_state::() { + if let Ok(mut pending) = state.pending_clip_uri.lock() { + *pending = Some(uri.to_string()); + } + } + let Some(window) = app.get_webview_window(CLIP_PROMPT_LABEL) else { + tracing::warn!("[Risuko] clip-prompt window not found"); + return; + }; + position_prompt(app, &window); + let _ = window.show(); + let _ = window.set_focus(); + let _ = window.emit("clip-prompt:show", uri.to_string()); +} + +/// Hide the prompt window +#[cfg(not(target_os = "android"))] +pub fn hide_clip_prompt(app: &tauri::AppHandle) { + if let Some(window) = app.get_webview_window(CLIP_PROMPT_LABEL) { + #[cfg(target_os = "macos")] + crate::managers::flyout::demote_if_ui_hidden(app); + let _ = window.hide(); + } +} + +#[cfg(target_os = "macos")] +pub fn restore_prev_focus() { + prev_focus::restore(); +} + +#[cfg(target_os = "macos")] +mod prev_focus { + use std::sync::atomic::{AtomicI32, Ordering}; + + // pid of the app frontmost when the prompt opened (0 = none / we were front) + static PREV_APP_PID: AtomicI32 = AtomicI32::new(0); + + pub fn remember() { + use objc2_app_kit::NSWorkspace; + let self_pid = std::process::id() as i32; + let pid = NSWorkspace::sharedWorkspace() + .frontmostApplication() + .map(|a| a.processIdentifier() as i32) + .filter(|&p| p != self_pid) + .unwrap_or(0); + PREV_APP_PID.store(pid, Ordering::SeqCst); + } + + pub fn restore() { + let pid = PREV_APP_PID.swap(0, Ordering::SeqCst); + if pid == 0 { + return; + } + use objc2_app_kit::{NSApplicationActivationOptions, NSRunningApplication}; + if let Some(app) = NSRunningApplication::runningApplicationWithProcessIdentifier(pid) { + app.activateWithOptions(NSApplicationActivationOptions::empty()); + } + } +} + +#[cfg(not(target_os = "android"))] +fn position_prompt(app: &tauri::AppHandle, window: &WebviewWindow) { + let anchor = app + .try_state::() + .and_then(|state| state.tray_anchor.lock().ok().and_then(|guard| *guard)); + + let (icon_x, icon_y, icon_w, icon_h) = match anchor { + Some(rect) => rect, + None => match app.cursor_position() { + Ok(pos) => (pos.x, pos.y, 0.0, 0.0), + Err(_) => (0.0, 0.0, 0.0, 0.0), + }, + }; + + let icon_center_x = icon_x + icon_w / 2.0; + let icon_center_y = icon_y + icon_h / 2.0; + + let monitor = app + .monitor_from_point(icon_center_x, icon_center_y) + .ok() + .flatten() + .or_else(|| app.primary_monitor().ok().flatten()); + + let Some(monitor) = monitor else { + let _ = window.set_position(PhysicalPosition::new(icon_x as i32, icon_y as i32)); + return; + }; + + let scale = monitor.scale_factor(); + let work = monitor.work_area(); + let wa_x = work.position.x as f64; + let wa_y = work.position.y as f64; + let wa_w = work.size.width as f64; + let wa_h = work.size.height as f64; + + let win_w = PROMPT_WIDTH * scale; + let win_h = PROMPT_HEIGHT * scale; + + let in_top_half = icon_center_y < wa_y + wa_h / 2.0; + let mut y = if in_top_half { + icon_y + icon_h + } else { + icon_y - win_h + }; + + let mut x = icon_center_x - win_w / 2.0; + + let max_x = wa_x + wa_w - win_w; + let max_y = wa_y + wa_h - win_h; + if x < wa_x { + x = wa_x; + } else if x > max_x { + x = max_x.max(wa_x); + } + if y < wa_y { + y = wa_y; + } else if y > max_y { + y = max_y.max(wa_y); + } + + let _ = window.set_position(PhysicalPosition::new(x.round() as i32, y.round() as i32)); +} diff --git a/src-tauri/src/managers/mod.rs b/src-tauri/src/managers/mod.rs index b38dd7aa..4c6b1907 100644 --- a/src-tauri/src/managers/mod.rs +++ b/src-tauri/src/managers/mod.rs @@ -1,3 +1,4 @@ +pub mod clip_prompt; pub mod flyout; pub mod menu; pub mod tray; diff --git a/src-tauri/src/state.rs b/src-tauri/src/state.rs index 267ca071..0657c971 100644 --- a/src-tauri/src/state.rs +++ b/src-tauri/src/state.rs @@ -16,9 +16,11 @@ pub struct AppState { pub upload_sinks: Arc, pub vault: Arc, pub log_dir: PathBuf, + pub last_clipboard_self_write: Mutex>, + pub last_clipboard_seen: Mutex>, + pub pending_clip_uri: Mutex>, #[cfg(not(target_os = "android"))] pub tray_anchor: Mutex>, - /// Keeps the file logger alive; dropped when AppState is dropped pub _log_guard: tracing_appender::non_blocking::WorkerGuard, } @@ -45,6 +47,9 @@ impl AppState { upload_sinks: Arc::new(upload_manager), vault: Arc::new(VaultManager::new()), log_dir, + last_clipboard_self_write: Mutex::new(None), + last_clipboard_seen: Mutex::new(None), + pending_clip_uri: Mutex::new(None), #[cfg(not(target_os = "android"))] tray_anchor: Mutex::new(None), _log_guard: log_guard, diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 756ce327..376b88c9 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -33,6 +33,7 @@ }, "bundle": { "active": true, + "createUpdaterArtifacts": false, "icon": [ "icons/32x32.png", "icons/128x128.png", @@ -77,7 +78,7 @@ }, "updater": { "endpoints": [ - "https://risuko.vercel.app/api/update/{{target}}/{{arch}}/{{current_version}}" + "https://risuko.app/api/update/{{target}}/{{arch}}/{{current_version}}" ], "pubkey": "" }, diff --git a/src/renderer/components/ClipboardWatcher/ClipPrompt.vue b/src/renderer/components/ClipboardWatcher/ClipPrompt.vue new file mode 100644 index 00000000..e5b692ff --- /dev/null +++ b/src/renderer/components/ClipboardWatcher/ClipPrompt.vue @@ -0,0 +1,168 @@ + + + + + diff --git a/src/renderer/components/Legal/LegalGate.vue b/src/renderer/components/Legal/LegalGate.vue new file mode 100644 index 00000000..54dd0bd7 --- /dev/null +++ b/src/renderer/components/Legal/LegalGate.vue @@ -0,0 +1,186 @@ + + + + + diff --git a/src/renderer/components/Native/EngineClient.vue b/src/renderer/components/Native/EngineClient.vue index 01aa1d22..51ad3bd8 100644 --- a/src/renderer/components/Native/EngineClient.vue +++ b/src/renderer/components/Native/EngineClient.vue @@ -14,6 +14,7 @@ import { invoke } from "@tauri-apps/api/core"; import { listen } from "@tauri-apps/api/event"; import { toast } from "vue-sonner"; import api from "@/api"; +import is from "@/shims/platform"; import { useAppStore } from "@/store/app"; import { usePreferenceStore } from "@/store/preference"; import { useTaskStore } from "@/store/task"; @@ -50,6 +51,9 @@ export default { initTimer: null, isPolling: false, isDestroyed: false, + notifyActionUnlisten: null as Promise<{ + unregister: () => Promise; + }> | null, noSleepDesired: false, noSleepApplied: false, noSleepSyncing: false, @@ -746,14 +750,73 @@ export default { ? this.$t("task.bt-download-complete-notify") : this.$t("task.download-complete-notify"); - const notify = new Notification(notifyMessage, { - body: `${taskName}${tips}`, - }); - notify.onclick = () => { - showItemInFolder(path, { - errorMsg: this.$t("task.file-not-exist"), + if (document.hidden && !is.android()) { + this.sendOsNotification(notifyMessage, `${taskName}${tips}`, path); + } else { + const notify = new Notification(notifyMessage, { + body: `${taskName}${tips}`, }); - }; + notify.onclick = () => { + showItemInFolder(path, { + errorMsg: this.$t("task.file-not-exist"), + }); + }; + } + }, + async sendOsNotification(title: string, body: string, path?: string) { + try { + const { + isPermissionGranted, + requestPermission, + sendNotification, + onAction, + } = await import("@tauri-apps/plugin-notification"); + let granted = await isPermissionGranted(); + if (!granted) { + granted = (await requestPermission()) === "granted"; + } + if (!granted) { + return; + } + if (!this.notifyActionUnlisten) { + this.notifyActionUnlisten = onAction((n) => { + const folder = n?.extra?.folderPath as string | undefined; + if (folder) { + showItemInFolder(folder, { + errorMsg: this.$t("task.file-not-exist"), + }); + } + }); + this.notifyActionUnlisten.catch(() => { + this.notifyActionUnlisten = null; + }); + } + sendNotification({ + title, + body, + extra: path ? { folderPath: path } : undefined, + }); + } catch (err) { + logger.warn( + "[Risuko] OS notification failed:", + (err as Error)?.message || err, + ); + } + }, + maybeShowClipboardNotice() { + if (is.android()) { + return; + } + const config = usePreferenceStore().config; + if (config?.clipboardWatch && !config?.clipboardWatchNoticeSeen) { + this.$msg.info({ + message: this.$t("preferences.clipboard-watch-notice"), + duration: 8000, + }); + usePreferenceStore() + .save({ clipboardWatchNoticeSeen: true }) + .catch(() => {}); + } }, async bindEngineEvents() { const handlers: [string, (payload: { gid: string }) => void][] = [ @@ -991,6 +1054,7 @@ export default { usePreferenceStore().applyEngineMode(); this.autoResumeUnfinishedTasksOnLaunch(); this.syncTraySpeedTooltip(); + this.maybeShowClipboardNotice(); this.startPolling(); }, 100); @@ -1001,6 +1065,8 @@ export default { clearTimeout(this.initTimer); this.initTimer = null; + this.notifyActionUnlisten?.then((u) => u.unregister()).catch(() => {}); + this.notifyActionUnlisten = null; this.unbindEngineEvents(); this.stopPolling(); this.clearAllAutoRetryTimers(); diff --git a/src/renderer/components/Native/Ipc.vue b/src/renderer/components/Native/Ipc.vue index e5831fc6..af6d9985 100644 --- a/src/renderer/components/Native/Ipc.vue +++ b/src/renderer/components/Native/Ipc.vue @@ -36,15 +36,25 @@ export default { } }); + // Clipboard Watcher + const unlistenClip = await listen("clipboard-download", (event) => { + const uri = event.payload as string; + if (uri) { + commands.execute("application:new-task", { uri }); + } + }); + if (token !== this.bindToken) { unlisten(); unlistenFile(); + unlistenClip(); return; } this.unlisten = () => { unlisten(); unlistenFile(); + unlistenClip(); }; }, unbindIpcEvents() { diff --git a/src/renderer/components/Preference/Advanced.vue b/src/renderer/components/Preference/Advanced.vue index 87746225..674e6ea1 100644 --- a/src/renderer/components/Preference/Advanced.vue +++ b/src/renderer/components/Preference/Advanced.vue @@ -1281,7 +1281,6 @@ import { reduceTrackerString, } from "@shared/utils/tracker"; import { invoke } from "@tauri-apps/api/core"; -import { writeText } from "@tauri-apps/plugin-clipboard-manager"; import { cloneDeep, isEmpty } from "lodash"; import api, { type CookieEntryView } from "@/api"; import SelectDirectory from "@/components/Native/SelectDirectory.vue"; @@ -1308,6 +1307,7 @@ import { Textarea } from "@/components/ui/textarea"; import is from "@/shims/platform"; import { usePreferenceStore } from "@/store/preference"; import { useTaskStore } from "@/store/task"; +import { copyText } from "@/utils/clipboard"; const resolveDohProvider = (storedProvider, storedUrl) => { const url = `${storedUrl || ""}`.trim(); @@ -1610,7 +1610,7 @@ export default { this.form.logDirOverride = `${dir || ""}`.trim(); }, copyRpcUrlToClipboard() { - writeText(this.currentRpcUrl).catch(() => {}); + copyText(this.currentRpcUrl).catch(() => {}); }, onExternalEngineHostBlur() { const host = `${this.form.externalEngineHost || ""}`.trim(); diff --git a/src/renderer/components/Preference/Basic.vue b/src/renderer/components/Preference/Basic.vue index 06468ee6..64adef4d 100644 --- a/src/renderer/components/Preference/Basic.vue +++ b/src/renderer/components/Preference/Basic.vue @@ -472,6 +472,40 @@ /> +
+
+
+ {{ $t('preferences.clipboard-watch') }} +
+
+ {{ $t('preferences.clipboard-watch-tips') }} +
+
+
+ +
+
+
+
+
+ {{ $t('preferences.clipboard-watch-ext') }} +
+
+ {{ $t('preferences.clipboard-watch-ext-tips') }} +
+