From a6ab95ab56c5186c61bbc2b014ada78d65ccc14a Mon Sep 17 00:00:00 2001 From: YueMiyuki <76854136+YueMiyuki@users.noreply.github.com> Date: Mon, 25 May 2026 14:33:00 +0800 Subject: [PATCH 1/5] feat(cookies): add browser cookie import and Cloudflare retry flow --- packages/risuko-app/package.json | 52 +- .../risuko-cli/npm/darwin-arm64/package.json | 26 +- .../risuko-cli/npm/darwin-x64/package.json | 26 +- .../npm/linux-arm64-gnu/package.json | 26 +- .../risuko-cli/npm/linux-x64-gnu/package.json | 26 +- .../npm/win32-arm64-msvc/package.json | 26 +- .../npm/win32-x64-msvc/package.json | 26 +- packages/risuko-cli/package.json | 70 +- .../risuko-js/npm/darwin-arm64/package.json | 28 +- .../risuko-js/npm/darwin-x64/package.json | 28 +- .../npm/linux-arm64-gnu/package.json | 34 +- .../risuko-js/npm/linux-x64-gnu/package.json | 34 +- .../npm/win32-arm64-msvc/package.json | 28 +- .../risuko-js/npm/win32-x64-msvc/package.json | 28 +- packages/risuko-js/package.json | 90 +- src-tauri/Cargo.lock | 994 ++++++++++++++++-- src-tauri/Cargo.toml | 6 +- src-tauri/risuko-cookies/Cargo.toml | 18 + src-tauri/risuko-cookies/src/lib.rs | 327 ++++++ src-tauri/risuko-engine/Cargo.toml | 1 + .../risuko-engine/src/engine/cookie_store.rs | 326 ++++++ .../risuko-engine/src/engine/error_code.rs | 24 + src-tauri/risuko-engine/src/engine/http.rs | 402 ++++++- src-tauri/risuko-engine/src/engine/manager.rs | 218 +++- src-tauri/risuko-engine/src/engine/mod.rs | 1 + .../src/engine/upload/manager.rs | 5 +- .../risuko-engine/tests/http_features.rs | 278 ++++- src-tauri/src/commands/cookie_cmds.rs | 326 ++++++ src-tauri/src/commands/engine_cmds.rs | 23 +- src-tauri/src/commands/mod.rs | 1 + src-tauri/src/lib.rs | 7 + src/renderer/api/Api.ts | 78 ++ src/renderer/api/index.ts | 7 + .../components/Native/EngineClient.vue | 33 + .../components/Preference/Advanced.vue | 110 +- src/renderer/components/Task/AddTask.vue | 33 +- .../components/Task/BrowserCookiePicker.vue | 161 +++ .../components/Task/CloudflareDialog.vue | 550 ++++++++++ .../ui/confirm-dialog/ConfirmDialog.vue | 2 +- src/renderer/pages/index/App.vue | 3 + src/renderer/store/app.ts | 48 + src/shared/locales/en-US/preferences.ts | 13 + src/shared/locales/en-US/task.ts | 39 + src/shared/locales/zh-CN/preferences.ts | 13 + src/shared/locales/zh-CN/task.ts | 34 + src/shared/locales/zh-TW/preferences.ts | 13 + src/shared/locales/zh-TW/task.ts | 34 + 47 files changed, 4278 insertions(+), 398 deletions(-) create mode 100644 src-tauri/risuko-cookies/Cargo.toml create mode 100644 src-tauri/risuko-cookies/src/lib.rs create mode 100644 src-tauri/risuko-engine/src/engine/cookie_store.rs create mode 100644 src-tauri/src/commands/cookie_cmds.rs create mode 100644 src/renderer/components/Task/BrowserCookiePicker.vue create mode 100644 src/renderer/components/Task/CloudflareDialog.vue diff --git a/packages/risuko-app/package.json b/packages/risuko-app/package.json index 6cbf2b29..f72b0e74 100644 --- a/packages/risuko-app/package.json +++ b/packages/risuko-app/package.json @@ -1,28 +1,28 @@ { - "name": "@risuko/app", - "version": "0.3.1", - "description": "Risuko download manager — launches the desktop app, downloading it from GitHub Releases on first run", - "license": "MIT", - "repository": { - "type": "git", - "url": "git+https://github.com/YueMiyuki/risuko.git" - }, - "keywords": [ - "download", - "torrent", - "bittorrent", - "ed2k", - "m3u8", - "ftp", - "download-manager" - ], - "bin": { - "risuko-app": "bin.js" - }, - "files": [ - "bin.js" - ], - "engines": { - "node": ">=22.0.0" - } + "name": "@risuko/app", + "version": "0.3.1", + "description": "Risuko download manager — launches the desktop app, downloading it from GitHub Releases on first run", + "license": "MIT", + "repository": { + "type": "git", + "url": "git+https://github.com/YueMiyuki/risuko.git" + }, + "keywords": [ + "download", + "torrent", + "bittorrent", + "ed2k", + "m3u8", + "ftp", + "download-manager" + ], + "bin": { + "risuko-app": "bin.js" + }, + "files": [ + "bin.js" + ], + "engines": { + "node": ">=22.0.0" + } } diff --git a/packages/risuko-cli/npm/darwin-arm64/package.json b/packages/risuko-cli/npm/darwin-arm64/package.json index c01f1d87..bbc4c6ad 100644 --- a/packages/risuko-cli/npm/darwin-arm64/package.json +++ b/packages/risuko-cli/npm/darwin-arm64/package.json @@ -1,15 +1,15 @@ { - "name": "@risuko/cli-darwin-arm64", - "version": "0.3.1", - "description": "Risuko CLI binary for macOS ARM64", - "license": "MIT", - "os": [ - "darwin" - ], - "cpu": [ - "arm64" - ], - "files": [ - "risuko" - ] + "name": "@risuko/cli-darwin-arm64", + "version": "0.3.1", + "description": "Risuko CLI binary for macOS ARM64", + "license": "MIT", + "os": [ + "darwin" + ], + "cpu": [ + "arm64" + ], + "files": [ + "risuko" + ] } diff --git a/packages/risuko-cli/npm/darwin-x64/package.json b/packages/risuko-cli/npm/darwin-x64/package.json index 9ea92cc7..67603efd 100644 --- a/packages/risuko-cli/npm/darwin-x64/package.json +++ b/packages/risuko-cli/npm/darwin-x64/package.json @@ -1,15 +1,15 @@ { - "name": "@risuko/cli-darwin-x64", - "version": "0.3.1", - "description": "Risuko CLI binary for macOS x64", - "license": "MIT", - "os": [ - "darwin" - ], - "cpu": [ - "x64" - ], - "files": [ - "risuko" - ] + "name": "@risuko/cli-darwin-x64", + "version": "0.3.1", + "description": "Risuko CLI binary for macOS x64", + "license": "MIT", + "os": [ + "darwin" + ], + "cpu": [ + "x64" + ], + "files": [ + "risuko" + ] } diff --git a/packages/risuko-cli/npm/linux-arm64-gnu/package.json b/packages/risuko-cli/npm/linux-arm64-gnu/package.json index 36d62d51..21387531 100644 --- a/packages/risuko-cli/npm/linux-arm64-gnu/package.json +++ b/packages/risuko-cli/npm/linux-arm64-gnu/package.json @@ -1,15 +1,15 @@ { - "name": "@risuko/cli-linux-arm64-gnu", - "version": "0.3.1", - "description": "Risuko CLI binary for Linux ARM64", - "license": "MIT", - "os": [ - "linux" - ], - "cpu": [ - "arm64" - ], - "files": [ - "risuko" - ] + "name": "@risuko/cli-linux-arm64-gnu", + "version": "0.3.1", + "description": "Risuko CLI binary for Linux ARM64", + "license": "MIT", + "os": [ + "linux" + ], + "cpu": [ + "arm64" + ], + "files": [ + "risuko" + ] } diff --git a/packages/risuko-cli/npm/linux-x64-gnu/package.json b/packages/risuko-cli/npm/linux-x64-gnu/package.json index 72bf327e..b05a844d 100644 --- a/packages/risuko-cli/npm/linux-x64-gnu/package.json +++ b/packages/risuko-cli/npm/linux-x64-gnu/package.json @@ -1,15 +1,15 @@ { - "name": "@risuko/cli-linux-x64-gnu", - "version": "0.3.1", - "description": "Risuko CLI binary for Linux x64", - "license": "MIT", - "os": [ - "linux" - ], - "cpu": [ - "x64" - ], - "files": [ - "risuko" - ] + "name": "@risuko/cli-linux-x64-gnu", + "version": "0.3.1", + "description": "Risuko CLI binary for Linux x64", + "license": "MIT", + "os": [ + "linux" + ], + "cpu": [ + "x64" + ], + "files": [ + "risuko" + ] } diff --git a/packages/risuko-cli/npm/win32-arm64-msvc/package.json b/packages/risuko-cli/npm/win32-arm64-msvc/package.json index 1172277b..5b6aaccc 100644 --- a/packages/risuko-cli/npm/win32-arm64-msvc/package.json +++ b/packages/risuko-cli/npm/win32-arm64-msvc/package.json @@ -1,15 +1,15 @@ { - "name": "@risuko/cli-win32-arm64-msvc", - "version": "0.3.1", - "description": "Risuko CLI binary for Windows ARM64", - "license": "MIT", - "os": [ - "win32" - ], - "cpu": [ - "arm64" - ], - "files": [ - "risuko.exe" - ] + "name": "@risuko/cli-win32-arm64-msvc", + "version": "0.3.1", + "description": "Risuko CLI binary for Windows ARM64", + "license": "MIT", + "os": [ + "win32" + ], + "cpu": [ + "arm64" + ], + "files": [ + "risuko.exe" + ] } diff --git a/packages/risuko-cli/npm/win32-x64-msvc/package.json b/packages/risuko-cli/npm/win32-x64-msvc/package.json index 839934ce..f9b2b3ce 100644 --- a/packages/risuko-cli/npm/win32-x64-msvc/package.json +++ b/packages/risuko-cli/npm/win32-x64-msvc/package.json @@ -1,15 +1,15 @@ { - "name": "@risuko/cli-win32-x64-msvc", - "version": "0.3.1", - "description": "Risuko CLI binary for Windows x64", - "license": "MIT", - "os": [ - "win32" - ], - "cpu": [ - "x64" - ], - "files": [ - "risuko.exe" - ] + "name": "@risuko/cli-win32-x64-msvc", + "version": "0.3.1", + "description": "Risuko CLI binary for Windows x64", + "license": "MIT", + "os": [ + "win32" + ], + "cpu": [ + "x64" + ], + "files": [ + "risuko.exe" + ] } diff --git a/packages/risuko-cli/package.json b/packages/risuko-cli/package.json index 5d7c9892..895cacb7 100644 --- a/packages/risuko-cli/package.json +++ b/packages/risuko-cli/package.json @@ -1,37 +1,37 @@ { - "name": "@risuko/cli", - "version": "0.3.1", - "description": "Risuko download engine CLI — multi-protocol downloads (HTTP, BitTorrent, ED2K, M3U8, FTP/SFTP)", - "license": "MIT", - "repository": { - "type": "git", - "url": "git+https://github.com/YueMiyuki/risuko.git" - }, - "keywords": [ - "download", - "torrent", - "bittorrent", - "ed2k", - "m3u8", - "ftp", - "cli", - "download-manager" - ], - "bin": { - "risuko": "bin.js" - }, - "files": [ - "bin.js" - ], - "optionalDependencies": { - "@risuko/cli-darwin-arm64": "0.3.1", - "@risuko/cli-darwin-x64": "0.3.1", - "@risuko/cli-linux-arm64-gnu": "0.3.1", - "@risuko/cli-linux-x64-gnu": "0.3.1", - "@risuko/cli-win32-arm64-msvc": "0.3.1", - "@risuko/cli-win32-x64-msvc": "0.3.1" - }, - "engines": { - "node": ">= 18" - } + "name": "@risuko/cli", + "version": "0.3.1", + "description": "Risuko download engine CLI — multi-protocol downloads (HTTP, BitTorrent, ED2K, M3U8, FTP/SFTP)", + "license": "MIT", + "repository": { + "type": "git", + "url": "git+https://github.com/YueMiyuki/risuko.git" + }, + "keywords": [ + "download", + "torrent", + "bittorrent", + "ed2k", + "m3u8", + "ftp", + "cli", + "download-manager" + ], + "bin": { + "risuko": "bin.js" + }, + "files": [ + "bin.js" + ], + "optionalDependencies": { + "@risuko/cli-darwin-arm64": "0.3.1", + "@risuko/cli-darwin-x64": "0.3.1", + "@risuko/cli-linux-arm64-gnu": "0.3.1", + "@risuko/cli-linux-x64-gnu": "0.3.1", + "@risuko/cli-win32-arm64-msvc": "0.3.1", + "@risuko/cli-win32-x64-msvc": "0.3.1" + }, + "engines": { + "node": ">= 18" + } } diff --git a/packages/risuko-js/npm/darwin-arm64/package.json b/packages/risuko-js/npm/darwin-arm64/package.json index c667b4d9..6f671b8a 100644 --- a/packages/risuko-js/npm/darwin-arm64/package.json +++ b/packages/risuko-js/npm/darwin-arm64/package.json @@ -1,16 +1,16 @@ { - "name": "@risuko/js-darwin-arm64", - "version": "0.3.1", - "description": "Risuko JS native module for macOS ARM64", - "license": "MIT", - "os": [ - "darwin" - ], - "cpu": [ - "arm64" - ], - "main": "risuko.darwin-arm64.node", - "files": [ - "risuko.darwin-arm64.node" - ] + "name": "@risuko/js-darwin-arm64", + "version": "0.3.1", + "description": "Risuko JS native module for macOS ARM64", + "license": "MIT", + "os": [ + "darwin" + ], + "cpu": [ + "arm64" + ], + "main": "risuko.darwin-arm64.node", + "files": [ + "risuko.darwin-arm64.node" + ] } diff --git a/packages/risuko-js/npm/darwin-x64/package.json b/packages/risuko-js/npm/darwin-x64/package.json index 2054feb9..25c5583d 100644 --- a/packages/risuko-js/npm/darwin-x64/package.json +++ b/packages/risuko-js/npm/darwin-x64/package.json @@ -1,16 +1,16 @@ { - "name": "@risuko/js-darwin-x64", - "version": "0.3.1", - "description": "Risuko JS native module for macOS x64", - "license": "MIT", - "os": [ - "darwin" - ], - "cpu": [ - "x64" - ], - "main": "risuko.darwin-x64.node", - "files": [ - "risuko.darwin-x64.node" - ] + "name": "@risuko/js-darwin-x64", + "version": "0.3.1", + "description": "Risuko JS native module for macOS x64", + "license": "MIT", + "os": [ + "darwin" + ], + "cpu": [ + "x64" + ], + "main": "risuko.darwin-x64.node", + "files": [ + "risuko.darwin-x64.node" + ] } diff --git a/packages/risuko-js/npm/linux-arm64-gnu/package.json b/packages/risuko-js/npm/linux-arm64-gnu/package.json index 0035502e..d0798a93 100644 --- a/packages/risuko-js/npm/linux-arm64-gnu/package.json +++ b/packages/risuko-js/npm/linux-arm64-gnu/package.json @@ -1,19 +1,19 @@ { - "name": "@risuko/js-linux-arm64-gnu", - "version": "0.3.1", - "description": "Risuko JS native module for Linux ARM64", - "license": "MIT", - "os": [ - "linux" - ], - "cpu": [ - "arm64" - ], - "libc": [ - "glibc" - ], - "main": "risuko.linux-arm64-gnu.node", - "files": [ - "risuko.linux-arm64-gnu.node" - ] + "name": "@risuko/js-linux-arm64-gnu", + "version": "0.3.1", + "description": "Risuko JS native module for Linux ARM64", + "license": "MIT", + "os": [ + "linux" + ], + "cpu": [ + "arm64" + ], + "libc": [ + "glibc" + ], + "main": "risuko.linux-arm64-gnu.node", + "files": [ + "risuko.linux-arm64-gnu.node" + ] } diff --git a/packages/risuko-js/npm/linux-x64-gnu/package.json b/packages/risuko-js/npm/linux-x64-gnu/package.json index d10802c8..252b50aa 100644 --- a/packages/risuko-js/npm/linux-x64-gnu/package.json +++ b/packages/risuko-js/npm/linux-x64-gnu/package.json @@ -1,19 +1,19 @@ { - "name": "@risuko/js-linux-x64-gnu", - "version": "0.3.1", - "description": "Risuko JS native module for Linux x64", - "license": "MIT", - "os": [ - "linux" - ], - "cpu": [ - "x64" - ], - "libc": [ - "glibc" - ], - "main": "risuko.linux-x64-gnu.node", - "files": [ - "risuko.linux-x64-gnu.node" - ] + "name": "@risuko/js-linux-x64-gnu", + "version": "0.3.1", + "description": "Risuko JS native module for Linux x64", + "license": "MIT", + "os": [ + "linux" + ], + "cpu": [ + "x64" + ], + "libc": [ + "glibc" + ], + "main": "risuko.linux-x64-gnu.node", + "files": [ + "risuko.linux-x64-gnu.node" + ] } diff --git a/packages/risuko-js/npm/win32-arm64-msvc/package.json b/packages/risuko-js/npm/win32-arm64-msvc/package.json index ba900b3b..76a27e62 100644 --- a/packages/risuko-js/npm/win32-arm64-msvc/package.json +++ b/packages/risuko-js/npm/win32-arm64-msvc/package.json @@ -1,16 +1,16 @@ { - "name": "@risuko/js-win32-arm64-msvc", - "version": "0.3.1", - "description": "Risuko JS native module for Windows ARM64", - "license": "MIT", - "os": [ - "win32" - ], - "cpu": [ - "arm64" - ], - "main": "risuko.win32-arm64-msvc.node", - "files": [ - "risuko.win32-arm64-msvc.node" - ] + "name": "@risuko/js-win32-arm64-msvc", + "version": "0.3.1", + "description": "Risuko JS native module for Windows ARM64", + "license": "MIT", + "os": [ + "win32" + ], + "cpu": [ + "arm64" + ], + "main": "risuko.win32-arm64-msvc.node", + "files": [ + "risuko.win32-arm64-msvc.node" + ] } diff --git a/packages/risuko-js/npm/win32-x64-msvc/package.json b/packages/risuko-js/npm/win32-x64-msvc/package.json index 205dd066..64988f54 100644 --- a/packages/risuko-js/npm/win32-x64-msvc/package.json +++ b/packages/risuko-js/npm/win32-x64-msvc/package.json @@ -1,16 +1,16 @@ { - "name": "@risuko/js-win32-x64-msvc", - "version": "0.3.1", - "description": "Risuko JS native module for Windows x64", - "license": "MIT", - "os": [ - "win32" - ], - "cpu": [ - "x64" - ], - "main": "risuko.win32-x64-msvc.node", - "files": [ - "risuko.win32-x64-msvc.node" - ] + "name": "@risuko/js-win32-x64-msvc", + "version": "0.3.1", + "description": "Risuko JS native module for Windows x64", + "license": "MIT", + "os": [ + "win32" + ], + "cpu": [ + "x64" + ], + "main": "risuko.win32-x64-msvc.node", + "files": [ + "risuko.win32-x64-msvc.node" + ] } diff --git a/packages/risuko-js/package.json b/packages/risuko-js/package.json index 0bca9d42..46eb6be0 100644 --- a/packages/risuko-js/package.json +++ b/packages/risuko-js/package.json @@ -1,47 +1,47 @@ { - "name": "@risuko/risuko-js", - "version": "0.3.1", - "description": "Risuko download engine — Node.js native bindings for multi-protocol downloads (HTTP, BitTorrent, ED2K, M3U8, FTP/SFTP)", - "main": "index.js", - "types": "index.d.ts", - "license": "MIT", - "repository": { - "type": "git", - "url": "git+https://github.com/YueMiyuki/risuko.git" - }, - "keywords": [ - "download", - "torrent", - "bittorrent", - "ed2k", - "m3u8", - "ftp", - "download-manager" - ], - "files": [ - "index.js", - "index.d.ts" - ], - "napi": { - "name": "risuko", - "triples": { - "defaults": true, - "additional": [ - "aarch64-apple-darwin", - "aarch64-unknown-linux-gnu", - "aarch64-pc-windows-msvc" - ] - } - }, - "optionalDependencies": { - "@risuko/js-darwin-arm64": "0.3.1", - "@risuko/js-darwin-x64": "0.3.1", - "@risuko/js-linux-x64-gnu": "0.3.1", - "@risuko/js-linux-arm64-gnu": "0.3.1", - "@risuko/js-win32-x64-msvc": "0.3.1", - "@risuko/js-win32-arm64-msvc": "0.3.1" - }, - "engines": { - "node": ">= 18" - } + "name": "@risuko/risuko-js", + "version": "0.3.1", + "description": "Risuko download engine — Node.js native bindings for multi-protocol downloads (HTTP, BitTorrent, ED2K, M3U8, FTP/SFTP)", + "main": "index.js", + "types": "index.d.ts", + "license": "MIT", + "repository": { + "type": "git", + "url": "git+https://github.com/YueMiyuki/risuko.git" + }, + "keywords": [ + "download", + "torrent", + "bittorrent", + "ed2k", + "m3u8", + "ftp", + "download-manager" + ], + "files": [ + "index.js", + "index.d.ts" + ], + "napi": { + "name": "risuko", + "triples": { + "defaults": true, + "additional": [ + "aarch64-apple-darwin", + "aarch64-unknown-linux-gnu", + "aarch64-pc-windows-msvc" + ] + } + }, + "optionalDependencies": { + "@risuko/js-darwin-arm64": "0.3.1", + "@risuko/js-darwin-x64": "0.3.1", + "@risuko/js-linux-x64-gnu": "0.3.1", + "@risuko/js-linux-arm64-gnu": "0.3.1", + "@risuko/js-win32-x64-msvc": "0.3.1", + "@risuko/js-win32-arm64-msvc": "0.3.1" + }, + "engines": { + "node": ">= 18" + } } diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 13d1cc1d..79f93386 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -78,6 +78,18 @@ dependencies = [ "subtle", ] +[[package]] +name = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if", + "once_cell", + "version_check", + "zerocopy", +] + [[package]] name = "aho-corasick" version = "1.1.4" @@ -220,13 +232,35 @@ dependencies = [ "password-hash", ] +[[package]] +name = "array-init" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d62b7694a562cdf5a74227903507c56ab2cc8bdd1f781ed5cb4cf9c9f810bfc" + +[[package]] +name = "arrayvec" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" + +[[package]] +name = "async-broadcast" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c48ccdbf6ca6b121e0f586cbc0e73ae440e56c67c30fa0873b4e110d9c26d2b" +dependencies = [ + "event-listener 2.5.3", + "futures-core", +] + [[package]] name = "async-broadcast" version = "0.7.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "435a87a52755b8f27fcf321ac4f04b2802e337c8c4872923137471ec39c37532" dependencies = [ - "event-listener", + "event-listener 5.4.1", "event-listener-strategy", "futures-core", "pin-project-lite", @@ -264,12 +298,44 @@ checksum = "c96bf972d85afc50bf5ab8fe2d54d1586b4e0b46c97c50a0c9e71e2f7bcd812a" dependencies = [ "async-task", "concurrent-queue", - "fastrand", - "futures-lite", + "fastrand 2.4.1", + "futures-lite 2.6.1", "pin-project-lite", "slab", ] +[[package]] +name = "async-fs" +version = "1.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "279cf904654eeebfa37ac9bb1598880884924aab82e290aa65c9e77a0e142e06" +dependencies = [ + "async-lock 2.8.0", + "autocfg", + "blocking", + "futures-lite 1.13.0", +] + +[[package]] +name = "async-io" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fc5b45d93ef0529756f812ca52e44c221b35341892d3dcc34132ac02f3dd2af" +dependencies = [ + "async-lock 2.8.0", + "autocfg", + "cfg-if", + "concurrent-queue", + "futures-lite 1.13.0", + "log", + "parking", + "polling 2.8.0", + "rustix 0.37.28", + "slab", + "socket2 0.4.10", + "waker-fn", +] + [[package]] name = "async-io" version = "2.6.0" @@ -280,25 +346,51 @@ dependencies = [ "cfg-if", "concurrent-queue", "futures-io", - "futures-lite", + "futures-lite 2.6.1", "parking", - "polling", - "rustix", + "polling 3.11.0", + "rustix 1.1.4", "slab", "windows-sys 0.61.2", ] +[[package]] +name = "async-lock" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "287272293e9d8c41773cec55e365490fe034813a2f172f502d6ddcf75b2f582b" +dependencies = [ + "event-listener 2.5.3", +] + [[package]] name = "async-lock" version = "3.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311" dependencies = [ - "event-listener", + "event-listener 5.4.1", "event-listener-strategy", "pin-project-lite", ] +[[package]] +name = "async-process" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea6438ba0a08d81529c69b36700fa2f95837bfe3e776ab39cde9c14d9149da88" +dependencies = [ + "async-io 1.13.0", + "async-lock 2.8.0", + "async-signal", + "blocking", + "cfg-if", + "event-listener 3.1.0", + "futures-lite 1.13.0", + "rustix 0.38.44", + "windows-sys 0.48.0", +] + [[package]] name = "async-process" version = "2.5.0" @@ -306,15 +398,15 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc50921ec0055cdd8a16de48773bfeec5c972598674347252c0399676be7da75" dependencies = [ "async-channel", - "async-io", - "async-lock", + "async-io 2.6.0", + "async-lock 3.4.2", "async-signal", "async-task", "blocking", "cfg-if", - "event-listener", - "futures-lite", - "rustix", + "event-listener 5.4.1", + "futures-lite 2.6.1", + "rustix 1.1.4", ] [[package]] @@ -334,13 +426,13 @@ version = "0.2.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "52b5aaafa020cf5053a01f2a60e8ff5dccf550f0f77ec54a4e47285ac2bab485" dependencies = [ - "async-io", - "async-lock", + "async-io 2.6.0", + "async-lock 3.4.2", "atomic-waker", "cfg-if", "futures-core", "futures-io", - "rustix", + "rustix 1.1.4", "signal-hook-registry", "slab", "windows-sys 0.61.2", @@ -521,6 +613,29 @@ dependencies = [ "sha2 0.10.9", ] +[[package]] +name = "binrw" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "003da50fae3878bd26cc710742decd3412b8cfb396bc9b306e5e710106d207fb" +dependencies = [ + "array-init", + "binrw_derive", + "bytemuck", +] + +[[package]] +name = "binrw_derive" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfdce8fda72e8cc5038fb9e739e665d1f3072f7e3e24c573277b8fdce595ef72" +dependencies = [ + "either", + "proc-macro2", + "quote", + "syn 1.0.109", +] + [[package]] name = "bit-set" version = "0.8.0" @@ -620,7 +735,7 @@ dependencies = [ "async-channel", "async-task", "futures-io", - "futures-lite", + "futures-lite 2.6.1", "piper", ] @@ -680,6 +795,12 @@ version = "3.20.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" +[[package]] +name = "bytecount" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "175812e0be2bccb6abe50bb8d566126198344f707e304f45c648fd8f2cc0365e" + [[package]] name = "bytemuck" version = "1.25.0" @@ -799,6 +920,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a1dce859f0832a7d088c4f1119888ab94ef4b5d6795d1ce05afb7fe159d79f98" dependencies = [ "find-msvc-tools", + "jobserver", + "libc", "shlex", ] @@ -1028,6 +1151,12 @@ dependencies = [ "tiny-keccak", ] +[[package]] +name = "convert_case" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6245d59a3e82a7fc217c5828a6692dbc6dfb63a0c8c90495621f7b9d79704a0e" + [[package]] name = "convert_case" version = "0.11.0" @@ -1184,6 +1313,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" dependencies = [ "generic-array 0.14.7", + "rand_core 0.6.4", "typenum", ] @@ -1384,7 +1514,7 @@ dependencies = [ "block-padding 0.3.3", "cbc 0.1.2", "dbus", - "fastrand", + "fastrand 2.4.1", "hkdf 0.12.4", "num", "once_cell", @@ -1434,6 +1564,30 @@ dependencies = [ "serde_core", ] +[[package]] +name = "derivative" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fcc3dd5e9e9c0b295d6e1e4d811fb6f157d5ffd784b8d202fc62eac8035a770b" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "derive_more" +version = "0.99.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6edb4b64a43d977b8e99788fe3a04d483834fba1215a7e02caa415b626497f7f" +dependencies = [ + "convert_case 0.4.0", + "proc-macro2", + "quote", + "rustc_version", + "syn 2.0.117", +] + [[package]] name = "derive_more" version = "2.1.1" @@ -1800,6 +1954,17 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "enumn" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f9ed6b3789237c8a0c1c505af1c7eb2c560df6186f01b098c3a1064ea532f38" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "env_filter" version = "1.0.1" @@ -1856,6 +2021,23 @@ version = "3.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dea2df4cf52843e0452895c455a1a2cfbb842a1e7329671acf418fdc53ed4c59" +[[package]] +name = "event-listener" +version = "2.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0206175f82b8d6bf6652ff7d71a1e27fd2e4efde587fd368662814d6ec1d9ce0" + +[[package]] +name = "event-listener" +version = "3.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d93877bcde0eb80ca09131a08d23f0a5c18a620b01db137dba666d18cd9b30c2" +dependencies = [ + "concurrent-queue", + "parking", + "pin-project-lite", +] + [[package]] name = "event-listener" version = "5.4.1" @@ -1873,10 +2055,41 @@ version = "0.5.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93" dependencies = [ - "event-listener", + "event-listener 5.4.1", "pin-project-lite", ] +[[package]] +name = "eyre" +version = "0.6.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd915d99f24784cdc19fd37ef22b97e3ff0ae756c7e492e9fbfe897d61e2aec" +dependencies = [ + "indenter", + "once_cell", +] + +[[package]] +name = "fallible-iterator" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649" + +[[package]] +name = "fallible-streaming-iterator" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" + +[[package]] +name = "fastrand" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e51093e27b0797c359783294ca4f0a911c270184cb10f85783b118614a1501be" +dependencies = [ + "instant", +] + [[package]] name = "fastrand" version = "2.4.1" @@ -1927,7 +2140,7 @@ version = "0.3.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "38e2275cc4e4fc009b0669731a1e5ab7ebf11f469eaede2bab9309a5b4d6057f" dependencies = [ - "memoffset", + "memoffset 0.9.1", "rustc_version", ] @@ -2023,7 +2236,7 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7e72ed92b67c146290f88e9c89d60ca163ea417a446f61ffd7b72df3e7f1dfd5" dependencies = [ - "rustix", + "rustix 1.1.4", "windows-sys 0.61.2", ] @@ -2075,13 +2288,28 @@ version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" +[[package]] +name = "futures-lite" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49a9d51ce47660b1e808d3c990b4709f2f415d928835a17dfd16991515c46bce" +dependencies = [ + "fastrand 1.9.0", + "futures-core", + "futures-io", + "memchr", + "parking", + "pin-project-lite", + "waker-fn", +] + [[package]] name = "futures-lite" version = "2.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad" dependencies = [ - "fastrand", + "fastrand 2.4.1", "futures-core", "futures-io", "parking", @@ -2254,7 +2482,7 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1bd49230192a3797a9a4d6abe9b3eed6f7fa4c8a8a4947977c6f80025f92cbd8" dependencies = [ - "rustix", + "rustix 1.1.4", "windows-link 0.2.1", ] @@ -2530,6 +2758,9 @@ name = "hashbrown" version = "0.14.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" +dependencies = [ + "ahash", +] [[package]] name = "hashbrown" @@ -2546,6 +2777,15 @@ version = "0.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" +[[package]] +name = "hashlink" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ba4ff7128dee98c7dc9794b6a411377e1404dba1c97deb8d1a55297bd25d8af" +dependencies = [ + "hashbrown 0.14.5", +] + [[package]] name = "heck" version = "0.4.1" @@ -2558,6 +2798,12 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +[[package]] +name = "hermit-abi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d231dfb89cfffdbc30e7fc41579ed6066ad03abda9e567ccafae602b97ec5024" + [[package]] name = "hermit-abi" version = "0.5.2" @@ -2612,6 +2858,15 @@ dependencies = [ "digest 0.11.3", ] +[[package]] +name = "home" +version = "0.5.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc627f471c528ff0c4a49e1d5e60450c8f6461dd6d10ba9dcd3a61d3dff7728d" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "html5ever" version = "0.38.0" @@ -2718,7 +2973,7 @@ dependencies = [ "libc", "percent-encoding", "pin-project-lite", - "socket2", + "socket2 0.6.3", "tokio", "tower-service", "tracing", @@ -2887,6 +3142,12 @@ dependencies = [ "tiff", ] +[[package]] +name = "indenter" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "964de6e86d545b246d84badc0fef527924ace5134f30641c203ef52ba83f58d5" + [[package]] name = "indexmap" version = "1.9.3" @@ -2910,6 +3171,15 @@ dependencies = [ "serde_core", ] +[[package]] +name = "indoc" +version = "2.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "79cf5c93f93228cf8efb3ba362535fb11199ac548a09ce117c9b1adc3030d706" +dependencies = [ + "rustversion", +] + [[package]] name = "infer" version = "0.19.0" @@ -2939,6 +3209,15 @@ dependencies = [ "hybrid-array", ] +[[package]] +name = "instant" +version = "0.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0242819d153cba4b4b05a5a8f2a7e9bbf97b6055b2a002b395c96b5ff3c0222" +dependencies = [ + "cfg-if", +] + [[package]] name = "internal-russh-forked-ssh-key" version = "0.6.18+upstream-0.6.7" @@ -2979,6 +3258,17 @@ dependencies = [ "rand_core 0.10.1", ] +[[package]] +name = "io-lifetimes" +version = "1.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eae7b9aee968036d54dce06cebaefd919e4472e753296daccd6d344e3e2df0c2" +dependencies = [ + "hermit-abi 0.3.9", + "libc", + "windows-sys 0.48.0", +] + [[package]] name = "ipnet" version = "2.12.0" @@ -3107,6 +3397,16 @@ dependencies = [ "syn 2.0.117", ] +[[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.99" @@ -3255,6 +3555,27 @@ dependencies = [ "pkg-config", ] +[[package]] +name = "libesedb" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c72b56d9764a22feaaed1de8f2fc2accc6745dfdf79caff845714fcb52a2f72" +dependencies = [ + "libesedb-sys", + "time", +] + +[[package]] +name = "libesedb-sys" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "110afdc2f932325712a6353243313d3878fd44e811d898176a13a6204b1f0186" +dependencies = [ + "cc", + "patch-apply", + "walkdir", +] + [[package]] name = "libloading" version = "0.7.4" @@ -3290,6 +3611,29 @@ dependencies = [ "libc", ] +[[package]] +name = "libsqlite3-sys" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c10584274047cb335c23d3e61bcef8e323adae7c5c8c760540f73610177fc3f" +dependencies = [ + "cc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "linux-raw-sys" +version = "0.3.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef53942eb7bf7ff43a617b3e2c1c4a5ecf5944a7c1bc12d7ee39bbb15e5c1519" + +[[package]] +name = "linux-raw-sys" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" + [[package]] name = "linux-raw-sys" version = "0.12.1" @@ -3323,6 +3667,15 @@ version = "0.4.29" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" +[[package]] +name = "lz4_flex" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "373f5eceeeab7925e0c1098212f2fbc4d416adec9d35051a6ab251e824c1854a" +dependencies = [ + "twox-hash", +] + [[package]] name = "m3u8-rs" version = "6.0.0" @@ -3411,6 +3764,15 @@ version = "2.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" +[[package]] +name = "memoffset" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5de893c32cde5f383baa4c04c5d6dbdd735cfd4a794b0debdb2bb1b421da5ff4" +dependencies = [ + "autocfg", +] + [[package]] name = "memoffset" version = "0.9.1" @@ -3538,7 +3900,7 @@ version = "3.5.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "89b3f766e04667e6da0e181e2da4f85475d5a6513b7cf6a80bea184e224a5b42" dependencies = [ - "convert_case", + "convert_case 0.11.0", "ctor 1.0.6", "napi-derive-backend", "proc-macro2", @@ -3552,7 +3914,7 @@ version = "5.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0d5af30503edf933ce7377cf6d4c877a62b0f1107ea05585f1b5e430e88d5baf" dependencies = [ - "convert_case", + "convert_case 0.11.0", "proc-macro2", "quote", "semver", @@ -3610,6 +3972,18 @@ version = "1.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" +[[package]] +name = "nix" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "598beaf3cc6fdd9a5dfb1630c2800c7acd31df7aaf0f565796fba2b53ca1af1b" +dependencies = [ + "bitflags 1.3.2", + "cfg-if", + "libc", + "memoffset 0.7.1", +] + [[package]] name = "nix" version = "0.31.3" @@ -3647,6 +4021,26 @@ dependencies = [ "memchr", ] +[[package]] +name = "nom_locate" +version = "4.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e3c83c053b0713da60c5b8de47fe8e494fe3ece5267b2f23090a07a053ba8f3" +dependencies = [ + "bytecount", + "memchr", + "nom 7.1.3", +] + +[[package]] +name = "normpath" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9985ef7269fa99f3b12437bb698381da2428743ab90f20393f399fa14cab21a" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "nosleep" version = "0.2.1" @@ -3710,12 +4104,41 @@ version = "4.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "50ff2e74231b72c832d82982193b417f230945be6bdb5575b251d941d31adb00" dependencies = [ - "futures-lite", + "futures-lite 2.6.1", "log", "mac-notification-sys", "serde", "tauri-winrt-notification", - "zbus", + "zbus 5.15.0", +] + +[[package]] +name = "nt-string" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97eac41de714f55e8daa14e27a51d8a9c16c17f52191f45c9abfaef38bc68459" +dependencies = [ + "displaydoc", + "widestring", +] + +[[package]] +name = "ntfs" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26fe2140f3b6eef2648fa331ca7af7652da1bf3bb84f715878bf50f2f8f03c38" +dependencies = [ + "arrayvec", + "binrw", + "bitflags 2.11.1", + "byteorder", + "derive_more 0.99.20", + "displaydoc", + "enumn", + "memoffset 0.9.1", + "nt-string", + "strum_macros", + "time", ] [[package]] @@ -4118,7 +4541,7 @@ checksum = "9cf20a545b305cf1da722b236b5155c9bb35f1d5ceb28c048bd96ca842f41b5b" dependencies = [ "android_system_properties", "log", - "nix", + "nix 0.31.3", "objc2", "objc2-foundation", "objc2-ui-kit", @@ -4262,6 +4685,17 @@ dependencies = [ "subtle", ] +[[package]] +name = "patch-apply" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7fe95476ec50a4e9b95ed12a4677ff5996aba4329bf2535fb21c49afaad20809" +dependencies = [ + "chrono", + "nom 7.1.3", + "nom_locate", +] + [[package]] name = "pathdiff" version = "0.2.3" @@ -4350,7 +4784,7 @@ version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "135ace3a761e564ec88c03a77317a7c6b80bb7f7135ef2544dbe054243b89737" dependencies = [ - "fastrand", + "fastrand 2.4.1", "phf_shared", ] @@ -4409,7 +4843,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c835479a4443ded371d6c535cbfd8d31ad92c5d23ae9770a61bc155e4992a3c1" dependencies = [ "atomic-waker", - "fastrand", + "fastrand 2.4.1", "futures-io", ] @@ -4497,6 +4931,22 @@ dependencies = [ "miniz_oxide", ] +[[package]] +name = "polling" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b2d323e8ca7996b3e23126511a523f7e62924d93ecd5ae73b333815b0eb3dce" +dependencies = [ + "autocfg", + "bitflags 1.3.2", + "cfg-if", + "concurrent-queue", + "libc", + "log", + "pin-project-lite", + "windows-sys 0.48.0", +] + [[package]] name = "polling" version = "3.11.0" @@ -4505,9 +4955,9 @@ checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218" dependencies = [ "cfg-if", "concurrent-queue", - "hermit-abi", + "hermit-abi 0.5.2", "pin-project-lite", - "rustix", + "rustix 1.1.4", "windows-sys 0.61.2", ] @@ -4623,6 +5073,18 @@ dependencies = [ "elliptic-curve", ] +[[package]] +name = "privilege" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "765ec92721e112ffe07f5c06fb0654da0b708990888981d05cf12a7c9909df30" +dependencies = [ + "libc", + "security-framework-sys", + "which", + "windows-sys 0.48.0", +] + [[package]] name = "proc-macro-crate" version = "1.3.1" @@ -4754,13 +5216,24 @@ version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" +[[package]] +name = "rand" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a" +dependencies = [ + "libc", + "rand_chacha 0.3.1", + "rand_core 0.6.4", +] + [[package]] name = "rand" version = "0.9.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea" dependencies = [ - "rand_chacha", + "rand_chacha 0.9.0", "rand_core 0.9.5", ] @@ -4768,11 +5241,21 @@ dependencies = [ name = "rand" version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d2e8e8bcc7961af1fdac401278c6a831614941f6164ee3bf4ce61b7edb162207" +checksum = "d2e8e8bcc7961af1fdac401278c6a831614941f6164ee3bf4ce61b7edb162207" +dependencies = [ + "chacha20 0.10.0", + "getrandom 0.4.2", + "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 = [ - "chacha20 0.10.0", - "getrandom 0.4.2", - "rand_core 0.10.1", + "ppv-lite86", + "rand_core 0.6.4", ] [[package]] @@ -4790,6 +5273,9 @@ name = "rand_core" version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", +] [[package]] name = "rand_core" @@ -4812,6 +5298,18 @@ version = "0.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539" +[[package]] +name = "rawcopy-rs-next" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ae516a90d6633521dd746eedb19018317daf519ef0c463c3fac4acb80bdec60" +dependencies = [ + "anyhow", + "log", + "normpath", + "ntfs", +] + [[package]] name = "rc4" version = "0.2.0" @@ -4997,6 +5495,7 @@ dependencies = [ "log", "open", "regex", + "risuko-cookies", "risuko-engine", "risuko-http", "serde", @@ -5051,7 +5550,7 @@ dependencies = [ "serde", "sha1 0.11.0", "sha2 0.11.0", - "socket2", + "socket2 0.6.3", "tempfile", "thiserror 2.0.18", "tokio", @@ -5075,6 +5574,18 @@ dependencies = [ "tracing-subscriber", ] +[[package]] +name = "risuko-cookies" +version = "0.3.0" +dependencies = [ + "rookie", + "serde", + "serde_json", + "tokio", + "tracing", + "url", +] + [[package]] name = "risuko-engine" version = "0.3.0" @@ -5103,12 +5614,13 @@ dependencies = [ "log", "m3u8-rs", "md-5", - "nix", + "nix 0.31.3", "parking_lot", "percent-encoding", "rand 0.10.1", "regex", "risuko-bt", + "risuko-cookies", "risuko-http", "russh", "russh-sftp", @@ -5117,7 +5629,7 @@ dependencies = [ "serde_json", "sha1 0.11.0", "sha2 0.11.0", - "socket2", + "socket2 0.6.3", "suppaftp", "tempfile", "thiserror 2.0.18", @@ -5154,7 +5666,7 @@ dependencies = [ "serde", "serde_json", "serde_urlencoded", - "socket2", + "socket2 0.6.3", "thiserror 2.0.18", "tokio", "tokio-rustls", @@ -5182,6 +5694,40 @@ dependencies = [ "tokio", ] +[[package]] +name = "rookie" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec13c4a0a48ef3a59770e8680eab740027ade9502c693320be12af29c025897e" +dependencies = [ + "aes 0.8.4", + "aes-gcm 0.10.3", + "base64 0.22.1", + "byteorder", + "cbc 0.1.2", + "eyre", + "glob", + "indoc", + "libesedb", + "log", + "lz4_flex", + "once_cell", + "pbkdf2 0.12.2", + "privilege", + "rand 0.8.6", + "rawcopy-rs-next", + "regex", + "rusqlite", + "rust-ini", + "serde", + "serde_json", + "sha1 0.10.6", + "url", + "windows 0.51.1", + "zbus 3.15.2", + "zvariant 3.15.2", +] + [[package]] name = "rsa" version = "0.10.0-rc.16" @@ -5200,6 +5746,20 @@ dependencies = [ "zeroize", ] +[[package]] +name = "rusqlite" +version = "0.31.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b838eba278d213a8beaf485bd313fd580ca4505a00d5871caeb1457c55322cae" +dependencies = [ + "bitflags 2.11.1", + "fallible-iterator", + "fallible-streaming-iterator", + "hashlink", + "libsqlite3-sys", + "smallvec", +] + [[package]] name = "russh" version = "0.60.3" @@ -5287,7 +5847,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "37cb4d0360bdd8935392a306d8b5edb539cc455b30e8bf13dd213a0cf7879b40" dependencies = [ "log", - "nix", + "nix 0.31.3", "ssh-encoding", "windows-sys 0.61.2", ] @@ -5370,6 +5930,33 @@ dependencies = [ "subtle", ] +[[package]] +name = "rustix" +version = "0.37.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "519165d378b97752ca44bbe15047d5d3409e875f39327546b42ac81d7e18c1b6" +dependencies = [ + "bitflags 1.3.2", + "errno", + "io-lifetimes", + "libc", + "linux-raw-sys 0.3.8", + "windows-sys 0.48.0", +] + +[[package]] +name = "rustix" +version = "0.38.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" +dependencies = [ + "bitflags 2.11.1", + "errno", + "libc", + "linux-raw-sys 0.4.15", + "windows-sys 0.59.0", +] + [[package]] name = "rustix" version = "1.1.4" @@ -5379,7 +5966,7 @@ dependencies = [ "bitflags 2.11.1", "errno", "libc", - "linux-raw-sys", + "linux-raw-sys 0.12.1", "windows-sys 0.61.2", ] @@ -5563,7 +6150,7 @@ checksum = "c5d9c0c92a92d33f08817311cf3f2c29a3538a8240e94a6a3c622ce652d7e00c" dependencies = [ "bitflags 2.11.1", "cssparser", - "derive_more", + "derive_more 2.1.1", "log", "new_debug_unreachable", "phf", @@ -5653,6 +6240,7 @@ version = "1.0.150" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" dependencies = [ + "indexmap 2.14.0", "itoa", "memchr", "serde", @@ -5952,6 +6540,16 @@ dependencies = [ "syn 1.0.109", ] +[[package]] +name = "socket2" +version = "0.4.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7916fc008ca5542385b89a3d3ce689953c143e9304a9bf8beec1de48994c0d" +dependencies = [ + "libc", + "winapi", +] + [[package]] name = "socket2" version = "0.6.3" @@ -6055,6 +6653,12 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + [[package]] name = "string_cache" version = "0.9.0" @@ -6085,6 +6689,19 @@ version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" +[[package]] +name = "strum_macros" +version = "0.24.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e385be0d24f186b4ce2f9982191e7101bb737312ad61c1f2f984f34bcf85d59" +dependencies = [ + "heck 0.4.1", + "proc-macro2", + "quote", + "rustversion", + "syn 1.0.109", +] + [[package]] name = "subtle" version = "2.6.1" @@ -6099,7 +6716,7 @@ checksum = "4275c142b5be3af2eeadd70dd368caf3b65546c8af1035839372dd7a1436127d" dependencies = [ "async-trait", "chrono", - "futures-lite", + "futures-lite 2.6.1", "lazy-regex", "log", "pin-project", @@ -6561,7 +7178,7 @@ dependencies = [ "thiserror 2.0.18", "tracing", "windows-sys 0.60.2", - "zbus", + "zbus 5.15.0", ] [[package]] @@ -6698,10 +7315,10 @@ version = "3.27.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ - "fastrand", + "fastrand 2.4.1", "getrandom 0.4.2", "once_cell", - "rustix", + "rustix 1.1.4", "windows-sys 0.61.2", ] @@ -6864,7 +7481,7 @@ dependencies = [ "parking_lot", "pin-project-lite", "signal-hook-registry", - "socket2", + "socket2 0.6.3", "tokio-macros", "windows-sys 0.61.2", ] @@ -7242,6 +7859,12 @@ dependencies = [ "thiserror 2.0.18", ] +[[package]] +name = "twox-hash" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ea3136b675547379c4bd395ca6b938e5ad3c3d20fad76e7fe85f9e0d011419c" + [[package]] name = "typeid" version = "1.0.3" @@ -7260,7 +7883,7 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e" dependencies = [ - "memoffset", + "memoffset 0.9.1", "tempfile", "windows-sys 0.61.2", ] @@ -7417,6 +8040,12 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + [[package]] name = "version-compare" version = "0.2.1" @@ -7449,6 +8078,12 @@ dependencies = [ "libc", ] +[[package]] +name = "waker-fn" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "317211a0dc0ceedd78fb2ca9a44aed3d7b9b26f81870d485c07122b4350673b7" + [[package]] name = "walkdir" version = "2.5.0" @@ -7602,7 +8237,7 @@ checksum = "2857dd20b54e916ec7253b3d6b4d5c4d7d4ca2c33c2e11c6c76a99bd8744755d" dependencies = [ "cc", "downcast-rs", - "rustix", + "rustix 1.1.4", "smallvec", "wayland-sys", ] @@ -7614,7 +8249,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "645c7c96bb74690c3189b5c9cb4ca1627062bb23693a4fad9d8c3de958260144" dependencies = [ "bitflags 2.11.1", - "rustix", + "rustix 1.1.4", "wayland-backend", "wayland-scanner", ] @@ -7781,6 +8416,24 @@ version = "0.1.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88" +[[package]] +name = "which" +version = "4.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87ba24419a2078cd2b0f2ede2691b6c66d8e47836da3b6db8265ebad47afbfc7" +dependencies = [ + "either", + "home", + "once_cell", + "rustix 0.38.44", +] + +[[package]] +name = "widestring" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72069c3113ab32ab29e5584db3c6ec55d416895e60715417b5b883a357c3e471" + [[package]] name = "winapi" version = "0.3.9" @@ -7840,6 +8493,16 @@ dependencies = [ "windows_x86_64_msvc 0.36.1", ] +[[package]] +name = "windows" +version = "0.51.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca229916c5ee38c2f2bc1e9d8f04df975b4bd93f9955dc69fabb5d91270045c9" +dependencies = [ + "windows-core 0.51.1", + "windows-targets 0.48.5", +] + [[package]] name = "windows" version = "0.56.0" @@ -7893,6 +8556,15 @@ dependencies = [ "windows-core 0.62.2", ] +[[package]] +name = "windows-core" +version = "0.51.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1f8cf84f35d2db49a46868f947758c7a1138116f7fac3bc844f43ade1292e64" +dependencies = [ + "windows-targets 0.48.5", +] + [[package]] name = "windows-core" version = "0.56.0" @@ -8107,6 +8779,15 @@ dependencies = [ "windows-targets 0.42.2", ] +[[package]] +name = "windows-sys" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" +dependencies = [ + "windows-targets 0.48.5", +] + [[package]] name = "windows-sys" version = "0.52.0" @@ -8158,6 +8839,21 @@ dependencies = [ "windows_x86_64_msvc 0.42.2", ] +[[package]] +name = "windows-targets" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" +dependencies = [ + "windows_aarch64_gnullvm 0.48.5", + "windows_aarch64_msvc 0.48.5", + "windows_i686_gnu 0.48.5", + "windows_i686_msvc 0.48.5", + "windows_x86_64_gnu 0.48.5", + "windows_x86_64_gnullvm 0.48.5", + "windows_x86_64_msvc 0.48.5", +] + [[package]] name = "windows-targets" version = "0.52.6" @@ -8224,6 +8920,12 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8" +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" + [[package]] name = "windows_aarch64_gnullvm" version = "0.52.6" @@ -8248,6 +8950,12 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43" +[[package]] +name = "windows_aarch64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" + [[package]] name = "windows_aarch64_msvc" version = "0.52.6" @@ -8272,6 +8980,12 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f" +[[package]] +name = "windows_i686_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" + [[package]] name = "windows_i686_gnu" version = "0.52.6" @@ -8308,6 +9022,12 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060" +[[package]] +name = "windows_i686_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" + [[package]] name = "windows_i686_msvc" version = "0.52.6" @@ -8332,6 +9052,12 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36" +[[package]] +name = "windows_x86_64_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" + [[package]] name = "windows_x86_64_gnu" version = "0.52.6" @@ -8350,6 +9076,12 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3" +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" + [[package]] name = "windows_x86_64_gnullvm" version = "0.52.6" @@ -8374,6 +9106,12 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0" +[[package]] +name = "windows_x86_64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" + [[package]] name = "windows_x86_64_msvc" version = "0.52.6" @@ -8532,7 +9270,7 @@ dependencies = [ "libc", "log", "os_pipe", - "rustix", + "rustix 1.1.4", "thiserror 2.0.18", "tree_magic_mini", "wayland-backend", @@ -8619,7 +9357,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9993aa5be5a26815fe2c3eacfc1fde061fc1a1f094bf1ad2a18bf9c495dd7414" dependencies = [ "gethostname", - "rustix", + "rustix 1.1.4", "x11rb-protocol", ] @@ -8629,6 +9367,16 @@ version = "0.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ea6fc2961e4ef194dcbfe56bb845534d0dc8098940c7e5c012a258bfec6701bd" +[[package]] +name = "xdg-home" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec1cdab258fb55c0da61328dc52c8764709b249011b2cad0454c72f0bf10a1f6" +dependencies = [ + "libc", + "windows-sys 0.59.0", +] + [[package]] name = "yoke" version = "0.8.2" @@ -8652,29 +9400,70 @@ dependencies = [ "synstructure", ] +[[package]] +name = "zbus" +version = "3.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "675d170b632a6ad49804c8cf2105d7c31eddd3312555cffd4b740e08e97c25e6" +dependencies = [ + "async-broadcast 0.5.1", + "async-executor", + "async-fs", + "async-io 1.13.0", + "async-lock 2.8.0", + "async-process 1.8.1", + "async-recursion", + "async-task", + "async-trait", + "blocking", + "byteorder", + "derivative", + "enumflags2", + "event-listener 2.5.3", + "futures-core", + "futures-sink", + "futures-util", + "hex", + "nix 0.26.4", + "once_cell", + "ordered-stream", + "rand 0.8.6", + "serde", + "serde_repr", + "sha1 0.10.6", + "static_assertions", + "tracing", + "uds_windows", + "winapi", + "xdg-home", + "zbus_macros 3.15.2", + "zbus_names 2.6.1", + "zvariant 3.15.2", +] + [[package]] name = "zbus" version = "5.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c3bcbf15c8708d7fc1be0c993622e0a5cbd5e8b52bfa40afa4c3e0cd8d724ac1" dependencies = [ - "async-broadcast", + "async-broadcast 0.7.2", "async-executor", - "async-io", - "async-lock", - "async-process", + "async-io 2.6.0", + "async-lock 3.4.2", + "async-process 2.5.0", "async-recursion", "async-task", "async-trait", "blocking", "enumflags2", - "event-listener", + "event-listener 5.4.1", "futures-core", - "futures-lite", + "futures-lite 2.6.1", "hex", "libc", "ordered-stream", - "rustix", + "rustix 1.1.4", "serde", "serde_repr", "tracing", @@ -8682,9 +9471,23 @@ dependencies = [ "uuid", "windows-sys 0.61.2", "winnow 1.0.3", - "zbus_macros", - "zbus_names", - "zvariant", + "zbus_macros 5.15.0", + "zbus_names 4.3.2", + "zvariant 5.11.0", +] + +[[package]] +name = "zbus_macros" +version = "3.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7131497b0f887e8061b430c530240063d33bf9455fa34438f388a245da69e0a5" +dependencies = [ + "proc-macro-crate 1.3.1", + "proc-macro2", + "quote", + "regex", + "syn 1.0.109", + "zvariant_utils 1.0.1", ] [[package]] @@ -8697,9 +9500,20 @@ dependencies = [ "proc-macro2", "quote", "syn 2.0.117", - "zbus_names", - "zvariant", - "zvariant_utils", + "zbus_names 4.3.2", + "zvariant 5.11.0", + "zvariant_utils 3.3.1", +] + +[[package]] +name = "zbus_names" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "437d738d3750bed6ca9b8d423ccc7a8eb284f6b1d6d4e225a0e4e6258d864c8d" +dependencies = [ + "serde", + "static_assertions", + "zvariant 3.15.2", ] [[package]] @@ -8710,7 +9524,7 @@ checksum = "7074f3e50b894eac91750142016d30d0a89be8e67dbfd9704fb875825760e52d" dependencies = [ "serde", "winnow 1.0.3", - "zvariant", + "zvariant 5.11.0", ] [[package]] @@ -8828,6 +9642,20 @@ dependencies = [ "zune-core", ] +[[package]] +name = "zvariant" +version = "3.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4eef2be88ba09b358d3b58aca6e41cd853631d44787f319a1383ca83424fb2db" +dependencies = [ + "byteorder", + "enumflags2", + "libc", + "serde", + "static_assertions", + "zvariant_derive 3.15.2", +] + [[package]] name = "zvariant" version = "5.11.0" @@ -8838,8 +9666,21 @@ dependencies = [ "enumflags2", "serde", "winnow 1.0.3", - "zvariant_derive", - "zvariant_utils", + "zvariant_derive 5.11.0", + "zvariant_utils 3.3.1", +] + +[[package]] +name = "zvariant_derive" +version = "3.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37c24dc0bed72f5f90d1f8bb5b07228cbf63b3c6e9f82d82559d4bae666e7ed9" +dependencies = [ + "proc-macro-crate 1.3.1", + "proc-macro2", + "quote", + "syn 1.0.109", + "zvariant_utils 1.0.1", ] [[package]] @@ -8852,7 +9693,18 @@ dependencies = [ "proc-macro2", "quote", "syn 2.0.117", - "zvariant_utils", + "zvariant_utils 3.3.1", +] + +[[package]] +name = "zvariant_utils" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7234f0d811589db492d16893e3f21e8e2fd282e6d01b0cddee310322062cc200" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", ] [[package]] diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 4e92bb6e..257288dc 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,5 +1,5 @@ [workspace] -members = ["risuko-engine", "risuko-bt", "risuko-cli", "risuko-napi", "risuko-http"] +members = ["risuko-engine", "risuko-bt", "risuko-cli", "risuko-napi", "risuko-http", "risuko-cookies"] [workspace.package] version = "0.3.0" @@ -11,9 +11,10 @@ rust-version = "1.82" risuko-engine = { path = "risuko-engine" } risuko-bt = { path = "risuko-bt" } risuko-http = { path = "risuko-http" } +risuko-cookies = { path = "risuko-cookies" } serde = { version = "1", features = ["derive"] } serde_json = "1" -tokio = { version = "1", features = ["process", "sync", "time", "rt", "net", "macros", "signal"] } +tokio = { version = "1", features = ["process", "sync", "time", "rt", "net", "macros", "signal", "io-util"] } log = "0.4" dirs = "6" clap = { version = "4", features = ["derive"] } @@ -67,6 +68,7 @@ tracing-subscriber = { version = "0.3", features = ["env-filter"] } tracing-appender = "0.2" regex = "1.12.3" risuko-http = { workspace = true } +risuko-cookies = { workspace = true } base64 = "0.22" sha1 = "0.11" sha2 = "0.11" diff --git a/src-tauri/risuko-cookies/Cargo.toml b/src-tauri/risuko-cookies/Cargo.toml new file mode 100644 index 00000000..983a2b3e --- /dev/null +++ b/src-tauri/risuko-cookies/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "risuko-cookies" +version.workspace = true +description = "Browser cookie extraction for Risuko" +authors.workspace = true +edition.workspace = true +license = "MIT" + +[lib] +name = "risuko_cookies" + +[dependencies] +serde = { version = "1", features = ["derive"] } +serde_json = "1" +tracing = "0.1" +tokio = { version = "1", features = ["rt"] } +rookie = "0.5" +url = "2" diff --git a/src-tauri/risuko-cookies/src/lib.rs b/src-tauri/risuko-cookies/src/lib.rs new file mode 100644 index 00000000..76a79e36 --- /dev/null +++ b/src-tauri/risuko-cookies/src/lib.rs @@ -0,0 +1,327 @@ +//! Browser cookie extraction façade +//! +//! Wraps the synchronous `rookie` crate behind async helpers and exposes a +//! flat list of supported browsers per host OS. Host-agnostic: both the +//! engine and the Tauri command layer pull from here + +use serde::{Deserialize, Serialize}; +use tokio::task::spawn_blocking; +use url::Url; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct Cookie { + pub name: String, + pub value: String, + pub domain: String, + pub path: String, + pub secure: bool, + pub http_only: bool, + pub expires: Option, +} + +impl From for Cookie { + fn from(c: rookie::enums::Cookie) -> Self { + Self { + name: c.name, + value: c.value, + domain: c.domain, + path: c.path, + secure: c.secure, + http_only: c.http_only, + expires: c.expires, + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct BrowserInfo { + pub id: String, + pub name: String, + pub available: bool, + pub user_agent: String, +} + +/// Format cookies into a single `Cookie:` header value +pub fn cookies_to_header(cookies: &[Cookie]) -> String { + cookies + .iter() + .map(|c| format!("{}={}", c.name, c.value)) + .collect::>() + .join("; ") +} + +/// List browsers known to rookie on this OS. `available` reflects whether +/// a quick probe (no domain filter) returned without an error; sandbox +/// failures or missing profiles surface as `false` +pub async fn list_browsers() -> Vec { + spawn_blocking(list_browsers_sync).await.unwrap_or_default() +} + +fn list_browsers_sync() -> Vec { + let mut out: Vec = Vec::new(); + + macro_rules! probe { + ($id:literal, $name:literal, $ua:expr, $f:expr) => {{ + let available = $f(None).is_ok(); + out.push(BrowserInfo { + id: $id.into(), + name: $name.into(), + available, + user_agent: $ua.into(), + }); + }}; + } + + probe!( + "chrome", + "Google Chrome", + default_ua("chrome"), + rookie::chrome + ); + probe!( + "chromium", + "Chromium", + default_ua("chrome"), + rookie::chromium + ); + probe!("brave", "Brave", default_ua("chrome"), rookie::brave); + probe!("edge", "Microsoft Edge", default_ua("edge"), rookie::edge); + probe!("vivaldi", "Vivaldi", default_ua("chrome"), rookie::vivaldi); + probe!("opera", "Opera", default_ua("chrome"), rookie::opera); + probe!("arc", "Arc", default_ua("chrome"), rookie::arc); + probe!("firefox", "Firefox", default_ua("firefox"), rookie::firefox); + probe!( + "librewolf", + "LibreWolf", + default_ua("firefox"), + rookie::librewolf + ); + probe!("zen", "Zen", default_ua("firefox"), rookie::zen); + + #[cfg(target_os = "windows")] + { + probe!( + "opera_gx", + "Opera GX", + default_ua("chrome"), + rookie::opera_gx + ); + } + #[cfg(target_os = "macos")] + { + probe!( + "opera_gx", + "Opera GX", + default_ua("chrome"), + rookie::opera_gx + ); + probe!("safari", "Safari", default_ua("safari"), rookie::safari); + } + #[cfg(target_os = "linux")] + { + probe!( + "cachy", + "Cachy Browser", + default_ua("firefox"), + rookie::cachy + ); + } + + out +} + +/// Pull cookies for `host` (and parent domains) from the named browser. +/// Returns `Err(message)` on rookie failure so callers can surface it +pub async fn cookies_for_host(browser: &str, host: &str) -> Result, String> { + let browser = browser.to_string(); + let host = host.to_string(); + spawn_blocking(move || cookies_for_host_sync(&browser, &host)) + .await + .map_err(|e| format!("join error: {e}"))? +} + +fn cookies_for_host_sync(browser: &str, host: &str) -> Result, String> { + // rookie does a substring match on the cookie's domain field. Passing + // both the bare host and its eTLD+1 catches `example.com` and + // `.example.com` alike + let mut domains: Vec = vec![host.to_string()]; + if let Some(parent) = registrable_domain(host) { + if parent != host { + domains.push(parent); + } + } + + tracing::info!("risuko-cookies: rookie fetch browser={browser} domains={domains:?}"); + let raw = call_rookie(browser, Some(domains.clone())).inspect_err(|e| { + tracing::warn!( + "risuko-cookies: rookie fetch failed browser={browser} domains={domains:?} err={e}" + ); + })?; + tracing::info!( + "risuko-cookies: rookie returned {} cookie(s) for browser={browser} host={host}", + raw.len() + ); + for c in raw.iter() { + tracing::debug!( + " cookie name={name} domain={domain} path={path} secure={secure} http_only={http_only} value_len={value_len}", + name = c.name, + domain = c.domain, + path = c.path, + secure = c.secure, + http_only = c.http_only, + value_len = c.value.len(), + ); + } + Ok(raw.into_iter().map(Cookie::from).collect()) +} + +/// Resolve a URL or bare host to (host, cookies, ua) for the named browser +pub async fn cookies_for_url(browser: &str, target: &str) -> Result { + let host = extract_host(target).ok_or_else(|| format!("invalid url: {target}"))?; + let cookies = cookies_for_host(browser, &host).await?; + Ok(HostCookies { + host, + user_agent: default_ua_for(browser), + cookies, + }) +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct HostCookies { + pub host: String, + pub user_agent: String, + pub cookies: Vec, +} + +fn call_rookie( + browser: &str, + domains: Option>, +) -> Result, String> { + let r = match browser { + "chrome" => rookie::chrome(domains), + "chromium" => rookie::chromium(domains), + "brave" => rookie::brave(domains), + "edge" => rookie::edge(domains), + "vivaldi" => rookie::vivaldi(domains), + "opera" => rookie::opera(domains), + "arc" => rookie::arc(domains), + "firefox" => rookie::firefox(domains), + "librewolf" => rookie::librewolf(domains), + "zen" => rookie::zen(domains), + #[cfg(any(target_os = "windows", target_os = "macos"))] + "opera_gx" => rookie::opera_gx(domains), + #[cfg(target_os = "macos")] + "safari" => rookie::safari(domains), + #[cfg(target_os = "linux")] + "cachy" => rookie::cachy(domains), + other => return Err(format!("unknown browser: {other}")), + }; + r.map_err(|e| e.to_string()) +} + +fn default_ua_for(browser: &str) -> String { + let kind = match browser { + "firefox" | "librewolf" | "zen" | "cachy" => "firefox", + "edge" => "edge", + "safari" => "safari", + _ => "chrome", + }; + default_ua(kind).to_string() +} + +fn default_ua(kind: &str) -> &'static str { + // Fallback UAs used before the user captures a real one through the + // dialog's "Detect from browser" flow. Whatever they capture is what + // should reach Cloudflare; these strings only keep imports from + // shipping empty values + match kind { + "firefox" => "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:141.0) Gecko/20100101 Firefox/141.0", + "edge" => "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/139.0.0.0 Safari/537.36 Edg/139.0.0.0", + "safari" => "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0 Safari/605.1.15", + _ => "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/139.0.0.0 Safari/537.36", + } +} + +fn extract_host(target: &str) -> Option { + if let Ok(url) = Url::parse(target) { + return url.host_str().map(|s| s.to_string()); + } + let trimmed = target + .trim_start_matches("http://") + .trim_start_matches("https://"); + let host = trimmed.split('/').next()?; + let host = host.split(':').next()?; + if host.is_empty() { + None + } else { + Some(host.to_string()) + } +} + +/// Best-effort registrable domain. Strips a single label off the front +/// when the host has at least two dotted components. Skips the full PSL +/// since we only need to widen rookie's domain filter +fn registrable_domain(host: &str) -> Option { + let labels: Vec<&str> = host.split('.').collect(); + if labels.len() < 2 { + return None; + } + Some(labels[labels.len() - 2..].join(".")) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn extract_host_from_url() { + assert_eq!( + extract_host("https://example.com/path"), + Some("example.com".into()) + ); + assert_eq!( + extract_host("http://sub.example.com:8080"), + Some("sub.example.com".into()) + ); + assert_eq!(extract_host("example.com/foo"), Some("example.com".into())); + } + + #[test] + fn registrable_strips_one_label() { + assert_eq!( + registrable_domain("a.b.example.com"), + Some("example.com".into()) + ); + assert_eq!( + registrable_domain("example.com"), + Some("example.com".into()) + ); + assert_eq!(registrable_domain("localhost"), None); + } + + #[test] + fn cookies_to_header_format() { + let cs = vec![ + Cookie { + name: "a".into(), + value: "1".into(), + domain: "example.com".into(), + path: "/".into(), + secure: true, + http_only: false, + expires: None, + }, + Cookie { + name: "b".into(), + value: "two".into(), + domain: "example.com".into(), + path: "/".into(), + secure: true, + http_only: false, + expires: None, + }, + ]; + assert_eq!(cookies_to_header(&cs), "a=1; b=two"); + } +} diff --git a/src-tauri/risuko-engine/Cargo.toml b/src-tauri/risuko-engine/Cargo.toml index 69e3a30e..1ed58f7a 100644 --- a/src-tauri/risuko-engine/Cargo.toml +++ b/src-tauri/risuko-engine/Cargo.toml @@ -36,6 +36,7 @@ tiger = "0.3" tower-http = { version = "0.6", features = ["cors"] } tracing = "0.1" 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/engine/cookie_store.rs b/src-tauri/risuko-engine/src/engine/cookie_store.rs new file mode 100644 index 00000000..b445477d --- /dev/null +++ b/src-tauri/risuko-engine/src/engine/cookie_store.rs @@ -0,0 +1,326 @@ +//! Domain-keyed cookie / User-Agent store +//! +//! Persists imported browser credentials to a single JSON file in the +//! engine's config directory. Two consumers: +//! +//! 1. The HTTP downloader auto-applies a saved entry when a new task's +//! URL matches by host +//! 2. The IPC layer surfaces these entries in the renderer's "Saved +//! domain credentials" preferences pane, with delete controls +//! +//! Thin by design: no encryption, no scheduled refresh. Entries leave +//! the store when the user deletes them in preferences, when the +//! downloader re-detects a Cloudflare challenge for a saved host (logic +//! lives in `manager.rs`), or when the store grows past `MAX_ENTRIES` +//! and the oldest by `last_validated_at` is evicted + +use parking_lot::RwLock; +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; +use std::path::{Path, PathBuf}; +use std::time::{SystemTime, UNIX_EPOCH}; + +const STORE_FILE: &str = "browser_cookies.json"; +const MAX_ENTRIES: usize = 200; + +#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)] +pub struct StoredCookie { + pub name: String, + pub value: String, + pub domain: String, + pub path: String, + pub secure: bool, + pub http_only: bool, + pub expires: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct CookieEntry { + pub host: String, + pub browser_id: String, + pub user_agent: String, + pub cookies: Vec, + pub imported_at: u64, + pub last_validated_at: u64, +} + +#[derive(Debug, Default, Serialize, Deserialize)] +struct StoreFile { + entries: HashMap, +} + +pub struct CookieStore { + path: PathBuf, + state: RwLock, +} + +impl CookieStore { + pub fn new(config_dir: &Path) -> Self { + let path = config_dir.join(STORE_FILE); + let state = load_from_disk(&path).unwrap_or_default(); + Self { + path, + state: RwLock::new(state), + } + } + + /// Find an entry whose host matches `target_url`. Tries an exact + /// match first, then a suffix match so `example.com` covers + /// `dl.example.com` + pub fn find_for_url(&self, target_url: &str) -> Option { + let host = host_of(target_url)?; + let s = self.state.read(); + if let Some(entry) = s.entries.get(&host) { + return Some(entry.clone()); + } + // Suffix match for subdomains + let mut best: Option<&CookieEntry> = None; + for entry in s.entries.values() { + if host_matches(&host, &entry.host) { + best = match best { + Some(prev) if prev.host.len() >= entry.host.len() => Some(prev), + _ => Some(entry), + }; + } + } + best.cloned() + } + + /// Insert or replace by host. Bumps `last_validated_at` and persists + pub fn upsert(&self, mut entry: CookieEntry) -> Result<(), String> { + let now = now_secs(); + if entry.imported_at == 0 { + entry.imported_at = now; + } + entry.last_validated_at = now; + + let mut s = self.state.write(); + s.entries.insert(entry.host.clone(), entry); + + if s.entries.len() > MAX_ENTRIES { + let mut by_age: Vec<(String, u64)> = s + .entries + .iter() + .map(|(k, v)| (k.clone(), v.last_validated_at)) + .collect(); + by_age.sort_by_key(|(_, t)| *t); + let to_drop = s.entries.len() - MAX_ENTRIES; + for (host, _) in by_age.into_iter().take(to_drop) { + s.entries.remove(&host); + } + } + write_to_disk(&self.path, &s) + } + + /// Bump `last_validated_at` for an entry that's still working. Called + /// from the HTTP downloader after a successful task start + pub fn touch(&self, host: &str) { + let mut s = self.state.write(); + if let Some(entry) = s.entries.get_mut(host) { + entry.last_validated_at = now_secs(); + // Best-effort persist; a write failure isn't fatal + let _ = write_to_disk(&self.path, &s); + } + } + + pub fn remove(&self, host: &str) -> Result { + let mut s = self.state.write(); + let removed = s.entries.remove(host).is_some(); + if removed { + write_to_disk(&self.path, &s)?; + } + Ok(removed) + } + + pub fn list(&self) -> Vec { + let s = self.state.read(); + let mut entries: Vec<_> = s.entries.values().cloned().collect(); + entries.sort_by_key(|b| std::cmp::Reverse(b.last_validated_at)); + entries + } + + pub fn clear(&self) -> Result<(), String> { + let mut s = self.state.write(); + s.entries.clear(); + write_to_disk(&self.path, &s) + } +} + +fn host_of(target_url: &str) -> Option { + if let Ok(url) = url::Url::parse(target_url) { + return url.host_str().map(|s| s.to_lowercase()); + } + let trimmed = target_url + .trim_start_matches("http://") + .trim_start_matches("https://"); + let host = trimmed.split('/').next()?.split(':').next()?; + if host.is_empty() { + None + } else { + Some(host.to_lowercase()) + } +} + +fn host_matches(request_host: &str, entry_host: &str) -> bool { + let r = request_host.to_lowercase(); + let e = entry_host.to_lowercase(); + if r == e { + return true; + } + // entry_host as suffix, with dot boundary + r.ends_with(&format!(".{e}")) +} + +fn now_secs() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0) +} + +fn load_from_disk(path: &Path) -> Result { + if !path.exists() { + return Ok(StoreFile::default()); + } + let data = + std::fs::read_to_string(path).map_err(|e| format!("read cookie store failed: {e}"))?; + serde_json::from_str(&data).map_err(|e| format!("parse cookie store failed: {e}")) +} + +fn write_to_disk(path: &Path, state: &StoreFile) -> Result<(), String> { + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent) + .map_err(|e| format!("create cookie store dir failed: {e}"))?; + } + let json = serde_json::to_string_pretty(state) + .map_err(|e| format!("serialize cookie store failed: {e}"))?; + let tmp = path.with_extension("json.tmp"); + std::fs::write(&tmp, json).map_err(|e| format!("write cookie store failed: {e}"))?; + std::fs::rename(&tmp, path).map_err(|e| format!("rename cookie store failed: {e}"))?; + Ok(()) +} + +/// Render the entry's cookies into a single `Cookie:` header value +pub fn cookies_to_header(cookies: &[StoredCookie]) -> String { + cookies + .iter() + .map(|c| format!("{}={}", c.name, c.value)) + .collect::>() + .join("; ") +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::TempDir; + + fn dummy_entry(host: &str) -> CookieEntry { + CookieEntry { + host: host.to_string(), + browser_id: "chrome".into(), + user_agent: "ua".into(), + cookies: vec![StoredCookie { + name: "cf_clearance".into(), + value: "abc".into(), + domain: format!(".{host}"), + path: "/".into(), + secure: true, + http_only: false, + expires: None, + }], + imported_at: 0, + last_validated_at: 0, + } + } + + #[test] + fn upsert_and_find_exact() { + let dir = TempDir::new().unwrap(); + let store = CookieStore::new(dir.path()); + store.upsert(dummy_entry("example.com")).unwrap(); + let hit = store.find_for_url("https://example.com/foo").unwrap(); + assert_eq!(hit.host, "example.com"); + assert_eq!(hit.cookies[0].name, "cf_clearance"); + } + + #[test] + fn find_for_url_subdomain() { + let dir = TempDir::new().unwrap(); + let store = CookieStore::new(dir.path()); + store.upsert(dummy_entry("example.com")).unwrap(); + let hit = store.find_for_url("https://dl.example.com/file.zip"); + assert!(hit.is_some()); + } + + #[test] + fn find_for_url_unrelated_host_misses() { + let dir = TempDir::new().unwrap(); + let store = CookieStore::new(dir.path()); + store.upsert(dummy_entry("example.com")).unwrap(); + assert!(store.find_for_url("https://attacker.com/foo").is_none()); + // suffix-but-not-dot-bounded + assert!(store.find_for_url("https://notexample.com/foo").is_none()); + } + + #[test] + fn persist_and_reload() { + let dir = TempDir::new().unwrap(); + { + let store = CookieStore::new(dir.path()); + store.upsert(dummy_entry("example.com")).unwrap(); + } + let store = CookieStore::new(dir.path()); + assert!(store.find_for_url("https://example.com").is_some()); + } + + #[test] + fn remove_works() { + let dir = TempDir::new().unwrap(); + let store = CookieStore::new(dir.path()); + store.upsert(dummy_entry("example.com")).unwrap(); + assert!(store.remove("example.com").unwrap()); + assert!(!store.remove("example.com").unwrap()); + assert!(store.find_for_url("https://example.com").is_none()); + } + + #[test] + fn lru_eviction() { + let dir = TempDir::new().unwrap(); + let store = CookieStore::new(dir.path()); + // Fill to MAX with synthetic timestamps so eviction is deterministic + for i in 0..(MAX_ENTRIES + 5) { + let mut e = dummy_entry(&format!("h{i}.example.com")); + e.last_validated_at = i as u64; + e.imported_at = i as u64; + // Bypass touch() in upsert by constructing low timestamp first + store.upsert(e).unwrap(); + // Manually backdate to make ordering meaningful + store.state.write().entries.values_mut().for_each(|e| { + if e.host == format!("h{i}.example.com") { + e.last_validated_at = i as u64; + } + }); + } + let listed = store.list(); + assert!(listed.len() <= MAX_ENTRIES); + // The oldest (h0..h4) should be evicted. h0 specifically should not exist. + assert!(store.find_for_url("https://h0.example.com").is_none()); + } + + #[test] + fn cookies_to_header_format() { + let cs = vec![ + StoredCookie { + name: "a".into(), + value: "1".into(), + ..Default::default() + }, + StoredCookie { + name: "b".into(), + value: "two".into(), + ..Default::default() + }, + ]; + assert_eq!(cookies_to_header(&cs), "a=1; b=two"); + } +} diff --git a/src-tauri/risuko-engine/src/engine/error_code.rs b/src-tauri/risuko-engine/src/engine/error_code.rs index 7759a492..c74bff29 100644 --- a/src-tauri/risuko-engine/src/engine/error_code.rs +++ b/src-tauri/risuko-engine/src/engine/error_code.rs @@ -39,6 +39,11 @@ impl ErrorCode { pub const HTTP_SERVICE_UNAVAILABLE: Self = Self(306); pub const HTTP_REDIRECT_LOOP: Self = Self(307); pub const HTTP_RESPONSE_ERROR: Self = Self(308); + /// Cloudflare bot-protection challenge detected on a 403/503/429 + /// response carrying cf-ray, `server: cloudflare`, or a "Just a + /// moment..." body. Resolved by importing browser cookies plus a + /// matching User-Agent + pub const CLOUDFLARE_CHALLENGE: Self = Self(315); // -- File system -- pub const DISK_FULL: Self = Self(400); @@ -106,6 +111,7 @@ impl ErrorCode { 306 => "HTTP 503 Service Unavailable", 307 => "Too many redirects", 308 => "HTTP response error", + 315 => "Cloudflare challenge detected", 400 => "Disk full or quota exceeded", 401 => "Permission denied", @@ -182,6 +188,16 @@ pub fn classify_error(msg: &str, protocol: &str) -> ErrorCode { // -- HTTP status codes (skip for youtube: its match arm classifies these) -- if protocol != "youtube" { + // Cloudflare has to beat the generic 403/503/429 catch-alls + // below. The HTTP downloader prefixes its message with + // [cloudflare-challenge] when it sniffs the response (see + // http.rs::looks_like_cloudflare_block) + if lower.contains("[cloudflare-challenge]") + || lower.contains("cloudflare challenge") + || lower.contains("cf_clearance required") + { + return ErrorCode::CLOUDFLARE_CHALLENGE; + } if lower.contains("401") || lower.contains("unauthorized") { return ErrorCode::HTTP_UNAUTHORIZED; } @@ -335,6 +351,14 @@ mod tests { ); } + #[test] + fn classify_cloudflare_marker_beats_403() { + assert_eq!( + classify_error("[cloudflare-challenge] host=example.com status=403", "http"), + ErrorCode::CLOUDFLARE_CHALLENGE + ); + } + #[test] fn classify_ftp_login() { assert_eq!( diff --git a/src-tauri/risuko-engine/src/engine/http.rs b/src-tauri/risuko-engine/src/engine/http.rs index 5f4a353d..ce066414 100644 --- a/src-tauri/risuko-engine/src/engine/http.rs +++ b/src-tauri/risuko-engine/src/engine/http.rs @@ -692,6 +692,7 @@ pub async fn run_http_download( global_limiter, task_limiter, chunk_completed, + Arc::new(parking_lot::Mutex::new(None)), ) .await } @@ -716,6 +717,7 @@ pub async fn run_http_download_multi( global_limiter: Arc, task_limiter: Arc, chunk_completed: Vec>, + adopted_filename: Arc>>, ) -> Result { if uris.is_empty() { return Err("no URIs provided".to_string()); @@ -760,6 +762,7 @@ pub async fn run_http_download_multi( global_limiter.clone(), task_limiter.clone(), chunk_completed.clone(), + adopted_filename.clone(), ) .await; @@ -806,25 +809,41 @@ async fn run_single_uri_download( global_limiter: Arc, task_limiter: Arc, chunk_completed: Vec>, + adopted_filename: Arc>>, ) -> Result { tracing::info!("Starting download: uri={uri}, dir={dir}, out={out}"); let dir_path = Path::new(dir); fs::create_dir_all(dir_path).map_err(|e| format!("Failed to create dir: {e}"))?; - let filename = if out.is_empty() { + let out_was_empty = out.is_empty(); + let mut filename = if out_was_empty { infer_filename_from_uri(uri) } else { out.to_string() }; // Sanitize: strip path separators and traversal components - let filename = sanitize_filename(&filename); + filename = sanitize_filename(&filename); + + // Detect a URL-derived filename. The Tauri layer pre-fills task.out + // from the URL path, falling back to "download" for opaque URLs (e.g. + // /resources/foo/download?version=N). Treat any of those as + // URL-derived so the engine can replace them when the server suggests + // a real name via Content-Disposition + let url_inferred = sanitize_filename(&infer_filename_from_uri(uri)); + let url_inferred_part = format!("{url_inferred}{PART_SUFFIX}"); + let filename_was_url_derived = out_was_empty + || filename == url_inferred + || filename == url_inferred_part + || filename.trim_end_matches(PART_SUFFIX) == url_inferred + || filename == "download" + || filename == "download.part"; let part_name = if filename.ends_with(PART_SUFFIX) { filename.clone() } else { format!("{filename}{PART_SUFFIX}") }; - let part_path = dir_path.join(&part_name); + let mut part_path = dir_path.join(&part_name); let split = options .get("split") @@ -878,14 +897,50 @@ async fn run_single_uri_download( let mut last_modified_header: Option = None; let is_http = uri.starts_with("http://") || uri.starts_with("https://"); - if is_http && split > 1 { - // Probe with the no-decompress client so the response headers preserve - // any `Content-Encoding` the origin advertises. Probing with the - // decompressing client would strip that header before we could - // inspect it, defeating the compatibility check + + // Probe the server before opening any output file. Picks up the + // Content-Disposition filename even when the response can't be + // sliced into ranges (chunked transfer, Content-Encoding, etc.) so + // the streaming path below still gets the rename + let probe_for_name: Option = if is_http && filename_was_url_derived { match probe_range_support(&range_client, uri, &headers).await { - Ok(Some(probe)) - if probe.content_length > min_split_size.saturating_mul(split as u64) => + Ok(p) => Some(p), + Err(e) => { + tracing::warn!("Range probe failed, falling back to single: {e}"); + None + } + } + } else { + None + }; + + if filename_was_url_derived { + if let Some(suggested) = probe_for_name + .as_ref() + .and_then(|p| p.suggested_filename.as_ref()) + { + if let Some((new_name, new_part)) = + adopt_suggested_filename(suggested, &filename, &part_path, dir_path) + { + tracing::info!( + "Adopting Content-Disposition filename: {filename:?} -> {new_name:?}" + ); + *adopted_filename.lock() = Some(new_name.clone()); + filename = new_name; + part_path = new_part; + } + } else if is_http { + tracing::debug!( + "No Content-Disposition filename available, keeping URL-derived name {filename:?}" + ); + } + } + + if is_http && split > 1 { + match probe_for_name.clone() { + Some(probe) + if probe.range_supported + && probe.content_length > min_split_size.saturating_mul(split as u64) => { if use_remote_time { last_modified_header = probe.last_modified.clone(); @@ -935,15 +990,12 @@ async fn run_single_uri_download( } return result; } - Ok(Some(_)) => { + Some(_) => { tracing::info!("File too small for multi-chunk, using single connection"); } - Ok(None) => { + None => { tracing::info!("Server does not support ranges, using single connection"); } - Err(e) => { - tracing::warn!("Range probe failed, falling back to single: {e}"); - } } } @@ -988,8 +1040,10 @@ async fn run_single_uri_download( } if is_http && split > 1 { - if let Ok(Some(probe)) = probe_range_support(&range_client, uri, &headers).await { - if probe.content_length > min_split_size.saturating_mul(split as u64) { + if let Ok(probe) = probe_range_support(&range_client, uri, &headers).await { + if probe.range_supported + && probe.content_length > min_split_size.saturating_mul(split as u64) + { if use_remote_time { last_modified_header = probe.last_modified.clone(); } @@ -1071,19 +1125,79 @@ async fn run_single_uri_download( } /// Result from probing range support: content length + optional ETag +#[derive(Clone, Default)] struct ProbeResult { content_length: u64, etag: Option, last_modified: Option, + /// Filename advertised by `Content-Disposition: attachment; filename=...` + /// when present. Overrides URL-path inference for opaque endpoints + /// like `download?version=N` + suggested_filename: Option, + /// True when the response confirms range support (206 with valid + /// Content-Range, or 200 + Accept-Ranges + Content-Length). When + /// false the caller must fall back to a single-connection stream; + /// the other fields can still carry useful headers + range_supported: bool, } -/// Probe whether the server supports Range requests -/// Returns Some(ProbeResult) if ranges are supported, None otherwise +/// Marker prefix used in error strings for Cloudflare-blocked downloads. +/// `manager.rs::classify_error` reads this and maps it to +/// `ErrorCode::CLOUDFLARE_CHALLENGE` (315) +pub const CLOUDFLARE_MARKER: &str = "[cloudflare-challenge]"; + +/// Returns true when the response looks like a Cloudflare bot-protection +/// challenge: a 4xx/5xx status from CF's edge carrying either a `cf-ray` +/// header, `server: cloudflare`, or `cf-mitigated: challenge` +/// +/// Header-only on purpose. Modern CF challenges always set these +/// headers, and reading the body would force buffering before downstream +/// code runs. Skipping the body keeps the streaming path simple +fn looks_like_cloudflare_block(headers: &HeaderMap, status: u16) -> bool { + if !matches!(status, 403 | 429 | 503) { + return false; + } + let header_str = |name: &str| -> Option { + headers + .get(name) + .and_then(|v| v.to_str().ok()) + .map(|s| s.to_ascii_lowercase()) + }; + + if header_str("cf-ray").is_some() { + return true; + } + if let Some(server) = header_str("server") { + if server.contains("cloudflare") { + return true; + } + } + if let Some(mitigated) = header_str("cf-mitigated") { + if mitigated.contains("challenge") { + return true; + } + } + false +} + +/// Build a cloudflare-marker error message the classifier maps to +/// `CLOUDFLARE_CHALLENGE` and the renderer can scan for the host +fn cloudflare_error(uri: &str, status: u16) -> String { + let host = url::Url::parse(uri) + .ok() + .and_then(|u| u.host_str().map(|s| s.to_string())) + .unwrap_or_else(|| "unknown".to_string()); + format!("{CLOUDFLARE_MARKER} host={host} status={status}") +} + +/// Probe whether the server supports Range requests. Returns the +/// response headers we care about regardless of range support; check +/// `range_supported` on the result before slicing async fn probe_range_support( client: &Client, uri: &str, headers: &HeaderMap, -) -> Result, String> { +) -> Result { let resp = client .get(uri) .headers(headers.clone()) @@ -1094,6 +1208,11 @@ async fn probe_range_support( .map_err(|e| format!("Range probe request failed: {e}"))?; let status = resp.status().as_u16(); + + if looks_like_cloudflare_block(resp.headers(), status) { + return Err(cloudflare_error(uri, status)); + } + let etag = resp .headers() .get(ETAG) @@ -1104,14 +1223,26 @@ async fn probe_range_support( .get(LAST_MODIFIED) .and_then(|v| v.to_str().ok()) .map(|s| s.to_string()); + let suggested_filename = filename_from_content_disposition(resp.headers()); + + // The fallback we hand back whenever range support isn't confirmed. + // Carries the filename / ETag / last-modified info so the streaming + // path can still adopt them + let no_range = ProbeResult { + content_length: 0, + etag: etag.clone(), + last_modified: last_modified.clone(), + suggested_filename: suggested_filename.clone(), + range_supported: false, + }; - // Compatibility short-circuits: if the server returns a compressed body - // or chunked transfer-encoding (no length), parallel range workers can't - // safely slice the file. Range offsets refer to the *encoded* bytes when - // Content-Encoding is present, but the worker sees decoded bytes — the - // two address spaces are incompatible. The chunked path additionally - // means we don't know the total size up front. Fall back to the single - // streaming path in either case + // Compatibility short-circuits: a compressed body or chunked + // transfer-encoding (no length) blocks parallel range workers from + // slicing the file safely. Range offsets refer to *encoded* bytes + // when Content-Encoding is present, but workers see decoded bytes; + // the two address spaces don't line up. Chunked also leaves the + // total size unknown up front. Drop to the streaming path in either + // case but keep the filename info we already pulled let content_encoding = resp .headers() .get(CONTENT_ENCODING) @@ -1121,7 +1252,7 @@ async fn probe_range_support( tracing::info!( "Range probe: Content-Encoding={content_encoding}; falling back to single connection" ); - return Ok(None); + return Ok(no_range); } let transfer_encoding = resp .headers() @@ -1133,7 +1264,7 @@ async fn probe_range_support( .any(|t| t.trim().eq_ignore_ascii_case("chunked")) { tracing::info!("Range probe: Transfer-Encoding=chunked; falling back to single connection"); - return Ok(None); + return Ok(no_range); } if status == 206 { @@ -1145,19 +1276,21 @@ async fn probe_range_support( { if let Some(slash) = cr.rfind('/') { if let Ok(total) = cr[slash + 1..].trim().parse::() { - return Ok(Some(ProbeResult { + return Ok(ProbeResult { content_length: total, etag, last_modified, - })); + suggested_filename, + range_supported: true, + }); } } } - return Ok(None); + return Ok(no_range); } if status == 200 { - // Server ignored Range header, check Accept-Ranges + // Server ignored Range header; check Accept-Ranges if let Some(ar) = resp .headers() .get(ACCEPT_RANGES) @@ -1171,20 +1304,22 @@ async fn probe_range_support( { if let Ok(total) = cl.trim().parse::() { if total > 0 { - return Ok(Some(ProbeResult { + return Ok(ProbeResult { content_length: total, etag, last_modified, - })); + suggested_filename, + range_supported: true, + }); } } } } } - return Ok(None); + return Ok(no_range); } - Ok(None) + Ok(no_range) } /// Multi-chunk parallel download using a piece queue + worker pool. @@ -1552,6 +1687,12 @@ async fn download_piece_stream( let status = resp.status().as_u16(); + if looks_like_cloudflare_block(resp.headers(), status) { + return StreamOutcome { + error: Some(cloudflare_error(uri, status)), + }; + } + if let Some(expected) = expected_etag { if let Some(actual) = resp.headers().get(ETAG).and_then(|v| v.to_str().ok()) { if actual != expected { @@ -1846,6 +1987,10 @@ async fn run_single_download( let status = resp.status().as_u16(); + if looks_like_cloudflare_block(resp.headers(), status) { + return Err(cloudflare_error(uri, status)); + } + if status == 416 && existing_size > 0 { tracing::warn!("Got 416 with existing_size={existing_size}, deleting stale .part"); let _ = fs::remove_file(part_path); @@ -2150,6 +2295,37 @@ fn sanitize_filename(name: &str) -> String { } } +/// Decide whether to switch from the URL-inferred filename to one the +/// server suggests via `Content-Disposition`. Skips the swap when the +/// suggested name is unsafe, identical to what we already have, or when +/// an existing .part on disk would have to be moved (resume safety). +/// Returns the new (filename, part_path) pair when adoption fires +fn adopt_suggested_filename( + suggested: &str, + current_filename: &str, + current_part_path: &Path, + dir_path: &Path, +) -> Option<(String, std::path::PathBuf)> { + let candidate = sanitize_filename(suggested); + if candidate.is_empty() || candidate == current_filename { + return None; + } + // Refuse to rename a download that already has bytes on disk + if current_part_path.exists() + && fs::metadata(current_part_path) + .map(|m| m.len() > 0) + .unwrap_or(false) + { + return None; + } + let new_part = if candidate.ends_with(PART_SUFFIX) { + dir_path.join(&candidate) + } else { + dir_path.join(format!("{candidate}{PART_SUFFIX}")) + }; + Some((candidate, new_part)) +} + pub fn infer_filename_from_uri(uri: &str) -> String { let without_hash = uri.split('#').next().unwrap_or(uri); let without_query = without_hash.split('?').next().unwrap_or(without_hash); @@ -2161,6 +2337,57 @@ pub fn infer_filename_from_uri(uri: &str) -> String { } } +/// Parse `Content-Disposition` for a filename. Recognizes: +/// - `attachment; filename="StoragePeek.jar"` (RFC 6266 quoted-string) +/// - `attachment; filename=StoragePeek.jar` (unquoted token) +/// - `attachment; filename*=UTF-8''Storage%20Peek.jar` (RFC 5987 ext-value) +/// +/// Returns `None` when no usable filename is present +pub fn filename_from_content_disposition(headers: &HeaderMap) -> Option { + let raw = headers + .get("content-disposition") + .and_then(|v| v.to_str().ok())?; + + // Prefer filename* (RFC 5987) since it can carry non-ASCII names. + let mut star_value: Option = None; + let mut plain_value: Option = None; + + for part in raw.split(';') { + let part = part.trim(); + if let Some(rest) = part + .strip_prefix("filename*=") + .or_else(|| part.strip_prefix("FILENAME*=")) + { + // Format: charset'lang'percent-encoded + // Find the second quote-mark + if let Some(second_tick) = rest[rest.find('\'').map(|i| i + 1).unwrap_or(0)..] + .find('\'') + .map(|i| i + rest.find('\'').map(|j| j + 1).unwrap_or(0)) + { + let encoded = &rest[second_tick + 1..]; + star_value = Some(url_decode(encoded.trim_matches('"'))); + } + } else if let Some(rest) = part + .strip_prefix("filename=") + .or_else(|| part.strip_prefix("FILENAME=")) + .or_else(|| part.strip_prefix("Filename=")) + { + let val = rest.trim().trim_matches('"').trim_matches('\''); + if !val.is_empty() { + plain_value = Some(url_decode(val)); + } + } + } + + let candidate = star_value.or(plain_value)?; + let trimmed = candidate.trim(); + if trimmed.is_empty() { + None + } else { + Some(sanitize_filename(trimmed)) + } +} + fn url_decode(s: &str) -> String { let mut result = String::with_capacity(s.len()); let bytes = s.as_bytes(); @@ -2192,6 +2419,107 @@ fn hex_val(b: u8) -> Option { mod tests { use super::*; + fn h(pairs: &[(&str, &str)]) -> HeaderMap { + let mut hm = HeaderMap::new(); + for (k, v) in pairs { + hm.insert( + HeaderName::from_bytes(k.as_bytes()).unwrap(), + HeaderValue::from_str(v).unwrap(), + ); + } + hm + } + + #[test] + fn filename_from_quoted_attachment() { + let headers = h(&[( + "content-disposition", + "attachment; filename=\"StoragePeek.jar\"", + )]); + assert_eq!( + filename_from_content_disposition(&headers).as_deref(), + Some("StoragePeek.jar") + ); + } + + #[test] + fn filename_from_unquoted_attachment() { + let headers = h(&[("content-disposition", "attachment; filename=Build_v2.zip")]); + assert_eq!( + filename_from_content_disposition(&headers).as_deref(), + Some("Build_v2.zip") + ); + } + + #[test] + fn filename_from_rfc5987() { + let headers = h(&[( + "content-disposition", + "attachment; filename*=UTF-8''Storage%20Peek.jar", + )]); + assert_eq!( + filename_from_content_disposition(&headers).as_deref(), + Some("Storage Peek.jar") + ); + } + + #[test] + fn filename_strips_path_traversal() { + let headers = h(&[( + "content-disposition", + "attachment; filename=\"../../etc/passwd\"", + )]); + // sanitize_filename collapses path components — exact result depends + // on the helper, but must not contain a slash. + let got = filename_from_content_disposition(&headers).unwrap(); + assert!(!got.contains('/')); + assert!(!got.contains('\\')); + } + + #[test] + fn filename_absent_returns_none() { + let headers = h(&[("content-type", "application/zip")]); + assert_eq!(filename_from_content_disposition(&headers), None); + } + + #[test] + fn cloudflare_detection_cf_ray_403() { + let headers = h(&[("cf-ray", "abc-IAD"), ("server", "cloudflare")]); + assert!(looks_like_cloudflare_block(&headers, 403)); + } + + #[test] + fn cloudflare_detection_503_challenge() { + let headers = h(&[("server", "cloudflare"), ("cf-mitigated", "challenge")]); + assert!(looks_like_cloudflare_block(&headers, 503)); + } + + #[test] + fn cloudflare_detection_429_with_cf_ray() { + let headers = h(&[("cf-ray", "xyz-LHR")]); + assert!(looks_like_cloudflare_block(&headers, 429)); + } + + #[test] + fn cloudflare_detection_skipped_on_2xx() { + let headers = h(&[("server", "cloudflare"), ("cf-ray", "abc")]); + assert!(!looks_like_cloudflare_block(&headers, 200)); + } + + #[test] + fn cloudflare_detection_skipped_on_plain_403() { + let headers = h(&[("server", "nginx")]); + assert!(!looks_like_cloudflare_block(&headers, 403)); + } + + #[test] + fn cloudflare_error_includes_marker_and_host() { + let msg = cloudflare_error("https://dl.example.com/file.zip", 403); + assert!(msg.starts_with(CLOUDFLARE_MARKER)); + assert!(msg.contains("host=dl.example.com")); + assert!(msg.contains("status=403")); + } + #[test] fn piece_queue_partitions_content_into_1mib_pieces() { let total = PIECE_SIZE * 3 + 7; diff --git a/src-tauri/risuko-engine/src/engine/manager.rs b/src-tauri/risuko-engine/src/engine/manager.rs index 180a3343..1a1adedc 100644 --- a/src-tauri/risuko-engine/src/engine/manager.rs +++ b/src-tauri/risuko-engine/src/engine/manager.rs @@ -6,6 +6,7 @@ use std::sync::Arc; use tokio::sync::RwLock; use tokio_util::sync::CancellationToken; +use super::cookie_store::CookieStore; use super::error_code::classify_error; use super::events::{EngineEvent, EventBroadcaster}; use super::http; @@ -31,6 +32,10 @@ struct ActiveDownload { connections: Arc, /// Split chunk completed bytes for multi-thread HTTP downloads chunk_completed: Vec>, + /// Filename adopted from Content-Disposition once the engine sees the + /// first response. Empty until adoption fires; the progress tick reads + /// it and updates `task.out` so the UI shows the real filename mid-download + adopted_filename: Arc>>, } pub struct TaskManager { @@ -43,6 +48,7 @@ pub struct TaskManager { session: SessionManager, torrent_engine: Arc>>, global_speed_limiter: Arc, + cookie_store: Arc, } /// Extract filename hint from the first HTTP URI by parsing URL path @@ -81,6 +87,35 @@ fn extract_filename_from_uri(uris: &[String]) -> String { String::new() } +fn header_contains_cookie(value: &Value) -> bool { + let lines: Vec<&str> = if let Some(s) = value.as_str() { + s.split('\n').collect() + } else if let Some(arr) = value.as_array() { + arr.iter().filter_map(|v| v.as_str()).collect() + } else { + return false; + }; + lines.iter().any(|l| { + let lower = l.trim().to_ascii_lowercase(); + lower.starts_with("cookie:") || lower.starts_with("cookie ") + }) +} + +/// Extract `host=...` from the cloudflare-challenge marker error so the +/// manager can evict the matching saved entry on re-detection +fn parse_cf_host(msg: &str) -> Option { + let key = "host="; + let start = msg.find(key)? + key.len(); + let rest = &msg[start..]; + let end = rest.find(|c: char| c.is_whitespace()).unwrap_or(rest.len()); + let host = rest[..end].trim(); + if host.is_empty() { + None + } else { + Some(host.to_lowercase()) + } +} + impl TaskManager { pub async fn new( config_dir: &Path, @@ -133,6 +168,7 @@ impl TaskManager { session, torrent_engine: Arc::new(RwLock::new(torrent_engine)), global_speed_limiter, + cookie_store: Arc::new(CookieStore::new(config_dir)), }; // Restore torrent_ids mapping from persisted librqbit session @@ -771,6 +807,7 @@ impl TaskManager { speed, connections, chunk_completed: Vec::new(), + adopted_filename: Arc::new(parking_lot::Mutex::new(None)), }; active_for_insert.write().await.insert(gid_for_insert, ad); @@ -929,7 +966,8 @@ impl TaskManager { } if task.kind == TaskKind::Http && !task.uris.is_empty() { task.status = TaskStatus::Active; - let merged = options_guard.merge_task_options(&task.options); + let mut merged = options_guard.merge_task_options(&task.options); + self.apply_stored_cookies(&task.uris, &mut merged); self.spawn_http_download(task, merged); started += 1; } else if task.kind == TaskKind::Youtube && !task.uris.is_empty() { @@ -963,6 +1001,71 @@ impl TaskManager { } } + /// Inject stored browser cookies into the merged options for an HTTP + /// task whose URI host has a saved entry. User-supplied cookies and + /// User-Agent on the task itself always win; the store only fills + /// in fields that are absent + fn apply_stored_cookies(&self, uris: &[String], merged: &mut Map) { + let Some(uri) = uris.first() else { + return; + }; + let Some(entry) = self.cookie_store.find_for_url(uri) else { + log::debug!("apply_stored_cookies: no entry for uri={uri}"); + return; + }; + + let cookie_names: Vec<&str> = entry.cookies.iter().map(|c| c.name.as_str()).collect(); + log::info!( + "apply_stored_cookies: matched entry host={} browser={} ({} cookies: {:?})", + entry.host, + entry.browser_id, + entry.cookies.len(), + cookie_names + ); + + let has_cookie = merged + .get("cookie") + .and_then(|v| v.as_str()) + .is_some_and(|s| !s.is_empty()) + || merged + .get("header") + .map(header_contains_cookie) + .unwrap_or(false); + if !has_cookie { + let cookie_header = super::cookie_store::cookies_to_header(&entry.cookies); + if !cookie_header.is_empty() { + log::info!( + "apply_stored_cookies: injecting cookie header ({} bytes) for host={}", + cookie_header.len(), + entry.host + ); + merged.insert("cookie".to_string(), Value::String(cookie_header)); + } + } else { + log::info!( + "apply_stored_cookies: cookie already set on task, leaving stored entry untouched" + ); + } + + let has_ua = merged + .get("user-agent") + .and_then(|v| v.as_str()) + .is_some_and(|s| !s.is_empty()); + if !has_ua && !entry.user_agent.is_empty() { + merged.insert( + "user-agent".to_string(), + Value::String(entry.user_agent.clone()), + ); + } + + // Touch the entry so LRU eviction prefers entries actually in use + self.cookie_store.touch(&entry.host); + } + + pub fn cookie_store(&self) -> &Arc { + &self.cookie_store + } + fn spawn_http_download(&self, task: &DownloadTask, merged_options: Map) { let gid = task.gid.clone(); // Pass the full URI list so the engine can fail over between mirrors @@ -973,6 +1076,7 @@ impl TaskManager { let events = self.events.clone(); let tasks = self.tasks.clone(); let active = self.active_downloads.clone(); + let cookie_store = self.cookie_store.clone(); let split: u32 = merged_options .get("split") @@ -1019,6 +1123,9 @@ impl TaskManager { let active_for_insert = active.clone(); let gid_for_insert = gid.clone(); + let adopted_filename: Arc>> = + Arc::new(parking_lot::Mutex::new(None)); + let adopted_filename_dl = adopted_filename.clone(); let completion_handle = tokio::spawn(async move { let ad = ActiveDownload { cancel, @@ -1028,6 +1135,7 @@ impl TaskManager { speed, connections, chunk_completed, + adopted_filename, }; active_for_insert.write().await.insert(gid_for_insert, ad); @@ -1045,6 +1153,7 @@ impl TaskManager { global_limiter, task_speed_limiter, chunk_completed_dl, + adopted_filename_dl, ) .await; @@ -1092,6 +1201,12 @@ impl TaskManager { path.display() ); task.status = TaskStatus::Complete; + // Pull the final filename off disk so the task + // record matches any Content-Disposition rename + // that happened in http.rs + if let Some(name) = path.file_name().and_then(|n| n.to_str()) { + task.out = name.to_string(); + } task.files = vec![DownloadFile { index: "1".to_string(), path: path.to_string_lossy().to_string(), @@ -1123,7 +1238,20 @@ impl TaskManager { } else { tracing::error!("Download failed for {}: {}", gid_clone, e); task.status = TaskStatus::Error; - task.error_code = Some(classify_error(&e, "http").to_string()); + let code = classify_error(&e, "http"); + // Drop a saved cookie entry that stopped + // working so the next attempt re-prompts the + // user instead of replaying stale credentials + if code == super::error_code::ErrorCode::CLOUDFLARE_CHALLENGE { + if let Some(host) = parse_cf_host(&e) { + if let Err(err) = cookie_store.remove(&host) { + log::warn!( + "[task:{gid_clone}] cookie store remove({host}) failed: {err}" + ); + } + } + } + task.error_code = Some(code.to_string()); task.error_message = Some(e); events.send(EngineEvent::DownloadError { gid: gid_clone.clone(), @@ -1180,6 +1308,7 @@ impl TaskManager { speed, connections, chunk_completed: Vec::new(), + adopted_filename: Arc::new(parking_lot::Mutex::new(None)), }; active_for_insert.write().await.insert(gid_for_insert, ad); @@ -1334,6 +1463,7 @@ impl TaskManager { speed, connections, chunk_completed: Vec::new(), + adopted_filename: Arc::new(parking_lot::Mutex::new(None)), }; active_for_insert.write().await.insert(gid_for_insert, ad); @@ -1471,6 +1601,7 @@ impl TaskManager { speed, connections, chunk_completed: Vec::new(), + adopted_filename: Arc::new(parking_lot::Mutex::new(None)), }; active_for_insert.write().await.insert(gid_for_insert, ad); @@ -1597,6 +1728,7 @@ impl TaskManager { speed, connections, chunk_completed: Vec::new(), + adopted_filename: Arc::new(parking_lot::Mutex::new(None)), }; active_for_insert.write().await.insert(gid_for_insert, ad); @@ -1708,6 +1840,19 @@ impl TaskManager { task.download_speed = ad.speed.load(Ordering::Relaxed); task.connections = ad.connections.load(Ordering::Relaxed); + // Pick up a filename the engine learned from + // Content-Disposition mid-download. The UI reads task + // names from files[0].path, so we sync both fields to + // keep the display in step with the .part on disk + if let Some(name) = ad.adopted_filename.lock().clone() { + if !name.is_empty() && task.out != name { + task.out = name.clone(); + if let Some(f) = task.files.first_mut() { + f.path = format!("{}/{}", task.dir, name); + } + } + } + // split chunk progress for multi-thread HTTP // Only populate when actually using multiple connections; // single-connection fallback leaves chunk_completed at zero. @@ -2087,6 +2232,60 @@ impl TaskManager { Ok(()) } + /// Replace an HTTP task's credentials with freshly imported ones and + /// resume it. Called from the Cloudflare-recovery flow after the user + /// solves a challenge in their browser. Either `cookie` or + /// `user_agent` may be empty to leave that field unchanged + pub async fn retry_with_cookies( + &self, + gid: &str, + cookie: Option, + user_agent: Option, + ) -> Result<(), String> { + log::info!("[task:{}] Retrying with imported cookies", gid); + let was_active; + { + let mut tasks = self.tasks.write().await; + let task = tasks + .iter_mut() + .find(|t| t.gid == gid) + .ok_or_else(|| format!("Task {} not found", gid))?; + if task.kind != TaskKind::Http { + return Err(format!("Task {} is not an HTTP task", gid)); + } + if let Some(c) = cookie { + if !c.is_empty() { + task.options.insert("cookie".to_string(), Value::String(c)); + } + } + if let Some(ua) = user_agent { + if !ua.is_empty() { + task.options + .insert("user-agent".to_string(), Value::String(ua)); + } + } + // Clear stale error state + task.error_code = None; + task.error_message = None; + was_active = task.status == TaskStatus::Active; + task.status = TaskStatus::Waiting; + } + + if was_active { + // Cancel any in-flight worker so the new options take effect + let active = self.active_downloads.read().await; + if let Some(ad) = active.get(gid) { + ad.cancel.store(true, Ordering::Relaxed); + ad.cancel_token.cancel(); + } + } + self.try_start_next().await; + self.events.send(EngineEvent::DownloadStart { + gid: gid.to_string(), + }); + Ok(()) + } + pub async fn remove(&self, gid: &str) -> Result<(), String> { log::info!("[task:{}] Removing", gid); // Cancel any active download @@ -2829,6 +3028,19 @@ fn bool_str(b: bool) -> String { mod tests { use super::*; + #[test] + fn parse_cf_host_extracts_host_from_marker() { + assert_eq!( + parse_cf_host("[cloudflare-challenge] host=www.spigotmc.org status=403").as_deref(), + Some("www.spigotmc.org") + ); + } + + #[test] + fn parse_cf_host_returns_none_when_absent() { + assert!(parse_cf_host("HTTP error: 403").is_none()); + } + // -- infer_m3u8_output_name -- #[test] @@ -2901,6 +3113,7 @@ mod tests { let options = EngineOptions::from_config(&Map::new(), &Map::new()); let events = EventBroadcaster::new(16); let global_speed_limiter = Arc::new(SpeedLimiter::new(0)); + let cookie_store = Arc::new(CookieStore::new(dir.path())); TaskManager { tasks: Arc::new(RwLock::new(tasks)), @@ -2912,6 +3125,7 @@ mod tests { session, torrent_engine: Arc::new(RwLock::new(None)), global_speed_limiter, + cookie_store, } } diff --git a/src-tauri/risuko-engine/src/engine/mod.rs b/src-tauri/risuko-engine/src/engine/mod.rs index 790c49cc..5f6d4b86 100644 --- a/src-tauri/risuko-engine/src/engine/mod.rs +++ b/src-tauri/risuko-engine/src/engine/mod.rs @@ -23,6 +23,7 @@ pub mod youtube; // Legacy P2P / IPC protocol stacks pub mod adc; +pub mod cookie_store; pub mod g2; pub mod gift; pub mod gnutella; diff --git a/src-tauri/risuko-engine/src/engine/upload/manager.rs b/src-tauri/risuko-engine/src/engine/upload/manager.rs index 7fd66a01..1020de11 100644 --- a/src-tauri/risuko-engine/src/engine/upload/manager.rs +++ b/src-tauri/risuko-engine/src/engine/upload/manager.rs @@ -810,8 +810,7 @@ mod tests { use tempfile::TempDir; struct UploadTestCtx { - #[allow(dead_code)] - dir: TempDir, + _dir: TempDir, mgr: UploadSinkManager, } @@ -821,7 +820,7 @@ mod tests { Arc::new(FileStorage::new(dir.path().to_path_buf())); let event_sink: Arc = Arc::new(NoopEventSink); let mgr = UploadSinkManager::new(storage, event_sink); - UploadTestCtx { dir, mgr } + UploadTestCtx { _dir: dir, mgr } } fn sftp_config() -> SinkConfig { diff --git a/src-tauri/risuko-engine/tests/http_features.rs b/src-tauri/risuko-engine/tests/http_features.rs index 19d3f81c..b61505b3 100644 --- a/src-tauri/risuko-engine/tests/http_features.rs +++ b/src-tauri/risuko-engine/tests/http_features.rs @@ -209,6 +209,7 @@ async fn mirror_failover_with_checksum_verify() { gl, tl, cc, + std::sync::Arc::new(parking_lot::Mutex::new(None)), ) .await .expect("download should succeed via failover to good mirror"); @@ -241,8 +242,20 @@ async fn checksum_mismatch_deletes_file() { let (total, completed, speed, cancelled, conns, ct, gl, tl, cc) = dummy_state(); let err = run_http_download_multi( - &uris, &dir, "file.bin", &options, total, completed, speed, cancelled, conns, ct, gl, tl, + &uris, + &dir, + "file.bin", + &options, + total, + completed, + speed, + cancelled, + conns, + ct, + gl, + tl, cc, + std::sync::Arc::new(parking_lot::Mutex::new(None)), ) .await .expect_err("checksum mismatch must fail"); @@ -283,3 +296,266 @@ async fn cookie_jar_loaded_from_netscape_file() { )]); assert!(opts.get("load-cookies").and_then(|v| v.as_str()).is_some()); } + +async fn handle_with_disposition( + req: Request, + payload: Arc>, + filename: &'static str, +) -> Result>, Infallible> { + let len = payload.len() as u64; + let cd = format!("attachment; filename=\"{filename}\""); + if let Some(range) = req.headers().get(hyper::header::RANGE) { + if let Ok(s) = range.to_str() { + if let Some(rest) = s.strip_prefix("bytes=") { + let mut parts = rest.split('-'); + let start: u64 = parts.next().unwrap_or("0").parse().unwrap_or(0); + let end_str = parts.next().unwrap_or(""); + let end: u64 = if end_str.is_empty() { + len - 1 + } else { + end_str.parse().unwrap_or(len - 1) + }; + let end = end.min(len - 1); + let slice = payload[start as usize..=end as usize].to_vec(); + return Ok(Response::builder() + .status(StatusCode::PARTIAL_CONTENT) + .header("Accept-Ranges", "bytes") + .header("Content-Length", slice.len().to_string()) + .header("Content-Range", format!("bytes {start}-{end}/{len}")) + .header("Content-Disposition", cd) + .body(Full::new(Bytes::from(slice))) + .unwrap()); + } + } + } + Ok(Response::builder() + .status(StatusCode::OK) + .header("Accept-Ranges", "bytes") + .header("Content-Length", len.to_string()) + .header("Content-Disposition", cd) + .body(Full::new(Bytes::from(payload.as_ref().clone()))) + .unwrap()) +} + +async fn spawn_disposition_server(payload: Arc>, filename: &'static str) -> SocketAddr { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + tokio::spawn(async move { + loop { + let (stream, _) = match listener.accept().await { + Ok(p) => p, + Err(_) => return, + }; + let payload = payload.clone(); + tokio::spawn(async move { + let io = TokioIo::new(stream); + let _ = http1::Builder::new() + .serve_connection( + io, + service_fn(move |req| { + handle_with_disposition(req, payload.clone(), filename) + }), + ) + .await; + }); + } + }); + addr +} + +#[tokio::test] +async fn adopts_filename_from_content_disposition() { + // Server advertises filename="StoragePeek.jar", URL ends in /download + let payload = Arc::new(make_payload()); + let addr = spawn_disposition_server(payload.clone(), "StoragePeek.jar").await; + tokio::task::yield_now().await; + + let tmp = tempfile::tempdir().unwrap(); + let dir = tmp.path().to_string_lossy().to_string(); + let uris = vec![format!("http://{}/resources/foo/download?version=42", addr)]; + let options = options_with(vec![]); + let (total, completed, speed, cancelled, conns, ct, gl, tl, cc) = dummy_state(); + let adopted = std::sync::Arc::new(parking_lot::Mutex::new(None)); + + // out is empty so the engine starts from URL inference ("download"), + // the placeholder we want overridden by Content-Disposition + let result = run_http_download_multi( + &uris, + &dir, + "", + &options, + total, + completed, + speed, + cancelled, + conns, + ct, + gl, + tl, + cc, + adopted.clone(), + ) + .await + .expect("download should succeed"); + + let final_name = result + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or("") + .to_string(); + assert!( + final_name.starts_with("StoragePeek.jar"), + "expected StoragePeek.jar*, got {final_name:?}" + ); + assert_eq!( + adopted.lock().as_deref(), + Some("StoragePeek.jar"), + "engine should publish the adopted filename to the manager slot" + ); +} + +#[tokio::test] +async fn keeps_user_supplied_filename_over_content_disposition() { + // When the user explicitly typed an output name, the server's + // Content-Disposition must not stomp it + let payload = Arc::new(make_payload()); + let addr = spawn_disposition_server(payload.clone(), "ServerSays.jar").await; + tokio::task::yield_now().await; + + let tmp = tempfile::tempdir().unwrap(); + let dir = tmp.path().to_string_lossy().to_string(); + let uris = vec![format!("http://{}/foo", addr)]; + let options = options_with(vec![]); + let (total, completed, speed, cancelled, conns, ct, gl, tl, cc) = dummy_state(); + let adopted = std::sync::Arc::new(parking_lot::Mutex::new(None)); + + let result = run_http_download_multi( + &uris, + &dir, + "MyChoice.jar", + &options, + total, + completed, + speed, + cancelled, + conns, + ct, + gl, + tl, + cc, + adopted.clone(), + ) + .await + .expect("download should succeed"); + + let final_name = result + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or("") + .to_string(); + assert!( + final_name.starts_with("MyChoice.jar"), + "user-supplied name must win, got {final_name:?}" + ); + assert!( + adopted.lock().is_none(), + "user-supplied name must not trigger an adoption notification" + ); +} + +async fn handle_chunked_disposition( + _req: Request, + payload: Arc>, + filename: &'static str, +) -> Result>, Infallible> { + // Simulates an app server like Spigot: serves the file with a + // Content-Disposition filename but no Accept-Ranges and no + // Content-Length, forcing the engine into the streaming path + let cd = format!("attachment; filename=\"{filename}\""); + Ok(Response::builder() + .status(StatusCode::OK) + .header("Content-Disposition", cd) + .header("Content-Type", "application/java-archive") + .body(Full::new(Bytes::from(payload.as_ref().clone()))) + .unwrap()) +} + +async fn spawn_chunked_disposition_server( + payload: Arc>, + filename: &'static str, +) -> SocketAddr { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + tokio::spawn(async move { + loop { + let (stream, _) = match listener.accept().await { + Ok(p) => p, + Err(_) => return, + }; + let payload = payload.clone(); + tokio::spawn(async move { + let io = TokioIo::new(stream); + let _ = http1::Builder::new() + .serve_connection( + io, + service_fn(move |req| { + handle_chunked_disposition(req, payload.clone(), filename) + }), + ) + .await; + }); + } + }); + addr +} + +#[tokio::test] +async fn adopts_filename_when_server_doesnt_support_ranges() { + // Spigot's case: server returns the file body with + // Content-Disposition but no range support. The engine should still + // pick up the filename rather than discarding it + let payload = Arc::new(make_payload()); + let addr = spawn_chunked_disposition_server(payload.clone(), "StoragePeek.jar").await; + tokio::task::yield_now().await; + + let tmp = tempfile::tempdir().unwrap(); + let dir = tmp.path().to_string_lossy().to_string(); + let uris = vec![format!("http://{}/resources/foo/download?version=1", addr)]; + let options = options_with(vec![]); + let (total, completed, speed, cancelled, conns, ct, gl, tl, cc) = dummy_state(); + let adopted = std::sync::Arc::new(parking_lot::Mutex::new(None)); + + let result = run_http_download_multi( + &uris, + &dir, + "", + &options, + total, + completed, + speed, + cancelled, + conns, + ct, + gl, + tl, + cc, + adopted.clone(), + ) + .await + .expect("download should succeed"); + + let final_name = result + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or("") + .to_string(); + assert!( + final_name.starts_with("StoragePeek.jar"), + "expected StoragePeek.jar*, got {final_name:?}" + ); + assert_eq!( + adopted.lock().as_deref(), + Some("StoragePeek.jar"), + "engine should publish the adopted filename even without range support" + ); +} diff --git a/src-tauri/src/commands/cookie_cmds.rs b/src-tauri/src/commands/cookie_cmds.rs new file mode 100644 index 00000000..bfef35cf --- /dev/null +++ b/src-tauri/src/commands/cookie_cmds.rs @@ -0,0 +1,326 @@ +use risuko_cookies::{cookies_for_url, list_browsers, BrowserInfo, HostCookies}; +use risuko_engine::engine; +use risuko_engine::engine::cookie_store::{cookies_to_header, CookieEntry, StoredCookie}; +use serde::{Deserialize, Serialize}; +use std::time::Duration; +use tokio::io::{AsyncReadExt, AsyncWriteExt}; +use tokio::net::TcpListener; +use tokio::time::timeout; + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ImportedCookieView { + pub name: String, + pub value: String, + pub domain: String, + pub path: String, + pub secure: bool, + pub http_only: bool, + pub expires: Option, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ImportedCookies { + pub host: String, + pub user_agent: String, + pub cookie_header: String, + pub count: usize, + /// True when the imported cookies include a `cf_clearance` token. The + /// renderer uses this to warn before retrying, since a CF-blocked + /// site will reject the next request immediately without it + pub has_cf_clearance: bool, + /// Names of imported cookies (values omitted). Helpful for diagnosing + /// "import succeeded but request still blocked" cases + pub cookie_names: Vec, + /// Full cookie list with values, surfaced to the dialog so the user + /// can confirm what's being sent. Local-IPC only + pub cookies: Vec, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct CookieEntryView { + pub host: String, + pub browser_id: String, + pub user_agent: String, + pub cookie_count: usize, + pub imported_at: u64, + pub last_validated_at: u64, +} + +impl From<&CookieEntry> for CookieEntryView { + fn from(e: &CookieEntry) -> Self { + Self { + host: e.host.clone(), + browser_id: e.browser_id.clone(), + user_agent: e.user_agent.clone(), + cookie_count: e.cookies.len(), + imported_at: e.imported_at, + last_validated_at: e.last_validated_at, + } + } +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct RetryWithCookiesPayload { + pub cookie: Option, + pub user_agent: Option, +} + +#[tauri::command] +pub async fn list_browsers_cmd() -> Vec { + list_browsers().await +} + +#[tauri::command] +pub async fn import_browser_cookies( + browser: String, + url: String, + persist: Option, +) -> Result { + log::info!("import_browser_cookies: browser={browser} url={url}"); + let HostCookies { + host, + user_agent, + cookies, + } = cookies_for_url(&browser, &url).await?; + + log::info!( + "import_browser_cookies: rookie returned {} cookie(s) for host={host} (browser={browser})", + cookies.len() + ); + for c in cookies.iter() { + log::debug!( + " imported cookie name={} domain={} path={} secure={} http_only={} value_len={}", + c.name, + c.domain, + c.path, + c.secure, + c.http_only, + c.value.len() + ); + } + + if cookies.is_empty() { + return Err(format!("no cookies found in {browser} for {host}")); + } + + let stored: Vec = cookies + .iter() + .map(|c| StoredCookie { + name: c.name.clone(), + value: c.value.clone(), + domain: c.domain.clone(), + path: c.path.clone(), + secure: c.secure, + http_only: c.http_only, + expires: c.expires, + }) + .collect(); + let cookie_header = cookies_to_header(&stored); + let has_cf_clearance = stored + .iter() + .any(|c| c.name.eq_ignore_ascii_case("cf_clearance")); + let cookie_names: Vec = stored.iter().map(|c| c.name.clone()).collect(); + + log::info!( + "import_browser_cookies: cookie names for host={host}: {cookie_names:?} (cf_clearance present: {has_cf_clearance})" + ); + + if persist.unwrap_or(true) { + if let Some(manager) = engine::get_manager().await { + let entry = CookieEntry { + host: host.clone(), + browser_id: browser.clone(), + user_agent: user_agent.clone(), + cookies: stored.clone(), + imported_at: 0, + last_validated_at: 0, + }; + manager.cookie_store().upsert(entry)?; + } + } + + let cookies_view: Vec = stored + .iter() + .map(|c| ImportedCookieView { + name: c.name.clone(), + value: c.value.clone(), + domain: c.domain.clone(), + path: c.path.clone(), + secure: c.secure, + http_only: c.http_only, + expires: c.expires, + }) + .collect(); + + Ok(ImportedCookies { + host, + user_agent, + cookie_header, + count: cookies.len(), + has_cf_clearance, + cookie_names, + cookies: cookies_view, + }) +} + +#[tauri::command] +pub async fn list_cookie_entries() -> Result, String> { + let manager = engine::get_manager().await.ok_or("Engine not running")?; + Ok(manager + .cookie_store() + .list() + .iter() + .map(Into::into) + .collect()) +} + +#[tauri::command] +pub async fn delete_cookie_entry(host: String) -> Result { + let manager = engine::get_manager().await.ok_or("Engine not running")?; + manager.cookie_store().remove(&host) +} + +#[tauri::command] +pub async fn clear_cookie_entries() -> Result<(), String> { + let manager = engine::get_manager().await.ok_or("Engine not running")?; + manager.cookie_store().clear() +} + +#[tauri::command] +pub async fn retry_with_cookies( + gid: String, + payload: RetryWithCookiesPayload, +) -> Result<(), String> { + let manager = engine::get_manager().await.ok_or("Engine not running")?; + manager + .retry_with_cookies(&gid, payload.cookie, payload.user_agent) + .await +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct CapturedUserAgent { + pub user_agent: String, +} + +/// Open `http://127.0.0.1:/` in the user's default browser, read +/// the User-Agent header from the first GET, and return it. cf_clearance +/// validates against the UA in effect when the challenge was solved, so +/// reusing the cookie requires the matching UA +/// +/// The internal listener replies with a small HTML page that auto-closes, +/// then shuts down. Times out after 60s if nothing connects +#[tauri::command] +pub async fn capture_user_agent() -> Result { + log::info!("capture_user_agent: starting one-shot listener"); + let listener = TcpListener::bind("127.0.0.1:0") + .await + .map_err(|e| format!("bind localhost listener failed: {e}"))?; + let port = listener + .local_addr() + .map_err(|e| format!("read local addr failed: {e}"))? + .port(); + let url = format!("http://127.0.0.1:{port}/risuko-ua-capture"); + + // Open in the user's default browser. Best effort; the user can also + // paste the URL into a different browser if they prefer + if let Err(e) = open::that(&url) { + log::warn!("capture_user_agent: open::that({url}) failed: {e}"); + // Keep the listener alive in case the user pastes the URL manually + } + + // Loop accept(): browsers may prefetch favicons or do CORS preflight + // before the real GET arrives. Take the first request to + // /risuko-ua-capture that parses cleanly + let captured = timeout(Duration::from_secs(60), async { + loop { + let (mut stream, peer) = match listener.accept().await { + Ok(v) => v, + Err(e) => { + log::warn!("capture_user_agent: accept failed: {e}"); + continue; + } + }; + log::debug!("capture_user_agent: connection from {peer}"); + + let mut buf = vec![0u8; 4096]; + let mut total = 0usize; + // Read until we see end-of-headers (double CRLF). Cap at 4 KiB + let parsed: Option<(String, String)> = loop { + match timeout(Duration::from_secs(5), stream.read(&mut buf[total..])).await { + Ok(Ok(0)) => break None, + Ok(Ok(n)) => { + total += n; + if let Some(end) = find_header_end(&buf[..total]) { + break parse_request(&buf[..end]); + } + if total == buf.len() { + break None; + } + } + _ => break None, + } + }; + + // Always send a tiny HTML body so the browser tab doesn't hang + let body = "Risuko\ + \ +

User-Agent captured \u{2713}

\ +

You can close this tab and return to Risuko.

\ + "; + let resp = format!( + "HTTP/1.1 200 OK\r\nContent-Type: text/html; charset=utf-8\r\n\ + Content-Length: {}\r\nConnection: close\r\n\r\n{}", + body.len(), + body + ); + let _ = stream.write_all(resp.as_bytes()).await; + let _ = stream.shutdown().await; + + if let Some((path, ua)) = parsed { + if path.starts_with("/risuko-ua-capture") && !ua.is_empty() { + return ua; + } + } + } + }) + .await + .map_err(|_| "timed out waiting for browser to open the capture URL".to_string())?; + + log::info!("capture_user_agent: captured ua={captured}"); + Ok(CapturedUserAgent { + user_agent: captured, + }) +} + +fn find_header_end(buf: &[u8]) -> Option { + buf.windows(4).position(|w| w == b"\r\n\r\n").map(|i| i + 4) +} + +fn parse_request(buf: &[u8]) -> Option<(String, String)> { + let text = std::str::from_utf8(buf).ok()?; + let mut lines = text.split("\r\n"); + let request_line = lines.next()?; + // GET /path HTTP/1.1 + let mut parts = request_line.split_whitespace(); + let _method = parts.next()?; + let path = parts.next()?.to_string(); + + let mut user_agent = String::new(); + for line in lines { + if line.is_empty() { + break; + } + if let Some((name, value)) = line.split_once(':') { + if name.trim().eq_ignore_ascii_case("user-agent") { + user_agent = value.trim().to_string(); + } + } + } + Some((path, user_agent)) +} diff --git a/src-tauri/src/commands/engine_cmds.rs b/src-tauri/src/commands/engine_cmds.rs index a5242541..fc9bcc1c 100644 --- a/src-tauri/src/commands/engine_cmds.rs +++ b/src-tauri/src/commands/engine_cmds.rs @@ -194,13 +194,17 @@ fn infer_out_from_uri_inner(uri: &str) -> String { let decoded_candidate = crate::commands::file_cmds::percent_decode_lossy(candidate); let decoded_candidate = decoded_candidate.trim(); if decoded_candidate.is_empty() || !decoded_candidate.contains('.') { - return String::new(); + // Opaque URL like /resources/foo/download?version=N has no + // extension to hint at a name. Drop in a placeholder so the task + // carries a stable display name; the engine swaps in the real + // filename once it sees Content-Disposition on the first response + return "download".to_string(); } if decoded_candidate.contains('/') || decoded_candidate.contains('\\') { - return String::new(); + return "download".to_string(); } if decoded_candidate.starts_with('.') || decoded_candidate.ends_with('.') { - return String::new(); + return "download".to_string(); } decoded_candidate.to_string() @@ -1368,10 +1372,19 @@ mod tests { } #[test] - fn infer_out_no_extension() { + fn infer_out_no_extension_falls_back_to_download() { + // Opaque URLs with no extension hint get a generic placeholder + // so the task carries a stable display name; Content-Disposition + // takes over once the engine sees the first response assert_eq!( infer_out_from_uri_inner("http://example.com/path/noext"), - "" + "download" + ); + assert_eq!( + infer_out_from_uri_inner( + "https://www.spigotmc.org/resources/storagepeek.134712/download?version=638562" + ), + "download" ); } diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index 006a5ef5..27c174e9 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -1,6 +1,7 @@ pub mod app_cmds; pub mod completion_script_cmds; pub mod config_cmds; +pub mod cookie_cmds; pub mod engine_cmds; pub mod event_cmds; pub mod file_cmds; diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index ae9ffb0e..31cceb96 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -327,6 +327,13 @@ pub fn run() { commands::engine_cmds::multicall_engine, commands::engine_cmds::infer_out_from_uri, commands::engine_cmds::resolve_file_category, + commands::cookie_cmds::list_browsers_cmd, + commands::cookie_cmds::import_browser_cookies, + commands::cookie_cmds::list_cookie_entries, + commands::cookie_cmds::delete_cookie_entry, + commands::cookie_cmds::clear_cookie_entries, + commands::cookie_cmds::retry_with_cookies, + commands::cookie_cmds::capture_user_agent, commands::engine_cmds::list_routing_rules, commands::engine_cmds::add_routing_rule, commands::engine_cmds::update_routing_rule, diff --git a/src/renderer/api/Api.ts b/src/renderer/api/Api.ts index 1fc3a77a..8f9a83a4 100644 --- a/src/renderer/api/Api.ts +++ b/src/renderer/api/Api.ts @@ -759,4 +759,82 @@ export default class Api { vaultRemoveCredential(id: string) { return invoke("vault_remove_credential", { id }); } + + // -- Browser cookie integration -- + + listBrowsers() { + return invoke("list_browsers_cmd"); + } + + importBrowserCookies(params: { + browser: string; + url: string; + persist?: boolean; + }) { + return invoke("import_browser_cookies", params); + } + + listCookieEntries() { + return invoke("list_cookie_entries"); + } + + deleteCookieEntry(host: string) { + return invoke("delete_cookie_entry", { host }); + } + + clearCookieEntries() { + return invoke("clear_cookie_entries"); + } + + retryWithCookies(params: { + gid: string; + cookie?: string; + userAgent?: string; + }) { + const { gid, cookie, userAgent } = params; + return invoke("retry_with_cookies", { + gid, + payload: { cookie, userAgent }, + }); + } + + captureUserAgent() { + return invoke<{ userAgent: string }>("capture_user_agent"); + } +} + +export interface BrowserInfo { + id: string; + name: string; + available: boolean; + userAgent: string; +} + +export interface ImportedCookieView { + name: string; + value: string; + domain: string; + path: string; + secure: boolean; + httpOnly: boolean; + expires: number | null; +} + +export interface ImportedCookies { + host: string; + userAgent: string; + cookieHeader: string; + count: number; + hasCfClearance: boolean; + cookieNames: string[]; + cookies: ImportedCookieView[]; +} + +export interface CookieEntryView { + host: string; + browserId: string; + userAgent: string; + cookieCount: number; + importedAt: number; + lastValidatedAt: number; } diff --git a/src/renderer/api/index.ts b/src/renderer/api/index.ts index d59f48b6..4a71a213 100644 --- a/src/renderer/api/index.ts +++ b/src/renderer/api/index.ts @@ -2,4 +2,11 @@ import Api from "./Api"; const api = new Api(); +export type { + BrowserInfo, + CookieEntryView, + ImportedCookies, + ImportedCookieView, +} from "./Api"; + export default api; diff --git a/src/renderer/components/Native/EngineClient.vue b/src/renderer/components/Native/EngineClient.vue index d4498898..368e8687 100644 --- a/src/renderer/components/Native/EngineClient.vue +++ b/src/renderer/components/Native/EngineClient.vue @@ -624,6 +624,39 @@ export default { logger.error( `[Risuko] download error gid: ${gid}, #${errorCode}, ${errorMessage}`, ); + + if (normalizedErrorCode === "315") { + const errorString = String(errorMessage || ""); + const hostMatch = errorString.match(/host=([^\s]+)/); + const host = hostMatch?.[1] || ""; + const url = + (task.files || []) + .flatMap((f) => f.uris || []) + .map((u) => u.uri || "") + .find((u) => /^https?:\/\//i.test(u)) || ""; + + const appStore = useAppStore(); + if (host && appStore.cloudflareSkipHosts.includes(host)) { + this.$msg.error({ + duration: 5000, + message: this.$t("task.download-error-message", { taskName }), + }); + } else { + appStore.showCloudflareDialog({ gid, host, url, taskName }); + } + this.readCompletionScriptOverrides(gid).then((overrides) => { + invoke("run_completion_script", { + path: getTaskFullPath(task) || "", + hash: task?.infoHash || null, + status: "error", + overrides, + }).catch(() => { + /* noop */ + }); + }); + return; + } + const isMissingYtDlp = normalizedErrorCode === "540" || /yt-dlp\s+is\s+not\s+available/i.test(String(errorMessage || "")) || diff --git a/src/renderer/components/Preference/Advanced.vue b/src/renderer/components/Preference/Advanced.vue index 6175de71..b04c01b2 100644 --- a/src/renderer/components/Preference/Advanced.vue +++ b/src/renderer/components/Preference/Advanced.vue @@ -986,6 +986,69 @@ + +
+
+
+
+

{{ $t('preferences.saved-cookies') }}

+

{{ $t('preferences.saved-cookies-tips') }}

+
+ + {{ $t('preferences.saved-cookies-clear') }} + +
+
+
+ {{ $t('preferences.saved-cookies-empty') }} +
+
+ + + + + + + + + + + + + + + + + + + +
{{ $t('preferences.saved-cookies-host') }}{{ $t('preferences.saved-cookies-browser') }}{{ $t('preferences.saved-cookies-count') }}{{ $t('preferences.saved-cookies-imported') }}
{{ entry.host }}{{ entry.browserId }}{{ entry.cookieCount }}{{ formatTimestamp(entry.importedAt) }} + + {{ $t('preferences.saved-cookies-delete') }} + +
+
+
+
+
@@ -1181,6 +1244,7 @@ import { X, } from "lucide-vue-next"; import randomize from "randomatic"; +import api, { type CookieEntryView } from "@/api"; import ShowInFolder from "@/components/Native/ShowInFolder.vue"; import CredentialManager from "@/components/Preference/CredentialManager.vue"; import SubnavSwitcher from "@/components/Subnav/SubnavSwitcher.vue"; @@ -1388,7 +1452,6 @@ export default { PopoverContent, PopoverTrigger, RefreshCw, - Globe, Radio, Cable, Network, @@ -1397,6 +1460,7 @@ export default { Code, Cookie, FileKey, + Globe, FileText, KeyRound, ScrollText, @@ -1429,6 +1493,7 @@ export default { trackerSyncing: false, completionScriptTesting: false, completionScriptTestResult: "", + cookieEntries: [] as CookieEntryView[], }; }, computed: { @@ -1920,6 +1985,49 @@ export default { resetForm(_formName) { this.syncFormConfig(); }, + async refreshCookieEntries() { + try { + this.cookieEntries = await api.listCookieEntries(); + } catch (_e) { + this.cookieEntries = []; + } + }, + async handleDeleteCookieEntry(host: string) { + try { + await api.deleteCookieEntry(host); + await this.refreshCookieEntries(); + } catch (e) { + this.$msg.error(String(e)); + } + }, + async handleClearAllCookieEntries() { + const { confirmed } = await confirm({ + message: this.$t("preferences.saved-cookies-confirm-clear"), + title: this.$t("preferences.saved-cookies-clear"), + kind: "warning", + confirmText: this.$t("app.yes"), + cancelText: this.$t("app.no"), + }); + if (!confirmed) { + return; + } + try { + await api.clearCookieEntries(); + await this.refreshCookieEntries(); + } catch (e) { + this.$msg.error(String(e)); + } + }, + formatTimestamp(seconds: number): string { + if (!seconds) { + return "-"; + } + const d = new Date(seconds * 1000); + return d.toLocaleString(); + }, + }, + mounted() { + this.refreshCookieEntries(); }, async beforeRouteLeave(to, _from) { changedConfig.advanced = diffConfig(this.formOriginal, this.form); diff --git a/src/renderer/components/Task/AddTask.vue b/src/renderer/components/Task/AddTask.vue index 3814e799..36abb6ce 100644 --- a/src/renderer/components/Task/AddTask.vue +++ b/src/renderer/components/Task/AddTask.vue @@ -161,7 +161,14 @@ />
- +