From d645b9cc468baed7e042930dd7f89181f055d88c Mon Sep 17 00:00:00 2001 From: YueMiyuki <76854136+YueMiyuki@users.noreply.github.com> Date: Sun, 7 Jun 2026 23:58:07 +0800 Subject: [PATCH 01/10] refactor(core): Refactor rust functions and front end components --- .github/workflows/release.yml | 41 +- packages/risuko-app/bin.js | 69 +- packages/risuko-app/package.json | 6 +- .../risuko-cli/npm/darwin-arm64/package.json | 5 +- .../risuko-cli/npm/darwin-x64/package.json | 5 +- .../npm/linux-arm64-gnu/package.json | 5 +- .../risuko-cli/npm/linux-x64-gnu/package.json | 5 +- .../npm/win32-arm64-msvc/package.json | 5 +- .../npm/win32-x64-msvc/package.json | 5 +- packages/risuko-cli/package.json | 14 +- .../risuko-js/npm/darwin-arm64/package.json | 5 +- .../risuko-js/npm/darwin-x64/package.json | 5 +- .../npm/linux-arm64-gnu/package.json | 5 +- .../risuko-js/npm/linux-x64-gnu/package.json | 5 +- .../npm/win32-arm64-msvc/package.json | 5 +- .../risuko-js/npm/win32-x64-msvc/package.json | 5 +- packages/risuko-js/package.json | 14 +- pnpm-lock.yaml | 142 +--- scripts/bootstrap-npm-local.mjs | 43 +- scripts/build-web.mjs | 12 +- scripts/build.mjs | 5 +- scripts/dev.mjs | 5 +- scripts/ensure-package-artifacts.mjs | 32 + src-tauri/Cargo.lock | 100 +-- src-tauri/Cargo.toml | 2 +- .../java/app/risuko/mobile/MainActivity.kt | 26 +- .../app/src/main/res/xml/file_paths.xml | 2 +- src-tauri/risuko-bt/src/bencode.rs | 4 +- src-tauri/risuko-bt/src/core/merkle.rs | 5 - src-tauri/risuko-bt/src/dht.rs | 60 +- src-tauri/risuko-bt/src/lsd.rs | 15 +- src-tauri/risuko-bt/src/magnet.rs | 2 - src-tauri/risuko-bt/src/peer.rs | 2 - src-tauri/risuko-bt/src/peer/connection.rs | 18 +- src-tauri/risuko-bt/src/peer/state.rs | 118 --- .../risuko-bt/src/piece/chunk_tracker.rs | 16 - .../risuko-bt/src/piece/piece_tracker.rs | 139 +++- src-tauri/risuko-bt/src/session.rs | 3 + src-tauri/risuko-bt/src/storage.rs | 17 +- src-tauri/risuko-bt/src/torrent.rs | 437 ++++++++--- src-tauri/risuko-bt/src/torrent/stats.rs | 3 +- src-tauri/risuko-bt/src/upnp.rs | 65 +- src-tauri/risuko-bt/src/utp/socket.rs | 35 +- src-tauri/risuko-bt/src/utp/stream.rs | 29 +- src-tauri/risuko-bt/src/wire/extended.rs | 26 +- src-tauri/risuko-bt/src/wire/handshake.rs | 23 +- src-tauri/risuko-bt/src/wire/mse.rs | 38 +- src-tauri/risuko-cli/src/commands.rs | 119 +-- src-tauri/risuko-cli/src/progress.rs | 8 +- src-tauri/risuko-cli/src/rpc_client.rs | 15 +- src-tauri/risuko-engine/Cargo.toml | 3 + src-tauri/risuko-engine/src/engine/adc/mod.rs | 3 + .../risuko-engine/src/engine/cookie_store.rs | 10 +- .../risuko-engine/src/engine/ed2k/chunks.rs | 28 +- .../risuko-engine/src/engine/ed2k/download.rs | 14 +- .../risuko-engine/src/engine/ed2k/peer.rs | 21 +- .../risuko-engine/src/engine/error_code.rs | 56 +- .../src/engine/ftp/ftp_download.rs | 235 ++++-- .../src/engine/ftp/sftp_download.rs | 279 +++++-- src-tauri/risuko-engine/src/engine/http.rs | 101 +-- .../risuko-engine/src/engine/m3u8/download.rs | 202 ++++- .../risuko-engine/src/engine/m3u8/segment.rs | 150 ++-- src-tauri/risuko-engine/src/engine/manager.rs | 716 ++++++++++-------- src-tauri/risuko-engine/src/engine/media.rs | 3 +- .../risuko-engine/src/engine/p2p_tests.rs | 11 +- src-tauri/risuko-engine/src/engine/rpc.rs | 36 +- src-tauri/risuko-engine/src/engine/rss/mod.rs | 45 +- .../src/engine/rss/rule_engine.rs | 65 +- src-tauri/risuko-engine/src/engine/task.rs | 26 +- src-tauri/risuko-engine/src/engine/torrent.rs | 2 +- .../risuko-engine/src/engine/upload/ftp.rs | 23 +- .../src/engine/upload/manager.rs | 63 +- .../risuko-engine/src/engine/upload/s3.rs | 86 ++- .../risuko-engine/src/engine/upload/sftp.rs | 2 +- .../risuko-engine/src/engine/upload/webdav.rs | 10 +- src-tauri/risuko-http/src/client.rs | 1 + src-tauri/risuko-http/src/connector.rs | 234 ++++-- src-tauri/risuko-http/src/response.rs | 14 +- src-tauri/src/cli/commands.rs | 74 +- src-tauri/src/cli/rpc_client.rs | 15 +- src-tauri/src/commands/app_cmds.rs | 5 - src-tauri/src/commands/engine_cmds.rs | 6 +- src-tauri/src/lib.rs | 1 - src-tauri/src/state.rs | 2 - src-tauri/tauri.conf.json | 2 +- src/renderer/api/Api.ts | 37 +- src/renderer/api/index.ts | 1 - .../components/Native/DynamicTray.vue | 6 +- .../components/Native/EngineClient.vue | 8 +- .../components/Preference/Advanced.vue | 84 +- src/renderer/components/Preference/Basic.vue | 87 +-- .../components/Preference/CloudSinks.vue | 2 - .../Preference/HistoryDirectory.vue | 11 +- src/renderer/components/Rss/Index.vue | 8 +- src/renderer/components/Rss/ItemList.vue | 18 +- src/renderer/components/Task/AddTask.vue | 12 +- src/renderer/components/Task/Index.vue | 36 +- src/renderer/components/Task/TaskActions.vue | 24 +- .../components/Task/TaskItemActions.vue | 8 +- src/renderer/components/Task/TaskStatus.vue | 4 - src/renderer/components/TaskDetail/Index.vue | 11 +- .../components/TaskDetail/TaskActivity.vue | 30 - src/renderer/pages/index/App.vue | 28 +- src/renderer/store/rss.ts | 45 +- src/renderer/store/task.ts | 44 +- src/renderer/store/uploadSink.ts | 20 +- src/renderer/styles/global.css | 14 +- src/renderer/workers/tray.worker.ts | 5 - src/shared/locales/ar/preferences.ts | 30 +- src/shared/locales/bg/preferences.ts | 30 +- src/shared/locales/ca/preferences.ts | 30 +- src/shared/locales/de/preferences.ts | 30 +- src/shared/locales/el/preferences.ts | 30 +- src/shared/locales/es/preferences.ts | 30 +- src/shared/locales/fa/preferences.ts | 30 +- src/shared/locales/fr/preferences.ts | 30 +- src/shared/locales/hu/preferences.ts | 30 +- src/shared/locales/id/preferences.ts | 30 +- src/shared/locales/index.ts | 2 +- src/shared/locales/it/preferences.ts | 30 +- src/shared/locales/ja/preferences.ts | 30 +- src/shared/locales/ko/preferences.ts | 30 +- src/shared/locales/nb/preferences.ts | 30 +- src/shared/locales/nl/preferences.ts | 30 +- src/shared/locales/pl/preferences.ts | 30 +- src/shared/locales/pt-BR/preferences.ts | 30 +- src/shared/locales/ro/preferences.ts | 30 +- src/shared/locales/ru/preferences.ts | 30 +- src/shared/locales/th/preferences.ts | 30 +- src/shared/locales/tr/preferences.ts | 30 +- src/shared/locales/uk/preferences.ts | 30 +- src/shared/locales/vi/preferences.ts | 30 +- src/shared/locales/zh-CN/preferences.ts | 30 +- src/shared/locales/zh-TW/preferences.ts | 30 +- src/shared/types/config.ts | 32 +- src/shared/types/rss.ts | 10 +- src/shared/utils/index.ts | 49 +- src/shared/utils/tray.ts | 8 +- 138 files changed, 3435 insertions(+), 2273 deletions(-) create mode 100644 scripts/ensure-package-artifacts.mjs delete mode 100644 src-tauri/risuko-bt/src/peer/state.rs diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index fa3f170c..04bbdff5 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -276,12 +276,26 @@ jobs: "APP_ASSET=$asset" | Out-File -Append -FilePath $env:GITHUB_ENV -Encoding utf8 "APP_PLATFORM_LABEL=${os}-${arch}" | Out-File -Append -FilePath $env:GITHUB_ENV -Encoding utf8 + - name: Write app bundle SHA-256 sidecar + shell: bash + run: | + set -euo pipefail + node - <<'NODE' + const fs = require("node:fs"); + const crypto = require("node:crypto"); + const asset = process.env.APP_ASSET; + const digest = crypto.createHash("sha256").update(fs.readFileSync(asset)).digest("hex"); + fs.writeFileSync(`${asset}.sha256`, `${digest} ${asset}\n`); + NODE + - name: Upload standardized app bundle to GitHub Release if: startsWith(github.ref, 'refs/tags/') uses: softprops/action-gh-release@v2 with: tag_name: ${{ github.ref_name }} - files: ${{ env.APP_ASSET }} + files: | + ${{ env.APP_ASSET }} + ${{ env.APP_ASSET }}.sha256 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} @@ -290,7 +304,9 @@ jobs: uses: actions/upload-artifact@v4 with: name: app-${{ env.APP_PLATFORM_LABEL }} - path: ${{ env.APP_ASSET }} + path: | + ${{ env.APP_ASSET }} + ${{ env.APP_ASSET }}.sha256 # ── Package Windows portable exe (raw unpacked binary) ── # Naming: Risuko_{version}_win32_{arch}.portable.exe @@ -316,12 +332,27 @@ jobs: Copy-Item $exe $asset "PORTABLE_ASSET=$asset" | Out-File -Append -FilePath $env:GITHUB_ENV -Encoding utf8 + - name: Write Windows portable SHA-256 sidecar + if: runner.os == 'Windows' + shell: bash + run: | + set -euo pipefail + node - <<'NODE' + const fs = require("node:fs"); + const crypto = require("node:crypto"); + const asset = process.env.PORTABLE_ASSET; + const digest = crypto.createHash("sha256").update(fs.readFileSync(asset)).digest("hex"); + fs.writeFileSync(`${asset}.sha256`, `${digest} ${asset}\n`); + NODE + - name: Upload Windows portable exe to GitHub Release if: runner.os == 'Windows' && startsWith(github.ref, 'refs/tags/') uses: softprops/action-gh-release@v2 with: tag_name: ${{ github.ref_name }} - files: ${{ env.PORTABLE_ASSET }} + files: | + ${{ env.PORTABLE_ASSET }} + ${{ env.PORTABLE_ASSET }}.sha256 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} @@ -330,7 +361,9 @@ jobs: uses: actions/upload-artifact@v4 with: name: app-portable-${{ env.APP_PLATFORM_LABEL }} - path: ${{ env.PORTABLE_ASSET }} + path: | + ${{ env.PORTABLE_ASSET }} + ${{ env.PORTABLE_ASSET }}.sha256 # ── Build standalone risuko-cli binary ── - name: Build risuko-cli binary diff --git a/packages/risuko-app/bin.js b/packages/risuko-app/bin.js index eeedf202..a3524fe4 100644 --- a/packages/risuko-app/bin.js +++ b/packages/risuko-app/bin.js @@ -16,6 +16,7 @@ const fs = require("node:fs"); const path = require("node:path"); const https = require("node:https"); +const crypto = require("node:crypto"); const { execFileSync, spawn } = require("node:child_process"); const PKG_VERSION = require("./package.json").version; @@ -139,10 +140,13 @@ function download(url, destPath) { if ( res.statusCode === 301 || res.statusCode === 302 || - res.statusCode === 307 + res.statusCode === 303 || + res.statusCode === 307 || + res.statusCode === 308 ) { req.destroy(); - return download(res.headers.location, destPath).then(resolve, reject); + const nextUrl = new URL(res.headers.location, url).toString(); + return download(nextUrl, destPath).then(resolve, reject); } if (res.statusCode !== 200) { req.destroy(); @@ -189,6 +193,56 @@ function download(url, destPath) { }); } +function downloadText(url) { + const tmpDir = fs.mkdtempSync( + path.join(require("node:os").tmpdir(), "risuko-launcher-"), + ); + const tmpPath = path.join(tmpDir, "download.txt"); + return download(url, tmpPath) + .then(() => fs.readFileSync(tmpPath, "utf8")) + .finally(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); +} + +function sha256File(filePath) { + const hash = crypto.createHash("sha256"); + const input = fs.createReadStream(filePath); + return new Promise((resolve, reject) => { + input.on("data", (chunk) => hash.update(chunk)); + input.on("error", reject); + input.on("end", () => resolve(hash.digest("hex"))); + }); +} + +function parseExpectedSha256(text, assetName) { + for (const line of text.split(/\r?\n/)) { + const trimmed = line.trim(); + if (!trimmed) { + continue; + } + const [hash, fileName] = trimmed.split(/\s+/, 2); + if ( + /^[a-fA-F0-9]{64}$/.test(hash) && + (!fileName || fileName === assetName) + ) { + return hash.toLowerCase(); + } + } + throw new Error(`No SHA-256 digest found for ${assetName}`); +} + +async function verifySha256(assetPath, checksumUrl, assetName) { + const checksumText = await downloadText(checksumUrl); + const expected = parseExpectedSha256(checksumText, assetName); + const actual = await sha256File(assetPath); + if (actual !== expected) { + throw new Error( + `SHA-256 mismatch for ${assetName}: expected ${expected}, got ${actual}`, + ); + } +} + function fmtBytes(n) { if (n >= 1024 * 1024) { return `${(n / 1024 / 1024).toFixed(1)} MB`; @@ -242,6 +296,7 @@ async function main() { if (!isCached || noCache) { const assetUrl = `https://github.com/${REPO}/releases/download/v${version}/${entry.asset}`; + const checksumUrl = `${assetUrl}.sha256`; const assetPath = path.join(cacheDir, entry.asset); const tmpPath = `${assetPath}.download`; @@ -250,8 +305,14 @@ async function main() { console.log(`Downloading Risuko v${version} for ${platform}/${arch}…`); console.log(` From: ${assetUrl}`); - await download(assetUrl, tmpPath); - fs.renameSync(tmpPath, assetPath); + try { + await download(assetUrl, tmpPath); + await verifySha256(tmpPath, checksumUrl, entry.asset); + fs.renameSync(tmpPath, assetPath); + } catch (err) { + fs.rmSync(tmpPath, { force: true }); + throw err; + } console.log(" Extracting…"); extract(entry, assetPath, cacheDir); console.log(` Cached to: ${cacheDir}`); diff --git a/packages/risuko-app/package.json b/packages/risuko-app/package.json index b77cd84e..4b98625b 100644 --- a/packages/risuko-app/package.json +++ b/packages/risuko-app/package.json @@ -1,7 +1,8 @@ { "name": "@risuko/app", - "version": "0.3.6", + "version": "0.4.0", "description": "Risuko download manager — launches the desktop app, downloading it from GitHub Releases on first run", + "main": "index.js", "license": "MIT", "repository": { "type": "git", @@ -20,7 +21,8 @@ "risuko-app": "bin.js" }, "files": [ - "bin.js" + "bin.js", + "index.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 3365d911..6bc0f6dd 100644 --- a/packages/risuko-cli/npm/darwin-arm64/package.json +++ b/packages/risuko-cli/npm/darwin-arm64/package.json @@ -1,6 +1,6 @@ { "name": "@risuko/cli-darwin-arm64", - "version": "0.3.6", + "version": "0.4.0", "description": "Risuko CLI binary for macOS ARM64", "repository": { "type": "git", @@ -14,6 +14,9 @@ "cpu": [ "arm64" ], + "scripts": { + "prepack": "node ../../../../scripts/ensure-package-artifacts.mjs" + }, "files": [ "risuko" ] diff --git a/packages/risuko-cli/npm/darwin-x64/package.json b/packages/risuko-cli/npm/darwin-x64/package.json index b93a4256..67b84d31 100644 --- a/packages/risuko-cli/npm/darwin-x64/package.json +++ b/packages/risuko-cli/npm/darwin-x64/package.json @@ -1,6 +1,6 @@ { "name": "@risuko/cli-darwin-x64", - "version": "0.3.6", + "version": "0.4.0", "description": "Risuko CLI binary for macOS x64", "repository": { "type": "git", @@ -14,6 +14,9 @@ "cpu": [ "x64" ], + "scripts": { + "prepack": "node ../../../../scripts/ensure-package-artifacts.mjs" + }, "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 6f9d7a43..6201c37c 100644 --- a/packages/risuko-cli/npm/linux-arm64-gnu/package.json +++ b/packages/risuko-cli/npm/linux-arm64-gnu/package.json @@ -1,6 +1,6 @@ { "name": "@risuko/cli-linux-arm64-gnu", - "version": "0.3.6", + "version": "0.4.0", "description": "Risuko CLI binary for Linux ARM64", "repository": { "type": "git", @@ -14,6 +14,9 @@ "cpu": [ "arm64" ], + "scripts": { + "prepack": "node ../../../../scripts/ensure-package-artifacts.mjs" + }, "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 509c36a7..bcc7c042 100644 --- a/packages/risuko-cli/npm/linux-x64-gnu/package.json +++ b/packages/risuko-cli/npm/linux-x64-gnu/package.json @@ -1,6 +1,6 @@ { "name": "@risuko/cli-linux-x64-gnu", - "version": "0.3.6", + "version": "0.4.0", "description": "Risuko CLI binary for Linux x64", "repository": { "type": "git", @@ -14,6 +14,9 @@ "cpu": [ "x64" ], + "scripts": { + "prepack": "node ../../../../scripts/ensure-package-artifacts.mjs" + }, "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 3b0640ee..00514712 100644 --- a/packages/risuko-cli/npm/win32-arm64-msvc/package.json +++ b/packages/risuko-cli/npm/win32-arm64-msvc/package.json @@ -1,6 +1,6 @@ { "name": "@risuko/cli-win32-arm64-msvc", - "version": "0.3.6", + "version": "0.4.0", "description": "Risuko CLI binary for Windows ARM64", "repository": { "type": "git", @@ -14,6 +14,9 @@ "cpu": [ "arm64" ], + "scripts": { + "prepack": "node ../../../../scripts/ensure-package-artifacts.mjs" + }, "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 e945407c..12cae4df 100644 --- a/packages/risuko-cli/npm/win32-x64-msvc/package.json +++ b/packages/risuko-cli/npm/win32-x64-msvc/package.json @@ -1,6 +1,6 @@ { "name": "@risuko/cli-win32-x64-msvc", - "version": "0.3.6", + "version": "0.4.0", "description": "Risuko CLI binary for Windows x64", "repository": { "type": "git", @@ -14,6 +14,9 @@ "cpu": [ "x64" ], + "scripts": { + "prepack": "node ../../../../scripts/ensure-package-artifacts.mjs" + }, "files": [ "risuko.exe" ] diff --git a/packages/risuko-cli/package.json b/packages/risuko-cli/package.json index 5ba4df9c..0403e2fa 100644 --- a/packages/risuko-cli/package.json +++ b/packages/risuko-cli/package.json @@ -1,6 +1,6 @@ { "name": "@risuko/cli", - "version": "0.3.6", + "version": "0.4.0", "description": "Risuko download engine CLI — multi-protocol downloads (HTTP, BitTorrent, ED2K, M3U8, FTP/SFTP)", "license": "MIT", "repository": { @@ -24,12 +24,12 @@ "bin.js" ], "optionalDependencies": { - "@risuko/cli-darwin-arm64": "0.3.6", - "@risuko/cli-darwin-x64": "0.3.6", - "@risuko/cli-linux-arm64-gnu": "0.3.6", - "@risuko/cli-linux-x64-gnu": "0.3.6", - "@risuko/cli-win32-arm64-msvc": "0.3.6", - "@risuko/cli-win32-x64-msvc": "0.3.6" + "@risuko/cli-darwin-arm64": "0.4.0", + "@risuko/cli-darwin-x64": "0.4.0", + "@risuko/cli-linux-arm64-gnu": "0.4.0", + "@risuko/cli-linux-x64-gnu": "0.4.0", + "@risuko/cli-win32-arm64-msvc": "0.4.0", + "@risuko/cli-win32-x64-msvc": "0.4.0" }, "engines": { "node": ">= 18" diff --git a/packages/risuko-js/npm/darwin-arm64/package.json b/packages/risuko-js/npm/darwin-arm64/package.json index a94df78d..32fe24de 100644 --- a/packages/risuko-js/npm/darwin-arm64/package.json +++ b/packages/risuko-js/npm/darwin-arm64/package.json @@ -1,6 +1,6 @@ { "name": "@risuko/js-darwin-arm64", - "version": "0.3.6", + "version": "0.4.0", "description": "Risuko JS native module for macOS ARM64", "repository": { "type": "git", @@ -15,6 +15,9 @@ "arm64" ], "main": "risuko.darwin-arm64.node", + "scripts": { + "prepack": "node ../../../../scripts/ensure-package-artifacts.mjs" + }, "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 7fc03bc8..f9b8eb41 100644 --- a/packages/risuko-js/npm/darwin-x64/package.json +++ b/packages/risuko-js/npm/darwin-x64/package.json @@ -1,6 +1,6 @@ { "name": "@risuko/js-darwin-x64", - "version": "0.3.6", + "version": "0.4.0", "description": "Risuko JS native module for macOS x64", "repository": { "type": "git", @@ -15,6 +15,9 @@ "x64" ], "main": "risuko.darwin-x64.node", + "scripts": { + "prepack": "node ../../../../scripts/ensure-package-artifacts.mjs" + }, "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 b4df486f..16dc46ac 100644 --- a/packages/risuko-js/npm/linux-arm64-gnu/package.json +++ b/packages/risuko-js/npm/linux-arm64-gnu/package.json @@ -1,6 +1,6 @@ { "name": "@risuko/js-linux-arm64-gnu", - "version": "0.3.6", + "version": "0.4.0", "description": "Risuko JS native module for Linux ARM64", "repository": { "type": "git", @@ -15,6 +15,9 @@ "arm64" ], "main": "risuko.linux-arm64-gnu.node", + "scripts": { + "prepack": "node ../../../../scripts/ensure-package-artifacts.mjs" + }, "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 932ac675..4a80a00b 100644 --- a/packages/risuko-js/npm/linux-x64-gnu/package.json +++ b/packages/risuko-js/npm/linux-x64-gnu/package.json @@ -1,6 +1,6 @@ { "name": "@risuko/js-linux-x64-gnu", - "version": "0.3.6", + "version": "0.4.0", "description": "Risuko JS native module for Linux x64", "repository": { "type": "git", @@ -15,6 +15,9 @@ "x64" ], "main": "risuko.linux-x64-gnu.node", + "scripts": { + "prepack": "node ../../../../scripts/ensure-package-artifacts.mjs" + }, "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 695d5554..3fde9b94 100644 --- a/packages/risuko-js/npm/win32-arm64-msvc/package.json +++ b/packages/risuko-js/npm/win32-arm64-msvc/package.json @@ -1,6 +1,6 @@ { "name": "@risuko/js-win32-arm64-msvc", - "version": "0.3.6", + "version": "0.4.0", "description": "Risuko JS native module for Windows ARM64", "repository": { "type": "git", @@ -15,6 +15,9 @@ "arm64" ], "main": "risuko.win32-arm64-msvc.node", + "scripts": { + "prepack": "node ../../../../scripts/ensure-package-artifacts.mjs" + }, "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 312b3512..518fafac 100644 --- a/packages/risuko-js/npm/win32-x64-msvc/package.json +++ b/packages/risuko-js/npm/win32-x64-msvc/package.json @@ -1,6 +1,6 @@ { "name": "@risuko/js-win32-x64-msvc", - "version": "0.3.6", + "version": "0.4.0", "description": "Risuko JS native module for Windows x64", "repository": { "type": "git", @@ -15,6 +15,9 @@ "x64" ], "main": "risuko.win32-x64-msvc.node", + "scripts": { + "prepack": "node ../../../../scripts/ensure-package-artifacts.mjs" + }, "files": [ "risuko.win32-x64-msvc.node" ] diff --git a/packages/risuko-js/package.json b/packages/risuko-js/package.json index 191f1241..e0339547 100644 --- a/packages/risuko-js/package.json +++ b/packages/risuko-js/package.json @@ -1,6 +1,6 @@ { "name": "@risuko/risuko-js", - "version": "0.3.6", + "version": "0.4.0", "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", @@ -34,12 +34,12 @@ } }, "optionalDependencies": { - "@risuko/js-darwin-arm64": "0.3.6", - "@risuko/js-darwin-x64": "0.3.6", - "@risuko/js-linux-x64-gnu": "0.3.6", - "@risuko/js-linux-arm64-gnu": "0.3.6", - "@risuko/js-win32-x64-msvc": "0.3.6", - "@risuko/js-win32-arm64-msvc": "0.3.6" + "@risuko/js-darwin-arm64": "0.4.0", + "@risuko/js-darwin-x64": "0.4.0", + "@risuko/js-linux-x64-gnu": "0.4.0", + "@risuko/js-linux-arm64-gnu": "0.4.0", + "@risuko/js-win32-x64-msvc": "0.4.0", + "@risuko/js-win32-arm64-msvc": "0.4.0" }, "engines": { "node": ">= 22" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 28f44c1b..c58df906 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -132,47 +132,9 @@ importers: packages/risuko-app: {} - packages/risuko-cli: - optionalDependencies: - '@risuko/cli-darwin-arm64': - specifier: 0.3.6 - version: 0.3.6 - '@risuko/cli-darwin-x64': - specifier: 0.3.6 - version: 0.3.6 - '@risuko/cli-linux-arm64-gnu': - specifier: 0.3.6 - version: 0.3.6 - '@risuko/cli-linux-x64-gnu': - specifier: 0.3.6 - version: 0.3.6 - '@risuko/cli-win32-arm64-msvc': - specifier: 0.3.6 - version: 0.3.6 - '@risuko/cli-win32-x64-msvc': - specifier: 0.3.6 - version: 0.3.6 - - packages/risuko-js: - optionalDependencies: - '@risuko/js-darwin-arm64': - specifier: 0.3.6 - version: 0.3.6 - '@risuko/js-darwin-x64': - specifier: 0.3.6 - version: 0.3.6 - '@risuko/js-linux-arm64-gnu': - specifier: 0.3.6 - version: 0.3.6 - '@risuko/js-linux-x64-gnu': - specifier: 0.3.6 - version: 0.3.6 - '@risuko/js-win32-arm64-msvc': - specifier: 0.3.6 - version: 0.3.6 - '@risuko/js-win32-x64-msvc': - specifier: 0.3.6 - version: 0.3.6 + packages/risuko-cli: {} + + packages/risuko-js: {} packages: @@ -365,68 +327,6 @@ packages: resolution: {integrity: sha512-tmmZ3lQxAe/k/+rNnXQRawJ4NjxO2hqiOLTHvWchtGZULp4RyFeh6aU4XdOYBFe2KE1oShQTv4AblOs2iOrNnQ==} engines: {node: '>= 10.0.0'} - '@risuko/cli-darwin-arm64@0.3.6': - resolution: {integrity: sha512-keFkgN9qafh4vI1rG7Zd3dWMe9OqYlwilAZkG7cP49wrmmi8lpZN/LtRPoJPlK3A0OqJ5LjNta/9A2t+RJrYSA==} - cpu: [arm64] - os: [darwin] - - '@risuko/cli-darwin-x64@0.3.6': - resolution: {integrity: sha512-2XvPmshK1eAoeCUjnHoXuL54jYVUb/gq+LQRv1uo8sMUg1+h5qioWVn0tTlI4xjsC8IM+Tib+yW0cKo2gdKbOQ==} - cpu: [x64] - os: [darwin] - - '@risuko/cli-linux-arm64-gnu@0.3.6': - resolution: {integrity: sha512-8N4wRqJhb0nhpug+GWFD1HTcsAJx+Wjtk4UM8NHPHcJ8mZbfAeX+iEIYQ+66mQ3WsK+H5W6wjrrgfCitNTptWg==} - cpu: [arm64] - os: [linux] - - '@risuko/cli-linux-x64-gnu@0.3.6': - resolution: {integrity: sha512-TXRpkWqjLVesY7KkurV/XJMC4/sOouN9OwgDTldeRJnuQFRfm/PeA06lYItYe6hDKAGvf+mlbo4r0Dd2aLjJZw==} - cpu: [x64] - os: [linux] - - '@risuko/cli-win32-arm64-msvc@0.3.6': - resolution: {integrity: sha512-6bk6fs3DN0Fgpxk5tNeDP5qL1o2HWO4J/npoauzbLvoWPxURoLKZhGcfY71StZy/aT6jCA4gK/kPoEfOdweyZw==} - cpu: [arm64] - os: [win32] - - '@risuko/cli-win32-x64-msvc@0.3.6': - resolution: {integrity: sha512-v720sQ8vn4/GkXC4Gvb8Ik0/jlHAS5wH6Xa4XohLdqvQQeoKQ01wM0k6L2ziT35IoyA6Wt9KX6IuIrzsElbNLw==} - cpu: [x64] - os: [win32] - - '@risuko/js-darwin-arm64@0.3.6': - resolution: {integrity: sha512-etFesBb3a9EVuoXYJYXAobOnykprfJUPcvP+tlvK9lXrmiwLU6NrdZnTkQmln8j7SMmmsreoKDW7HcETh109wQ==} - cpu: [arm64] - os: [darwin] - - '@risuko/js-darwin-x64@0.3.6': - resolution: {integrity: sha512-Z+fqedmTqmbM0tkQ72RHqTTuNaHDLFLX5+gFcPgGHavCP1CjXwxF5rJTsAzqvDMuZcOypgyIwxgG3gVakOuQiA==} - cpu: [x64] - os: [darwin] - - '@risuko/js-linux-arm64-gnu@0.3.6': - resolution: {integrity: sha512-HnjPeO87Q9TpZHLWI8RwCUo40hF8wC0TunhJBhavFNjHrYl+mkxnDq71ce2+kJPjE1Te9E3ILB98eX6Ekzu2HA==} - cpu: [arm64] - os: [linux] - libc: [glibc] - - '@risuko/js-linux-x64-gnu@0.3.6': - resolution: {integrity: sha512-IzkHNsHFUhuDc7xMuwd/tlGaaJeYAbXC0UAk5Cl3KW+oByEahRLbBVb4RI9bI3us27m1S3cREx+ZpjaQeUazqQ==} - cpu: [x64] - os: [linux] - libc: [glibc] - - '@risuko/js-win32-arm64-msvc@0.3.6': - resolution: {integrity: sha512-dzaNc+ziG/iNrBuKRNa+rKIWPHQBdVeggCdhu4PbQSxIja/vAjeEbvYtAHeVNfWHBu1nSF3RE47sqwtCninmhg==} - cpu: [arm64] - os: [win32] - - '@risuko/js-win32-x64-msvc@0.3.6': - resolution: {integrity: sha512-R5zA+yjzCm/MLx36oX+XuMYx7AR1PvNnNCOyF95gC+Ehv2+SSxuL9AF5gVyxTDaV+S0a75h0q0vQvKeOOwEL4w==} - cpu: [x64] - os: [win32] - '@rolldown/binding-android-arm64@1.0.3': resolution: {integrity: sha512-454rs7jHngixp/NMxd5srYD57OnzSlZ/eFTETjORQHLwJG1lRtmNOJcBerZlfu4GjKqeq8aCCIQrMdHyhI51Hw==} engines: {node: ^20.19.0 || >=22.12.0} @@ -1794,42 +1694,6 @@ snapshots: '@parcel/watcher-win32-x64': 2.5.6 optional: true - '@risuko/cli-darwin-arm64@0.3.6': - optional: true - - '@risuko/cli-darwin-x64@0.3.6': - optional: true - - '@risuko/cli-linux-arm64-gnu@0.3.6': - optional: true - - '@risuko/cli-linux-x64-gnu@0.3.6': - optional: true - - '@risuko/cli-win32-arm64-msvc@0.3.6': - optional: true - - '@risuko/cli-win32-x64-msvc@0.3.6': - optional: true - - '@risuko/js-darwin-arm64@0.3.6': - optional: true - - '@risuko/js-darwin-x64@0.3.6': - optional: true - - '@risuko/js-linux-arm64-gnu@0.3.6': - optional: true - - '@risuko/js-linux-x64-gnu@0.3.6': - optional: true - - '@risuko/js-win32-arm64-msvc@0.3.6': - optional: true - - '@risuko/js-win32-x64-msvc@0.3.6': - optional: true - '@rolldown/binding-android-arm64@1.0.3': optional: true diff --git a/scripts/bootstrap-npm-local.mjs b/scripts/bootstrap-npm-local.mjs index 793c8922..fdfca82a 100644 --- a/scripts/bootstrap-npm-local.mjs +++ b/scripts/bootstrap-npm-local.mjs @@ -23,7 +23,7 @@ import { } from "node:fs"; import { tmpdir } from "node:os"; import { basename, dirname, join, resolve } from "node:path"; -import { execSync } from "node:child_process"; +import { spawnSync } from "node:child_process"; const root = resolve(import.meta.dirname, ".."); @@ -40,16 +40,27 @@ if (!/^\d+\.\d+\.\d+(-[\w.]+)?$/.test(version)) { process.exit(1); } -function run(command, cwd = root, allowFailure = false) { - try { - return execSync(command, { cwd, stdio: ["ignore", "pipe", "pipe"], encoding: "utf8" }).trim(); - } catch (err) { - if (allowFailure) { - return ""; - } - const stderr = err?.stderr?.toString?.() || ""; - throw new Error(`Command failed: ${command}\n${stderr}`); +if (!tag || tag.startsWith("-") || /\s/.test(tag)) { + console.error(`Invalid npm tag: ${tag}`); + process.exit(1); +} + +function run(command, commandArgs = [], cwd = root, allowFailure = false) { + const result = spawnSync(command, commandArgs, { + cwd, + stdio: ["ignore", "pipe", "pipe"], + encoding: "utf8", + shell: false, + }); + if (result.status === 0) { + return result.stdout.trim(); + } + if (allowFailure) { + return ""; } + throw new Error( + `Command failed: ${[command, ...commandArgs].join(" ")}\n${result.stderr}`, + ); } function listPackageDirs(baseDir) { @@ -116,6 +127,12 @@ function createPlaceholderFile(pkgName, filePath) { function preparePackage(srcDir, outDir, nextVersion) { const pkg = readPkg(srcDir); pkg.version = nextVersion; + if (pkg.scripts?.prepack) { + delete pkg.scripts.prepack; + if (Object.keys(pkg.scripts).length === 0) { + delete pkg.scripts; + } + } updateInternalDeps(pkg, nextVersion); writePkg(outDir, pkg); @@ -130,7 +147,7 @@ function preparePackage(srcDir, outDir, nextVersion) { } function packageExists(name, nextVersion) { - const out = run(`npm view ${JSON.stringify(`${name}@${nextVersion}`)} version`, root, true); + const out = run("npm", ["view", `${name}@${nextVersion}`, "version"], root, true); return out === nextVersion; } @@ -152,7 +169,7 @@ console.log(`Dry run: ${dryRun ? "yes" : "no"}`); if (!dryRun) { try { - const whoami = run("npm whoami"); + const whoami = run("npm", ["whoami"]); console.log(`npm auth ok as: ${whoami}`); } catch { console.error("npm auth missing. Run 'npm login' and retry."); @@ -184,7 +201,7 @@ for (const item of prepared) { } console.log(`publishing ${item.name}@${item.version} (tag: ${tag})`); - run(`npm publish --access public --tag ${tag}`, item.dir); + run("npm", ["publish", "--access", "public", "--tag", tag], item.dir); } if (dryRun) { diff --git a/scripts/build-web.mjs b/scripts/build-web.mjs index 82003c73..d65da060 100644 --- a/scripts/build-web.mjs +++ b/scripts/build-web.mjs @@ -3,7 +3,7 @@ * * Usage: node scripts/build-web.mjs */ -import { execSync } from "node:child_process"; +import { spawnSync } from "node:child_process"; import { cpSync, mkdirSync, rmSync, existsSync } from "node:fs"; import { resolve } from "node:path"; @@ -12,10 +12,18 @@ const webDist = resolve(root, "dist/web"); const pkgDist = resolve(root, "packages/risuko-app/dist"); console.log("Building web UI with Vite...\n"); -execSync("npx vite build --config vite.web.config.ts", { +const npxBin = process.platform === "win32" ? "npx.cmd" : "npx"; +const build = spawnSync(npxBin, ["vite", "build", "--config", "vite.web.config.ts"], { cwd: root, stdio: "inherit", + shell: false, }); +if (build.error) { + throw build.error; +} +if (build.status !== 0) { + process.exit(build.status ?? 1); +} if (!existsSync(webDist)) { console.error("Build output not found at dist/web/"); diff --git a/scripts/build.mjs b/scripts/build.mjs index 44c31dd2..0e40f69e 100644 --- a/scripts/build.mjs +++ b/scripts/build.mjs @@ -47,6 +47,7 @@ greeting(); const args = process.argv.slice(2); const tauriArgs = ["tauri", "build", ...args]; +const pnpmBin = process.platform === "win32" ? "pnpm.cmd" : "pnpm"; function findOptionValue(rawArgs, flags) { for (const flag of flags) { @@ -143,9 +144,9 @@ if (!userProvidedConfig && resourceConfig) { ); } -const child = spawn("pnpm", tauriArgs, { +const child = spawn(pnpmBin, tauriArgs, { stdio: "inherit", - shell: true, + shell: false, env: { ...process.env }, }); diff --git a/scripts/dev.mjs b/scripts/dev.mjs index 5a5e95bf..b7324b63 100644 --- a/scripts/dev.mjs +++ b/scripts/dev.mjs @@ -31,9 +31,10 @@ function greeting() { greeting(); -const child = spawn("pnpm", ["tauri", "dev"], { +const pnpmBin = process.platform === "win32" ? "pnpm.cmd" : "pnpm"; +const child = spawn(pnpmBin, ["tauri", "dev"], { stdio: "inherit", - shell: true, + shell: false, env: { ...process.env }, }); diff --git a/scripts/ensure-package-artifacts.mjs b/scripts/ensure-package-artifacts.mjs new file mode 100644 index 00000000..f5ef66ac --- /dev/null +++ b/scripts/ensure-package-artifacts.mjs @@ -0,0 +1,32 @@ +#!/usr/bin/env node + +import { existsSync, readFileSync, statSync } from "node:fs"; +import { join, resolve } from "node:path"; + +const packageDir = process.cwd(); +const packagePath = join(packageDir, "package.json"); +const pkg = JSON.parse(readFileSync(packagePath, "utf8")); +const files = Array.isArray(pkg.files) ? pkg.files : []; +const missing = []; + +for (const rel of files) { + const path = resolve(packageDir, rel); + if (!existsSync(path)) { + missing.push(`${rel} (missing)`); + continue; + } + if (statSync(path).size === 0) { + missing.push(`${rel} (empty)`); + } +} + +if (missing.length > 0) { + console.error( + [ + `Package ${pkg.name} is missing required artifact(s):`, + ...missing.map((entry) => ` - ${entry}`), + "Run the release staging step before packing or publishing this platform package.", + ].join("\n"), + ); + process.exit(1); +} diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 0cb23091..b60ae19e 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -659,9 +659,9 @@ checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" [[package]] name = "bitflags" -version = "2.12.1" +version = "2.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "84d7ced0ae9557296835c32bf1b1e02b44c746701f898460fb000d7eaa84f00a" +checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8" dependencies = [ "serde_core", ] @@ -834,7 +834,7 @@ version = "0.18.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8ca26ef0159422fb77631dc9d17b102f253b876fe1586b03b803e63a309b4ee2" dependencies = [ - "bitflags 2.12.1", + "bitflags 2.13.0", "cairo-sys-rs", "glib", "libc", @@ -1217,7 +1217,7 @@ version = "0.25.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "064badf302c3194842cf2c5d61f56cc88e54a759313879cdf03abdd27d0c3b97" dependencies = [ - "bitflags 2.12.1", + "bitflags 2.13.0", "core-foundation", "core-graphics-types", "foreign-types", @@ -1230,7 +1230,7 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d44a101f213f6c4cdc1853d4b78aef6db6bdfa3468798cc1d9912f4735013eb" dependencies = [ - "bitflags 2.12.1", + "bitflags 2.13.0", "core-foundation", "libc", ] @@ -1680,7 +1680,7 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" dependencies = [ - "bitflags 2.12.1", + "bitflags 2.13.0", "block2", "libc", "objc2", @@ -2582,7 +2582,7 @@ version = "0.18.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "233daaf6e83ae6a12a52055f568f9d7cf4671dabb78ff9560ab6da230ce00ee5" dependencies = [ - "bitflags 2.12.1", + "bitflags 2.13.0", "futures-channel", "futures-core", "futures-executor", @@ -3467,7 +3467,7 @@ version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b750dcadc39a09dbadd74e118f6dd6598df77fa01df0cfcdc52c28dece74528a" dependencies = [ - "bitflags 2.12.1", + "bitflags 2.13.0", "serde", "unicode-segmentation", ] @@ -3688,9 +3688,9 @@ dependencies = [ [[package]] name = "mac-notification-sys" -version = "0.6.12" +version = "0.6.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "29a16783dd1a47849b8c8133c9cd3eb2112cfbc6901670af3dba47c8bbfb07d3" +checksum = "50efa634682b3fc5a1ab6f3dd5b2bce7b848011fc485b53b063dc68f2f74feae" dependencies = [ "cc", "objc2", @@ -3876,7 +3876,7 @@ version = "3.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f1d395473824516f38dd1071a1a37bc57daa7be65b293ebba4ead5f7abb017a2" dependencies = [ - "bitflags 2.12.1", + "bitflags 2.13.0", "ctor 1.0.7", "futures", "napi-build", @@ -3936,7 +3936,7 @@ version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c3f42e7bbe13d351b6bead8286a43aac9534b82bd3cc43e47037f012ebfd62d4" dependencies = [ - "bitflags 2.12.1", + "bitflags 2.13.0", "jni-sys 0.3.1", "log", "ndk-sys", @@ -3996,7 +3996,7 @@ version = "0.31.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf20d2fde8ff38632c426f1165ed7436270b44f199fc55284c38276f9db47c3d" dependencies = [ - "bitflags 2.12.1", + "bitflags 2.13.0", "cfg-if", "cfg_aliases", "libc", @@ -4136,7 +4136,7 @@ checksum = "26fe2140f3b6eef2648fa331ca7af7652da1bf3bb84f715878bf50f2f8f03c38" dependencies = [ "arrayvec", "binrw", - "bitflags 2.12.1", + "bitflags 2.13.0", "byteorder", "derive_more 0.99.20", "displaydoc", @@ -4293,7 +4293,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c" dependencies = [ - "bitflags 2.12.1", + "bitflags 2.13.0", "block2", "objc2", "objc2-core-foundation", @@ -4307,7 +4307,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "73ad74d880bb43877038da939b7427bba67e9dd42004a18b809ba7d87cee241c" dependencies = [ - "bitflags 2.12.1", + "bitflags 2.13.0", "objc2", "objc2-foundation", ] @@ -4328,7 +4328,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" dependencies = [ - "bitflags 2.12.1", + "bitflags 2.13.0", "dispatch2", "objc2", ] @@ -4339,7 +4339,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807" dependencies = [ - "bitflags 2.12.1", + "bitflags 2.13.0", "dispatch2", "objc2", "objc2-core-foundation", @@ -4372,7 +4372,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0cde0dfb48d25d2b4862161a4d5fcc0e3c24367869ad306b0c9ec0073bfed92d" dependencies = [ - "bitflags 2.12.1", + "bitflags 2.13.0", "objc2", "objc2-core-foundation", "objc2-core-graphics", @@ -4399,7 +4399,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" dependencies = [ - "bitflags 2.12.1", + "bitflags 2.13.0", "block2", "libc", "objc2", @@ -4412,7 +4412,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d" dependencies = [ - "bitflags 2.12.1", + "bitflags 2.13.0", "objc2", "objc2-core-foundation", ] @@ -4423,7 +4423,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "96c1358452b371bf9f104e21ec536d37a650eb10f7ee379fff67d2e08d537f1f" dependencies = [ - "bitflags 2.12.1", + "bitflags 2.13.0", "objc2", "objc2-core-foundation", "objc2-foundation", @@ -4435,7 +4435,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d87d638e33c06f577498cbcc50491496a3ed4246998a7fbba7ccb98b1e7eab22" dependencies = [ - "bitflags 2.12.1", + "bitflags 2.13.0", "block2", "objc2", "objc2-cloud-kit", @@ -4466,7 +4466,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b2e5aaab980c433cf470df9d7af96a7b46a9d892d521a2cbbb2f8a4c16751e7f" dependencies = [ - "bitflags 2.12.1", + "bitflags 2.13.0", "block2", "objc2", "objc2-app-kit", @@ -4930,7 +4930,7 @@ version = "0.18.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61" dependencies = [ - "bitflags 2.12.1", + "bitflags 2.13.0", "crc32fast", "fdeflate", "flate2", @@ -5331,7 +5331,7 @@ version = "0.5.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" dependencies = [ - "bitflags 2.12.1", + "bitflags 2.13.0", ] [[package]] @@ -5489,7 +5489,7 @@ dependencies = [ [[package]] name = "risuko" -version = "0.3.6" +version = "0.4.0" dependencies = [ "apple-native-keyring-store", "base64 0.22.1", @@ -5567,7 +5567,7 @@ dependencies = [ [[package]] name = "risuko-cli" -version = "0.3.6" +version = "0.4.0" dependencies = [ "base64 0.22.1", "clap", @@ -5584,7 +5584,7 @@ dependencies = [ [[package]] name = "risuko-cookies" -version = "0.3.6" +version = "0.4.0" dependencies = [ "rookie", "serde", @@ -5596,7 +5596,7 @@ dependencies = [ [[package]] name = "risuko-engine" -version = "0.3.6" +version = "0.4.0" dependencies = [ "aes 0.9.1", "async-trait", @@ -5654,7 +5654,7 @@ dependencies = [ [[package]] name = "risuko-http" -version = "0.3.6" +version = "0.4.0" dependencies = [ "async-compression", "async-stream", @@ -5689,7 +5689,7 @@ dependencies = [ [[package]] name = "risuko-napi" -version = "0.3.6" +version = "0.4.0" dependencies = [ "base64 0.22.1", "dirs 6.0.0", @@ -5761,7 +5761,7 @@ version = "0.31.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b838eba278d213a8beaf485bd313fd580ca4505a00d5871caeb1457c55322cae" dependencies = [ - "bitflags 2.12.1", + "bitflags 2.13.0", "fallible-iterator", "fallible-streaming-iterator", "hashlink", @@ -5779,7 +5779,7 @@ dependencies = [ "aes 0.8.4", "aes 0.9.1", "aes-gcm 0.11.0-rc.3", - "bitflags 2.12.1", + "bitflags 2.13.0", "block-padding 0.3.3", "byteorder", "bytes", @@ -5867,7 +5867,7 @@ version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ed8949eca4163c18a8f59ff96d32cf61e9c13b9735e21ef32b3907f4aafa1a9" dependencies = [ - "bitflags 2.12.1", + "bitflags 2.13.0", "bytes", "chrono", "dashmap", @@ -5959,7 +5959,7 @@ version = "0.38.44" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" dependencies = [ - "bitflags 2.12.1", + "bitflags 2.13.0", "errno", "libc", "linux-raw-sys 0.4.15", @@ -5972,7 +5972,7 @@ version = "1.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" dependencies = [ - "bitflags 2.12.1", + "bitflags 2.13.0", "errno", "libc", "linux-raw-sys 0.12.1", @@ -6134,7 +6134,7 @@ version = "3.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" dependencies = [ - "bitflags 2.12.1", + "bitflags 2.13.0", "core-foundation", "core-foundation-sys", "libc", @@ -6157,7 +6157,7 @@ version = "0.36.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c5d9c0c92a92d33f08817311cf3f2c29a3538a8240e94a6a3c622ce652d7e00c" dependencies = [ - "bitflags 2.12.1", + "bitflags 2.13.0", "cssparser", "derive_more 2.1.1", "log", @@ -6311,9 +6311,9 @@ dependencies = [ [[package]] name = "serde_with" -version = "3.20.0" +version = "3.21.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e72c1c2cb7b223fafb600a619537a871c2818583d619401b785e7c0b746ccde2" +checksum = "76a5c54c7310e7b8b9577c286d7e399ddd876c3e12b3ed917a8aabc4b96e9e8c" dependencies = [ "base64 0.22.1", "bs58", @@ -6331,9 +6331,9 @@ dependencies = [ [[package]] name = "serde_with_macros" -version = "3.20.0" +version = "3.21.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b90c488738ecb4fb0262f41f43bc40efc5868d9fb744319ddf5f5317f417bfac" +checksum = "84d57bc0c8b9a17920c178daa6bb924850d54a9c97ab45194bb8c17ad66bb660" dependencies = [ "darling", "proc-macro2", @@ -6847,7 +6847,7 @@ version = "0.35.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d1c93047acf68669466a34690ac58cca7010bd1b201e1ec86f1fd0a75d3dd4a9" dependencies = [ - "bitflags 2.12.1", + "bitflags 2.13.0", "block2", "core-foundation", "core-graphics", @@ -7721,7 +7721,7 @@ version = "0.6.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" dependencies = [ - "bitflags 2.12.1", + "bitflags 2.13.0", "bytes", "futures-util", "http", @@ -8257,7 +8257,7 @@ version = "0.244.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" dependencies = [ - "bitflags 2.12.1", + "bitflags 2.13.0", "hashbrown 0.15.5", "indexmap 2.14.0", "semver", @@ -8282,7 +8282,7 @@ version = "0.31.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "645c7c96bb74690c3189b5c9cb4ca1627062bb23693a4fad9d8c3de958260144" dependencies = [ - "bitflags 2.12.1", + "bitflags 2.13.0", "rustix 1.1.4", "wayland-backend", "wayland-scanner", @@ -8294,7 +8294,7 @@ version = "0.32.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "563a85523cade2429938e790815fd7319062103b9f4a2dc806e9b53b95982d8f" dependencies = [ - "bitflags 2.12.1", + "bitflags 2.13.0", "wayland-backend", "wayland-client", "wayland-scanner", @@ -8306,7 +8306,7 @@ version = "0.3.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "eb04e52f7836d7c7976c78ca0250d61e33873c34156a2a1fc9474828ec268234" dependencies = [ - "bitflags 2.12.1", + "bitflags 2.13.0", "wayland-backend", "wayland-client", "wayland-protocols", @@ -9265,7 +9265,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" dependencies = [ "anyhow", - "bitflags 2.12.1", + "bitflags 2.13.0", "indexmap 2.14.0", "log", "serde", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 319ad864..40653d29 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -2,7 +2,7 @@ members = ["risuko-engine", "risuko-bt", "risuko-cli", "risuko-napi", "risuko-http", "risuko-cookies"] [workspace.package] -version = "0.3.6" +version = "0.4.0" authors = ["YueMiyuki"] edition = "2021" rust-version = "1.82" diff --git a/src-tauri/gen/android/app/src/main/java/app/risuko/mobile/MainActivity.kt b/src-tauri/gen/android/app/src/main/java/app/risuko/mobile/MainActivity.kt index 47cf9acc..3ff8a759 100644 --- a/src-tauri/gen/android/app/src/main/java/app/risuko/mobile/MainActivity.kt +++ b/src-tauri/gen/android/app/src/main/java/app/risuko/mobile/MainActivity.kt @@ -349,13 +349,25 @@ class MainActivity : TauriActivity() { if (path.startsWith("content://") || path.startsWith("http://") || path.startsWith("https://")) { return Uri.parse(path) } - val filePath = if (path.startsWith("file://")) { - Uri.parse(path).path ?: path.removePrefix("file://") - } else { - path - } - return FileProvider.getUriForFile(activity, "${activity.packageName}.fileprovider", File(filePath)) - } + val filePath = if (path.startsWith("file://")) { + Uri.parse(path).path ?: path.removePrefix("file://") + } else { + path + } + val file = File(filePath).canonicalFile + val allowedRoots = listOfNotNull( + activity.getExternalFilesDir(null), + activity.cacheDir, + activity.externalCacheDir, + ).map { it.canonicalFile } + val allowed = allowedRoots.any { root -> + file == root || file.relativeToOrNull(root) != null + } + if (!allowed) { + throw SecurityException("open_path only allows app-owned files") + } + return FileProvider.getUriForFile(activity, "${activity.packageName}.fileprovider", file) + } private fun tryRevealFolder(activity: MainActivity, path: String): String { if (path.isBlank()) { diff --git a/src-tauri/gen/android/app/src/main/res/xml/file_paths.xml b/src-tauri/gen/android/app/src/main/res/xml/file_paths.xml index 782d63b9..32e2392a 100644 --- a/src-tauri/gen/android/app/src/main/res/xml/file_paths.xml +++ b/src-tauri/gen/android/app/src/main/res/xml/file_paths.xml @@ -1,5 +1,5 @@ - + diff --git a/src-tauri/risuko-bt/src/bencode.rs b/src-tauri/risuko-bt/src/bencode.rs index 8e487562..ceb9e91d 100644 --- a/src-tauri/risuko-bt/src/bencode.rs +++ b/src-tauri/risuko-bt/src/bencode.rs @@ -249,7 +249,6 @@ impl<'a> Parser<'a> { debug_assert_eq!(self.buf[self.pos], b'd'); self.pos += 1; let mut items: Vec<(Vec, Value)> = Vec::new(); - let mut last: Option> = None; while self.peek()? != b'e' { let key_pos = self.pos; let key_val = self.parse_value()?.value; @@ -257,13 +256,12 @@ impl<'a> Parser<'a> { Value::Bytes(b) => b, _ => return Err(Error::NonStringDictKey(key_pos)), }; - if let Some(prev) = &last { + if let Some((prev, _)) = items.last() { if prev.as_slice() >= key.as_slice() { return Err(Error::BadDictOrder(key_pos)); } } let value = self.parse_value()?.value; - last = Some(key.clone()); items.push((key, value)); } self.pos += 1; diff --git a/src-tauri/risuko-bt/src/core/merkle.rs b/src-tauri/risuko-bt/src/core/merkle.rs index 9a7c9ec5..25ebfbee 100644 --- a/src-tauri/risuko-bt/src/core/merkle.rs +++ b/src-tauri/risuko-bt/src/core/merkle.rs @@ -491,11 +491,6 @@ impl PieceVerifier { } } } - - /// True for the SHA-1 (v1) variant. Only useful for diagnostics - pub fn is_v1(&self) -> bool { - matches!(self, Self::V1Sha1 { .. }) - } } #[derive(Debug, thiserror::Error, PartialEq, Eq)] diff --git a/src-tauri/risuko-bt/src/dht.rs b/src-tauri/risuko-bt/src/dht.rs index 0d9df392..819a9e74 100644 --- a/src-tauri/risuko-bt/src/dht.rs +++ b/src-tauri/risuko-bt/src/dht.rs @@ -83,6 +83,20 @@ impl Drop for Dht { type PendingMap = std::collections::HashMap, SocketAddr)>; +/// Removes its transaction id from `pending` on drop +/// +/// Keeps aborted lookup tasks from leaving orphaned pending entries +struct PendingGuard { + pending: Arc>, + txn: u16, +} + +impl Drop for PendingGuard { + fn drop(&mut self) { + self.pending.lock().remove(&self.txn); + } +} + struct KrpcResponse { from: SocketAddr, body: Value, @@ -383,8 +397,9 @@ impl Dht { target: SocketAddr, info_hash: Id20, ) -> Option { - let (txn, rx) = self.register_transaction(target); + let (txn, rx, _guard) = self.register_transaction(target); let packet = build_get_peers(txn, &self.our_id, &info_hash); + // `_guard` removes `txn` from `pending`, including JoinSet aborts on budget timeout // Route via the appropriate socket family. If we target an IPv6 // node but lack a v6 socket, drop the query. let send_res = match target { @@ -393,19 +408,16 @@ impl Dht { if let Some(s6) = &self.sock6 { s6.send_to(&packet, target).await } else { - self.pending.lock().remove(&txn); return None; } } }; if send_res.is_err() { - self.pending.lock().remove(&txn); return None; } let resp = match tokio::time::timeout(QUERY_TIMEOUT, rx).await { Ok(Ok(r)) => r, _ => { - self.pending.lock().remove(&txn); return None; } }; @@ -413,7 +425,10 @@ impl Dht { .map(|(rid, peers, nodes, token)| (resp.from, rid, peers, nodes, token)) } - fn register_transaction(&self, target: SocketAddr) -> (u16, oneshot::Receiver) { + fn register_transaction( + &self, + target: SocketAddr, + ) -> (u16, oneshot::Receiver, PendingGuard) { let (tx, rx) = oneshot::channel(); let mut map = self.pending.lock(); let mut txn: u16 = rand::rng().random(); @@ -421,7 +436,11 @@ impl Dht { txn = txn.wrapping_add(1); } map.insert(txn, (tx, target)); - (txn, rx) + let guard = PendingGuard { + pending: self.pending.clone(), + txn, + }; + (txn, rx, guard) } /// Number of unique nodes currently held in the Kademlia routing table @@ -430,11 +449,6 @@ impl Dht { self.routing.lock().len() } - /// Snapshot of the routing-table state for diagnostics. - pub fn routing_table_status(&self) -> RoutingTableStatus { - self.routing.lock().status() - } - /// Warm the routing table by performing an iterative lookup against our /// own id. Bootstrap nodes respond with the contacts they know that are /// closest to us, which is exactly what populates a fresh routing table @@ -473,12 +487,6 @@ pub(crate) struct RoutingTable { buckets: Vec>, } -#[derive(Debug, Clone, Copy, Default)] -pub struct RoutingTableStatus { - pub total_nodes: usize, - pub non_empty_buckets: usize, -} - impl RoutingTable { fn new(our_id: Id20) -> Self { let buckets = (0..NUM_BUCKETS) @@ -491,21 +499,6 @@ impl RoutingTable { self.buckets.iter().map(|b| b.len()).sum() } - fn status(&self) -> RoutingTableStatus { - let mut total = 0; - let mut non_empty = 0; - for b in &self.buckets { - if !b.is_empty() { - non_empty += 1; - total += b.len(); - } - } - RoutingTableStatus { - total_nodes: total, - non_empty_buckets: non_empty, - } - } - fn bucket_index(&self, id: &Id20) -> Option { // Position of the highest set bit in XOR(our_id, id). Identical ids // (distance 0) belong to no bucket and are skipped @@ -803,9 +796,6 @@ mod tests { ); } assert_eq!(rt.len(), BUCKET_SIZE); - let st = rt.status(); - assert_eq!(st.total_nodes, BUCKET_SIZE); - assert_eq!(st.non_empty_buckets, 1); } #[test] diff --git a/src-tauri/risuko-bt/src/lsd.rs b/src-tauri/risuko-bt/src/lsd.rs index d5f67ee0..387b321c 100644 --- a/src-tauri/risuko-bt/src/lsd.rs +++ b/src-tauri/risuko-bt/src/lsd.rs @@ -271,11 +271,16 @@ async fn recv_loop(sock: Arc, inner: Arc) { continue; } let peer_addr = SocketAddr::new(from.ip(), port); - let our_hashes = inner.info_hashes.lock().clone(); - for ih in parsed.info_hashes { - if !our_hashes.contains(&ih) { - continue; - } + // Snapshot only tracked hashes under the lock, then send outside it + let matched: Vec = { + let our = inner.info_hashes.lock(); + parsed + .info_hashes + .into_iter() + .filter(|ih| our.contains(ih)) + .collect() + }; + for ih in matched { // Rate limit 1 peer per (info_hash, ip) per second. let key = (ih, from.ip()); let now = Instant::now(); diff --git a/src-tauri/risuko-bt/src/magnet.rs b/src-tauri/risuko-bt/src/magnet.rs index e3dca31b..fe23f00a 100644 --- a/src-tauri/risuko-bt/src/magnet.rs +++ b/src-tauri/risuko-bt/src/magnet.rs @@ -51,7 +51,6 @@ pub struct Resolved { pub info_hash_v2: Option, pub info_bytes: Vec, pub trackers: Vec, - pub display_name: Option, /// BEP 52 piece layers fetched from peers via `HASH_REQUEST`. Keyed by /// each file's `pieces root`. Empty for v1-only magnets and for v2 /// magnets whose every file fits in a single piece (no layer required) @@ -294,7 +293,6 @@ pub async fn resolve_with_peers( info_hash_v2: want_v2, info_bytes, trackers, - display_name: magnet.display_name.clone(), piece_layers, }) } diff --git a/src-tauri/risuko-bt/src/peer.rs b/src-tauri/risuko-bt/src/peer.rs index 2ff57565..36986a2f 100644 --- a/src-tauri/risuko-bt/src/peer.rs +++ b/src-tauri/risuko-bt/src/peer.rs @@ -13,11 +13,9 @@ //! policy (what to request, when to unchoke) lives in `torrent::` pub mod connection; -pub mod state; pub use connection::{ accept, accept_utp_plaintext, accept_with_policy, accept_with_policy_and_capabilities, connect, connect_utp_plaintext, connect_with_utp_fallback, EncryptionPolicy, ExtHandshakeBuilder, KnownInfoHash, PeerCommand, PeerEvent, PeerHandle, SpawnPeer, }; -pub use state::{PeerFlags, PeerState}; diff --git a/src-tauri/risuko-bt/src/peer/connection.rs b/src-tauri/risuko-bt/src/peer/connection.rs index fe428588..086e34f7 100644 --- a/src-tauri/risuko-bt/src/peer/connection.rs +++ b/src-tauri/risuko-bt/src/peer/connection.rs @@ -589,6 +589,7 @@ async fn connect_mse( )); } }; + let scan_from = mse::scan_start_after_append(recv.len(), 8); recv.extend_from_slice(&chunk[..rn]); // Scan new candidate offsets. The responder encrypts with keyB @@ -599,12 +600,7 @@ async fn connect_mse( // any offset `off` where recv[off..off+8] == keystream[0..8] if recv.len() >= 8 && keystream.len() >= 8 { let needle = &keystream[..8]; - for off in 0..=(recv.len() - 8) { - if &recv[off..off + 8] == needle { - found_offset = Some(off); - break; - } - } + found_offset = mse::find_subsequence_from(&recv, needle, scan_from); } if found_offset.is_some() { break; @@ -852,8 +848,9 @@ async fn accept_mse( "eof before req1", )); } + let scan_from = mse::scan_start_after_append(recv.len(), req1.len()); recv.extend_from_slice(&chunk[..n]); - if let Some(off) = mse::find_subsequence(&recv, &req1) { + if let Some(off) = mse::find_subsequence_from(&recv, &req1, scan_from) { break off; } }; @@ -1407,7 +1404,6 @@ async fn reader_task(mut reader: Box, tx: mpsc::Se // Piece reply is up to 16 KiB of payload + 13 B header; 64 KiB lets us // ingest several pipelined replies per syscall let mut buf = BytesMut::with_capacity(256 * 1024); - let mut tmp = vec![0u8; 64 * 1024]; loop { // Try to decode any complete frame already buffered loop { @@ -1428,7 +1424,9 @@ async fn reader_task(mut reader: Box, tx: mpsc::Se } } } - match reader.read(&mut tmp).await { + // Reserve the old 64 KiB scratch size so `read_buf` can absorb pipelined Piece replies without an extra copy + buf.reserve(64 * 1024); + match reader.read_buf(&mut buf).await { Ok(0) => { let _ = tx .send(PeerEvent::Disconnected { @@ -1437,7 +1435,7 @@ async fn reader_task(mut reader: Box, tx: mpsc::Se .await; return; } - Ok(n) => buf.extend_from_slice(&tmp[..n]), + Ok(_) => {} Err(e) => { let _ = tx .send(PeerEvent::Disconnected { diff --git a/src-tauri/risuko-bt/src/peer/state.rs b/src-tauri/risuko-bt/src/peer/state.rs deleted file mode 100644 index 6e11ffb6..00000000 --- a/src-tauri/risuko-bt/src/peer/state.rs +++ /dev/null @@ -1,118 +0,0 @@ -//! BitTorrent peer state tracked per connection - -use std::time::Instant; - -use bytes::Bytes; - -#[derive(Debug, Clone, Copy)] -pub struct PeerFlags { - /// We are choking the remote - pub am_choking: bool, - /// We have declared interest in the remote's pieces - pub am_interested: bool, - /// The remote is choking us - pub peer_choking: bool, - /// The remote has declared interest in our pieces - pub peer_interested: bool, -} - -impl PeerFlags { - pub fn new() -> Self { - // BEP-3 default: both sides start choked & not interested. - Self { - am_choking: true, - am_interested: false, - peer_choking: true, - peer_interested: false, - } - } -} - -impl Default for PeerFlags { - fn default() -> Self { - Self::new() - } -} - -#[derive(Debug)] -pub struct PeerState { - pub flags: PeerFlags, - /// Peer's advertised bitfield (MSB-first). Updated on `Bitfield` and - /// individual `Have` messages - pub bitfield: Vec, - /// Monotonic instant of the last message we received. Used to cull - /// idle/unresponsive peers - pub last_activity: Instant, - /// Peer-supplied ut_metadata id (post ext handshake), if any - pub ut_metadata_id: Option, - pub ut_pex_id: Option, - /// Peer-advertised metadata size from ext handshake - pub metadata_size: Option, - pub client: Option, - pub bytes_up: u64, - pub bytes_down: u64, - /// Last sent request queue — used by the torrent to issue cancels - pub outstanding_requests: Vec, -} - -#[derive(Debug, Clone)] -pub struct OutstandingRequest { - pub index: u32, - pub begin: u32, - pub length: u32, - pub sent_at: Instant, -} - -impl PeerState { - pub fn new(bitfield_bytes: usize) -> Self { - Self { - flags: PeerFlags::new(), - bitfield: vec![0u8; bitfield_bytes], - last_activity: Instant::now(), - ut_metadata_id: None, - ut_pex_id: None, - metadata_size: None, - client: None, - bytes_up: 0, - bytes_down: 0, - outstanding_requests: Vec::new(), - } - } - - pub fn merge_bitfield(&mut self, bf: &Bytes) { - let n = bf.len().min(self.bitfield.len()); - self.bitfield[..n].copy_from_slice(&bf[..n]); - } - - pub fn set_have(&mut self, piece: u32) { - let byte = (piece / 8) as usize; - let bit = 7 - (piece % 8) as u8; - if byte < self.bitfield.len() { - self.bitfield[byte] |= 1 << bit; - } - } - - pub fn has_piece(&self, piece: u32) -> bool { - let byte = (piece / 8) as usize; - let bit = 7 - (piece % 8) as u8; - self.bitfield - .get(byte) - .map(|b| b & (1 << bit) != 0) - .unwrap_or(false) - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn bitfield_updates() { - let mut s = PeerState::new(2); - s.set_have(0); - s.set_have(9); - assert!(s.has_piece(0)); - assert!(s.has_piece(9)); - assert!(!s.has_piece(1)); - } -} diff --git a/src-tauri/risuko-bt/src/piece/chunk_tracker.rs b/src-tauri/risuko-bt/src/piece/chunk_tracker.rs index 0f8dd623..b15bbb39 100644 --- a/src-tauri/risuko-bt/src/piece/chunk_tracker.rs +++ b/src-tauri/risuko-bt/src/piece/chunk_tracker.rs @@ -260,22 +260,6 @@ impl ChunkTracker { } } -// The `states_for` borrow above conflicts with the endgame scan that also -// borrows `self.pieces`. Work around with an explicit helper that takes -// `&mut HashMap<…>` -impl ChunkTracker { - fn _ensure<'a>( - pieces: &'a mut HashMap>, - lengths: &Lengths, - piece: ValidPieceIndex, - ) -> &'a mut Vec { - pieces.entry(piece.get()).or_insert_with(|| { - let count = lengths.chunks_of(piece).count(); - vec![ChunkState::Missing; count] - }) - } -} - #[cfg(test)] mod tests { use super::*; diff --git a/src-tauri/risuko-bt/src/piece/piece_tracker.rs b/src-tauri/risuko-bt/src/piece/piece_tracker.rs index be853489..9bdcd5ed 100644 --- a/src-tauri/risuko-bt/src/piece/piece_tracker.rs +++ b/src-tauri/risuko-bt/src/piece/piece_tracker.rs @@ -84,11 +84,19 @@ impl PieceTracker { /// Increment the availability counter for every bit set in `bitfield` pub fn add_peer_bitfield(&mut self, bitfield: &[u8]) { let n = self.lengths.total_pieces() as usize; - for i in 0..n { - let byte = i / 8; - let bit = 7 - (i % 8); - if bitfield.get(byte).is_some_and(|b| b & (1 << bit) != 0) { - self.availability[i] = self.availability[i].saturating_add(1); + for (byte_idx, &b) in bitfield.iter().enumerate() { + if b == 0 { + continue; + } + for bit in 0..8 { + if b & (1 << (7 - bit)) != 0 { + let i = byte_idx * 8 + bit; + // Ignore trailing padding bits past total_pieces + if i >= n { + break; + } + self.availability[i] = self.availability[i].saturating_add(1); + } } } } @@ -96,11 +104,19 @@ impl PieceTracker { /// Inverse of `add_peer_bitfield`, called when a peer disconnects pub fn remove_peer_bitfield(&mut self, bitfield: &[u8]) { let n = self.lengths.total_pieces() as usize; - for i in 0..n { - let byte = i / 8; - let bit = 7 - (i % 8); - if bitfield.get(byte).is_some_and(|b| b & (1 << bit) != 0) { - self.availability[i] = self.availability[i].saturating_sub(1); + for (byte_idx, &b) in bitfield.iter().enumerate() { + if b == 0 { + continue; + } + for bit in 0..8 { + if b & (1 << (7 - bit)) != 0 { + let i = byte_idx * 8 + bit; + // Ignore trailing padding bits past total_pieces + if i >= n { + break; + } + self.availability[i] = self.availability[i].saturating_sub(1); + } } } } @@ -110,11 +126,6 @@ impl PieceTracker { *slot = slot.saturating_add(1); } - pub fn note_peer_lost(&mut self, idx: ValidPieceIndex) { - let slot = &mut self.availability[idx.get_usize()]; - *slot = slot.saturating_sub(1); - } - pub fn is_complete(&self) -> bool { self.have_local.iter().all(|b| *b) } @@ -157,6 +168,22 @@ impl PieceTracker { self.choose_impl(peer_bitfield, true, hint, None) } + /// Return requestable pieces in rarest-first order from one bitfield scan + /// + /// `hint` rotates equal-availability buckets to spread first choice + pub fn choose_requestable_pieces( + &mut self, + peer_bitfield: &[u8], + hint: u32, + ) -> Vec { + self.choose_many_impl(peer_bitfield, true, hint) + } + + /// Return useful pieces, including in-flight pieces for endgame duplication + pub fn choose_pieces(&mut self, peer_bitfield: &[u8], hint: u32) -> Vec { + self.choose_many_impl(peer_bitfield, false, hint) + } + fn choose_impl( &mut self, peer_bitfield: &[u8], @@ -213,6 +240,51 @@ impl PieceTracker { let idx = hint as usize % self.scratch.len(); Some(self.scratch[idx]) } + + fn choose_many_impl( + &mut self, + peer_bitfield: &[u8], + skip_in_flight: bool, + hint: u32, + ) -> Vec { + self.scratch.clear(); + let n = self.lengths.total_pieces() as usize; + for i in 0..n { + let byte = i / 8; + let bit = 7 - (i % 8); + if peer_bitfield.get(byte).is_none_or(|b| b & (1 << bit) == 0) { + continue; + } + if self.have_local[i] { + continue; + } + if skip_in_flight && self.in_flight[i] { + continue; + } + if self.availability[i] == 0 { + continue; + } + if let Ok(vpi) = self.lengths.validate_piece(i as u32) { + self.scratch.push(vpi); + } + } + + let availability = &self.availability; + self.scratch + .sort_by_key(|idx| (availability[idx.get_usize()], idx.get())); + let mut start = 0; + while start < self.scratch.len() { + let avail = availability[self.scratch[start].get_usize()]; + let mut end = start + 1; + while end < self.scratch.len() && availability[self.scratch[end].get_usize()] == avail { + end += 1; + } + let len = end - start; + self.scratch[start..end].rotate_left(hint as usize % len); + start = end; + } + self.scratch.clone() + } } #[cfg(test)] @@ -251,6 +323,43 @@ mod tests { assert_eq!(chosen.get(), 3); } + #[test] + fn batched_requestable_pieces_preserve_rarest_hint_order() { + let mut t = PieceTracker::new(lengths(4)); + t.add_peer_bitfield(&[0b1111_0000]); + t.add_peer_bitfield(&[0b0011_0000]); + // Availability is [1, 1, 2, 2]; hint rotates each equal bucket + let chosen: Vec = t + .choose_requestable_pieces(&[0b1111_0000], 1) + .into_iter() + .map(|p| p.get()) + .collect(); + + assert_eq!(chosen, vec![1, 0, 3, 2]); + } + + #[test] + fn batched_endgame_pieces_include_in_flight() { + let mut t = PieceTracker::new(lengths(2)); + t.add_peer_bitfield(&[0b1100_0000]); + let first = t.lengths.validate_piece(0).unwrap(); + t.mark_in_flight(first); + + let requestable: Vec = t + .choose_requestable_pieces(&[0b1100_0000], 0) + .into_iter() + .map(|p| p.get()) + .collect(); + let endgame: Vec = t + .choose_pieces(&[0b1100_0000], 0) + .into_iter() + .map(|p| p.get()) + .collect(); + + assert_eq!(requestable, vec![1]); + assert_eq!(endgame, vec![0, 1]); + } + #[test] fn peer_with_nothing_useful() { let mut t = PieceTracker::new(lengths(4)); diff --git a/src-tauri/risuko-bt/src/session.rs b/src-tauri/risuko-bt/src/session.rs index 47743951..2870fff8 100644 --- a/src-tauri/risuko-bt/src/session.rs +++ b/src-tauri/risuko-bt/src/session.rs @@ -155,6 +155,9 @@ impl Drop for Session { if let Some(h) = self.dht_bootstrap_handle.lock().take() { h.abort(); } + if let Some(utp) = self.utp.take() { + utp.shutdown(); + } // Dropping the UpnpHandle / LSD service / DHT triggers their cleanup. let _ = self.upnp_handle.lock().take(); let _ = self.lsd.lock().take(); diff --git a/src-tauri/risuko-bt/src/storage.rs b/src-tauri/risuko-bt/src/storage.rs index 8b48910e..8dba3d8e 100644 --- a/src-tauri/risuko-bt/src/storage.rs +++ b/src-tauri/risuko-bt/src/storage.rs @@ -216,22 +216,27 @@ impl StorageBackend for FilesystemStorage { return Err(StorageError::OutOfRange { offset, total }); } - let mut cursor = 0usize; + // Start all span reads first, then copy each owned buffer into place + // `buf` cannot cross spawned tasks, so handles carry cursor and length let spans: Vec<_> = self.layout.spans_for(offset, buf.len() as u64).collect(); + let mut tasks = Vec::with_capacity(spans.len()); + let mut cursor = 0usize; for span in spans { let handle = self.handle(span.file_index).await?; let len = span.len as usize; let file_offset = span.file_offset; - let chunk = task::spawn_blocking(move || -> io::Result> { + let join = task::spawn_blocking(move || -> io::Result> { let mut out = vec![0u8; len]; pread_exact(&handle, file_offset, &mut out)?; Ok(out) - }) - .await - .map_err(|e| io::Error::other(e.to_string()))??; - buf[cursor..cursor + len].copy_from_slice(&chunk); + }); + tasks.push((cursor, len, join)); cursor += len; } + for (cursor, len, join) in tasks { + let chunk = join.await.map_err(|e| io::Error::other(e.to_string()))??; + buf[cursor..cursor + len].copy_from_slice(&chunk); + } Ok(()) } diff --git a/src-tauri/risuko-bt/src/torrent.rs b/src-tauri/risuko-bt/src/torrent.rs index 36a2af38..c403f102 100644 --- a/src-tauri/risuko-bt/src/torrent.rs +++ b/src-tauri/risuko-bt/src/torrent.rs @@ -23,7 +23,7 @@ use super::core::{ }; use super::peer::{connect_with_utp_fallback, PeerCommand, PeerEvent, SpawnPeer}; use super::piece::{ChunkTracker, PieceTracker}; -use super::storage::{FilesystemStorage, StorageBackend}; +use super::storage::{FileSet, FilesystemStorage, StorageBackend}; use super::tracker::{announce as tracker_announce, AnnounceEvent, AnnounceRequest}; use super::utp::UtpSocket; use super::wire::extended::{ut_metadata_data, ut_metadata_type, ExtHandshake, EXT_HANDSHAKE_ID}; @@ -86,6 +86,7 @@ const PEER_IDLE_TIMEOUT: Duration = Duration::from_secs(180); /// hard-disconnect peers that stay snubbed for this long so the slot is /// recycled to a peer that can actually serve us bytes const SNUB_EVICTION_TIMEOUT: Duration = Duration::from_secs(60); +const PEER_SNAPSHOT_INTERVAL: Duration = Duration::from_secs(5); /// Per-peer message id we advertise for the BEP-9 `ut_metadata` extension. /// Peers send `Extended { ext_id: OUR_UT_METADATA_ID, .. }` to request a @@ -211,6 +212,14 @@ pub async fn spawn( our_peer_id: Id20, listen_port: u16, ) -> std::io::Result> { + // `only_files` reaches TorrentInit, but the scheduler still fetches every piece + // Warn so callers do not think a subset-only download is active + if init.only_files.is_some() { + log::warn!( + "torrent {id}: selective download (only_files) is not yet supported; \ + downloading all files" + ); + } let info_hash = init.meta.info_hash; let name = Some(init.meta.info.name.clone()); let (cmd_tx, cmd_rx) = mpsc::channel::(64); @@ -472,7 +481,7 @@ async fn torrent_loop( // feed discovered peer addresses here; the main loop drains it and dials // (with dedup + cap enforcement). DHT feeds peers via the AddPeer command. let (peer_src_tx, mut peer_addr_rx) = mpsc::channel::(256); - spawn_tracker_pollers( + let mut tracker_tasks = spawn_tracker_pollers( peer_src_tx.clone(), collect_trackers(&init.meta), announce_hashes, @@ -516,6 +525,7 @@ async fn torrent_loop( // the BT handshake yet. Tracked separately so the max-peer cap accounts // for in-flight connection bursts, not just handshook peers. let mut pending_dials: HashMap = HashMap::new(); + let registry_scope = Arc::new(()); let mut paused = false; let mut tick = interval(Duration::from_millis(500)); tick.set_missed_tick_behavior(MissedTickBehavior::Skip); @@ -527,6 +537,7 @@ async fn torrent_loop( // flows between two seeders, so without an explicit liveness frame // the TCP session looks dead from their side). let mut last_keepalive = Instant::now(); + let mut last_peer_snapshot = Instant::now() - PEER_SNAPSHOT_INTERVAL; let mut bytes_this_tick = (0u64, 0u64); // Upload bytes accumulate from spawned send tasks; share via atomic so // we only credit them after the disk read and channel send succeed @@ -536,22 +547,24 @@ async fn torrent_loop( // write_at could leave a partially-written piece on disk while the // torrent loop has already returned let mut write_tasks: tokio::task::JoinSet<()> = tokio::task::JoinSet::new(); + let mut outbound_tasks: tokio::task::JoinSet<()> = tokio::task::JoinSet::new(); loop { tokio::select! { Some(cmd) = cmd_rx.recv() => match cmd { TorrentCommand::AddPeer(addr) => { if !paused - && known_addrs.insert(addr) && peers.len() < max_peers && pending_dials.len() < MAX_PENDING_DIALS + && known_addrs.insert(addr) { let pid = next_pid; next_pid += 1; pending_dials.insert(pid, addr); - spawn_outbound_peer( + outbound_tasks.spawn(run_outbound_peer( torrent_id, pid, addr, + registry_scope.clone(), info_hash, our_peer_id, peer_event_tx.clone(), @@ -559,13 +572,13 @@ async fn torrent_loop( advertise_v2, Some(initial_ext_handshake_builder.clone()), utp.clone(), - ); + )); } } TorrentCommand::AddInboundPeer { addr, cmd_tx, event_rx } => { if !paused - && known_addrs.insert(addr) && peers.len() < max_peers + && known_addrs.insert(addr) { let pid = next_pid; next_pid += 1; adopt_inbound_peer(pid, addr, cmd_tx, event_rx, peer_event_tx.clone(), &mut peers, &lengths, &mut piece_tracker, pipeline_floor).await; @@ -573,8 +586,16 @@ async fn torrent_loop( } TorrentCommand::Pause(ack) => { paused = true; - for (_, p) in peers.drain() { + for (pid, p) in peers.drain() { let _ = p.cmd_tx.send(PeerCommand::Disconnect).await; + release_peer_scheduler_state( + pid, + &p.bitfield, + &mut piece_tracker, + &mut chunk_tracker, + &mut piece_assemblies, + &lengths, + ); } pending_dials.clear(); known_addrs.clear(); @@ -598,11 +619,25 @@ async fn torrent_loop( for (_, p) in peers.drain() { let _ = p.cmd_tx.send(PeerCommand::Disconnect).await; } + tracker_tasks.abort_all(); + while let Some(result) = tracker_tasks.join_next().await { + if let Err(e) = result { + if !e.is_cancelled() { + log::warn!("tracker task failed during stop: {e}"); + } + } + } + outbound_tasks.shutdown().await; + for pid in pending_dials.keys().copied().collect::>() { + peer_registry::remove(torrent_id, pid, ®istry_scope); + } pending_dials.clear(); - // Wait for any in-flight piece write/verify tasks so we - // don't return Stop while a write_at is still pending. - // shutdown() aborts then joins all handles - write_tasks.shutdown().await; + // Wait for in-flight write/verify tasks before Stop acknowledges disk completion + while let Some(result) = write_tasks.join_next().await { + if let Err(e) = result { + log::warn!("write task failed during stop: {e}"); + } + } // Flush and release cached file descriptors if let Err(e) = storage.close_handles().await { log::warn!("failed to close storage handles on stop: {e}"); @@ -613,16 +648,17 @@ async fn torrent_loop( }, Some(addr) = peer_addr_rx.recv() => { if !paused - && known_addrs.insert(addr) && peers.len() < max_peers && pending_dials.len() < MAX_PENDING_DIALS + && known_addrs.insert(addr) { let pid = next_pid; next_pid += 1; pending_dials.insert(pid, addr); - spawn_outbound_peer( + outbound_tasks.spawn(run_outbound_peer( torrent_id, pid, addr, + registry_scope.clone(), info_hash, our_peer_id, peer_event_tx.clone(), @@ -630,12 +666,12 @@ async fn torrent_loop( advertise_v2, Some(initial_ext_handshake_builder.clone()), utp.clone(), - ); + )); } } Some((pid, ev)) = peer_event_rx.recv() => { let kick = process_peer_event( - torrent_id, pid, ev, &mut peers, &mut piece_tracker, &mut chunk_tracker, + torrent_id, pid, ev, ®istry_scope, paused, &mut peers, &mut piece_tracker, &mut chunk_tracker, &mut piece_assemblies, &lengths, &storage, &stats, &mut bytes_this_tick, &upload_tick, @@ -666,6 +702,13 @@ async fn torrent_loop( drive_requests(&mut peers, &mut piece_tracker, &mut chunk_tracker).await; } } + result = outbound_tasks.join_next(), if !outbound_tasks.is_empty() => { + if let Some(Err(e)) = result { + if !e.is_cancelled() { + log::warn!("outbound peer task failed: {e}"); + } + } + } _ = tick.tick() => { let now = Instant::now(); let dt = now.duration_since(last_tick).as_secs_f32().max(0.001); @@ -782,29 +825,38 @@ async fn torrent_loop( } } } - let total_pieces = lengths.total_pieces() as usize; - let peer_snaps: Vec = peers - .values() - .map(|p| { - let seeder = peer_bitfield_is_full(&p.bitfield, total_pieces); - stats::PeerSnapshot { - addr: p.addr, - bitfield: p.bitfield.clone(), - am_choking: p.am_choking, - am_interested: p.am_interested, - peer_choking: p.peer_choking, - peer_interested: p.peer_interested, - seeder, - } - }) - .collect(); + let peer_snaps = if now.duration_since(last_peer_snapshot) >= PEER_SNAPSHOT_INTERVAL { + last_peer_snapshot = now; + let total_pieces = lengths.total_pieces() as usize; + Some( + peers + .values() + .map(|p| { + let seeder = peer_bitfield_is_full(&p.bitfield, total_pieces); + stats::PeerSnapshot { + addr: p.addr, + bitfield: Arc::<[u8]>::from(p.bitfield.as_slice()), + am_choking: p.am_choking, + am_interested: p.am_interested, + peer_choking: p.peer_choking, + peer_interested: p.peer_interested, + seeder, + } + }) + .collect(), + ) + } else { + None + }; { let mut s = stats.lock(); let upload_dt = upload_tick.swap(0, Ordering::Relaxed); bytes_this_tick.1 = upload_dt; s.live_stats.update(bytes_this_tick.0, bytes_this_tick.1, dt); s.live_stats.snapshot.peer_stats.live = peers.len() as u32; - s.peers = peer_snaps; + if let Some(peer_snaps) = peer_snaps { + s.peers = peer_snaps; + } } bytes_this_tick = (0, 0); if !paused { @@ -823,21 +875,67 @@ mod peer_registry { use super::*; use once_cell::sync::Lazy; use std::sync::Mutex as StdMutex; - type PeerCmdRegistry = StdMutex, SocketAddr)>>; + + struct RegistryEntry { + scope: Arc<()>, + tx: mpsc::Sender, + addr: SocketAddr, + } + + type PeerCmdRegistry = StdMutex>; static REG: Lazy = Lazy::new(|| StdMutex::new(HashMap::new())); - pub fn put(torrent_id: usize, pid: u32, tx: mpsc::Sender, addr: SocketAddr) { - REG.lock().unwrap().insert((torrent_id, pid), (tx, addr)); + + pub fn put( + torrent_id: usize, + pid: u32, + scope: &Arc<()>, + tx: mpsc::Sender, + addr: SocketAddr, + ) { + REG.lock().unwrap().insert( + (torrent_id, pid), + RegistryEntry { + scope: scope.clone(), + tx, + addr, + }, + ); } - pub fn take(torrent_id: usize, pid: u32) -> Option<(mpsc::Sender, SocketAddr)> { - REG.lock().unwrap().remove(&(torrent_id, pid)) + + pub fn take( + torrent_id: usize, + pid: u32, + scope: &Arc<()>, + ) -> Option<(mpsc::Sender, SocketAddr)> { + let mut reg = REG.lock().unwrap(); + let key = (torrent_id, pid); + if !reg + .get(&key) + .is_some_and(|entry| Arc::ptr_eq(&entry.scope, scope)) + { + return None; + } + reg.remove(&key).map(|entry| (entry.tx, entry.addr)) + } + + pub fn remove(torrent_id: usize, pid: u32, scope: &Arc<()>) { + let mut reg = REG.lock().unwrap(); + let key = (torrent_id, pid); + if reg + .get(&key) + .is_some_and(|entry| Arc::ptr_eq(&entry.scope, scope)) + { + reg.remove(&key); + } } } #[allow(clippy::too_many_arguments)] -fn spawn_outbound_peer( +async fn run_outbound_peer( torrent_id: usize, pid: u32, addr: SocketAddr, + registry_scope: Arc<()>, info_hash: Id20, our_peer_id: Id20, event_tx: mpsc::Sender<(u32, PeerEvent)>, @@ -846,38 +944,43 @@ fn spawn_outbound_peer( ext_handshake_builder: Option, utp: Option>, ) { - tokio::spawn(async move { - let spawn = SpawnPeer { - addr, - info_hash, - our_peer_id, - connect_timeout: Duration::from_secs(10), - read_timeout: Duration::from_secs(120), - encryption, - advertise_v2, - ext_handshake_builder, - }; - match connect_with_utp_fallback(spawn, utp).await { - Ok((handle, mut rx)) => { - peer_registry::put(torrent_id, pid, handle.tx.clone(), handle.addr); - while let Some(ev) = rx.recv().await { - if event_tx.send((pid, ev)).await.is_err() { - break; - } + let spawn = SpawnPeer { + addr, + info_hash, + our_peer_id, + connect_timeout: Duration::from_secs(10), + read_timeout: Duration::from_secs(120), + encryption, + advertise_v2, + ext_handshake_builder, + }; + match connect_with_utp_fallback(spawn, utp).await { + Ok((handle, mut rx)) => { + peer_registry::put( + torrent_id, + pid, + ®istry_scope, + handle.tx.clone(), + handle.addr, + ); + while let Some(ev) = rx.recv().await { + if event_tx.send((pid, ev)).await.is_err() { + break; } } - Err(e) => { - let _ = event_tx - .send(( - pid, - PeerEvent::Disconnected { - reason: format!("connect: {e}"), - }, - )) - .await; - } + peer_registry::remove(torrent_id, pid, ®istry_scope); } - }); + Err(e) => { + let _ = event_tx + .send(( + pid, + PeerEvent::Disconnected { + reason: format!("connect: {e}"), + }, + )) + .await; + } + } } #[allow(clippy::too_many_arguments)] @@ -948,6 +1051,8 @@ async fn process_peer_event( torrent_id: usize, pid: u32, ev: PeerEvent, + registry_scope: &Arc<()>, + paused: bool, peers: &mut HashMap, piece_tracker: &mut PieceTracker, chunk_tracker: &mut ChunkTracker, @@ -976,7 +1081,16 @@ async fn process_peer_event( match ev { PeerEvent::Handshook { encrypted, .. } => { if !peers.contains_key(&pid) { - if let Some((cmd_tx, registry_addr)) = peer_registry::take(torrent_id, pid) { + if let Some((cmd_tx, registry_addr)) = + peer_registry::take(torrent_id, pid, registry_scope) + { + // The torrent paused while this dial was in flight + // The take() removed the registry entry; drop `cmd_tx` and disconnect + if paused { + pending_dials.remove(&pid); + let _ = cmd_tx.send(PeerCommand::Disconnect).await; + return false; + } // Move from pending dial to live peer. The registry is // the authoritative source of `addr` because // `pending_dials` may have been cleared by Pause/Stop @@ -1395,7 +1509,7 @@ async fn process_peer_event( } // Drop the registry slot in case the peer disconnected before // the Handshook event moved it into `peers`. - let _ = peer_registry::take(torrent_id, pid); + peer_registry::remove(torrent_id, pid, registry_scope); if let Some(dead) = peers.remove(&pid) { piece_tracker.remove_peer_bitfield(&dead.bitfield); let freed = chunk_tracker.release_peer(pid); @@ -1465,7 +1579,7 @@ async fn process_verify_result( // would re-serialise every piece completion through the torrent // loop's select arm and stall all peer events for the hash duration if vr.verify_ok { - piece_tracker.set_local(vpi, true); + let became_local = mark_verified_piece_local(piece_tracker, vpi); // Piece is verified + on disk; its dense chunk state is no longer // needed. Dropping keeps `release_peer` and `pending_chunks` // bounded by the working set of in-flight pieces rather than the @@ -1478,8 +1592,9 @@ async fn process_verify_result( cancel_piece_outstanding(peers, vr.piece_index); broadcast_have(peers, vr.piece_index).await; let mut s = stats.lock(); - s.progress_bytes = completed_bytes(piece_tracker, lengths); - s.file_progress = compute_file_progress(piece_tracker, lengths, storage.layout()); + if became_local { + add_piece_progress(&mut s, lengths, storage.layout(), vpi); + } s.finished = piece_tracker.is_complete(); } else { log::debug!("piece {} verify failed", vr.piece_index); @@ -1637,36 +1752,53 @@ async fn drive_peer( // PieceTracker::choose_piece (does NOT skip in_flight) — does not loop // forever picking the same piece every iteration. let mut exhausted: HashSet = HashSet::new(); + let requestable_pieces = piece_tracker.choose_requestable_pieces(&peer.bitfield, pid); + let mut requestable_idx = 0usize; + let mut endgame_pieces: Option> = None; + let mut endgame_idx = 0usize; + let mut current_piece: Option = None; while peer.outstanding.len() < max_outstanding { - // Use the peer id as a hint to distribute piece selection across - // peers, avoiding the scenario where every peer picks the same piece. - // - // Endgame fallback: when no requestable (i.e. non-in-flight) piece - // remains, flip endgame on if we're near completion and then ask the - // tracker for ANY piece the peer has (including in-flight ones) that - // we haven't already exhausted this call. ChunkTracker::next_chunk's - // endgame branch will then duplicate another peer's outstanding chunk - // request. Without this fallback, the endgame flag is set but never - // observed, fast peers idle until REQUEST_TIMEOUT reclaims, and the - // last 1% takes minutes. - let piece = match piece_tracker.choose_requestable_piece(&peer.bitfield, pid) { - Some(p) if !exhausted.contains(&p.get()) => p, - _ => { - if !chunk_tracker.endgame() && chunk_tracker.pending_chunks() <= 64 { - chunk_tracker.set_endgame(true); - } - if chunk_tracker.endgame() { - match piece_tracker.choose_piece_excluding(&peer.bitfield, pid, &exhausted) { - Some(p) => p, - None => break, + let piece = match current_piece { + Some(piece) => piece, + None => { + let mut selected = None; + loop { + if let Some(piece) = requestable_pieces.get(requestable_idx).copied() { + requestable_idx += 1; + if !exhausted.contains(&piece.get()) { + selected = Some(piece); + break; + } + continue; + } + + if !chunk_tracker.endgame() && chunk_tracker.pending_chunks() <= 64 { + chunk_tracker.set_endgame(true); + } + if chunk_tracker.endgame() { + let pieces = endgame_pieces.get_or_insert_with(|| { + piece_tracker.choose_pieces(&peer.bitfield, pid) + }); + if let Some(piece) = pieces.get(endgame_idx).copied() { + endgame_idx += 1; + if !exhausted.contains(&piece.get()) { + selected = Some(piece); + break; + } + continue; + } } - } else { break; } + let Some(piece) = selected else { + break; + }; + piece } }; match chunk_tracker.next_chunk(piece, pid) { Some(chunk) => { + current_piece = Some(piece); let info = chunk.info; let prior = chunk.prior_state; // try_send + rollback so a single peer with a full writer @@ -1696,6 +1828,7 @@ async fn drive_peer( } } None => { + current_piece = None; // All chunks of this piece are already requested or received // (under endgame, also already requested by THIS peer). Mark // in_flight so non-endgame `choose_requestable_piece` skips @@ -1788,6 +1921,30 @@ fn completed_bytes(pt: &PieceTracker, lengths: &Lengths) -> u64 { total } +fn mark_verified_piece_local(pt: &mut PieceTracker, vpi: super::core::ValidPieceIndex) -> bool { + let was_local = pt.has_local(vpi); + pt.set_local(vpi, true); + !was_local +} + +fn add_piece_progress( + stats: &mut TorrentStats, + lengths: &Lengths, + layout: &FileSet, + vpi: super::core::ValidPieceIndex, +) { + let offset = lengths.piece_offset(vpi); + let len = lengths.piece_length_of(vpi) as u64; + stats.progress_bytes = stats.progress_bytes.saturating_add(len); + if stats.file_progress.len() < layout.files().len() { + stats.file_progress.resize(layout.files().len(), 0); + } + for span in layout.spans_for(offset, len) { + stats.file_progress[span.file_index] = + stats.file_progress[span.file_index].saturating_add(span.len); + } +} + /// Distribute the bytes of every completed piece across the files it /// overlaps. Used to populate `TorrentStats::file_progress` so per-file /// completion can be reported in the UI. @@ -1860,14 +2017,15 @@ fn spawn_tracker_pollers( peer_id: Id20, port: u16, stats: Arc>, -) { +) -> tokio::task::JoinSet<()> { + let mut tasks = tokio::task::JoinSet::new(); for url in trackers { for info_hash in &info_hashes { let tx = tx.clone(); let url = url.clone(); let info_hash = *info_hash; let stats = Arc::clone(&stats); - tokio::spawn(async move { + tasks.spawn(async move { let mut event = AnnounceEvent::Started; // Track whether we have already announced `Completed` to // this tracker so we only emit it once per session, even if @@ -1924,6 +2082,30 @@ fn spawn_tracker_pollers( }); } } + tasks +} + +fn release_peer_scheduler_state( + pid: u32, + bitfield: &[u8], + piece_tracker: &mut PieceTracker, + chunk_tracker: &mut ChunkTracker, + piece_assemblies: &mut HashMap, + lengths: &Lengths, +) { + piece_tracker.remove_peer_bitfield(bitfield); + let freed = chunk_tracker.release_peer(pid); + for piece_idx in freed { + if let Ok(vpi) = lengths.validate_piece(piece_idx) { + piece_tracker.clear_in_flight(vpi); + } + let should_drop = piece_assemblies + .get(&piece_idx) + .is_none_or(|a| a.received_chunks.is_empty()); + if should_drop { + piece_assemblies.remove(&piece_idx); + } + } } /// Build a `HASHES` / `HashReject` reply for an inbound BEP 52 @@ -2035,6 +2217,8 @@ mod tests { use super::*; use crate::core::merkle::{compute_root, hash_block, MerkleProofTable, BLOCK_SIZE}; use crate::core::Id32; + use crate::core::TorrentMetaInfo; + use std::path::Path; fn make_v2_tables(num_pieces: usize, piece_length: u32) -> (Vec, Id32) { let blocks_per_piece = piece_length / BLOCK_SIZE; @@ -2075,6 +2259,67 @@ mod tests { (vec![table], file_root) } + fn two_file_layout() -> (Lengths, FileSet) { + let info = ValidatedTorrentMetaV1Info { + name: "root".into(), + piece_length: 10, + pieces: vec![0; 20 * 3], + private: false, + files: vec![ + TorrentMetaInfo { + path: vec!["a".into()], + length: 15, + }, + TorrentMetaInfo { + path: vec!["b".into()], + length: 15, + }, + ], + single_file_mode: false, + }; + let lengths = Lengths::new(30, 10).unwrap(); + let layout = FileSet::from_meta(&info, Path::new("/tmp")); + (lengths, layout) + } + + #[test] + fn verified_piece_progress_is_idempotent() { + let (lengths, layout) = two_file_layout(); + let mut stats = TorrentStats::initial( + lengths.total_length(), + layout.files().iter().map(|f| f.length).collect(), + ); + let mut tracker = PieceTracker::new(lengths); + let piece = tracker.lengths().validate_piece(1).unwrap(); + + if mark_verified_piece_local(&mut tracker, piece) { + add_piece_progress(&mut stats, tracker.lengths(), &layout, piece); + } + assert_eq!(stats.progress_bytes, 10); + assert_eq!(stats.file_progress, vec![5, 5]); + + if mark_verified_piece_local(&mut tracker, piece) { + add_piece_progress(&mut stats, tracker.lengths(), &layout, piece); + } + assert_eq!(stats.progress_bytes, 10); + assert_eq!(stats.file_progress, vec![5, 5]); + } + + #[test] + fn peer_registry_scope_prevents_cross_session_take() { + let torrent_id = 7; + let pid = 3; + let addr: SocketAddr = "127.0.0.1:6881".parse().unwrap(); + let scope_a = Arc::new(()); + let scope_b = Arc::new(()); + let (tx, _rx) = mpsc::channel(1); + + peer_registry::put(torrent_id, pid, &scope_a, tx, addr); + + assert!(peer_registry::take(torrent_id, pid, &scope_b).is_none()); + assert!(peer_registry::take(torrent_id, pid, &scope_a).is_some()); + } + #[test] fn build_hash_response_serves_full_piece_layer() { let (tables, root) = make_v2_tables(4, 64 * 1024); diff --git a/src-tauri/risuko-bt/src/torrent/stats.rs b/src-tauri/risuko-bt/src/torrent/stats.rs index ccf875cf..74bdbca6 100644 --- a/src-tauri/risuko-bt/src/torrent/stats.rs +++ b/src-tauri/risuko-bt/src/torrent/stats.rs @@ -1,6 +1,7 @@ //! Per-torrent live stats use std::net::SocketAddr; +use std::sync::Arc; use std::time::Instant; /// Snapshot of a single connected peer for UI consumption @@ -8,7 +9,7 @@ use std::time::Instant; pub struct PeerSnapshot { pub addr: SocketAddr, /// Raw bitfield bytes; consumers can hex-encode for display - pub bitfield: Vec, + pub bitfield: Arc<[u8]>, pub am_choking: bool, pub am_interested: bool, pub peer_choking: bool, diff --git a/src-tauri/risuko-bt/src/upnp.rs b/src-tauri/risuko-bt/src/upnp.rs index cbf655c5..bfacaa59 100644 --- a/src-tauri/risuko-bt/src/upnp.rs +++ b/src-tauri/risuko-bt/src/upnp.rs @@ -8,7 +8,7 @@ //! Scope: IPv4 only. IPv6 has no UPnP IGD equivalent (PCP/NAT-PMP covers //! a subset of routers; left to a follow-up). -use std::net::{IpAddr, Ipv4Addr, SocketAddr, SocketAddrV4}; +use std::net::{Ipv4Addr, SocketAddr, SocketAddrV4}; use std::sync::atomic::{AtomicUsize, Ordering}; use std::sync::Arc; use std::time::Duration; @@ -43,13 +43,6 @@ impl MapProto { } } -/// Returned from [`map_port`]; kept for backward compatibility with earlier -/// callers that expected an immediate `PortMapping`. -#[derive(Debug, Clone)] -pub struct PortMapping { - pub external: Option, -} - /// Options for [`UpnpPortForwarder`]. Defaults mirror common client choices. #[derive(Debug, Clone)] pub struct UpnpOptions { @@ -119,17 +112,6 @@ pub struct UpnpHandle { } impl UpnpHandle { - /// Snapshot of currently confirmed mappings. - pub fn mappings(&self) -> Vec { - self.active - .lock() - .iter() - .map(|m| PortMapping { - external: Some(SocketAddr::new(IpAddr::V4(m.external_ip), m.external_port)), - }) - .collect() - } - /// Number of currently confirmed mappings without cloning them pub fn mapping_count(&self) -> usize { self.active.lock().len() @@ -159,47 +141,6 @@ impl Drop for UpnpHandle { } } -/// One-shot mapping call. Preserved for backwards compatibility. Discovers -/// the first IGD, maps TCP `port`, and returns the external address; the -/// mapping is not renewed and will expire. -pub async fn map_port(port: u16) -> std::io::Result { - let opts = UpnpOptions::default(); - let endpoints = ssdp_discover(opts.discover_timeout).await?; - let ifaces = NetworkInterface::show().unwrap_or_default(); - for ep in endpoints { - let Ok(root) = fetch_root_desc(&ep.location).await else { - continue; - }; - let Some((control_url, service_type)) = find_wan_service(&root, &ep.location) else { - continue; - }; - let Some(local_ip) = pick_local_ipv4(&ifaces, *ep.received_from.ip()) else { - continue; - }; - if add_port_mapping( - &control_url, - &service_type, - &local_ip, - port, - MapProto::Tcp, - opts.lease, - &opts.description, - ) - .await - .is_ok() - { - let external = get_external_ip(&control_url, &service_type).await.ok(); - return Ok(PortMapping { - external: external.map(|ip| SocketAddr::new(IpAddr::V4(ip), port)), - }); - } - } - Err(std::io::Error::new( - std::io::ErrorKind::NotFound, - "no UPnP IGD responded to SSDP M-SEARCH", - )) -} - // --------------------------------------------------------------------------- // Core loop // --------------------------------------------------------------------------- @@ -208,8 +149,6 @@ pub async fn map_port(port: u16) -> std::io::Result { struct ActiveMapping { control_url: Url, service_type: String, - external_ip: Ipv4Addr, - external_port: u16, port: u16, proto: MapProto, } @@ -301,8 +240,6 @@ async fn discover_and_map( g.push(ActiveMapping { control_url: control_url.clone(), service_type: service_type.clone(), - external_ip: external_ip.unwrap_or(Ipv4Addr::UNSPECIFIED), - external_port: port, port, proto, }); diff --git a/src-tauri/risuko-bt/src/utp/socket.rs b/src-tauri/risuko-bt/src/utp/socket.rs index d4a882e4..afaa3a4b 100644 --- a/src-tauri/risuko-bt/src/utp/socket.rs +++ b/src-tauri/risuko-bt/src/utp/socket.rs @@ -11,6 +11,7 @@ use std::net::SocketAddr; use std::sync::Arc; use std::time::Duration; +use bytes::{Bytes, BytesMut}; use parking_lot::Mutex; use rand::RngExt; use tokio::net::UdpSocket; @@ -24,11 +25,12 @@ pub(crate) type ConnKey = (SocketAddr, u16); /// Maps each live connection to the channel its driver reads packets from. pub(crate) type ConnRegistry = - Arc)>>>>; + Arc>>>; /// Largest UDP datagram we'll read (µTP payloads are MSS-sized; this leaves /// room for the header plus any extensions). const MAX_DATAGRAM: usize = 2048; +const ROUTER_READ_SLAB: usize = MAX_DATAGRAM * 64; const DEFAULT_CONNECT_TIMEOUT: Duration = Duration::from_secs(10); /// A µTP endpoint sharing a single UDP socket across all its connections. @@ -37,6 +39,7 @@ pub struct UtpSocket { registry: ConnRegistry, local_addr: SocketAddr, accept_rx: tokio::sync::Mutex>, + router_handle: Mutex>>, } impl UtpSocket { @@ -54,12 +57,13 @@ impl UtpSocket { .unwrap_or_else(|_| SocketAddr::from(([0, 0, 0, 0], 0))); let registry: ConnRegistry = Arc::new(Mutex::new(HashMap::new())); let (accept_tx, accept_rx) = mpsc::unbounded_channel(); - tokio::spawn(router(udp.clone(), registry.clone(), accept_tx)); + let router_handle = tokio::spawn(router(udp.clone(), registry.clone(), accept_tx)); Arc::new(Self { udp, registry, local_addr, accept_rx: tokio::sync::Mutex::new(accept_rx), + router_handle: Mutex::new(Some(router_handle)), }) } @@ -144,6 +148,21 @@ impl UtpSocket { .await .ok_or_else(|| io::Error::new(io::ErrorKind::BrokenPipe, "utp socket closed")) } + + pub fn shutdown(&self) { + if let Some(handle) = self.router_handle.lock().take() { + handle.abort(); + } + self.registry.lock().clear(); + } +} + +impl Drop for UtpSocket { + fn drop(&mut self) { + if let Some(handle) = self.router_handle.get_mut().take() { + handle.abort(); + } + } } /// Reads every datagram and routes it to the owning connection, or opens a new @@ -153,9 +172,13 @@ async fn router( registry: ConnRegistry, accept_tx: mpsc::UnboundedSender, ) { - let mut buf = vec![0u8; MAX_DATAGRAM]; + let mut buf = BytesMut::with_capacity(ROUTER_READ_SLAB); + buf.resize(ROUTER_READ_SLAB, 0); loop { - let (n, src) = match udp.recv_from(&mut buf).await { + if buf.len() < MAX_DATAGRAM { + buf.resize(ROUTER_READ_SLAB, 0); + } + let (n, src) = match udp.recv_from(&mut buf[..MAX_DATAGRAM]).await { Ok(x) => x, // A transient recv error (e.g. ICMP port-unreachable surfaced on // some platforms) shouldn't kill the whole endpoint. @@ -164,12 +187,14 @@ async fn router( let Ok((header, payload)) = UtpHeader::decode(&buf[..n]) else { continue; }; + let payload_offset = n - payload.len(); + let payload = buf.split_to(n).freeze().slice(payload_offset..); let key = (src, header.connection_id); // Fast path: an established connection owns this id. { let reg = registry.lock(); if let Some(tx) = reg.get(&key) { - let _ = tx.send((header, payload.to_vec())); + let _ = tx.send((header, payload)); continue; } } diff --git a/src-tauri/risuko-bt/src/utp/stream.rs b/src-tauri/risuko-bt/src/utp/stream.rs index 0d6c7b4b..13a34555 100644 --- a/src-tauri/risuko-bt/src/utp/stream.rs +++ b/src-tauri/risuko-bt/src/utp/stream.rs @@ -23,6 +23,7 @@ use std::sync::Arc; use std::task::{Context, Poll, Waker}; use std::time::{Duration, Instant}; +use bytes::Bytes; use parking_lot::Mutex; use tokio::io::{AsyncRead, AsyncWrite, ReadBuf}; use tokio::net::UdpSocket; @@ -124,6 +125,11 @@ pub(crate) struct ConnState { eof: bool, error: Option, + /// Recovery marker for the current fast-retransmit episode + /// + /// Further SACKs before this seq is cumulatively acked do not shrink the window again + recovery_seq: Option, + /// Encoded datagrams the driver should put on the wire this iteration. outbox: Vec>, read_waker: Option, @@ -134,7 +140,10 @@ pub(crate) struct ConnState { impl ConnState { fn advertised_window(&self) -> u32 { - RECV_BUF_MAX.saturating_sub(self.recv_ready.len()) as u32 + // Out-of-order bytes in `reorder` also consume receive buffer, so count them in the advertised window + let reorder_bytes: usize = self.reorder.values().map(|b| b.len()).sum(); + let used = self.recv_ready.len().saturating_add(reorder_bytes); + RECV_BUF_MAX.saturating_sub(used) as u32 } /// Encode an outstanding packet with current ack/window/timestamps. @@ -253,6 +262,12 @@ impl ConnState { } } if acked_any { + // Recovery ends once its marker is cumulatively acked, re-arming decrease for the next loss + if let Some(rseq) = self.recovery_seq { + if !seq_after(rseq, ack_nr) { + self.recovery_seq = None; + } + } self.update_cwnd(their_delay, acked_bytes); self.notify_write(); } @@ -279,6 +294,13 @@ impl ConnState { // fast-retransmit it. Extract its fields first so the mutable borrow // is released before we re-encode (which borrows &self). if sacked { + // SACK past a hole signals loss; decrease once per `recovery_seq` episode + if self.recovery_seq.is_none() { + if let Some(back) = self.unacked.back() { + self.recovery_seq = Some(back.seq_nr); + self.max_window = (self.max_window / 2).max(MIN_CWND); + } + } let p = self.unacked.front_mut().map(|f| { f.sent_at = Instant::now(); f.transmissions += 1; @@ -545,7 +567,7 @@ pub(crate) enum RoleKind { pub(crate) struct DriverConfig { pub udp: Arc, pub remote: SocketAddr, - pub incoming: mpsc::UnboundedReceiver<(UtpHeader, Vec)>, + pub incoming: mpsc::UnboundedReceiver<(UtpHeader, Bytes)>, pub registry: ConnRegistry, pub key: ConnKey, } @@ -584,6 +606,7 @@ pub(crate) fn new_shared(remote: SocketAddr, conn_id_send: u16, kind: RoleKind) peer_fin: None, eof: false, error: None, + recovery_seq: None, outbox: Vec::new(), read_waker: None, write_waker: None, @@ -857,7 +880,7 @@ mod tests { sent_at: Instant::now() - Duration::from_secs(60), transmissions: MAX_RETRANSMITS, }); - st.send_buf.extend(std::iter::repeat(0u8).take(100)); + st.send_buf.extend(std::iter::repeat_n(0u8, 100)); st.fail(io::ErrorKind::TimedOut); assert!(st.unacked.is_empty()); diff --git a/src-tauri/risuko-bt/src/wire/extended.rs b/src-tauri/risuko-bt/src/wire/extended.rs index 5f376772..ea813bb5 100644 --- a/src-tauri/risuko-bt/src/wire/extended.rs +++ b/src-tauri/risuko-bt/src/wire/extended.rs @@ -8,7 +8,7 @@ use std::collections::HashMap; use std::net::IpAddr; -use bytes::{BufMut, Bytes, BytesMut}; +use bytes::Bytes; use super::super::bencode::{decode_all, encode_to_vec, Value}; @@ -255,21 +255,9 @@ pub fn parse_ut_pex( Some((v4, v6)) } -/// Encode a compact peer list as a bencoded ut_pex payload (just `added`) -pub fn build_ut_pex(v4: &[std::net::SocketAddrV4]) -> Bytes { - let mut buf = BytesMut::with_capacity(v4.len() * 6); - for addr in v4 { - buf.put_slice(&addr.ip().octets()); - buf.put_u16(addr.port()); - } - let dict = Value::Dict(vec![(b"added".to_vec(), Value::Bytes(buf.to_vec()))]); - Bytes::from(encode_to_vec(&dict)) -} - #[cfg(test)] mod tests { use super::*; - use std::net::SocketAddrV4; #[test] fn handshake_round_trip() { @@ -328,16 +316,4 @@ mod tests { let parsed = ExtHandshake::decode(&bytes).unwrap(); assert_eq!(parsed.yourip, Some(ip)); } - - #[test] - fn ut_pex_round_trip() { - let addrs = vec![ - SocketAddrV4::new("1.2.3.4".parse().unwrap(), 6881), - SocketAddrV4::new("5.6.7.8".parse().unwrap(), 6882), - ]; - let bytes = build_ut_pex(&addrs); - let (v4, _) = parse_ut_pex(&bytes).unwrap(); - assert_eq!(v4.len(), 2); - assert_eq!(v4[0].port(), 6881); - } } diff --git a/src-tauri/risuko-bt/src/wire/handshake.rs b/src-tauri/risuko-bt/src/wire/handshake.rs index 98550166..c9017848 100644 --- a/src-tauri/risuko-bt/src/wire/handshake.rs +++ b/src-tauri/risuko-bt/src/wire/handshake.rs @@ -46,10 +46,6 @@ pub struct Handshake { } impl Handshake { - pub fn new(info_hash: Id20, peer_id: Id20) -> Self { - Self::new_with_v2(info_hash, peer_id, true) - } - pub fn new_with_v2(info_hash: Id20, peer_id: Id20, advertise_v2: bool) -> Self { // We always advertise the BEP-10 extension-protocol bit. The BEP-52 v2 bit // is *only* set when the caller is explicitly connecting on a v2 info-hash @@ -77,11 +73,6 @@ impl Handshake { self.reserved[b] & m != 0 } - pub fn has_dht(&self) -> bool { - let (b, m) = reserved::DHT; - self.reserved[b] & m != 0 - } - /// True if the peer advertises BEP 52 (BitTorrent v2) capability pub fn has_v2(&self) -> bool { let (b, m) = reserved::V2; @@ -123,25 +114,15 @@ mod tests { #[test] fn round_trip_advertises_ext_and_optionally_v2() { - let hs = Handshake::new(Id20([0xaau8; 20]), Id20([0xbbu8; 20])); + let hs = Handshake::new_with_v2(Id20([0xaau8; 20]), Id20([0xbbu8; 20]), true); let bytes = hs.to_bytes(); let parsed = Handshake::parse(&bytes).unwrap(); assert_eq!(hs, parsed); assert!(parsed.has_ext_protocol()); - // DHT bit is never advertised: we don't act on `Port` from peers - assert!(!parsed.has_dht()); - // `Handshake::new` defaults `advertise_v2` to true so this carries the v2 capability bit + // `advertise_v2 = true` carries the v2 capability bit assert!(parsed.has_v2()); } - #[test] - fn dht_bit_is_never_set() { - let hs_off = Handshake::new_with_v2(Id20([0xaau8; 20]), Id20([0xbbu8; 20]), false); - let hs_on = Handshake::new_with_v2(Id20([0xaau8; 20]), Id20([0xbbu8; 20]), true); - assert!(!hs_off.has_dht()); - assert!(!hs_on.has_dht()); - } - #[test] fn v2_reserved_bit_is_caller_controlled() { let hs_off = Handshake::new_with_v2(Id20([0xaau8; 20]), Id20([0xbbu8; 20]), false); diff --git a/src-tauri/risuko-bt/src/wire/mse.rs b/src-tauri/risuko-bt/src/wire/mse.rs index 34651039..dff2ae45 100644 --- a/src-tauri/risuko-bt/src/wire/mse.rs +++ b/src-tauri/risuko-bt/src/wire/mse.rs @@ -197,10 +197,29 @@ pub enum MseError { /// Find the first occurrence of `needle` in `hay`. Returns its starting index /// if found, else `None`. Used by the incoming side to locate `HASH('req1',S)` pub fn find_subsequence(hay: &[u8], needle: &[u8]) -> Option { + find_subsequence_from(hay, needle, 0) +} + +/// Like [`find_subsequence`], but skips candidate offsets before `start` +pub fn find_subsequence_from(hay: &[u8], needle: &[u8], start: usize) -> Option { if needle.is_empty() || needle.len() > hay.len() { return None; } - hay.windows(needle.len()).position(|w| w == needle) + let max_start = hay.len() - needle.len(); + if start > max_start { + return None; + } + hay[start..] + .windows(needle.len()) + .position(|w| w == needle) + .map(|off| off + start) +} + +/// First candidate offset to rescan after appending bytes to a searched buffer +/// +/// Backs up by `needle_len - 1` to keep matches that cross the old/new boundary +pub fn scan_start_after_append(previous_len: usize, needle_len: usize) -> usize { + previous_len.saturating_sub(needle_len.saturating_sub(1)) } /// Build the initiator's third message body (plaintext, to be RC4-encrypted @@ -316,4 +335,21 @@ mod tests { assert_eq!(find_subsequence(hay, b"zzzz"), None); assert_eq!(find_subsequence(hay, b""), None); } + + #[test] + fn cursor_search_finds_boundary_spanning_subsequence() { + let needle = b"needle"; + let mut hay = b"aaaa nee".to_vec(); + let start = scan_start_after_append(hay.len(), needle.len()); + hay.extend_from_slice(b"dle zzzz"); + + assert_eq!(find_subsequence_from(&hay, needle, start), Some(5)); + } + + #[test] + fn cursor_search_skips_already_scanned_prefix() { + let hay = b"needle xxxx needle"; + + assert_eq!(find_subsequence_from(hay, b"needle", 1), Some(12)); + } } diff --git a/src-tauri/risuko-cli/src/commands.rs b/src-tauri/risuko-cli/src/commands.rs index 14f3dc0d..fbb035b7 100644 --- a/src-tauri/risuko-cli/src/commands.rs +++ b/src-tauri/risuko-cli/src/commands.rs @@ -9,7 +9,7 @@ use risuko_engine::engine::manager::TaskManager; use risuko_engine::engine::options::EngineOptions; use risuko_engine::engine::rpc::RpcServer; -use crate::progress::{self, format_size, format_size_speed}; +use crate::progress::{self, extract_filename, format_size, format_size_speed, parse_num}; use crate::rpc_client::RpcClient; use crate::{ ConfigAction, ConfigCommand, DownloadArgs, GidArgs, PauseArgs, RemoveArgs, ResumeArgs, RpcArgs, @@ -22,18 +22,19 @@ fn resolve_rpc_secret(explicit: Option) -> Option { .or_else(read_secret_from_config) } +fn rpc_client(port: u16, secret: Option) -> RpcClient { + let host = resolve_rpc_host(); + RpcClient::new_with_host(&host, port, secret) +} + +fn resolve_rpc_host() -> String { + read_options_from_config().rpc_host() +} + /// Read rpc-secret from the config files, returning None if empty or absent /// user.json takes precedence over system.json fn read_secret_from_config() -> Option { - let config_dir = get_config_dir(); - let mut merged = load_config(&config_dir.join("system.json"), Map::new()); - let user = load_config(&config_dir.join("user.json"), Map::new()); - merged.extend(user); - let secret = merged - .get("rpc-secret") - .and_then(|v| v.as_str()) - .unwrap_or("") - .to_string(); + let secret = read_options_from_config().rpc_secret(); if secret.is_empty() { None } else { @@ -41,18 +42,25 @@ fn read_secret_from_config() -> Option { } } +fn read_options_from_config() -> EngineOptions { + let config_dir = get_config_dir(); + let system = load_config(&config_dir.join("system.json"), defaults::system_defaults()); + let user = load_config(&config_dir.join("user.json"), defaults::user_defaults()); + EngineOptions::from_config(&system, &user) +} + // Download pub async fn download(args: DownloadArgs) -> Result<(), Box> { let secret = resolve_rpc_secret(args.rpc_secret.clone()); - let client = RpcClient::new(args.rpc_port, secret.clone()); + let client = rpc_client(args.rpc_port, secret.clone()); let mut headless_engine = None; if !client.is_engine_running().await { eprintln!("No running Risuko instance found. Starting headless engine..."); + // `start_headless_engine` waits for the RPC bind and task manager init, so no readiness sleep is needed let engine = start_headless_engine(args.rpc_port).await?; headless_engine = Some(engine); - tokio::time::sleep(std::time::Duration::from_millis(200)).await; } let result = do_download(&client, &args).await; @@ -166,7 +174,7 @@ async fn do_download( pub async fn status(args: StatusArgs) -> Result<(), Box> { let secret = resolve_rpc_secret(args.rpc_secret.clone()); - let client = RpcClient::new(args.rpc_port, secret); + let client = rpc_client(args.rpc_port, secret); require_engine(&client).await?; if let Some(ref gid) = args.gid { @@ -222,7 +230,7 @@ pub async fn status(args: StatusArgs) -> Result<(), Box> pub async fn pause(args: PauseArgs) -> Result<(), Box> { let secret = resolve_rpc_secret(args.rpc_secret.clone()); - let client = RpcClient::new(args.rpc_port, secret); + let client = rpc_client(args.rpc_port, secret); require_engine(&client).await?; client.call("risuko.pause", vec![json!(args.gid)]).await?; println!("Paused: {}", args.gid); @@ -231,7 +239,7 @@ pub async fn pause(args: PauseArgs) -> Result<(), Box> { pub async fn resume(args: ResumeArgs) -> Result<(), Box> { let secret = resolve_rpc_secret(args.rpc_secret.clone()); - let client = RpcClient::new(args.rpc_port, secret); + let client = rpc_client(args.rpc_port, secret); require_engine(&client).await?; client.call("risuko.unpause", vec![json!(args.gid)]).await?; println!("Resumed: {}", args.gid); @@ -240,7 +248,7 @@ pub async fn resume(args: ResumeArgs) -> Result<(), Box> pub async fn remove(args: RemoveArgs) -> Result<(), Box> { let secret = resolve_rpc_secret(args.rpc_secret.clone()); - let client = RpcClient::new(args.rpc_port, secret); + let client = rpc_client(args.rpc_port, secret); require_engine(&client).await?; for gid in &args.gids { match client.call("risuko.remove", vec![json!(gid)]).await { @@ -255,7 +263,7 @@ pub async fn remove(args: RemoveArgs) -> Result<(), Box> pub async fn pause_all(args: RpcArgs) -> Result<(), Box> { let secret = resolve_rpc_secret(args.rpc_secret.clone()); - let client = RpcClient::new(args.rpc_port, secret); + let client = rpc_client(args.rpc_port, secret); require_engine(&client).await?; client.call("risuko.pauseAll", vec![]).await?; println!("All downloads paused."); @@ -264,7 +272,7 @@ pub async fn pause_all(args: RpcArgs) -> Result<(), Box> pub async fn resume_all(args: RpcArgs) -> Result<(), Box> { let secret = resolve_rpc_secret(args.rpc_secret.clone()); - let client = RpcClient::new(args.rpc_port, secret); + let client = rpc_client(args.rpc_port, secret); require_engine(&client).await?; client.call("risuko.unpauseAll", vec![]).await?; println!("All downloads resumed."); @@ -275,7 +283,7 @@ pub async fn resume_all(args: RpcArgs) -> Result<(), Box> pub async fn global_stat(args: RpcArgs) -> Result<(), Box> { let secret = resolve_rpc_secret(args.rpc_secret.clone()); - let client = RpcClient::new(args.rpc_port, secret); + let client = rpc_client(args.rpc_port, secret); require_engine(&client).await?; let stat = client.call("risuko.getGlobalStat", vec![]).await?; @@ -306,7 +314,7 @@ pub async fn global_stat(args: RpcArgs) -> Result<(), Box pub async fn files(args: GidArgs) -> Result<(), Box> { let secret = resolve_rpc_secret(args.rpc_secret.clone()); - let client = RpcClient::new(args.rpc_port, secret); + let client = rpc_client(args.rpc_port, secret); require_engine(&client).await?; let result = client .call("risuko.getFiles", vec![json!(args.gid)]) @@ -336,7 +344,7 @@ pub async fn files(args: GidArgs) -> Result<(), Box> { pub async fn peers(args: GidArgs) -> Result<(), Box> { let secret = resolve_rpc_secret(args.rpc_secret.clone()); - let client = RpcClient::new(args.rpc_port, secret); + let client = rpc_client(args.rpc_port, secret); require_engine(&client).await?; let result = client .call("risuko.getPeers", vec![json!(args.gid)]) @@ -374,7 +382,7 @@ pub async fn peers(args: GidArgs) -> Result<(), Box> { pub async fn purge(args: RpcArgs) -> Result<(), Box> { let secret = resolve_rpc_secret(args.rpc_secret.clone()); - let client = RpcClient::new(args.rpc_port, secret); + let client = rpc_client(args.rpc_port, secret); require_engine(&client).await?; client.call("risuko.purgeDownloadResult", vec![]).await?; println!("Purged completed/error/removed download results."); @@ -444,7 +452,7 @@ pub async fn rss(cmd: RssCommand) -> Result<(), Box> { rpc_secret, } => { let secret = resolve_rpc_secret(rpc_secret); - let client = RpcClient::new(rpc_port, secret); + let client = rpc_client(rpc_port, secret); require_engine(&client).await?; let result = client.call("risuko.addRssFeed", vec![json!(url)]).await?; println!("{}", serde_json::to_string_pretty(&result)?); @@ -456,7 +464,7 @@ pub async fn rss(cmd: RssCommand) -> Result<(), Box> { json, } => { let secret = resolve_rpc_secret(rpc_secret); - let client = RpcClient::new(rpc_port, secret); + let client = rpc_client(rpc_port, secret); require_engine(&client).await?; let result = client.call("risuko.getRssFeeds", vec![]).await?; if json { @@ -480,7 +488,7 @@ pub async fn rss(cmd: RssCommand) -> Result<(), Box> { rpc_secret, } => { let secret = resolve_rpc_secret(rpc_secret); - let client = RpcClient::new(rpc_port, secret); + let client = rpc_client(rpc_port, secret); require_engine(&client).await?; client.call("risuko.refreshAllRssFeeds", vec![]).await?; println!("RSS feeds refreshed."); @@ -492,7 +500,7 @@ pub async fn rss(cmd: RssCommand) -> Result<(), Box> { rpc_secret, } => { let secret = resolve_rpc_secret(rpc_secret); - let client = RpcClient::new(rpc_port, secret); + let client = rpc_client(rpc_port, secret); require_engine(&client).await?; client.call("risuko.removeRssFeed", vec![json!(id)]).await?; println!("Removed RSS feed: {}", id); @@ -508,8 +516,17 @@ pub async fn serve(args: ServeArgs) -> Result<(), Box> { let engine = start_headless_engine(args.rpc_port).await?; tracing::info!("Risuko engine running, press Ctrl+C to stop"); - tokio::signal::ctrl_c().await?; - tracing::info!("Received Ctrl+C, shutting down..."); + // Shut down on Ctrl+C or RPC `risuko.shutdown` + // The `shutdown_requested()` borrow ends with `select!`, so `engine.shutdown()` can consume `engine` + tokio::select! { + res = tokio::signal::ctrl_c() => { + res?; + tracing::info!("Received Ctrl+C, shutting down..."); + } + _ = engine.shutdown_requested() => { + tracing::info!("Shutdown requested via RPC, shutting down..."); + } + } engine.shutdown().await; Ok(()) } @@ -518,7 +535,7 @@ pub async fn serve(args: ServeArgs) -> Result<(), Box> { pub async fn shutdown(args: RpcArgs) -> Result<(), Box> { let secret = resolve_rpc_secret(args.rpc_secret.clone()); - let client = RpcClient::new(args.rpc_port, secret); + let client = rpc_client(args.rpc_port, secret); require_engine(&client).await?; client.call("risuko.shutdown", vec![]).await?; println!("Shutdown request sent."); @@ -532,9 +549,15 @@ struct HeadlessEngine { rpc_server: RpcServer, progress_task: tokio::task::JoinHandle<()>, auto_save_task: tokio::task::JoinHandle<()>, + shutdown_notify: Arc, } impl HeadlessEngine { + /// Future resolved by an RPC shutdown request + fn shutdown_requested(&self) -> impl std::future::Future + '_ { + self.shutdown_notify.notified() + } + async fn shutdown(mut self) { self.progress_task.abort(); self.auto_save_task.abort(); @@ -577,7 +600,7 @@ async fn start_headless_engine( tracing::info!("Task manager ready"); - let (rpc_shutdown_tx, _rpc_shutdown_rx) = tokio::sync::mpsc::channel::<()>(1); + let (rpc_shutdown_tx, mut rpc_shutdown_rx) = tokio::sync::mpsc::channel::<()>(1); let mut rpc_server = RpcServer::new( rpc_host.clone(), @@ -614,11 +637,22 @@ async fn start_headless_engine( } }); + // Monitor RPC shutdown requests, such as `risuko shutdown` + let shutdown_notify = Arc::new(tokio::sync::Notify::new()); + let shutdown_notify_clone = shutdown_notify.clone(); + tokio::spawn(async move { + if rpc_shutdown_rx.recv().await.is_some() { + tracing::info!("Shutdown requested via RPC"); + shutdown_notify_clone.notify_one(); + } + }); + Ok(HeadlessEngine { manager, rpc_server, progress_task, auto_save_task, + shutdown_notify, }) } @@ -653,27 +687,6 @@ fn load_config(path: &std::path::Path, defaults: Map) -> Map u64 { - val.get(key) - .and_then(|v| v.as_str().and_then(|s| s.parse().ok()).or(v.as_u64())) - .unwrap_or(0) -} - -fn extract_name(task: &Value) -> String { - task.get("files") - .and_then(|f| f.as_array()) - .and_then(|arr| arr.first()) - .and_then(|f| f.get("path")) - .and_then(|p| p.as_str()) - .and_then(|p| { - std::path::Path::new(p) - .file_name() - .map(|n| n.to_string_lossy().to_string()) - }) - .map(|n| n.strip_suffix(".part").unwrap_or(&n).to_string()) - .unwrap_or_else(|| "-".into()) -} - fn print_task_table(tasks: &[Value]) { println!( "{:<18} {:<10} {:<30} {:>9} {:>12} {:>10}", @@ -687,7 +700,7 @@ fn print_task_table(tasks: &[Value]) { let total = parse_num(task, "totalLength"); let completed = parse_num(task, "completedLength"); let speed = parse_num(task, "downloadSpeed"); - let name = extract_name(task); + let name = extract_filename(task, "-"); let pct = if total > 0 { format!("{:.1}%", completed as f64 / total as f64 * 100.0) @@ -727,7 +740,7 @@ fn print_task_detail(task: &Value) { let completed = parse_num(task, "completedLength"); let dl_speed = parse_num(task, "downloadSpeed"); let ul_speed = parse_num(task, "uploadSpeed"); - let name = extract_name(task); + let name = extract_filename(task, "-"); let pct = if total > 0 { format!("{:.1}%", completed as f64 / total as f64 * 100.0) diff --git a/src-tauri/risuko-cli/src/progress.rs b/src-tauri/risuko-cli/src/progress.rs index 4b0e43c3..9231dae7 100644 --- a/src-tauri/risuko-cli/src/progress.rs +++ b/src-tauri/risuko-cli/src/progress.rs @@ -35,7 +35,7 @@ pub async fn watch_download( let total: u64 = parse_num(&status, "totalLength"); let completed: u64 = parse_num(&status, "completedLength"); let speed: u64 = parse_num(&status, "downloadSpeed"); - let name = extract_filename(&status); + let name = extract_filename(&status, "unknown"); if json_output { println!("{}", serde_json::to_string(&status)?); @@ -74,13 +74,13 @@ pub async fn watch_download( } } -fn parse_num(val: &Value, key: &str) -> u64 { +pub fn parse_num(val: &Value, key: &str) -> u64 { val.get(key) .and_then(|v| v.as_str().and_then(|s| s.parse().ok()).or(v.as_u64())) .unwrap_or(0) } -fn extract_filename(status: &Value) -> String { +pub fn extract_filename(status: &Value, fallback: &str) -> String { status .get("files") .and_then(|f| f.as_array()) @@ -93,7 +93,7 @@ fn extract_filename(status: &Value) -> String { .map(|n| n.to_string_lossy().to_string()) }) .map(|n| n.strip_suffix(".part").unwrap_or(&n).to_string()) - .unwrap_or_else(|| "unknown".into()) + .unwrap_or_else(|| fallback.into()) } fn print_progress(name: &str, status: &str, total: u64, completed: u64, speed: u64) { diff --git a/src-tauri/risuko-cli/src/rpc_client.rs b/src-tauri/risuko-cli/src/rpc_client.rs index 96fe5efa..0469367d 100644 --- a/src-tauri/risuko-cli/src/rpc_client.rs +++ b/src-tauri/risuko-cli/src/rpc_client.rs @@ -8,7 +8,7 @@ pub struct RpcClient { } impl RpcClient { - pub fn new(port: u16, secret: Option) -> Self { + pub fn new_with_host(host: &str, port: u16, secret: Option) -> Self { // Fail closed: a default client would silently drop the timeout and // connect_timeout configured above, leaving JSON-RPC calls free to hang // indefinitely. Building the client cannot legitimately fail with this @@ -18,8 +18,19 @@ impl RpcClient { .connect_timeout(std::time::Duration::from_secs(5)) .build() .expect("Failed to build HTTP client with custom config"); + // Map wildcard binds to loopback; 0.0.0.0 and :: are listeners, not dial targets + let connect_host = match host.trim() { + "" | "0.0.0.0" | "::" | "[::]" => "127.0.0.1", + h => h, + }; + // Bracket bare IPv6 literals so the URL authority parses correctly + let url = if connect_host.contains(':') && !connect_host.starts_with('[') { + format!("http://[{}]:{}/jsonrpc", connect_host, port) + } else { + format!("http://{}:{}/jsonrpc", connect_host, port) + }; Self { - url: format!("http://127.0.0.1:{}/jsonrpc", port), + url, secret, client, id_counter: std::sync::atomic::AtomicU64::new(1), diff --git a/src-tauri/risuko-engine/Cargo.toml b/src-tauri/risuko-engine/Cargo.toml index 1ed58f7a..be25cb26 100644 --- a/src-tauri/risuko-engine/Cargo.toml +++ b/src-tauri/risuko-engine/Cargo.toml @@ -64,6 +64,9 @@ libc = "0.2" [features] default = [] +# Builds the experimental ADC/NMDC peer-transfer clients. The default engine +# still parses ADC/NMDC URIs and returns the current NoSource stub. +adc-transfer = [] [dev-dependencies] tempfile = "3" diff --git a/src-tauri/risuko-engine/src/engine/adc/mod.rs b/src-tauri/risuko-engine/src/engine/adc/mod.rs index 8d03fe01..867aa727 100644 --- a/src-tauri/risuko-engine/src/engine/adc/mod.rs +++ b/src-tauri/risuko-engine/src/engine/adc/mod.rs @@ -10,12 +10,15 @@ //! (NMDC) or `CGET file …` (ADC) /// ADC binary-frame protocol implementation (`CSUP`, `BINF`, `BGET`, `BRES`) +#[cfg(feature = "adc-transfer")] pub mod adc_proto; /// Top-level download orchestrator dispatching by hub dialect pub mod download; /// NMDC (legacy DC++) line-text protocol client +#[cfg(feature = "adc-transfer")] pub mod nmdc; /// Streaming peer-side file transfer over both NMDC and ADC +#[cfg(feature = "adc-transfer")] pub mod transfer; /// Shared URI parsers and protocol-agnostic types pub mod types; diff --git a/src-tauri/risuko-engine/src/engine/cookie_store.rs b/src-tauri/risuko-engine/src/engine/cookie_store.rs index 07670770..01de4b79 100644 --- a/src-tauri/risuko-engine/src/engine/cookie_store.rs +++ b/src-tauri/risuko-engine/src/engine/cookie_store.rs @@ -127,8 +127,10 @@ impl CookieStore { } entry.last_validated_at = now; + // Lowercase host keys so exact lookup, remove, and touch share one form + let key = entry.host.to_ascii_lowercase(); let mut s = self.state.write(); - s.entries.insert(entry.host.clone(), entry); + s.entries.insert(key, entry); if s.entries.len() > MAX_ENTRIES { let mut by_age: Vec<(String, u64)> = s @@ -148,8 +150,9 @@ impl CookieStore { /// 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 host = host.to_ascii_lowercase(); let mut s = self.state.write(); - if let Some(entry) = s.entries.get_mut(host) { + 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); @@ -157,8 +160,9 @@ impl CookieStore { } pub fn remove(&self, host: &str) -> Result { + let host = host.to_ascii_lowercase(); let mut s = self.state.write(); - let removed = s.entries.remove(host).is_some(); + let removed = s.entries.remove(&host).is_some(); if removed { write_to_disk(&self.path, &s)?; } diff --git a/src-tauri/risuko-engine/src/engine/ed2k/chunks.rs b/src-tauri/risuko-engine/src/engine/ed2k/chunks.rs index d47c509d..adee92af 100644 --- a/src-tauri/risuko-engine/src/engine/ed2k/chunks.rs +++ b/src-tauri/risuko-engine/src/engine/ed2k/chunks.rs @@ -12,6 +12,8 @@ pub struct ChunkManager { chunk_hashes: Vec<[u8; 16]>, chunk_status: Vec, completed_length: u64, + /// Output handle opened on first write and reused to avoid open(2) per packet + file: Option, } impl ChunkManager { @@ -24,6 +26,7 @@ impl ChunkManager { chunk_hashes: Vec::new(), chunk_status: vec![ChunkStatus::Missing; count as usize], completed_length: 0, + file: None, } } @@ -49,11 +52,20 @@ impl ChunkManager { /// Find the next chunk to download (Missing state, peer has it) pub fn next_needed_chunk(&self, peer_parts: &[bool]) -> Option { + self.next_needed_chunk_excluding(peer_parts, &[]) + } + + /// Like [`next_needed_chunk`], but skips chunks already chosen this request round + /// + /// Chunk status is not updated until data arrives, so this avoids duplicate ranges + pub fn next_needed_chunk_excluding(&self, peer_parts: &[bool], exclude: &[u64]) -> Option { for i in 0..self.chunk_count as usize { + let idx = i as u64; if self.chunk_status[i] == ChunkStatus::Missing + && !exclude.contains(&idx) && (peer_parts.is_empty() || (i < peer_parts.len() && peer_parts[i])) { - return Some(i as u64); + return Some(idx); } } None @@ -102,11 +114,15 @@ impl ChunkManager { )); } - let mut file = OpenOptions::new() - .write(true) - .open(&self.file_path) - .await - .map_err(|e| format!("Failed to open file for writing: {}", e))?; + if self.file.is_none() { + let f = OpenOptions::new() + .write(true) + .open(&self.file_path) + .await + .map_err(|e| format!("Failed to open file for writing: {}", e))?; + self.file = Some(f); + } + let file = self.file.as_mut().expect("file handle just initialized"); file.seek(SeekFrom::Start(offset)) .await diff --git a/src-tauri/risuko-engine/src/engine/ed2k/download.rs b/src-tauri/risuko-engine/src/engine/ed2k/download.rs index e474bb70..c086b388 100644 --- a/src-tauri/risuko-engine/src/engine/ed2k/download.rs +++ b/src-tauri/risuko-engine/src/engine/ed2k/download.rs @@ -33,6 +33,16 @@ pub async fn run_ed2k_download( let file_hash = file_link.file_hash_bytes; let file_path = PathBuf::from(dir).join(&file_link.file_name); + // Part request/answer opcodes use 32-bit offsets + // Reject files over 4 GiB until the eMule 64-bit extension is implemented + if file_link.file_size > u32::MAX as u64 { + return Err(format!( + "ed2k file too large ({} bytes): files over 4 GiB require the 64-bit \ + large-file extension, which is not supported", + file_link.file_size + )); + } + total.store(file_link.file_size, Ordering::Relaxed); let chunks = ChunkManager::new(file_path.clone(), file_link.file_size); @@ -456,8 +466,10 @@ async fn run_peer_download( fn collect_needed_ranges(cm: &ChunkManager, max: usize) -> Vec<(u32, u32)> { let mut ranges = Vec::with_capacity(max); + let mut picked: Vec = Vec::with_capacity(max); for _ in 0..max { - if let Some(idx) = cm.next_needed_chunk(&[]) { + if let Some(idx) = cm.next_needed_chunk_excluding(&[], &picked) { + picked.push(idx); let (s, e) = cm.chunk_range(idx); ranges.push((s as u32, e as u32)); } else { diff --git a/src-tauri/risuko-engine/src/engine/ed2k/peer.rs b/src-tauri/risuko-engine/src/engine/ed2k/peer.rs index d3887fd6..759c154e 100644 --- a/src-tauri/risuko-engine/src/engine/ed2k/peer.rs +++ b/src-tauri/risuko-engine/src/engine/ed2k/peer.rs @@ -37,6 +37,7 @@ pub struct PeerConnection { server_ip: u32, server_port: u16, tx: Option>, + tasks: Vec>, } impl PeerConnection { @@ -56,6 +57,7 @@ impl PeerConnection { server_ip, server_port, tx: None, + tasks: Vec::new(), } } @@ -87,7 +89,7 @@ impl PeerConnection { self.tx = Some(packet_tx.clone()); // Writer task - tokio::spawn(async move { + let writer_task = tokio::spawn(async move { while let Some(packet) = packet_rx.recv().await { if write_half.write_all(&packet.encode()).await.is_err() { break; @@ -97,7 +99,7 @@ impl PeerConnection { // Reader task let event_tx_clone = event_tx.clone(); - tokio::spawn(async move { + let reader_task = tokio::spawn(async move { let mut reader = read_half; let mut buf = bytes::BytesMut::with_capacity(65536); loop { @@ -126,6 +128,9 @@ impl PeerConnection { } }); + self.tasks.push(writer_task); + self.tasks.push(reader_task); + Ok((event_rx, packet_tx)) } @@ -138,9 +143,6 @@ impl PeerConnection { if packet.payload.len() < 17 { return Ok(()); } - let mut hash = [0u8; 16]; - // Skip first byte (hash length = 0x10) - hash.copy_from_slice(&packet.payload[1..17]); PeerEvent::HelloAnswer } OP_FILE_STATUS => { @@ -224,3 +226,12 @@ impl PeerConnection { .map_err(|_| "Send failed".to_string()) } } + +impl Drop for PeerConnection { + fn drop(&mut self) { + // Abort detached IO tasks so a half-open peer cannot park them after download teardown + for task in self.tasks.drain(..) { + task.abort(); + } + } +} diff --git a/src-tauri/risuko-engine/src/engine/error_code.rs b/src-tauri/risuko-engine/src/engine/error_code.rs index ffbc8318..2557b37a 100644 --- a/src-tauri/risuko-engine/src/engine/error_code.rs +++ b/src-tauri/risuko-engine/src/engine/error_code.rs @@ -156,6 +156,25 @@ impl fmt::Display for ErrorCode { } } +/// Returns true when `code` appears as a standalone ASCII number +/// +/// Avoids `contains("500")` matching embedded runs like "5003" or "15000" +fn contains_status(haystack: &str, code: &str) -> bool { + let bytes = haystack.as_bytes(); + let mut start = 0; + while let Some(pos) = haystack[start..].find(code) { + let i = start + pos; + let before_ok = i == 0 || !bytes[i - 1].is_ascii_digit(); + let after = i + code.len(); + let after_ok = after >= bytes.len() || !bytes[after].is_ascii_digit(); + if before_ok && after_ok { + return true; + } + start = i + 1; + } + false +} + /// Classify an error message string into an appropriate error code. /// /// For HTTP downloads, also pass the protocol kind for better classification. @@ -198,30 +217,30 @@ pub fn classify_error(msg: &str, protocol: &str) -> ErrorCode { { return ErrorCode::CLOUDFLARE_CHALLENGE; } - if lower.contains("401") || lower.contains("unauthorized") { + if contains_status(&lower, "401") || lower.contains("unauthorized") { return ErrorCode::HTTP_UNAUTHORIZED; } - if lower.contains("403") || lower.contains("forbidden") { + if contains_status(&lower, "403") || lower.contains("forbidden") { return ErrorCode::HTTP_FORBIDDEN; } - if lower.contains("404") || lower.contains("not found") { + if contains_status(&lower, "404") || lower.contains("not found") { // Distinguish HTTP 404 from file-not-found on disk if protocol == "http" || protocol == "m3u8" { return ErrorCode::HTTP_NOT_FOUND; } } - if lower.contains("416") || lower.contains("range not satisfiable") { + if contains_status(&lower, "416") || lower.contains("range not satisfiable") { return ErrorCode::HTTP_RANGE_NOT_SATISFIABLE; } - if lower.contains("429") || lower.contains("too many requests") { + if contains_status(&lower, "429") || lower.contains("too many requests") { return ErrorCode::HTTP_TOO_MANY_REQUESTS; } - if lower.contains("503") || lower.contains("service unavailable") { + if contains_status(&lower, "503") || lower.contains("service unavailable") { return ErrorCode::HTTP_SERVICE_UNAVAILABLE; } if lower.contains("5xx") - || lower.contains("500") - || lower.contains("502") + || contains_status(&lower, "500") + || contains_status(&lower, "502") || lower.contains("server error") { return ErrorCode::HTTP_SERVER_ERROR; @@ -278,7 +297,7 @@ pub fn classify_error(msg: &str, protocol: &str) -> ErrorCode { } } "ftp" | "sftp" => { - if lower.contains("login") || lower.contains("530") { + if lower.contains("login") || contains_status(&lower, "530") { return ErrorCode::FTP_LOGIN_FAILED; } if lower.contains("auth") { @@ -287,7 +306,7 @@ pub fn classify_error(msg: &str, protocol: &str) -> ErrorCode { if lower.contains("host key") { return ErrorCode::SFTP_HOST_KEY_FAILED; } - if lower.contains("not found") || lower.contains("550") { + if lower.contains("not found") || contains_status(&lower, "550") { return ErrorCode::FTP_FILE_NOT_FOUND; } if lower.contains("transfer") { @@ -309,7 +328,8 @@ pub fn classify_error(msg: &str, protocol: &str) -> ErrorCode { if lower.contains("requested format") || lower.contains("format is not available") { return ErrorCode::MEDIA_FORMAT_UNAVAILABLE; } - if lower.contains("http error 404") || (lower.contains("404") && lower.contains("http")) + if lower.contains("http error 404") + || (contains_status(&lower, "404") && lower.contains("http")) { return ErrorCode::HTTP_NOT_FOUND; } @@ -467,6 +487,20 @@ mod tests { ); } + #[test] + fn classify_status_code_respects_digit_boundaries() { + // A digit run that embeds "500" must not classify as HTTP 5xx + assert_eq!( + classify_error("received 5003 bytes then the stream ended", "http"), + ErrorCode::UNKNOWN + ); + // A standalone 500 status still classifies correctly + assert_eq!( + classify_error("HTTP 500 returned by origin", "http"), + ErrorCode::HTTP_SERVER_ERROR + ); + } + #[test] fn classify_media_wins_over_http_403() { // A YouTube-specific message that also contains an HTTP status code diff --git a/src-tauri/risuko-engine/src/engine/ftp/ftp_download.rs b/src-tauri/risuko-engine/src/engine/ftp/ftp_download.rs index 7a86cd27..f37f4bf1 100644 --- a/src-tauri/risuko-engine/src/engine/ftp/ftp_download.rs +++ b/src-tauri/risuko-engine/src/engine/ftp/ftp_download.rs @@ -89,97 +89,116 @@ macro_rules! ftp_transfer { } else { $file_size }; - let resume_offset = - if existing_size > 0 && effective_size > 0 && existing_size < effective_size { - match $ftp.resume_transfer(existing_size as usize).await { - Ok(()) => { - $completed.store(existing_size, Ordering::Relaxed); - tracing::info!("Resuming FTP download from byte {existing_size}"); - existing_size - } - Err(e) => { - tracing::warn!("FTP resume not supported: {e}"); - 0 + // If .part already matches the remote size, skip transfer and let finalization rename it + // Oversized .part files are stale and get recreated in the resume branch below + if existing_size > 0 && effective_size > 0 && existing_size == effective_size { + $completed.store(existing_size, Ordering::Relaxed); + tracing::info!("FTP .part already complete ({existing_size} bytes), skipping transfer"); + let _ = $ftp.quit().await; + } else { + let resume_offset = + if existing_size > 0 && effective_size > 0 && existing_size < effective_size { + // Avoid truncating the u64 resume offset on 32-bit targets + match usize::try_from(existing_size) { + Ok(off) => match $ftp.resume_transfer(off).await { + Ok(()) => { + $completed.store(existing_size, Ordering::Relaxed); + tracing::info!("Resuming FTP download from byte {existing_size}"); + existing_size + } + Err(e) => { + tracing::warn!("FTP resume not supported: {e}"); + 0 + } + }, + Err(_) => { + tracing::warn!( + "FTP resume offset {existing_size} exceeds usize; \ + restarting download from scratch" + ); + 0 + } } - } + } else { + 0 + }; + + let mut file = if resume_offset > 0 { + tokio::fs::OpenOptions::new() + .write(true) + .append(true) + .open(&$part_path) + .await + .map_err(|e| format!("Failed to open part file: {e}"))? } else { - 0 + tokio::fs::File::create(&$part_path) + .await + .map_err(|e| format!("Failed to create part file: {e}"))? }; - let mut file = if resume_offset > 0 { - tokio::fs::OpenOptions::new() - .write(true) - .append(true) - .open(&$part_path) - .await - .map_err(|e| format!("Failed to open part file: {e}"))? - } else { - tokio::fs::File::create(&$part_path) + let mut data_stream = $ftp + .retr_as_stream(remote_path) .await - .map_err(|e| format!("Failed to create part file: {e}"))? - }; - - let mut data_stream = $ftp - .retr_as_stream(remote_path) - .await - .map_err(|e| format!("FTP RETR failed: {e}"))?; + .map_err(|e| format!("FTP RETR failed: {e}"))?; - let mut bytes_downloaded = resume_offset; - let mut buf = vec![0u8; BUF_SIZE]; - let mut last_speed_time = Instant::now(); - let mut interval_bytes: u64 = 0; - let mut ema_speed: f64 = 0.0; - - loop { - if $cancelled.load(Ordering::Relaxed) || $cancel_token.is_cancelled() { - return Err("Download cancelled".to_string()); - } + let mut bytes_downloaded = resume_offset; + let mut buf = vec![0u8; BUF_SIZE]; + let mut last_speed_time = Instant::now(); + let mut interval_bytes: u64 = 0; + let mut ema_speed: f64 = 0.0; - let n = tokio::select! { - result = data_stream.read(&mut buf) => { - result.map_err(|e| format!("FTP read error: {e}"))? - } - _ = $cancel_token.cancelled() => { + loop { + if $cancelled.load(Ordering::Relaxed) || $cancel_token.is_cancelled() { return Err("Download cancelled".to_string()); } - }; - if n == 0 { - break; - } + let n = tokio::select! { + result = data_stream.read(&mut buf) => { + result.map_err(|e| format!("FTP read error: {e}"))? + } + _ = $cancel_token.cancelled() => { + return Err("Download cancelled".to_string()); + } + }; - $global_limiter.acquire(n).await; - $task_limiter.acquire(n).await; + if n == 0 { + break; + } - file.write_all(&buf[..n]) - .await - .map_err(|e| format!("Failed to write: {e}"))?; - - bytes_downloaded += n as u64; - $completed.store(bytes_downloaded, Ordering::Relaxed); - interval_bytes += n as u64; - - let elapsed = last_speed_time.elapsed(); - if elapsed.as_millis() >= 500 { - let secs = elapsed.as_secs_f64(); - let instant_speed = interval_bytes as f64 / secs; - ema_speed = SPEED_EMA_ALPHA * instant_speed + (1.0 - SPEED_EMA_ALPHA) * ema_speed; - $speed.store(ema_speed as u64, Ordering::Relaxed); - interval_bytes = 0; - last_speed_time = Instant::now(); + $global_limiter.acquire(n).await; + $task_limiter.acquire(n).await; + + file.write_all(&buf[..n]) + .await + .map_err(|e| format!("Failed to write: {e}"))?; + + bytes_downloaded += n as u64; + $completed.store(bytes_downloaded, Ordering::Relaxed); + interval_bytes += n as u64; + + let elapsed = last_speed_time.elapsed(); + if elapsed.as_millis() >= 500 { + let secs = elapsed.as_secs_f64(); + let instant_speed = interval_bytes as f64 / secs; + ema_speed = + SPEED_EMA_ALPHA * instant_speed + (1.0 - SPEED_EMA_ALPHA) * ema_speed; + $speed.store(ema_speed as u64, Ordering::Relaxed); + interval_bytes = 0; + last_speed_time = Instant::now(); + } } - } - file.flush() - .await - .map_err(|e| format!("Failed to flush: {e}"))?; - drop(file); + file.flush() + .await + .map_err(|e| format!("Failed to flush: {e}"))?; + drop(file); - $ftp.finalize_retr_stream(data_stream) - .await - .map_err(|e| format!("FTP finalize failed: {e}"))?; + $ftp.finalize_retr_stream(data_stream) + .await + .map_err(|e| format!("FTP finalize failed: {e}"))?; - let _ = $ftp.quit().await; + let _ = $ftp.quit().await; + } Ok::<(), String>(()) }}; } @@ -215,7 +234,7 @@ pub async fn run_ftp_ftps_download( fs::create_dir_all(dir_path).map_err(|e| format!("Failed to create dir: {e}"))?; let filename = if out.is_empty() { - super::infer_filename_from_ftp_uri(&format!("ftp://{}{}", parsed.host, parsed.path)) + basename_from_ftp_path(&parsed.path) } else { out.to_string() }; @@ -242,18 +261,42 @@ pub async fn run_ftp_ftps_download( let file_size = total.load(Ordering::Relaxed); if parsed.protocol == FtpProtocol::Ftps { - let rustls_config = rustls::ClientConfig::builder() - .dangerous() - .with_custom_certificate_verifier(Arc::new(AcceptAnyCert)) - .with_no_client_auth(); + // Match aria2's `--check-certificate=true` default + // Only accept self-signed or invalid certs when `check-certificate=false` + let verify_cert = option_bool(options, "check-certificate", true); + let rustls_config = if verify_cert { + let root_store = rustls::RootCertStore { + roots: webpki_roots::TLS_SERVER_ROOTS.to_vec(), + }; + rustls::ClientConfig::builder() + .with_root_certificates(root_store) + .with_no_client_auth() + } else { + tracing::warn!( + "FTPS certificate verification disabled (check-certificate=false) for {}", + parsed.host + ); + rustls::ClientConfig::builder() + .dangerous() + .with_custom_certificate_verifier(Arc::new(AcceptAnyCert)) + .with_no_client_auth() + }; let connector = AsyncRustlsConnector::from(suppaftp::tokio_rustls::TlsConnector::from( Arc::new(rustls_config), )); - // Use implicit TLS connection for FTPS - let mut ftp = AsyncRustlsFtpStream::connect_secure_implicit(&addr, connector, &parsed.host) - .await - .map_err(|e| format!("FTPS connect failed: {e}"))?; + let mut ftp = if parsed.port == 990 { + AsyncRustlsFtpStream::connect_secure_implicit(&addr, connector, &parsed.host) + .await + .map_err(|e| format!("FTPS implicit connect failed: {e}"))? + } else { + AsyncRustlsFtpStream::connect(&addr) + .await + .map_err(|e| format!("FTPS explicit connect failed: {e}"))? + .into_secure(connector, &parsed.host) + .await + .map_err(|e| format!("FTPS AUTH TLS failed: {e}"))? + }; ftp.login(&user, &password) .await @@ -362,3 +405,27 @@ fn option_str(options: &Map, key: &str) -> Option { .filter(|s| !s.is_empty()) .map(|s| s.to_string()) } + +/// Read a boolean option from JSON bools or common string forms +/// +/// Returns `default` when absent or unrecognized +fn option_bool(options: &Map, key: &str, default: bool) -> bool { + match options.get(key) { + Some(Value::Bool(b)) => *b, + Some(Value::String(s)) => match s.trim().to_ascii_lowercase().as_str() { + "true" | "1" | "yes" | "on" => true, + "false" | "0" | "no" | "off" => false, + _ => default, + }, + _ => default, + } +} + +/// Derive a download filename from an already-parsed FTP path +fn basename_from_ftp_path(path: &str) -> String { + let trimmed = path.trim_end_matches('/'); + match trimmed.rfind('/') { + Some(idx) if !trimmed[idx + 1..].is_empty() => trimmed[idx + 1..].to_string(), + _ => "download".to_string(), + } +} diff --git a/src-tauri/risuko-engine/src/engine/ftp/sftp_download.rs b/src-tauri/risuko-engine/src/engine/ftp/sftp_download.rs index 41339eb0..7114740e 100644 --- a/src-tauri/risuko-engine/src/engine/ftp/sftp_download.rs +++ b/src-tauri/risuko-engine/src/engine/ftp/sftp_download.rs @@ -1,12 +1,15 @@ +use std::collections::BTreeMap; use std::fs; use std::path::{Path, PathBuf}; use std::sync::atomic::{AtomicBool, AtomicU32, AtomicU64, Ordering}; use std::sync::Arc; use std::time::Instant; +use futures_util::stream::{FuturesUnordered, StreamExt}; use russh::client; use russh::keys::PrivateKeyWithHashAlg; -use russh_sftp::client::SftpSession; +use russh_sftp::client::{error::Error as SftpError, RawSftpSession}; +use russh_sftp::protocol::{FileAttributes, OpenFlags, StatusCode}; use serde_json::{Map, Value}; use tokio::io::AsyncWriteExt; use tokio_util::sync::CancellationToken; @@ -17,8 +20,91 @@ use crate::engine::ssh_known_hosts::TofuHandler; const PART_SUFFIX: &str = ".part"; const BUF_SIZE: usize = 64 * 1024; +const SFTP_READ_AHEAD: usize = 6; const SPEED_EMA_ALPHA: f64 = 0.3; +#[derive(Debug)] +struct SftpReadChunk { + offset: u64, + requested_len: usize, + data: Vec, +} + +struct OrderedChunkBuffer { + next_offset: u64, + pending: BTreeMap>, +} + +impl OrderedChunkBuffer { + fn new(next_offset: u64) -> Self { + Self { + next_offset, + pending: BTreeMap::new(), + } + } + + fn push(&mut self, chunk: SftpReadChunk) { + if !chunk.data.is_empty() { + self.pending.insert(chunk.offset, chunk.data); + } + } + + fn pop_ready(&mut self) -> Option> { + let data = self.pending.remove(&self.next_offset)?; + self.next_offset += data.len() as u64; + Some(data) + } +} + +fn next_sftp_read_len(file_size: u64, offset: u64) -> Option { + if file_size > 0 { + if offset >= file_size { + None + } else { + Some(BUF_SIZE.min((file_size - offset) as usize)) + } + } else { + Some(BUF_SIZE) + } +} + +fn short_read_gap(chunk: &SftpReadChunk, file_size: u64) -> Option<(u64, usize)> { + let actual_len = chunk.data.len(); + if actual_len == 0 || actual_len >= chunk.requested_len { + return None; + } + + let gap_offset = chunk.offset + actual_len as u64; + if file_size > 0 && gap_offset >= file_size { + return None; + } + + let mut gap_len = chunk.requested_len - actual_len; + if file_size > 0 { + gap_len = gap_len.min((file_size - gap_offset) as usize); + } + (gap_len > 0).then_some((gap_offset, gap_len)) +} + +async fn read_sftp_range( + sftp: Arc, + handle: String, + offset: u64, + len: usize, +) -> Result { + let data = match sftp.read(handle, offset, len as u32).await { + Ok(data) => data.data, + Err(SftpError::Status(status)) if status.status_code == StatusCode::Eof => Vec::new(), + Err(e) => return Err(format!("SFTP read error: {e}")), + }; + + Ok(SftpReadChunk { + offset, + requested_len: len, + data, + }) +} + /// Run an SFTP download #[allow(clippy::too_many_arguments)] pub async fn run_sftp_download( @@ -109,16 +195,29 @@ pub async fn run_sftp_download( .await .map_err(|e| format!("SFTP subsystem request failed: {e}"))?; - let sftp = SftpSession::new(channel.into_stream()) + let mut sftp = RawSftpSession::new(channel.into_stream()); + let version = sftp + .init() .await .map_err(|e| format!("SFTP session init failed: {e}"))?; + if version + .extensions + .get(russh_sftp::extensions::LIMITS) + .is_some_and(|v| v == "1") + { + match sftp.limits().await { + Ok(limits) => sftp.set_limits(limits.into()), + Err(e) => tracing::warn!("SFTP limits extension failed: {e}"), + } + } + let sftp = Arc::new(sftp); connections.store(1, Ordering::Relaxed); // Stat remote file for size let remote_path = &parsed.path; - let file_size = match sftp.metadata(remote_path).await { - Ok(attrs) => attrs.size.unwrap_or(0), + let file_size = match sftp.stat(remote_path).await { + Ok(attrs) => attrs.attrs.size.unwrap_or(0), Err(e) => { tracing::warn!("SFTP stat failed (continuing without size): {e}"); 0 @@ -146,20 +245,11 @@ pub async fn run_sftp_download( tracing::info!("Resuming SFTP download from byte {resume_offset}"); } - // Open remote file for reading - let mut remote_file = sftp - .open(remote_path) + let handle = sftp + .open(remote_path, OpenFlags::READ, FileAttributes::empty()) .await - .map_err(|e| format!("SFTP open failed: {e}"))?; - - // Seek to resume offset if needed - if resume_offset > 0 { - use tokio::io::AsyncSeekExt; - remote_file - .seek(std::io::SeekFrom::Start(resume_offset)) - .await - .map_err(|e| format!("SFTP seek failed: {e}"))?; - } + .map_err(|e| format!("SFTP open failed: {e}"))? + .handle; // Open local file let mut local_file = if resume_offset > 0 { @@ -175,58 +265,106 @@ pub async fn run_sftp_download( .map_err(|e| format!("Failed to create part file: {e}"))? }; - // Download loop + // Use positional reads with a small read-ahead window for high-latency SFTP links let mut bytes_downloaded = resume_offset; - let mut buf = vec![0u8; BUF_SIZE]; let mut last_speed_time = Instant::now(); let mut interval_bytes: u64 = 0; let mut ema_speed: f64 = 0.0; + let mut next_read_offset = resume_offset; + let mut saw_eof = false; + let mut reads = FuturesUnordered::new(); + let mut ordered = OrderedChunkBuffer::new(resume_offset); - use tokio::io::AsyncReadExt; + while reads.len() < SFTP_READ_AHEAD { + let Some(len) = next_sftp_read_len(file_size, next_read_offset) else { + break; + }; + reads.push(read_sftp_range( + sftp.clone(), + handle.clone(), + next_read_offset, + len, + )); + next_read_offset += len as u64; + } - loop { + while !reads.is_empty() { if cancelled.load(Ordering::Relaxed) || cancel_token.is_cancelled() { + let _ = sftp.close(handle.clone()).await; return Err("Download cancelled".to_string()); } - let n = tokio::select! { - result = remote_file.read(&mut buf) => { - result.map_err(|e| format!("SFTP read error: {e}"))? - } + let chunk = match tokio::select! { + result = reads.next() => result, _ = cancel_token.cancelled() => { + let _ = sftp.close(handle.clone()).await; return Err("Download cancelled".to_string()); } + } { + Some(chunk) => chunk, + None => break, }; + let chunk = chunk?; - if n == 0 { - break; + if chunk.data.is_empty() { + saw_eof = true; + } else { + if let Some((gap_offset, gap_len)) = short_read_gap(&chunk, file_size) { + reads.push(read_sftp_range( + sftp.clone(), + handle.clone(), + gap_offset, + gap_len, + )); + } + ordered.push(chunk); + + while let Some(data) = ordered.pop_ready() { + let n = data.len(); + global_limiter.acquire(n).await; + task_limiter.acquire(n).await; + + local_file + .write_all(&data) + .await + .map_err(|e| format!("Failed to write: {e}"))?; + + bytes_downloaded += n as u64; + completed.store(bytes_downloaded, Ordering::Relaxed); + interval_bytes += n as u64; + + // Update speed EMA every 500 ms + let elapsed = last_speed_time.elapsed(); + if elapsed.as_millis() >= 500 { + let secs = elapsed.as_secs_f64(); + let instant_speed = interval_bytes as f64 / secs; + ema_speed = + SPEED_EMA_ALPHA * instant_speed + (1.0 - SPEED_EMA_ALPHA) * ema_speed; + speed.store(ema_speed as u64, Ordering::Relaxed); + interval_bytes = 0; + last_speed_time = Instant::now(); + } + } } - // Apply speed limiting - global_limiter.acquire(n).await; - task_limiter.acquire(n).await; - - local_file - .write_all(&buf[..n]) - .await - .map_err(|e| format!("Failed to write: {e}"))?; - - bytes_downloaded += n as u64; - completed.store(bytes_downloaded, Ordering::Relaxed); - interval_bytes += n as u64; - - // Update speed EMA every 500ms - let elapsed = last_speed_time.elapsed(); - if elapsed.as_millis() >= 500 { - let secs = elapsed.as_secs_f64(); - let instant_speed = interval_bytes as f64 / secs; - ema_speed = SPEED_EMA_ALPHA * instant_speed + (1.0 - SPEED_EMA_ALPHA) * ema_speed; - speed.store(ema_speed as u64, Ordering::Relaxed); - interval_bytes = 0; - last_speed_time = Instant::now(); + while !saw_eof && reads.len() < SFTP_READ_AHEAD { + let Some(len) = next_sftp_read_len(file_size, next_read_offset) else { + break; + }; + reads.push(read_sftp_range( + sftp.clone(), + handle.clone(), + next_read_offset, + len, + )); + next_read_offset += len as u64; } } + sftp.close(handle) + .await + .map_err(|e| format!("SFTP close failed: {e}"))?; + local_file .flush() .await @@ -336,3 +474,46 @@ fn option_str(options: &Map, key: &str) -> Option { .filter(|s| !s.is_empty()) .map(|s| s.to_string()) } + +#[cfg(test)] +mod tests { + use super::*; + + fn chunk(offset: u64, requested_len: usize, data: &[u8]) -> SftpReadChunk { + SftpReadChunk { + offset, + requested_len, + data: data.to_vec(), + } + } + + #[test] + fn ordered_chunk_buffer_waits_for_missing_offsets() { + let mut buffer = OrderedChunkBuffer::new(0); + buffer.push(chunk(4, 4, b"efgh")); + + assert!(buffer.pop_ready().is_none()); + + buffer.push(chunk(0, 4, b"abcd")); + assert_eq!(buffer.pop_ready().as_deref(), Some(&b"abcd"[..])); + assert_eq!(buffer.pop_ready().as_deref(), Some(&b"efgh"[..])); + assert!(buffer.pop_ready().is_none()); + } + + #[test] + fn short_read_gap_requests_only_the_missing_range() { + let c = chunk(64, 16, b"abcd"); + + assert_eq!(short_read_gap(&c, 100), Some((68, 12))); + assert_eq!(short_read_gap(&c, 68), None); + assert_eq!(short_read_gap(&chunk(64, 4, b"abcd"), 100), None); + } + + #[test] + fn next_sftp_read_len_caps_to_remaining_known_size() { + assert_eq!(next_sftp_read_len(100, 0), Some(100)); + assert_eq!(next_sftp_read_len(100, 99), Some(1)); + assert_eq!(next_sftp_read_len(100, 100), None); + assert_eq!(next_sftp_read_len(0, u64::MAX), Some(BUF_SIZE)); + } +} diff --git a/src-tauri/risuko-engine/src/engine/http.rs b/src-tauri/risuko-engine/src/engine/http.rs index c8cc9de6..84b7dac0 100644 --- a/src-tauri/risuko-engine/src/engine/http.rs +++ b/src-tauri/risuko-engine/src/engine/http.rs @@ -749,6 +749,8 @@ pub async fn run_http_download_multi( } let started = std::time::Instant::now(); + // Existing .part bytes are already in `completed`; exclude them from this mirror's speed EMA + let start_bytes = completed.load(Ordering::Relaxed); let result = run_single_uri_download( uri, dir, @@ -770,7 +772,9 @@ pub async fn run_http_download_multi( match result { Ok(path) => { let elapsed = started.elapsed().as_secs_f64(); - let bytes = completed.load(Ordering::Relaxed); + let bytes = completed + .load(Ordering::Relaxed) + .saturating_sub(start_bytes); stats.record_success(&super::uri_selector::host_of(uri), bytes, elapsed); return Ok(path); } @@ -784,8 +788,9 @@ pub async fn run_http_download_multi( last_err = Some(e); // Reset counters before retrying the next mirror so progress // accounting doesn't double-count partial bytes from a - // failed attempt. + // Clear `total` so a failed mirror's content-length cannot leak into an unknown-length mirror completed.store(0, Ordering::Relaxed); + total.store(0, Ordering::Relaxed); speed.store(0, Ordering::Relaxed); for cc in &chunk_completed { cc.store(0, Ordering::Relaxed); @@ -978,7 +983,7 @@ async fn run_single_uri_download( } if is_http && split > 1 { - match probe_for_name.clone() { + match probe_for_name.as_ref() { Some(probe) if probe.range_supported && probe.content_length > min_split_size.saturating_mul(split as u64) => @@ -1005,7 +1010,7 @@ async fn run_single_uri_download( dir_path, global_limiter, task_limiter, - probe.etag, + probe.etag.clone(), &chunk_completed, stall.clone(), falloc_mode, @@ -1366,6 +1371,27 @@ fn cloudflare_error(uri: &str, status: u16) -> String { format!("{CLOUDFLARE_MARKER} host={host} status={status}") } +/// Drain a small response body so reqwest can reuse the connection +/// +/// Bounded for early-return paths such as Cloudflare challenges +async fn drain_response_body(resp: risuko_http::Response) { + use futures_util::StreamExt; + const MAX_DRAIN_BYTES: usize = 64 * 1024; + let mut read: usize = 0; + let mut stream = resp.bytes_stream(); + while let Some(item) = stream.next().await { + match item { + Ok(bytes) => { + read = read.saturating_add(bytes.len()); + if read >= MAX_DRAIN_BYTES { + break; + } + } + Err(_) => break, + } + } +} + /// 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 @@ -1387,7 +1413,9 @@ async fn probe_range_support( if looks_like_cloudflare_block(resp.headers(), status) { log_cloudflare_diagnostic(client, headers, resp.headers(), uri); - return Err(cloudflare_error(uri, status)); + let err = cloudflare_error(uri, status); + drain_response_body(resp).await; + return Err(err); } tracing::debug!( @@ -1455,7 +1483,7 @@ async fn probe_range_support( return Ok(no_range); } - if status == 206 { + if range_supported_from_probe_status(status) { // Parse Content-Range: bytes 0-0/TOTAL if let Some(cr) = resp .headers() @@ -1479,33 +1507,8 @@ async fn probe_range_support( } if status == 200 { - // Server ignored Range header; check Accept-Ranges - if let Some(ar) = resp - .headers() - .get(ACCEPT_RANGES) - .and_then(|v| v.to_str().ok()) - { - if ar.eq_ignore_ascii_case("bytes") { - if let Some(cl) = resp - .headers() - .get(CONTENT_LENGTH) - .and_then(|v| v.to_str().ok()) - { - if let Ok(total) = cl.trim().parse::() { - if total > 0 { - return Ok(ProbeResult { - content_length: total, - etag, - last_modified, - suggested_filename, - content_type, - range_supported: true, - }); - } - } - } - } - } + // A 200 to a concrete Range request is not a byte-range response + // Keep filename/type metadata, but stay out of multi-chunk mode return Ok(no_range); } @@ -1796,13 +1799,13 @@ async fn piece_worker( if now_completed >= piece_length { queue.complete(idx); retry_count = 0; + } else if now_completed > already { + // Early EOF made progress; return the piece with progress intact + // Keep retry budget because per-piece resume moves toward piece_length + queue.release(idx); + retry_count = 0; } else { - // Server closed the stream early — return the piece to the - // pool with its progress preserved; another worker (or - // this one on a later iteration) will pick up the tail. - // Count the early EOF against the retry budget so a - // server that consistently truncates responses cannot - // pin a worker in an infinite loop + // Early EOF with zero progress counts against retry budget to avoid worker spin queue.release(idx); retry_count += 1; if retry_count > max_retries { @@ -1883,9 +1886,9 @@ async fn download_piece_stream( if looks_like_cloudflare_block(resp.headers(), status) { log_cloudflare_diagnostic(client, headers, resp.headers(), uri); - return StreamOutcome { - error: Some(cloudflare_error(uri, status)), - }; + let err = cloudflare_error(uri, status); + drain_response_body(resp).await; + return StreamOutcome { error: Some(err) }; } if let Some(expected) = expected_etag { @@ -2284,7 +2287,9 @@ async fn run_single_download( if looks_like_cloudflare_block(resp.headers(), status) { log_cloudflare_diagnostic(client, headers, resp.headers(), uri); - return Err(cloudflare_error(uri, status)); + let err = cloudflare_error(uri, status); + drain_response_body(resp).await; + return Err(err); } if status == 416 && existing_size > 0 { @@ -2583,6 +2588,10 @@ fn parse_http_date(s: &str) -> Option { None } +fn range_supported_from_probe_status(status: u16) -> bool { + status == 206 +} + /// Convert a civil date (year, month, day) to days since Unix epoch. /// Algorithm from Howard Hinnant. fn days_from_civil(y: i64, m: u32, d: u32) -> Option { @@ -3029,6 +3038,12 @@ mod tests { assert!(msg.contains("status=403")); } + #[test] + fn range_probe_200_does_not_confirm_range_support() { + assert!(!range_supported_from_probe_status(200)); + assert!(range_supported_from_probe_status(206)); + } + #[test] fn piece_queue_partitions_content_into_1mib_pieces() { let total = PIECE_SIZE * 3 + 7; diff --git a/src-tauri/risuko-engine/src/engine/m3u8/download.rs b/src-tauri/risuko-engine/src/engine/m3u8/download.rs index bdcde675..fda350a6 100644 --- a/src-tauri/risuko-engine/src/engine/m3u8/download.rs +++ b/src-tauri/risuko-engine/src/engine/m3u8/download.rs @@ -1,6 +1,7 @@ use std::path::{Path, PathBuf}; use std::sync::atomic::{AtomicBool, AtomicU32, AtomicU64, Ordering}; use std::sync::Arc; +use std::time::{SystemTime, UNIX_EPOCH}; use serde_json::{Map, Value}; use tokio::io::AsyncWriteExt; @@ -10,6 +11,8 @@ use super::parser::{self, ParsedPlaylist, Variant}; use super::segment; use crate::engine::speed_limiter::SpeedLimiter; +static TEMP_DIR_COUNTER: AtomicU64 = AtomicU64::new(0); + /// Run an M3U8/HLS download /// Main entry point called from manager.rs /// Returns the final output file path on success. @@ -98,7 +101,7 @@ pub async fn run_m3u8_download( } else { out.to_string() }; - let temp_dir_name = format!(".m3u8_{}", sanitize_filename(&filename)); + let temp_dir_name = temp_dir_name_for(&filename); let temp_dir = dir_path.join(&temp_dir_name); // Download all segments (speed tracker runs alongside) @@ -109,7 +112,7 @@ pub async fn run_m3u8_download( run_speed_tracker(speed_completed, speed_val, speed_cancel).await; }); - let (seg_paths, progress) = segment::download_segments( + let segments_result = segment::download_segments( &segments, media_sequence, &temp_dir, @@ -123,7 +126,17 @@ pub async fn run_m3u8_download( task_limiter, split, ) - .await?; + .await; + + let (seg_paths, progress) = match segments_result { + Ok(result) => result, + Err(e) => { + cancel_token.cancel(); + speed.store(0, Ordering::Relaxed); + speed_tracker.abort(); + return Err(e); + } + }; // Stop speed tracker speed.store(0, Ordering::Relaxed); @@ -132,11 +145,10 @@ pub async fn run_m3u8_download( check_cancelled(&cancelled, &cancel_token)?; // Concatenate segments into final output - let ts_path = dir_path.join(&filename); - concatenate_segments(&seg_paths, &ts_path).await?; + let final_ts_path = concatenate_segments_unique(&seg_paths, dir_path, &filename).await?; // Set final byte-accurate total from the output file - if let Ok(meta) = tokio::fs::metadata(&ts_path).await { + if let Ok(meta) = tokio::fs::metadata(&final_ts_path).await { let file_size = meta.len(); total.store(file_size, Ordering::Relaxed); completed.store(file_size, Ordering::Relaxed); @@ -149,19 +161,19 @@ pub async fn run_m3u8_download( .unwrap_or("ts"); let final_path = if output_format == "mp4" { - match remux_to_mp4(&ts_path).await { + match remux_to_mp4(&final_ts_path).await { Ok(mp4_path) => { // Remove the .ts intermediate - let _ = tokio::fs::remove_file(&ts_path).await; + let _ = tokio::fs::remove_file(&final_ts_path).await; mp4_path } Err(e) => { tracing::warn!("[m3u8] ffmpeg remux failed, keeping .ts output: {e}"); - ts_path // fall back to .ts + final_ts_path // fall back to .ts } } } else { - ts_path + final_ts_path }; // Cleanup temp dir and progress @@ -286,37 +298,134 @@ fn sanitize_filename(name: &str) -> String { .collect() } +fn temp_dir_name_for(filename: &str) -> String { + let nonce = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|duration| duration.as_nanos()) + .unwrap_or_default(); + let counter = TEMP_DIR_COUNTER.fetch_add(1, Ordering::Relaxed); + format!( + ".m3u8_{}_{}_{}_{}", + sanitize_filename(filename), + std::process::id(), + nonce, + counter + ) +} + +fn final_path_candidate(dir: &Path, filename: &str, n: u32) -> PathBuf { + let sanitized = sanitize_filename(filename); + if n == 0 { + return dir.join(sanitized); + } + + let (stem, ext) = match sanitized.rfind('.') { + Some(dot) if dot > 0 => (&sanitized[..dot], &sanitized[dot..]), + _ => (sanitized.as_str(), ""), + }; + + let numbered = if ext.is_empty() { + format!("{stem}.{n}") + } else { + format!("{stem}.{n}{ext}") + }; + dir.join(numbered) +} + /// Concatenate segment files into a single output file -async fn concatenate_segments(segment_paths: &[PathBuf], output_path: &Path) -> Result<(), String> { - let mut output = tokio::fs::File::create(output_path) +async fn concatenate_segments_unique( + segment_paths: &[PathBuf], + output_dir: &Path, + filename: &str, +) -> Result { + for n in 0u32.. { + let output_path = final_path_candidate(output_dir, filename, n); + match concatenate_segments_to_new_file(segment_paths, &output_path).await { + Ok(()) => return Ok(output_path), + Err(ConcatError::AlreadyExists) => continue, + Err(ConcatError::Failed(message)) => return Err(message), + } + } + + Err("Failed to reserve M3U8 output filename".to_string()) +} + +enum ConcatError { + AlreadyExists, + Failed(String), +} + +async fn concatenate_segments_to_new_file( + segment_paths: &[PathBuf], + output_path: &Path, +) -> Result<(), ConcatError> { + let mut output = tokio::fs::OpenOptions::new() + .write(true) + .create_new(true) + .open(output_path) .await - .map_err(|e| format!("Failed to create output file: {e}"))?; + .map_err(|e| { + if e.kind() == std::io::ErrorKind::AlreadyExists { + ConcatError::AlreadyExists + } else { + ConcatError::Failed(format!("Failed to create output file: {e}")) + } + })?; for path in segment_paths { if !path.exists() { - return Err(format!("Missing segment file: {}", path.display())); + let _ = tokio::fs::remove_file(output_path).await; + return Err(ConcatError::Failed(format!( + "Missing segment file: {}", + path.display() + ))); } - let data = tokio::fs::read(path) - .await - .map_err(|e| format!("Failed to read segment {}: {e}", path.display()))?; + let mut seg_file = match tokio::fs::File::open(path).await { + Ok(file) => file, + Err(e) => { + let _ = tokio::fs::remove_file(output_path).await; + return Err(ConcatError::Failed(format!( + "Failed to open segment {}: {e}", + path.display() + ))); + } + }; - output - .write_all(&data) - .await - .map_err(|e| format!("Failed to write to output: {e}"))?; + if let Err(e) = tokio::io::copy(&mut seg_file, &mut output).await { + let _ = tokio::fs::remove_file(output_path).await; + return Err(ConcatError::Failed(format!( + "Failed to write to output: {e}" + ))); + } } - output - .flush() - .await - .map_err(|e| format!("Failed to flush output: {e}"))?; + output.flush().await.map_err(|e| { + let _ = std::fs::remove_file(output_path); + ConcatError::Failed(format!("Failed to flush output: {e}")) + })?; Ok(()) } /// Attempt to remux .ts to .mp4 using system ffmpeg async fn remux_to_mp4(ts_path: &Path) -> Result { - let mp4_path = ts_path.with_extension("mp4"); + let parent = ts_path + .parent() + .ok_or_else(|| "M3U8 output path has no parent directory".to_string())?; + let mp4_name = ts_path + .file_name() + .and_then(|name| name.to_str()) + .map(|name| { + let mut name = name.to_string(); + if let Some(dot) = name.rfind('.') { + name.replace_range(dot.., ".mp4"); + name + } else { + format!("{name}.mp4") + } + }) + .ok_or_else(|| "M3U8 output filename is not valid UTF-8".to_string())?; + let mp4_path = reserve_unique_output_path(parent, &mp4_name).await?; // Check ffmpeg availability let ffmpeg_check = tokio::process::Command::new("ffmpeg") @@ -342,6 +451,7 @@ async fn remux_to_mp4(ts_path: &Path) -> Result { .map_err(|e| format!("ffmpeg execution failed: {e}"))?; if !output.status.success() { + let _ = tokio::fs::remove_file(&mp4_path).await; let stderr = String::from_utf8_lossy(&output.stderr); return Err(format!("ffmpeg remux failed: {stderr}")); } @@ -349,6 +459,24 @@ async fn remux_to_mp4(ts_path: &Path) -> Result { Ok(mp4_path) } +async fn reserve_unique_output_path(dir: &Path, filename: &str) -> Result { + for n in 0u32.. { + let path = final_path_candidate(dir, filename, n); + match tokio::fs::OpenOptions::new() + .write(true) + .create_new(true) + .open(&path) + .await + { + Ok(_) => return Ok(path), + Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => continue, + Err(e) => return Err(format!("Failed to reserve output file: {e}")), + } + } + + Err("Failed to reserve output filename".to_string()) +} + #[cfg(test)] mod tests { use super::*; @@ -379,4 +507,26 @@ mod tests { assert_eq!(sanitize_filename("video/name:1.ts"), "video_name_1.ts"); assert_eq!(sanitize_filename("normal-file_01.ts"), "normal-file_01.ts"); } + + #[test] + fn temp_dir_names_include_unique_suffix() { + let a = temp_dir_name_for("video.ts"); + let b = temp_dir_name_for("video.ts"); + assert!(a.starts_with(".m3u8_video.ts_")); + assert_ne!(a, b); + } + + #[test] + fn final_path_candidate_deduplicates_stem_and_extension() { + let dir = Path::new("/downloads"); + assert_eq!( + final_path_candidate(dir, "video.ts", 0), + dir.join("video.ts") + ); + assert_eq!( + final_path_candidate(dir, "video.ts", 1), + dir.join("video.1.ts") + ); + assert_eq!(final_path_candidate(dir, "video", 2), dir.join("video.2")); + } } diff --git a/src-tauri/risuko-engine/src/engine/m3u8/segment.rs b/src-tauri/risuko-engine/src/engine/m3u8/segment.rs index dae6a062..b3478434 100644 --- a/src-tauri/risuko-engine/src/engine/m3u8/segment.rs +++ b/src-tauri/risuko-engine/src/engine/m3u8/segment.rs @@ -3,9 +3,11 @@ use std::path::{Path, PathBuf}; use std::sync::atomic::{AtomicBool, AtomicU32, AtomicU64, Ordering}; use std::sync::Arc; +use futures_util::StreamExt; use risuko_http::Client; use tokio::io::AsyncWriteExt; use tokio::sync::Semaphore; +use tokio::task::JoinHandle; use tokio_util::sync::CancellationToken; use super::decrypt::{decrypt_segment, fetch_decryption_key, iv_from_sequence}; @@ -19,6 +21,10 @@ const PROGRESS_FILENAME: &str = ".m3u8.progress"; pub struct ProgressState { pub completed_indices: HashSet, progress_path: PathBuf, + /// Append-only progress file handle opened once in `load` + /// + /// Completed segments append one line, avoiding O(N^2) rewrites on large playlists + append_file: Option, } impl ProgressState { @@ -34,26 +40,27 @@ impl ProgressState { } else { HashSet::new() }; + let append_file = std::fs::OpenOptions::new() + .create(true) + .append(true) + .open(&progress_path) + .ok(); Self { completed_indices, progress_path, + append_file, } } - /// Mark a segment index as completed and persist + /// Mark a segment index as completed and persist by appending a single line pub fn mark_completed(&mut self, index: usize) { - self.completed_indices.insert(index); - self.persist(); - } - - fn persist(&self) { - let content: String = self - .completed_indices - .iter() - .map(|i| i.to_string()) - .collect::>() - .join("\n"); - let _ = std::fs::write(&self.progress_path, content); + // Persist only newly-seen indices to keep resume work small + if self.completed_indices.insert(index) { + if let Some(file) = self.append_file.as_mut() { + use std::io::Write; + let _ = writeln!(file, "{index}"); + } + } } /// Remove the progress file @@ -202,12 +209,15 @@ pub async fn download_segments( // Collect results // On error, cancel remaining tasks via cancel_token - for maybe_handle in handles { + for idx in 0..handles.len() { if cancelled.load(Ordering::Relaxed) || cancel_token.is_cancelled() { + abort_remaining(&mut handles).await; return Err("cancelled".to_string()); } - let Some(handle) = maybe_handle else { continue }; + let Some(handle) = handles[idx].take() else { + continue; + }; match handle.await { Ok(Ok(index)) => { @@ -215,14 +225,17 @@ pub async fn download_segments( } Ok(Err(e)) => { if e.contains("cancelled") { + abort_remaining(&mut handles).await; return Err("cancelled".to_string()); } // Cancel remaining spawned tasks before returning error cancel_token.cancel(); + abort_remaining(&mut handles).await; return Err(e); } Err(e) => { cancel_token.cancel(); + abort_remaining(&mut handles).await; return Err(format!("Segment task panicked: {e}")); } } @@ -231,6 +244,13 @@ pub async fn download_segments( Ok((segment_paths, progress)) } +async fn abort_remaining(handles: &mut [Option>>]) { + for handle in handles.iter_mut().filter_map(Option::take) { + handle.abort(); + let _ = handle.await; + } +} + /// Download a single segment /// Returns the number of bytes written on success #[allow(clippy::too_many_arguments)] @@ -295,8 +315,11 @@ async fn attempt_segment_download( // Add byte range header if specified if let Some(ref br) = segment.byte_range { - let end = br.offset + br.length - 1; - request = request.header("Range", format!("bytes={}-{}", br.offset, end)); + // Guard malformed playlists from underflowing `length - 1` or overflowing offset math + if br.length > 0 { + let end = br.offset.saturating_add(br.length).saturating_sub(1); + request = request.header("Range", format!("bytes={}-{}", br.offset, end)); + } } let resp = request @@ -308,50 +331,63 @@ async fn attempt_segment_download( return Err(format!("Segment HTTP {}", resp.status())); } - let data = resp - .bytes() - .await - .map_err(|e| format!("Failed to read segment body: {e}"))?; - - // Apply speed limiting - let chunk_size = data.len(); - global_limiter.acquire(chunk_size).await; - task_limiter.acquire(chunk_size).await; - - // Decrypt if needed - let final_data = if let Some(ref enc) = segment.encryption { - if enc.method == "AES-128" { - let key = key_cache - .lock() - .await - .get_or_fetch(&enc.key_uri, client) - .await?; - let iv = match &enc.iv { - Some(iv_bytes) => { - let mut iv = [0u8; 16]; - let len = iv_bytes.len().min(16); - iv[16 - len..].copy_from_slice(&iv_bytes[..len]); - iv - } - None => iv_from_sequence(sequence_number), - }; - decrypt_segment(&data, &key, &iv)? - } else { - // or unknown — pass through - data.to_vec() - } - } else { - data.to_vec() - }; + // AES-128 needs the whole ciphertext buffered before decrypting; other segments pass through + let needs_decrypt = segment + .encryption + .as_ref() + .is_some_and(|enc| enc.method == "AES-128"); - // Write to disk - let bytes_written = final_data.len() as u64; + // Throttle each network chunk instead of sleeping once after buffering + // Encrypted segments still buffer ciphertext, but only after rate limiting + let mut stream = resp.bytes_stream(); let mut file = tokio::fs::File::create(output_path) .await .map_err(|e| format!("Failed to create segment file: {e}"))?; - file.write_all(&final_data) - .await - .map_err(|e| format!("Failed to write segment: {e}"))?; + let mut ciphertext: Vec = Vec::new(); + let mut bytes_written: u64 = 0; + + while let Some(chunk) = stream.next().await { + let chunk = chunk.map_err(|e| format!("Failed to read segment body: {e}"))?; + let len = chunk.len(); + // Pace the actual transfer by limiting each chunk + global_limiter.acquire(len).await; + task_limiter.acquire(len).await; + + if needs_decrypt { + ciphertext.extend_from_slice(&chunk); + } else { + file.write_all(&chunk) + .await + .map_err(|e| format!("Failed to write segment: {e}"))?; + bytes_written += len as u64; + } + } + + // Decrypt buffered AES-128 ciphertext and write plaintext + if needs_decrypt { + // Safe to unwrap: `needs_decrypt` only becomes true when encryption is set + let enc = segment.encryption.as_ref().expect("encryption present"); + let key = key_cache + .lock() + .await + .get_or_fetch(&enc.key_uri, client) + .await?; + let iv = match &enc.iv { + Some(iv_bytes) => { + let mut iv = [0u8; 16]; + let len = iv_bytes.len().min(16); + iv[16 - len..].copy_from_slice(&iv_bytes[..len]); + iv + } + None => iv_from_sequence(sequence_number), + }; + let plaintext = decrypt_segment(&ciphertext, &key, &iv)?; + bytes_written = plaintext.len() as u64; + file.write_all(&plaintext) + .await + .map_err(|e| format!("Failed to write segment: {e}"))?; + } + file.flush() .await .map_err(|e| format!("Failed to flush segment: {e}"))?; diff --git a/src-tauri/risuko-engine/src/engine/manager.rs b/src-tauri/risuko-engine/src/engine/manager.rs index 987df670..c8bf62b2 100644 --- a/src-tauri/risuko-engine/src/engine/manager.rs +++ b/src-tauri/risuko-engine/src/engine/manager.rs @@ -1072,8 +1072,13 @@ impl TaskManager { /// Start download workers for waiting tasks up to max concurrent limit async fn try_start_next(&self) { - let options_guard = self.options.read().await; - let max_concurrent = options_guard.max_concurrent_downloads(); + let (max_concurrent, options_snapshot) = { + let options_guard = self.options.read().await; + ( + options_guard.max_concurrent_downloads(), + options_guard.clone(), + ) + }; let active_count = self.active_downloads.read().await.len(); if active_count >= max_concurrent { @@ -1093,19 +1098,19 @@ impl TaskManager { } if task.kind == TaskKind::Http && !task.uris.is_empty() { task.status = TaskStatus::Active; - let mut merged = options_guard.merge_task_options(&task.options); + let mut merged = options_snapshot.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::Media && !task.uris.is_empty() { task.status = TaskStatus::Active; - let mut merged = options_guard.merge_task_options(&task.options); + let mut merged = options_snapshot.merge_task_options(&task.options); self.apply_stored_cookies(&task.uris, &mut merged); self.spawn_media_download(task, merged); started += 1; } else if task.kind == TaskKind::M3u8 && !task.uris.is_empty() { task.status = TaskStatus::Active; - let merged = options_guard.merge_task_options(&task.options); + let merged = options_snapshot.merge_task_options(&task.options); self.spawn_m3u8_download(task, merged); started += 1; } else if task.kind == TaskKind::Ed2k && !task.uris.is_empty() { @@ -1114,7 +1119,7 @@ impl TaskManager { started += 1; } else if task.kind == TaskKind::Ftp && !task.uris.is_empty() { task.status = TaskStatus::Active; - let merged = options_guard.merge_task_options(&task.options); + let merged = options_snapshot.merge_task_options(&task.options); self.spawn_ftp_download(task, merged); started += 1; } else if matches!( @@ -1951,6 +1956,9 @@ impl TaskManager { /// Update progress for all active downloads /// Also starts waiting tasks if slots are available pub async fn update_progress(&self) { + // Cap stopped task history once per tick to avoid long-uptime growth + self.enforce_result_cap().await; + // If there are no Active or Waiting tasks, skip the // expensive write-lock + per-task scan + try_start_next entirely // This reduces per-second CPU wake-ups when the engine is idle @@ -1965,96 +1973,13 @@ impl TaskManager { } { - let active = self.active_downloads.read().await; - let mut tasks = self.tasks.write().await; - - for task in tasks.iter_mut() { - if task.status != TaskStatus::Active { - continue; - } - if let Some(ad) = active.get(&task.gid) { - task.total_length = ad.total.load(Ordering::Relaxed); - task.completed_length = ad.completed.load(Ordering::Relaxed); - 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. - let conns = ad.connections.load(Ordering::Relaxed); - if !ad.chunk_completed.is_empty() && task.total_length > 0 && conns > 1 { - let split = ad.chunk_completed.len() as u64; - let chunk_size = task.total_length / split; - task.chunk_progress = ad - .chunk_completed - .iter() - .enumerate() - .map(|(i, cc)| { - // With work-stealing, cc is total bytes worker i has - // downloaded across pieces it pulled. Clamp so percent - // stays in [0, 100]. - let baseline = if i as u64 == split - 1 { - task.total_length - chunk_size * (split - 1) - } else { - chunk_size - }; - let completed = cc.load(Ordering::Relaxed); - ChunkProgress { - completed, - total: completed.max(baseline), - } - }) - .collect(); - } else { - task.chunk_progress.clear(); - } - - if let Some(f) = task.files.first_mut() { - f.length = task.total_length.to_string(); - f.completed_length = task.completed_length.to_string(); - // if it's still a raw URL, resolve to disk path - if looks_like_url(&f.path) { - let filename = if !task.out.is_empty() { - task.out.clone() - } else if let Some(uri) = task.uris.first() { - let name = http::infer_filename_from_uri(uri); - format!("{name}.part") - } else { - String::new() - }; - if !filename.is_empty() { - let display = filename.strip_suffix(".part").unwrap_or(&filename); - f.path = format!("{}/{}", task.dir, display); - } - } - } - } - } - - // Update torrent tasks - let te_guard = self.torrent_engine.read().await; - let tid_guard = self.torrent_ids.read().await; + // Snapshot seeding options before tasks.write() to avoid cross-lock awaits in the tick let (keep_seeding, seed_time_minutes, seed_ratio, bt_create_subfolder_default) = { let opts = self.options.read().await; let st = opts.seed_time(); let ratio = opts.seed_ratio(); let manual = opts.keep_seeding(); - // Seed on completion if the user enabled keep-seeding, or if - // a finite seed-time / seed-ratio goal was set (both imply - // the user wants to seed at least for some duration). When + // Seed on completion when keep-seeding or a finite seed goal is set // `keep-seeding` is true we ignore the seed-time/seed-ratio // limits so the torrent runs until manually stopped. let keep = manual || st > 0 || ratio > 0.0; @@ -2066,224 +1991,283 @@ impl TaskManager { let csub_default = opts.get_bool("bt-create-subfolder").unwrap_or(true); (keep, effective_time, effective_ratio, csub_default) }; - if let Some(ref te) = *te_guard { + + let active_torrent_gids = { + let active = self.active_downloads.read().await; + let mut tasks = self.tasks.write().await; + let mut active_torrent_gids = Vec::new(); + for task in tasks.iter_mut() { - if task.kind != TaskKind::Torrent || task.status != TaskStatus::Active { + if task.status != TaskStatus::Active { continue; } - if let Some(&tid) = tid_guard.get(&task.gid) { - if let Some(stats) = te.get_torrent_stats(tid) { - task.total_length = stats.total_bytes; - task.completed_length = stats.downloaded_bytes; - task.upload_length = stats.uploaded_bytes; - task.download_speed = stats.download_speed; - task.upload_speed = stats.upload_speed; - task.connections = stats.num_peers; - task.num_seeders = stats.num_seeders; - - // Snapshot connected peers for the detail panel - // Peer-level speed tracking is not yet wired up, - // so dl/ul speeds are reported as zero - task.peers = stats - .peers + if task.kind == TaskKind::Torrent { + active_torrent_gids.push(task.gid.clone()); + } + if let Some(ad) = active.get(&task.gid) { + task.total_length = ad.total.load(Ordering::Relaxed); + task.completed_length = ad.completed.load(Ordering::Relaxed); + task.download_speed = ad.speed.load(Ordering::Relaxed); + task.connections = ad.connections.load(Ordering::Relaxed); + + // Sync a Content-Disposition filename into both display path fields + 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 only when multiple HTTP connections are active + let conns = ad.connections.load(Ordering::Relaxed); + if !ad.chunk_completed.is_empty() && task.total_length > 0 && conns > 1 { + let split = ad.chunk_completed.len() as u64; + let chunk_size = task.total_length / split; + task.chunk_progress = ad + .chunk_completed .iter() - .map(|p| { - let bitfield_hex = bytes_to_hex(&p.bitfield); - PeerInfo { - peer_id: String::new(), - ip: p.addr.ip().to_string(), - port: p.addr.port().to_string(), - bitfield: bitfield_hex, - am_choking: bool_str(p.am_choking), - peer_choking: bool_str(p.peer_choking), - download_speed: "0".to_string(), - upload_speed: "0".to_string(), - seeder: bool_str(p.seeder), + .enumerate() + .map(|(i, cc)| { + // Work-stealing reports bytes per worker; clamp percent into [0, 100] + let baseline = if i as u64 == split - 1 { + task.total_length - chunk_size * (split - 1) + } else { + chunk_size + }; + let completed = cc.load(Ordering::Relaxed); + ChunkProgress { + completed, + total: completed.max(baseline), } }) .collect(); + } else { + task.chunk_progress.clear(); + } - // Surface .torrent metadata once the BT engine - // has parsed it (immediate for torrent files, - // after metadata exchange for magnets) - if let Some(ref meta) = stats.metadata { - task.piece_length = meta.piece_length; - task.num_pieces = meta.num_pieces; - if task.bt_comment.is_none() { - task.bt_comment = meta.comment.clone(); - } - if task.bt_creation_date.is_none() { - task.bt_creation_date = meta.creation_date; - } - if task.bt_announce_list.is_empty() { - task.bt_announce_list = meta.announce_list.clone(); + if let Some(f) = task.files.first_mut() { + f.length = task.total_length.to_string(); + f.completed_length = task.completed_length.to_string(); + // Resolve raw URLs to disk paths + if looks_like_url(&f.path) { + let filename = if !task.out.is_empty() { + task.out.clone() + } else if let Some(uri) = task.uris.first() { + let name = http::infer_filename_from_uri(uri); + format!("{name}.part") + } else { + String::new() + }; + if !filename.is_empty() { + let display = + filename.strip_suffix(".part").unwrap_or(&filename); + f.path = format!("{}/{}", task.dir, display); } } + } + } + } - if task.bt_name.is_none() { - if let Some(ref name) = stats.name { - task.bt_name = Some(name.clone()); - } + active_torrent_gids + }; + + // Snapshot torrent stats before task mutations to keep lock scope small + let torrent_stats_by_gid: HashMap = + if active_torrent_gids.is_empty() { + HashMap::new() + } else { + let te_guard = self.torrent_engine.read().await; + let tid_guard = self.torrent_ids.read().await; + if let Some(ref te) = *te_guard { + active_torrent_gids + .into_iter() + .filter_map(|gid| { + let tid = *tid_guard.get(&gid)?; + te.get_torrent_stats(tid).map(|stats| (gid, stats)) + }) + .collect() + } else { + HashMap::new() + } + }; + + if !torrent_stats_by_gid.is_empty() { + let mut tasks = self.tasks.write().await; + for task in tasks.iter_mut() { + if task.kind != TaskKind::Torrent || task.status != TaskStatus::Active { + continue; + } + if let Some(stats) = torrent_stats_by_gid.get(&task.gid) { + task.total_length = stats.total_bytes; + task.completed_length = stats.downloaded_bytes; + task.upload_length = stats.uploaded_bytes; + task.download_speed = stats.download_speed; + task.upload_speed = stats.upload_speed; + task.connections = stats.num_peers; + task.num_seeders = stats.num_seeders; + + // Snapshot connected peers for detail; per-peer speeds are not wired yet + sync_peer_infos(&mut task.peers, &stats.peers); + + // Surface .torrent metadata once parsed, immediate for files and delayed for magnets + if let Some(ref meta) = stats.metadata { + task.piece_length = meta.piece_length; + task.num_pieces = meta.num_pieces; + if task.bt_comment.is_none() { + task.bt_comment = meta.comment.clone(); + } + if task.bt_creation_date.is_none() { + task.bt_creation_date = meta.creation_date; + } + if task.bt_announce_list.is_empty() { + task.bt_announce_list = meta.announce_list.clone(); } + } - // Populate file list from torrent metadata - if let Some(ref file_details) = stats.file_details { - let torrent_name = task.bt_name.as_deref().unwrap_or(""); - let create_subfolder = task - .options - .get("bt-create-subfolder") - .and_then(|v| v.as_bool()) - .unwrap_or(bt_create_subfolder_default); - let base_dir = if let Some(resolved_root) = - stats.resolved_root.as_ref().filter(|s| !s.is_empty()) - { - resolved_root.clone() - } else if torrent_name.is_empty() - || stats.single_file_mode - || !create_subfolder - { - task.dir.clone() - } else { - // Multi-file grouped: files are inside torrent folder - format!("{}/{}", task.dir, torrent_name) - }; + if task.bt_name.is_none() { + if let Some(ref name) = stats.name { + task.bt_name = Some(name.clone()); + } + } - // Determine which files are selected (0-based indices) - let selected_indices: Option> = - task.options - .get("select-file") - .and_then(|v| v.as_str()) - .and_then(|raw| { - let raw = raw.trim(); - if raw.is_empty() { - return None; - } - let set: std::collections::HashSet = raw - .split(',') - .filter_map(|s| s.trim().parse::().ok()) - .filter(|&i| i >= 1) - .map(|i| i - 1) // 1-based to 0-based - .collect(); - if set.is_empty() { - None - } else { - Some(set) - } - }); - - let mut selected_total: u64 = 0; - let mut selected_completed: u64 = 0; - - task.files = file_details - .iter() - .map(|fd| { - let completed = - stats.file_progress.get(fd.index).copied().unwrap_or(0); - let is_selected = selected_indices - .as_ref() - .is_none_or(|set| set.contains(&fd.index)); - if is_selected { - selected_total += fd.length; - selected_completed += completed; - } - DownloadFile { - index: (fd.index + 1).to_string(), // 1-based for compatibility - path: format!("{}/{}", base_dir, fd.path), - length: fd.length.to_string(), - completed_length: completed.to_string(), - selected: if is_selected { "true" } else { "false" } - .to_string(), - uris: Vec::new(), - } - }) - .collect(); - - // Override totals with selected-only sums - if selected_indices.is_some() { - task.total_length = selected_total; - task.completed_length = selected_completed; - } - } else if task.files.is_empty() { - // Fallback: metadata not yet available - if let Some(ref name) = stats.name { - task.files = vec![DownloadFile { - index: "1".to_string(), - path: format!("{}/{}", task.dir, name), - length: stats.total_bytes.to_string(), - completed_length: stats.downloaded_bytes.to_string(), - selected: "true".to_string(), - uris: Vec::new(), - }]; - } + // Populate file list from torrent metadata + if let Some(ref file_details) = stats.file_details { + let torrent_name = task.bt_name.as_deref().unwrap_or(""); + let create_subfolder = task + .options + .get("bt-create-subfolder") + .and_then(|v| v.as_bool()) + .unwrap_or(bt_create_subfolder_default); + let base_dir = if let Some(resolved_root) = + stats.resolved_root.as_ref().filter(|s| !s.is_empty()) + { + resolved_root.clone() + } else if torrent_name.is_empty() + || stats.single_file_mode + || !create_subfolder + { + task.dir.clone() } else { - // Update progress for existing single-entry fallback - if let Some(f) = task.files.first_mut() { - f.length = stats.total_bytes.to_string(); - f.completed_length = stats.downloaded_bytes.to_string(); - } + // Multi-file torrents store files inside the torrent folder + format!("{}/{}", task.dir, torrent_name) + }; + + // Determine selected files as zero-based indices + let selected_indices: Option> = task + .options + .get("select-file") + .and_then(|v| v.as_str()) + .and_then(|raw| { + let raw = raw.trim(); + if raw.is_empty() { + return None; + } + let set: std::collections::HashSet = raw + .split(',') + .filter_map(|s| s.trim().parse::().ok()) + .filter(|&i| i >= 1) + .map(|i| i - 1) // 1-based to 0-based + .collect(); + if set.is_empty() { + None + } else { + Some(set) + } + }); + + let (selected_total, selected_completed) = sync_torrent_files( + &mut task.files, + file_details, + &stats.file_progress, + &base_dir, + selected_indices.as_ref(), + ); + + // Override totals with selected-only sums + if selected_indices.is_some() { + task.total_length = selected_total; + task.completed_length = selected_completed; + } + } else if task.files.is_empty() { + // Fallback while metadata is unavailable + if let Some(ref name) = stats.name { + task.files = vec![DownloadFile { + index: "1".to_string(), + path: format!("{}/{}", task.dir, name), + length: stats.total_bytes.to_string(), + completed_length: stats.downloaded_bytes.to_string(), + selected: "true".to_string(), + uris: Vec::new(), + }]; } + } else { + // Update progress for the existing single-entry fallback + if let Some(f) = task.files.first_mut() { + f.length = stats.total_bytes.to_string(); + f.completed_length = stats.downloaded_bytes.to_string(); + } + } - if stats.is_finished && !task.seeder { - if keep_seeding { - // Mark as seeder but keep Active so the torrent - // continues uploading to peers - task.seeder = true; - task.seeding_since = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap_or_default() - .as_millis() - as u64; - task.download_speed = 0; - self.events.send(EngineEvent::BtDownloadComplete { - gid: task.gid.clone(), - }); - } else { - task.status = TaskStatus::Complete; - self.events.send(EngineEvent::BtDownloadComplete { - gid: task.gid.clone(), - }); - self.events.send(EngineEvent::DownloadComplete { - gid: task.gid.clone(), - }); - } + if stats.is_finished && !task.seeder { + if keep_seeding { + // Mark as seeder while keeping Active so uploads continue + task.seeder = true; + task.seeding_since = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_millis() + as u64; + task.download_speed = 0; + self.events.send(EngineEvent::BtDownloadComplete { + gid: task.gid.clone(), + }); + } else { + task.status = TaskStatus::Complete; + self.events.send(EngineEvent::BtDownloadComplete { + gid: task.gid.clone(), + }); + self.events.send(EngineEvent::DownloadComplete { + gid: task.gid.clone(), + }); } + } - // Check if seed time has elapsed or seed ratio reached - if task.seeder && task.seeding_since > 0 { - let mut should_stop = false; - - // Check seed time limit - let seed_time_ms = seed_time_minutes * 60 * 1000; - if seed_time_ms > 0 { - let now = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap_or_default() - .as_millis() - as u64; - if now >= task.seeding_since - && now - task.seeding_since >= seed_time_ms - { - should_stop = true; - } + // Check seed time and seed ratio limits + if task.seeder && task.seeding_since > 0 { + let mut should_stop = false; + + // Check seed time limit + let seed_time_ms = seed_time_minutes * 60 * 1000; + if seed_time_ms > 0 { + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_millis() as u64; + if now >= task.seeding_since + && now - task.seeding_since >= seed_time_ms + { + should_stop = true; } + } - // Check seed ratio limit - if !should_stop && seed_ratio > 0.0 && task.total_length > 0 { - let current_ratio = - task.upload_length as f64 / task.total_length as f64; - if current_ratio >= seed_ratio { - should_stop = true; - } + // Check seed ratio limit + if !should_stop && seed_ratio > 0.0 && task.total_length > 0 { + let current_ratio = + task.upload_length as f64 / task.total_length as f64; + if current_ratio >= seed_ratio { + should_stop = true; } + } - if should_stop { - task.seeder = false; - task.seeding_since = 0; - task.status = TaskStatus::Complete; - self.events.send(EngineEvent::DownloadComplete { - gid: task.gid.clone(), - }); - } + if should_stop { + task.seeder = false; + task.seeding_since = 0; + task.status = TaskStatus::Complete; + self.events.send(EngineEvent::DownloadComplete { + gid: task.gid.clone(), + }); } } } @@ -2982,6 +2966,44 @@ impl TaskManager { self.session.save(&tasks) } + /// Hard cap for retained finished/failed/removed task records + const MAX_STOPPED_RESULTS: usize = 1000; + + /// Evict oldest stopped tasks beyond [`Self::MAX_STOPPED_RESULTS`] + /// + /// Drops torrent bt-session entries first so stale `by_hash` records do not block re-add + async fn enforce_result_cap(&self) { + let to_evict: Vec = { + let tasks = self.tasks.read().await; + let stopped = tasks.iter().filter(|t| t.status.is_stopped()).count(); + if stopped <= Self::MAX_STOPPED_RESULTS { + return; + } + let mut excess = stopped - Self::MAX_STOPPED_RESULTS; + let mut gids = Vec::with_capacity(excess); + // Tasks are appended in creation order, so evict the earliest stopped entries first + for t in tasks.iter() { + if excess == 0 { + break; + } + if t.status.is_stopped() { + gids.push(t.gid.clone()); + excess -= 1; + } + } + gids + }; + + // Drop bt-session entries before removing evicted torrent tasks + for gid in &to_evict { + self.drop_torrent_engine_entry(gid).await; + } + + let evict: std::collections::HashSet<&str> = to_evict.iter().map(String::as_str).collect(); + let mut tasks = self.tasks.write().await; + tasks.retain(|t| !(t.status.is_stopped() && evict.contains(t.gid.as_str()))); + } + pub async fn purge_download_result(&self) { // Collect gids of stopped torrent tasks so we can drop their bt-session // entries before evicting them from the task list. Without this, the @@ -3161,6 +3183,7 @@ impl TaskManager { let active = self.active_downloads.read().await; for (_, ad) in active.iter() { ad.cancel.store(true, Ordering::Relaxed); + ad.cancel_token.cancel(); } drop(active); @@ -3210,20 +3233,98 @@ fn is_retryable_magnet_resolution_error(err: &str) -> bool { || lower.contains("no seeds") } -/// Lower-case hex encoding for BT bitfields. The frontend's -/// `bitfieldToPercent` walks each hex nibble, so the format must be hex -fn bytes_to_hex(bytes: &[u8]) -> String { +fn write_hex(bytes: &[u8], out: &mut String) { use std::fmt::Write; - let mut s = String::with_capacity(bytes.len() * 2); + out.clear(); + out.reserve(bytes.len() * 2); for b in bytes { - let _ = write!(s, "{b:02x}"); + let _ = write!(out, "{b:02x}"); + } +} + +fn set_bool_string(out: &mut String, value: bool) { + out.clear(); + out.push_str(if value { "true" } else { "false" }); +} + +fn set_u64_string(out: &mut String, value: u64) { + use std::fmt::Write; + out.clear(); + let _ = write!(out, "{value}"); +} + +fn set_usize_string(out: &mut String, value: usize) { + use std::fmt::Write; + out.clear(); + let _ = write!(out, "{value}"); +} + +fn sync_peer_infos(target: &mut Vec, peers: &[torrent::PeerSnapshot]) { + target.resize_with(peers.len(), || PeerInfo { + peer_id: String::new(), + ip: String::new(), + port: String::new(), + bitfield: String::new(), + am_choking: String::new(), + peer_choking: String::new(), + download_speed: String::new(), + upload_speed: String::new(), + seeder: String::new(), + }); + + for (info, peer) in target.iter_mut().zip(peers) { + info.peer_id.clear(); + info.ip.clear(); + info.ip.push_str(&peer.addr.ip().to_string()); + set_usize_string(&mut info.port, peer.addr.port() as usize); + write_hex(&peer.bitfield, &mut info.bitfield); + set_bool_string(&mut info.am_choking, peer.am_choking); + set_bool_string(&mut info.peer_choking, peer.peer_choking); + set_u64_string(&mut info.download_speed, 0); + set_u64_string(&mut info.upload_speed, 0); + set_bool_string(&mut info.seeder, peer.seeder); } - s } -/// Aria2-compatible "true"/"false" string for bool flags exposed via RPC -fn bool_str(b: bool) -> String { - if b { "true" } else { "false" }.to_string() +fn sync_torrent_files( + target: &mut Vec, + file_details: &[torrent::TorrentFileInfo], + file_progress: &[u64], + base_dir: &str, + selected_indices: Option<&std::collections::HashSet>, +) -> (u64, u64) { + use std::fmt::Write; + + target.resize_with(file_details.len(), || DownloadFile { + index: String::new(), + path: String::new(), + length: String::new(), + completed_length: String::new(), + selected: String::new(), + uris: Vec::new(), + }); + + let mut selected_total = 0; + let mut selected_completed = 0; + for (file, fd) in target.iter_mut().zip(file_details) { + let completed = file_progress.get(fd.index).copied().unwrap_or(0); + let is_selected = selected_indices.is_none_or(|set| set.contains(&fd.index)); + if is_selected { + selected_total += fd.length; + selected_completed += completed; + } + + // 1-based index for aria2/RPC compatibility + set_usize_string(&mut file.index, fd.index + 1); + file.path.clear(); + let _ = write!(file.path, "{}/{}", base_dir, fd.path); + set_u64_string(&mut file.length, fd.length); + set_u64_string(&mut file.completed_length, completed); + set_bool_string(&mut file.selected, is_selected); + file.uris.clear(); + } + + (selected_total, selected_completed) } #[cfg(test)] @@ -3389,6 +3490,31 @@ mod tests { assert_eq!(task.uris, vec![uri.to_string()]); } + #[tokio::test] + async fn shutdown_cancels_active_download_tokens() { + let mgr = make_test_manager(Vec::new()); + let cancel = Arc::new(AtomicBool::new(false)); + let cancel_token = CancellationToken::new(); + mgr.active_downloads.write().await.insert( + "gid1".to_string(), + ActiveDownload { + cancel: cancel.clone(), + cancel_token: cancel_token.clone(), + total: Arc::new(AtomicU64::new(0)), + completed: Arc::new(AtomicU64::new(0)), + speed: Arc::new(AtomicU64::new(0)), + connections: Arc::new(AtomicU32::new(0)), + chunk_completed: Vec::new(), + adopted_filename: Arc::new(parking_lot::Mutex::new(None)), + }, + ); + + mgr.shutdown().await; + + assert!(cancel.load(Ordering::Relaxed)); + assert!(cancel_token.is_cancelled()); + } + #[tokio::test] async fn tell_active_returns_only_active() { let mgr = make_test_manager(vec![ diff --git a/src-tauri/risuko-engine/src/engine/media.rs b/src-tauri/risuko-engine/src/engine/media.rs index d28c0abc..0b9e1f12 100644 --- a/src-tauri/risuko-engine/src/engine/media.rs +++ b/src-tauri/risuko-engine/src/engine/media.rs @@ -368,7 +368,8 @@ pub async fn run_media_download( if let Some(dl) = dl_bytes { if dl < last_dl_bytes && last_dl_bytes > 65536 { - base_bytes += last_stage_total; + // Advance by the finished stage size, or last downloaded bytes when total is unknown + base_bytes += last_stage_total.max(last_dl_bytes); } last_dl_bytes = dl; if let Some(t) = total_bytes { diff --git a/src-tauri/risuko-engine/src/engine/p2p_tests.rs b/src-tauri/risuko-engine/src/engine/p2p_tests.rs index 8595ebfd..10d617e5 100644 --- a/src-tauri/risuko-engine/src/engine/p2p_tests.rs +++ b/src-tauri/risuko-engine/src/engine/p2p_tests.rs @@ -11,12 +11,9 @@ //! //! So we exercise URI parsers, binary message codecs, and pure helpers -use crate::engine::adc::{ - is_adc_uri, - nmdc::{escape_key, lock_to_key}, - parse_adc_hub_uri, parse_dchub_file_uri, - types::HubDialect, -}; +#[cfg(feature = "adc-transfer")] +use crate::engine::adc::nmdc::{escape_key, lock_to_key}; +use crate::engine::adc::{is_adc_uri, parse_adc_hub_uri, parse_dchub_file_uri, types::HubDialect}; use crate::engine::g2::{is_g2_uri, parse_g2_uri}; use crate::engine::gift::{extract_gift_name, is_gift_uri, parse_gift_uri}; use crate::engine::gnutella::{ @@ -106,6 +103,7 @@ fn dchub_file_uri_returns_none_when_no_query() { } #[test] +#[cfg(feature = "adc-transfer")] fn nmdc_lock_to_key_round_trip_properties() { // Algorithm correctness check: for the well-known short input the // first byte mixes lock[0] xor lock[n-1] xor lock[n-2] xor 5, then @@ -120,6 +118,7 @@ fn nmdc_lock_to_key_round_trip_properties() { } #[test] +#[cfg(feature = "adc-transfer")] fn nmdc_escape_key_escapes_only_reserved_bytes() { let raw = vec![0u8, 1, 5, 36, 50, 96, 100, 124, 126, 200]; let esc = escape_key(&raw); diff --git a/src-tauri/risuko-engine/src/engine/rpc.rs b/src-tauri/risuko-engine/src/engine/rpc.rs index bbc17fae..63778abb 100644 --- a/src-tauri/risuko-engine/src/engine/rpc.rs +++ b/src-tauri/risuko-engine/src/engine/rpc.rs @@ -448,7 +448,7 @@ fn check_auth(secret: &str, mut params: Vec) -> (Vec, bool) { if let Some(first) = params.first() { if let Some(token_str) = first.as_str() { if let Some(provided) = token_str.strip_prefix("token:") { - if provided == secret { + if secret_eq(provided, secret) { params.remove(0); return (params, true); } @@ -458,6 +458,20 @@ fn check_auth(secret: &str, mut params: Vec) -> (Vec, bool) { (params, false) } +/// Constant-time comparison for RPC tokens +/// +/// Hashes both sides to fixed-size digests, then fold-XORs to avoid early exit +fn secret_eq(provided: &str, secret: &str) -> bool { + use sha2::{Digest, Sha256}; + let a = Sha256::digest(provided.as_bytes()); + let b = Sha256::digest(secret.as_bytes()); + let mut diff = 0u8; + for (x, y) in a.iter().zip(b.iter()) { + diff |= x ^ y; + } + diff == 0 +} + /// Normalize `aria2.X` → `risuko.X`, pass `system.X` and `risuko.X` through fn normalize_method(method: &str) -> String { if let Some(suffix) = method.strip_prefix("aria2.") { @@ -815,12 +829,12 @@ fn dispatch_method<'a>( } "risuko.addRoutingRule" => { - let rule = params - .first() - .and_then(|v| { - serde_json::from_value::(v.clone()).ok() - }) + let rule_value = params + .into_iter() + .next() .ok_or_else(|| RpcError::from("Invalid routing rule".to_string()))?; + let rule = serde_json::from_value::(rule_value) + .map_err(|e| RpcError::from(format!("Invalid routing rule: {e}")))?; let added = state .manager .add_routing_rule(rule) @@ -830,12 +844,12 @@ fn dispatch_method<'a>( } "risuko.updateRoutingRule" => { - let rule = params - .first() - .and_then(|v| { - serde_json::from_value::(v.clone()).ok() - }) + let rule_value = params + .into_iter() + .next() .ok_or_else(|| RpcError::from("Invalid routing rule".to_string()))?; + let rule = serde_json::from_value::(rule_value) + .map_err(|e| RpcError::from(format!("Invalid routing rule: {e}")))?; state .manager .update_routing_rule(rule) diff --git a/src-tauri/risuko-engine/src/engine/rss/mod.rs b/src-tauri/risuko-engine/src/engine/rss/mod.rs index 264cda7e..cd3853d4 100644 --- a/src-tauri/risuko-engine/src/engine/rss/mod.rs +++ b/src-tauri/risuko-engine/src/engine/rss/mod.rs @@ -241,6 +241,48 @@ impl RssManager { all_new } + /// Update feeds whose own interval elapsed + /// + /// The background poller uses this so slow feeds do not refetch at the global wake cadence + async fn update_due_feeds(&self) -> Vec<(String, Vec)> { + let now = now_secs(); + let feeds: Vec<(String, bool, u64, Option)> = { + let s = self.store.lock().await; + s.feeds + .iter() + .map(|f| { + ( + f.id.clone(), + f.is_active, + f.update_interval_secs, + f.last_fetched_at, + ) + }) + .collect() + }; + + let mut all_new = Vec::new(); + for (feed_id, is_active, interval, last_fetched_at) in feeds { + if !is_active { + continue; + } + let due_at = last_fetched_at.unwrap_or(0).saturating_add(interval); + if now < due_at { + continue; + } + match self.update_feed(&feed_id).await { + Ok(new_items) if !new_items.is_empty() => { + all_new.push((feed_id, new_items)); + } + Ok(_) => {} + Err(e) => { + log::warn!("Failed to update feed {}: {}", feed_id, e); + } + } + } + all_new + } + pub async fn get_feeds(&self) -> Vec { self.store.lock().await.feeds.clone() } @@ -647,7 +689,8 @@ impl RssManager { tokio::time::sleep(tokio::time::Duration::from_secs(min_interval)).await; - let new_items_per_feed = rss.update_all_feeds().await; + // The global minimum is only the wake cadence; each feed keeps its own interval + let new_items_per_feed = rss.update_due_feeds().await; // Auto-download matching items via the v2 rule engine for (feed_id, new_items) in &new_items_per_feed { diff --git a/src-tauri/risuko-engine/src/engine/rss/rule_engine.rs b/src-tauri/risuko-engine/src/engine/rss/rule_engine.rs index b7118282..607c2346 100644 --- a/src-tauri/risuko-engine/src/engine/rss/rule_engine.rs +++ b/src-tauri/risuko-engine/src/engine/rss/rule_engine.rs @@ -1,5 +1,8 @@ //! Rule evaluation: pure functions over `RssRule`, `RssItem`, `ParsedMeta` +use std::collections::HashMap; +use std::sync::{Mutex, OnceLock}; + use globset::{GlobBuilder, GlobMatcher}; use regex::Regex; @@ -71,10 +74,13 @@ pub fn evaluate_rule(rule: &RssRule, item: &RssItem, parsed: &ParsedMeta) -> Rul // Series filter if let Some(ref want) = rule.series_filter { let want_norm = normalize_series(want); - let have_norm = parsed.series.as_deref().map(normalize_series); - match have_norm { - Some(have) if have.contains(&want_norm) || want_norm.contains(&have) => {} - _ => return reject("series filter mismatch"), + // Empty normalized filters would match every item, so treat them as no constraint + if !want_norm.is_empty() { + let have_norm = parsed.series.as_deref().map(normalize_series); + match have_norm { + Some(have) if have.contains(&want_norm) || want_norm.contains(&have) => {} + _ => return reject("series filter mismatch"), + } } } @@ -173,7 +179,7 @@ fn pattern_matches(p: &Pattern, text: &str) -> bool { } normalize(text, p.case_sensitive).contains(&normalize(&p.value, p.case_sensitive)) } - PatternKind::Glob => match build_glob(&p.value, p.case_sensitive) { + PatternKind::Glob => match cached_glob(&p.value, p.case_sensitive) { Some(matcher) => matcher.is_match(text), None => false, }, @@ -183,19 +189,56 @@ fn pattern_matches(p: &Pattern, text: &str) -> bool { } else { format!("(?i){}", p.value) }; - Regex::new(&pattern_src) - .map(|re| re.is_match(text)) - .unwrap_or(false) + match cached_regex(&pattern_src) { + Some(re) => re.is_match(text), + None => false, + } } } } -fn build_glob(value: &str, case_sensitive: bool) -> Option { - GlobBuilder::new(value) +/// Cap distinct user patterns retained per cache +/// +/// On overflow, clear the cache instead of precise eviction; active rules reuse few patterns +const PATTERN_CACHE_CAP: usize = 1024; + +/// Compile-once regex cache for rules +/// +/// Caches invalid patterns too, keyed by fully-resolved source including `(?i)` +fn cached_regex(pattern_src: &str) -> Option { + static CACHE: OnceLock>>> = OnceLock::new(); + let cache = CACHE.get_or_init(|| Mutex::new(HashMap::new())); + let mut map = cache.lock().unwrap_or_else(|e| e.into_inner()); + if let Some(cached) = map.get(pattern_src) { + return cached.clone(); + } + let compiled = Regex::new(pattern_src).ok(); + if map.len() >= PATTERN_CACHE_CAP { + map.clear(); + } + map.insert(pattern_src.to_string(), compiled.clone()); + compiled +} + +/// Compile-once glob cache for rules, keyed by value and case sensitivity +fn cached_glob(value: &str, case_sensitive: bool) -> Option { + static CACHE: OnceLock>>> = OnceLock::new(); + let cache = CACHE.get_or_init(|| Mutex::new(HashMap::new())); + let key = (value.to_string(), case_sensitive); + let mut map = cache.lock().unwrap_or_else(|e| e.into_inner()); + if let Some(cached) = map.get(&key) { + return cached.clone(); + } + let compiled = GlobBuilder::new(value) .case_insensitive(!case_sensitive) .build() .ok() - .map(|g| g.compile_matcher()) + .map(|g| g.compile_matcher()); + if map.len() >= PATTERN_CACHE_CAP { + map.clear(); + } + map.insert(key, compiled.clone()); + compiled } fn episode_in_selector(sel: &EpisodeSelector, ep: Option) -> bool { diff --git a/src-tauri/risuko-engine/src/engine/task.rs b/src-tauri/risuko-engine/src/engine/task.rs index 9b343fa9..c1f09e48 100644 --- a/src-tauri/risuko-engine/src/engine/task.rs +++ b/src-tauri/risuko-engine/src/engine/task.rs @@ -637,7 +637,7 @@ impl DownloadTask { /// Build status response for `tellStatus` pub fn to_rpc_status(&self, keys: &[String]) -> Value { - let full = self.to_full_rpc_status(); + let full = self.to_full_rpc_status(keys); if keys.is_empty() { return full; } @@ -653,7 +653,9 @@ impl DownloadTask { Value::Object(filtered) } - fn to_full_rpc_status(&self) -> Value { + fn to_full_rpc_status(&self, keys: &[String]) -> Value { + // Skip serializing large `files` arrays when a non-empty key filter does not request them + let want_files = keys.is_empty() || keys.iter().any(|k| k == "files"); let mut m = Map::new(); m.insert("gid".into(), Value::String(self.gid.clone())); m.insert( @@ -696,15 +698,17 @@ impl DownloadTask { m.insert("tag".into(), Value::String(tag.clone())); } - if !self.files.is_empty() { - let files_val: Vec = self - .files - .iter() - .map(|f| serde_json::to_value(f).unwrap_or(Value::Null)) - .collect(); - m.insert("files".into(), Value::Array(files_val)); - } else { - m.insert("files".into(), Value::Array(Vec::new())); + if want_files { + if !self.files.is_empty() { + let files_val: Vec = self + .files + .iter() + .map(|f| serde_json::to_value(f).unwrap_or(Value::Null)) + .collect(); + m.insert("files".into(), Value::Array(files_val)); + } else { + m.insert("files".into(), Value::Array(Vec::new())); + } } if let Some(ref code) = self.error_code { diff --git a/src-tauri/risuko-engine/src/engine/torrent.rs b/src-tauri/risuko-engine/src/engine/torrent.rs index 91ea747e..45773db2 100644 --- a/src-tauri/risuko-engine/src/engine/torrent.rs +++ b/src-tauri/risuko-engine/src/engine/torrent.rs @@ -559,7 +559,7 @@ pub struct TorrentStats { pub struct PeerSnapshot { pub addr: std::net::SocketAddr, /// Raw bitfield bytes; manager hex-encodes for the RPC payload - pub bitfield: Vec, + pub bitfield: std::sync::Arc<[u8]>, pub am_choking: bool, pub am_interested: bool, pub peer_choking: bool, diff --git a/src-tauri/risuko-engine/src/engine/upload/ftp.rs b/src-tauri/risuko-engine/src/engine/upload/ftp.rs index e4e29289..2c9b65ce 100644 --- a/src-tauri/risuko-engine/src/engine/upload/ftp.rs +++ b/src-tauri/risuko-engine/src/engine/upload/ftp.rs @@ -146,10 +146,13 @@ macro_rules! copy_to { let mut local_file = tokio::fs::File::open(&$local) .await .map_err(|e| format!("open {}: {e}", $local.display()))?; + + // Stage to ".part" and rename only after finalize, so failures never commit a truncated file + let part_remote = format!("{}.part", $remote); let mut writer = $ftp - .put_with_stream(&$remote) + .put_with_stream(&part_remote) .await - .map_err(|e| format!("FTP STOR {}: {e}", $remote))?; + .map_err(|e| format!("FTP STOR {}: {e}", part_remote))?; let mut buf = vec![0u8; COPY_BUF]; let mut sent: u64 = 0; @@ -180,12 +183,22 @@ macro_rules! copy_to { } .await; - // Propagate copy/flush errors before finalizing — otherwise the - // server commits a truncated file on cancellation or write failure - copy_res?; + // On copy, flush, or cancel failure, close the stream and remove the staged file + if let Err(e) = copy_res { + let _ = $ftp.finalize_put_stream(writer).await; + let _ = $ftp.rm(part_remote.as_str()).await; + return Err(e); + } $ftp.finalize_put_stream(writer) .await .map_err(|e| format!("FTP finalize: {e}"))?; + + // Remove any stale target before RNTO; some servers refuse overwrite + let _ = $ftp.rm($remote.as_str()).await; + $ftp + .rename(part_remote.as_str(), $remote.as_str()) + .await + .map_err(|e| format!("FTP rename {} -> {}: {e}", part_remote, $remote))?; }}; } diff --git a/src-tauri/risuko-engine/src/engine/upload/manager.rs b/src-tauri/risuko-engine/src/engine/upload/manager.rs index 1020de11..1918690c 100644 --- a/src-tauri/risuko-engine/src/engine/upload/manager.rs +++ b/src-tauri/risuko-engine/src/engine/upload/manager.rs @@ -27,6 +27,11 @@ use super::webdav::WebdavSink; const UPLOAD_STORE_KEY: &str = "upload-sinks"; +/// Upper bound for retained terminal upload jobs +/// +/// Queued and active jobs are never evicted by this cap +const MAX_TERMINAL_JOBS: usize = 200; + fn now_secs() -> u64 { SystemTime::now() .duration_since(UNIX_EPOCH) @@ -437,7 +442,12 @@ impl UploadSinkManager { finished_at: None, }; - self.jobs.lock().await.insert(job_id.clone(), job.clone()); + { + let mut jobs = self.jobs.lock().await; + jobs.insert(job_id.clone(), job.clone()); + // Trim oldest terminal jobs only; queued and active jobs stay + prune_terminal_jobs(&mut jobs, MAX_TERMINAL_JOBS); + } // Pre-create the cancel token so users can cancel queued jobs that // haven't acquired a semaphore permit yet let cancel = CancellationToken::new(); @@ -660,12 +670,21 @@ impl UploadSinkManager { } async fn touch_sink_used(&self, sink_id: &str) { - let mut s = self.store.write().await; - if let Some(sink) = s.sinks.iter_mut().find(|x| x.id == sink_id) { - sink.last_used_at = Some(now_secs()); + let now = now_secs(); + let changed = { + let mut s = self.store.write().await; + match s.sinks.iter_mut().find(|x| x.id == sink_id) { + // Skip full-store save when second-granularity timestamp has not moved + Some(sink) if sink.last_used_at != Some(now) => { + sink.last_used_at = Some(now); + true + } + _ => false, + } + }; + if changed { + let _ = self.save().await; } - drop(s); - let _ = self.save().await; } fn emit_event(&self, name: &str, job: &UploadJob) { @@ -682,6 +701,38 @@ impl UploadSinkManager { } } +/// Evict oldest terminal jobs so at most `max_terminal` remain +fn prune_terminal_jobs(jobs: &mut HashMap, max_terminal: usize) { + let terminal_count = jobs + .values() + .filter(|j| { + matches!( + j.status, + JobStatus::Complete | JobStatus::Failed | JobStatus::Cancelled + ) + }) + .count(); + if terminal_count <= max_terminal { + return; + } + // Collect terminal job ids oldest-first, then drop the excess + let mut terminal: Vec<(u64, String)> = jobs + .values() + .filter(|j| { + matches!( + j.status, + JobStatus::Complete | JobStatus::Failed | JobStatus::Cancelled + ) + }) + .map(|j| (j.created_at, j.id.clone())) + .collect(); + terminal.sort_by_key(|(created_at, _)| *created_at); + let to_remove = terminal_count - max_terminal; + for (_, id) in terminal.into_iter().take(to_remove) { + jobs.remove(&id); + } +} + /// Build the runtime sink object from its persisted config. Centralised so /// adding a new protocol means adding one match arm here plus one new module pub fn build_sink_runtime(cfg: &SinkConfig) -> Result { diff --git a/src-tauri/risuko-engine/src/engine/upload/s3.rs b/src-tauri/risuko-engine/src/engine/upload/s3.rs index b0f3b92d..0ed3a01c 100644 --- a/src-tauri/risuko-engine/src/engine/upload/s3.rs +++ b/src-tauri/risuko-engine/src/engine/upload/s3.rs @@ -7,9 +7,12 @@ //! / UploadPart × N / Complete) which raises the per-object cap to 5 TiB //! at the cost of one extra round-trip and an XML completion document +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::Arc; use std::time::Duration; use async_trait::async_trait; +use futures_util::{StreamExt, TryStreamExt}; use hmac::digest::KeyInit; use hmac::{Hmac, Mac}; use risuko_http::{Client, ClientBuilder, Url}; @@ -36,6 +39,11 @@ const MIN_PART_SIZE: u64 = 5 * 1024 * 1024; /// AWS-imposed maximum number of parts per multipart upload const MAX_PARTS: u64 = 10_000; +/// Number of multipart parts uploaded concurrently +/// +/// Small fixed fan-out improves high-latency uploads without flooding the client pool +const MULTIPART_CONCURRENCY: usize = 4; + pub struct S3Sink { cfg: S3Config, client: Client, @@ -208,9 +216,8 @@ impl S3Sink { /// without that the bucket would silently accumulate orphan multipart /// state that the user pays for /// - /// Parts are uploaded sequentially in v1; concurrency is a future - /// optimisation but adds a stricter ordering of progress reports than - /// the rest of the pipeline currently expects + /// Parts are uploaded with bounded concurrency ([`MULTIPART_CONCURRENCY`]); + /// progress uses a shared atomic so out-of-order parts still report a monotonic total async fn upload_multipart( &self, file: &UploadFile, @@ -227,9 +234,8 @@ impl S3Sink { .upload_parts_and_complete(file, ctl, &url, &upload_id, part_size) .await; - if result.is_err() || ctl.cancel.is_cancelled() { - // Best-effort abort. Errors here are logged but don't override - // the original failure reason + if result.is_err() { + // Best-effort abort; log errors without replacing the original failure if let Err(e) = self.abort_multipart(&url, &upload_id).await { log::warn!("S3 abort multipart {upload_id}: {e}"); } @@ -246,41 +252,47 @@ impl S3Sink { upload_id: &str, part_size: u64, ) -> Result { - let mut parts: Vec<(u32, String)> = Vec::new(); let total = file.size; - let mut bytes_done: u64 = 0; - let mut part_number: u32 = 1; - let mut offset: u64 = 0; + // Build descriptors first so the part-count cap is checked before any request + let mut descriptors: Vec<(u32, u64, u64)> = Vec::new(); + let mut offset: u64 = 0; + let mut part_number: u32 = 1; while offset < total { - if ctl.cancel.is_cancelled() { - return Err("cancelled".into()); - } let len = part_size.min(total - offset); - let etag = self - .upload_part( - file, - ctl, - url, - upload_id, - part_number, - offset, - len, - bytes_done, - total, - ) - .await?; - parts.push((part_number, etag)); + descriptors.push((part_number, offset, len)); offset += len; - bytes_done += len; - part_number += 1; - if part_number as u64 > MAX_PARTS && offset < total { + if part_number as u64 >= MAX_PARTS && offset < total { return Err(format!( "S3 multipart: part count exceeded {MAX_PARTS} (file too large for chosen part size)" )); } + part_number += 1; } + // Track cumulative bytes across out-of-order parts with one shared atomic + let uploaded = Arc::new(AtomicU64::new(0)); + + let mut parts: Vec<(u32, String)> = + futures_util::stream::iter(descriptors.into_iter().map(|(pn, off, len)| { + let uploaded = uploaded.clone(); + async move { + if ctl.cancel.is_cancelled() { + return Err("cancelled".to_string()); + } + let etag = self + .upload_part(file, ctl, url, upload_id, pn, off, len, &uploaded, total) + .await?; + Ok::<(u32, String), String>((pn, etag)) + } + })) + .buffer_unordered(MULTIPART_CONCURRENCY) + .try_collect() + .await?; + + // CompleteMultipartUpload requires ascending part numbers + parts.sort_by_key(|(pn, _)| *pn); + // -- Complete -- self.complete_multipart(url, upload_id, &parts).await?; ctl.report(total, total); @@ -327,7 +339,7 @@ impl S3Sink { part_number: u32, offset: u64, len: u64, - bytes_done_before: u64, + uploaded: &Arc, total: u64, ) -> Result { let query = format!( @@ -340,14 +352,20 @@ impl S3Sink { let now = chrono_now_utc(); let auth = self.sign_request("PUT", &part_url, &query, UNSIGNED, &now.0, &now.1); - // Progress callback rebases per-part bytes onto the cumulative - // total so the UI sees a monotonic climb across all parts + // Fold each part's `sent` delta into the shared counter for monotonic UI progress let progress = ctl.clone(); + let uploaded = uploaded.clone(); + let part_last = Arc::new(AtomicU64::new(0)); let body = risuko_http::file_stream_body_range_with_progress( file.local_path.clone(), offset, len, - move |sent| progress.report((bytes_done_before + sent).min(total), total), + move |sent| { + let prev = part_last.swap(sent, Ordering::Relaxed); + let delta = sent.saturating_sub(prev); + let cum = uploaded.fetch_add(delta, Ordering::Relaxed) + delta; + progress.report(cum.min(total), total); + }, Some(ctl.cancel.clone()), ); diff --git a/src-tauri/risuko-engine/src/engine/upload/sftp.rs b/src-tauri/risuko-engine/src/engine/upload/sftp.rs index 55af4cfd..bed64a41 100644 --- a/src-tauri/risuko-engine/src/engine/upload/sftp.rs +++ b/src-tauri/risuko-engine/src/engine/upload/sftp.rs @@ -14,7 +14,7 @@ use tokio::io::{AsyncReadExt, AsyncWriteExt}; use super::sink::{SftpConfig, UploadControl, UploadFile, UploadSink}; use crate::engine::ssh_known_hosts::TofuHandler; -const COPY_BUF: usize = 64 * 1024; +const COPY_BUF: usize = 256 * 1024; const CONNECT_TIMEOUT: Duration = Duration::from_secs(20); pub struct SftpSink { diff --git a/src-tauri/risuko-engine/src/engine/upload/webdav.rs b/src-tauri/risuko-engine/src/engine/upload/webdav.rs index e5b285b3..fd462562 100644 --- a/src-tauri/risuko-engine/src/engine/upload/webdav.rs +++ b/src-tauri/risuko-engine/src/engine/upload/webdav.rs @@ -176,7 +176,15 @@ impl UploadSink for WebdavSink { // Streaming PUT. The body factory opens the file lazily so retries // start from byte zero with a fresh reader let path: PathBuf = file.local_path.clone(); - let body = risuko_http::file_stream_body(path.clone(), Some(file.size)); + // Wrap the file stream so chunks report progress and observe cancellation mid-stream + let total = file.size; + let progress = ctl.clone(); + let body = risuko_http::file_stream_body_with_progress( + path.clone(), + total, + move |sent| progress.report(sent.min(total), total), + Some(ctl.cancel.clone()), + ); let mut req = self .client diff --git a/src-tauri/risuko-http/src/client.rs b/src-tauri/risuko-http/src/client.rs index 6dfbdd63..ec4b6143 100644 --- a/src-tauri/risuko-http/src/client.rs +++ b/src-tauri/risuko-http/src/client.rs @@ -204,6 +204,7 @@ impl Client { headers.remove(HOST); } + resp.drain().await?; url = next; } } diff --git a/src-tauri/risuko-http/src/connector.rs b/src-tauri/risuko-http/src/connector.rs index 8d025265..13b49c1b 100644 --- a/src-tauri/risuko-http/src/connector.rs +++ b/src-tauri/risuko-http/src/connector.rs @@ -45,7 +45,7 @@ impl Connector { } pub(crate) enum MaybeTls { - Plain(TcpStream), + Plain(BoxedIo), Tls(Box>), } @@ -64,6 +64,56 @@ impl BoxedIo { } } +struct PrefixedIo { + prefix: io::Cursor>, + inner: T, +} + +impl PrefixedIo { + fn new(prefix: Vec, inner: T) -> Self { + Self { + prefix: io::Cursor::new(prefix), + inner, + } + } +} + +impl AsyncRead for PrefixedIo { + fn poll_read( + mut self: Pin<&mut Self>, + cx: &mut Context<'_>, + buf: &mut ReadBuf<'_>, + ) -> Poll> { + if self.prefix.position() < self.prefix.get_ref().len() as u64 { + let position = self.prefix.position(); + let remaining = &self.prefix.get_ref()[position as usize..]; + let n = remaining.len().min(buf.remaining()); + buf.put_slice(&remaining[..n]); + self.prefix.set_position(position + n as u64); + return Poll::Ready(Ok(())); + } + Pin::new(&mut self.inner).poll_read(cx, buf) + } +} + +impl AsyncWrite for PrefixedIo { + fn poll_write( + mut self: Pin<&mut Self>, + cx: &mut Context<'_>, + buf: &[u8], + ) -> Poll> { + Pin::new(&mut self.inner).poll_write(cx, buf) + } + + fn poll_flush(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + Pin::new(&mut self.inner).poll_flush(cx) + } + + fn poll_shutdown(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + Pin::new(&mut self.inner).poll_shutdown(cx) + } +} + impl AsyncRead for BoxedIo { fn poll_read( mut self: Pin<&mut Self>, @@ -198,7 +248,7 @@ impl Connector { let stream = match self.proxy.as_deref() { Some(p) => self.via_proxy(p, &host, port, is_https).await?, - None => self.direct(&host, port).await?, + None => BoxedIo::new(self.direct(&host, port).await?), }; let final_io = if is_https { @@ -207,9 +257,7 @@ impl Connector { // The TCP-connect timeout above doesn't cover the TLS // handshake, so apply the same budget here to avoid hangs on // half-open or misconfigured TLS servers - let handshake = self - .tls_connector() - .connect(server_name, BoxedIo::new(stream)); + let handshake = self.tls_connector().connect(server_name, stream); let tls = match self.connect_timeout { Some(d) => tokio::time::timeout(d, handshake) .await @@ -329,7 +377,7 @@ impl Connector { host: &str, port: u16, is_https: bool, - ) -> Result { + ) -> Result { match proxy.scheme() { ProxyScheme::Http => { let phost = proxy @@ -338,11 +386,12 @@ impl Connector { .ok_or_else(|| Error::Url("proxy missing host".into()))? .to_string(); let pport = proxy.url().port().unwrap_or(80); - let mut stream = self.direct(&phost, pport).await?; + let stream = self.direct(&phost, pport).await?; if is_https { - http_connect(&mut stream, host, port, proxy, self.connect_timeout).await?; + http_connect(stream, host, port, proxy, self.connect_timeout).await + } else { + Ok(BoxedIo::new(stream)) } - Ok(stream) } ProxyScheme::Socks5 { resolve_locally } => { let phost = proxy @@ -395,19 +444,19 @@ impl Connector { } }; self.tune(&stream)?; - Ok(stream) + Ok(BoxedIo::new(stream)) } } } } async fn http_connect( - stream: &mut TcpStream, + stream: TcpStream, host: &str, port: u16, proxy: &Proxy, timeout: Option, -) -> Result<(), Error> { +) -> Result { let fut = http_connect_inner(stream, host, port, proxy); match timeout { Some(d) => tokio::time::timeout(d, fut) @@ -418,11 +467,11 @@ async fn http_connect( } async fn http_connect_inner( - stream: &mut TcpStream, + mut stream: TcpStream, host: &str, port: u16, proxy: &Proxy, -) -> Result<(), Error> { +) -> Result { let mut req = format!("CONNECT {host}:{port} HTTP/1.1\r\nHost: {host}:{port}\r\n"); if !proxy.url().username().is_empty() { // Percent-decode credentials per RFC 3986 before encoding to Basic; @@ -440,24 +489,28 @@ async fn http_connect_inner( .await .map_err(|e| Error::Connect(format!("CONNECT write: {e}")))?; - let mut buf = Vec::with_capacity(256); + let mut buf = Vec::with_capacity(1024); loop { - let mut byte = [0u8; 1]; + let mut chunk = [0u8; 1024]; let n = stream - .read(&mut byte) + .read(&mut chunk) .await .map_err(|e| Error::Connect(format!("CONNECT read: {e}")))?; if n == 0 { return Err(Error::Connect("proxy closed during CONNECT".into())); } - buf.push(byte[0]); - if buf.ends_with(b"\r\n\r\n") { + buf.extend_from_slice(&chunk[..n]); + let header_end = find_header_end(&buf); + if header_end.is_some() { break; } if buf.len() > 16 * 1024 { return Err(Error::Connect("CONNECT response too large".into())); } } + let header_end = find_header_end(&buf) + .ok_or_else(|| Error::Connect("CONNECT response missing header terminator".into()))?; + let leftover = buf.split_off(header_end); let head = std::str::from_utf8(&buf).map_err(|e| Error::Connect(e.to_string()))?; let status_line = head.lines().next().unwrap_or(""); let mut parts = status_line.split_whitespace(); @@ -466,7 +519,13 @@ async fn http_connect_inner( if !code.starts_with('2') { return Err(Error::Connect(format!("CONNECT failed: {status_line}"))); } - Ok(()) + Ok(BoxedIo::new(PrefixedIo::new(leftover, stream))) +} + +fn find_header_end(buf: &[u8]) -> Option { + buf.windows(4) + .position(|w| w == b"\r\n\r\n") + .map(|pos| pos + 4) } /// Connect to a single address with the optional per-attempt timeout @@ -531,10 +590,66 @@ fn percent_decode_str(s: &str) -> String { String::from_utf8_lossy(&out).into_owned() } +/// SOCKS5 connect with optional local DNS and username/password auth +/// +/// Split out so the connect-timeout wrapper covers every path +async fn socks5_connect( + proxy_addr: &str, + target_addr_str: &str, + host: &str, + port: u16, + resolve_locally: bool, + auth: Option<(&str, &str)>, + resolver: &SharedResolver, +) -> Result { + if resolve_locally { + // Use the configured resolver instead of bypassing split-horizon or DoH on SOCKS5 + let target_ip = resolver + .resolve(host) + .await? + .next() + .ok_or_else(|| Error::Connect(format!("no addrs for {host}")))? + .ip(); + let target = SocketAddr::new(target_ip, port); + let _ = target_addr_str; + match auth { + Some((u, p)) => Ok(tokio_socks::tcp::Socks5Stream::connect_with_password( + proxy_addr, target, u, p, + ) + .await + .map_err(|e| Error::Connect(e.to_string()))? + .into_inner()), + None => Ok(tokio_socks::tcp::Socks5Stream::connect(proxy_addr, target) + .await + .map_err(|e| Error::Connect(e.to_string()))? + .into_inner()), + } + } else { + match auth { + Some((u, p)) => Ok(tokio_socks::tcp::Socks5Stream::connect_with_password( + proxy_addr, + (host, port), + u, + p, + ) + .await + .map_err(|e| Error::Connect(e.to_string()))? + .into_inner()), + None => Ok( + tokio_socks::tcp::Socks5Stream::connect(proxy_addr, (host, port)) + .await + .map_err(|e| Error::Connect(e.to_string()))? + .into_inner(), + ), + } + } +} + #[cfg(test)] mod tests { use super::*; use std::net::{Ipv4Addr, Ipv6Addr}; + use tokio::net::TcpListener; fn v4(n: u8) -> SocketAddr { SocketAddr::new(std::net::IpAddr::V4(Ipv4Addr::new(10, 0, 0, n)), 443) @@ -621,62 +736,33 @@ mod tests { let res = c.happy_eyeballs("localhost", vec![dead1, dead2]).await; assert!(res.is_err()); } -} -/// SOCKS5 connect with optional local DNS and optional username/password -/// auth. Pulled out so the connect-timeout wrapper can apply the same -/// budget to every code path -async fn socks5_connect( - proxy_addr: &str, - target_addr_str: &str, - host: &str, - port: u16, - resolve_locally: bool, - auth: Option<(&str, &str)>, - resolver: &SharedResolver, -) -> Result { - if resolve_locally { - // Honour the configured custom resolver instead of bypassing it via - // `tokio::net::lookup_host`. Otherwise a custom DNS resolver set on - // the client (e.g. for split-horizon or DoH) would silently be - // skipped on the SOCKS5 path - let target_ip = resolver - .resolve(host) - .await? - .next() - .ok_or_else(|| Error::Connect(format!("no addrs for {host}")))? - .ip(); - let target = SocketAddr::new(target_ip, port); - let _ = target_addr_str; - match auth { - Some((u, p)) => Ok(tokio_socks::tcp::Socks5Stream::connect_with_password( - proxy_addr, target, u, p, - ) - .await - .map_err(|e| Error::Connect(e.to_string()))? - .into_inner()), - None => Ok(tokio_socks::tcp::Socks5Stream::connect(proxy_addr, target) + #[tokio::test] + async fn http_connect_preserves_bytes_read_after_headers() { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + let server = tokio::spawn(async move { + let (mut socket, _) = listener.accept().await.unwrap(); + let mut req = [0u8; 256]; + let n = socket.read(&mut req).await.unwrap(); + assert!(std::str::from_utf8(&req[..n]) + .unwrap() + .starts_with("CONNECT example.com:443 HTTP/1.1")); + socket + .write_all(b"HTTP/1.1 200 Connection Established\r\n\r\nhello") .await - .map_err(|e| Error::Connect(e.to_string()))? - .into_inner()), - } - } else { - match auth { - Some((u, p)) => Ok(tokio_socks::tcp::Socks5Stream::connect_with_password( - proxy_addr, - (host, port), - u, - p, - ) + .unwrap(); + }); + + let stream = TcpStream::connect(addr).await.unwrap(); + let proxy = Proxy::all("http://127.0.0.1:8080").unwrap(); + let mut tunneled = http_connect(stream, "example.com", 443, &proxy, None) .await - .map_err(|e| Error::Connect(e.to_string()))? - .into_inner()), - None => Ok( - tokio_socks::tcp::Socks5Stream::connect(proxy_addr, (host, port)) - .await - .map_err(|e| Error::Connect(e.to_string()))? - .into_inner(), - ), - } + .unwrap(); + let mut out = [0u8; 5]; + tunneled.read_exact(&mut out).await.unwrap(); + + assert_eq!(&out, b"hello"); + server.await.unwrap(); } } diff --git a/src-tauri/risuko-http/src/response.rs b/src-tauri/risuko-http/src/response.rs index 48370ebc..0b179fc5 100644 --- a/src-tauri/risuko-http/src/response.rs +++ b/src-tauri/risuko-http/src/response.rs @@ -1,5 +1,5 @@ use bytes::Bytes; -use futures_util::Stream; +use futures_util::{Stream, StreamExt}; use http::{HeaderMap, StatusCode, Version}; use http_body_util::BodyExt; use serde::de::DeserializeOwned; @@ -84,9 +84,19 @@ impl Response { Ok(collected.to_bytes()) } + pub(crate) async fn drain(mut self) -> Result<()> { + if let Some(body) = self.body.take() { + let mut stream = BodyStream::new(body); + while let Some(chunk) = stream.next().await { + chunk?; + } + } + Ok(()) + } + pub async fn text(self) -> Result { let bytes = self.bytes().await?; - String::from_utf8(bytes.to_vec()).map_err(|e| Error::Decode(e.to_string())) + String::from_utf8(bytes.into()).map_err(|e| Error::Decode(e.to_string())) } pub async fn json(self) -> Result { diff --git a/src-tauri/src/cli/commands.rs b/src-tauri/src/cli/commands.rs index 51ac1b92..9a705480 100644 --- a/src-tauri/src/cli/commands.rs +++ b/src-tauri/src/cli/commands.rs @@ -1,10 +1,12 @@ -use serde_json::{json, Value}; +use serde_json::{json, Map, Value}; use super::headless; use super::progress::{self, format_size, format_size_speed}; use super::rpc_client::RpcClient; use super::{DownloadArgs, PauseArgs, RemoveArgs, ResumeArgs, ServeArgs, StatusArgs}; +use risuko_engine::config::defaults; use risuko_engine::engine::media::is_media_uri; +use risuko_engine::engine::options::EngineOptions; fn resolve_rpc_secret(explicit: Option) -> Option { explicit @@ -12,18 +14,18 @@ fn resolve_rpc_secret(explicit: Option) -> Option { .or_else(read_secret_from_config) } +fn rpc_client(port: u16, secret: Option) -> RpcClient { + RpcClient::new_with_host(&resolve_rpc_host(), port, secret) +} + +fn resolve_rpc_host() -> String { + read_options_from_config().rpc_host() +} + /// Read rpc-secret from the config files, returning None if empty or absent. /// user.json takes precedence over system.json fn read_secret_from_config() -> Option { - let config_dir = dirs::config_dir().map(|d| d.join("dev.risuko.app"))?; - let mut merged = load_config_file(&config_dir.join("system.json")); - let user = load_config_file(&config_dir.join("user.json")); - merged.extend(user); - let secret = merged - .get("rpc-secret") - .and_then(|v| v.as_str()) - .unwrap_or("") - .to_string(); + let secret = read_options_from_config().rpc_secret(); if secret.is_empty() { None } else { @@ -31,23 +33,32 @@ fn read_secret_from_config() -> Option { } } -fn load_config_file(path: &std::path::Path) -> serde_json::Map { - std::fs::read_to_string(path) - .ok() - .and_then(|data| serde_json::from_str::(&data).ok()) - .and_then(|v| { - if let Value::Object(m) = v { - Some(m) - } else { - None +fn read_options_from_config() -> EngineOptions { + let config_dir = dirs::config_dir() + .map(|d| d.join("dev.risuko.app")) + .unwrap_or_else(|| std::path::PathBuf::from(".")); + let system = load_config_file(&config_dir.join("system.json"), defaults::system_defaults()); + let user = load_config_file(&config_dir.join("user.json"), defaults::user_defaults()); + EngineOptions::from_config(&system, &user) +} + +fn load_config_file(path: &std::path::Path, defaults: Map) -> Map { + if let Ok(data) = std::fs::read_to_string(path) { + if let Ok(Value::Object(mut map)) = serde_json::from_str(&data) { + for (k, v) in &defaults { + if !map.contains_key(k) { + map.insert(k.clone(), v.clone()); + } } - }) - .unwrap_or_default() + return map; + } + } + defaults } pub async fn download(args: DownloadArgs) -> Result<(), Box> { let mut secret = resolve_rpc_secret(args.rpc_secret.clone()); - let client = RpcClient::new(args.rpc_port, secret.clone()); + let client = rpc_client(args.rpc_port, secret.clone()); let mut headless_engine = None; if !client.is_engine_running().await { @@ -60,7 +71,7 @@ pub async fn download(args: DownloadArgs) -> Result<(), Box Result<(), Box> { let secret = resolve_rpc_secret(args.rpc_secret); - let client = RpcClient::new(args.rpc_port, secret); + let client = rpc_client(args.rpc_port, secret); require_engine(&client).await?; if let Some(ref gid) = args.gid { @@ -302,7 +313,7 @@ pub async fn status(args: StatusArgs) -> Result<(), Box> pub async fn pause(args: PauseArgs) -> Result<(), Box> { let secret = resolve_rpc_secret(args.rpc_secret); - let client = RpcClient::new(args.rpc_port, secret); + let client = rpc_client(args.rpc_port, secret); require_engine(&client).await?; client.call("risuko.pause", vec![json!(args.gid)]).await?; @@ -312,7 +323,7 @@ pub async fn pause(args: PauseArgs) -> Result<(), Box> { pub async fn resume(args: ResumeArgs) -> Result<(), Box> { let secret = resolve_rpc_secret(args.rpc_secret); - let client = RpcClient::new(args.rpc_port, secret); + let client = rpc_client(args.rpc_port, secret); require_engine(&client).await?; client.call("risuko.unpause", vec![json!(args.gid)]).await?; @@ -322,7 +333,7 @@ pub async fn resume(args: ResumeArgs) -> Result<(), Box> pub async fn remove(args: RemoveArgs) -> Result<(), Box> { let secret = resolve_rpc_secret(args.rpc_secret); - let client = RpcClient::new(args.rpc_port, secret); + let client = rpc_client(args.rpc_port, secret); require_engine(&client).await?; client.call("risuko.remove", vec![json!(args.gid)]).await?; @@ -439,7 +450,14 @@ pub async fn serve(args: ServeArgs) -> Result<(), Box> { let engine = headless::start_headless_engine(args.rpc_port).await?; eprintln!("Risuko engine running. Press Ctrl+C to stop."); - tokio::signal::ctrl_c().await?; + // Shut down on Ctrl+C or RPC shutdown + // The `shutdown_requested()` borrow ends with `select!`, so `engine.shutdown()` can consume `engine` + tokio::select! { + res = tokio::signal::ctrl_c() => { res?; } + _ = engine.shutdown_requested() => { + eprintln!("Shutdown requested via RPC."); + } + } eprintln!("\nShutting down..."); engine.shutdown().await; Ok(()) diff --git a/src-tauri/src/cli/rpc_client.rs b/src-tauri/src/cli/rpc_client.rs index 18937108..b9e4be86 100644 --- a/src-tauri/src/cli/rpc_client.rs +++ b/src-tauri/src/cli/rpc_client.rs @@ -9,6 +9,10 @@ pub struct RpcClient { impl RpcClient { pub fn new(port: u16, secret: Option) -> Self { + Self::new_with_host("127.0.0.1", port, secret) + } + + pub fn new_with_host(host: &str, port: u16, secret: Option) -> Self { // Fail closed: a default client would silently drop the timeout and // connect_timeout configured above, leaving JSON-RPC calls free to hang // indefinitely. Building the client cannot legitimately fail with this @@ -18,8 +22,17 @@ impl RpcClient { .connect_timeout(std::time::Duration::from_secs(5)) .build() .expect("Failed to build HTTP client with custom config"); + let connect_host = match host.trim() { + "" | "0.0.0.0" | "::" | "[::]" => "127.0.0.1", + h => h, + }; + let url = if connect_host.contains(':') && !connect_host.starts_with('[') { + format!("http://[{}]:{}/jsonrpc", connect_host, port) + } else { + format!("http://{}:{}/jsonrpc", connect_host, port) + }; Self { - url: format!("http://127.0.0.1:{}/jsonrpc", port), + url, secret, client, id_counter: std::sync::atomic::AtomicU64::new(1), diff --git a/src-tauri/src/commands/app_cmds.rs b/src-tauri/src/commands/app_cmds.rs index af276f98..c1e8e8cf 100644 --- a/src-tauri/src/commands/app_cmds.rs +++ b/src-tauri/src/commands/app_cmds.rs @@ -98,11 +98,6 @@ pub fn factory_reset( handle.restart(); } -#[tauri::command] -pub fn check_for_updates() -> Result<(), String> { - Err("Update checking is not implemented for this build".to_string()) -} - #[tauri::command] pub async fn reset_session(handle: AppHandle) -> Result<(), String> { risuko_engine::engine::stop_engine() diff --git a/src-tauri/src/commands/engine_cmds.rs b/src-tauri/src/commands/engine_cmds.rs index b58f81cc..88bf3469 100644 --- a/src-tauri/src/commands/engine_cmds.rs +++ b/src-tauri/src/commands/engine_cmds.rs @@ -505,9 +505,9 @@ pub async fn probe_m3u8(url: String) -> Result { } #[tauri::command] -pub fn get_engine_status(state: tauri::State<'_, crate::state::AppState>) -> Result { - let running = state.engine_running.lock().map_err(|e| e.to_string())?; - Ok(*running) +pub fn get_engine_status() -> Result { + // Mirror the engine-owned liveness source used by the health report + Ok(engine::engine_uptime().is_some()) } async fn add_torrent_by_path_inner(path: &str, options: Option) -> Result { diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 8c32b807..348fb2b5 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -374,7 +374,6 @@ pub fn run() { commands::app_cmds::show_window, commands::app_cmds::hide_window, commands::app_cmds::factory_reset, - commands::app_cmds::check_for_updates, commands::app_cmds::reset_session, commands::app_cmds::auto_hide_window, commands::app_cmds::toggle_app_menu, diff --git a/src-tauri/src/state.rs b/src-tauri/src/state.rs index 263f4e05..fddd9597 100644 --- a/src-tauri/src/state.rs +++ b/src-tauri/src/state.rs @@ -11,7 +11,6 @@ use crate::managers::vault::VaultManager; pub struct AppState { pub config: Mutex, - pub engine_running: Mutex, pub is_quitting: AtomicBool, pub rss: Mutex>>, pub upload_sinks: Mutex>>, @@ -39,7 +38,6 @@ impl AppState { } Ok(Self { config: Mutex::new(config), - engine_running: Mutex::new(false), is_quitting: AtomicBool::new(false), rss: Mutex::new(Some(Arc::new(rss_manager))), upload_sinks: Mutex::new(Some(Arc::new(upload_manager))), diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 076835b8..d9b4ea0c 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "../node_modules/@tauri-apps/cli/config.schema.json", "productName": "Risuko", - "version": "0.3.6", + "version": "0.4.0", "identifier": "app.risuko.native", "build": { "devUrl": "http://127.0.0.1:9080", diff --git a/src/renderer/api/Api.ts b/src/renderer/api/Api.ts index 12c4a7bf..fa226d73 100644 --- a/src/renderer/api/Api.ts +++ b/src/renderer/api/Api.ts @@ -7,6 +7,7 @@ import type { DownloadTask, GlobalStat, LowSpeedEvaluationResult, + MediaInfo, PeerInfo, SyncOrderResult, } from "@shared/types/task"; @@ -183,13 +184,20 @@ export default class Api { } updateActiveTaskOption(options: Record) { - this.fetchTaskList({ type: "active" }).then((data) => { - if (isEmpty(data)) { - return; - } - const gids = (data as DownloadTask[]).map((task) => task.gid); - this.batchChangeOption({ gids, options }); - }); + return this.fetchTaskList({ type: "active" }) + .then((data) => { + if (isEmpty(data)) { + return; + } + const gids = (data as DownloadTask[]).map((task) => task.gid); + return this.batchChangeOption({ gids, options }); + }) + .catch((err) => { + logger.warn( + "[Risuko] updateActiveTaskOption failed:", + err?.message || err, + ); + }); } changeOption( @@ -320,7 +328,7 @@ export default class Api { getMediaInfo(params: { url: string; options?: Record }) { const engineOptions = formatOptionsForEngine(params.options); - return invoke("get_media_info", { + return invoke("get_media_info", { url: params.url, options: engineOptions, }); @@ -361,15 +369,10 @@ export default class Api { async fetchDownloadingTaskList( params: { offset?: number; num?: number; keys?: string[] } = {}, ) { - const { offset = 0, num = TASK_LIST_FETCH_SIZE, keys } = params; - const safeNum = clampTaskListFetchSize(num); - const [active, waiting] = await Promise.all([ - invoke("tell_active", { keys }), - invoke("tell_waiting", { offset, num: safeNum, keys }), - ]); + const { keys } = params; + const active = await invoke("tell_active", { keys }); const activeArr = Array.isArray(active) ? active : []; - const waitingArr = Array.isArray(waiting) ? waiting : []; - return [...activeArr, ...waitingArr]; + return activeArr; } fetchWaitingTaskList( @@ -817,7 +820,7 @@ export interface BrowserInfo { userAgent: string; } -export interface ImportedCookieView { +interface ImportedCookieView { name: string; value: string; domain: string; diff --git a/src/renderer/api/index.ts b/src/renderer/api/index.ts index 4a71a213..e3e7a27e 100644 --- a/src/renderer/api/index.ts +++ b/src/renderer/api/index.ts @@ -6,7 +6,6 @@ export type { BrowserInfo, CookieEntryView, ImportedCookies, - ImportedCookieView, } from "./Api"; export default api; diff --git a/src/renderer/components/Native/DynamicTray.vue b/src/renderer/components/Native/DynamicTray.vue index 036f1b8b..40415b88 100644 --- a/src/renderer/components/Native/DynamicTray.vue +++ b/src/renderer/components/Native/DynamicTray.vue @@ -64,10 +64,8 @@ export default { return focused ? getInverseTheme(theme) : theme; }, iconKey() { - const { bigSur, iconStatus, currentTheme } = this; - return bigSur - ? "tray-icon-light-normal" - : `tray-icon-${currentTheme}-${iconStatus}`; + const { iconStatus, currentTheme } = this; + return `tray-icon-${currentTheme}-${iconStatus}`; }, }, watch: { diff --git a/src/renderer/components/Native/EngineClient.vue b/src/renderer/components/Native/EngineClient.vue index 3dfb43c8..e9b489f2 100644 --- a/src/renderer/components/Native/EngineClient.vue +++ b/src/renderer/components/Native/EngineClient.vue @@ -90,9 +90,6 @@ export default { progress() { return useAppStore().progress; }, - messages() { - return (useTaskStore() as unknown as Record).messages; - }, seedingList() { return useTaskStore().seedingList; }, @@ -873,6 +870,11 @@ export default { const unlisten = await listen(event, (e: { payload: unknown }) => handler(e.payload as { gid: string }), ); + // If unmounted while listen() is pending, release the resolved listener instead of storing it + if (this.isDestroyed) { + unlisten(); + break; + } this.eventUnlisteners.push(unlisten); } }, diff --git a/src/renderer/components/Preference/Advanced.vue b/src/renderer/components/Preference/Advanced.vue index 0c29b6d8..4bdd83ef 100644 --- a/src/renderer/components/Preference/Advanced.vue +++ b/src/renderer/components/Preference/Advanced.vue @@ -8,42 +8,6 @@
- -
-
-
-
-

{{ $t('preferences.auto-update') }}

-
-
-
-
-
-
- {{ $t('preferences.auto-check-update') }} -
-
- {{ $t('preferences.last-check-update-time') }}: - {{ new Date(form.lastCheckUpdateTime).toLocaleString() }} - - {{ $t('app.check-updates-now') }} - -
-
-
- -
-
-
-
-
@@ -1408,7 +1372,6 @@ const resolveDohProvider = (storedProvider, storedUrl) => { const initForm = (config) => { const { - autoCheckUpdate, autoSyncTracker, engineOverridesText, engineOverrides, @@ -1442,7 +1405,6 @@ const initForm = (config) => { ed2KPort: ed2kPort, ed2KServer: ed2kServer, hideAppMenu, - lastCheckUpdateTime, lastSyncTrackerTime, listenPort, logDirOverride, @@ -1466,7 +1428,6 @@ const initForm = (config) => { ? engineOverridesText : JSON.stringify(engineOverrides || {}, null, 2); const result = { - autoCheckUpdate: parseBooleanConfig(autoCheckUpdate), autoSyncTracker: parseBooleanConfig(autoSyncTracker), engineOverridesText: pendingEngineOverridesText, externalEngineEnabled: parseBooleanConfig(externalEngineEnabled, false), @@ -1515,7 +1476,6 @@ const initForm = (config) => { config["youtube-format"] ?? "", hideAppMenu, - lastCheckUpdateTime, lastSyncTrackerTime, listenPort, logDirOverride: typeof logDirOverride === "string" ? logDirOverride : "", @@ -1649,6 +1609,7 @@ export default { completionScriptTesting: false, completionScriptTestResult: "", cookieEntries: [] as CookieEntryView[], + rpcSecretTimer: null as ReturnType | null, }; }, watch: { @@ -1756,18 +1717,6 @@ export default { this.form.trackerSource.splice(idx, 1); } }, - onCheckUpdateClick() { - this.$msg.info(this.$t("app.checking-for-updates")); - invoke("check_for_updates").catch(() => { - this.$msg.error(this.$t("app.update-error-message")); - }); - usePreferenceStore() - .fetchPreference() - .then((config) => { - const { lastCheckUpdateTime } = config; - this.form.lastCheckUpdateTime = lastCheckUpdateTime; - }); - }, async onTestCompletionScript() { const command = `${this.form.completionScriptCommand || ""}`.trim(); if (!command) { @@ -1843,24 +1792,6 @@ export default { enable: !!enable, }; }, - onProxyServerChange(server) { - this.form.proxy = { - ...this.form.proxy, - server, - }; - }, - handleProxyBypassChange(bypass) { - this.form.proxy = { - ...this.form.proxy, - bypass: convertLineToComma(bypass), - }; - }, - onProxyScopeChange(scope) { - this.form.proxy = { - ...this.form.proxy, - scope: [...scope], - }; - }, onProxyScopeToggle(item, checked) { const isChecked = !!checked; const scope = [...this.form.proxy.scope]; @@ -1911,8 +1842,12 @@ export default { const rpcSecret = randomize("Aa0", 16); this.form.rpcSecret = rpcSecret; - setTimeout(() => { + if (this.rpcSecretTimer) { + clearTimeout(this.rpcSecretTimer); + } + this.rpcSecretTimer = setTimeout(() => { this.hideRpcSecret = true; + this.rpcSecretTimer = null; }, 2000); }, async onSessionResetClick() { @@ -1993,7 +1928,6 @@ export default { delete data.engineOverridesText; } const booleanKeys = [ - "autoCheckUpdate", "autoSyncTracker", "externalEngineEnabled", "completionScriptEnabled", @@ -2245,6 +2179,12 @@ export default { mounted() { this.refreshCookieEntries(); }, + beforeUnmount() { + if (this.rpcSecretTimer) { + clearTimeout(this.rpcSecretTimer); + this.rpcSecretTimer = null; + } + }, async beforeRouteLeave(to, _from) { changedConfig.advanced = diffConfig(this.formOriginal, this.form); if (to.path === "/preference/basic") { diff --git a/src/renderer/components/Preference/Basic.vue b/src/renderer/components/Preference/Basic.vue index 389943e3..6e5b4e70 100644 --- a/src/renderer/components/Preference/Basic.vue +++ b/src/renderer/components/Preference/Basic.vue @@ -676,6 +676,13 @@ import { FILE_CATEGORIES, } from "@shared/constants"; import { availableLanguages } from "@shared/locales"; +import { + DEFAULT_FONT_FAMILY, + DEFAULT_FONT_SIZE, + FONT_FAMILY_OPTIONS, + FONT_SIZE_OPTIONS, + normalizeConfigOption, +} from "@shared/types/config"; import { changedConfig, convertLineToComma, @@ -709,12 +716,6 @@ import { getRisukoVersion } from "@/utils/version"; const RETRY_STRATEGY_STATIC = "static"; const RETRY_STRATEGY_EXPONENTIAL = "exponential"; -const FONT_FAMILY_OPTIONS = ["system", "rounded", "serif", "mono"]; -const FONT_SIZE_OPTIONS = ["small", "default", "large", "extra-large"]; - -const normalizeOption = (value, options, fallback) => { - return options.includes(value) ? value : fallback; -}; const normalizePositiveInt = ( value, @@ -793,8 +794,16 @@ const initForm = (config) => { rss: "", ...(fileCategoryDirs || {}), }, - fontFamily: normalizeOption(fontFamily, FONT_FAMILY_OPTIONS, "system"), - fontSize: normalizeOption(fontSize, FONT_SIZE_OPTIONS, "default"), + fontFamily: normalizeConfigOption( + fontFamily, + FONT_FAMILY_OPTIONS, + DEFAULT_FONT_FAMILY, + ), + fontSize: normalizeConfigOption( + fontSize, + FONT_SIZE_OPTIONS, + DEFAULT_FONT_SIZE, + ), taskRoutingRules: (config.taskRoutingRules || []).map((rule) => ({ ...rule, id: rule.id || crypto.randomUUID(), @@ -944,48 +953,17 @@ export default { return result; }, fontFamilyOptions() { - return [ - { - label: this.$t("preferences.font-family-system"), - value: "system", - }, - { - label: this.$t("preferences.font-family-rounded"), - value: "rounded", - }, - { - label: this.$t("preferences.font-family-serif"), - value: "serif", - }, - { - label: this.$t("preferences.font-family-mono"), - value: "mono", - }, - ]; + return FONT_FAMILY_OPTIONS.map((value) => ({ + label: this.$t(`preferences.font-family-${value}`), + value, + })); }, fontSizeOptions() { - return [ - { - label: this.$t("preferences.font-size-small"), - shortLabel: this.$t("preferences.font-size-small-short"), - value: "small", - }, - { - label: this.$t("preferences.font-size-default"), - shortLabel: this.$t("preferences.font-size-default-short"), - value: "default", - }, - { - label: this.$t("preferences.font-size-large"), - shortLabel: this.$t("preferences.font-size-large-short"), - value: "large", - }, - { - label: this.$t("preferences.font-size-extra-large"), - shortLabel: this.$t("preferences.font-size-extra-large-short"), - value: "extra-large", - }, - ]; + return FONT_SIZE_OPTIONS.map((value) => ({ + label: this.$t(`preferences.font-size-${value}`), + shortLabel: this.$t(`preferences.font-size-${value}-short`), + value, + })); }, speedUnits() { return [ @@ -1194,18 +1172,18 @@ export default { } if ("fontFamily" in data) { - data.fontFamily = normalizeOption( + data.fontFamily = normalizeConfigOption( this.form.fontFamily, FONT_FAMILY_OPTIONS, - "system", + DEFAULT_FONT_FAMILY, ); } if ("fontSize" in data) { - data.fontSize = normalizeOption( + data.fontSize = normalizeConfigOption( this.form.fontSize, FONT_SIZE_OPTIONS, - "default", + DEFAULT_FONT_SIZE, ); } @@ -1232,13 +1210,12 @@ export default { }); } } + changedConfig.basic = {}; + changedConfig.advanced = {}; }) .catch(() => { this.$msg.error(this.$t("preferences.save-fail-message")); }); - - changedConfig.basic = {}; - changedConfig.advanced = {}; }, resetForm(_formName) { this.syncFormConfig(); diff --git a/src/renderer/components/Preference/CloudSinks.vue b/src/renderer/components/Preference/CloudSinks.vue index a75bca9e..91a4655d 100644 --- a/src/renderer/components/Preference/CloudSinks.vue +++ b/src/renderer/components/Preference/CloudSinks.vue @@ -794,7 +794,6 @@ import { Activity, AlertCircle, - Check, ChevronDown, Cloud, CloudOff, @@ -980,7 +979,6 @@ export default defineComponent({ Activity, AlertCircle, Button, - Check, ChevronDown, Cloud, CloudOff, diff --git a/src/renderer/components/Preference/HistoryDirectory.vue b/src/renderer/components/Preference/HistoryDirectory.vue index 059bbd45..18f8cfa7 100644 --- a/src/renderer/components/Preference/HistoryDirectory.vue +++ b/src/renderer/components/Preference/HistoryDirectory.vue @@ -96,9 +96,7 @@ export default { }, }, data() { - return { - visible: false, - }; + return {}; }, computed: { historyDirectories() { @@ -111,12 +109,6 @@ export default { const { favoriteDirectories, historyDirectories } = this; return favoriteDirectories.length + historyDirectories.length === 0; }, - popoverDisabled() { - const { favoriteDirectories, historyDirectories } = this; - return ( - favoriteDirectories.length === 0 && historyDirectories.length === 0 - ); - }, showDivider() { const { favoriteDirectories, historyDirectories } = this; return favoriteDirectories.length > 0 && historyDirectories.length > 0; @@ -129,7 +121,6 @@ export default { methods: { handleSelectItem(directory) { this.$emit("selected", directory.trim()); - this.visible = false; }, handleFavoriteItem(directory) { logger.log("handleFavoriteItem==>", directory); diff --git a/src/renderer/components/Rss/Index.vue b/src/renderer/components/Rss/Index.vue index 0f3d507a..7eb2c199 100644 --- a/src/renderer/components/Rss/Index.vue +++ b/src/renderer/components/Rss/Index.vue @@ -274,10 +274,10 @@ export default { const store = useRssStore(); store.initEventListeners(); await store.fetchFeeds(); - for (const feed of store.feeds) { - await store.fetchItems(feed.id); - } - await store.fetchRules(); + await Promise.allSettled([ + ...store.feeds.map((feed) => store.fetchItems(feed.id)), + store.fetchRules(), + ]); }, beforeUnmount() { useRssStore().cleanupEventListeners(); diff --git a/src/renderer/components/Rss/ItemList.vue b/src/renderer/components/Rss/ItemList.vue index 1c2e7fda..f3c958d0 100644 --- a/src/renderer/components/Rss/ItemList.vue +++ b/src/renderer/components/Rss/ItemList.vue @@ -180,13 +180,13 @@ {{ $t('rss.open-externally') }}
-