diff --git a/.github/workflows/bump-homebrew-tap.yml b/.github/workflows/bump-homebrew-tap.yml index b1a2333d..60b69271 100644 --- a/.github/workflows/bump-homebrew-tap.yml +++ b/.github/workflows/bump-homebrew-tap.yml @@ -18,6 +18,9 @@ on: permissions: contents: read +env: + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true + jobs: bump: name: Bump Formula/risuko-cli.rb and Casks/risuko-app.rb diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index 37519db7..8265cde9 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -20,6 +20,9 @@ on: schedule: - cron: '23 8 * * 5' +env: + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true + jobs: analyze: name: Analyze diff --git a/.github/workflows/publish-npm.yml b/.github/workflows/publish-npm.yml index 799b5a6e..2a9cae9f 100644 --- a/.github/workflows/publish-npm.yml +++ b/.github/workflows/publish-npm.yml @@ -18,6 +18,7 @@ permissions: env: CARGO_INCREMENTAL: 0 CARGO_NET_RETRY: 10 + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true jobs: build-cli: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 66263029..fa3f170c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -9,7 +9,114 @@ on: permissions: contents: write +env: + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true + jobs: + android-release: + name: Release (android) + runs-on: ubuntu-22.04 + steps: + - name: Check out Git repository + uses: actions/checkout@v4 + with: + persist-credentials: false + + - name: Setup pnpm + uses: pnpm/action-setup@v4 + with: + run_install: false + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 24 + cache: pnpm + + - name: Setup Java + uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: 17 + + - name: Setup Android SDK + uses: android-actions/setup-android@v3 + + - name: Install Android SDK packages + shell: bash + run: | + set -euo pipefail + sdkmanager \ + "platforms;android-36" \ + "build-tools;35.0.0" \ + "ndk;27.2.12479018" + + - name: Setup Rust Android targets + uses: dtolnay/rust-toolchain@stable + with: + targets: aarch64-linux-android,armv7-linux-androideabi,i686-linux-android,x86_64-linux-android + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Build Android release APKs + run: pnpm android:build + env: + ANDROID_NDK_VERSION: 27.2.12479018 + ANDROID_API_LEVEL: 24 + + - name: Sign Android release APKs + run: pnpm android:sign + env: + ANDROID_SIGNING_KEYSTORE_BASE64: ${{ secrets.ANDROID_SIGNING_KEYSTORE_BASE64 }} + ANDROID_SIGNING_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_SIGNING_KEYSTORE_PASSWORD }} + ANDROID_SIGNING_KEY_ALIAS: ${{ secrets.ANDROID_SIGNING_KEY_ALIAS }} + ANDROID_SIGNING_KEY_PASSWORD: ${{ secrets.ANDROID_SIGNING_KEY_PASSWORD }} + + - name: Package Android APKs with standardized names + shell: bash + run: | + set -euo pipefail + if [[ "${{ github.ref }}" == refs/tags/* ]]; then + version="${{ github.ref_name }}"; version="${version#v}" + else + version=$(grep -m1 '^version' src-tauri/Cargo.toml | sed 's/.*"\(.*\)"/\1/') + fi + mkdir -p dist/android + declare -A labels=( + [arm]=arm + [arm64]=arm64 + [x86]=x86 + [x86_64]=x64 + ) + for abi in arm arm64 x86 x86_64; do + src="src-tauri/gen/android/app/build/outputs/apk/${abi}/release/app-${abi}-release.apk" + if [[ ! -f "$src" ]]; then + echo "::error::Signed Android APK not found: $src" + find src-tauri/gen/android/app/build/outputs/apk -type f -name '*.apk' -print || true + exit 1 + fi + dest="dist/android/Risuko_${version}_android_${labels[$abi]}.apk" + cp "$src" "$dest" + echo "packaged: $dest" + done + + - name: Upload Android APKs to GitHub Release + if: startsWith(github.ref, 'refs/tags/') + uses: softprops/action-gh-release@v2 + with: + tag_name: ${{ github.ref_name }} + files: dist/android/*.apk + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: Upload Android APK artifacts (manual builds) + if: ${{ !startsWith(github.ref, 'refs/tags/') }} + uses: actions/upload-artifact@v4 + with: + name: app-android + path: dist/android/*.apk + release: name: Release (${{ matrix.platform }}) runs-on: ${{ matrix.os }} @@ -45,6 +152,8 @@ jobs: steps: - name: Check out Git repository uses: actions/checkout@v4 + with: + persist-credentials: false - name: Setup pnpm uses: pnpm/action-setup@v4 @@ -77,7 +186,7 @@ jobs: xdg-utils - name: Install dependencies - run: pnpm install --no-frozen-lockfile + run: pnpm install --frozen-lockfile - name: Inject updater public key from secrets if: startsWith(github.ref, 'refs/tags/') diff --git a/.gitignore b/.gitignore index bff0ba71..ffdd02a0 100644 --- a/.gitignore +++ b/.gitignore @@ -27,6 +27,8 @@ release # tauri src-tauri/target -src-tauri/gen +src-tauri/gen/* +!src-tauri/gen/android/ +!src-tauri/gen/android/** *.node \ No newline at end of file diff --git a/README-CN.md b/README-CN.md index 6e916a7a..961f2be1 100644 --- a/README-CN.md +++ b/README-CN.md @@ -86,6 +86,20 @@ pnpm run dev pnpm run build ``` +### Android + +需要 Android SDK 36、build-tools 35.0.0、NDK 27.2.12479018,以及 Rust 的 Android 目标 + +```bash +# Debug 包,单 ABI(快一些) +pnpm android:build:debug + +# Release 拆 ABI 打包,并签名 +pnpm android:build:signed +``` + +签名相关的环境变量在 `scripts/sign-android-apks.mjs`:`ANDROID_SIGNING_KEYSTORE_PATH`(CI 用 `ANDROID_SIGNING_KEYSTORE_BASE64`)、`ANDROID_SIGNING_KEYSTORE_PASSWORD`、`ANDROID_SIGNING_KEY_ALIAS`,可选 `ANDROID_SIGNING_KEY_PASSWORD` + ## 🛠 技术栈 - [Tauri v2](https://v2.tauri.app/) diff --git a/README.md b/README.md index cf11b970..9264e17c 100644 --- a/README.md +++ b/README.md @@ -84,6 +84,28 @@ pnpm run dev pnpm run build ``` +### Android + +Requires Android SDK 36, build-tools 35.0.0, NDK 27.2.12479018, and Rust Android targets. + +```bash +# Debug build, single ABI (faster) +pnpm android:build:debug + +# Release APKs (split per ABI), then sign +pnpm android:build:signed +``` + +A few files under `src-tauri/gen/android/` are autogenerated by Tauri and gitignored on purpose: + +- `tauri.settings.gradle` — has hardcoded paths into your local Cargo registry, would break on other machines +- `app/tauri.build.gradle.kts` — plugin dependency list +- `app/tauri.properties` — version numbers pulled from `tauri.conf.json` + +`pnpm android:build` regenerates all three every time. If they go missing or look stale, just run a build. Don't commit them. + +Signing reads `ANDROID_SIGNING_KEYSTORE_PATH` (or `ANDROID_SIGNING_KEYSTORE_BASE64` in CI), `ANDROID_SIGNING_KEYSTORE_PASSWORD`, `ANDROID_SIGNING_KEY_ALIAS`, and optionally `ANDROID_SIGNING_KEY_PASSWORD`. See `scripts/sign-android-apks.mjs` + ## 🛠 Technology Stack - [Tauri v2](https://v2.tauri.app/) diff --git a/biome.json b/biome.json index ea2aa28c..e605517b 100644 --- a/biome.json +++ b/biome.json @@ -1,5 +1,5 @@ { - "$schema": "https://biomejs.dev/schemas/2.4.15/schema.json", + "$schema": "https://biomejs.dev/schemas/2.4.16/schema.json", "vcs": { "enabled": true, "clientKind": "git", "useIgnoreFile": true }, "files": { "includes": [ diff --git a/package.json b/package.json index e25c9ec2..e47b7c5e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "risuko", - "version": "0.3.3", + "version": "0.3.4", "description": "A full-featured download manager", "homepage": "https://risuko.vercel.app", "author": { @@ -19,6 +19,11 @@ "build": "node scripts/build.mjs", "dev:renderer": "vite --config vite.renderer.config.ts --mode development --port 9080", "pack:renderer": "vite build --config vite.renderer.config.ts --mode production", + "android:dev": "node scripts/android-env.mjs pnpm exec tauri android dev", + "android:build": "node scripts/android-env.mjs pnpm exec tauri android build --apk --split-per-abi", + "android:build:debug": "node scripts/android-env.mjs pnpm exec tauri android build --debug --target aarch64 --apk --split-per-abi", + "android:build:signed": "pnpm android:build && pnpm android:sign", + "android:sign": "node scripts/sign-android-apks.mjs", "fmt": "pnpx @biomejs/biome check --write --max-diagnostics none", "typecheck": "vue-tsc --noEmit -p tsconfig.json", "knip": "pnpx knip --fix", @@ -30,6 +35,7 @@ "node": ">=22.0.0" }, "dependencies": { + "@lucide/vue": "^1.16.0", "@tauri-apps/api": "^2.11.0", "@tauri-apps/plugin-clipboard-manager": "^2.3.2", "@tauri-apps/plugin-dialog": "^2.7.1", @@ -37,7 +43,6 @@ "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "eventemitter3": "^5.0.4", - "@lucide/vue": "^1.16.0", "motion-v": "^2.2.1", "reka-ui": "^2.9.8", "tailwind-merge": "^3.6.0", @@ -53,11 +58,11 @@ "@tauri-apps/cli": "^2.11.2", "@types/node": "^25.9.1", "@vitejs/plugin-vue": "^6.0.7", - "@vue/compiler-sfc": "^3.5.34", + "@vue/compiler-sfc": "^3.5.35", "axios": "^1.16.1", "cfonts": "^3.3.1", "chalk": "^5.6.2", - "i18next": "^26.2.0", + "i18next": "^26.3.0", "lodash": "^4.18.1", "normalize.css": "^8.0.1", "pinia": "^3.0.4", @@ -68,9 +73,9 @@ "typescript": "^6.0.3", "vite": "^8.0.14", "vite-plugin-static-copy": "^4.1.0", - "vue": "^3.5.34", + "vue": "^3.5.35", "vue-router": "^5.0.7", - "vue-tsc": "^3.3.1", + "vue-tsc": "^3.3.2", "vue-virtual-scroller": "3.0.4" }, "packageManager": "pnpm@11.3.0" diff --git a/packages/risuko-app/package.json b/packages/risuko-app/package.json index 04dfbe46..79099f88 100644 --- a/packages/risuko-app/package.json +++ b/packages/risuko-app/package.json @@ -1,28 +1,28 @@ { - "name": "@risuko/app", - "version": "0.3.3", - "description": "Risuko download manager — launches the desktop app, downloading it from GitHub Releases on first run", - "license": "MIT", - "repository": { - "type": "git", - "url": "git+https://github.com/YueMiyuki/Risuko.git" - }, - "keywords": [ - "download", - "torrent", - "bittorrent", - "ed2k", - "m3u8", - "ftp", - "download-manager" - ], - "bin": { - "risuko-app": "bin.js" - }, - "files": [ - "bin.js" - ], - "engines": { - "node": ">=22.0.0" - } + "name": "@risuko/app", + "version": "0.3.4", + "description": "Risuko download manager — launches the desktop app, downloading it from GitHub Releases on first run", + "license": "MIT", + "repository": { + "type": "git", + "url": "git+https://github.com/YueMiyuki/Risuko.git" + }, + "keywords": [ + "download", + "torrent", + "bittorrent", + "ed2k", + "m3u8", + "ftp", + "download-manager" + ], + "bin": { + "risuko-app": "bin.js" + }, + "files": [ + "bin.js" + ], + "engines": { + "node": ">=22.0.0" + } } diff --git a/packages/risuko-cli/npm/darwin-arm64/package.json b/packages/risuko-cli/npm/darwin-arm64/package.json index d18d5d19..6bd55f5c 100644 --- a/packages/risuko-cli/npm/darwin-arm64/package.json +++ b/packages/risuko-cli/npm/darwin-arm64/package.json @@ -1,20 +1,20 @@ { - "name": "@risuko/cli-darwin-arm64", - "version": "0.3.3", - "description": "Risuko CLI binary for macOS ARM64", - "repository": { - "type": "git", - "url": "git+https://github.com/YueMiyuki/Risuko.git", - "directory": "packages/risuko-cli/npm/darwin-arm64" - }, - "license": "MIT", - "os": [ - "darwin" - ], - "cpu": [ - "arm64" - ], - "files": [ - "risuko" - ] + "name": "@risuko/cli-darwin-arm64", + "version": "0.3.4", + "description": "Risuko CLI binary for macOS ARM64", + "repository": { + "type": "git", + "url": "git+https://github.com/YueMiyuki/Risuko.git", + "directory": "packages/risuko-cli/npm/darwin-arm64" + }, + "license": "MIT", + "os": [ + "darwin" + ], + "cpu": [ + "arm64" + ], + "files": [ + "risuko" + ] } diff --git a/packages/risuko-cli/npm/darwin-x64/package.json b/packages/risuko-cli/npm/darwin-x64/package.json index 32438324..13a27f64 100644 --- a/packages/risuko-cli/npm/darwin-x64/package.json +++ b/packages/risuko-cli/npm/darwin-x64/package.json @@ -1,20 +1,20 @@ { - "name": "@risuko/cli-darwin-x64", - "version": "0.3.3", - "description": "Risuko CLI binary for macOS x64", - "repository": { - "type": "git", - "url": "git+https://github.com/YueMiyuki/Risuko.git", - "directory": "packages/risuko-cli/npm/darwin-x64" - }, - "license": "MIT", - "os": [ - "darwin" - ], - "cpu": [ - "x64" - ], - "files": [ - "risuko" - ] + "name": "@risuko/cli-darwin-x64", + "version": "0.3.4", + "description": "Risuko CLI binary for macOS x64", + "repository": { + "type": "git", + "url": "git+https://github.com/YueMiyuki/Risuko.git", + "directory": "packages/risuko-cli/npm/darwin-x64" + }, + "license": "MIT", + "os": [ + "darwin" + ], + "cpu": [ + "x64" + ], + "files": [ + "risuko" + ] } diff --git a/packages/risuko-cli/npm/linux-arm64-gnu/package.json b/packages/risuko-cli/npm/linux-arm64-gnu/package.json index 8fa156ed..fe78f9b6 100644 --- a/packages/risuko-cli/npm/linux-arm64-gnu/package.json +++ b/packages/risuko-cli/npm/linux-arm64-gnu/package.json @@ -1,20 +1,20 @@ { - "name": "@risuko/cli-linux-arm64-gnu", - "version": "0.3.3", - "description": "Risuko CLI binary for Linux ARM64", - "repository": { - "type": "git", - "url": "git+https://github.com/YueMiyuki/Risuko.git", - "directory": "packages/risuko-cli/npm/linux-arm64-gnu" - }, - "license": "MIT", - "os": [ - "linux" - ], - "cpu": [ - "arm64" - ], - "files": [ - "risuko" - ] + "name": "@risuko/cli-linux-arm64-gnu", + "version": "0.3.4", + "description": "Risuko CLI binary for Linux ARM64", + "repository": { + "type": "git", + "url": "git+https://github.com/YueMiyuki/Risuko.git", + "directory": "packages/risuko-cli/npm/linux-arm64-gnu" + }, + "license": "MIT", + "os": [ + "linux" + ], + "cpu": [ + "arm64" + ], + "files": [ + "risuko" + ] } diff --git a/packages/risuko-cli/npm/linux-x64-gnu/package.json b/packages/risuko-cli/npm/linux-x64-gnu/package.json index 48931c4c..6e14223f 100644 --- a/packages/risuko-cli/npm/linux-x64-gnu/package.json +++ b/packages/risuko-cli/npm/linux-x64-gnu/package.json @@ -1,20 +1,20 @@ { - "name": "@risuko/cli-linux-x64-gnu", - "version": "0.3.3", - "description": "Risuko CLI binary for Linux x64", - "repository": { - "type": "git", - "url": "git+https://github.com/YueMiyuki/Risuko.git", - "directory": "packages/risuko-cli/npm/linux-x64-gnu" - }, - "license": "MIT", - "os": [ - "linux" - ], - "cpu": [ - "x64" - ], - "files": [ - "risuko" - ] + "name": "@risuko/cli-linux-x64-gnu", + "version": "0.3.4", + "description": "Risuko CLI binary for Linux x64", + "repository": { + "type": "git", + "url": "git+https://github.com/YueMiyuki/Risuko.git", + "directory": "packages/risuko-cli/npm/linux-x64-gnu" + }, + "license": "MIT", + "os": [ + "linux" + ], + "cpu": [ + "x64" + ], + "files": [ + "risuko" + ] } diff --git a/packages/risuko-cli/npm/win32-arm64-msvc/package.json b/packages/risuko-cli/npm/win32-arm64-msvc/package.json index 722661e2..52ed95b4 100644 --- a/packages/risuko-cli/npm/win32-arm64-msvc/package.json +++ b/packages/risuko-cli/npm/win32-arm64-msvc/package.json @@ -1,20 +1,20 @@ { - "name": "@risuko/cli-win32-arm64-msvc", - "version": "0.3.3", - "description": "Risuko CLI binary for Windows ARM64", - "repository": { - "type": "git", - "url": "git+https://github.com/YueMiyuki/Risuko.git", - "directory": "packages/risuko-cli/npm/win32-arm64-msvc" - }, - "license": "MIT", - "os": [ - "win32" - ], - "cpu": [ - "arm64" - ], - "files": [ - "risuko.exe" - ] + "name": "@risuko/cli-win32-arm64-msvc", + "version": "0.3.4", + "description": "Risuko CLI binary for Windows ARM64", + "repository": { + "type": "git", + "url": "git+https://github.com/YueMiyuki/Risuko.git", + "directory": "packages/risuko-cli/npm/win32-arm64-msvc" + }, + "license": "MIT", + "os": [ + "win32" + ], + "cpu": [ + "arm64" + ], + "files": [ + "risuko.exe" + ] } diff --git a/packages/risuko-cli/npm/win32-x64-msvc/package.json b/packages/risuko-cli/npm/win32-x64-msvc/package.json index 3419cb77..aca95bd5 100644 --- a/packages/risuko-cli/npm/win32-x64-msvc/package.json +++ b/packages/risuko-cli/npm/win32-x64-msvc/package.json @@ -1,20 +1,20 @@ { - "name": "@risuko/cli-win32-x64-msvc", - "version": "0.3.3", - "description": "Risuko CLI binary for Windows x64", - "repository": { - "type": "git", - "url": "git+https://github.com/YueMiyuki/Risuko.git", - "directory": "packages/risuko-cli/npm/win32-x64-msvc" - }, - "license": "MIT", - "os": [ - "win32" - ], - "cpu": [ - "x64" - ], - "files": [ - "risuko.exe" - ] + "name": "@risuko/cli-win32-x64-msvc", + "version": "0.3.4", + "description": "Risuko CLI binary for Windows x64", + "repository": { + "type": "git", + "url": "git+https://github.com/YueMiyuki/Risuko.git", + "directory": "packages/risuko-cli/npm/win32-x64-msvc" + }, + "license": "MIT", + "os": [ + "win32" + ], + "cpu": [ + "x64" + ], + "files": [ + "risuko.exe" + ] } diff --git a/packages/risuko-cli/package.json b/packages/risuko-cli/package.json index 6e2dbaec..0d41689c 100644 --- a/packages/risuko-cli/package.json +++ b/packages/risuko-cli/package.json @@ -1,37 +1,37 @@ { - "name": "@risuko/cli", - "version": "0.3.3", - "description": "Risuko download engine CLI — multi-protocol downloads (HTTP, BitTorrent, ED2K, M3U8, FTP/SFTP)", - "license": "MIT", - "repository": { - "type": "git", - "url": "git+https://github.com/YueMiyuki/Risuko.git" - }, - "keywords": [ - "download", - "torrent", - "bittorrent", - "ed2k", - "m3u8", - "ftp", - "cli", - "download-manager" - ], - "bin": { - "risuko": "bin.js" - }, - "files": [ - "bin.js" - ], - "optionalDependencies": { - "@risuko/cli-darwin-arm64": "0.3.3", - "@risuko/cli-darwin-x64": "0.3.3", - "@risuko/cli-linux-arm64-gnu": "0.3.3", - "@risuko/cli-linux-x64-gnu": "0.3.3", - "@risuko/cli-win32-arm64-msvc": "0.3.3", - "@risuko/cli-win32-x64-msvc": "0.3.3" - }, - "engines": { - "node": ">= 18" - } + "name": "@risuko/cli", + "version": "0.3.4", + "description": "Risuko download engine CLI — multi-protocol downloads (HTTP, BitTorrent, ED2K, M3U8, FTP/SFTP)", + "license": "MIT", + "repository": { + "type": "git", + "url": "git+https://github.com/YueMiyuki/Risuko.git" + }, + "keywords": [ + "download", + "torrent", + "bittorrent", + "ed2k", + "m3u8", + "ftp", + "cli", + "download-manager" + ], + "bin": { + "risuko": "bin.js" + }, + "files": [ + "bin.js" + ], + "optionalDependencies": { + "@risuko/cli-darwin-arm64": "0.3.4", + "@risuko/cli-darwin-x64": "0.3.4", + "@risuko/cli-linux-arm64-gnu": "0.3.4", + "@risuko/cli-linux-x64-gnu": "0.3.4", + "@risuko/cli-win32-arm64-msvc": "0.3.4", + "@risuko/cli-win32-x64-msvc": "0.3.4" + }, + "engines": { + "node": ">= 18" + } } diff --git a/packages/risuko-js/npm/darwin-arm64/package.json b/packages/risuko-js/npm/darwin-arm64/package.json index 68eac77b..65d388de 100644 --- a/packages/risuko-js/npm/darwin-arm64/package.json +++ b/packages/risuko-js/npm/darwin-arm64/package.json @@ -1,21 +1,21 @@ { - "name": "@risuko/js-darwin-arm64", - "version": "0.3.3", - "description": "Risuko JS native module for macOS ARM64", - "repository": { - "type": "git", - "url": "git+https://github.com/YueMiyuki/Risuko.git", - "directory": "packages/risuko-js/npm/darwin-arm64" - }, - "license": "MIT", - "os": [ - "darwin" - ], - "cpu": [ - "arm64" - ], - "main": "risuko.darwin-arm64.node", - "files": [ - "risuko.darwin-arm64.node" - ] + "name": "@risuko/js-darwin-arm64", + "version": "0.3.4", + "description": "Risuko JS native module for macOS ARM64", + "repository": { + "type": "git", + "url": "git+https://github.com/YueMiyuki/Risuko.git", + "directory": "packages/risuko-js/npm/darwin-arm64" + }, + "license": "MIT", + "os": [ + "darwin" + ], + "cpu": [ + "arm64" + ], + "main": "risuko.darwin-arm64.node", + "files": [ + "risuko.darwin-arm64.node" + ] } diff --git a/packages/risuko-js/npm/darwin-x64/package.json b/packages/risuko-js/npm/darwin-x64/package.json index 330fdcad..28ad59d2 100644 --- a/packages/risuko-js/npm/darwin-x64/package.json +++ b/packages/risuko-js/npm/darwin-x64/package.json @@ -1,21 +1,21 @@ { - "name": "@risuko/js-darwin-x64", - "version": "0.3.3", - "description": "Risuko JS native module for macOS x64", - "repository": { - "type": "git", - "url": "git+https://github.com/YueMiyuki/Risuko.git", - "directory": "packages/risuko-js/npm/darwin-x64" - }, - "license": "MIT", - "os": [ - "darwin" - ], - "cpu": [ - "x64" - ], - "main": "risuko.darwin-x64.node", - "files": [ - "risuko.darwin-x64.node" - ] + "name": "@risuko/js-darwin-x64", + "version": "0.3.4", + "description": "Risuko JS native module for macOS x64", + "repository": { + "type": "git", + "url": "git+https://github.com/YueMiyuki/Risuko.git", + "directory": "packages/risuko-js/npm/darwin-x64" + }, + "license": "MIT", + "os": [ + "darwin" + ], + "cpu": [ + "x64" + ], + "main": "risuko.darwin-x64.node", + "files": [ + "risuko.darwin-x64.node" + ] } diff --git a/packages/risuko-js/npm/linux-arm64-gnu/package.json b/packages/risuko-js/npm/linux-arm64-gnu/package.json index 1d5f0935..dd59c311 100644 --- a/packages/risuko-js/npm/linux-arm64-gnu/package.json +++ b/packages/risuko-js/npm/linux-arm64-gnu/package.json @@ -1,24 +1,24 @@ { - "name": "@risuko/js-linux-arm64-gnu", - "version": "0.3.3", - "description": "Risuko JS native module for Linux ARM64", - "repository": { - "type": "git", - "url": "git+https://github.com/YueMiyuki/Risuko.git", - "directory": "packages/risuko-js/npm/linux-arm64-gnu" - }, - "license": "MIT", - "os": [ - "linux" - ], - "cpu": [ - "arm64" - ], - "main": "risuko.linux-arm64-gnu.node", - "files": [ - "risuko.linux-arm64-gnu.node" - ], - "libc": [ - "glibc" - ] + "name": "@risuko/js-linux-arm64-gnu", + "version": "0.3.4", + "description": "Risuko JS native module for Linux ARM64", + "repository": { + "type": "git", + "url": "git+https://github.com/YueMiyuki/Risuko.git", + "directory": "packages/risuko-js/npm/linux-arm64-gnu" + }, + "license": "MIT", + "os": [ + "linux" + ], + "cpu": [ + "arm64" + ], + "main": "risuko.linux-arm64-gnu.node", + "files": [ + "risuko.linux-arm64-gnu.node" + ], + "libc": [ + "glibc" + ] } diff --git a/packages/risuko-js/npm/linux-x64-gnu/package.json b/packages/risuko-js/npm/linux-x64-gnu/package.json index 8a516fa4..1fd13c71 100644 --- a/packages/risuko-js/npm/linux-x64-gnu/package.json +++ b/packages/risuko-js/npm/linux-x64-gnu/package.json @@ -1,24 +1,24 @@ { - "name": "@risuko/js-linux-x64-gnu", - "version": "0.3.3", - "description": "Risuko JS native module for Linux x64", - "repository": { - "type": "git", - "url": "git+https://github.com/YueMiyuki/Risuko.git", - "directory": "packages/risuko-js/npm/linux-x64-gnu" - }, - "license": "MIT", - "os": [ - "linux" - ], - "cpu": [ - "x64" - ], - "main": "risuko.linux-x64-gnu.node", - "files": [ - "risuko.linux-x64-gnu.node" - ], - "libc": [ - "glibc" - ] + "name": "@risuko/js-linux-x64-gnu", + "version": "0.3.4", + "description": "Risuko JS native module for Linux x64", + "repository": { + "type": "git", + "url": "git+https://github.com/YueMiyuki/Risuko.git", + "directory": "packages/risuko-js/npm/linux-x64-gnu" + }, + "license": "MIT", + "os": [ + "linux" + ], + "cpu": [ + "x64" + ], + "main": "risuko.linux-x64-gnu.node", + "files": [ + "risuko.linux-x64-gnu.node" + ], + "libc": [ + "glibc" + ] } diff --git a/packages/risuko-js/npm/win32-arm64-msvc/package.json b/packages/risuko-js/npm/win32-arm64-msvc/package.json index 17c36271..588954a8 100644 --- a/packages/risuko-js/npm/win32-arm64-msvc/package.json +++ b/packages/risuko-js/npm/win32-arm64-msvc/package.json @@ -1,21 +1,21 @@ { - "name": "@risuko/js-win32-arm64-msvc", - "version": "0.3.3", - "description": "Risuko JS native module for Windows ARM64", - "repository": { - "type": "git", - "url": "git+https://github.com/YueMiyuki/Risuko.git", - "directory": "packages/risuko-js/npm/win32-arm64-msvc" - }, - "license": "MIT", - "os": [ - "win32" - ], - "cpu": [ - "arm64" - ], - "main": "risuko.win32-arm64-msvc.node", - "files": [ - "risuko.win32-arm64-msvc.node" - ] + "name": "@risuko/js-win32-arm64-msvc", + "version": "0.3.4", + "description": "Risuko JS native module for Windows ARM64", + "repository": { + "type": "git", + "url": "git+https://github.com/YueMiyuki/Risuko.git", + "directory": "packages/risuko-js/npm/win32-arm64-msvc" + }, + "license": "MIT", + "os": [ + "win32" + ], + "cpu": [ + "arm64" + ], + "main": "risuko.win32-arm64-msvc.node", + "files": [ + "risuko.win32-arm64-msvc.node" + ] } diff --git a/packages/risuko-js/npm/win32-x64-msvc/package.json b/packages/risuko-js/npm/win32-x64-msvc/package.json index c0eac006..b1367884 100644 --- a/packages/risuko-js/npm/win32-x64-msvc/package.json +++ b/packages/risuko-js/npm/win32-x64-msvc/package.json @@ -1,21 +1,21 @@ { - "name": "@risuko/js-win32-x64-msvc", - "version": "0.3.3", - "description": "Risuko JS native module for Windows x64", - "repository": { - "type": "git", - "url": "git+https://github.com/YueMiyuki/Risuko.git", - "directory": "packages/risuko-js/npm/win32-x64-msvc" - }, - "license": "MIT", - "os": [ - "win32" - ], - "cpu": [ - "x64" - ], - "main": "risuko.win32-x64-msvc.node", - "files": [ - "risuko.win32-x64-msvc.node" - ] + "name": "@risuko/js-win32-x64-msvc", + "version": "0.3.4", + "description": "Risuko JS native module for Windows x64", + "repository": { + "type": "git", + "url": "git+https://github.com/YueMiyuki/Risuko.git", + "directory": "packages/risuko-js/npm/win32-x64-msvc" + }, + "license": "MIT", + "os": [ + "win32" + ], + "cpu": [ + "x64" + ], + "main": "risuko.win32-x64-msvc.node", + "files": [ + "risuko.win32-x64-msvc.node" + ] } diff --git a/packages/risuko-js/package.json b/packages/risuko-js/package.json index 0e2f72a5..a4c36efe 100644 --- a/packages/risuko-js/package.json +++ b/packages/risuko-js/package.json @@ -1,47 +1,47 @@ { - "name": "@risuko/risuko-js", - "version": "0.3.3", - "description": "Risuko download engine — Node.js native bindings for multi-protocol downloads (HTTP, BitTorrent, ED2K, M3U8, FTP/SFTP)", - "main": "index.js", - "types": "index.d.ts", - "license": "MIT", - "repository": { - "type": "git", - "url": "git+https://github.com/YueMiyuki/Risuko.git" - }, - "keywords": [ - "download", - "torrent", - "bittorrent", - "ed2k", - "m3u8", - "ftp", - "download-manager" - ], - "files": [ - "index.js", - "index.d.ts" - ], - "napi": { - "name": "risuko", - "triples": { - "defaults": true, - "additional": [ - "aarch64-apple-darwin", - "aarch64-unknown-linux-gnu", - "aarch64-pc-windows-msvc" - ] - } - }, - "optionalDependencies": { - "@risuko/js-darwin-arm64": "0.3.3", - "@risuko/js-darwin-x64": "0.3.3", - "@risuko/js-linux-x64-gnu": "0.3.3", - "@risuko/js-linux-arm64-gnu": "0.3.3", - "@risuko/js-win32-x64-msvc": "0.3.3", - "@risuko/js-win32-arm64-msvc": "0.3.3" - }, - "engines": { - "node": ">= 18" - } + "name": "@risuko/risuko-js", + "version": "0.3.4", + "description": "Risuko download engine — Node.js native bindings for multi-protocol downloads (HTTP, BitTorrent, ED2K, M3U8, FTP/SFTP)", + "main": "index.js", + "types": "index.d.ts", + "license": "MIT", + "repository": { + "type": "git", + "url": "git+https://github.com/YueMiyuki/Risuko.git" + }, + "keywords": [ + "download", + "torrent", + "bittorrent", + "ed2k", + "m3u8", + "ftp", + "download-manager" + ], + "files": [ + "index.js", + "index.d.ts" + ], + "napi": { + "name": "risuko", + "triples": { + "defaults": true, + "additional": [ + "aarch64-apple-darwin", + "aarch64-unknown-linux-gnu", + "aarch64-pc-windows-msvc" + ] + } + }, + "optionalDependencies": { + "@risuko/js-darwin-arm64": "0.3.4", + "@risuko/js-darwin-x64": "0.3.4", + "@risuko/js-linux-x64-gnu": "0.3.4", + "@risuko/js-linux-arm64-gnu": "0.3.4", + "@risuko/js-win32-x64-msvc": "0.3.4", + "@risuko/js-win32-arm64-msvc": "0.3.4" + }, + "engines": { + "node": ">= 22" + } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 67924e9d..aad2f35e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -10,7 +10,7 @@ importers: dependencies: '@lucide/vue': specifier: ^1.16.0 - version: 1.16.0(vue@3.5.34(typescript@6.0.3)) + version: 1.16.0(vue@3.5.35(typescript@6.0.3)) '@tauri-apps/api': specifier: ^2.11.0 version: 2.11.0 @@ -22,7 +22,7 @@ importers: version: 2.7.1 '@vueuse/core': specifier: ^14.3.0 - version: 14.3.0(vue@3.5.34(typescript@6.0.3)) + version: 14.3.0(vue@3.5.35(typescript@6.0.3)) class-variance-authority: specifier: ^0.7.1 version: 0.7.1 @@ -34,10 +34,10 @@ importers: version: 5.0.4 motion-v: specifier: ^2.2.1 - version: 2.2.1(@vueuse/core@14.3.0(vue@3.5.34(typescript@6.0.3)))(vue@3.5.34(typescript@6.0.3)) + version: 2.2.1(@vueuse/core@14.3.0(vue@3.5.35(typescript@6.0.3)))(vue@3.5.35(typescript@6.0.3)) reka-ui: specifier: ^2.9.8 - version: 2.9.8(vue@3.5.34(typescript@6.0.3)) + version: 2.9.8(vue@3.5.35(typescript@6.0.3)) tailwind-merge: specifier: ^3.6.0 version: 3.6.0 @@ -49,10 +49,10 @@ importers: version: 1.4.0 vaul-vue: specifier: ^0.4.1 - version: 0.4.1(reka-ui@2.9.8(vue@3.5.34(typescript@6.0.3)))(vue@3.5.34(typescript@6.0.3)) + version: 0.4.1(reka-ui@2.9.8(vue@3.5.35(typescript@6.0.3)))(vue@3.5.35(typescript@6.0.3)) vee-validate: specifier: ^4.15.1 - version: 4.15.1(vue@3.5.34(typescript@6.0.3)) + version: 4.15.1(vue@3.5.35(typescript@6.0.3)) vue-sonner: specifier: ^2.0.9 version: 2.0.9 @@ -71,10 +71,10 @@ importers: version: 25.9.1 '@vitejs/plugin-vue': specifier: ^6.0.7 - version: 6.0.7(vite@8.0.14(@types/node@25.9.1)(jiti@2.7.0)(sass@1.100.0)(terser@5.48.0)(yaml@2.9.0))(vue@3.5.34(typescript@6.0.3)) + version: 6.0.7(vite@8.0.14(@types/node@25.9.1)(jiti@2.7.0)(sass@1.100.0)(terser@5.48.0)(yaml@2.9.0))(vue@3.5.35(typescript@6.0.3)) '@vue/compiler-sfc': - specifier: ^3.5.34 - version: 3.5.34 + specifier: ^3.5.35 + version: 3.5.35 axios: specifier: ^1.16.1 version: 1.16.1 @@ -85,8 +85,8 @@ importers: specifier: ^5.6.2 version: 5.6.2 i18next: - specifier: ^26.2.0 - version: 26.2.0(typescript@6.0.3) + specifier: ^26.3.0 + version: 26.3.0(typescript@6.0.3) lodash: specifier: ^4.18.1 version: 4.18.1 @@ -95,7 +95,7 @@ importers: version: 8.0.1 pinia: specifier: ^3.0.4 - version: 3.0.4(typescript@6.0.3)(vue@3.5.34(typescript@6.0.3)) + version: 3.0.4(typescript@6.0.3)(vue@3.5.35(typescript@6.0.3)) randomatic: specifier: ^3.1.1 version: 3.1.1 @@ -118,17 +118,17 @@ importers: specifier: ^4.1.0 version: 4.1.0(vite@8.0.14(@types/node@25.9.1)(jiti@2.7.0)(sass@1.100.0)(terser@5.48.0)(yaml@2.9.0)) vue: - specifier: ^3.5.34 - version: 3.5.34(typescript@6.0.3) + specifier: ^3.5.35 + version: 3.5.35(typescript@6.0.3) vue-router: specifier: ^5.0.7 - version: 5.0.7(@vue/compiler-sfc@3.5.34)(pinia@3.0.4(typescript@6.0.3)(vue@3.5.34(typescript@6.0.3)))(vue@3.5.34(typescript@6.0.3)) + version: 5.0.7(@vue/compiler-sfc@3.5.35)(pinia@3.0.4(typescript@6.0.3)(vue@3.5.35(typescript@6.0.3)))(vue@3.5.35(typescript@6.0.3)) vue-tsc: - specifier: ^3.3.1 - version: 3.3.1(typescript@6.0.3) + specifier: ^3.3.2 + version: 3.3.2(typescript@6.0.3) vue-virtual-scroller: specifier: 3.0.4 - version: 3.0.4(vue@3.5.34(typescript@6.0.3)) + version: 3.0.4(vue@3.5.35(typescript@6.0.3)) packages/risuko-app: {} @@ -138,42 +138,42 @@ importers: packages: - '@babel/generator@8.0.0-rc.5': - resolution: {integrity: sha512-nFZPWz3FHIS7y6rMIVoa/WBwjdutfIaRJIBQjzn+t3RnecZoRNlGmGcyR2wb0T/IgSd50Kz/6dG8/LvMCRunjg==} + '@babel/generator@8.0.0-rc.6': + resolution: {integrity: sha512-6mIzgVK8DgEzvIapoQwhXTMnnkuE4STQmVv9H03i/tZ2ml8oev3TRvZJgTenK2Bsq0YWNtzOrFdTyNzCMFtjJQ==} engines: {node: ^22.18.0 || >=24.11.0} - '@babel/helper-string-parser@7.27.1': - resolution: {integrity: sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==} + '@babel/helper-string-parser@7.29.7': + resolution: {integrity: sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==} engines: {node: '>=6.9.0'} - '@babel/helper-string-parser@8.0.0-rc.5': - resolution: {integrity: sha512-sN7R8rBvDurfaziNfDEIjIntlazmlkCDGO4SNl2RJ3wRCn+QxspLV7hzYAE8WWVd2joVuT8sUxeePdLp2idI1A==} + '@babel/helper-string-parser@8.0.0-rc.6': + resolution: {integrity: sha512-BCkFy+zN6kXQed3YOT7aJl93NfDSzQc3pBfsvTVPs9gU9X3V0aefEF5kwBT0E+mDWH9QgKaZstYUQN9VdQZT4g==} engines: {node: ^22.18.0 || >=24.11.0} - '@babel/helper-validator-identifier@7.28.5': - resolution: {integrity: sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==} + '@babel/helper-validator-identifier@7.29.7': + resolution: {integrity: sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==} engines: {node: '>=6.9.0'} - '@babel/helper-validator-identifier@8.0.0-rc.5': - resolution: {integrity: sha512-ehJDxHvtbZ85RtX/L2fi0h9AGsBNqB5Euv1EB8RMAvGYvD+2X+QbpzzOpbklnNXO+WSZJNOaetw2BBj27xsWVg==} + '@babel/helper-validator-identifier@8.0.0-rc.6': + resolution: {integrity: sha512-nVJ+1JcCgntv8d78rRo++o2wuODT0Irknx2BF8Np4Ft2CRgjLqIs4qzSZ8b66yGbBdMWGmZBO9WEZv1hhNiSpg==} engines: {node: ^22.18.0 || >=24.11.0} - '@babel/parser@7.29.3': - resolution: {integrity: sha512-b3ctpQwp+PROvU/cttc4OYl4MzfJUWy6FZg+PMXfzmt/+39iHVF0sDfqay8TQM3JA2EUOyKcFZt75jWriQijsA==} + '@babel/parser@7.29.7': + resolution: {integrity: sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==} engines: {node: '>=6.0.0'} hasBin: true - '@babel/parser@8.0.0-rc.5': - resolution: {integrity: sha512-/Mfg83rK3+jsRbl4Vbd0jqxc6M1A1/WNFtgrowRM1unEsD3XcNnrBdMM0JWakd0/RN9lseQKwPduW1TiEwKOlQ==} + '@babel/parser@8.0.0-rc.6': + resolution: {integrity: sha512-rOS8IpdO7mQELkTPlCsTgPejO0bFuZdEDCGQJouYbYf9e1FLTym7Fei2pEjq8q7MWbX0ravcd7QQYKs1TxOuog==} engines: {node: ^22.18.0 || >=24.11.0} hasBin: true - '@babel/types@7.29.0': - resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==} + '@babel/types@7.29.7': + resolution: {integrity: sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==} engines: {node: '>=6.9.0'} - '@babel/types@8.0.0-rc.5': - resolution: {integrity: sha512-JeSVu/m8x/zpp4CLjYHVNXuhEyOkhPXuxM8YOXjh6L4LlvQNKuUNOTo5KdBuKAcTDHw8DquToTaEkhsBqPXOaA==} + '@babel/types@8.0.0-rc.6': + resolution: {integrity: sha512-p7/ABylAYlexb31wtRdIfH9L9A0Z2T/9H6zAqzqndkY2PLkvNNc580wGhp/gGKN4Sp9sQvSkhc6Oga8/O+wTyw==} engines: {node: ^22.18.0 || >=24.11.0} '@bany/curl-to-json@1.2.10': @@ -425,8 +425,8 @@ packages: '@rolldown/pluginutils@1.0.1': resolution: {integrity: sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==} - '@swc/helpers@0.5.21': - resolution: {integrity: sha512-jI/VAmtdjB/RnI8GTnokyX7Ug8c+g+ffD6QRLa6XQewtnGyukKkKSk3wLTM3b5cjt1jNh9x0jfVlagdN2gDKQg==} + '@swc/helpers@0.5.23': + resolution: {integrity: sha512-5lSsMOTXURePglDfvuAQUqkGek9Hg2kksOYay2m0+XR++b2NWYL/4sWyuvVBIs8oKnJaxkdi9whaL/sqN13afw==} '@tailwindcss/node@4.3.0': resolution: {integrity: sha512-aFb4gUhFOgdh9AXo4IzBEOzBkkAxm9VigwDJnMIYv3lcfXCJVesNfbEaBl4BNgVRyid92AmdviqwBUBRKSeY3g==} @@ -522,11 +522,11 @@ packages: peerDependencies: vite: ^5.2.0 || ^6 || ^7 || ^8 - '@tanstack/virtual-core@3.15.0': - resolution: {integrity: sha512-0AwPGx0I8QxPYjAxShT/+z+ZOe9u8mW5rsXvivCTjRfRmz9a43+3mRyi4wwlyoUqOC56q/jatKa0Bh9M99BEHQ==} + '@tanstack/virtual-core@3.16.0': + resolution: {integrity: sha512-Er2N7q3WOiH6y2JLxsxNX+u2/sLqSsL0bxFgDjuiPiA7vKhZRm+IzcS17vRee3GNXr64UsesA5CAp9yTiIYw9A==} - '@tanstack/vue-virtual@3.13.25': - resolution: {integrity: sha512-/ez+t68a5O4CgVysvk7Bav0XbSYSYufOVHZveXF+DYO9hvtg2UheYzR0YkniCeUtXmMjDne1dDqwBMkOmEUOow==} + '@tanstack/vue-virtual@3.13.26': + resolution: {integrity: sha512-4TmREKi8rKiQC8E2XVEMMgzWbrgHNYolkBgYTXVK1kqXmXRGz6xPWgBq20GUYWUDDhit94+g0ricUQKpZhWRmg==} peerDependencies: vue: ^2.7.0 || ^3.0.0 @@ -659,17 +659,17 @@ packages: vue: optional: true - '@vue/compiler-core@3.5.34': - resolution: {integrity: sha512-s9cLyK5mLcvZ4Agva5QgRsQyLKvts9WbU9DB6NqiZkkGEdwmcEiylj5Jbwkp680drF/NNCV8OlAJSe+yMLxaJw==} + '@vue/compiler-core@3.5.35': + resolution: {integrity: sha512-BUmHaR1J+O+CKZ9uJucdVTEr1LHsdyvv7vG3eNRhK3CczEHeMd/LtsHAuD7PbrxvI2envCY2v7HI1vC1aBRzKw==} - '@vue/compiler-dom@3.5.34': - resolution: {integrity: sha512-EbF/T++k0e2MMZlJsBhzK8Sgwt0HcIPOhzn1CTB/lv6sQcyk+OWf8YeiLxZp3ro7MbbLcAfAJ6sEvjFWuNgUCw==} + '@vue/compiler-dom@3.5.35': + resolution: {integrity: sha512-k+bprkXxuqhVajgTx5mUHuir7TwQzUKOWR40ng1ncAqQRPnrLngGGgqVEEhOnTMlc8btHYVKmrP8s5Qyg0hvYA==} - '@vue/compiler-sfc@3.5.34': - resolution: {integrity: sha512-D/ihr6uZeIt6r+pVZf46RWT1fAsLFMbUP7k8G1VkiiWexriED9GrX3echHd4Abbt17zjlfiFJ8z7a3BxZOPNjg==} + '@vue/compiler-sfc@3.5.35': + resolution: {integrity: sha512-G5VPMcXTSywXBgtFOZOnHKBxKSrwXUcvY1iaF5/hRcy7t0J6CH/d8ha9F4nzi00Fax1eLV0QHM7v4mQu68jydw==} - '@vue/compiler-ssr@3.5.34': - resolution: {integrity: sha512-cDtTHKibkThKGHH1SP+WdccquNRYQDFH6rRjQCqT9G2ltFAfoR5pUftpab/z+aM5mW9HLLVQW7hfKKQe/1GBeQ==} + '@vue/compiler-ssr@3.5.35': + resolution: {integrity: sha512-rGhAeXgdM7/ffTJGXT69rCCdTmjDewnFuUZfBQQHTdcEBeWdT5HCGY60y2ytLJr9/Dsu7IntUi5z/w0h6Rjnzw==} '@vue/devtools-api@7.7.9': resolution: {integrity: sha512-kIE8wvwlcZ6TJTbNeU2HQNtaxLx3a84aotTITUuL/4bzfPxzajGBOoqjMhwZJ8L9qFYDU/lAYMEEm11dnZOD6g==} @@ -689,25 +689,25 @@ packages: '@vue/devtools-shared@8.1.2': resolution: {integrity: sha512-X9RyVFYAdkBe4IUf5v48TxBF/6QPmF8CmWrDAjXzfUHrgQ/HGfTC1A6TqgXqZ03ye66l3AD51BAGD69IvKM9sw==} - '@vue/language-core@3.3.1': - resolution: {integrity: sha512-NP8g6V7x81NVOXbLupUvYY6i6LqUkjkVowe2epRedmpgaFCOdjgWHE/rQBvEJ4r7koAYODIjGeBWEdt6n7jYXQ==} + '@vue/language-core@3.3.2': + resolution: {integrity: sha512-CLwjSfHlPLhjd2qhuS3tTFtnOIWHXAM5u4X1DxmzlQ8j5bmOYlKCsSusOP7jCRJnlVg0mCTQtHU3vwFvopZGoQ==} - '@vue/reactivity@3.5.34': - resolution: {integrity: sha512-y9XDjCEuBp+98k+UL5dbYkh57AHU4o6cxZedOPXw3bmrZZYLQsVHguGurq7hVrPCSrQtrnz1f9dssyFr+dMXfQ==} + '@vue/reactivity@3.5.35': + resolution: {integrity: sha512-tVc+SsHConvh/Lz64qq1pP3rYArBmK42xonovEcxY74SQtvctZodG/zhq54P5dr38cVuw25d27cPNRdlMidpGQ==} - '@vue/runtime-core@3.5.34': - resolution: {integrity: sha512-mKeBYvu8tcMSLhypAHBmriUFfWXKTCF/23Z4jiCoYK3UtWepkliViNLuR90V9XOyD62mUxs9p1jsrpK3CCGIzw==} + '@vue/runtime-core@3.5.35': + resolution: {integrity: sha512-A/xFNX9loIcWDygeQuNCfKuh0CoYBzxhqEMNah5TSFg9Z53DrFYEN2qi5CU9necjM1OWYegYREUTHmXTmhfXtg==} - '@vue/runtime-dom@3.5.34': - resolution: {integrity: sha512-e8kZzERmCwUnBRVsgSQlAfrfU2rGoy0FFKPBXSlfEjc/O3KfA7QP0t1/2ZylrbchjmIKB4dPTd07A6WPr0eOrg==} + '@vue/runtime-dom@3.5.35': + resolution: {integrity: sha512-odrJ1C391dbGnyDRh8U+rnP7J2amIEzfmRk5vXy7xi3aZhEXofTvpi0T4HJb6jlNqQZTNPR5MPHSB3RHNkIORA==} - '@vue/server-renderer@3.5.34': - resolution: {integrity: sha512-nHxmJoTrKsmrkbILRhkC9gY1G3moZbJTqCzDd7DOOzG5KH9oeJ0Unqrff5f9v0pW//jES05ZkJcNtfE8JjOIew==} + '@vue/server-renderer@3.5.35': + resolution: {integrity: sha512-NkebSOYdB97wi8OQcO3HqzZSlymJi/aWsN/7h74OSVhRTm6qGs3Jp3e0rCXynmWwSlKeRrnlIug+ilYoHBmQDA==} peerDependencies: - vue: 3.5.34 + vue: 3.5.35 - '@vue/shared@3.5.34': - resolution: {integrity: sha512-24uqU4OIiX29ryC3MeWid/Xf2fa2EFRUVLb77nRhk+UrTVrh/XiGtFAFmJBAtBRbjwNdsPRP+jj/OL27Eg1NDA==} + '@vue/shared@3.5.35': + resolution: {integrity: sha512-zSbjL7gRXwks2ZQLRGCajBtBXEOXW9Ddhn/HvSdrGkE2dqGnumzW8XtusRrxrE9LvqtiqDXQ+A60Hp6mvdYxfA==} '@vueuse/core@10.11.1': resolution: {integrity: sha512-guoy26JQktXPcz+0n3GukWIy/JDNKti9v6VEMu6kV2sYBsWuGiTU8OWdg+ADfUbHg3/3DlqySDe7JmdHrktiww==} @@ -981,8 +981,8 @@ packages: resolution: {integrity: sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==} engines: {node: '>= 6'} - i18next@26.2.0: - resolution: {integrity: sha512-zwBHldHdTmwN7r6UNc7lC6GWNN+YYg3DrRSeHR5PRRBf5QnJZcYHrQc0uaU26qZeYxR7iFZD+Y315dPnKP47wA==} + i18next@26.3.0: + resolution: {integrity: sha512-gHSgGpUXVmuqE2El1W61DmxeyeTlFfZgdJRWMo9jScAn5pu7TuTuiccb1zh3E2J9hEBVGJ23+96x0ieBhfuIHA==} peerDependencies: typescript: ^5 || ^6 peerDependenciesMeta: @@ -1476,8 +1476,8 @@ packages: nuxt: optional: true - vue-tsc@3.3.1: - resolution: {integrity: sha512-webBP3jhlxzhELZ2g+11KJ6pg5OVY1xWhWrj7N/yQMi1CrtxJnW+tUACyRVeDK0cQNLP2Va5HNYK8pe+7c+msw==} + vue-tsc@3.3.2: + resolution: {integrity: sha512-n7nQoA3YWW/eiDR8jMiv/uJvlg0uLGs+YgUrsTrf9EZaYSt3tuvMZb5V8+7Mvh/EH5pnY/hoVdgfjH+XcK+wwA==} hasBin: true peerDependencies: typescript: '>=5.0.0' @@ -1487,8 +1487,8 @@ packages: peerDependencies: vue: ^3.3.0 - vue@3.5.34: - resolution: {integrity: sha512-WdLBG9gm02OgJIG9axd5Hpx0TFLdzVgfG2evFFu8Rur5O/IoGc5cMjnjh3tPL6GnRGsYvUhBSKVPYVcxRKpMCA==} + vue@3.5.35: + resolution: {integrity: sha512-cx89fnr+0kVGHiNFG6y6s0bdjypJRFNZn6x3WPstNdQR1bi1mbB7h4v5IBGTsPJU3nK1+0Iqj3Zf+hZWMieR4Q==} peerDependencies: typescript: '*' peerDependenciesMeta: @@ -1510,40 +1510,40 @@ packages: snapshots: - '@babel/generator@8.0.0-rc.5': + '@babel/generator@8.0.0-rc.6': dependencies: - '@babel/parser': 8.0.0-rc.5 - '@babel/types': 8.0.0-rc.5 + '@babel/parser': 8.0.0-rc.6 + '@babel/types': 8.0.0-rc.6 '@jridgewell/gen-mapping': 0.3.13 '@jridgewell/trace-mapping': 0.3.31 '@types/jsesc': 2.5.1 jsesc: 3.1.0 - '@babel/helper-string-parser@7.27.1': {} + '@babel/helper-string-parser@7.29.7': {} - '@babel/helper-string-parser@8.0.0-rc.5': {} + '@babel/helper-string-parser@8.0.0-rc.6': {} - '@babel/helper-validator-identifier@7.28.5': {} + '@babel/helper-validator-identifier@7.29.7': {} - '@babel/helper-validator-identifier@8.0.0-rc.5': {} + '@babel/helper-validator-identifier@8.0.0-rc.6': {} - '@babel/parser@7.29.3': + '@babel/parser@7.29.7': dependencies: - '@babel/types': 7.29.0 + '@babel/types': 7.29.7 - '@babel/parser@8.0.0-rc.5': + '@babel/parser@8.0.0-rc.6': dependencies: - '@babel/types': 8.0.0-rc.5 + '@babel/types': 8.0.0-rc.6 - '@babel/types@7.29.0': + '@babel/types@7.29.7': dependencies: - '@babel/helper-string-parser': 7.27.1 - '@babel/helper-validator-identifier': 7.28.5 + '@babel/helper-string-parser': 7.29.7 + '@babel/helper-validator-identifier': 7.29.7 - '@babel/types@8.0.0-rc.5': + '@babel/types@8.0.0-rc.6': dependencies: - '@babel/helper-string-parser': 8.0.0-rc.5 - '@babel/helper-validator-identifier': 8.0.0-rc.5 + '@babel/helper-string-parser': 8.0.0-rc.6 + '@babel/helper-validator-identifier': 8.0.0-rc.6 '@bany/curl-to-json@1.2.10': dependencies: @@ -1576,22 +1576,22 @@ snapshots: '@floating-ui/utils@0.2.11': {} - '@floating-ui/vue@1.1.11(vue@3.5.34(typescript@6.0.3))': + '@floating-ui/vue@1.1.11(vue@3.5.35(typescript@6.0.3))': dependencies: '@floating-ui/dom': 1.7.6 '@floating-ui/utils': 0.2.11 - vue-demi: 0.14.10(vue@3.5.34(typescript@6.0.3)) + vue-demi: 0.14.10(vue@3.5.35(typescript@6.0.3)) transitivePeerDependencies: - '@vue/composition-api' - vue '@internationalized/date@3.12.1': dependencies: - '@swc/helpers': 0.5.21 + '@swc/helpers': 0.5.23 '@internationalized/number@3.6.6': dependencies: - '@swc/helpers': 0.5.21 + '@swc/helpers': 0.5.23 '@jridgewell/gen-mapping@0.3.13': dependencies: @@ -1617,9 +1617,9 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.5 - '@lucide/vue@1.16.0(vue@3.5.34(typescript@6.0.3))': + '@lucide/vue@1.16.0(vue@3.5.35(typescript@6.0.3))': dependencies: - vue: 3.5.34(typescript@6.0.3) + vue: 3.5.35(typescript@6.0.3) '@napi-rs/wasm-runtime@1.1.4(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)': dependencies: @@ -1742,7 +1742,7 @@ snapshots: '@rolldown/pluginutils@1.0.1': {} - '@swc/helpers@0.5.21': + '@swc/helpers@0.5.23': dependencies: tslib: 2.8.1 @@ -1814,12 +1814,12 @@ snapshots: tailwindcss: 4.3.0 vite: 8.0.14(@types/node@25.9.1)(jiti@2.7.0)(sass@1.100.0)(terser@5.48.0)(yaml@2.9.0) - '@tanstack/virtual-core@3.15.0': {} + '@tanstack/virtual-core@3.16.0': {} - '@tanstack/vue-virtual@3.13.25(vue@3.5.34(typescript@6.0.3))': + '@tanstack/vue-virtual@3.13.26(vue@3.5.35(typescript@6.0.3))': dependencies: - '@tanstack/virtual-core': 3.15.0 - vue: 3.5.34(typescript@6.0.3) + '@tanstack/virtual-core': 3.16.0 + vue: 3.5.35(typescript@6.0.3) '@tauri-apps/api@1.0.0-rc.4': dependencies: @@ -1897,11 +1897,11 @@ snapshots: '@types/web-bluetooth@0.0.21': {} - '@vitejs/plugin-vue@6.0.7(vite@8.0.14(@types/node@25.9.1)(jiti@2.7.0)(sass@1.100.0)(terser@5.48.0)(yaml@2.9.0))(vue@3.5.34(typescript@6.0.3))': + '@vitejs/plugin-vue@6.0.7(vite@8.0.14(@types/node@25.9.1)(jiti@2.7.0)(sass@1.100.0)(terser@5.48.0)(yaml@2.9.0))(vue@3.5.35(typescript@6.0.3))': dependencies: '@rolldown/pluginutils': 1.0.1 vite: 8.0.14(@types/node@25.9.1)(jiti@2.7.0)(sass@1.100.0)(terser@5.48.0)(yaml@2.9.0) - vue: 3.5.34(typescript@6.0.3) + vue: 3.5.35(typescript@6.0.3) '@volar/language-core@2.4.28': dependencies: @@ -1915,45 +1915,45 @@ snapshots: path-browserify: 1.0.1 vscode-uri: 3.1.0 - '@vue-macros/common@3.1.2(vue@3.5.34(typescript@6.0.3))': + '@vue-macros/common@3.1.2(vue@3.5.35(typescript@6.0.3))': dependencies: - '@vue/compiler-sfc': 3.5.34 + '@vue/compiler-sfc': 3.5.35 ast-kit: 2.2.0 local-pkg: 1.2.1 magic-string-ast: 1.0.3 unplugin-utils: 0.3.1 optionalDependencies: - vue: 3.5.34(typescript@6.0.3) + vue: 3.5.35(typescript@6.0.3) - '@vue/compiler-core@3.5.34': + '@vue/compiler-core@3.5.35': dependencies: - '@babel/parser': 7.29.3 - '@vue/shared': 3.5.34 + '@babel/parser': 7.29.7 + '@vue/shared': 3.5.35 entities: 7.0.1 estree-walker: 2.0.2 source-map-js: 1.2.1 - '@vue/compiler-dom@3.5.34': + '@vue/compiler-dom@3.5.35': dependencies: - '@vue/compiler-core': 3.5.34 - '@vue/shared': 3.5.34 + '@vue/compiler-core': 3.5.35 + '@vue/shared': 3.5.35 - '@vue/compiler-sfc@3.5.34': + '@vue/compiler-sfc@3.5.35': dependencies: - '@babel/parser': 7.29.3 - '@vue/compiler-core': 3.5.34 - '@vue/compiler-dom': 3.5.34 - '@vue/compiler-ssr': 3.5.34 - '@vue/shared': 3.5.34 + '@babel/parser': 7.29.7 + '@vue/compiler-core': 3.5.35 + '@vue/compiler-dom': 3.5.35 + '@vue/compiler-ssr': 3.5.35 + '@vue/shared': 3.5.35 estree-walker: 2.0.2 magic-string: 0.30.21 postcss: 8.5.15 source-map-js: 1.2.1 - '@vue/compiler-ssr@3.5.34': + '@vue/compiler-ssr@3.5.35': dependencies: - '@vue/compiler-dom': 3.5.34 - '@vue/shared': 3.5.34 + '@vue/compiler-dom': 3.5.35 + '@vue/shared': 3.5.35 '@vue/devtools-api@7.7.9': dependencies: @@ -1986,71 +1986,71 @@ snapshots: '@vue/devtools-shared@8.1.2': {} - '@vue/language-core@3.3.1': + '@vue/language-core@3.3.2': dependencies: '@volar/language-core': 2.4.28 - '@vue/compiler-dom': 3.5.34 - '@vue/shared': 3.5.34 + '@vue/compiler-dom': 3.5.35 + '@vue/shared': 3.5.35 alien-signals: 3.2.1 muggle-string: 0.4.1 path-browserify: 1.0.1 picomatch: 4.0.4 - '@vue/reactivity@3.5.34': + '@vue/reactivity@3.5.35': dependencies: - '@vue/shared': 3.5.34 + '@vue/shared': 3.5.35 - '@vue/runtime-core@3.5.34': + '@vue/runtime-core@3.5.35': dependencies: - '@vue/reactivity': 3.5.34 - '@vue/shared': 3.5.34 + '@vue/reactivity': 3.5.35 + '@vue/shared': 3.5.35 - '@vue/runtime-dom@3.5.34': + '@vue/runtime-dom@3.5.35': dependencies: - '@vue/reactivity': 3.5.34 - '@vue/runtime-core': 3.5.34 - '@vue/shared': 3.5.34 + '@vue/reactivity': 3.5.35 + '@vue/runtime-core': 3.5.35 + '@vue/shared': 3.5.35 csstype: 3.2.3 - '@vue/server-renderer@3.5.34(vue@3.5.34(typescript@6.0.3))': + '@vue/server-renderer@3.5.35(vue@3.5.35(typescript@6.0.3))': dependencies: - '@vue/compiler-ssr': 3.5.34 - '@vue/shared': 3.5.34 - vue: 3.5.34(typescript@6.0.3) + '@vue/compiler-ssr': 3.5.35 + '@vue/shared': 3.5.35 + vue: 3.5.35(typescript@6.0.3) - '@vue/shared@3.5.34': {} + '@vue/shared@3.5.35': {} - '@vueuse/core@10.11.1(vue@3.5.34(typescript@6.0.3))': + '@vueuse/core@10.11.1(vue@3.5.35(typescript@6.0.3))': dependencies: '@types/web-bluetooth': 0.0.20 '@vueuse/metadata': 10.11.1 - '@vueuse/shared': 10.11.1(vue@3.5.34(typescript@6.0.3)) - vue-demi: 0.14.10(vue@3.5.34(typescript@6.0.3)) + '@vueuse/shared': 10.11.1(vue@3.5.35(typescript@6.0.3)) + vue-demi: 0.14.10(vue@3.5.35(typescript@6.0.3)) transitivePeerDependencies: - '@vue/composition-api' - vue - '@vueuse/core@14.3.0(vue@3.5.34(typescript@6.0.3))': + '@vueuse/core@14.3.0(vue@3.5.35(typescript@6.0.3))': dependencies: '@types/web-bluetooth': 0.0.21 '@vueuse/metadata': 14.3.0 - '@vueuse/shared': 14.3.0(vue@3.5.34(typescript@6.0.3)) - vue: 3.5.34(typescript@6.0.3) + '@vueuse/shared': 14.3.0(vue@3.5.35(typescript@6.0.3)) + vue: 3.5.35(typescript@6.0.3) '@vueuse/metadata@10.11.1': {} '@vueuse/metadata@14.3.0': {} - '@vueuse/shared@10.11.1(vue@3.5.34(typescript@6.0.3))': + '@vueuse/shared@10.11.1(vue@3.5.35(typescript@6.0.3))': dependencies: - vue-demi: 0.14.10(vue@3.5.34(typescript@6.0.3)) + vue-demi: 0.14.10(vue@3.5.35(typescript@6.0.3)) transitivePeerDependencies: - '@vue/composition-api' - vue - '@vueuse/shared@14.3.0(vue@3.5.34(typescript@6.0.3))': + '@vueuse/shared@14.3.0(vue@3.5.35(typescript@6.0.3))': dependencies: - vue: 3.5.34(typescript@6.0.3) + vue: 3.5.35(typescript@6.0.3) acorn@8.16.0: {} @@ -2073,12 +2073,12 @@ snapshots: ast-kit@2.2.0: dependencies: - '@babel/parser': 7.29.3 + '@babel/parser': 7.29.7 pathe: 2.0.3 ast-walker-scope@0.8.3: dependencies: - '@babel/parser': 7.29.3 + '@babel/parser': 7.29.7 ast-kit: 2.2.0 asynckit@0.4.0: {} @@ -2279,7 +2279,7 @@ snapshots: transitivePeerDependencies: - supports-color - i18next@26.2.0(typescript@6.0.3): + i18next@26.3.0(typescript@6.0.3): optionalDependencies: typescript: 6.0.3 @@ -2424,14 +2424,14 @@ snapshots: motion-utils@12.39.0: {} - motion-v@2.2.1(@vueuse/core@14.3.0(vue@3.5.34(typescript@6.0.3)))(vue@3.5.34(typescript@6.0.3)): + motion-v@2.2.1(@vueuse/core@14.3.0(vue@3.5.35(typescript@6.0.3)))(vue@3.5.35(typescript@6.0.3)): dependencies: - '@vueuse/core': 14.3.0(vue@3.5.34(typescript@6.0.3)) + '@vueuse/core': 14.3.0(vue@3.5.35(typescript@6.0.3)) framer-motion: 12.40.0 hey-listen: 1.0.8 motion-dom: 12.40.0 motion-utils: 12.39.0 - vue: 3.5.34(typescript@6.0.3) + vue: 3.5.35(typescript@6.0.3) transitivePeerDependencies: - '@emotion/is-prop-valid' - react @@ -2468,10 +2468,10 @@ snapshots: picomatch@4.0.4: {} - pinia@3.0.4(typescript@6.0.3)(vue@3.5.34(typescript@6.0.3)): + pinia@3.0.4(typescript@6.0.3)(vue@3.5.35(typescript@6.0.3)): dependencies: '@vue/devtools-api': 7.7.9 - vue: 3.5.34(typescript@6.0.3) + vue: 3.5.35(typescript@6.0.3) optionalDependencies: typescript: 6.0.3 @@ -2509,19 +2509,19 @@ snapshots: readdirp@5.0.0: {} - reka-ui@2.9.8(vue@3.5.34(typescript@6.0.3)): + reka-ui@2.9.8(vue@3.5.35(typescript@6.0.3)): dependencies: '@floating-ui/dom': 1.7.6 - '@floating-ui/vue': 1.1.11(vue@3.5.34(typescript@6.0.3)) + '@floating-ui/vue': 1.1.11(vue@3.5.35(typescript@6.0.3)) '@internationalized/date': 3.12.1 '@internationalized/number': 3.6.6 - '@tanstack/vue-virtual': 3.13.25(vue@3.5.34(typescript@6.0.3)) - '@vueuse/core': 14.3.0(vue@3.5.34(typescript@6.0.3)) - '@vueuse/shared': 14.3.0(vue@3.5.34(typescript@6.0.3)) + '@tanstack/vue-virtual': 3.13.26(vue@3.5.35(typescript@6.0.3)) + '@vueuse/core': 14.3.0(vue@3.5.35(typescript@6.0.3)) + '@vueuse/shared': 14.3.0(vue@3.5.35(typescript@6.0.3)) aria-hidden: 1.2.6 defu: 6.1.7 ohash: 2.0.11 - vue: 3.5.34(typescript@6.0.3) + vue: 3.5.35(typescript@6.0.3) transitivePeerDependencies: - '@vue/composition-api' @@ -2629,19 +2629,19 @@ snapshots: picomatch: 4.0.4 webpack-virtual-modules: 0.6.2 - vaul-vue@0.4.1(reka-ui@2.9.8(vue@3.5.34(typescript@6.0.3)))(vue@3.5.34(typescript@6.0.3)): + vaul-vue@0.4.1(reka-ui@2.9.8(vue@3.5.35(typescript@6.0.3)))(vue@3.5.35(typescript@6.0.3)): dependencies: - '@vueuse/core': 10.11.1(vue@3.5.34(typescript@6.0.3)) - reka-ui: 2.9.8(vue@3.5.34(typescript@6.0.3)) - vue: 3.5.34(typescript@6.0.3) + '@vueuse/core': 10.11.1(vue@3.5.35(typescript@6.0.3)) + reka-ui: 2.9.8(vue@3.5.35(typescript@6.0.3)) + vue: 3.5.35(typescript@6.0.3) transitivePeerDependencies: - '@vue/composition-api' - vee-validate@4.15.1(vue@3.5.34(typescript@6.0.3)): + vee-validate@4.15.1(vue@3.5.35(typescript@6.0.3)): dependencies: '@vue/devtools-api': 7.7.9 type-fest: 4.41.0 - vue: 3.5.34(typescript@6.0.3) + vue: 3.5.35(typescript@6.0.3) vite-plugin-static-copy@4.1.0(vite@8.0.14(@types/node@25.9.1)(jiti@2.7.0)(sass@1.100.0)(terser@5.48.0)(yaml@2.9.0)): dependencies: @@ -2668,14 +2668,14 @@ snapshots: vscode-uri@3.1.0: {} - vue-demi@0.14.10(vue@3.5.34(typescript@6.0.3)): + vue-demi@0.14.10(vue@3.5.35(typescript@6.0.3)): dependencies: - vue: 3.5.34(typescript@6.0.3) + vue: 3.5.35(typescript@6.0.3) - vue-router@5.0.7(@vue/compiler-sfc@3.5.34)(pinia@3.0.4(typescript@6.0.3)(vue@3.5.34(typescript@6.0.3)))(vue@3.5.34(typescript@6.0.3)): + vue-router@5.0.7(@vue/compiler-sfc@3.5.35)(pinia@3.0.4(typescript@6.0.3)(vue@3.5.35(typescript@6.0.3)))(vue@3.5.35(typescript@6.0.3)): dependencies: - '@babel/generator': 8.0.0-rc.5 - '@vue-macros/common': 3.1.2(vue@3.5.34(typescript@6.0.3)) + '@babel/generator': 8.0.0-rc.6 + '@vue-macros/common': 3.1.2(vue@3.5.35(typescript@6.0.3)) '@vue/devtools-api': 8.1.2 ast-walker-scope: 0.8.3 chokidar: 5.0.0 @@ -2690,31 +2690,31 @@ snapshots: tinyglobby: 0.2.16 unplugin: 3.0.0 unplugin-utils: 0.3.1 - vue: 3.5.34(typescript@6.0.3) + vue: 3.5.35(typescript@6.0.3) yaml: 2.9.0 optionalDependencies: - '@vue/compiler-sfc': 3.5.34 - pinia: 3.0.4(typescript@6.0.3)(vue@3.5.34(typescript@6.0.3)) + '@vue/compiler-sfc': 3.5.35 + pinia: 3.0.4(typescript@6.0.3)(vue@3.5.35(typescript@6.0.3)) vue-sonner@2.0.9: {} - vue-tsc@3.3.1(typescript@6.0.3): + vue-tsc@3.3.2(typescript@6.0.3): dependencies: '@volar/typescript': 2.4.28 - '@vue/language-core': 3.3.1 + '@vue/language-core': 3.3.2 typescript: 6.0.3 - vue-virtual-scroller@3.0.4(vue@3.5.34(typescript@6.0.3)): + vue-virtual-scroller@3.0.4(vue@3.5.35(typescript@6.0.3)): dependencies: - vue: 3.5.34(typescript@6.0.3) + vue: 3.5.35(typescript@6.0.3) - vue@3.5.34(typescript@6.0.3): + vue@3.5.35(typescript@6.0.3): dependencies: - '@vue/compiler-dom': 3.5.34 - '@vue/compiler-sfc': 3.5.34 - '@vue/runtime-dom': 3.5.34 - '@vue/server-renderer': 3.5.34(vue@3.5.34(typescript@6.0.3)) - '@vue/shared': 3.5.34 + '@vue/compiler-dom': 3.5.35 + '@vue/compiler-sfc': 3.5.35 + '@vue/runtime-dom': 3.5.35 + '@vue/server-renderer': 3.5.35(vue@3.5.35(typescript@6.0.3)) + '@vue/shared': 3.5.35 optionalDependencies: typescript: 6.0.3 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 73e86376..ebc91e9a 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -7,3 +7,16 @@ allowBuilds: esbuild: true unrs-resolver: true vue-demi: true +minimumReleaseAgeExclude: + - '@risuko/cli-darwin-arm64@0.3.4' + - '@risuko/cli-darwin-x64@0.3.4' + - '@risuko/cli-linux-arm64-gnu@0.3.4' + - '@risuko/cli-linux-x64-gnu@0.3.4' + - '@risuko/cli-win32-arm64-msvc@0.3.4' + - '@risuko/cli-win32-x64-msvc@0.3.4' + - '@risuko/js-darwin-arm64@0.3.4' + - '@risuko/js-darwin-x64@0.3.4' + - '@risuko/js-linux-arm64-gnu@0.3.4' + - '@risuko/js-linux-x64-gnu@0.3.4' + - '@risuko/js-win32-arm64-msvc@0.3.4' + - '@risuko/js-win32-x64-msvc@0.3.4' diff --git a/scripts/android-env.mjs b/scripts/android-env.mjs new file mode 100644 index 00000000..43fa2a64 --- /dev/null +++ b/scripts/android-env.mjs @@ -0,0 +1,112 @@ +#!/usr/bin/env node +import { spawn } from "node:child_process"; +import { existsSync } from "node:fs"; +import { delimiter, join } from "node:path"; + +const sdkRoot = + process.env.ANDROID_HOME || + process.env.ANDROID_SDK_ROOT || + "/opt/homebrew/share/android-commandlinetools"; +const ndkVersion = process.env.ANDROID_NDK_VERSION || "27.2.12479018"; +const ndkHome = process.env.ANDROID_NDK_HOME || join(sdkRoot, "ndk", ndkVersion); +const prebuiltRoot = join(ndkHome, "toolchains", "llvm", "prebuilt"); +const prebuiltHostCandidates = [ + process.platform === "darwin" && process.arch === "arm64" ? "darwin-arm64" : "", + process.platform === "darwin" ? "darwin-x86_64" : "", + process.platform === "linux" ? "linux-x86_64" : "", + process.platform === "win32" ? "windows-x86_64" : "", + "darwin-arm64", + "darwin-x86_64", + "linux-x86_64", + "windows-x86_64", +].filter(Boolean); +const prebuiltHost = prebuiltHostCandidates.find((host) => + existsSync(join(prebuiltRoot, host)), +); +if (!prebuiltHost) { + console.error(`Android NDK prebuilt toolchain not found under: ${prebuiltRoot}`); + process.exit(1); +} +const llvmBin = join(prebuiltRoot, prebuiltHost, "bin"); +const api = process.env.ANDROID_API_LEVEL || "35"; +const javaHomeCandidates = [ + process.env.ANDROID_JAVA_HOME, + "/opt/homebrew/opt/openjdk@17/libexec/openjdk.jdk/Contents/Home", + "/opt/homebrew/opt/openjdk@21/libexec/openjdk.jdk/Contents/Home", + process.env.JAVA_HOME, +].filter(Boolean); +const javaHome = javaHomeCandidates.find((path) => + existsSync(join(path, "bin", "java")), +); +const javaBin = javaHome ? join(javaHome, "bin") : ""; + +if (!existsSync(llvmBin)) { + console.error(`Android NDK LLVM toolchain not found: ${llvmBin}`); + process.exit(1); +} + +const env = { + ...process.env, + ANDROID_HOME: sdkRoot, + ANDROID_SDK_ROOT: sdkRoot, + ANDROID_NDK_HOME: ndkHome, + NDK_HOME: ndkHome, + ...(javaHome ? { JAVA_HOME: javaHome } : {}), + PATH: [javaBin, llvmBin, process.env.PATH || ""].filter(Boolean).join(delimiter), + CC_aarch64_linux_android: join(llvmBin, `aarch64-linux-android${api}-clang`), + CXX_aarch64_linux_android: join(llvmBin, `aarch64-linux-android${api}-clang++`), + AR_aarch64_linux_android: join(llvmBin, "llvm-ar"), + RANLIB_aarch64_linux_android: join(llvmBin, "llvm-ranlib"), + CARGO_TARGET_AARCH64_LINUX_ANDROID_LINKER: join( + llvmBin, + `aarch64-linux-android${api}-clang`, + ), + CC_armv7_linux_androideabi: join(llvmBin, `armv7a-linux-androideabi${api}-clang`), + CXX_armv7_linux_androideabi: join(llvmBin, `armv7a-linux-androideabi${api}-clang++`), + AR_armv7_linux_androideabi: join(llvmBin, "llvm-ar"), + RANLIB_armv7_linux_androideabi: join(llvmBin, "llvm-ranlib"), + CARGO_TARGET_ARMV7_LINUX_ANDROIDEABI_LINKER: join( + llvmBin, + `armv7a-linux-androideabi${api}-clang`, + ), + CC_i686_linux_android: join(llvmBin, `i686-linux-android${api}-clang`), + CXX_i686_linux_android: join(llvmBin, `i686-linux-android${api}-clang++`), + AR_i686_linux_android: join(llvmBin, "llvm-ar"), + RANLIB_i686_linux_android: join(llvmBin, "llvm-ranlib"), + CARGO_TARGET_I686_LINUX_ANDROID_LINKER: join( + llvmBin, + `i686-linux-android${api}-clang`, + ), + CC_x86_64_linux_android: join(llvmBin, `x86_64-linux-android${api}-clang`), + CXX_x86_64_linux_android: join(llvmBin, `x86_64-linux-android${api}-clang++`), + AR_x86_64_linux_android: join(llvmBin, "llvm-ar"), + RANLIB_x86_64_linux_android: join(llvmBin, "llvm-ranlib"), + CARGO_TARGET_X86_64_LINUX_ANDROID_LINKER: join( + llvmBin, + `x86_64-linux-android${api}-clang`, + ), +}; + +const args = process.argv.slice(2); +if (args.length === 0) { + console.log(`ANDROID_HOME=${env.ANDROID_HOME}`); + console.log(`ANDROID_NDK_HOME=${env.ANDROID_NDK_HOME}`); + if (env.JAVA_HOME) { + console.log("JAVA_HOME is set"); + } + console.log(`PATH prefix=${llvmBin}`); + process.exit(0); +} + +const child = spawn(args[0], args.slice(1), { + stdio: "inherit", + env, +}); + +child.on("exit", (code, signal) => { + if (signal) { + process.kill(process.pid, signal); + return; + } + process.exit(code ?? 1); +}); diff --git a/scripts/sign-android-apks.mjs b/scripts/sign-android-apks.mjs new file mode 100644 index 00000000..f8cbb281 --- /dev/null +++ b/scripts/sign-android-apks.mjs @@ -0,0 +1,173 @@ +#!/usr/bin/env node +import { spawnSync } from "node:child_process"; +import { existsSync, mkdtempSync, readdirSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { basename, dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const projectRoot = resolve(fileURLToPath(new URL("..", import.meta.url))); + +const envFile = resolve(projectRoot, ".env"); +if (existsSync(envFile)) { + process.loadEnvFile(envFile); +} + +const apkRoot = resolve( + projectRoot, + "src-tauri/gen/android/app/build/outputs/apk", +); + +function fail(message) { + throw new Error(message); +} + +function run(command, args, options = {}) { + const result = spawnSync(command, args, { + stdio: "inherit", + env: process.env, + ...options, + }); + if (result.status !== 0) { + fail(`${command} failed with exit code ${result.status}`); + } +} + +function versionParts(version) { + return version.split(".").map((part) => Number(part)); +} + +function compareVersions(a, b) { + const left = versionParts(a); + const right = versionParts(b); + const length = Math.max(left.length, right.length); + for (let index = 0; index < length; index += 1) { + const diff = (left[index] || 0) - (right[index] || 0); + if (diff !== 0) { + return diff; + } + } + return 0; +} + +function findBuildTool(name) { + const explicit = process.env[`${name.toUpperCase()}_PATH`]; + if (explicit) { + return explicit; + } + const sdkRoot = process.env.ANDROID_HOME || process.env.ANDROID_SDK_ROOT; + if (!sdkRoot) { + fail("ANDROID_HOME or ANDROID_SDK_ROOT is required to locate Android build tools"); + } + const buildToolsRoot = join(sdkRoot, "build-tools"); + if (!existsSync(buildToolsRoot)) { + fail(`Android build-tools directory not found: ${buildToolsRoot}`); + } + const candidates = + process.platform === "win32" + ? [name, `${name}.bat`, `${name}.cmd`, `${name}.exe`] + : [name]; + const versions = readdirSync(buildToolsRoot) + .filter((entry) => + candidates.some((candidate) => + existsSync(join(buildToolsRoot, entry, candidate)), + ), + ) + .sort(compareVersions) + .reverse(); + if (versions.length === 0) { + fail(`${name} not found under ${buildToolsRoot}`); + } + const toolName = candidates.find((candidate) => + existsSync(join(buildToolsRoot, versions[0], candidate)), + ); + return join(buildToolsRoot, versions[0], toolName); +} + +function findUnsignedApks(root) { + if (!existsSync(root)) { + fail(`APK output directory not found: ${root}`); + } + const results = []; + const stack = [root]; + while (stack.length > 0) { + const current = stack.pop(); + for (const entry of readdirSync(current, { withFileTypes: true })) { + const fullPath = join(current, entry.name); + if (entry.isDirectory()) { + stack.push(fullPath); + } else if (entry.isFile() && entry.name.endsWith("-unsigned.apk")) { + results.push(fullPath); + } + } + } + return results.sort(); +} + +function resolveKeystore() { + const keystorePath = process.env.ANDROID_SIGNING_KEYSTORE_PATH; + if (keystorePath) { + return { path: keystorePath }; + } + const encoded = process.env.ANDROID_SIGNING_KEYSTORE_BASE64; + if (!encoded) { + fail( + "ANDROID_SIGNING_KEYSTORE_PATH or ANDROID_SIGNING_KEYSTORE_BASE64 is required", + ); + } + const dir = mkdtempSync(join(tmpdir(), "risuko-android-signing-")); + const path = join(dir, "release.keystore"); + writeFileSync(path, Buffer.from(encoded, "base64")); + return { path, cleanup: () => rmSync(dir, { recursive: true, force: true }) }; +} + +const storePassword = process.env.ANDROID_SIGNING_KEYSTORE_PASSWORD; +const alias = process.env.ANDROID_SIGNING_KEY_ALIAS; +if (!storePassword) { + fail("ANDROID_SIGNING_KEYSTORE_PASSWORD is required"); +} +if (!alias) { + fail("ANDROID_SIGNING_KEY_ALIAS is required"); +} +if (!process.env.ANDROID_SIGNING_KEY_PASSWORD) { + process.env.ANDROID_SIGNING_KEY_PASSWORD = storePassword; +} + +const apksigner = findBuildTool("apksigner"); +const zipalign = findBuildTool("zipalign"); +const unsignedApks = findUnsignedApks(apkRoot); + +if (unsignedApks.length === 0) { + fail(`No unsigned release APKs found under ${apkRoot}`); +} + +const keystore = resolveKeystore(); + +try { + for (const unsignedApk of unsignedApks) { + const outputApk = unsignedApk.replace(/-unsigned\.apk$/, ".apk"); + const alignedApk = join( + dirname(unsignedApk), + `${basename(unsignedApk, ".apk")}-aligned.apk`, + ); + run(zipalign, ["-f", "-p", "4", unsignedApk, alignedApk]); + run(apksigner, [ + "sign", + "--ks", + keystore.path, + "--ks-key-alias", + alias, + "--ks-pass", + "env:ANDROID_SIGNING_KEYSTORE_PASSWORD", + "--key-pass", + "env:ANDROID_SIGNING_KEY_PASSWORD", + "--out", + outputApk, + alignedApk, + ]); + run(apksigner, ["verify", "--verbose", "--print-certs", outputApk]); + rmSync(alignedApk, { force: true }); + console.log(`signed: ${outputApk}`); + } +} finally { + keystore.cleanup?.(); +} \ No newline at end of file diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 577a31c1..f74a0a78 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -41,9 +41,9 @@ dependencies = [ [[package]] name = "aes" -version = "0.9.0" +version = "0.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "66bd29a732b644c0431c6140f370d097879203d79b80c94a6747ba0872adaef8" +checksum = "f1fc76eaeac4c9164506c466d4ffdd8ec9d0c5bf57ee97177c4d8eceb3a0e138" dependencies = [ "cipher 0.5.2", "cpubits", @@ -71,7 +71,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e22c0c90bbe8d4f77c3ca9ddabe41a1f8382d6fc1f7cea89459d0f320371f972" dependencies = [ "aead 0.6.0-rc.10", - "aes 0.9.0", + "aes 0.9.1", "cipher 0.5.2", "ctr 0.10.1", "ghash 0.6.0", @@ -751,9 +751,9 @@ dependencies = [ [[package]] name = "brotli" -version = "8.0.2" +version = "8.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4bd8b9603c7aa97359dbd97ecf258968c95f3adddd6db2f7e7a5bef101c84560" +checksum = "8119e4516436f5708bbc474a9d395bf12f1b5395e93a92a56e647ac3388c8610" dependencies = [ "alloc-no-stdlib", "alloc-stdlib", @@ -762,9 +762,9 @@ dependencies = [ [[package]] name = "brotli-decompressor" -version = "5.0.0" +version = "5.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "874bb8112abecc98cbd6d81ea4fa7e94fb9449648c93cc89aa40c81c24d7de03" +checksum = "5962523e1b92ce1b5e793d9169b9943eece10d39f62550bc04bb605d75b94924" dependencies = [ "alloc-no-stdlib", "alloc-stdlib", @@ -1688,9 +1688,9 @@ dependencies = [ [[package]] name = "displaydoc" -version = "0.2.5" +version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f" dependencies = [ "proc-macro2", "quote", @@ -2936,9 +2936,9 @@ dependencies = [ [[package]] name = "hyper" -version = "1.9.0" +version = "1.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6299f016b246a94207e63da54dbe807655bf9e00044f73ded42c3ac5305fbcca" +checksum = "eb92f162bf56536459fc83c79b974bb12837acfed43d6bc370a7916d0ae15ecc" dependencies = [ "atomic-waker", "bytes", @@ -3331,9 +3331,9 @@ dependencies = [ [[package]] name = "jiff" -version = "0.2.26" +version = "0.2.27" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "30457d51cb0e68ee18184b30cd9eb8e1602a20837c321f6ea9706b94f1c681c3" +checksum = "392c70591e8749fe235ddaf513e6f58b26bce3dcc16524cecc8936f75afa161e" dependencies = [ "jiff-static", "log", @@ -3344,9 +3344,9 @@ dependencies = [ [[package]] name = "jiff-static" -version = "0.2.26" +version = "0.2.27" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "05f86e4f0326c61ae6c00b04d9009aaeda644d0b5bdfbf6c67247f492f42b3f3" +checksum = "47b605b0c050d845fc355bb11eb3f9a8deddc218ea60c76e61aa1f2adfb2c96a" dependencies = [ "proc-macro2", "quote", @@ -3604,9 +3604,9 @@ checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" [[package]] name = "libredox" -version = "0.1.16" +version = "0.1.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e02f3bb43d335493c96bf3fd3a321600bf6bd07ed34bc64118e9293bdffea46c" +checksum = "f02ab6bace2054fb888a3c16f990117b579d14a3088e472d63c6011fa185c9d3" dependencies = [ "libc", ] @@ -3760,9 +3760,9 @@ dependencies = [ [[package]] name = "memchr" -version = "2.8.0" +version = "2.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" +checksum = "6b947ae49db0d222b1dbc6b113ce7248a3fc3a6ca21b696717bfc000ba4484d8" [[package]] name = "memoffset" @@ -3945,6 +3945,12 @@ dependencies = [ "thiserror 1.0.69", ] +[[package]] +name = "ndk-context" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27b02d87554356db9e9a873add8782d4ea6e3e58ea071a9adb9a2e8ddb884a8b" + [[package]] name = "ndk-sys" version = "0.6.0+11769913" @@ -4863,7 +4869,7 @@ version = "0.8.0-rc.13" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c5a777c6e26664bc9504b3ce3f6133f8f20d9071f130a4f9fcbd3186959d8dd6" dependencies = [ - "aes 0.9.0", + "aes 0.9.1", "aes-gcm 0.11.0-rc.3", "cbc 0.2.1", "der", @@ -5111,7 +5117,7 @@ version = "3.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" dependencies = [ - "toml_edit 0.25.11+spec-1.1.0", + "toml_edit 0.25.12+spec-1.1.0", ] [[package]] @@ -5483,7 +5489,7 @@ dependencies = [ [[package]] name = "risuko" -version = "0.3.3" +version = "0.3.4" dependencies = [ "apple-native-keyring-store", "base64 0.22.1", @@ -5491,8 +5497,10 @@ dependencies = [ "dbus-secret-service-keyring-store", "dirs 6.0.0", "fs4", + "jni", "keyring-core", "log", + "ndk-context", "open", "regex", "risuko-cookies", @@ -5559,7 +5567,7 @@ dependencies = [ [[package]] name = "risuko-cli" -version = "0.3.3" +version = "0.3.4" dependencies = [ "base64 0.22.1", "clap", @@ -5576,7 +5584,7 @@ dependencies = [ [[package]] name = "risuko-cookies" -version = "0.3.3" +version = "0.3.4" dependencies = [ "rookie", "serde", @@ -5588,9 +5596,9 @@ dependencies = [ [[package]] name = "risuko-engine" -version = "0.3.3" +version = "0.3.4" dependencies = [ - "aes 0.9.0", + "aes 0.9.1", "async-trait", "axum", "base64 0.22.1", @@ -5646,7 +5654,7 @@ dependencies = [ [[package]] name = "risuko-http" -version = "0.3.3" +version = "0.3.4" dependencies = [ "async-compression", "async-stream", @@ -5680,7 +5688,7 @@ dependencies = [ [[package]] name = "risuko-napi" -version = "0.3.3" +version = "0.3.4" dependencies = [ "base64 0.22.1", "dirs 6.0.0", @@ -5768,7 +5776,7 @@ checksum = "324b92f459d3e42da294e14e8eb150d2215fcfb7c966838bc1127cd68bc05a0d" dependencies = [ "aead 0.6.0-rc.10", "aes 0.8.4", - "aes 0.9.0", + "aes 0.9.1", "aes-gcm 0.11.0-rc.3", "bitflags 2.11.1", "block-padding 0.3.3", @@ -7640,9 +7648,9 @@ dependencies = [ [[package]] name = "toml_edit" -version = "0.25.11+spec-1.1.0" +version = "0.25.12+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b59c4d22ed448339746c59b905d24568fcbb3ab65a500494f7b8c3e97739f2b" +checksum = "d2153edc6955a6c354fad8f5efd38b6a8769bdccf9fe50f8e1329f81b0baa5d7" dependencies = [ "indexmap 2.14.0", "toml_datetime 1.1.1+spec-1.1.0", @@ -9529,18 +9537,18 @@ dependencies = [ [[package]] name = "zerocopy" -version = "0.8.48" +version = "0.8.49" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eed437bf9d6692032087e337407a86f04cd8d6a16a37199ed57949d415bd68e9" +checksum = "bce33a6288fa3f072a8c2c7d0f2fdbb90e28298f0135c1f99b96c3db2efcc60b" dependencies = [ "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.8.48" +version = "0.8.49" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4" +checksum = "8fd425244944f4ab65ccff928e7323354c5a018c75838362fdce749dfad2ee1e" dependencies = [ "proc-macro2", "quote", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 34752024..4688e422 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.3" +version = "0.3.4" authors = ["YueMiyuki"] edition = "2021" rust-version = "1.82" @@ -47,7 +47,6 @@ tauri-plugin-store = "2" tauri-plugin-deep-link = "2" tauri-plugin-dialog = "2" tauri-plugin-shell = "2" -tauri-plugin-autostart = "2" tauri-plugin-process = "2" tauri-plugin-fs = "2" tauri-plugin-notification = "2" @@ -59,10 +58,7 @@ tokio = { workspace = true } log = { workspace = true } dirs = { workspace = true } open = "5" -trash = "5" tauri-plugin-clipboard-manager = "2.3.2" -tauri-plugin-nosleep = { git = "https://github.com/pevers/tauri-plugin-nosleep", rev = "6f6ab76ec171d075476b585282290fcf2e1c40b5" } -tauri-plugin-single-instance = "2" tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["env-filter"] } tracing-appender = "0.2" @@ -86,4 +82,18 @@ windows-native-keyring-store = "1" [target.'cfg(all(unix, not(any(target_os = "macos", target_os = "ios", target_os = "android"))))'.dependencies] dbus-secret-service-keyring-store = { version = "1", features = ["crypto-rust"] } +[target.'cfg(not(target_os = "android"))'.dependencies] +trash = "5" +tauri-plugin-nosleep = { git = "https://github.com/pevers/tauri-plugin-nosleep", rev = "6f6ab76ec171d075476b585282290fcf2e1c40b5" } +tauri-plugin-autostart = "2" +tauri-plugin-single-instance = "2" + +[target.'cfg(target_os = "android")'.dependencies] +# JNI bridge so we can dispatch Android `Intent`s with explicit MIME types +# and `Intent.createChooser`. `tauri-plugin-shell::open` only calls +# `Intent(ACTION_VIEW, uri)` with no extras, which lets generic +# wildcard-MIME apps like Messages intercept any content URI we send. +jni = "0.21" +ndk-context = "0.1" + [dev-dependencies] diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json index 0a044f7d..b72c1047 100644 --- a/src-tauri/capabilities/default.json +++ b/src-tauri/capabilities/default.json @@ -25,7 +25,16 @@ "dialog:allow-open", "dialog:allow-message", "shell:default", - "shell:allow-open", + { + "identifier": "shell:allow-open", + "allow": [ + { "url": "file://**" }, + { "url": "https://**" }, + { "url": "http://**" }, + { "url": "mailto:**" }, + { "url": "tel:**" } + ] + }, "store:default", "fs:default", { @@ -56,10 +65,7 @@ "os:default", "notification:default", "notification:allow-notify", - "autostart:default", "deep-link:default", - "nosleep:allow-block", - "nosleep:allow-unblock", "clipboard-manager:default", "clipboard-manager:allow-read-text", "clipboard-manager:allow-write-text" diff --git a/src-tauri/capabilities/desktop.json b/src-tauri/capabilities/desktop.json new file mode 100644 index 00000000..5824ff77 --- /dev/null +++ b/src-tauri/capabilities/desktop.json @@ -0,0 +1,11 @@ +{ + "identifier": "desktop", + "description": "Desktop-only capabilities for Risuko", + "windows": ["main"], + "platforms": ["macOS", "windows", "linux"], + "permissions": [ + "autostart:default", + "nosleep:allow-block", + "nosleep:allow-unblock" + ] +} diff --git a/src-tauri/gen/android/.editorconfig b/src-tauri/gen/android/.editorconfig new file mode 100644 index 00000000..ebe51d3b --- /dev/null +++ b/src-tauri/gen/android/.editorconfig @@ -0,0 +1,12 @@ +# EditorConfig is awesome: https://EditorConfig.org + +# top-most EditorConfig file +root = true + +[*] +indent_style = space +indent_size = 2 +end_of_line = lf +charset = utf-8 +trim_trailing_whitespace = false +insert_final_newline = false \ No newline at end of file diff --git a/src-tauri/gen/android/.gitignore b/src-tauri/gen/android/.gitignore new file mode 100644 index 00000000..68262551 --- /dev/null +++ b/src-tauri/gen/android/.gitignore @@ -0,0 +1,22 @@ +*.iml +.gradle +/local.properties +/.idea/caches +/.idea/libraries +/.idea/modules.xml +/.idea/workspace.xml +/.idea/navEditor.xml +/.idea/assetWizardSettings.xml +.DS_Store +build +/captures +.externalNativeBuild +.cxx +local.properties +key.properties +keystore.properties + +/.tauri + +# Autogenerated by `tauri android build` +/tauri.settings.gradle \ No newline at end of file diff --git a/src-tauri/gen/android/app/.gitignore b/src-tauri/gen/android/app/.gitignore new file mode 100644 index 00000000..0504c979 --- /dev/null +++ b/src-tauri/gen/android/app/.gitignore @@ -0,0 +1,7 @@ +/src/main/**/generated +/src/main/jniLibs/**/*.so +/src/main/assets/tauri.conf.json + +# Autogenerated by `tauri android build` +/tauri.build.gradle.kts +/tauri.properties \ No newline at end of file diff --git a/src-tauri/gen/android/app/build.gradle.kts b/src-tauri/gen/android/app/build.gradle.kts new file mode 100644 index 00000000..f26cb414 --- /dev/null +++ b/src-tauri/gen/android/app/build.gradle.kts @@ -0,0 +1,71 @@ +import java.util.Properties + +plugins { + id("com.android.application") + id("org.jetbrains.kotlin.android") + id("rust") +} + +val tauriProperties = Properties().apply { + val propFile = file("tauri.properties") + if (propFile.exists()) { + propFile.inputStream().use { load(it) } + } +} + +android { + compileSdk = 36 + namespace = "app.risuko.mobile" + defaultConfig { + manifestPlaceholders["usesCleartextTraffic"] = "false" + applicationId = "app.risuko.mobile" + minSdk = 24 + targetSdk = 36 + versionCode = tauriProperties.getProperty("tauri.android.versionCode", "1").toInt() + versionName = tauriProperties.getProperty("tauri.android.versionName", "1.0") + } + buildTypes { + getByName("debug") { + manifestPlaceholders["usesCleartextTraffic"] = "true" + isDebuggable = true + isJniDebuggable = true + isMinifyEnabled = false + packaging { jniLibs.keepDebugSymbols.add("*/arm64-v8a/*.so") + jniLibs.keepDebugSymbols.add("*/armeabi-v7a/*.so") + jniLibs.keepDebugSymbols.add("*/x86/*.so") + jniLibs.keepDebugSymbols.add("*/x86_64/*.so") + } + } + getByName("release") { + isMinifyEnabled = true + proguardFiles( + *fileTree(".") { include("**/*.pro") } + .plus(getDefaultProguardFile("proguard-android-optimize.txt")) + .toList().toTypedArray() + ) + } + } + kotlinOptions { + jvmTarget = "1.8" + } + buildFeatures { + buildConfig = true + } +} + +rust { + rootDirRel = "../../../" +} + +dependencies { + implementation("androidx.webkit:webkit:1.14.0") + implementation("androidx.appcompat:appcompat:1.7.1") + implementation("androidx.activity:activity-ktx:1.10.1") + implementation("com.google.android.material:material:1.12.0") + implementation("androidx.lifecycle:lifecycle-process:2.10.0") + testImplementation("junit:junit:4.13.2") + androidTestImplementation("androidx.test.ext:junit:1.1.4") + androidTestImplementation("androidx.test.espresso:espresso-core:3.5.0") +} + +apply(from = "tauri.build.gradle.kts") \ No newline at end of file diff --git a/src-tauri/gen/android/app/proguard-rules.pro b/src-tauri/gen/android/app/proguard-rules.pro new file mode 100644 index 00000000..4d581b80 --- /dev/null +++ b/src-tauri/gen/android/app/proguard-rules.pro @@ -0,0 +1,24 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +-keep class app.risuko.mobile.MainActivity { *; } +-keep class app.risuko.mobile.RisukoForegroundService { *; } + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile \ No newline at end of file diff --git a/src-tauri/gen/android/app/proguard-tauri.pro b/src-tauri/gen/android/app/proguard-tauri.pro new file mode 100644 index 00000000..065e16c0 --- /dev/null +++ b/src-tauri/gen/android/app/proguard-tauri.pro @@ -0,0 +1,5 @@ +# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. + +-keep class app.risuko.mobile.TauriActivity { + public app.tauri.plugin.PluginManager getPluginManager(); +} diff --git a/src-tauri/gen/android/app/src/main/AndroidManifest.xml b/src-tauri/gen/android/app/src/main/AndroidManifest.xml new file mode 100644 index 00000000..d3ef2bc2 --- /dev/null +++ b/src-tauri/gen/android/app/src/main/AndroidManifest.xml @@ -0,0 +1,75 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 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 new file mode 100644 index 00000000..47cf9acc --- /dev/null +++ b/src-tauri/gen/android/app/src/main/java/app/risuko/mobile/MainActivity.kt @@ -0,0 +1,510 @@ +package app.risuko.mobile + +import android.Manifest +import android.content.ActivityNotFoundException +import android.content.Intent +import android.content.pm.PackageManager +import android.graphics.Color +import android.net.Uri +import android.os.Build +import android.os.Bundle +import android.os.Environment +import android.provider.DocumentsContract +import android.provider.Settings +import android.util.Log +import android.webkit.WebView +import androidx.activity.OnBackPressedCallback +import androidx.activity.SystemBarStyle +import androidx.activity.enableEdgeToEdge +import androidx.activity.result.contract.ActivityResultContracts +import androidx.core.app.ActivityCompat +import androidx.core.content.ContextCompat +import androidx.core.content.FileProvider +import androidx.core.view.WindowCompat +import java.io.File +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit + +class MainActivity : TauriActivity() { + private var pendingDirectoryRequestId: String? = null + private var appWebView: RustWebView? = null + private var requestedNotificationPermission = false + private val directoryPicker = registerForActivityResult(ActivityResultContracts.OpenDocumentTree()) { uri: Uri? -> + val requestId = pendingDirectoryRequestId + pendingDirectoryRequestId = null + if (requestId != null) { + if (uri != null) { + try { + contentResolver.takePersistableUriPermission( + uri, + Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION, + ) + } catch (_: Exception) { + } + } + nativeOnDirectoryPicked(requestId, uri?.toString()) + } + } + + override fun onCreate(savedInstanceState: Bundle?) { + current = this + enableEdgeToEdge() + super.onCreate(savedInstanceState) + setSystemBarsForTheme(false) + onBackPressedDispatcher.addCallback(this, object : OnBackPressedCallback(true) { + override fun handleOnBackPressed() { + dispatchAndroidBack() + } + }) + } + + override fun onWebViewCreate(webView: WebView) { + appWebView = webView as? RustWebView + } + + override fun onDestroy() { + if (current === this) { + current = null + } + super.onDestroy() + } + + private external fun nativeOnDirectoryPicked(requestId: String, uri: String?) + + private fun hasExternalStorageAccess(): Boolean { + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + Environment.isExternalStorageManager() + } else { + ContextCompat.checkSelfPermission(this, Manifest.permission.WRITE_EXTERNAL_STORAGE) == PackageManager.PERMISSION_GRANTED + } + } + + private fun requestExternalStorageAccess(): Boolean { + if (hasExternalStorageAccess()) { + return true + } + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + val packageUri = Uri.parse("package:$packageName") + val intent = Intent(Settings.ACTION_MANAGE_APP_ALL_FILES_ACCESS_PERMISSION, packageUri) + try { + startActivity(intent) + } catch (_: Exception) { + startActivity(Intent(Settings.ACTION_MANAGE_ALL_FILES_ACCESS_PERMISSION)) + } + } else { + ActivityCompat.requestPermissions(this, arrayOf(Manifest.permission.WRITE_EXTERNAL_STORAGE), 4737) + } + return false + } + + private fun setSystemBarsForTheme(darkMode: Boolean) { + // Android 15 (API 35) deprecated the `window.statusBarColor` and + // `window.navigationBarColor` setters. On those releases the framework + // draws system bars edge-to-edge and supplies its own scrim when needed. + // The androidx `enableEdgeToEdge(statusBarStyle, navigationBarStyle)` + // overload handles both cases: it sets the scrim color explicitly on + // API < 29/32, and on API >= 30 it flips icon appearance via + // `SystemBarStyle.dark`/`light` without going near the deprecated setters + val navScrimLight = Color.rgb(253, 248, 255) + val navScrimDark = Color.rgb(20, 18, 24) + val statusBarStyle = if (darkMode) { + SystemBarStyle.dark(Color.TRANSPARENT) + } else { + SystemBarStyle.light(Color.TRANSPARENT, Color.TRANSPARENT) + } + val navigationBarStyle = if (darkMode) { + SystemBarStyle.dark(navScrimDark) + } else { + SystemBarStyle.light(navScrimLight, navScrimDark) + } + enableEdgeToEdge(statusBarStyle, navigationBarStyle) + // `SystemBarStyle` handles icon appearance on API >= 30, but on older + // releases the inset controller is still the documented surface for + // flipping light/dark icons. Setting it explicitly also catches the + // rare case where the activity is rebuilt after a config change with a + // stale appearance. The call is cheap and idempotent either way + WindowCompat.getInsetsController(window, window.decorView).apply { + isAppearanceLightStatusBars = !darkMode + isAppearanceLightNavigationBars = !darkMode + } + } + + private fun dispatchAndroidBack() { + val script = "window.dispatchEvent(new CustomEvent('risuko-android-back'))" + appWebView?.post { + appWebView?.evaluateJavascript(script, null) + } + } + + private fun ensureNotificationPermission() { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) { + return + } + if (ContextCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS) == PackageManager.PERMISSION_GRANTED) { + return + } + if (requestedNotificationPermission) { + return + } + requestedNotificationPermission = true + ActivityCompat.requestPermissions(this, arrayOf(Manifest.permission.POST_NOTIFICATIONS), 4738) + } + + companion object { + private var current: MainActivity? = null + + @JvmStatic + fun pickDirectory(requestId: String): Boolean { + val activity = current ?: return false + activity.runOnUiThread { + try { + activity.pendingDirectoryRequestId = requestId + activity.directoryPicker.launch(null) + } catch (_: Exception) { + activity.pendingDirectoryRequestId = null + activity.nativeOnDirectoryPicked(requestId, null) + } + } + return true + } + + @JvmStatic + fun requestAllFilesAccess(): Boolean { + val activity = current ?: return false + if (activity.hasExternalStorageAccess()) { + return true + } + activity.runOnUiThread { + activity.requestExternalStorageAccess() + } + return false + } + + @JvmStatic + fun setSystemBars(darkMode: Boolean) { + current?.runOnUiThread { + current?.setSystemBarsForTheme(darkMode) + } + } + + @JvmStatic + fun showDownloadNotification(progress: Int, activeCount: Int, detail: String) { + current?.runOnUiThread { + val activity = current ?: return@runOnUiThread + activity.ensureNotificationPermission() + RisukoForegroundService.show(activity, progress, activeCount, detail) + } + } + + @JvmStatic + fun hideDownloadNotification() { + current?.runOnUiThread { + val activity = current ?: return@runOnUiThread + RisukoForegroundService.hide(activity) + } + } + + // Opens `path` in a file manager and reports the outcome back so the + // caller can surface a useful error string. Returns "ok" on success or + // a diagnostic string otherwise. We try three intent shapes because no + // single one works on every device: + // 1. ACTION_VIEW with `vnd.android.document/directory` MIME wrapped + // in a chooser. Files by Google and AOSP DocumentsUI both claim + // this + // 2. Same intent without the chooser, so the system can launch the + // default handler directly when only one app matches + // 3. ACTION_VIEW with the URI alone, letting the documents provider + // infer the MIME. Broader compatibility at the cost of pulling in + // generic handlers + // If one variant throws, we move on to the next. We only give up and + // report back to Rust once all three fail + @JvmStatic + fun revealFolder(path: String): String { + val activity = current ?: return "no_activity" + val resultRef = java.util.concurrent.atomic.AtomicReference("ok") + val latch = CountDownLatch(1) + activity.runOnUiThread { + try { + resultRef.set(tryRevealFolder(activity, path)) + } catch (e: Throwable) { + Log.w(REVEAL_TAG, "revealFolder threw for path=$path", e) + resultRef.set("error: ${e.javaClass.simpleName}: ${e.message ?: "(no message)"}") + } + latch.countDown() + } + val completed = try { + latch.await(5, TimeUnit.SECONDS) + } catch (_: InterruptedException) { + Thread.currentThread().interrupt() + return "interrupted" + } + if (!completed) { + return "timeout" + } + return resultRef.get() + } + + private const val REVEAL_TAG = "RisukoReveal" + private const val OPEN_TAG = "RisukoOpen" + + @JvmStatic + fun openFile(path: String, mime: String): String { + val activity = current ?: return "no_activity" + val resultRef = java.util.concurrent.atomic.AtomicReference("ok") + val latch = CountDownLatch(1) + activity.runOnUiThread { + try { + resultRef.set(tryOpenFile(activity, path, mime)) + } catch (e: Throwable) { + Log.w(OPEN_TAG, "openFile threw for path=$path mime=$mime", e) + resultRef.set("error: ${e.javaClass.simpleName}: ${e.message ?: "(no message)"}") + } + latch.countDown() + } + val completed = try { + latch.await(5, TimeUnit.SECONDS) + } catch (_: InterruptedException) { + Thread.currentThread().interrupt() + return "interrupted" + } + if (!completed) { + return "timeout" + } + return resultRef.get() + } + + private fun tryOpenFile(activity: MainActivity, path: String, mime: String): String { + if (path.isBlank()) { + return "empty_path" + } + val uri = buildOpenFileUri(activity, path) + val normalizedMime = mime.ifBlank { "*/*" } + val grantRead = uri.scheme != "http" && uri.scheme != "https" + val newViewIntent: (String) -> Intent = { targetMime -> + Intent(Intent.ACTION_VIEW).apply { + setDataAndType(uri, targetMime) + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + if (grantRead) { + addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + } + } + } + data class Attempt( + val label: String, + val probe: Intent, + val launchFactory: () -> Intent, + ) + val attempts = mutableListOf() + fun addAttempts(label: String, targetMime: String) { + attempts += Attempt( + label = "$label+chooser", + probe = newViewIntent(targetMime), + launchFactory = { + Intent.createChooser(newViewIntent(targetMime), "Open file with").apply { + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + if (grantRead) { + addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + } + } + }, + ) + attempts += Attempt( + label = "$label+direct", + probe = newViewIntent(targetMime), + launchFactory = { newViewIntent(targetMime) }, + ) + } + addAttempts("mime:$normalizedMime", normalizedMime) + if (normalizedMime != "*/*") { + addAttempts("mime:*/*", "*/*") + } + val errors = mutableListOf() + for (attempt in attempts) { + val resolved = activity.packageManager.queryIntentActivities(attempt.probe, 0) + if (resolved.isEmpty()) { + Log.w(OPEN_TAG, "no handler for ${attempt.label} path=$path uri=$uri mime=$mime") + errors.add("${attempt.label}: no_handler") + continue + } + try { + activity.startActivity(attempt.launchFactory()) + Log.i(OPEN_TAG, "openFile ${attempt.label} succeeded path=$path uri=$uri resolved=${resolved.size}") + return "ok" + } catch (e: ActivityNotFoundException) { + Log.w(OPEN_TAG, "${attempt.label} dispatch failed: ActivityNotFoundException", e) + errors.add("${attempt.label}: ActivityNotFoundException") + } catch (e: SecurityException) { + Log.w(OPEN_TAG, "${attempt.label} dispatch failed: SecurityException", e) + errors.add("${attempt.label}: SecurityException: ${e.message ?: "(no message)"}") + } catch (e: Throwable) { + Log.w(OPEN_TAG, "${attempt.label} dispatch failed: ${e.javaClass.simpleName}", e) + errors.add("${attempt.label}: ${e.javaClass.simpleName}: ${e.message ?: "(no message)"}") + } + } + Log.w(OPEN_TAG, "openFile exhausted all attempts for path=$path uri=$uri mime=$mime: $errors") + return errors.joinToString("; ") + } + + private fun buildOpenFileUri(activity: MainActivity, path: String): Uri { + 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)) + } + + private fun tryRevealFolder(activity: MainActivity, path: String): String { + if (path.isBlank()) { + return "empty_path" + } + val docId = buildExternalStorageDocId(path) + if (docId == null) { + Log.w(REVEAL_TAG, "buildExternalStorageDocId returned null for path=$path") + return "invalid_path:$path" + } + val authority = "com.android.externalstorage.documents" + val docUri = try { + DocumentsContract.buildDocumentUri(authority, docId) + } catch (e: Throwable) { + Log.w(REVEAL_TAG, "buildDocumentUri failed for docId=$docId", e) + return "uri_error:$docId" + } + val treeUri = try { + DocumentsContract.buildTreeDocumentUri(authority, docId) + } catch (e: Throwable) { + Log.w(REVEAL_TAG, "buildTreeDocumentUri failed for docId=$docId", e) + null + } + val treeDocUri = treeUri?.let { tree -> + try { + DocumentsContract.buildDocumentUriUsingTree(tree, docId) + } catch (e: Throwable) { + Log.w(REVEAL_TAG, "buildDocumentUriUsingTree failed for docId=$docId", e) + null + } + } + Log.i(REVEAL_TAG, "revealFolder path=$path docUri=$docUri treeUri=$treeUri treeDocUri=$treeDocUri") + + // We deliberately do NOT add FLAG_GRANT_READ_URI_PERMISSION here. + // Adding it makes the system enforce that the calling app already + // has permission on the URI, which we never asked for: the only + // SAF grant we hold is for the picker-selected download root, not + // this arbitrary subfolder. Files by Google and AOSP DocumentsUI + // both query the documents provider with their own credentials, + // so dropping the grant flag lets them resolve the URI on their + // own instead of failing with a SecurityException at startActivity + val dirMime = DocumentsContract.Document.MIME_TYPE_DIR + val newViewIntent: (uri: Uri, mime: String?) -> Intent = { target, mime -> + Intent(Intent.ACTION_VIEW).apply { + if (mime != null) { + setDataAndType(target, mime) + } else { + setData(target) + } + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + } + } + // Each attempt has two halves: + // - probe: the ACTION_VIEW intent we run `queryIntentActivities` + // against to decide if there's a real handler. We always probe + // the inner ACTION_VIEW; querying `Intent.ACTION_CHOOSER` would + // just return the system ChooserActivity and tell us nothing + // - launchFactory: builds the intent we actually `startActivity` + // on. For the chooser variant we wrap a fresh ACTION_VIEW in + // `Intent.createChooser`, which lets the user pick between + // multiple file managers and bypass any "always open with" + // default the system has stored + data class Attempt( + val label: String, + val probe: Intent, + val launchFactory: () -> Intent, + ) + val attempts = mutableListOf() + if (treeDocUri != null) { + attempts += Attempt( + label = "tree-doc+dirmime", + probe = newViewIntent(treeDocUri, dirMime), + launchFactory = { newViewIntent(treeDocUri, dirMime) }, + ) + attempts += Attempt( + label = "tree-doc+nomime", + probe = newViewIntent(treeDocUri, null), + launchFactory = { newViewIntent(treeDocUri, null) }, + ) + } + attempts += Attempt( + label = "doc+dirmime", + probe = newViewIntent(docUri, dirMime), + launchFactory = { newViewIntent(docUri, dirMime) }, + ) + attempts += Attempt( + label = "chooser+doc+dirmime", + probe = newViewIntent(docUri, dirMime), + launchFactory = { + Intent.createChooser(newViewIntent(docUri, dirMime), "Open folder with").apply { + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + } + }, + ) + attempts += Attempt( + label = "doc+nomime", + probe = newViewIntent(docUri, null), + launchFactory = { newViewIntent(docUri, null) }, + ) + + val errors = mutableListOf() + for (attempt in attempts) { + val resolved = activity.packageManager.queryIntentActivities(attempt.probe, 0) + if (resolved.isEmpty()) { + Log.w(REVEAL_TAG, "no handler for ${attempt.label} intent for path=$path") + errors.add("${attempt.label}: no_handler") + continue + } + try { + activity.startActivity(attempt.launchFactory()) + Log.i(REVEAL_TAG, "revealFolder ${attempt.label} succeeded (resolved=${resolved.size})") + return "ok" + } catch (e: ActivityNotFoundException) { + Log.w(REVEAL_TAG, "${attempt.label} dispatch failed: ActivityNotFoundException", e) + errors.add("${attempt.label}: ActivityNotFoundException") + } catch (e: SecurityException) { + Log.w(REVEAL_TAG, "${attempt.label} dispatch failed: SecurityException", e) + errors.add("${attempt.label}: SecurityException: ${e.message ?: "(no message)"}") + } catch (e: Throwable) { + Log.w(REVEAL_TAG, "${attempt.label} dispatch failed: ${e.javaClass.simpleName}", e) + errors.add("${attempt.label}: ${e.javaClass.simpleName}: ${e.message ?: "(no message)"}") + } + } + + Log.w(REVEAL_TAG, "revealFolder exhausted all attempts for path=$path: $errors") + return errors.joinToString("; ") + } + + private fun buildExternalStorageDocId(path: String): String? { + val primaryPrefix = "/storage/emulated/0/" + return when { + path == "/storage/emulated/0" || path == "/storage/emulated/0/" -> "primary:" + path.startsWith(primaryPrefix) -> { + val rel = path.removePrefix(primaryPrefix).trimEnd('/') + if (rel.isEmpty()) "primary:" else "primary:$rel" + } + path.startsWith("/storage/") -> { + val rest = path.removePrefix("/storage/").trimEnd('/') + val slash = rest.indexOf('/') + if (slash < 0) { + "$rest:" + } else { + val volume = rest.substring(0, slash) + val rel = rest.substring(slash + 1) + if (rel.isEmpty()) "$volume:" else "$volume:$rel" + } + } + else -> null + } + } + } +} diff --git a/src-tauri/gen/android/app/src/main/java/app/risuko/mobile/RisukoForegroundService.kt b/src-tauri/gen/android/app/src/main/java/app/risuko/mobile/RisukoForegroundService.kt new file mode 100644 index 00000000..beb3d70f --- /dev/null +++ b/src-tauri/gen/android/app/src/main/java/app/risuko/mobile/RisukoForegroundService.kt @@ -0,0 +1,111 @@ +package app.risuko.mobile + +import android.app.Notification +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.PendingIntent +import android.app.Service +import android.content.Context +import android.content.Intent +import android.os.Build +import android.os.IBinder +import androidx.core.app.NotificationCompat + +class RisukoForegroundService : Service() { + override fun onBind(intent: Intent?): IBinder? = null + + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + ensureChannel(this) + val progress = intent?.getIntExtra(EXTRA_PROGRESS, 0)?.coerceIn(0, 100) ?: 0 + val activeCount = intent?.getIntExtra(EXTRA_ACTIVE_COUNT, 0)?.coerceAtLeast(0) ?: 0 + val detail = intent?.getStringExtra(EXTRA_DETAIL).orEmpty() + + if (activeCount <= 0) { + stopForeground(STOP_FOREGROUND_REMOVE) + stopSelf() + return START_NOT_STICKY + } + + startForeground(NOTIFICATION_ID, buildNotification(this, progress, activeCount, detail)) + return START_STICKY + } + + companion object { + private const val CHANNEL_ID = "risuko_downloads" + private const val CHANNEL_NAME = "Downloads" + private const val NOTIFICATION_ID = 4737 + private const val EXTRA_PROGRESS = "progress" + private const val EXTRA_ACTIVE_COUNT = "activeCount" + private const val EXTRA_DETAIL = "detail" + + fun show(context: Context, progress: Int, activeCount: Int, detail: String) { + val intent = Intent(context, RisukoForegroundService::class.java).apply { + putExtra(EXTRA_PROGRESS, progress.coerceIn(0, 100)) + putExtra(EXTRA_ACTIVE_COUNT, activeCount.coerceAtLeast(0)) + putExtra(EXTRA_DETAIL, detail) + } + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + context.startForegroundService(intent) + } else { + context.startService(intent) + } + } + + fun hide(context: Context) { + context.stopService(Intent(context, RisukoForegroundService::class.java)) + } + + private fun ensureChannel(context: Context) { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) { + return + } + val manager = context.getSystemService(NotificationManager::class.java) + val existing = manager.getNotificationChannel(CHANNEL_ID) + if (existing != null) { + return + } + val channel = NotificationChannel( + CHANNEL_ID, + CHANNEL_NAME, + NotificationManager.IMPORTANCE_LOW, + ).apply { + setShowBadge(false) + } + manager.createNotificationChannel(channel) + } + + private fun buildNotification( + context: Context, + progress: Int, + activeCount: Int, + detail: String, + ): Notification { + val launchIntent = context.packageManager.getLaunchIntentForPackage(context.packageName) + ?: Intent(context, MainActivity::class.java) + val pendingIntent = PendingIntent.getActivity( + context, + 0, + launchIntent, + PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT, + ) + val title = if (activeCount == 1) { + "1 active download" + } else { + "$activeCount active downloads" + } + val text = detail.ifBlank { "$progress% complete" } + + return NotificationCompat.Builder(context, CHANNEL_ID) + .setSmallIcon(R.drawable.ic_notification) + .setContentTitle(title) + .setContentText(text) + .setContentIntent(pendingIntent) + .setOngoing(true) + .setOnlyAlertOnce(true) + .setShowWhen(false) + .setProgress(100, progress.coerceIn(0, 100), false) + .setPriority(NotificationCompat.PRIORITY_LOW) + .build() + } + } +} diff --git a/src-tauri/gen/android/app/src/main/res/drawable-v24/ic_launcher_foreground.xml b/src-tauri/gen/android/app/src/main/res/drawable-v24/ic_launcher_foreground.xml new file mode 100644 index 00000000..2b068d11 --- /dev/null +++ b/src-tauri/gen/android/app/src/main/res/drawable-v24/ic_launcher_foreground.xml @@ -0,0 +1,30 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/src-tauri/gen/android/app/src/main/res/drawable/empty_splash_icon.xml b/src-tauri/gen/android/app/src/main/res/drawable/empty_splash_icon.xml new file mode 100644 index 00000000..4ad30eb4 --- /dev/null +++ b/src-tauri/gen/android/app/src/main/res/drawable/empty_splash_icon.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/src-tauri/gen/android/app/src/main/res/drawable/ic_launcher_background.xml b/src-tauri/gen/android/app/src/main/res/drawable/ic_launcher_background.xml new file mode 100644 index 00000000..07d5da9c --- /dev/null +++ b/src-tauri/gen/android/app/src/main/res/drawable/ic_launcher_background.xml @@ -0,0 +1,170 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src-tauri/gen/android/app/src/main/res/drawable/ic_notification.xml b/src-tauri/gen/android/app/src/main/res/drawable/ic_notification.xml new file mode 100644 index 00000000..238de351 --- /dev/null +++ b/src-tauri/gen/android/app/src/main/res/drawable/ic_notification.xml @@ -0,0 +1,10 @@ + + + + diff --git a/src-tauri/gen/android/app/src/main/res/layout/activity_main.xml b/src-tauri/gen/android/app/src/main/res/layout/activity_main.xml new file mode 100644 index 00000000..4fc24441 --- /dev/null +++ b/src-tauri/gen/android/app/src/main/res/layout/activity_main.xml @@ -0,0 +1,18 @@ + + + + + + \ No newline at end of file diff --git a/src-tauri/gen/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/src-tauri/gen/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 00000000..2ffbf24b --- /dev/null +++ b/src-tauri/gen/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/src-tauri/gen/android/app/src/main/res/mipmap-hdpi/ic_launcher.png b/src-tauri/gen/android/app/src/main/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 00000000..98aec124 Binary files /dev/null and b/src-tauri/gen/android/app/src/main/res/mipmap-hdpi/ic_launcher.png differ diff --git a/src-tauri/gen/android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png b/src-tauri/gen/android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png new file mode 100644 index 00000000..98aec124 Binary files /dev/null and b/src-tauri/gen/android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png differ diff --git a/src-tauri/gen/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png b/src-tauri/gen/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png new file mode 100644 index 00000000..98aec124 Binary files /dev/null and b/src-tauri/gen/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png differ diff --git a/src-tauri/gen/android/app/src/main/res/mipmap-mdpi/ic_launcher.png b/src-tauri/gen/android/app/src/main/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 00000000..73847fd9 Binary files /dev/null and b/src-tauri/gen/android/app/src/main/res/mipmap-mdpi/ic_launcher.png differ diff --git a/src-tauri/gen/android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png b/src-tauri/gen/android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png new file mode 100644 index 00000000..73847fd9 Binary files /dev/null and b/src-tauri/gen/android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png differ diff --git a/src-tauri/gen/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png b/src-tauri/gen/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png new file mode 100644 index 00000000..73847fd9 Binary files /dev/null and b/src-tauri/gen/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png differ diff --git a/src-tauri/gen/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/src-tauri/gen/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 00000000..5e95e2a7 Binary files /dev/null and b/src-tauri/gen/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/src-tauri/gen/android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png b/src-tauri/gen/android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png new file mode 100644 index 00000000..5e95e2a7 Binary files /dev/null and b/src-tauri/gen/android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png differ diff --git a/src-tauri/gen/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png b/src-tauri/gen/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png new file mode 100644 index 00000000..5e95e2a7 Binary files /dev/null and b/src-tauri/gen/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png differ diff --git a/src-tauri/gen/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/src-tauri/gen/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 00000000..16773d7d Binary files /dev/null and b/src-tauri/gen/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/src-tauri/gen/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png b/src-tauri/gen/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png new file mode 100644 index 00000000..16773d7d Binary files /dev/null and b/src-tauri/gen/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png differ diff --git a/src-tauri/gen/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png b/src-tauri/gen/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png new file mode 100644 index 00000000..16773d7d Binary files /dev/null and b/src-tauri/gen/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png differ diff --git a/src-tauri/gen/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/src-tauri/gen/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 00000000..05a1bf2c Binary files /dev/null and b/src-tauri/gen/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/src-tauri/gen/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png b/src-tauri/gen/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png new file mode 100644 index 00000000..05a1bf2c Binary files /dev/null and b/src-tauri/gen/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png differ diff --git a/src-tauri/gen/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png b/src-tauri/gen/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png new file mode 100644 index 00000000..05a1bf2c Binary files /dev/null and b/src-tauri/gen/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png differ diff --git a/src-tauri/gen/android/app/src/main/res/values-night/themes.xml b/src-tauri/gen/android/app/src/main/res/values-night/themes.xml new file mode 100644 index 00000000..60e3bcd1 --- /dev/null +++ b/src-tauri/gen/android/app/src/main/res/values-night/themes.xml @@ -0,0 +1,6 @@ + + + + diff --git a/src-tauri/gen/android/app/src/main/res/values-v31/themes.xml b/src-tauri/gen/android/app/src/main/res/values-v31/themes.xml new file mode 100644 index 00000000..5fefdde6 --- /dev/null +++ b/src-tauri/gen/android/app/src/main/res/values-v31/themes.xml @@ -0,0 +1,7 @@ + + + diff --git a/src-tauri/gen/android/app/src/main/res/values/colors.xml b/src-tauri/gen/android/app/src/main/res/values/colors.xml new file mode 100644 index 00000000..f8c6127d --- /dev/null +++ b/src-tauri/gen/android/app/src/main/res/values/colors.xml @@ -0,0 +1,10 @@ + + + #FFBB86FC + #FF6200EE + #FF3700B3 + #FF03DAC5 + #FF018786 + #FF000000 + #FFFFFFFF + \ No newline at end of file diff --git a/src-tauri/gen/android/app/src/main/res/values/ic_launcher_background.xml b/src-tauri/gen/android/app/src/main/res/values/ic_launcher_background.xml new file mode 100644 index 00000000..ea9c223a --- /dev/null +++ b/src-tauri/gen/android/app/src/main/res/values/ic_launcher_background.xml @@ -0,0 +1,4 @@ + + + #fff + \ No newline at end of file diff --git a/src-tauri/gen/android/app/src/main/res/values/strings.xml b/src-tauri/gen/android/app/src/main/res/values/strings.xml new file mode 100644 index 00000000..204ffb59 --- /dev/null +++ b/src-tauri/gen/android/app/src/main/res/values/strings.xml @@ -0,0 +1,4 @@ + + Risuko + Risuko + \ No newline at end of file diff --git a/src-tauri/gen/android/app/src/main/res/values/themes.xml b/src-tauri/gen/android/app/src/main/res/values/themes.xml new file mode 100644 index 00000000..60e3bcd1 --- /dev/null +++ b/src-tauri/gen/android/app/src/main/res/values/themes.xml @@ -0,0 +1,6 @@ + + + + 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 new file mode 100644 index 00000000..782d63b9 --- /dev/null +++ b/src-tauri/gen/android/app/src/main/res/xml/file_paths.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/src-tauri/gen/android/build.gradle.kts b/src-tauri/gen/android/build.gradle.kts new file mode 100644 index 00000000..607240bc --- /dev/null +++ b/src-tauri/gen/android/build.gradle.kts @@ -0,0 +1,22 @@ +buildscript { + repositories { + google() + mavenCentral() + } + dependencies { + classpath("com.android.tools.build:gradle:8.11.0") + classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:1.9.25") + } +} + +allprojects { + repositories { + google() + mavenCentral() + } +} + +tasks.register("clean").configure { + delete("build") +} + diff --git a/src-tauri/gen/android/buildSrc/build.gradle.kts b/src-tauri/gen/android/buildSrc/build.gradle.kts new file mode 100644 index 00000000..5c55bba7 --- /dev/null +++ b/src-tauri/gen/android/buildSrc/build.gradle.kts @@ -0,0 +1,23 @@ +plugins { + `kotlin-dsl` +} + +gradlePlugin { + plugins { + create("pluginsForCoolKids") { + id = "rust" + implementationClass = "RustPlugin" + } + } +} + +repositories { + google() + mavenCentral() +} + +dependencies { + compileOnly(gradleApi()) + implementation("com.android.tools.build:gradle:8.11.0") +} + diff --git a/src-tauri/gen/android/buildSrc/src/main/java/app/risuko/mobile/kotlin/BuildTask.kt b/src-tauri/gen/android/buildSrc/src/main/java/app/risuko/mobile/kotlin/BuildTask.kt new file mode 100644 index 00000000..f764e2ad --- /dev/null +++ b/src-tauri/gen/android/buildSrc/src/main/java/app/risuko/mobile/kotlin/BuildTask.kt @@ -0,0 +1,68 @@ +import java.io.File +import org.apache.tools.ant.taskdefs.condition.Os +import org.gradle.api.DefaultTask +import org.gradle.api.GradleException +import org.gradle.api.logging.LogLevel +import org.gradle.api.tasks.Input +import org.gradle.api.tasks.TaskAction + +open class BuildTask : DefaultTask() { + @Input + var rootDirRel: String? = null + @Input + var target: String? = null + @Input + var release: Boolean? = null + + @TaskAction + fun assemble() { + val executable = """pnpm"""; + try { + runTauriCli(executable) + } catch (e: Exception) { + if (Os.isFamily(Os.FAMILY_WINDOWS)) { + // Try different Windows-specific extensions + val fallbacks = listOf( + "$executable.exe", + "$executable.cmd", + "$executable.bat", + ) + + var lastException: Exception = e + for (fallback in fallbacks) { + try { + runTauriCli(fallback) + return + } catch (fallbackException: Exception) { + lastException = fallbackException + } + } + throw lastException + } else { + throw e; + } + } + } + + fun runTauriCli(executable: String) { + val rootDirRel = rootDirRel ?: throw GradleException("rootDirRel cannot be null") + val target = target ?: throw GradleException("target cannot be null") + val release = release ?: throw GradleException("release cannot be null") + val args = listOf("tauri", "android", "android-studio-script"); + + project.exec { + workingDir(File(project.projectDir, rootDirRel)) + executable(executable) + args(args) + if (project.logger.isEnabled(LogLevel.DEBUG)) { + args("-vv") + } else if (project.logger.isEnabled(LogLevel.INFO)) { + args("-v") + } + if (release) { + args("--release") + } + args(listOf("--target", target)) + }.assertNormalExitValue() + } +} \ No newline at end of file diff --git a/src-tauri/gen/android/buildSrc/src/main/java/app/risuko/mobile/kotlin/RustPlugin.kt b/src-tauri/gen/android/buildSrc/src/main/java/app/risuko/mobile/kotlin/RustPlugin.kt new file mode 100644 index 00000000..4aa7fcaf --- /dev/null +++ b/src-tauri/gen/android/buildSrc/src/main/java/app/risuko/mobile/kotlin/RustPlugin.kt @@ -0,0 +1,85 @@ +import com.android.build.api.dsl.ApplicationExtension +import org.gradle.api.DefaultTask +import org.gradle.api.Plugin +import org.gradle.api.Project +import org.gradle.kotlin.dsl.configure +import org.gradle.kotlin.dsl.get + +const val TASK_GROUP = "rust" + +open class Config { + lateinit var rootDirRel: String +} + +open class RustPlugin : Plugin { + private lateinit var config: Config + + override fun apply(project: Project) = with(project) { + config = extensions.create("rust", Config::class.java) + + val defaultAbiList = listOf("arm64-v8a", "armeabi-v7a", "x86", "x86_64"); + val abiList = (findProperty("abiList") as? String)?.split(',') ?: defaultAbiList + + val defaultArchList = listOf("arm64", "arm", "x86", "x86_64"); + val archList = (findProperty("archList") as? String)?.split(',') ?: defaultArchList + + val targetsList = (findProperty("targetList") as? String)?.split(',') ?: listOf("aarch64", "armv7", "i686", "x86_64") + + extensions.configure { + @Suppress("UnstableApiUsage") + flavorDimensions.add("abi") + productFlavors { + create("universal") { + dimension = "abi" + ndk { + abiFilters += abiList + } + } + defaultArchList.forEachIndexed { index, arch -> + create(arch) { + dimension = "abi" + ndk { + abiFilters.add(defaultAbiList[index]) + } + } + } + } + } + + afterEvaluate { + for (profile in listOf("debug", "release")) { + val profileCapitalized = profile.replaceFirstChar { it.uppercase() } + val buildTask = tasks.maybeCreate( + "rustBuildUniversal$profileCapitalized", + DefaultTask::class.java + ).apply { + group = TASK_GROUP + description = "Build dynamic library in $profile mode for all targets" + } + + tasks["mergeUniversal${profileCapitalized}JniLibFolders"].dependsOn(buildTask) + + for (targetPair in targetsList.withIndex()) { + val targetName = targetPair.value + val targetArch = archList[targetPair.index] + val targetArchCapitalized = targetArch.replaceFirstChar { it.uppercase() } + val targetBuildTask = project.tasks.maybeCreate( + "rustBuild$targetArchCapitalized$profileCapitalized", + BuildTask::class.java + ).apply { + group = TASK_GROUP + description = "Build dynamic library in $profile mode for $targetArch" + rootDirRel = config.rootDirRel + target = targetName + release = profile == "release" + } + + buildTask.dependsOn(targetBuildTask) + tasks["merge$targetArchCapitalized${profileCapitalized}JniLibFolders"].dependsOn( + targetBuildTask + ) + } + } + } + } +} \ No newline at end of file diff --git a/src-tauri/gen/android/gradle.properties b/src-tauri/gen/android/gradle.properties new file mode 100644 index 00000000..2a7ec695 --- /dev/null +++ b/src-tauri/gen/android/gradle.properties @@ -0,0 +1,24 @@ +# Project-wide Gradle settings. +# IDE (e.g. Android Studio) users: +# Gradle settings configured through the IDE *will override* +# any settings specified in this file. +# For more details on how to configure your build environment visit +# http://www.gradle.org/docs/current/userguide/build_environment.html +# Specifies the JVM arguments used for the daemon process. +# The setting is particularly useful for tweaking memory settings. +org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8 +# When configured, Gradle will run in incubating parallel mode. +# This option should only be used with decoupled projects. More details, visit +# http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects +# org.gradle.parallel=true +# AndroidX package structure to make it clearer which packages are bundled with the +# Android operating system, and which are packaged with your app"s APK +# https://developer.android.com/topic/libraries/support-library/androidx-rn +android.useAndroidX=true +# Kotlin code style for this project: "official" or "obsolete": +kotlin.code.style=official +# Enables namespacing of each library's R class so that its R class includes only the +# resources declared in the library itself and none from the library's dependencies, +# thereby reducing the size of the R class for that library +android.nonTransitiveRClass=true +android.nonFinalResIds=false \ No newline at end of file diff --git a/src-tauri/gen/android/gradle/wrapper/gradle-wrapper.jar b/src-tauri/gen/android/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 00000000..e708b1c0 Binary files /dev/null and b/src-tauri/gen/android/gradle/wrapper/gradle-wrapper.jar differ diff --git a/src-tauri/gen/android/gradle/wrapper/gradle-wrapper.properties b/src-tauri/gen/android/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 00000000..c5f9a53c --- /dev/null +++ b/src-tauri/gen/android/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,6 @@ +#Tue May 10 19:22:52 CST 2022 +distributionBase=GRADLE_USER_HOME +distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.3-bin.zip +distributionPath=wrapper/dists +zipStorePath=wrapper/dists +zipStoreBase=GRADLE_USER_HOME diff --git a/src-tauri/gen/android/gradlew b/src-tauri/gen/android/gradlew new file mode 100755 index 00000000..4f906e0c --- /dev/null +++ b/src-tauri/gen/android/gradlew @@ -0,0 +1,185 @@ +#!/usr/bin/env sh + +# +# Copyright 2015 the original author or authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +############################################################################## +## +## Gradle start up script for UN*X +## +############################################################################## + +# Attempt to set APP_HOME +# Resolve links: $0 may be a link +PRG="$0" +# Need this for relative symlinks. +while [ -h "$PRG" ] ; do + ls=`ls -ld "$PRG"` + link=`expr "$ls" : '.*-> \(.*\)$'` + if expr "$link" : '/.*' > /dev/null; then + PRG="$link" + else + PRG=`dirname "$PRG"`"/$link" + fi +done +SAVED="`pwd`" +cd "`dirname \"$PRG\"`/" >/dev/null +APP_HOME="`pwd -P`" +cd "$SAVED" >/dev/null + +APP_NAME="Gradle" +APP_BASE_NAME=`basename "$0"` + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD="maximum" + +warn () { + echo "$*" +} + +die () { + echo + echo "$*" + echo + exit 1 +} + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "`uname`" in + CYGWIN* ) + cygwin=true + ;; + Darwin* ) + darwin=true + ;; + MINGW* ) + msys=true + ;; + NONSTOP* ) + nonstop=true + ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD="$JAVA_HOME/jre/sh/java" + else + JAVACMD="$JAVA_HOME/bin/java" + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD="java" + which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." +fi + +# Increase the maximum file descriptors if we can. +if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then + MAX_FD_LIMIT=`ulimit -H -n` + if [ $? -eq 0 ] ; then + if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then + MAX_FD="$MAX_FD_LIMIT" + fi + ulimit -n $MAX_FD + if [ $? -ne 0 ] ; then + warn "Could not set maximum file descriptor limit: $MAX_FD" + fi + else + warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" + fi +fi + +# For Darwin, add options to specify how the application appears in the dock +if $darwin; then + GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" +fi + +# For Cygwin or MSYS, switch paths to Windows format before running java +if [ "$cygwin" = "true" -o "$msys" = "true" ] ; then + APP_HOME=`cygpath --path --mixed "$APP_HOME"` + CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` + + JAVACMD=`cygpath --unix "$JAVACMD"` + + # We build the pattern for arguments to be converted via cygpath + ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null` + SEP="" + for dir in $ROOTDIRSRAW ; do + ROOTDIRS="$ROOTDIRS$SEP$dir" + SEP="|" + done + OURCYGPATTERN="(^($ROOTDIRS))" + # Add a user-defined pattern to the cygpath arguments + if [ "$GRADLE_CYGPATTERN" != "" ] ; then + OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)" + fi + # Now convert the arguments - kludge to limit ourselves to /bin/sh + i=0 + for arg in "$@" ; do + CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -` + CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option + + if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition + eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"` + else + eval `echo args$i`="\"$arg\"" + fi + i=`expr $i + 1` + done + case $i in + 0) set -- ;; + 1) set -- "$args0" ;; + 2) set -- "$args0" "$args1" ;; + 3) set -- "$args0" "$args1" "$args2" ;; + 4) set -- "$args0" "$args1" "$args2" "$args3" ;; + 5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; + 6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; + 7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; + 8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; + 9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; + esac +fi + +# Escape application args +save () { + for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done + echo " " +} +APP_ARGS=`save "$@"` + +# Collect all arguments for the java command, following the shell quoting and substitution rules +eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS" + +exec "$JAVACMD" "$@" diff --git a/src-tauri/gen/android/gradlew.bat b/src-tauri/gen/android/gradlew.bat new file mode 100644 index 00000000..107acd32 --- /dev/null +++ b/src-tauri/gen/android/gradlew.bat @@ -0,0 +1,89 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem + +@if "%DEBUG%" == "" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%" == "" set DIRNAME=. +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if "%ERRORLEVEL%" == "0" goto execute + +echo. +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* + +:end +@rem End local scope for the variables with windows NT shell +if "%ERRORLEVEL%"=="0" goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 +exit /b 1 + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/src-tauri/gen/android/settings.gradle b/src-tauri/gen/android/settings.gradle new file mode 100644 index 00000000..39391166 --- /dev/null +++ b/src-tauri/gen/android/settings.gradle @@ -0,0 +1,3 @@ +include ':app' + +apply from: 'tauri.settings.gradle' diff --git a/src-tauri/icons/128x128.png b/src-tauri/icons/128x128.png index 1b789827..51f0f97b 100644 Binary files a/src-tauri/icons/128x128.png and b/src-tauri/icons/128x128.png differ diff --git a/src-tauri/icons/128x128@2x.png b/src-tauri/icons/128x128@2x.png index d42a0af6..3333630a 100644 Binary files a/src-tauri/icons/128x128@2x.png and b/src-tauri/icons/128x128@2x.png differ diff --git a/src-tauri/icons/32x32.png b/src-tauri/icons/32x32.png index 4ac821ba..c6fa8ac2 100644 Binary files a/src-tauri/icons/32x32.png and b/src-tauri/icons/32x32.png differ diff --git a/src-tauri/icons/64x64.png b/src-tauri/icons/64x64.png index 86640d70..153e63cb 100644 Binary files a/src-tauri/icons/64x64.png and b/src-tauri/icons/64x64.png differ diff --git a/src-tauri/icons/Square107x107Logo.png b/src-tauri/icons/Square107x107Logo.png index e9fbf229..02ddaa00 100644 Binary files a/src-tauri/icons/Square107x107Logo.png and b/src-tauri/icons/Square107x107Logo.png differ diff --git a/src-tauri/icons/Square142x142Logo.png b/src-tauri/icons/Square142x142Logo.png index daa3deb4..c2f03379 100644 Binary files a/src-tauri/icons/Square142x142Logo.png and b/src-tauri/icons/Square142x142Logo.png differ diff --git a/src-tauri/icons/Square150x150Logo.png b/src-tauri/icons/Square150x150Logo.png index 7464c2bc..4dfd6ca1 100644 Binary files a/src-tauri/icons/Square150x150Logo.png and b/src-tauri/icons/Square150x150Logo.png differ diff --git a/src-tauri/icons/Square284x284Logo.png b/src-tauri/icons/Square284x284Logo.png index 0493e7ba..46995803 100644 Binary files a/src-tauri/icons/Square284x284Logo.png and b/src-tauri/icons/Square284x284Logo.png differ diff --git a/src-tauri/icons/Square30x30Logo.png b/src-tauri/icons/Square30x30Logo.png index 0fa60fc9..58a149cf 100644 Binary files a/src-tauri/icons/Square30x30Logo.png and b/src-tauri/icons/Square30x30Logo.png differ diff --git a/src-tauri/icons/Square310x310Logo.png b/src-tauri/icons/Square310x310Logo.png index 35409a19..02fb9194 100644 Binary files a/src-tauri/icons/Square310x310Logo.png and b/src-tauri/icons/Square310x310Logo.png differ diff --git a/src-tauri/icons/Square44x44Logo.png b/src-tauri/icons/Square44x44Logo.png index 14d1f91e..8348f6f9 100644 Binary files a/src-tauri/icons/Square44x44Logo.png and b/src-tauri/icons/Square44x44Logo.png differ diff --git a/src-tauri/icons/Square71x71Logo.png b/src-tauri/icons/Square71x71Logo.png index 945994be..f5620878 100644 Binary files a/src-tauri/icons/Square71x71Logo.png and b/src-tauri/icons/Square71x71Logo.png differ diff --git a/src-tauri/icons/Square89x89Logo.png b/src-tauri/icons/Square89x89Logo.png index 58eaf048..4311f1f7 100644 Binary files a/src-tauri/icons/Square89x89Logo.png and b/src-tauri/icons/Square89x89Logo.png differ diff --git a/src-tauri/icons/StoreLogo.png b/src-tauri/icons/StoreLogo.png index b18ebcd6..431526ad 100644 Binary files a/src-tauri/icons/StoreLogo.png and b/src-tauri/icons/StoreLogo.png differ diff --git a/src-tauri/icons/android/mipmap-hdpi/ic_launcher.png b/src-tauri/icons/android/mipmap-hdpi/ic_launcher.png index 4c4f3063..98aec124 100644 Binary files a/src-tauri/icons/android/mipmap-hdpi/ic_launcher.png and b/src-tauri/icons/android/mipmap-hdpi/ic_launcher.png differ diff --git a/src-tauri/icons/android/mipmap-hdpi/ic_launcher_foreground.png b/src-tauri/icons/android/mipmap-hdpi/ic_launcher_foreground.png index 4c4f3063..98aec124 100644 Binary files a/src-tauri/icons/android/mipmap-hdpi/ic_launcher_foreground.png and b/src-tauri/icons/android/mipmap-hdpi/ic_launcher_foreground.png differ diff --git a/src-tauri/icons/android/mipmap-hdpi/ic_launcher_round.png b/src-tauri/icons/android/mipmap-hdpi/ic_launcher_round.png index 4c4f3063..98aec124 100644 Binary files a/src-tauri/icons/android/mipmap-hdpi/ic_launcher_round.png and b/src-tauri/icons/android/mipmap-hdpi/ic_launcher_round.png differ diff --git a/src-tauri/icons/android/mipmap-mdpi/ic_launcher.png b/src-tauri/icons/android/mipmap-mdpi/ic_launcher.png index 87441160..73847fd9 100644 Binary files a/src-tauri/icons/android/mipmap-mdpi/ic_launcher.png and b/src-tauri/icons/android/mipmap-mdpi/ic_launcher.png differ diff --git a/src-tauri/icons/android/mipmap-mdpi/ic_launcher_foreground.png b/src-tauri/icons/android/mipmap-mdpi/ic_launcher_foreground.png index 87441160..73847fd9 100644 Binary files a/src-tauri/icons/android/mipmap-mdpi/ic_launcher_foreground.png and b/src-tauri/icons/android/mipmap-mdpi/ic_launcher_foreground.png differ diff --git a/src-tauri/icons/android/mipmap-mdpi/ic_launcher_round.png b/src-tauri/icons/android/mipmap-mdpi/ic_launcher_round.png index 87441160..73847fd9 100644 Binary files a/src-tauri/icons/android/mipmap-mdpi/ic_launcher_round.png and b/src-tauri/icons/android/mipmap-mdpi/ic_launcher_round.png differ diff --git a/src-tauri/icons/android/mipmap-xhdpi/ic_launcher.png b/src-tauri/icons/android/mipmap-xhdpi/ic_launcher.png index 39e4e0b1..5e95e2a7 100644 Binary files a/src-tauri/icons/android/mipmap-xhdpi/ic_launcher.png and b/src-tauri/icons/android/mipmap-xhdpi/ic_launcher.png differ diff --git a/src-tauri/icons/android/mipmap-xhdpi/ic_launcher_foreground.png b/src-tauri/icons/android/mipmap-xhdpi/ic_launcher_foreground.png index 39e4e0b1..5e95e2a7 100644 Binary files a/src-tauri/icons/android/mipmap-xhdpi/ic_launcher_foreground.png and b/src-tauri/icons/android/mipmap-xhdpi/ic_launcher_foreground.png differ diff --git a/src-tauri/icons/android/mipmap-xhdpi/ic_launcher_round.png b/src-tauri/icons/android/mipmap-xhdpi/ic_launcher_round.png index 39e4e0b1..5e95e2a7 100644 Binary files a/src-tauri/icons/android/mipmap-xhdpi/ic_launcher_round.png and b/src-tauri/icons/android/mipmap-xhdpi/ic_launcher_round.png differ diff --git a/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher.png b/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher.png index 24a4afbe..16773d7d 100644 Binary files a/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher.png and b/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher.png differ diff --git a/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_foreground.png b/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_foreground.png index 24a4afbe..16773d7d 100644 Binary files a/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_foreground.png and b/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_foreground.png differ diff --git a/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_round.png b/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_round.png index 24a4afbe..16773d7d 100644 Binary files a/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_round.png and b/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_round.png differ diff --git a/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher.png b/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher.png index 10466600..05a1bf2c 100644 Binary files a/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher.png and b/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher_foreground.png b/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher_foreground.png index 10466600..05a1bf2c 100644 Binary files a/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher_foreground.png and b/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher_foreground.png differ diff --git a/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher_round.png b/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher_round.png index 10466600..05a1bf2c 100644 Binary files a/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher_round.png and b/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher_round.png differ diff --git a/src-tauri/icons/android/values/ic_launcher_background.xml b/src-tauri/icons/android/values/ic_launcher_background.xml index ea9c223a..e1ed886c 100644 --- a/src-tauri/icons/android/values/ic_launcher_background.xml +++ b/src-tauri/icons/android/values/ic_launcher_background.xml @@ -1,4 +1,4 @@ - #fff + #141218 \ No newline at end of file diff --git a/src-tauri/icons/icon.icns b/src-tauri/icons/icon.icns index 6edb5acd..f43271a9 100644 Binary files a/src-tauri/icons/icon.icns and b/src-tauri/icons/icon.icns differ diff --git a/src-tauri/icons/icon.ico b/src-tauri/icons/icon.ico index 62c2b723..24fbd10f 100644 Binary files a/src-tauri/icons/icon.ico and b/src-tauri/icons/icon.ico differ diff --git a/src-tauri/icons/icon.png b/src-tauri/icons/icon.png index c544ee8a..b82293af 100644 Binary files a/src-tauri/icons/icon.png and b/src-tauri/icons/icon.png differ diff --git a/src-tauri/icons/icon.svg b/src-tauri/icons/icon.svg index c5f9910a..4dc6bdee 100644 --- a/src-tauri/icons/icon.svg +++ b/src-tauri/icons/icon.svg @@ -1,4 +1,5 @@ - + + @@ -6,16 +7,18 @@ - - - - - - - - - - - + + + + + + + + + + + + + diff --git a/src-tauri/icons/ios/AppIcon-20x20@1x.png b/src-tauri/icons/ios/AppIcon-20x20@1x.png index 00ec83a7..709165d5 100644 Binary files a/src-tauri/icons/ios/AppIcon-20x20@1x.png and b/src-tauri/icons/ios/AppIcon-20x20@1x.png differ diff --git a/src-tauri/icons/ios/AppIcon-20x20@2x-1.png b/src-tauri/icons/ios/AppIcon-20x20@2x-1.png index f9575f6c..36610e87 100644 Binary files a/src-tauri/icons/ios/AppIcon-20x20@2x-1.png and b/src-tauri/icons/ios/AppIcon-20x20@2x-1.png differ diff --git a/src-tauri/icons/ios/AppIcon-20x20@2x.png b/src-tauri/icons/ios/AppIcon-20x20@2x.png index f9575f6c..36610e87 100644 Binary files a/src-tauri/icons/ios/AppIcon-20x20@2x.png and b/src-tauri/icons/ios/AppIcon-20x20@2x.png differ diff --git a/src-tauri/icons/ios/AppIcon-20x20@3x.png b/src-tauri/icons/ios/AppIcon-20x20@3x.png index 025d63f2..55031ca6 100644 Binary files a/src-tauri/icons/ios/AppIcon-20x20@3x.png and b/src-tauri/icons/ios/AppIcon-20x20@3x.png differ diff --git a/src-tauri/icons/ios/AppIcon-29x29@1x.png b/src-tauri/icons/ios/AppIcon-29x29@1x.png index ad6c9747..7750739c 100644 Binary files a/src-tauri/icons/ios/AppIcon-29x29@1x.png and b/src-tauri/icons/ios/AppIcon-29x29@1x.png differ diff --git a/src-tauri/icons/ios/AppIcon-29x29@2x-1.png b/src-tauri/icons/ios/AppIcon-29x29@2x-1.png index bd42e32a..0947ad87 100644 Binary files a/src-tauri/icons/ios/AppIcon-29x29@2x-1.png and b/src-tauri/icons/ios/AppIcon-29x29@2x-1.png differ diff --git a/src-tauri/icons/ios/AppIcon-29x29@2x.png b/src-tauri/icons/ios/AppIcon-29x29@2x.png index bd42e32a..0947ad87 100644 Binary files a/src-tauri/icons/ios/AppIcon-29x29@2x.png and b/src-tauri/icons/ios/AppIcon-29x29@2x.png differ diff --git a/src-tauri/icons/ios/AppIcon-29x29@3x.png b/src-tauri/icons/ios/AppIcon-29x29@3x.png index bc6f18a8..09b79681 100644 Binary files a/src-tauri/icons/ios/AppIcon-29x29@3x.png and b/src-tauri/icons/ios/AppIcon-29x29@3x.png differ diff --git a/src-tauri/icons/ios/AppIcon-40x40@1x.png b/src-tauri/icons/ios/AppIcon-40x40@1x.png index f9575f6c..36610e87 100644 Binary files a/src-tauri/icons/ios/AppIcon-40x40@1x.png and b/src-tauri/icons/ios/AppIcon-40x40@1x.png differ diff --git a/src-tauri/icons/ios/AppIcon-40x40@2x-1.png b/src-tauri/icons/ios/AppIcon-40x40@2x-1.png index bcb2e076..6bf0fb14 100644 Binary files a/src-tauri/icons/ios/AppIcon-40x40@2x-1.png and b/src-tauri/icons/ios/AppIcon-40x40@2x-1.png differ diff --git a/src-tauri/icons/ios/AppIcon-40x40@2x.png b/src-tauri/icons/ios/AppIcon-40x40@2x.png index bcb2e076..6bf0fb14 100644 Binary files a/src-tauri/icons/ios/AppIcon-40x40@2x.png and b/src-tauri/icons/ios/AppIcon-40x40@2x.png differ diff --git a/src-tauri/icons/ios/AppIcon-40x40@3x.png b/src-tauri/icons/ios/AppIcon-40x40@3x.png index 0b25baa2..79f8c99d 100644 Binary files a/src-tauri/icons/ios/AppIcon-40x40@3x.png and b/src-tauri/icons/ios/AppIcon-40x40@3x.png differ diff --git a/src-tauri/icons/ios/AppIcon-512@2x.png b/src-tauri/icons/ios/AppIcon-512@2x.png index 33df92fe..88b8f087 100644 Binary files a/src-tauri/icons/ios/AppIcon-512@2x.png and b/src-tauri/icons/ios/AppIcon-512@2x.png differ diff --git a/src-tauri/icons/ios/AppIcon-60x60@2x.png b/src-tauri/icons/ios/AppIcon-60x60@2x.png index 0b25baa2..79f8c99d 100644 Binary files a/src-tauri/icons/ios/AppIcon-60x60@2x.png and b/src-tauri/icons/ios/AppIcon-60x60@2x.png differ diff --git a/src-tauri/icons/ios/AppIcon-60x60@3x.png b/src-tauri/icons/ios/AppIcon-60x60@3x.png index da7fd8c1..22152239 100644 Binary files a/src-tauri/icons/ios/AppIcon-60x60@3x.png and b/src-tauri/icons/ios/AppIcon-60x60@3x.png differ diff --git a/src-tauri/icons/ios/AppIcon-76x76@1x.png b/src-tauri/icons/ios/AppIcon-76x76@1x.png index 82f0bb3d..d23ceb05 100644 Binary files a/src-tauri/icons/ios/AppIcon-76x76@1x.png and b/src-tauri/icons/ios/AppIcon-76x76@1x.png differ diff --git a/src-tauri/icons/ios/AppIcon-76x76@2x.png b/src-tauri/icons/ios/AppIcon-76x76@2x.png index 41bd95ce..14539dcc 100644 Binary files a/src-tauri/icons/ios/AppIcon-76x76@2x.png and b/src-tauri/icons/ios/AppIcon-76x76@2x.png differ diff --git a/src-tauri/icons/ios/AppIcon-83.5x83.5@2x.png b/src-tauri/icons/ios/AppIcon-83.5x83.5@2x.png index b37493d7..d6c81a0d 100644 Binary files a/src-tauri/icons/ios/AppIcon-83.5x83.5@2x.png and b/src-tauri/icons/ios/AppIcon-83.5x83.5@2x.png differ diff --git a/src-tauri/risuko-bt/Cargo.toml b/src-tauri/risuko-bt/Cargo.toml index 351fd62d..4212ff0d 100644 --- a/src-tauri/risuko-bt/Cargo.toml +++ b/src-tauri/risuko-bt/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "risuko-bt" version = "0.1.0" -description = "BitTorrent v1 engine used by Risuko (in-tree replacement for librqbit)" +description = "BitTorrent v1 engine used by Risuko" authors = ["YueMiyuki"] edition = "2021" license = "MIT" diff --git a/src-tauri/risuko-bt/examples/download_magnet.rs b/src-tauri/risuko-bt/examples/download_magnet.rs new file mode 100644 index 00000000..0991615c --- /dev/null +++ b/src-tauri/risuko-bt/examples/download_magnet.rs @@ -0,0 +1,119 @@ +//! Diagnostic: drive a real risuko-bt `Session` download of a magnet and +//! print progress/peer stats periodically. Mirrors how the app downloads. +//! +//! Usage: download_magnet [plaintext|prefer|require] [seconds] + +use std::time::Duration; + +use risuko_bt::session::{ + AddTorrent, AddTorrentOptions, AddTorrentResponse, ListenerOptions, Session, SessionOptions, +}; +use risuko_bt::EncryptionPolicy; + +#[tokio::main(flavor = "multi_thread", worker_threads = 4)] +async fn main() { + env_logger::init(); + + let mut args = std::env::args().skip(1); + let magnet = args + .next() + .expect("usage: download_magnet [plaintext|prefer|require] [seconds]"); + let policy = match args.next().as_deref() { + Some("prefer") => EncryptionPolicy::Prefer, + Some("require") => EncryptionPolicy::RequireEncryption, + _ => EncryptionPolicy::PlaintextOnly, + }; + let secs: u64 = args.next().and_then(|s| s.parse().ok()).unwrap_or(90); + // Optional 4th arg: path to a file with one tracker URL per line. + let trackers: Option> = args.next().map(|path| { + let body = std::fs::read_to_string(&path).expect("read trackers file"); + body.lines() + .map(|l| l.trim().to_string()) + .filter(|l| !l.is_empty()) + .collect() + }); + if let Some(t) = &trackers { + eprintln!("loaded {} trackers", t.len()); + } + + let out = std::env::temp_dir().join("risuko-cmp-dl"); + std::fs::create_dir_all(&out).unwrap(); + + let session = Session::new_with_opts( + out.clone(), + SessionOptions { + listen: Some(ListenerOptions { + listen_addr: Some("0.0.0.0:0".parse().unwrap()), + enable_upnp_port_forwarding: false, + listen_ipv6: true, + ..Default::default() + }), + encryption: policy, + ..Default::default() + }, + ) + .await + .expect("session"); + + eprintln!( + "session listen_port={} policy={:?} out={}", + session.listen_port(), + policy, + out.display() + ); + eprintln!("resolving + adding magnet (may take a few seconds)..."); + + let add_opts = AddTorrentOptions { + trackers, + ..AddTorrentOptions::default() + }; + // If the first arg is an existing file, treat it as a .torrent (skip + // magnet resolution); otherwise treat it as a magnet URL. + let which = if std::path::Path::new(&magnet).is_file() { + eprintln!("loading .torrent file (skipping magnet resolution)"); + let bytes = std::fs::read(&magnet).expect("read torrent file"); + AddTorrent::TorrentFileBytes(bytes.into()) + } else { + AddTorrent::Url(magnet) + }; + let handle = match session.add_torrent(which, Some(add_opts)).await { + Ok(AddTorrentResponse::Added(_, h)) => h, + Ok(_) => { + eprintln!("unexpected non-Added response"); + return; + } + Err(e) => { + eprintln!("add_torrent failed: {e}"); + return; + } + }; + + eprintln!("added; polling stats for {secs}s"); + let deadline = tokio::time::Instant::now() + Duration::from_secs(secs); + loop { + let s = handle.stats(); + let live = s + .live + .as_ref() + .map(|l| l.snapshot.peer_stats.live) + .unwrap_or(0); + let pct = if s.total_bytes > 0 { + s.progress_bytes as f64 / s.total_bytes as f64 * 100.0 + } else { + 0.0 + }; + eprintln!( + "progress {} / {} bytes ({pct:.3}%) live_peers={} known_peers={} finished={}", + s.progress_bytes, + s.total_bytes, + live, + s.peers.len(), + s.finished + ); + if s.finished || tokio::time::Instant::now() > deadline { + break; + } + tokio::time::sleep(Duration::from_secs(3)).await; + } + eprintln!("DONE"); +} diff --git a/src-tauri/risuko-bt/examples/probe_handshake.rs b/src-tauri/risuko-bt/examples/probe_handshake.rs new file mode 100644 index 00000000..90d22d51 --- /dev/null +++ b/src-tauri/risuko-bt/examples/probe_handshake.rs @@ -0,0 +1,105 @@ +//! Diagnostic probe: dial a single peer via risuko-bt's real `connect()` +//! path and report the handshake outcome. Used to reproduce/inspect the +//! plaintext-handshake rejection seen on certain swarms. +//! +//! Usage: probe_handshake [v2] + +use std::time::Duration; + +use risuko_bt::generate_peer_id; +use risuko_bt::peer::{connect, EncryptionPolicy, PeerCommand, PeerEvent, SpawnPeer}; +use risuko_bt::wire::Message; +use risuko_bt::Id20; + +#[tokio::main] +async fn main() { + let mut args = std::env::args().skip(1); + let addr: std::net::SocketAddr = args + .next() + .expect("usage: probe_handshake [v2]") + .parse() + .expect("valid socket addr"); + let ih_hex = args.next().expect("info_hash hex required"); + let advertise_v2 = args.next().map(|s| s == "v2").unwrap_or(false); + // Optional 4th arg: override the local peer_id (e.g. "-qB5011-" or + // "-rQ0011-") to test whether a peer rejects us based on our peer_id. + let peer_id_override = args.next(); + + let ih = hex::decode(ih_hex.trim()).expect("valid hex info hash"); + let info_hash = Id20::from_slice(&ih).expect("info hash must be 20 bytes"); + let our_peer_id = match peer_id_override { + Some(prefix) => { + let mut raw = [0u8; 20]; + let pb = prefix.as_bytes(); + let n = pb.len().min(20); + raw[..n].copy_from_slice(&pb[..n]); + // fill the remainder with pseudo-random-ish bytes + for (i, b) in raw.iter_mut().enumerate().skip(n) { + *b = (i as u8).wrapping_mul(37).wrapping_add(11); + } + Id20::from_slice(&raw).unwrap() + } + None => generate_peer_id(), + }; + + eprintln!( + "dialing {addr} info_hash={ih_hex} advertise_v2={advertise_v2} peer_id={:02x?}", + our_peer_id.0 + ); + + let spawn = SpawnPeer { + addr, + info_hash, + our_peer_id, + connect_timeout: Duration::from_secs(10), + read_timeout: Duration::from_secs(15), + encryption: EncryptionPolicy::PlaintextOnly, + advertise_v2, + ext_handshake_builder: None, + }; + + match connect(spawn).await { + Ok((handle, mut rx)) => { + eprintln!("CONNECTED (tcp + handshake write ok)"); + // Mimic rqbit: eagerly send Unchoke + Interested right after the + // handshake so the peer sees a complete, well-behaved client. + let _ = handle.tx.send(PeerCommand::Send(Message::Unchoke)).await; + let _ = handle.tx.send(PeerCommand::Send(Message::Interested)).await; + eprintln!("sent Unchoke + Interested"); + let mut idle_deadline = tokio::time::Instant::now() + Duration::from_secs(15); + loop { + match tokio::time::timeout_at(idle_deadline, rx.recv()).await { + Ok(Some(PeerEvent::Handshook { + peer_id, + reserved, + encrypted, + .. + })) => { + idle_deadline = tokio::time::Instant::now() + Duration::from_secs(15); + eprintln!( + "HANDSHOOK reserved={:02x?} encrypted={encrypted} remote_peer_id={:02x?}", + reserved, peer_id.0 + ); + } + Ok(Some(PeerEvent::Message(m))) => { + idle_deadline = tokio::time::Instant::now() + Duration::from_secs(15); + eprintln!("MSG {m:?}") + } + Ok(Some(PeerEvent::Disconnected { reason })) => { + eprintln!("DISCONNECTED: {reason}"); + break; + } + Ok(None) => { + eprintln!("event channel closed"); + break; + } + Err(_) => { + eprintln!("idle timeout (still connected, no further events)"); + break; + } + } + } + } + Err(e) => eprintln!("CONNECT FAILED: {e}"), + } +} diff --git a/src-tauri/risuko-bt/src/core/hash.rs b/src-tauri/risuko-bt/src/core/hash.rs index a365cd38..ed934040 100644 --- a/src-tauri/risuko-bt/src/core/hash.rs +++ b/src-tauri/risuko-bt/src/core/hash.rs @@ -31,8 +31,6 @@ impl Id20 { hex::encode(self.0) } - /// Alias of `to_hex`, kept to match the librqbit API shape that - /// `engine::torrent.rs` consumes pub fn as_string(&self) -> String { self.to_hex() } diff --git a/src-tauri/risuko-bt/src/core/magnet.rs b/src-tauri/risuko-bt/src/core/magnet.rs index cefc965e..4f0f37e0 100644 --- a/src-tauri/risuko-bt/src/core/magnet.rs +++ b/src-tauri/risuko-bt/src/core/magnet.rs @@ -59,14 +59,11 @@ impl Magnet { } } - /// Kept for API compatibility with librqbit shims pub fn as_id20(&self) -> Option { Some(self.info_hash) } pub fn parse(input: &str) -> Result { - // Accept a bare 40-char hex hash as a shortcut — useful for CLI use - // and matches librqbit's behaviour let input = input.trim(); if input.len() == 40 { if let Ok(id) = Id20::from_str(input) { diff --git a/src-tauri/risuko-bt/src/core/merkle.rs b/src-tauri/risuko-bt/src/core/merkle.rs index b517ab6e..9a7c9ec5 100644 --- a/src-tauri/risuko-bt/src/core/merkle.rs +++ b/src-tauri/risuko-bt/src/core/merkle.rs @@ -356,6 +356,21 @@ use std::sync::Arc; use super::metainfo::TorrentMeta; +pub fn supports_v2_wire(meta: &TorrentMeta) -> bool { + let Some(v2) = meta.info_v2.as_ref() else { + return false; + }; + v2.files.iter().all(|file| { + let layer = meta + .piece_layers + .get(&file.pieces_root) + .map(|v| v.as_slice()) + .unwrap_or(&[]); + MerkleProofTable::from_layer_bytes(file.pieces_root, file.length, v2.piece_length, layer) + .is_ok() + }) +} + /// Strategy for verifying a fully-downloaded piece. Chosen once per torrent /// at session-attach time. v1 torrents use SHA-1 over the piece bytes /// (`pieces[piece_index * 20 .. + 20]`); v2 torrents collapse 16 KiB diff --git a/src-tauri/risuko-bt/src/core/mod.rs b/src-tauri/risuko-bt/src/core/mod.rs index a6087e6d..fcc4e4e7 100644 --- a/src-tauri/risuko-bt/src/core/mod.rs +++ b/src-tauri/risuko-bt/src/core/mod.rs @@ -11,7 +11,7 @@ pub mod peer_id; pub use hash::{Id20, Id32}; pub use lengths::{ChunkInfo, Lengths, PieceInfo, ValidPieceIndex, CHUNK_SIZE}; pub use magnet::Magnet; -pub use merkle::{MerkleError, MerkleProofTable, PieceVerifier, VerifyError}; +pub use merkle::{supports_v2_wire, MerkleError, MerkleProofTable, PieceVerifier, VerifyError}; pub use metainfo::{ parse_info_v2_from_bytes, FileDetails, MetaVersion, TorrentInfoHashes, TorrentMeta, TorrentMetaInfo, ValidatedTorrentMetaV1Info, ValidatedTorrentMetaV2Info, diff --git a/src-tauri/risuko-bt/src/dht.rs b/src-tauri/risuko-bt/src/dht.rs index 438a7988..0d9df392 100644 --- a/src-tauri/risuko-bt/src/dht.rs +++ b/src-tauri/risuko-bt/src/dht.rs @@ -26,10 +26,16 @@ type GetPeersReply = ( Option, Vec, Vec<(Id20, SocketAddr)>, + Option>, ); /// Body fields parsed from a `get_peers` response (no source addr). -type GetPeersResponseBody = (Option, Vec, Vec<(Id20, SocketAddr)>); +type GetPeersResponseBody = ( + Option, + Vec, + Vec<(Id20, SocketAddr)>, + Option>, +); const K: usize = 8; const ALPHA: usize = 3; @@ -83,6 +89,24 @@ struct KrpcResponse { } impl Dht { + /// Process-wide DHT node, lazily spawned and bootstrapped on + /// first use + pub async fn shared() -> Option> { + static SHARED: tokio::sync::OnceCell> = tokio::sync::OnceCell::const_new(); + SHARED + .get_or_try_init(|| async { + let dht = Dht::spawn(DhtConfig::default()).await?; + // Warm the routing table in the background so the first + // lookup already has live, close-ish nodes to query. + let warm = dht.clone(); + tokio::spawn(async move { warm.bootstrap().await }); + Ok::, std::io::Error>(dht) + }) + .await + .ok() + .cloned() + } + pub async fn spawn(config: DhtConfig) -> std::io::Result> { let sock = UdpSocket::bind("0.0.0.0:0").await?; let sock = Arc::new(sock); @@ -150,7 +174,28 @@ impl Dht { let (tx, rx) = mpsc::unbounded_channel::(); let this = self.clone(); tokio::spawn(async move { - let _ = tokio::time::timeout(budget, this.iterative_get_peers(info_hash, tx)).await; + let _ = + tokio::time::timeout(budget, this.iterative_get_peers(info_hash, tx, None)).await; + }); + rx + } + + /// Like [`get_peers_stream`] but also re-publishes us to the DHT (BEP-5 + /// `announce_peer`) on the closest nodes that hand back a write token, so + /// other clients searching this info-hash can discover and dial us. + /// `port` is our BT listen port. + pub fn announce_and_get_peers_stream( + self: &Arc, + info_hash: Id20, + budget: Duration, + port: u16, + ) -> mpsc::UnboundedReceiver { + let (tx, rx) = mpsc::unbounded_channel::(); + let this = self.clone(); + tokio::spawn(async move { + let _ = + tokio::time::timeout(budget, this.iterative_get_peers(info_hash, tx, Some(port))) + .await; }); rx } @@ -165,18 +210,21 @@ impl Dht { out } - pub async fn announce_peer(&self, _info_hash: Id20, _port: u16) { - // Omitted: we do not publish ourselves to the DHT. This keeps writes - // off the wire and avoids the token-tracking machinery - } - async fn iterative_get_peers( self: Arc, info_hash: Id20, peer_tx: mpsc::UnboundedSender, + announce_port: Option, ) { - // Resolve bootstrap nodes to SocketAddrs concurrently - let mut addrs: Vec = Vec::new(); + // Seed the lookup from our warm routing table first (Kademlia reuses + // known-close nodes), then augment with the public bootstrap + // hostnames. Without the routing-table seed every lookup restarts + // cold from the bootstrap servers — slow, and the reason the first + // magnet resolutions after launch were sluggish. Once the table is + // warm, subsequent lookups (resolution retries, the ongoing + // per-torrent get_peers poller, additional torrents) start close to + // the target and converge quickly. + let mut addrs: Vec = self.routing.lock().closest(&info_hash, K * 2); for host in &self.bootstrap { if let Ok(iter) = lookup_host(host).await { addrs.extend(iter); @@ -192,6 +240,10 @@ impl Dht { let mut shortlist: BTreeMap = BTreeMap::new(); let mut queried: HashSet = HashSet::new(); let mut peers_seen: HashSet = HashSet::new(); + // Nodes (keyed by XOR distance to the target) that returned a write + // token, paired with that token. After the lookup we announce + // ourselves to the closest of these (BEP-5) when announce_port is set. + let mut announce_targets: BTreeMap)> = BTreeMap::new(); // Seed: ask bootstrap nodes with a dummy id = info_hash (so their // responses contain nodes close to the target) @@ -215,7 +267,7 @@ impl Dht { }; let res = joined.ok().flatten(); - let Some((from, responder_id, peers, nodes)) = res else { + let Some((from, responder_id, peers, nodes, token)) = res else { continue; }; @@ -240,6 +292,12 @@ impl Dht { if responder_id.is_some() { self.routing.lock().add(node_id, from); } + if let Some(tok) = token { + announce_targets.insert(xor(&node_id, &info_hash), (from, tok)); + while announce_targets.len() > K * 2 { + announce_targets.pop_last(); + } + } // Merge any learned nodes into the shortlist let mut progressed = false; @@ -295,6 +353,29 @@ impl Dht { total_nodes, queried.len() ); + + // BEP-5 announce_peer: publish ourselves on the closest token-bearing + // nodes so other clients doing get_peers for this info-hash discover + // us and can open inbound connections. Fire-and-forget — we don't need + // the ack. + if let Some(port) = announce_port { + for (_d, (addr, token)) in announce_targets.into_iter().take(K) { + let pkt = build_announce_peer( + rand::rng().random(), + &self.our_id, + &info_hash, + port, + &token, + ); + let _ = match addr { + SocketAddr::V4(_) => self.sock.send_to(&pkt, addr).await, + SocketAddr::V6(_) => match &self.sock6 { + Some(s6) => s6.send_to(&pkt, addr).await, + None => continue, + }, + }; + } + } } async fn query_get_peers( @@ -329,7 +410,7 @@ impl Dht { } }; parse_get_peers_response(&resp.body) - .map(|(rid, peers, nodes)| (resp.from, rid, peers, nodes)) + .map(|(rid, peers, nodes, token)| (resp.from, rid, peers, nodes, token)) } fn register_transaction(&self, target: SocketAddr) -> (u16, oneshot::Receiver) { @@ -478,6 +559,20 @@ impl RoutingTable { }; } } + + /// The `n` nodes whose ids are closest (by XOR) to `target`. Used to + /// seed an iterative lookup from the warm routing table instead of the + /// cold public bootstrap servers. + fn closest(&self, target: &Id20, n: usize) -> Vec { + let mut all: Vec<(Id20, SocketAddr)> = self + .buckets + .iter() + .flatten() + .map(|node| (xor(&node.id, target), node.addr)) + .collect(); + all.sort_by_key(|&(dist, _)| dist); + all.into_iter().take(n).map(|(_, addr)| addr).collect() + } } fn random_id() -> Id20 { @@ -511,6 +606,36 @@ fn xor(a: &Id20, b: &Id20) -> Id20 { Id20::from_slice(&out).unwrap() } +/// Build a BEP-5 `announce_peer` query. The `token` must be one we received +/// from this node's prior `get_peers` response, otherwise it rejects us. +fn build_announce_peer( + txn: u16, + our_id: &Id20, + info_hash: &Id20, + port: u16, + token: &[u8], +) -> Vec { + // Dict keys must be bencode-sorted: id, implied_port, info_hash, port, token + let args = Value::Dict(vec![ + (b"id".to_vec(), Value::Bytes(our_id.as_bytes().to_vec())), + (b"implied_port".to_vec(), Value::Int(0)), + ( + b"info_hash".to_vec(), + Value::Bytes(info_hash.as_bytes().to_vec()), + ), + (b"port".to_vec(), Value::Int(port as i64)), + (b"token".to_vec(), Value::Bytes(token.to_vec())), + ]); + let tid = txn.to_be_bytes().to_vec(); + let msg = Value::Dict(vec![ + (b"a".to_vec(), args), + (b"q".to_vec(), Value::Bytes(b"announce_peer".to_vec())), + (b"t".to_vec(), Value::Bytes(tid)), + (b"y".to_vec(), Value::Bytes(b"q".to_vec())), + ]); + encode_to_vec(&msg) +} + fn build_get_peers(txn: u16, our_id: &Id20, info_hash: &Id20) -> Vec { let args = Value::Dict(vec![ (b"id".to_vec(), Value::Bytes(our_id.as_bytes().to_vec())), @@ -590,7 +715,11 @@ fn parse_get_peers_response(body: &Value) -> Option { nodes.push((id, SocketAddr::V6(SocketAddrV6::new(ip, port, 0, 0)))); } } - Some((responder_id, peers, nodes)) + let token = r_val + .get(b"token") + .and_then(|v| v.as_bytes()) + .map(|b| b.to_vec()); + Some((responder_id, peers, nodes, token)) } async fn reader_loop(sock: Arc, pending: Arc>) { @@ -701,6 +830,32 @@ mod tests { assert_eq!(a.len(), 3); } + #[test] + fn announce_peer_packet_is_bencoded_krpc_query() { + let our_id = Id20::from_slice(&[0u8; 20]).unwrap(); + let info_hash = Id20::from_slice(&[1u8; 20]).unwrap(); + let packet = build_announce_peer(0xCAFE, &our_id, &info_hash, 6881, b"tok"); + let decoded = decode_all(&packet).unwrap(); + assert_eq!( + decoded.get(b"q").and_then(|v| v.as_bytes()), + Some(b"announce_peer" as &[u8]) + ); + assert_eq!( + decoded.get(b"y").and_then(|v| v.as_bytes()), + Some(b"q" as &[u8]) + ); + let a = Value::Dict(decoded.get(b"a").unwrap().as_dict().unwrap().to_vec()); + assert_eq!(a.get(b"port").and_then(|v| v.as_int()), Some(6881)); + assert_eq!( + a.get(b"token").and_then(|v| v.as_bytes()), + Some(b"tok" as &[u8]) + ); + assert_eq!( + a.get(b"info_hash").and_then(|v| v.as_bytes()), + Some(&[1u8; 20][..]) + ); + } + #[test] fn parse_response_extracts_peers_and_nodes() { // values: [6-byte peer for 1.2.3.4:5678] @@ -713,6 +868,7 @@ mod tests { let r = Value::Dict(vec![ (b"id".to_vec(), Value::Bytes(vec![0u8; 20])), (b"nodes".to_vec(), Value::Bytes(node_bytes)), + (b"token".to_vec(), Value::Bytes(b"abcd".to_vec())), ( b"values".to_vec(), Value::List(vec![Value::Bytes(peer_bytes)]), @@ -723,7 +879,8 @@ mod tests { (b"t".to_vec(), Value::Bytes(b"aa".to_vec())), (b"y".to_vec(), Value::Bytes(b"r".to_vec())), ]); - let (_id, peers, nodes) = parse_get_peers_response(&body).unwrap(); + let (_id, peers, nodes, token) = parse_get_peers_response(&body).unwrap(); + assert_eq!(token.as_deref(), Some(b"abcd" as &[u8])); assert_eq!(peers.len(), 1); assert_eq!( peers[0], @@ -758,7 +915,7 @@ mod tests { (b"t".to_vec(), Value::Bytes(b"bb".to_vec())), (b"y".to_vec(), Value::Bytes(b"r".to_vec())), ]); - let (_id, peers, nodes) = parse_get_peers_response(&body).unwrap(); + let (_id, peers, nodes, _token) = parse_get_peers_response(&body).unwrap(); assert_eq!(peers.len(), 1); assert_eq!( peers[0], diff --git a/src-tauri/risuko-bt/src/lib.rs b/src-tauri/risuko-bt/src/lib.rs index a762e7da..80a06aef 100644 --- a/src-tauri/risuko-bt/src/lib.rs +++ b/src-tauri/risuko-bt/src/lib.rs @@ -1,4 +1,4 @@ -//! BitTorrent v1 engine (in-tree replacement for librqbit) +//! BitTorrent v1 engine pub mod api; pub mod bencode; @@ -13,6 +13,7 @@ pub mod storage; pub mod torrent; pub mod tracker; pub mod upnp; +pub mod utp; pub mod wire; pub use api::TorrentIdOrHash; diff --git a/src-tauri/risuko-bt/src/magnet.rs b/src-tauri/risuko-bt/src/magnet.rs index 502693ff..e3dca31b 100644 --- a/src-tauri/risuko-bt/src/magnet.rs +++ b/src-tauri/risuko-bt/src/magnet.rs @@ -1,8 +1,8 @@ //! Magnet URI → info-dict resolution //! -//! Discovers peers via user-supplied trackers and downloads the `info` dict -//! from them using BEP-9 (ut_metadata). DHT is not used (the in-tree DHT is -//! a stub) +//! Discovers peers via user-supplied trackers and the process-wide warm DHT +//! (`Dht::shared`), then downloads the `info` dict from them using BEP-9 +//! (ut_metadata). use std::collections::{BTreeMap, HashSet}; use std::net::SocketAddr; @@ -19,13 +19,13 @@ use super::core::merkle::MerkleProofTable; use super::core::{ generate_peer_id, parse_info_v2_from_bytes, Id20, Id32, Magnet, ValidatedTorrentMetaV2Info, }; -use super::dht::{Dht, DhtConfig}; +use super::dht::Dht; use super::peer::{connect, PeerCommand, PeerEvent, SpawnPeer}; use super::tracker::{announce, AnnounceEvent, AnnounceRequest}; use super::wire::extended::{ parse_ut_metadata, ut_metadata_request, ut_metadata_type, ExtHandshake, EXT_HANDSHAKE_ID, }; -use super::wire::Message; +use super::wire::{Message, MessageEncoder}; const META_PIECE_SIZE: usize = 16 * 1024; const MAX_METADATA_SIZE: usize = 32 * 1024 * 1024; @@ -82,6 +82,7 @@ pub async fn resolve_with_peers( let info_hash = magnet.info_hash(); let want_v1 = magnet.info_hash_v1(); let want_v2 = magnet.info_hash_v2(); + let advertise_v2 = want_v1.is_none() && want_v2.is_some(); let mut trackers: Vec = magnet.trackers.clone(); for t in extra_trackers { @@ -129,27 +130,25 @@ pub async fn resolve_with_peers( }); } - // Fire up DHT in parallel; it feeds the same peer channel as trackers - // If DHT fails to start (firewalled UDP, etc.) we just lose that source - let dht_handle: Option> = - match Dht::spawn(DhtConfig::default()).await { - Ok(dht) => { - let tx = peer_tx.clone(); - let dht_budget = budget.min(Duration::from_secs(60)); - let mut dht_rx = dht.get_peers_stream(info_hash, dht_budget); - Some(tokio::spawn(async move { - while let Some(p) = dht_rx.recv().await { - if tx.send(p).is_err() { - break; - } + // Fire up DHT in parallel + let dht_handle: Option> = match Dht::shared().await { + Some(dht) => { + let tx = peer_tx.clone(); + let dht_budget = budget.min(Duration::from_secs(60)); + let mut dht_rx = dht.get_peers_stream(info_hash, dht_budget); + Some(tokio::spawn(async move { + while let Some(p) = dht_rx.recv().await { + if tx.send(p).is_err() { + break; } - })) - } - Err(e) => { - log::debug!("dht spawn failed: {e}"); - None - } - }; + } + })) + } + None => { + log::debug!("dht unavailable for magnet resolution"); + None + } + }; // Caller-supplied peers go in first so the driver can begin contacting // them immediately, without waiting for any tracker / DHT round-trip for p in extra_peers { @@ -212,7 +211,7 @@ pub async fn resolve_with_peers( let _permit = permit; let fetched = tokio::time::timeout( PEER_TOTAL_TIMEOUT, - try_fetch_from_peer(addr, info_hash, our_peer_id, encryption), + try_fetch_from_peer(addr, info_hash, our_peer_id, encryption, advertise_v2), ) .await .ok() @@ -246,6 +245,12 @@ pub async fn resolve_with_peers( } info_seen.store(true, std::sync::atomic::Ordering::Relaxed); if !layers_complete { + if can_use_v1_metadata_without_piece_layers(want_v1, &bytes) { + if let Some(tx) = result_tx.lock().take() { + let _ = tx.send((bytes, BTreeMap::new())); + } + return; + } // Hash-validated info dict but the peer could // not serve every required piece layer; let // another peer try @@ -312,6 +317,26 @@ pub async fn resolve_with_peers( } } +fn can_use_v1_metadata_without_piece_layers(want_v1: Option, info_bytes: &[u8]) -> bool { + if want_v1.is_none() { + return false; + } + + let Ok(value) = crate::bencode::decode_all(info_bytes) else { + return false; + }; + let Some(dict) = value.as_dict() else { + return false; + }; + + dict.iter().any(|(key, value)| { + key == b"pieces" + && value + .as_bytes() + .is_some_and(|pieces| !pieces.is_empty() && pieces.len() % Id20::LEN == 0) + }) +} + /// Outcome of a single-peer fetch attempt: raw info dict bytes, any piece /// layers we managed to validate, and whether the layers cover every file /// that requires them. `(_, _, false)` indicates the metadata is v2 but at @@ -322,7 +347,22 @@ async fn try_fetch_from_peer( info_hash: Id20, our_peer_id: Id20, encryption: crate::peer::EncryptionPolicy, + advertise_v2: bool, ) -> Option<(Vec, BTreeMap>, bool)> { + // Build a per-peer extended-handshake builder. The connection layer + // invokes it once with the peer's IP so `yourip` matches that peer — + // some swarms (notably CN BT clients) only engage with remotes that + // populate this. Metadata size is unknown until we receive the peer's + // reply, so we leave it `None` here + let ext_handshake_builder: crate::peer::ExtHandshakeBuilder = + std::sync::Arc::new(|peer_ip: std::net::IpAddr| { + let hs = ExtHandshake::new_outgoing(OUR_UT_METADATA_ID, OUR_UT_PEX_ID, None) + .with_yourip(peer_ip); + MessageEncoder::encode(&Message::Extended { + ext_id: EXT_HANDSHAKE_ID, + payload: hs.encode(), + }) + }); let (handle, rx) = connect(SpawnPeer { addr, info_hash, @@ -330,6 +370,8 @@ async fn try_fetch_from_peer( connect_timeout: PEER_CONNECT_TIMEOUT, read_timeout: PEER_READ_TIMEOUT, encryption, + advertise_v2, + ext_handshake_builder: Some(ext_handshake_builder), }) .await .ok()?; @@ -346,18 +388,10 @@ async fn try_fetch_from_peer_inner( handle: &super::peer::PeerHandle, mut rx: tokio::sync::mpsc::Receiver, ) -> Option<(Vec, BTreeMap>, bool)> { - // Pipeline our extended handshake immediately; we'll validate that the - // peer actually supports extensions when we see their Handshook event - // This saves one async round-trip per peer - let our_hs = ExtHandshake::new_outgoing(OUR_UT_METADATA_ID, OUR_UT_PEX_ID, None); - handle - .tx - .send(PeerCommand::Send(Message::Extended { - ext_id: EXT_HANDSHAKE_ID, - payload: our_hs.encode(), - })) - .await - .ok()?; + // Our extended handshake was already shipped on the wire by the + // connection layer (see `try_fetch_from_peer`'s `ext_handshake_bytes`). + // Validate the peer's reserved bits when we observe `Handshook` and then + // wait for the peer's extended handshake reply // Collect Handshook and the peer's extended handshake from a single // receive loop. The peer's extended handshake message can arrive before @@ -680,4 +714,66 @@ mod tests { Some("http://tracker.example/announce") ); } + + #[test] + fn v1_info_can_be_used_without_piece_layers() { + use crate::bencode::{encode_to_vec, Value}; + let pieces = vec![0u8; 20]; + let info = Value::Dict(vec![ + (b"length".to_vec(), Value::Int(1024)), + (b"name".to_vec(), Value::Bytes(b"hello".to_vec())), + (b"piece length".to_vec(), Value::Int(1024)), + (b"pieces".to_vec(), Value::Bytes(pieces)), + ]); + let info_bytes = encode_to_vec(&info); + let want_v1 = Some(Id20::from_slice(&[1u8; 20]).unwrap()); + + assert!(can_use_v1_metadata_without_piece_layers( + want_v1, + &info_bytes + )); + assert!(!can_use_v1_metadata_without_piece_layers(None, &info_bytes)); + } + + #[test] + fn hybrid_info_round_trips_without_piece_layers_for_v1_download() { + use crate::bencode::{encode_to_vec, Value}; + let length = 64 * 1024; + let piece_length = 16 * 1024; + let file_leaf = Value::Dict(vec![( + Vec::new(), + Value::Dict(vec![ + (b"length".to_vec(), Value::Int(length)), + (b"pieces root".to_vec(), Value::Bytes(vec![1u8; 32])), + ]), + )]); + let file_tree = Value::Dict(vec![(b"hello.bin".to_vec(), file_leaf)]); + let info = Value::Dict(vec![ + (b"file tree".to_vec(), file_tree), + (b"length".to_vec(), Value::Int(length)), + (b"meta version".to_vec(), Value::Int(2)), + (b"name".to_vec(), Value::Bytes(b"hello".to_vec())), + (b"piece length".to_vec(), Value::Int(piece_length)), + (b"pieces".to_vec(), Value::Bytes(vec![0u8; 4 * Id20::LEN])), + ]); + let info_bytes = encode_to_vec(&info); + let want_v1 = Some(Id20::from_slice(&[1u8; 20]).unwrap()); + + assert!(can_use_v1_metadata_without_piece_layers( + want_v1, + &info_bytes + )); + + let torrent_bytes = synth_torrent_bytes(&info_bytes, &[], &BTreeMap::new()); + let meta = crate::parse_torrent(&torrent_bytes).unwrap(); + assert_eq!(meta.meta_version.as_str(), "hybrid"); + assert!(meta.piece_layers.is_empty()); + // Wire-bit advertisement still applies because the metadata carries + // v2 hashes — peers that gate engagement on the V2 reserved bit will + // see us as a v2-aware client. Serving piece layers / announcing v2 + // info-hashes remains gated on `supports_v2_wire`, which is false + // here, so the runtime falls back to the v1 download path + assert!(meta.info_v2.is_some()); + assert!(!crate::core::supports_v2_wire(&meta)); + } } diff --git a/src-tauri/risuko-bt/src/peer.rs b/src-tauri/risuko-bt/src/peer.rs index 18ec103c..2ff57565 100644 --- a/src-tauri/risuko-bt/src/peer.rs +++ b/src-tauri/risuko-bt/src/peer.rs @@ -16,7 +16,8 @@ pub mod connection; pub mod state; pub use connection::{ - accept, accept_with_policy, connect, EncryptionPolicy, PeerCommand, PeerEvent, PeerHandle, - SpawnPeer, + 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 d6ab4e19..fe428588 100644 --- a/src-tauri/risuko-bt/src/peer/connection.rs +++ b/src-tauri/risuko-bt/src/peer/connection.rs @@ -1,11 +1,12 @@ //! Per-peer async actor -use std::net::SocketAddr; +use std::net::{IpAddr, SocketAddr}; use std::pin::Pin; +use std::sync::Arc; use std::task::{Context, Poll}; use std::time::Duration; -use bytes::BytesMut; +use bytes::{Bytes, BytesMut}; use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt, ReadBuf}; use tokio::net::TcpStream; use tokio::sync::mpsc; @@ -63,7 +64,14 @@ pub struct PeerHandle { pub tx: mpsc::Sender, } +/// Builds the BEP-10 extended handshake bytes for a peer given that peer's +/// IP. The connection layer invokes this once per dial / accept after +/// reading the remote BT handshake, so the resulting message can include +/// the `yourip` field set to the peer's actual IP +pub type ExtHandshakeBuilder = Arc Bytes + Send + Sync>; + /// Parameters for spawning an outbound peer connection +#[derive(Clone)] pub struct SpawnPeer { pub addr: SocketAddr, pub info_hash: Id20, @@ -71,6 +79,34 @@ pub struct SpawnPeer { pub connect_timeout: Duration, pub read_timeout: Duration, pub encryption: EncryptionPolicy, + pub advertise_v2: bool, + /// Optional builder for the BEP-10 extended handshake bytes. Invoked by + /// the connection layer immediately after the BT handshake exchange + /// completes, inside the same task. Eliminates the multi-hop tokio + /// latency between "BT handshake done" and "ext handshake on the wire" + /// and lets callers populate `yourip`. The bytes are only sent when the + /// remote advertises the BEP-10 reserved bit + pub ext_handshake_builder: Option, +} + +#[derive(Clone)] +pub struct KnownInfoHash { + pub info_hash: Id20, + pub advertise_v2: bool, + /// See `SpawnPeer::ext_handshake_builder` — same builder, applied to + /// the inbound accept path so seeders also get our extended handshake + /// on the wire as quickly as possible (and with `yourip` set) + pub ext_handshake_builder: Option, +} + +impl From for KnownInfoHash { + fn from(info_hash: Id20) -> Self { + Self { + info_hash, + advertise_v2: true, + ext_handshake_builder: None, + } + } } /// Connect to a peer, perform the BEP-3 handshake, and split the socket into @@ -79,10 +115,57 @@ pub async fn connect(spawn: SpawnPeer) -> std::io::Result<(PeerHandle, mpsc::Rec let stream = timeout(spawn.connect_timeout, TcpStream::connect(spawn.addr)) .await .map_err(|_| std::io::Error::new(std::io::ErrorKind::TimedOut, "connect timeout"))??; - stream.set_nodelay(true).ok(); drive_handshake(stream, spawn).await } +/// Run the plaintext BT handshake over an already-established µTP (BEP-29) connection, +/// mirroring [`connect`] for the µTP transport. The peer connection layer is +/// transport-agnostic (see [`finish_spawn`]), so the resulting reader/writer tasks +/// behave identically to the TCP path +/// +/// MSE-over-µTP is intentionally not attempted: µTP peers in the swarms this client +/// targets speak plaintext, and TCP remains the path for encrypted peers. The caller +/// obtains `stream` from [`crate::utp::UtpSocket::connect`] +pub async fn connect_utp_plaintext( + stream: crate::utp::UtpStream, + spawn: SpawnPeer, +) -> std::io::Result<(PeerHandle, mpsc::Receiver)> { + let addr = spawn.addr; + let (reader, writer) = tokio::io::split(stream); + connect_plaintext(reader, writer, addr, &spawn).await +} + +/// Dial a peer, preferring TCP and falling back to µTP (BEP-29) when the TCP attempt +/// fails (refused, filtered, or timed out). Many peers are reachable over only one +/// transport—notably those behind NATs/ISPs that drop inbound TCP SYNs but pass UDP—so +/// the fallback widens connectivity. With `utp = None` this is exactly [`connect`], so +/// callers without a µTP socket keep the unchanged TCP path +/// +/// TCP is tried first rather than racing both at once: TCP carries our fast path today, +/// and a pure fallback avoids opening (then cancelling) a µTP connection for every peer +/// that TCP already reaches +pub async fn connect_with_utp_fallback( + spawn: SpawnPeer, + utp: Option>, +) -> std::io::Result<(PeerHandle, mpsc::Receiver)> { + let Some(utp) = utp else { + return connect(spawn).await; + }; + let addr = spawn.addr; + let utp_timeout = spawn.connect_timeout; + match connect(spawn.clone()).await { + Ok(v) => Ok(v), + Err(tcp_err) => match utp.connect_timeout(addr, utp_timeout).await { + Ok(stream) => { + log::debug!("tcp dial to {addr} failed ({tcp_err}); connected via µTP"); + connect_utp_plaintext(stream, spawn).await + } + // Surface the TCP error — it's usually the more actionable one. + Err(_) => Err(tcp_err), + }, + } +} + /// Accept an inbound peer connection: peer sends handshake first, we reply /// `known_hashes` is the list of info-hashes the responder currently hosts; /// it is used both to validate plaintext handshakes and to resolve the @@ -103,6 +186,16 @@ pub async fn accept( .await } +pub async fn accept_with_policy_and_capabilities( + stream: TcpStream, + our_peer_id: Id20, + known_hashes: Vec, + read_timeout: Duration, + policy: EncryptionPolicy, +) -> std::io::Result<(PeerHandle, mpsc::Receiver)> { + accept_with_policy_inner(stream, our_peer_id, known_hashes, read_timeout, policy).await +} + /// Accept with an explicit encryption policy. The first byte is peeked: /// `0x13` means plaintext BEP-3; any other byte is treated as the start of /// an MSE handshake (Ya) @@ -113,7 +206,23 @@ pub async fn accept_with_policy( read_timeout: Duration, policy: EncryptionPolicy, ) -> std::io::Result<(PeerHandle, mpsc::Receiver)> { - stream.set_nodelay(true).ok(); + accept_with_policy_inner( + stream, + our_peer_id, + known_hashes.into_iter().map(Into::into).collect(), + read_timeout, + policy, + ) + .await +} + +async fn accept_with_policy_inner( + stream: TcpStream, + our_peer_id: Id20, + known_hashes: Vec, + read_timeout: Duration, + policy: EncryptionPolicy, +) -> std::io::Result<(PeerHandle, mpsc::Receiver)> { let addr = stream.peer_addr()?; // Peek the first 20 bytes (1 pstrlen + 19-byte "BitTorrent protocol") @@ -179,25 +288,95 @@ async fn accept_plaintext( stream: TcpStream, addr: SocketAddr, our_peer_id: Id20, - known_hashes: Vec, + known_hashes: Vec, + read_timeout: Duration, +) -> std::io::Result<(PeerHandle, mpsc::Receiver)> { + let (reader, writer) = stream.into_split(); + accept_plaintext_generic( + reader, + writer, + addr, + our_peer_id, + known_hashes, + read_timeout, + ) + .await +} + +/// Accept an inbound µTP peer: run the plaintext BEP-3 responder handshake directly +/// over an established [`crate::utp::UtpStream`]. µTP carries no MSE layer (it is its +/// own transport), so unlike the TCP accept path there is no first-byte probe—the BT +/// handshake runs straight on the stream. `known_hashes` lists the +/// info-hashes we currently host; it is used both to validate the peer's handshake and +/// to choose the matching ext-handshake capabilities +pub async fn accept_utp_plaintext( + stream: crate::utp::UtpStream, + our_peer_id: Id20, + known_hashes: Vec, read_timeout: Duration, ) -> std::io::Result<(PeerHandle, mpsc::Receiver)> { - let (mut reader, mut writer) = stream.into_split(); + let addr = stream.peer_addr(); + let (reader, writer) = tokio::io::split(stream); + accept_plaintext_generic( + reader, + writer, + addr, + our_peer_id, + known_hashes, + read_timeout, + ) + .await +} + +/// Responder side of the plaintext BEP-3 handshake, generic over the transport +/// so TCP (`into_split` halves) and µTP (`tokio::io::split` halves) share the +/// exact same logic: read the peer's handshake, validate the info-hash against +/// `known_hashes`, reply with ours, optionally write the ext-handshake, then +/// hand off to `finish_spawn`. +async fn accept_plaintext_generic( + mut reader: R, + mut writer: W, + addr: SocketAddr, + our_peer_id: Id20, + known_hashes: Vec, + read_timeout: Duration, +) -> std::io::Result<(PeerHandle, mpsc::Receiver)> +where + R: AsyncRead + Unpin + Send + 'static, + W: AsyncWrite + Unpin + Send + 'static, +{ let mut buf = [0u8; HANDSHAKE_LEN]; timeout(read_timeout, reader.read_exact(&mut buf)) .await .map_err(|_| std::io::Error::new(std::io::ErrorKind::TimedOut, "hs read timeout"))??; let remote_hs = Handshake::parse(&buf) .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, format!("{e}")))?; - if !known_hashes.contains(&remote_hs.info_hash) { + let Some(known) = known_hashes + .iter() + .find(|known| known.info_hash == remote_hs.info_hash) + else { return Err(std::io::Error::new( std::io::ErrorKind::InvalidData, "unknown info hash", )); - } - let our_hs = Handshake::new(remote_hs.info_hash, our_peer_id); + }; + let our_hs = Handshake::new_with_v2(remote_hs.info_hash, our_peer_id, known.advertise_v2); writer.write_all(&our_hs.to_bytes()).await?; - finish_spawn(addr, remote_hs, false, Box::new(reader), Box::new(writer)) + write_ext_handshake_if_supported( + &mut writer, + &remote_hs, + addr.ip(), + known.ext_handshake_builder.as_ref(), + ) + .await?; + finish_spawn( + addr, + our_peer_id, + remote_hs, + false, + Box::new(reader), + Box::new(writer), + ) } async fn drive_handshake( @@ -206,7 +385,10 @@ async fn drive_handshake( ) -> std::io::Result<(PeerHandle, mpsc::Receiver)> { let addr = spawn.addr; match spawn.encryption { - EncryptionPolicy::PlaintextOnly => connect_plaintext(stream, addr, &spawn).await, + EncryptionPolicy::PlaintextOnly => { + let (reader, writer) = stream.into_split(); + connect_plaintext(reader, writer, addr, &spawn).await + } EncryptionPolicy::RequireEncryption => connect_mse(stream, addr, &spawn).await, EncryptionPolicy::Prefer => { // Try plaintext first: it's a single 68-byte exchange and @@ -221,9 +403,10 @@ async fn drive_handshake( // accepts the TCP connect but never sends the handshake cannot // tie us up for the much longer read_timeout before we fall // back to MSE. + let (reader, writer) = stream.into_split(); let plaintext = timeout( spawn.connect_timeout, - connect_plaintext(stream, addr, &spawn), + connect_plaintext(reader, writer, addr, &spawn), ) .await; let fallback_err: std::io::Error = match plaintext { @@ -239,7 +422,6 @@ async fn drive_handshake( .map_err(|_| { std::io::Error::new(std::io::ErrorKind::TimedOut, "connect timeout") })??; - stream.set_nodelay(true).ok(); // Log the MSE outcome so a debug-level log captures whether the // encrypted fallback ever succeeds. Without this, an operator // troubleshooting "0 KB/s" only sees N "trying mse" lines and @@ -260,13 +442,21 @@ async fn drive_handshake( } } -async fn connect_plaintext( - stream: TcpStream, +/// Run the plaintext BEP-3 handshake over an already-split byte stream and +/// spawn the reader/writer tasks. Generic over the transport so both TCP +/// (`OwnedReadHalf`/`OwnedWriteHalf`) and µTP (`tokio::io::split` halves) can +/// share the exact same handshake logic. +async fn connect_plaintext( + mut reader: R, + mut writer: W, addr: SocketAddr, spawn: &SpawnPeer, -) -> std::io::Result<(PeerHandle, mpsc::Receiver)> { - let (mut reader, mut writer) = stream.into_split(); - let our_hs = Handshake::new(spawn.info_hash, spawn.our_peer_id); +) -> std::io::Result<(PeerHandle, mpsc::Receiver)> +where + R: AsyncRead + Unpin + Send + 'static, + W: AsyncWrite + Unpin + Send + 'static, +{ + let our_hs = Handshake::new_with_v2(spawn.info_hash, spawn.our_peer_id, spawn.advertise_v2); writer.write_all(&our_hs.to_bytes()).await?; let mut buf = [0u8; HANDSHAKE_LEN]; @@ -281,7 +471,21 @@ async fn connect_plaintext( "info hash mismatch", )); } - finish_spawn(addr, remote_hs, false, Box::new(reader), Box::new(writer)) + write_ext_handshake_if_supported( + &mut writer, + &remote_hs, + addr.ip(), + spawn.ext_handshake_builder.as_ref(), + ) + .await?; + finish_spawn( + addr, + spawn.our_peer_id, + remote_hs, + false, + Box::new(reader), + Box::new(writer), + ) } /// Perform the BEP-8 MSE handshake as the initiator (A), then send the BEP-3 @@ -333,7 +537,8 @@ async fn connect_mse( } // Send IA = our BT handshake immediately so the responder can begin on // its very first reply packet — saves a round trip - let our_hs_bytes = Handshake::new(spawn.info_hash, spawn.our_peer_id).to_bytes(); + let our_hs_bytes = + Handshake::new_with_v2(spawn.info_hash, spawn.our_peer_id, spawn.advertise_v2).to_bytes(); let mut payload = mse::build_initiator_payload(crypto_provide, &pad_c, &our_hs_bytes) .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, format!("{e}")))?; enc_out.apply_keystream(&mut payload); @@ -493,7 +698,7 @@ async fn connect_mse( // Now read the peer's BT handshake from the still-encrypted (or now // plaintext) stream, starting with any leftover bytes we already buffered - let (reader_boxed, writer_boxed, remote_hs): ( + let (reader_boxed, mut writer_boxed, remote_hs): ( Box, Box, Handshake, @@ -576,7 +781,21 @@ async fn connect_mse( (Box::new(r), Box::new(write_h), remote_hs) }; - finish_spawn(addr, remote_hs, use_rc4, reader_boxed, writer_boxed) + write_ext_handshake_if_supported( + &mut *writer_boxed, + &remote_hs, + addr.ip(), + spawn.ext_handshake_builder.as_ref(), + ) + .await?; + finish_spawn( + addr, + spawn.our_peer_id, + remote_hs, + use_rc4, + reader_boxed, + writer_boxed, + ) } /// Accept an MSE connection as responder (B) @@ -584,7 +803,7 @@ async fn accept_mse( stream: TcpStream, addr: SocketAddr, our_peer_id: Id20, - known_hashes: Vec, + known_hashes: Vec, read_timeout: Duration, policy: EncryptionPolicy, ) -> std::io::Result<(PeerHandle, mpsc::Receiver)> { @@ -664,16 +883,17 @@ async fn accept_mse( let req3_s = mse::req3(&s); let want_req2 = mse::xor20(&req23, &req3_s); // Resolve SKEY by brute force over known info hashes - let mut skey: Option = None; - for ih in &known_hashes { - if mse::req2(&ih.0) == want_req2 { - skey = Some(*ih); + let mut known_info: Option = None; + for known in &known_hashes { + if mse::req2(&known.info_hash.0) == want_req2 { + known_info = Some(known.clone()); break; } } - let skey = skey.ok_or_else(|| { + let known_info = known_info.ok_or_else(|| { std::io::Error::new(std::io::ErrorKind::InvalidData, "mse: unknown info hash") })?; + let skey = known_info.info_hash; // Now derive RC4 keys and decrypt the rest of A's third message let key_a = mse::rc4_key(b"keyA", &s, &skey.0); // A writes (we decrypt with keyA) @@ -821,7 +1041,7 @@ async fn accept_mse( }; // Send our BT handshake, encrypted if RC4 selected - let our_hs = Handshake::new(skey, our_peer_id).to_bytes(); + let our_hs = Handshake::new_with_v2(skey, our_peer_id, known_info.advertise_v2).to_bytes(); if crypto_select == mse::crypto::RC4 { let mut encoded = our_hs.to_vec(); enc_out.apply_keystream(&mut encoded); @@ -857,8 +1077,15 @@ async fn accept_mse( hs } }; - let w = Rc4WriteHalf::new(write_h, enc_out); - finish_spawn(addr, remote_hs, true, Box::new(r), Box::new(w)) + let mut w = Rc4WriteHalf::new(write_h, enc_out); + write_ext_handshake_if_supported( + &mut w, + &remote_hs, + addr.ip(), + known_info.ext_handshake_builder.as_ref(), + ) + .await?; + finish_spawn(addr, our_peer_id, remote_hs, true, Box::new(r), Box::new(w)) } else { write_h.write_all(&our_hs).await?; let r = PrefixedReadHalf::new(read_h, leftover); @@ -871,17 +1098,65 @@ async fn accept_mse( )); } }; - finish_spawn(addr, remote_hs, false, Box::new(r), Box::new(write_h)) + write_ext_handshake_if_supported( + &mut write_h, + &remote_hs, + addr.ip(), + known_info.ext_handshake_builder.as_ref(), + ) + .await?; + finish_spawn( + addr, + our_peer_id, + remote_hs, + false, + Box::new(r), + Box::new(write_h), + ) + } +} + +/// Write our BEP-10 extended handshake to the wire if the remote advertised +/// the extension-protocol reserved bit. The bytes are produced by `builder` +/// per-peer so the message can include the peer's IP in the `yourip` field +/// Called inline by every connection-establishment path (plaintext + MSE, +/// inbound + outbound) so the message ships in the same async frame that +/// just completed the BT handshake — no event-channel hop, no writer-task +/// hop. Some real-world peers RST the connection if our follow-up doesn't +/// arrive promptly +async fn write_ext_handshake_if_supported( + writer: &mut W, + remote_hs: &Handshake, + peer_ip: IpAddr, + builder: Option<&ExtHandshakeBuilder>, +) -> std::io::Result<()> { + if !remote_hs.has_ext_protocol() { + return Ok(()); } + let Some(builder) = builder else { + return Ok(()); + }; + let bytes = builder(peer_ip); + writer.write_all(&bytes).await } fn finish_spawn( addr: SocketAddr, + our_peer_id: Id20, remote_hs: Handshake, encrypted: bool, reader: Box, writer: Box, ) -> std::io::Result<(PeerHandle, mpsc::Receiver)> { + // Reject self-connections: the DHT can hand us our own externally-mapped + // address, and without this we complete a full handshake with ourselves, + // burning a peer slot on a connection that can never serve data + if remote_hs.peer_id == our_peer_id { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidData, + "self-connection (peer_id matches ours)", + )); + } // Sized for high-throughput pipelining: with up to ~64 outstanding // chunk requests and Piece replies of 16 KiB arriving back-to-back, // a 64-slot channel would backpressure the reader and cap throughput @@ -1251,6 +1526,8 @@ mod tests { connect_timeout: Duration::from_secs(5), read_timeout: Duration::from_secs(5), encryption: EncryptionPolicy::PlaintextOnly, + advertise_v2: true, + ext_handshake_builder: None, }) .await .unwrap(); @@ -1312,6 +1589,8 @@ mod tests { connect_timeout: Duration::from_secs(5), read_timeout: Duration::from_secs(5), encryption: EncryptionPolicy::PlaintextOnly, + advertise_v2: true, + ext_handshake_builder: None, }) .await; assert!(res.is_err() || accept_fut.await.unwrap().is_err()); @@ -1345,6 +1624,8 @@ mod tests { connect_timeout: Duration::from_secs(5), read_timeout: Duration::from_secs(10), encryption: EncryptionPolicy::RequireEncryption, + advertise_v2: true, + ext_handshake_builder: None, }) .await .unwrap(); @@ -1375,4 +1656,216 @@ mod tests { e => panic!("unexpected: {e:?}"), } } + + #[tokio::test] + async fn utp_plaintext_handshake_and_message_over_loopback() { + use crate::utp::UtpSocket; + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + + let info_hash = Id20([7u8; 20]); + let client_id = Id20([1u8; 20]); + let server_id = Id20([2u8; 20]); + + let server_sock = UtpSocket::bind("127.0.0.1:0".parse().unwrap()) + .await + .unwrap(); + let client_sock = UtpSocket::bind("127.0.0.1:0".parse().unwrap()) + .await + .unwrap(); + let server_addr = server_sock.local_addr(); + + // Minimal peer: accept a µTP stream, exchange BT handshakes, then push + // one peer message so the client's reader task surfaces it. + let server = tokio::spawn(async move { + let mut s = server_sock.accept().await.unwrap(); + let mut buf = [0u8; HANDSHAKE_LEN]; + s.read_exact(&mut buf).await.unwrap(); + let remote = Handshake::parse(&buf).unwrap(); + assert_eq!(remote.info_hash, info_hash); + assert_eq!(remote.peer_id, client_id); + let reply = Handshake::new_with_v2(remote.info_hash, server_id, false); + s.write_all(&reply.to_bytes()).await.unwrap(); + s.write_all(&MessageEncoder::encode(&Message::Have { piece_index: 7 })) + .await + .unwrap(); + s.flush().await.unwrap(); + // Keep the connection alive until the client has read everything. + tokio::time::sleep(Duration::from_millis(300)).await; + }); + + let result = tokio::time::timeout(Duration::from_secs(10), async move { + let utp = client_sock.connect(server_addr).await.unwrap(); + let (_handle, mut rx) = connect_utp_plaintext( + utp, + SpawnPeer { + addr: server_addr, + info_hash, + our_peer_id: client_id, + connect_timeout: Duration::from_secs(5), + read_timeout: Duration::from_secs(5), + encryption: EncryptionPolicy::PlaintextOnly, + advertise_v2: false, + ext_handshake_builder: None, + }, + ) + .await + .unwrap(); + + match rx.recv().await.unwrap() { + PeerEvent::Handshook { + peer_id, + info_hash: ih, + encrypted, + .. + } => { + assert_eq!(peer_id, server_id); + assert_eq!(ih, info_hash); + assert!(!encrypted); + } + e => panic!("expected Handshook, got {e:?}"), + } + match rx.recv().await.unwrap() { + PeerEvent::Message(Message::Have { piece_index }) => assert_eq!(piece_index, 7), + e => panic!("expected Have over µTP, got {e:?}"), + } + }) + .await; + result.expect("µTP handshake/message exchange timed out"); + server.await.unwrap(); + } + + #[tokio::test] + async fn utp_fallback_engages_when_tcp_refused() { + use crate::utp::UtpSocket; + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + + let info_hash = Id20([7u8; 20]); + let client_id = Id20([1u8; 20]); + let server_id = Id20([2u8; 20]); + + // The peer listens on µTP only — no TCP listener exists on this UDP + // port number — so the client's TCP dial is refused and it must fall + // back to µTP to connect. + let server_sock = UtpSocket::bind("127.0.0.1:0".parse().unwrap()) + .await + .unwrap(); + let client_utp = UtpSocket::bind("127.0.0.1:0".parse().unwrap()) + .await + .unwrap(); + let server_addr = server_sock.local_addr(); + + let server = tokio::spawn(async move { + let mut s = server_sock.accept().await.unwrap(); + let mut buf = [0u8; HANDSHAKE_LEN]; + s.read_exact(&mut buf).await.unwrap(); + let remote = Handshake::parse(&buf).unwrap(); + let reply = Handshake::new_with_v2(remote.info_hash, server_id, false); + s.write_all(&reply.to_bytes()).await.unwrap(); + s.flush().await.unwrap(); + tokio::time::sleep(Duration::from_millis(300)).await; + }); + + let spawn = SpawnPeer { + addr: server_addr, + info_hash, + our_peer_id: client_id, + connect_timeout: Duration::from_secs(5), + read_timeout: Duration::from_secs(5), + encryption: EncryptionPolicy::PlaintextOnly, + advertise_v2: false, + ext_handshake_builder: None, + }; + let (_handle, mut rx) = tokio::time::timeout( + Duration::from_secs(10), + connect_with_utp_fallback(spawn, Some(client_utp)), + ) + .await + .expect("utp fallback timed out") + .expect("utp fallback connect failed"); + match rx.recv().await.unwrap() { + PeerEvent::Handshook { peer_id, .. } => assert_eq!(peer_id, server_id), + e => panic!("expected Handshook via µTP fallback, got {e:?}"), + } + server.await.unwrap(); + } + + #[tokio::test] + async fn utp_inbound_accept_completes_handshake() { + use crate::utp::UtpSocket; + + let info_hash = Id20([9u8; 20]); + let client_id = Id20([1u8; 20]); + let server_id = Id20([2u8; 20]); + + let server_sock = UtpSocket::bind("127.0.0.1:0".parse().unwrap()) + .await + .unwrap(); + let client_sock = UtpSocket::bind("127.0.0.1:0".parse().unwrap()) + .await + .unwrap(); + let server_addr = server_sock.local_addr(); + + // Responder: accept the inbound µTP stream and run the plaintext BT + // handshake through the production `accept_utp_plaintext` path, then + // push one message so the initiator's reader surfaces it. + let server = tokio::spawn(async move { + let s = server_sock.accept().await.unwrap(); + let known = vec![KnownInfoHash { + info_hash, + advertise_v2: false, + ext_handshake_builder: None, + }]; + let (handle, mut rx) = + accept_utp_plaintext(s, server_id, known, Duration::from_secs(5)) + .await + .unwrap(); + match rx.recv().await.unwrap() { + PeerEvent::Handshook { + peer_id, + info_hash: ih, + .. + } => { + assert_eq!(peer_id, client_id); + assert_eq!(ih, info_hash); + } + e => panic!("server expected Handshook, got {e:?}"), + } + handle + .tx + .send(PeerCommand::Send(Message::Have { piece_index: 9 })) + .await + .unwrap(); + tokio::time::sleep(Duration::from_millis(300)).await; + }); + + let result = tokio::time::timeout(Duration::from_secs(10), async move { + let utp = client_sock.connect(server_addr).await.unwrap(); + let (_handle, mut rx) = connect_utp_plaintext( + utp, + SpawnPeer { + addr: server_addr, + info_hash, + our_peer_id: client_id, + connect_timeout: Duration::from_secs(5), + read_timeout: Duration::from_secs(5), + encryption: EncryptionPolicy::PlaintextOnly, + advertise_v2: false, + ext_handshake_builder: None, + }, + ) + .await + .unwrap(); + match rx.recv().await.unwrap() { + PeerEvent::Handshook { peer_id, .. } => assert_eq!(peer_id, server_id), + e => panic!("client expected Handshook, got {e:?}"), + } + match rx.recv().await.unwrap() { + PeerEvent::Message(Message::Have { piece_index }) => assert_eq!(piece_index, 9), + e => panic!("client expected Have over µTP, got {e:?}"), + } + }) + .await; + result.expect("inbound µTP accept handshake timed out"); + server.await.unwrap(); + } } diff --git a/src-tauri/risuko-bt/src/session.rs b/src-tauri/risuko-bt/src/session.rs index 27062549..686225a1 100644 --- a/src-tauri/risuko-bt/src/session.rs +++ b/src-tauri/risuko-bt/src/session.rs @@ -3,6 +3,7 @@ use std::collections::HashMap; use std::net::SocketAddr; use std::path::PathBuf; +use std::sync::atomic::Ordering; use std::sync::Arc; use bytes::Bytes; @@ -13,7 +14,7 @@ use tokio::sync::mpsc; use super::api::TorrentIdOrHash; use super::core::metainfo::{parse_torrent, FileDetails}; use super::core::{generate_peer_id, Id20, Lengths}; -use super::peer::{PeerCommand, PeerEvent}; +use super::peer::{KnownInfoHash, PeerCommand, PeerEvent}; use super::torrent::{spawn as spawn_torrent, ManagedTorrent, TorrentCommand, TorrentInit}; #[derive(Clone, Debug)] @@ -122,9 +123,14 @@ pub struct Session { opts: SessionOptions, peer_id: Id20, listen_port: u16, + /// Shared µTP (BEP-29) endpoint, bound on the same UDP port as the TCP + /// listener. Threaded into every torrent so outbound dials can retry over + /// µTP when TCP fails. `None` when µTP could not bind (TCP-only fallback). + utp: Option>, inner: Mutex, accept_handle: Mutex>>, accept6_handle: Mutex>>, + utp_accept_handle: Mutex>>, upnp_handle: Mutex>, lsd: Mutex>>, lsd_router_handle: Mutex>>, @@ -140,6 +146,9 @@ impl Drop for Session { if let Some(h) = self.accept6_handle.lock().take() { h.abort(); } + if let Some(h) = self.utp_accept_handle.lock().take() { + h.abort(); + } if let Some(h) = self.lsd_router_handle.lock().take() { h.abort(); } @@ -198,11 +207,37 @@ impl Session { upnp_handle = Some(fwd.spawn()); } + // µTP (BEP-29) endpoint: bind UDP on the same port as the TCP listener so + // peers reach us at the same ip:port over either transport + // Outbound dials retry over µTP when TCP fails. A bind failure is non-fatal— + // µTP is disabled and we fall back to TCP only + let utp = + match super::utp::UtpSocket::bind(SocketAddr::from(([0, 0, 0, 0], local_port))).await { + Ok(s) => { + log::info!("µTP listening on udp/{}", s.local_addr().port()); + Some(s) + } + Err(e) => { + log::warn!("µTP: bind udp/{local_port} failed ({e}); trying ephemeral"); + match super::utp::UtpSocket::bind(SocketAddr::from(([0, 0, 0, 0], 0))).await { + Ok(s) => { + log::info!("µTP listening on udp/{}", s.local_addr().port()); + Some(s) + } + Err(e2) => { + log::warn!("µTP: disabled (bind failed: {e2})"); + None + } + } + } + }; + let session = Arc::new(Self { output_dir, opts, peer_id, listen_port: local_port, + utp, inner: Mutex::new(SessionInner { torrents: HashMap::new(), by_hash: HashMap::new(), @@ -210,6 +245,7 @@ impl Session { }), accept_handle: Mutex::new(None), accept6_handle: Mutex::new(None), + utp_accept_handle: Mutex::new(None), upnp_handle: Mutex::new(upnp_handle), lsd: Mutex::new(None), lsd_router_handle: Mutex::new(None), @@ -237,29 +273,32 @@ impl Session { } if !session.opts.disable_dht { - let dht_cfg = session.opts.dht_config.clone().unwrap_or_default(); - match super::dht::Dht::spawn(dht_cfg).await { - Ok(dht) => { - let dht_for_bg = dht.clone(); - // Bootstrap once in the background so the routing table - // becomes useful for diagnostics + future lookups - let handle = tokio::spawn(async move { - dht_for_bg.bootstrap().await; - }); - *session.dht.lock() = Some(dht); - *session.dht_bootstrap_handle.lock() = Some(handle); - } - Err(e) => log::warn!("dht: not started: {e}"), + // Reuse the process-wide warm DHT (also used by magnet resolution and each + // torrent's ongoing get_peers poller) rather than spawning a session-local one. + // shared() spawns + bootstraps a single long-lived instance on first use + match super::dht::Dht::shared().await { + Some(dht) => *session.dht.lock() = Some(dht), + None => log::warn!("dht: not started"), } } - // Hold a Weak reference inside the accept loop so the loop does not - // keep the session alive. When the last external Arc is dropped the - // session's Drop aborts the spawned task via `accept_handle`. + // Hold a Weak reference inside the accept loop so the loop does not keep the + // session alive. When the last external Arc is dropped the session's Drop + // aborts the spawned task via `accept_handle` let weak = Arc::downgrade(&session); let accept_handle = tokio::spawn(run_accept_loop(listener, weak)); *session.accept_handle.lock() = Some(accept_handle); + // Inbound µTP (BEP-29) accept loop. Only spawned when µTP bound. This + // drains the µTP socket's accept queue so inbound SYNs become real + // peers instead of leaking queued streams + driver tasks, and gives us + // inbound µTP connectivity on par with the TCP listener. + if let Some(utp) = session.utp.clone() { + let weak_utp = Arc::downgrade(&session); + let h = tokio::spawn(run_utp_accept_loop(utp, weak_utp)); + *session.utp_accept_handle.lock() = Some(h); + } + // Optional v6 listener on the same port. Failure to bind is not // fatal: we simply log and continue with v4 only. let listen_v6 = session @@ -472,6 +511,13 @@ impl Session { return Err(format!("build verifier: {e}")); } }; + // Reserved-bit advertisement is set only for *pure-v2* torrents + // (no v1 hash). Hybrid torrents connect via the v1 info-hash and we + // intentionally do not assert the BEP-52 v2 bit there: empirically + // some swarms (notably CN Thunder/Xunlei clients) close the + // connection right after the BT handshake when v2 is asserted on a + // v1 info_hash + let advertise_v2 = matches!(meta.meta_version, crate::core::metainfo::MetaVersion::V2); let init = TorrentInit { meta: meta.clone(), lengths, @@ -480,8 +526,10 @@ impl Session { max_outstanding_per_peer: self.opts.max_outstanding_requests_per_peer, max_peers: self.opts.max_peers_per_torrent, encryption: self.opts.encryption, + advertise_v2, verifier, create_subfolder, + utp: self.utp.clone(), }; let handle = match spawn_torrent(id, init, self.peer_id, self.listen_port).await { Ok(h) => h, @@ -501,6 +549,43 @@ impl Session { if let Some(lsd) = self.lsd.lock().as_ref() { lsd.add_infohash(meta.info_hash); } + // Wire DHT peer discovery into the running torrent. The session DHT is + // otherwise only used during one-shot magnet resolution; running downloads + // discover peers via trackers, LSD, and inbound connections. For a + // trackerless torrent—or one with dead trackers, which is common for CN + // Thunder/Xunlei swarms—this leaves the download with no peer source so it + // stalls at 0% even though the swarm is reachable over DHT + if !info.private { + if let Some(dht) = self.dht.lock().clone() { + let info_hash = meta.info_hash; + let listen_port = self.listen_port; + let weak = Arc::downgrade(&handle); + tokio::spawn(async move { + loop { + // Stop polling once the torrent has been removed + let Some(t) = weak.upgrade() else { return }; + let cmd_tx = t.cmd_tx(); + drop(t); + // get_peers to find seeders AND announce_peer so other clients + // searching this info-hash can find and dial us + let mut rx = dht.announce_and_get_peers_stream( + info_hash, + std::time::Duration::from_secs(60), + listen_port, + ); + while let Some(addr) = rx.recv().await { + if cmd_tx.send(TorrentCommand::AddPeer(addr)).await.is_err() { + return // torrent loop ended + } + } + // A single lookup rarely returns the whole swarm and DHT peer sets + // churn; re-query on a steady cadence to keep the peer list topped up + // (mirrors a tracker re-announce interval) + tokio::time::sleep(std::time::Duration::from_secs(120)).await; + } + }); + } + } Ok(AddTorrentResponse::Added(id, handle)) } @@ -664,9 +749,19 @@ async fn run_accept_loop(listener: TcpListener, weak: std::sync::Weak) return; }; tokio::spawn(async move { - let allowed: Vec = s.inner.lock().by_hash.keys().copied().collect(); + let allowed: Vec = s + .inner + .lock() + .torrents + .values() + .map(|handle| KnownInfoHash { + info_hash: handle.info_hash, + advertise_v2: handle.advertise_v2.load(Ordering::Relaxed), + ext_handshake_builder: Some(handle.ext_handshake_builder.clone()), + }) + .collect(); let policy = s.opts.encryption; - let res = super::peer::accept_with_policy( + let res = super::peer::accept_with_policy_and_capabilities( stream, s.peer_id, allowed, @@ -691,3 +786,55 @@ async fn run_accept_loop(listener: TcpListener, weak: std::sync::Weak) } } } + +/// Inbound µTP (BEP-29) accept loop. Mirrors [`run_accept_loop`] but over the shared +/// µTP endpoint: each accepted connection runs the plaintext BT responder handshake +/// (µTP carries no MSE layer) and is routed to its torrent by info-hash. Parameterised +/// on a `Weak` so it does not keep the session alive; the session's Drop +/// aborts it via `utp_accept_handle` +async fn run_utp_accept_loop(utp: Arc, weak: std::sync::Weak) { + loop { + let stream = match utp.accept().await { + Ok(s) => s, + // The endpoint closed (last Arc dropped); nothing more to accept. + Err(_) => return, + }; + let Some(s) = weak.upgrade() else { + return; + }; + let addr = stream.peer_addr(); + tokio::spawn(async move { + let allowed: Vec = s + .inner + .lock() + .torrents + .values() + .map(|handle| KnownInfoHash { + info_hash: handle.info_hash, + advertise_v2: handle.advertise_v2.load(Ordering::Relaxed), + ext_handshake_builder: Some(handle.ext_handshake_builder.clone()), + }) + .collect(); + // No managed torrents—nothing this peer could be after, so drop the stream + // (its driver tears the connection down) + if allowed.is_empty() { + return; + } + let res = super::peer::accept_utp_plaintext( + stream, + s.peer_id, + allowed, + std::time::Duration::from_secs(30), + ) + .await; + match res { + Ok((handle, rx)) => { + s.route_inbound_peer(addr, handle.tx, rx).await; + } + Err(e) => { + log::debug!("inbound µTP peer handshake failed: {e}"); + } + } + }); + } +} diff --git a/src-tauri/risuko-bt/src/torrent.rs b/src-tauri/risuko-bt/src/torrent.rs index e7ad79be..3345aac8 100644 --- a/src-tauri/risuko-bt/src/torrent.rs +++ b/src-tauri/risuko-bt/src/torrent.rs @@ -5,7 +5,7 @@ pub mod stats; use std::collections::{HashMap, HashSet}; use std::net::SocketAddr; use std::path::PathBuf; -use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; use std::sync::Arc; use std::time::{Duration, Instant}; @@ -18,14 +18,16 @@ use tokio::sync::{mpsc, oneshot}; use tokio::time::{interval, MissedTickBehavior}; use super::core::{ - Id20, Lengths, MerkleProofTable, PieceVerifier, TorrentMeta, ValidatedTorrentMetaV1Info, + supports_v2_wire, Id20, Lengths, MerkleProofTable, PieceVerifier, TorrentMeta, + ValidatedTorrentMetaV1Info, }; -use super::peer::{connect, PeerCommand, PeerEvent, SpawnPeer}; +use super::peer::{connect_with_utp_fallback, PeerCommand, PeerEvent, SpawnPeer}; use super::piece::{ChunkTracker, PieceTracker}; use super::storage::{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}; -use super::wire::Message; +use super::wire::{Message, MessageEncoder}; pub use stats::{ AggregatedLiveStats, LiveStats, PeerSnapshot, Snapshot, SpeedSample, TorrentStats, @@ -89,9 +91,10 @@ const SNUB_EVICTION_TIMEOUT: Duration = Duration::from_secs(60); /// Peers send `Extended { ext_id: OUR_UT_METADATA_ID, .. }` to request a /// 16 KiB chunk of our raw `info` dict const OUR_UT_METADATA_ID: u8 = 3; -/// Per-peer message id we advertise for `ut_pex`. Currently unused on the -/// receive side (we don't accept PEX) but advertised so peers know we -/// support BEP-10 extension negotiation +/// Per-peer message id we advertise for `ut_pex` (BEP-11). Peers send +/// `Extended { ext_id: OUR_UT_PEX_ID, .. }` carrying gossiped swarm members; +/// we parse the `added`/`added6` fields and feed them into the dial path +/// (see the Extended handler) const OUR_UT_PEX_ID: u8 = 4; /// BEP-9 metadata piece size: every ut_metadata DATA carries up to one /// 16 KiB block of the info dict, except possibly the last @@ -105,6 +108,7 @@ pub struct TorrentInit { pub max_outstanding_per_peer: Option, pub max_peers: Option, pub encryption: super::peer::EncryptionPolicy, + pub advertise_v2: bool, /// Per-piece verifier strategy chosen at session-attach time. Hybrid /// and pure-v1 torrents use SHA-1; pure-v2 torrents use SHA-256 /// Merkle subtree verification @@ -114,6 +118,9 @@ pub struct TorrentInit { /// written directly under `root_dir`. Carried on `ManagedTorrent` /// so `Session::delete(with_files=true)` can locate the right paths pub create_subfolder: bool, + /// Shared µTP (BEP-29) endpoint for this session. When present, outbound + /// dials that fail over TCP retry over µTP. `None` disables µTP. + pub utp: Option>, } #[derive(Debug)] @@ -144,6 +151,20 @@ pub struct ManagedTorrent { /// can target the actual output location rather than the session /// default `output_dir` (per-torrent `opts.output_folder` overrides). pub root_dir: PathBuf, + /// Atomically updated by `torrent_loop` after Merkle tables are built. + /// Starts as `init.advertise_v2`; clamped to `false` when `serve_v2_layers` + /// turns out to be false so inbound handshakes never assert the v2 bit + /// without valid Merkle proof tables + pub advertise_v2: Arc, + /// Pre-serialized BEP-10 extended handshake — encoded once at spawn + /// time from the torrent's `info_bytes` length and our extension ids. + /// Per-peer builder for the BEP-10 extended handshake bytes. Captures + /// this torrent's metadata size and our extension ids; takes the peer's + /// IP address per call so we can populate `yourip`. The accept loop + /// hands a clone of this builder to the connection layer so inbound + /// peers receive our extended handshake on the same async frame as + /// the BT handshake exchange completes (and with `yourip` set) + pub ext_handshake_builder: crate::peer::ExtHandshakeBuilder, pub(crate) cmd_tx: mpsc::Sender, pub(crate) stats: Arc>, } @@ -200,6 +221,19 @@ pub async fn spawn( ))); let meta_arc = Arc::new(init.meta.clone()); let metadata_swap = ArcSwapOption::new(Some(meta_arc)); + let ext_handshake_builder: crate::peer::ExtHandshakeBuilder = { + let metadata_size = init.meta.info_bytes.len() as u64; + std::sync::Arc::new(move |peer_ip: std::net::IpAddr| { + let hs = + ExtHandshake::new_outgoing(OUR_UT_METADATA_ID, OUR_UT_PEX_ID, Some(metadata_size)) + .with_yourip(peer_ip); + MessageEncoder::encode(&Message::Extended { + ext_id: EXT_HANDSHAKE_ID, + payload: hs.encode(), + }) + }) + }; + let advertise_v2_flag = Arc::new(AtomicBool::new(init.advertise_v2)); let handle = Arc::new(ManagedTorrent { id, info_hash, @@ -207,6 +241,8 @@ pub async fn spawn( metadata: metadata_swap, create_subfolder: init.create_subfolder, root_dir: init.root_dir.clone(), + advertise_v2: Arc::clone(&advertise_v2_flag), + ext_handshake_builder, cmd_tx, stats: stats.clone(), }); @@ -217,6 +253,7 @@ pub async fn spawn( listen_port, cmd_rx, stats, + advertise_v2_flag, )); Ok(handle) } @@ -271,10 +308,6 @@ struct Peer { /// (info is already loaded) but record the id so the responder can /// echo it back on DATA / REJECT replies their_ut_metadata_id: Option, - /// Set once we have sent our own BEP-10 extended handshake on this - /// connection. Guards against re-sending if the peer pings us with a - /// fresh handshake mid-session - sent_ext_handshake: bool, } /// Result of an off-runtime piece write + verify pass @@ -323,6 +356,7 @@ async fn torrent_loop( listen_port: u16, mut cmd_rx: mpsc::Receiver, stats: Arc>, + advertise_v2_flag: Arc, ) { let info = Arc::new(init.meta.info.clone()); let info_hash = init.meta.info_hash; @@ -332,6 +366,14 @@ async fn torrent_loop( let info_bytes: Arc> = Arc::new(init.meta.info_bytes.clone()); let lengths = init.lengths; let encryption = init.encryption; + // Shared µTP endpoint (if any), handed to every outbound dial so a failed + // TCP connect can retry over µTP. + let utp = init.utp.clone(); + // Preliminary: whether the meta supports v2 wire. Refined below to + // `supports_v2_wire && hash_tables.is_some()` after table construction + // so a failed build never leads us to announce truncated v2 hashes + // or answer BEP-52 HASH_REQUEST messages without valid Merkle data + let supports_v2 = supports_v2_wire(&init.meta); let verifier = init.verifier; // V2 Merkle tables for serving HASH_REQUEST — built from the meta for // any torrent that carries v2 data (pure-v2 or hybrid). Hybrid torrents @@ -340,33 +382,37 @@ async fn torrent_loop( let hash_tables: Option>> = { if let PieceVerifier::V2Merkle { ref tables, .. } = verifier { Some(Arc::clone(tables)) - } else if let Some(ref v2) = init.meta.info_v2 { - // Hybrid torrent: build Merkle tables from the meta's piece_layers - let mut tbls = Vec::with_capacity(v2.files.len()); - let mut ok = true; - for f in &v2.files { - let layer = init - .meta - .piece_layers - .get(&f.pieces_root) - .map(|v| v.as_slice()) - .unwrap_or(&[]); - match super::core::MerkleProofTable::from_layer_bytes( - f.pieces_root, - f.length, - v2.piece_length, - layer, - ) { - Ok(t) => tbls.push(t), - Err(e) => { - log::warn!("hybrid torrent {info_hash}: could not build Merkle table for serving: {e}"); - ok = false; - break; + } else if supports_v2 { + if let Some(ref v2) = init.meta.info_v2 { + // Hybrid torrent: build Merkle tables from the meta's piece_layers + let mut tbls = Vec::with_capacity(v2.files.len()); + let mut ok = true; + for f in &v2.files { + let layer = init + .meta + .piece_layers + .get(&f.pieces_root) + .map(|v| v.as_slice()) + .unwrap_or(&[]); + match super::core::MerkleProofTable::from_layer_bytes( + f.pieces_root, + f.length, + v2.piece_length, + layer, + ) { + Ok(t) => tbls.push(t), + Err(e) => { + log::warn!("hybrid torrent {info_hash}: could not build Merkle table for serving: {e}"); + ok = false; + break; + } } } - } - if ok { - Some(Arc::new(tbls)) + if ok { + Some(Arc::new(tbls)) + } else { + None + } } else { None } @@ -374,6 +420,15 @@ async fn torrent_loop( None } }; + // True only when we have valid Merkle tables: gates BEP-52 HASH_REQUEST + // serving AND v2/truncated info-hash announcement on trackers. + // A failed from_layer_bytes build leaves hash_tables = None so we must + // not advertise v2 capability in that case. + let serve_v2_layers = supports_v2 && hash_tables.is_some(); + // Clamp to actual capability now that hash_tables is known. Updates the + // shared handle field so inbound connection handling sees the same value + let advertise_v2 = init.advertise_v2 && serve_v2_layers; + advertise_v2_flag.store(advertise_v2, Ordering::Relaxed); // Per-peer pipeline depth bounds. When the session explicitly sets // `max_outstanding_per_peer` we honour it as a fixed value (legacy // behaviour, useful for benchmarks / debugging); otherwise we use @@ -405,14 +460,47 @@ async fn torrent_loop( s.finished = piece_tracker.is_complete(); } - let mut peer_addr_rx = spawn_tracker_pollers( + // Truncated v2 hashes are only meaningful on trackers when we can + // actually serve v2 piece layers; otherwise advertise the v1 info-hash + // alone so peers we discover go through the v1 download path + let announce_hashes = if serve_v2_layers { + init.meta.announce_infohashes() + } else { + vec![info_hash] + }; + // Unified peer-source channel: trackers and inbound PEX (ut_pex) both + // 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( + peer_src_tx.clone(), collect_trackers(&init.meta), - init.meta.announce_infohashes(), + announce_hashes, our_peer_id, listen_port, Arc::clone(&stats), ); + // Pre-serialize the BEP-10 extended handshake once. The connection + // layer ships this on the wire in the same async frame that just + // completed the BT handshake, eliminating tokio task hops between + // "BT handshake done" and "ext handshake on the wire". Some peers RST + // the connection if our follow-up doesn't arrive within their grace + // window. The builder is invoked per-peer so each `yourip` field + // matches the dialed peer's address + let initial_ext_handshake_builder: crate::peer::ExtHandshakeBuilder = { + let metadata_size = info_bytes.len() as u64; + std::sync::Arc::new(move |peer_ip: std::net::IpAddr| { + let hs = + ExtHandshake::new_outgoing(OUR_UT_METADATA_ID, OUR_UT_PEX_ID, Some(metadata_size)) + .with_yourip(peer_ip); + MessageEncoder::encode(&Message::Extended { + ext_id: EXT_HANDSHAKE_ID, + payload: hs.encode(), + }) + }) + }; + // Large enough to not block peers: with MAX_PEERS peers each potentially // delivering MAX_OUTSTANDING_PER_PEER Piece events in rapid succession, // undersizing this channel serializes the entire download @@ -460,7 +548,18 @@ async fn torrent_loop( { let pid = next_pid; next_pid += 1; pending_dials.insert(pid, addr); - spawn_outbound_peer(torrent_id, pid, addr, info_hash, our_peer_id, peer_event_tx.clone(), encryption); + spawn_outbound_peer( + torrent_id, + pid, + addr, + info_hash, + our_peer_id, + peer_event_tx.clone(), + encryption, + advertise_v2, + Some(initial_ext_handshake_builder.clone()), + utp.clone(), + ); } } TorrentCommand::AddInboundPeer { addr, cmd_tx, event_rx } => { @@ -506,7 +605,18 @@ async fn torrent_loop( { let pid = next_pid; next_pid += 1; pending_dials.insert(pid, addr); - spawn_outbound_peer(torrent_id, pid, addr, info_hash, our_peer_id, peer_event_tx.clone(), encryption); + spawn_outbound_peer( + torrent_id, + pid, + addr, + info_hash, + our_peer_id, + peer_event_tx.clone(), + encryption, + advertise_v2, + Some(initial_ext_handshake_builder.clone()), + utp.clone(), + ); } } Some((pid, ev)) = peer_event_rx.recv() => { @@ -517,6 +627,7 @@ async fn torrent_loop( &upload_tick, &mut write_tasks, &mut pending_dials, &mut known_addrs, + &peer_src_tx, &verify_tx, &verifier, &info_bytes, @@ -708,6 +819,7 @@ mod peer_registry { } } +#[allow(clippy::too_many_arguments)] fn spawn_outbound_peer( torrent_id: usize, pid: u32, @@ -716,20 +828,22 @@ fn spawn_outbound_peer( our_peer_id: Id20, event_tx: mpsc::Sender<(u32, PeerEvent)>, encryption: crate::peer::EncryptionPolicy, + advertise_v2: bool, + ext_handshake_builder: Option, + utp: Option>, ) { tokio::spawn(async move { let spawn = SpawnPeer { addr, info_hash, our_peer_id, - // 5 s is plenty for any reachable peer; 10 s used to park dial - // slots for unreachable peers and starve real connections, since - // a single tracker batch can include many dead addresses - connect_timeout: Duration::from_secs(5), + connect_timeout: Duration::from_secs(10), read_timeout: Duration::from_secs(120), encryption, + advertise_v2, + ext_handshake_builder, }; - match connect(spawn).await { + 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 { @@ -764,6 +878,19 @@ async fn adopt_inbound_peer( piece_tracker: &mut PieceTracker, pipeline_floor: usize, ) { + // Install the peer directly in the per-loop `peers` map. We deliberately + // do *not* route through the shared `peer_registry` (used for outbound + // dials); the registry is keyed by `(torrent_id, pid)` and torrent ids + // are not unique across sessions running in the same process — using it + // for both directions would cause cross-session take/put collisions in + // any environment that hosts multiple sessions (notably integration + // tests, but also future per-user multi-session deployments). + // + // The ext-handshake is already on the wire (the connection layer wrote + // it inline before pushing the `Handshook` event), so we only need to + // emit the optional bitfield + unchoke here. `process_peer_event`'s + // `Handshook` arm short-circuits on `peers.contains_key(pid)`, so the + // forwarded event is a benign no-op peers.insert( pid, Peer { @@ -781,16 +908,15 @@ async fn adopt_inbound_peer( last_recv: Instant::now(), snub_since: None, their_ut_metadata_id: None, - sent_ext_handshake: false, }, ); - // Seed bitfield + unchoke straight away let bf = piece_tracker.bitfield(); - let _ = cmd_tx - .send(PeerCommand::Send(Message::Bitfield(Bytes::from(bf)))) - .await; + if should_send_initial_bitfield(&bf) { + let _ = cmd_tx + .send(PeerCommand::Send(Message::Bitfield(Bytes::from(bf)))) + .await; + } let _ = cmd_tx.send(PeerCommand::Send(Message::Unchoke)).await; - // Mark am_choking false so Request from peer is served if let Some(p) = peers.get_mut(&pid) { p.am_choking = false; } @@ -820,6 +946,7 @@ async fn process_peer_event( write_tasks: &mut tokio::task::JoinSet<()>, pending_dials: &mut HashMap, known_addrs: &mut HashSet, + peer_src_tx: &mpsc::Sender, verify_tx: &mpsc::Sender, verifier: &PieceVerifier, info_bytes: &Arc>, @@ -833,11 +960,7 @@ async fn process_peer_event( // (Piece) or unblock requests (Unchoke, Bitfield, Have) let mut kick = false; match ev { - PeerEvent::Handshook { - reserved, - encrypted, - .. - } => { + PeerEvent::Handshook { encrypted, .. } => { if !peers.contains_key(&pid) { if let Some((cmd_tx, registry_addr)) = peer_registry::take(torrent_id, pid) { // Move from pending dial to live peer. The registry is @@ -879,44 +1002,23 @@ async fn process_peer_event( last_recv: Instant::now(), snub_since: None, their_ut_metadata_id: None, - sent_ext_handshake: false, }, ); + // Extended handshake (when peer supports BEP-10) is + // already on the wire — the connection layer wrote it + // synchronously before the Handshook event was emitted let bf = piece_tracker.bitfield(); - let _ = cmd_tx - .send(PeerCommand::Send(Message::Bitfield(Bytes::from(bf)))) - .await; + if should_send_initial_bitfield(&bf) { + let _ = cmd_tx + .send(PeerCommand::Send(Message::Bitfield(Bytes::from(bf)))) + .await; + } let _ = cmd_tx.send(PeerCommand::Send(Message::Unchoke)).await; if let Some(p) = peers.get_mut(&pid) { p.am_choking = false; } } } - // BEP-10: if the peer advertised the extension protocol bit - // in its handshake, send our extended handshake announcing - // the `ut_metadata` id we want them to use, plus the size of - // our raw info dict so they can fetch it via BEP-9. Doing - // this for every Handshook (inbound + outbound) lets a - // magnet leecher dial us and pull the info dict back - let supports_ext = super::wire::handshake::reserved::EXT_PROTOCOL; - if reserved[supports_ext.0] & supports_ext.1 != 0 { - if let Some(peer) = peers.get_mut(&pid) { - if !peer.sent_ext_handshake { - peer.sent_ext_handshake = true; - let metadata_size = info_bytes.len() as u64; - let hs = ExtHandshake::new_outgoing( - OUR_UT_METADATA_ID, - OUR_UT_PEX_ID, - Some(metadata_size), - ); - let payload = hs.encode(); - let _ = peer.cmd_tx.try_send(PeerCommand::Send(Message::Extended { - ext_id: EXT_HANDSHAKE_ID, - payload, - })); - } - } - } } PeerEvent::Message(msg) => { let Some(peer) = peers.get_mut(&pid) else { @@ -928,6 +1030,32 @@ async fn process_peer_event( // and recycle their slot to a fresh dial \u2014 see the eviction // sweep in the `tick.tick()` arm peer.last_recv = Instant::now(); + { + let kind = match &msg { + Message::KeepAlive => "KeepAlive".to_string(), + Message::Choke => "Choke".to_string(), + Message::Unchoke => "Unchoke".to_string(), + Message::Interested => "Interested".to_string(), + Message::NotInterested => "NotInterested".to_string(), + Message::Have { piece_index } => format!("Have({piece_index})"), + Message::Bitfield(b) => { + let set: u32 = b.iter().map(|x| x.count_ones()).sum(); + format!("Bitfield(len={} set_bits={})", b.len(), set) + } + Message::Piece { index, begin, data } => { + format!("Piece(i={index} b={begin} len={})", data.len()) + } + Message::Unknown { id, payload } => { + format!("Unknown(id={id} len={})", payload.len()) + } + other => format!("{other:?}"), + }; + log::debug!( + target: "diag", + "RX {} {kind} am_interested={} peer_choking={} am_choking={}", + peer.addr, peer.am_interested, peer.peer_choking, peer.am_choking + ); + } match msg { Message::Choke => peer.peer_choking = true, Message::Unchoke => { @@ -1179,15 +1307,13 @@ async fn process_peer_event( }); } } - // BEP 52 hash-exchange messages. We honour requests for - // an entire piece-layer at the piece base layer (the - // common shape used by the magnet resolver) by serving - // from the v2 verifier's `MerkleProofTable`. Other - // request shapes (sub-piece-layer leaf requests, partial - // ranges with proof_layers > 0) are answered with - // `HashReject` — a valid BEP 52 outcome that prompts the - // peer to fall back to v1 or another seeder. Inbound - // `Hashes` / `HashReject` we did not request are dropped + // BEP 52 hash-exchange messages. We honour requests for an entire + // piece-layer at the piece base layer (the common shape used by the + // magnet resolver) by serving from the v2 verifier's `MerkleProofTable`. + // Other request shapes (sub-piece-layer leaf requests, partial ranges + // with proof_layers > 0) are answered with `HashReject`—a valid BEP 52 + // outcome that prompts the peer to fall back to v1 or another seeder. + // Inbound `Hashes` / `HashReject` we did not request are dropped Message::HashRequest { pieces_root, base_layer, @@ -1203,25 +1329,22 @@ async fn process_peer_event( length, proof_layers, ); - // try_send: a `.await` here would stall the entire - // torrent loop on a single peer's backed-up writer - // queue. HashReject / Hashes are best-effort — if the - // peer's command channel is full it will time out its - // own request and either retry or fall back to v1 + // try_send: a `.await` here would stall the entire torrent loop on a + // single peer's backed-up writer queue. HashReject / Hashes are + // best-effort—if the peer's command channel is full it will time out + // its own request and either retry or fall back to v1 let _ = peer.cmd_tx.try_send(PeerCommand::Send(response)); } Message::Hashes { .. } | Message::HashReject { .. } => { // Discard: no outstanding HASH_REQUEST to correlate } - // BEP-10 extended messages. We respond to: - // - The handshake itself (`ext_id == 0`): record the - // peer's `ut_metadata` id so subsequent REQUESTs can - // be validated. - // - `ut_metadata` REQUESTs (`ext_id == OUR_UT_METADATA_ID`): - // serve a 16 KiB block of our raw info dict, or - // REJECT for out-of-range pieces. - // PEX (`ut_pex`) is advertised but not handled here - // beyond ignoring inbound payloads + // BEP-10 extended messages. We handle: + // - The handshake itself (`ext_id == 0`): record the peer's `ut_metadata` + // id so subsequent REQUESTs can be validated + // - `ut_metadata` REQUESTs (`ext_id == OUR_UT_METADATA_ID`): serve a 16 KiB + // block of our raw info dict, or REJECT for out-of-range pieces + // - `ut_pex` (`ext_id == OUR_UT_PEX_ID`): BEP-11 peer exchange—feed gossiped + // peers into the dial path Message::Extended { ext_id, payload } => { if ext_id == EXT_HANDSHAKE_ID { if let Some(peer_ext) = ExtHandshake::decode(&payload) { @@ -1229,15 +1352,21 @@ async fn process_peer_event( } } else if ext_id == OUR_UT_METADATA_ID { serve_ut_metadata(peer, &payload, info_bytes); + } else if ext_id == OUR_UT_PEX_ID { + // A connected peer (often a seeder) gossips other swarm members + if let Some((v4, v6)) = super::wire::extended::parse_ut_pex(&payload) { + for addr in v4.into_iter().chain(v6) { + let _ = peer_src_tx.try_send(addr); + } + } } } _ => {} } } PeerEvent::Disconnected { reason } => { - // Clear from either in-flight or live, and release the address - // for future retries (otherwise a single drop permanently - // blacklists the peer). + // Clear from either in-flight or live, and release the address for future + // retries (otherwise a single drop permanently blacklists the peer) let addr = pending_dials .remove(&pid) .or_else(|| peers.get(&pid).map(|p| p.addr)); @@ -1348,12 +1477,12 @@ async fn process_verify_result( } } -/// Clear the endgame flag once the working set has grown back above the -/// activation threshold. Endgame is otherwise a one-way ratchet (set when -/// `pending_chunks() <= 64`, never cleared), which prevents pieces re-queued -/// via `reset_piece` after a hash/write failure from regaining the cheap -/// sequential-scan path in `next_chunk` and forces every peer through the -/// `choose_piece_excluding` fallback for the rest of the download +/// Clear the endgame flag once the working set has grown back above the activation +/// threshold. Endgame is otherwise a one-way ratchet (set when `pending_chunks() <= 64`, +/// never cleared), which prevents pieces re-queued via `reset_piece` after a +/// hash/write failure from regaining the cheap sequential-scan path in `next_chunk` +/// and forces every peer through the `choose_piece_excluding` fallback for the rest +/// of the download fn maybe_clear_endgame(chunk_tracker: &mut ChunkTracker) { if chunk_tracker.endgame() && chunk_tracker.pending_chunks() > 64 { chunk_tracker.set_endgame(false); @@ -1361,7 +1490,14 @@ fn maybe_clear_endgame(chunk_tracker: &mut ChunkTracker) { } async fn send_interested_if_useful(peer: &mut Peer, piece_tracker: &mut PieceTracker) { - if !peer.am_interested && piece_tracker.choose_piece(&peer.bitfield).is_some() { + let useful = piece_tracker.choose_piece(&peer.bitfield).is_some(); + let set_bits: u32 = peer.bitfield.iter().map(|x| x.count_ones()).sum(); + log::debug!( + target: "diag", + "send_interested_if_useful {} am_interested={} useful={} my_bitfield_set={}", + peer.addr, peer.am_interested, useful, set_bits + ); + if !peer.am_interested && useful { peer.am_interested = true; let _ = peer .cmd_tx @@ -1684,6 +1820,10 @@ fn peer_bitfield_is_full(bitfield: &[u8], total_pieces: usize) -> bool { bitfield[full_bytes] & mask == mask } +fn should_send_initial_bitfield(bitfield: &[u8]) -> bool { + bitfield.iter().any(|b| *b != 0) +} + fn collect_trackers(meta: &TorrentMeta) -> Vec { let mut v = Vec::new(); if let Some(a) = &meta.announce { @@ -1700,13 +1840,13 @@ fn collect_trackers(meta: &TorrentMeta) -> Vec { } fn spawn_tracker_pollers( + tx: mpsc::Sender, trackers: Vec, info_hashes: Vec, peer_id: Id20, port: u16, stats: Arc>, -) -> mpsc::Receiver { - let (tx, rx) = mpsc::channel(256); +) { for url in trackers { for info_hash in &info_hashes { let tx = tx.clone(); @@ -1770,7 +1910,6 @@ fn spawn_tracker_pollers( }); } } - rx } /// Build a `HASHES` / `HashReject` reply for an inbound BEP 52 @@ -1949,6 +2088,19 @@ mod tests { } } + #[test] + fn initial_bitfield_is_suppressed_when_empty() { + assert!(!should_send_initial_bitfield(&[])); + assert!(!should_send_initial_bitfield(&[0])); + assert!(!should_send_initial_bitfield(&[0, 0, 0])); + } + + #[test] + fn initial_bitfield_is_sent_when_any_piece_is_local() { + assert!(should_send_initial_bitfield(&[0x80])); + assert!(should_send_initial_bitfield(&[0, 0x01])); + } + #[test] fn build_hash_response_rejects_unknown_root() { let (tables, _root) = make_v2_tables(4, 64 * 1024); diff --git a/src-tauri/risuko-bt/src/utp.rs b/src-tauri/risuko-bt/src/utp.rs new file mode 100644 index 00000000..c94ec1c9 --- /dev/null +++ b/src-tauri/risuko-bt/src/utp.rs @@ -0,0 +1,32 @@ +//! µTP (BEP-29) — Micro Transport Protocol over UDP. +//! +//! An additive, TCP-alternative transport for the BitTorrent peer wire. The +//! peer connection layer is already generic over `AsyncRead + AsyncWrite` +//! (see `peer::connection::finish_spawn`), so a [`stream::UtpStream`] can be +//! dialed in place of a `TcpStream` and the BT/MSE handshake runs unchanged +//! on top of it. +//! +//! Module layout: +//! - [`packet`] — the wire header + extension codec (pure, no I/O). +//! - [`socket`] — the shared UDP endpoint that demuxes datagrams to per-peer +//! connection state machines. +//! - [`stream`] — the per-connection `AsyncRead`/`AsyncWrite` handle. + +pub mod packet; +pub mod socket; +pub mod stream; + +pub use socket::UtpSocket; +pub use stream::UtpStream; + +/// Microsecond timestamp from a monotonic clock, truncated to the 32-bit +/// field µTP uses. Only differences matter, so wraparound is harmless. +pub fn now_micros() -> u32 { + use std::sync::OnceLock; + use std::time::Instant; + // Anchor to a fixed start so the value is a small monotonic microsecond + // counter rather than nanoseconds-since-epoch truncated unpredictably. + static START: OnceLock = OnceLock::new(); + let start = START.get_or_init(Instant::now); + start.elapsed().as_micros() as u32 +} diff --git a/src-tauri/risuko-bt/src/utp/packet.rs b/src-tauri/risuko-bt/src/utp/packet.rs new file mode 100644 index 00000000..77b7c6f7 --- /dev/null +++ b/src-tauri/risuko-bt/src/utp/packet.rs @@ -0,0 +1,268 @@ +//! µTP (BEP-29) packet header + extension codec. +//! +//! Wire layout of the fixed 20-byte header (all multi-byte fields big-endian): +//! +//! ```text +//! 0 4 8 16 24 32 +//! +-------+-------+---------------+-------------------------------+ +//! | type | ver=1 | extension | connection_id | +//! +-------+-------+---------------+-------------------------------+ +//! | timestamp_microseconds | +//! +---------------------------------------------------------------+ +//! | timestamp_difference_microseconds | +//! +---------------------------------------------------------------+ +//! | wnd_size | +//! +-------------------------------+-------------------------------+ +//! | seq_nr | ack_nr | +//! +-------------------------------+-------------------------------+ +//! ``` +//! +//! `type` is the high nibble of byte 0, `ver` the low nibble. A non-zero +//! `extension` byte introduces a linked list of `(next_ext, len, data)` +//! records following the header; the only one we care about is Selective +//! ACK (extension type 1), whose `data` is a little-endian-bit bitmask of +//! sequence numbers received past `ack_nr`. + +use std::io; + +/// µTP protocol version we speak (the only version defined by BEP-29). +pub const UTP_VERSION: u8 = 1; +/// Length of the fixed µTP header. Extensions and payload follow. +pub const HEADER_LEN: usize = 20; +/// Extension id for Selective ACK (BEP-29). +const EXT_SELECTIVE_ACK: u8 = 1; + +/// µTP packet type (the high nibble of the first header byte). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PacketType { + /// Carries application data. + Data = 0, + /// Sender is done writing; consumes a sequence number like Data. + Fin = 1, + /// Pure acknowledgement; does NOT consume a sequence number. + State = 2, + /// Hard reset / abort. + Reset = 3, + /// Connection initiation. + Syn = 4, +} + +impl PacketType { + fn from_nibble(v: u8) -> Option { + match v { + 0 => Some(Self::Data), + 1 => Some(Self::Fin), + 2 => Some(Self::State), + 3 => Some(Self::Reset), + 4 => Some(Self::Syn), + _ => None, + } + } +} + +/// A decoded µTP header. Payload bytes are returned separately by +/// [`UtpHeader::decode`] so the header type stays `Copy`-cheap to clone. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct UtpHeader { + pub packet_type: PacketType, + /// Connection id this packet is stamped with. The receiver demuxes on it. + pub connection_id: u16, + /// Microsecond timestamp sampled by the sender when the packet left. + pub timestamp_micros: u32, + /// Sender's measured one-way delay (its `now - peer_timestamp`). Drives + /// LEDBAT congestion control on the receiver side. + pub timestamp_diff_micros: u32, + /// Bytes of receive-buffer space the sender still has free (flow control). + pub wnd_size: u32, + /// Sequence number of this packet (meaningful for Data/Fin/Syn). + pub seq_nr: u16, + /// Highest in-order sequence number the sender has received. + pub ack_nr: u16, + /// Selective-ACK bitmask (extension type 1) if the packet carried one. + /// Bit `i` (LSB-first within each byte) acks `ack_nr + 2 + i`. + pub selective_ack: Option>, +} + +impl UtpHeader { + /// Serialize this header plus `payload` into a single datagram body. + pub fn encode(&self, payload: &[u8]) -> Vec { + let sack = self.selective_ack.as_deref().filter(|s| !s.is_empty()); + let ext_len = sack.map_or(0, |s| 2 + s.len()); + let mut out = Vec::with_capacity(HEADER_LEN + ext_len + payload.len()); + out.push(((self.packet_type as u8) << 4) | UTP_VERSION); + out.push(if sack.is_some() { EXT_SELECTIVE_ACK } else { 0 }); + out.extend_from_slice(&self.connection_id.to_be_bytes()); + out.extend_from_slice(&self.timestamp_micros.to_be_bytes()); + out.extend_from_slice(&self.timestamp_diff_micros.to_be_bytes()); + out.extend_from_slice(&self.wnd_size.to_be_bytes()); + out.extend_from_slice(&self.seq_nr.to_be_bytes()); + out.extend_from_slice(&self.ack_nr.to_be_bytes()); + if let Some(s) = sack { + // Single extension, then end-of-chain marker (0). + out.push(0); + out.push(s.len() as u8); + out.extend_from_slice(s); + } + out.extend_from_slice(payload); + out + } + + /// Parse a datagram body into a header and its trailing payload slice. + pub fn decode(buf: &[u8]) -> io::Result<(UtpHeader, &[u8])> { + if buf.len() < HEADER_LEN { + return Err(io::Error::new( + io::ErrorKind::UnexpectedEof, + "utp packet shorter than header", + )); + } + if buf[0] & 0x0f != UTP_VERSION { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "unsupported utp version", + )); + } + let packet_type = PacketType::from_nibble(buf[0] >> 4) + .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidData, "unknown utp packet type"))?; + let connection_id = u16::from_be_bytes([buf[2], buf[3]]); + let timestamp_micros = u32::from_be_bytes([buf[4], buf[5], buf[6], buf[7]]); + let timestamp_diff_micros = u32::from_be_bytes([buf[8], buf[9], buf[10], buf[11]]); + let wnd_size = u32::from_be_bytes([buf[12], buf[13], buf[14], buf[15]]); + let seq_nr = u16::from_be_bytes([buf[16], buf[17]]); + let ack_nr = u16::from_be_bytes([buf[18], buf[19]]); + + // Walk the extension chain. `ext` names the type of the record at + // `off`; a value of 0 terminates the list. + let mut ext = buf[1]; + let mut off = HEADER_LEN; + let mut selective_ack = None; + while ext != 0 { + if off + 2 > buf.len() { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "truncated utp extension record", + )); + } + let next_ext = buf[off]; + let len = buf[off + 1] as usize; + off += 2; + if off + len > buf.len() { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "truncated utp extension data", + )); + } + if ext == EXT_SELECTIVE_ACK { + selective_ack = Some(buf[off..off + len].to_vec()); + } + off += len; + ext = next_ext; + } + + Ok(( + UtpHeader { + packet_type, + connection_id, + timestamp_micros, + timestamp_diff_micros, + wnd_size, + seq_nr, + ack_nr, + selective_ack, + }, + &buf[off..], + )) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn sample(packet_type: PacketType) -> UtpHeader { + UtpHeader { + packet_type, + connection_id: 0xABCD, + timestamp_micros: 0x11223344, + timestamp_diff_micros: 0x55667788, + wnd_size: 0x0010_0000, + seq_nr: 0x0102, + ack_nr: 0x0304, + selective_ack: None, + } + } + + #[test] + fn data_packet_round_trips_with_payload() { + let h = sample(PacketType::Data); + let payload = b"hello utp"; + let bytes = h.encode(payload); + assert_eq!(bytes.len(), HEADER_LEN + payload.len()); + // First byte: type in high nibble, version in low nibble. + assert_eq!(bytes[0], (PacketType::Data as u8) << 4 | UTP_VERSION); + assert_eq!(bytes[1], 0); // no extension + let (decoded, rest) = UtpHeader::decode(&bytes).unwrap(); + assert_eq!(decoded, h); + assert_eq!(rest, payload); + } + + #[test] + fn every_packet_type_round_trips() { + for ty in [ + PacketType::Data, + PacketType::Fin, + PacketType::State, + PacketType::Reset, + PacketType::Syn, + ] { + let h = sample(ty); + let bytes = h.encode(&[]); + let (decoded, rest) = UtpHeader::decode(&bytes).unwrap(); + assert_eq!(decoded.packet_type, ty); + assert!(rest.is_empty()); + } + } + + #[test] + fn selective_ack_extension_round_trips() { + let mut h = sample(PacketType::State); + h.selective_ack = Some(vec![0b1010_0101, 0x00, 0x00, 0xFF]); + let bytes = h.encode(&[]); + assert_eq!(bytes[1], EXT_SELECTIVE_ACK); + // Extension record: next_ext=0, len=4, then 4 bytes of mask. + assert_eq!(bytes[HEADER_LEN], 0); + assert_eq!(bytes[HEADER_LEN + 1], 4); + let (decoded, rest) = UtpHeader::decode(&bytes).unwrap(); + assert_eq!(decoded.selective_ack, h.selective_ack); + assert!(rest.is_empty()); + } + + #[test] + fn payload_after_extension_is_recovered() { + let mut h = sample(PacketType::Data); + h.selective_ack = Some(vec![0xFF, 0xFF, 0xFF, 0xFF]); + let payload = b"data-after-sack"; + let bytes = h.encode(payload); + let (decoded, rest) = UtpHeader::decode(&bytes).unwrap(); + assert_eq!(decoded, h); + assert_eq!(rest, payload); + } + + #[test] + fn decode_rejects_short_buffer() { + assert!(UtpHeader::decode(&[0u8; HEADER_LEN - 1]).is_err()); + } + + #[test] + fn decode_rejects_bad_version() { + let mut bytes = sample(PacketType::Data).encode(&[]); + bytes[0] = (PacketType::Data as u8) << 4 | 2; // version 2 + assert!(UtpHeader::decode(&bytes).is_err()); + } + + #[test] + fn decode_rejects_truncated_extension() { + let mut bytes = sample(PacketType::State).encode(&[]); + bytes[1] = EXT_SELECTIVE_ACK; // claim an extension that isn't there + assert!(UtpHeader::decode(&bytes).is_err()); + } +} diff --git a/src-tauri/risuko-bt/src/utp/socket.rs b/src-tauri/risuko-bt/src/utp/socket.rs new file mode 100644 index 00000000..d4a882e4 --- /dev/null +++ b/src-tauri/risuko-bt/src/utp/socket.rs @@ -0,0 +1,312 @@ +//! The shared µTP endpoint: one UDP socket multiplexing many connections. +//! +//! A background router task reads every datagram and dispatches it to the +//! right connection driver, keyed by `(peer_addr, our_recv_conn_id)`. A SYN +//! for an unknown key opens a new inbound connection and enqueues its +//! [`UtpStream`] for [`UtpSocket::accept`]. + +use std::collections::HashMap; +use std::io; +use std::net::SocketAddr; +use std::sync::Arc; +use std::time::Duration; + +use parking_lot::Mutex; +use rand::RngExt; +use tokio::net::UdpSocket; +use tokio::sync::{mpsc, oneshot}; + +use super::packet::{PacketType, UtpHeader}; +use super::stream::{self, DriverConfig, Role, RoleKind, UtpStream}; + +/// Demux key: a connection is identified by (peer address, our receive id). +pub(crate) type ConnKey = (SocketAddr, u16); + +/// Maps each live connection to the channel its driver reads packets from. +pub(crate) type ConnRegistry = + 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 DEFAULT_CONNECT_TIMEOUT: Duration = Duration::from_secs(10); + +/// A µTP endpoint sharing a single UDP socket across all its connections. +pub struct UtpSocket { + udp: Arc, + registry: ConnRegistry, + local_addr: SocketAddr, + accept_rx: tokio::sync::Mutex>, +} + +impl UtpSocket { + /// Bind a fresh UDP socket and start serving µTP on it. + pub async fn bind(addr: SocketAddr) -> io::Result> { + let udp = UdpSocket::bind(addr).await?; + Ok(Self::from_udp(Arc::new(udp))) + } + + /// Build a µTP endpoint over an existing UDP socket (e.g. one shared with + /// another protocol on the same port). + pub fn from_udp(udp: Arc) -> Arc { + let local_addr = udp + .local_addr() + .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)); + Arc::new(Self { + udp, + registry, + local_addr, + accept_rx: tokio::sync::Mutex::new(accept_rx), + }) + } + + pub fn local_addr(&self) -> SocketAddr { + self.local_addr + } + + /// Dial a peer over µTP, resolving once the handshake completes. + pub async fn connect(&self, remote: SocketAddr) -> io::Result { + self.connect_timeout(remote, DEFAULT_CONNECT_TIMEOUT).await + } + + pub async fn connect_timeout( + &self, + remote: SocketAddr, + timeout: Duration, + ) -> io::Result { + // Reserve a receive id that doesn't collide with an existing + // connection to this peer (and register the incoming channel). + let (key, inc_rx) = { + let mut reg = self.registry.lock(); + let mut id: u16 = rand::rng().random(); + let mut tries = 0; + while reg.contains_key(&(remote, id)) { + id = id.wrapping_add(1); + tries += 1; + if tries > 64 { + return Err(io::Error::new( + io::ErrorKind::AddrInUse, + "no free utp connection id for peer", + )); + } + } + let key = (remote, id); + let (inc_tx, inc_rx) = mpsc::unbounded_channel(); + reg.insert(key, inc_tx); + (key, inc_rx) + }; + let recv_id = key.1; + let send_id = recv_id.wrapping_add(1); + + let (done_tx, done_rx) = oneshot::channel(); + let shared = stream::new_shared(remote, send_id, RoleKind::Initiator); + let cfg = DriverConfig { + udp: self.udp.clone(), + remote, + incoming: inc_rx, + registry: self.registry.clone(), + key, + }; + let driver_shared = shared.clone(); + tokio::spawn(stream::drive(driver_shared, cfg, Role::Initiator(done_tx))); + + match tokio::time::timeout(timeout, done_rx).await { + Ok(Ok(Ok(()))) => Ok(UtpStream::new(shared)), + Ok(Ok(Err(e))) => Err(e), + Ok(Err(_)) => Err(io::Error::new( + io::ErrorKind::ConnectionAborted, + "utp driver exited before handshake", + )), + Err(_) => { + // Timed out waiting for the peer's STATE. Tell the driver to + // stop retransmitting and reclaim the slot. + { + let mut st = shared.state.lock(); + st.force_close(); + } + shared.nudge.notify_one(); + self.registry.lock().remove(&key); + Err(io::Error::new( + io::ErrorKind::TimedOut, + "utp connect timed out", + )) + } + } + } + + /// Accept the next inbound µTP connection. + pub async fn accept(&self) -> io::Result { + let mut rx = self.accept_rx.lock().await; + rx.recv() + .await + .ok_or_else(|| io::Error::new(io::ErrorKind::BrokenPipe, "utp socket closed")) + } +} + +/// Reads every datagram and routes it to the owning connection, or opens a new +/// inbound connection for an unrecognized SYN. +async fn router( + udp: Arc, + registry: ConnRegistry, + accept_tx: mpsc::UnboundedSender, +) { + let mut buf = vec![0u8; MAX_DATAGRAM]; + loop { + let (n, src) = match udp.recv_from(&mut buf).await { + Ok(x) => x, + // A transient recv error (e.g. ICMP port-unreachable surfaced on + // some platforms) shouldn't kill the whole endpoint. + Err(_) => continue, + }; + let Ok((header, payload)) = UtpHeader::decode(&buf[..n]) else { + continue; + }; + 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())); + continue; + } + } + // Otherwise only a SYN is meaningful; everything else is a stray + // packet for a connection we don't have (ignored). + if header.packet_type == PacketType::Syn { + open_inbound(&udp, ®istry, &accept_tx, src, &header); + } + } +} + +/// Create the responder side of a connection from an inbound SYN. +fn open_inbound( + udp: &Arc, + registry: &ConnRegistry, + accept_tx: &mpsc::UnboundedSender, + src: SocketAddr, + syn: &UtpHeader, +) { + // Responder sends stamped with the SYN's id (C) and receives stamped C+1. + let send_id = syn.connection_id; + let recv_id = send_id.wrapping_add(1); + let key = (src, recv_id); + + let inc_rx = { + let mut reg = registry.lock(); + if reg.contains_key(&key) { + return; // duplicate / retransmitted SYN for an open connection + } + let (inc_tx, inc_rx) = mpsc::unbounded_channel(); + reg.insert(key, inc_tx); + inc_rx + }; + + let shared = stream::new_shared(src, send_id, RoleKind::Responder); + shared.state.lock().seed_responder(syn); + + let cfg = DriverConfig { + udp: udp.clone(), + remote: src, + incoming: inc_rx, + registry: registry.clone(), + key, + }; + tokio::spawn(stream::drive(shared.clone(), cfg, Role::Responder)); + // If nobody is accepting, the stream drops immediately and its driver + // tears the connection down cleanly. + let _ = accept_tx.send(UtpStream::new(shared)); +} + +#[cfg(test)] +mod tests { + use super::*; + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + + async fn loopback_pair() -> (Arc, Arc) { + let a = UtpSocket::bind("127.0.0.1:0".parse().unwrap()) + .await + .unwrap(); + let b = UtpSocket::bind("127.0.0.1:0".parse().unwrap()) + .await + .unwrap(); + (a, b) + } + + #[tokio::test] + async fn handshake_then_echo() { + let (client_sock, server_sock) = loopback_pair().await; + let server_addr = server_sock.local_addr(); + + let server = tokio::spawn(async move { + let mut s = server_sock.accept().await.unwrap(); + let mut buf = [0u8; 5]; + s.read_exact(&mut buf).await.unwrap(); + s.write_all(&buf).await.unwrap(); + s.flush().await.unwrap(); + // Hold the connection open until the client reads the echo. + tokio::time::sleep(Duration::from_millis(300)).await; + }); + + tokio::time::timeout(Duration::from_secs(5), async move { + let mut c = client_sock.connect(server_addr).await.unwrap(); + c.write_all(b"hello").await.unwrap(); + c.flush().await.unwrap(); + let mut echo = [0u8; 5]; + c.read_exact(&mut echo).await.unwrap(); + assert_eq!(&echo, b"hello"); + }) + .await + .expect("echo round trip timed out"); + server.await.unwrap(); + } + + #[tokio::test] + async fn bulk_transfer_preserves_bytes() { + let (client_sock, server_sock) = loopback_pair().await; + let server_addr = server_sock.local_addr(); + // Many MSS-sized packets to exercise sequencing, acks, and the window. + const N: usize = 256 * 1024; + let data: Vec = (0..N).map(|i| (i % 251) as u8).collect(); + let expected = data.clone(); + + let server = tokio::spawn(async move { + let mut s = server_sock.accept().await.unwrap(); + let mut got = Vec::new(); + s.read_to_end(&mut got).await.unwrap(); + got + }); + + tokio::time::timeout(Duration::from_secs(20), async move { + let mut c = client_sock.connect(server_addr).await.unwrap(); + c.write_all(&data).await.unwrap(); + // Clean FIN; the server's read_to_end completes on the resulting EOF. + c.shutdown().await.unwrap(); + }) + .await + .expect("bulk send timed out"); + + let got = tokio::time::timeout(Duration::from_secs(20), server) + .await + .expect("bulk recv timed out") + .unwrap(); + assert_eq!(got.len(), expected.len()); + assert_eq!(got, expected); + } + + #[tokio::test] + async fn connect_to_dead_peer_times_out() { + let sock = UtpSocket::bind("127.0.0.1:0".parse().unwrap()) + .await + .unwrap(); + // 127.0.0.1:1 has no µTP listener; the SYN goes unanswered. + let dead: SocketAddr = "127.0.0.1:1".parse().unwrap(); + let err = sock + .connect_timeout(dead, Duration::from_millis(600)) + .await + .unwrap_err(); + assert_eq!(err.kind(), io::ErrorKind::TimedOut); + } +} diff --git a/src-tauri/risuko-bt/src/utp/stream.rs b/src-tauri/risuko-bt/src/utp/stream.rs new file mode 100644 index 00000000..c68ace01 --- /dev/null +++ b/src-tauri/risuko-bt/src/utp/stream.rs @@ -0,0 +1,845 @@ +//! Per-connection µTP state machine and the [`UtpStream`] `AsyncRead` / +//! `AsyncWrite` handle. +//! +//! Each connection is driven by a single background task ([`drive`]). The +//! task owns the connection's slice of the shared UDP socket's traffic (fed +//! to it by the socket router over an mpsc channel) and is the only thing +//! that touches the wire for this connection. The [`UtpStream`] handle shares +//! a [`Mutex`] with the driver: reads drain `recv_ready`, writes +//! append to `send_buf`, and a [`Notify`] nudges the driver to do work. The +//! driver wakes the stream's stored wakers when data arrives or buffer space +//! frees up. +//! +//! Reliability model: in-order byte delivery with a reorder buffer for +//! out-of-order data, cumulative + selective acknowledgements, RFC-6298-style +//! RTO retransmission (Karn's algorithm for RTT sampling), and LEDBAT-lite +//! delay-based congestion control bounded by the peer's advertised window. + +use std::collections::{BTreeMap, VecDeque}; +use std::io; +use std::net::SocketAddr; +use std::pin::Pin; +use std::sync::Arc; +use std::task::{Context, Poll, Waker}; +use std::time::{Duration, Instant}; + +use parking_lot::Mutex; +use tokio::io::{AsyncRead, AsyncWrite, ReadBuf}; +use tokio::net::UdpSocket; +use tokio::sync::{mpsc, oneshot, Notify}; + +use super::now_micros; +use super::packet::{PacketType, UtpHeader}; +use super::socket::{ConnKey, ConnRegistry}; + +/// Payload bytes per outgoing DATA packet. Conservative to stay under common +/// path MTUs (1500 - IP - UDP - µTP header) without PMTU discovery. +const MSS: usize = 1200; +/// Cap on our reorder + ready buffers; also what we advertise as `wnd_size`. +const RECV_BUF_MAX: usize = 1024 * 1024; +/// Cap on app bytes buffered for sending before `poll_write` backpressures. +const SEND_BUF_MAX: usize = 512 * 1024; +/// LEDBAT target queuing delay (100 ms, per BEP-29). +const TARGET_MICROS: f64 = 100_000.0; +/// LEDBAT window-gain factor. +const CWND_GAIN: f64 = 1.0; +const MIN_CWND: usize = 2 * MSS; +const MAX_CWND: usize = 2 * 1024 * 1024; +const INITIAL_CWND: usize = 3 * MSS; +const MIN_RTO: Duration = Duration::from_millis(500); +const MAX_RTO: Duration = Duration::from_secs(10); +const INITIAL_RTO: Duration = Duration::from_secs(1); +/// Give up retransmitting after this many tries and reset the connection. +const MAX_RETRANSMITS: u32 = 8; +/// Tear the driver down if nothing happens for this long after close. +const LINGER_TIMEOUT: Duration = Duration::from_secs(30); + +/// `a` is strictly after `b` in 16-bit sequence space (within half the ring). +fn seq_after(a: u16, b: u16) -> bool { + let d = a.wrapping_sub(b); + d != 0 && d < 0x8000 +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum State { + /// Outgoing SYN sent, awaiting the peer's STATE. + SynSent, + /// Handshake done; data may flow. + Connected, + /// We sent a FIN; still draining/acking. + FinSent, + /// Terminal — either clean or via RESET/error. + Closed, +} + +/// A packet we've transmitted that is awaiting acknowledgement. Stored +/// un-encoded so retransmissions carry fresh timestamps / ack_nr / window. +struct OutPacket { + packet_type: PacketType, + seq_nr: u16, + payload: Vec, + sent_at: Instant, + transmissions: u32, +} + +pub(crate) struct ConnState { + state: State, + remote: SocketAddr, + /// Connection id stamped on our outgoing (non-SYN) packets. + conn_id_send: u16, + + seq_nr: u16, + ack_nr: u16, + + send_buf: VecDeque, + unacked: VecDeque, + + recv_ready: VecDeque, + reorder: BTreeMap>, + + /// Peer's advertised receive window (flow control), in bytes. + peer_wnd: u32, + /// Our congestion window, in bytes (LEDBAT-controlled). + max_window: usize, + /// Minimum observed one-way delay (LEDBAT baseline), microseconds. + base_delay: u32, + + rtt: f64, + rtt_var: f64, + rto: Duration, + + /// One-way delay we last measured for the peer's packets; echoed back in + /// our `timestamp_difference_microseconds` field so the peer can run + /// LEDBAT against us. + reply_micros: u32, + + /// True once we've received data we haven't acked yet. + needs_ack: bool, + /// App requested close; driver should emit a FIN once the send buffer + /// has drained. + want_fin: bool, + /// Peer's FIN sequence number, once received. EOF is delivered to the + /// reader after all bytes up to and including this are in order. + peer_fin: Option, + eof: bool, + error: Option, + + /// Encoded datagrams the driver should put on the wire this iteration. + outbox: Vec>, + read_waker: Option, + write_waker: Option, + /// Fired by the driver when the connection becomes established (or fails). + connect_notify: Option>>, +} + +impl ConnState { + fn advertised_window(&self) -> u32 { + RECV_BUF_MAX.saturating_sub(self.recv_ready.len()) as u32 + } + + /// Encode an outstanding packet with current ack/window/timestamps. + fn encode(&self, p: &OutPacket) -> Vec { + // The SYN is special-cased to carry our *receive* id (send-1); every + // other packet carries our send id. + let conn_id = if p.packet_type == PacketType::Syn { + self.conn_id_send.wrapping_sub(1) + } else { + self.conn_id_send + }; + let header = UtpHeader { + packet_type: p.packet_type, + connection_id: conn_id, + timestamp_micros: now_micros(), + timestamp_diff_micros: self.reply_micros, + wnd_size: self.advertised_window(), + seq_nr: p.seq_nr, + ack_nr: self.ack_nr, + selective_ack: self.build_selective_ack(), + }; + header.encode(&p.payload) + } + + /// Build a standalone ST_STATE acknowledgement. + fn encode_state(&self) -> Vec { + UtpHeader { + packet_type: PacketType::State, + connection_id: self.conn_id_send, + timestamp_micros: now_micros(), + timestamp_diff_micros: self.reply_micros, + wnd_size: self.advertised_window(), + // ST_STATE carries the next seq to be used but does not consume it. + seq_nr: self.seq_nr, + ack_nr: self.ack_nr, + selective_ack: self.build_selective_ack(), + } + .encode(&[]) + } + + /// Selective-ACK bitmask covering buffered out-of-order packets, if any. + /// Bit `i` (LSB-first per byte) acks `ack_nr + 2 + i`. + fn build_selective_ack(&self) -> Option> { + if self.reorder.is_empty() { + return None; + } + let base = self.ack_nr.wrapping_add(2); + let mut mask = vec![0u8; 4]; + for &seq in self.reorder.keys() { + let bit = seq.wrapping_sub(base) as usize; + if bit < mask.len() * 8 { + mask[bit / 8] |= 1 << (bit % 8); + } + } + Some(mask) + } + + fn bytes_in_flight(&self) -> usize { + self.unacked.iter().map(|p| p.payload.len()).sum() + } + + /// Move app bytes from `send_buf` into DATA packets while the congestion + /// and flow-control windows allow, queuing them for transmission. + fn fill_send_window(&mut self) { + if self.state != State::Connected { + return; + } + loop { + if self.send_buf.is_empty() { + break; + } + let window = self.max_window.min(self.peer_wnd as usize); + let room = window.saturating_sub(self.bytes_in_flight()); + if room == 0 { + break; + } + let take = self.send_buf.len().min(MSS).min(room); + if take == 0 { + break; + } + let payload: Vec = self.send_buf.drain(..take).collect(); + self.transmit_new(PacketType::Data, payload); + } + } + + /// Assign a fresh sequence number to a DATA/FIN packet and queue it. + fn transmit_new(&mut self, packet_type: PacketType, payload: Vec) { + let p = OutPacket { + packet_type, + seq_nr: self.seq_nr, + payload, + sent_at: Instant::now(), + transmissions: 1, + }; + self.seq_nr = self.seq_nr.wrapping_add(1); + self.outbox.push(self.encode(&p)); + self.unacked.push_back(p); + // A DATA/FIN packet carries our ack_nr, so it doubles as an ack. + self.needs_ack = false; + } + + /// Process a cumulative ack: drop fully-acked packets and sample RTT. + fn process_ack(&mut self, ack_nr: u16, their_delay: u32) { + let mut acked_any = false; + let mut acked_bytes = 0usize; + while let Some(front) = self.unacked.front() { + if seq_after(front.seq_nr, ack_nr) { + break; // front is beyond the ack point + } + let p = self.unacked.pop_front().unwrap(); + acked_any = true; + acked_bytes += p.payload.len(); + // Karn: only sample RTT from packets sent exactly once. + if p.transmissions == 1 { + self.update_rtt(p.sent_at.elapsed()); + } + } + if acked_any { + self.update_cwnd(their_delay, acked_bytes); + self.notify_write(); + } + } + + /// Remove packets named by a selective-ACK bitmask and fast-retransmit the + /// oldest unacked packet if anything past it was selectively acked. + fn process_selective_ack(&mut self, ack_nr: u16, mask: &[u8]) { + let base = ack_nr.wrapping_add(2); + let mut sacked = false; + for (byte_idx, byte) in mask.iter().enumerate() { + for bit in 0..8 { + if byte & (1 << bit) == 0 { + continue; + } + let seq = base.wrapping_add((byte_idx * 8 + bit) as u16); + if let Some(pos) = self.unacked.iter().position(|p| p.seq_nr == seq) { + self.unacked.remove(pos); + sacked = true; + } + } + } + // If holes remain before SACKed packets, the front was likely lost; + // fast-retransmit it. Extract its fields first so the mutable borrow + // is released before we re-encode (which borrows &self). + if sacked { + let p = self.unacked.front_mut().map(|f| { + f.sent_at = Instant::now(); + f.transmissions += 1; + OutPacket { + packet_type: f.packet_type, + seq_nr: f.seq_nr, + payload: f.payload.clone(), + sent_at: f.sent_at, + transmissions: f.transmissions, + } + }); + if let Some(p) = p { + self.outbox.push(self.encode(&p)); + } + } + } + + fn update_rtt(&mut self, sample: Duration) { + let s = sample.as_secs_f64(); + if self.rtt == 0.0 { + self.rtt = s; + self.rtt_var = s / 2.0; + } else { + self.rtt_var = 0.75 * self.rtt_var + 0.25 * (self.rtt - s).abs(); + self.rtt = 0.875 * self.rtt + 0.125 * s; + } + let rto = Duration::from_secs_f64(self.rtt + 4.0 * self.rtt_var); + self.rto = rto.clamp(MIN_RTO, MAX_RTO); + } + + /// LEDBAT congestion-window update from the peer-measured one-way delay. + fn update_cwnd(&mut self, their_delay: u32, acked_bytes: usize) { + if their_delay == 0 { + return; + } + if self.base_delay == 0 || their_delay < self.base_delay { + self.base_delay = their_delay; + } + let queuing = their_delay.saturating_sub(self.base_delay) as f64; + let off_target = (TARGET_MICROS - queuing) / TARGET_MICROS; + let gain = CWND_GAIN * off_target * (acked_bytes as f64) * (MSS as f64) + / (self.max_window.max(MSS) as f64); + let next = self.max_window as f64 + gain; + self.max_window = (next as i64).clamp(MIN_CWND as i64, MAX_CWND as i64) as usize; + } + + /// Ingest one decoded packet. Returns nothing; mutates buffers/outbox and + /// arms wakers as appropriate. + fn handle_packet(&mut self, header: &UtpHeader, payload: &[u8]) { + if self.state == State::Closed { + return; + } + self.peer_wnd = header.wnd_size; + // Measure the one-way delay of *this* packet so we can echo it back. + self.reply_micros = now_micros().wrapping_sub(header.timestamp_micros); + + // Handshake completion: first STATE after our SYN. + if self.state == State::SynSent && header.packet_type == PacketType::State { + self.state = State::Connected; + // Peer's STATE carries its next-data seq; we've received nothing yet. + self.ack_nr = header.seq_nr.wrapping_sub(1); + if let Some(tx) = self.connect_notify.take() { + let _ = tx.send(Ok(())); + } + self.notify_write(); + } + + self.process_ack(header.ack_nr, header.timestamp_diff_micros); + if let Some(mask) = &header.selective_ack { + self.process_selective_ack(header.ack_nr, mask); + } + + match header.packet_type { + PacketType::Reset => { + self.fail(io::ErrorKind::ConnectionReset); + return; + } + PacketType::Data | PacketType::Fin => { + self.accept_inorder(header, payload); + } + PacketType::State | PacketType::Syn => {} + } + + // After a FIN whose sequence we've now reached in order, signal EOF. + if let Some(fin) = self.peer_fin { + if !seq_after(fin, self.ack_nr) { + self.eof = true; + self.notify_read(); + } + } + self.maybe_finish(); + } + + /// Place a DATA/FIN payload in order, buffering out-of-order arrivals. + fn accept_inorder(&mut self, header: &UtpHeader, payload: &[u8]) { + let expected = self.ack_nr.wrapping_add(1); + if header.seq_nr == expected { + self.consume(header.packet_type, header.seq_nr, payload); + // Drain any contiguous reorder-buffer entries. + loop { + let next = self.ack_nr.wrapping_add(1); + let Some(buf) = self.reorder.remove(&next) else { + break; + }; + // A buffered FIN is recorded; its (empty) payload adds nothing. + let ty = if Some(next) == self.peer_fin { + PacketType::Fin + } else { + PacketType::Data + }; + self.consume(ty, next, &buf); + } + self.needs_ack = true; + } else if seq_after(header.seq_nr, self.ack_nr) { + // Future packet: buffer it (bounded by the advertised window). + if self.reorder.len() < RECV_BUF_MAX / MSS { + if header.packet_type == PacketType::Fin { + self.peer_fin = Some(header.seq_nr); + } + self.reorder.insert(header.seq_nr, payload.to_vec()); + } + self.needs_ack = true; + } else { + // Duplicate / already-acked: re-ack so the peer makes progress. + self.needs_ack = true; + } + } + + /// Advance `ack_nr` past `seq`, delivering DATA bytes to the reader and + /// recording a FIN. + fn consume(&mut self, ty: PacketType, seq: u16, payload: &[u8]) { + self.ack_nr = seq; + if ty == PacketType::Fin { + self.peer_fin = Some(seq); + } else if !payload.is_empty() { + self.recv_ready.extend(payload.iter().copied()); + self.notify_read(); + } + } + + /// Retransmit timed-out packets; returns the deadline of the next timer. + fn check_retransmit(&mut self) { + let Some(front) = self.unacked.front() else { + return; + }; + if front.sent_at.elapsed() < self.rto { + return; + } + if front.transmissions >= MAX_RETRANSMITS { + self.fail(io::ErrorKind::TimedOut); + return; + } + // Timeout: collapse the congestion window (TCP-style) and back off RTO. + self.max_window = MIN_CWND; + self.rto = (self.rto * 2).min(MAX_RTO); + // Re-send the oldest unacked packet; cumulative acks pull the rest. + let (pt, seq, payload, tx) = { + let f = self.unacked.front_mut().unwrap(); + f.sent_at = Instant::now(); + f.transmissions += 1; + (f.packet_type, f.seq_nr, f.payload.clone(), f.transmissions) + }; + let p = OutPacket { + packet_type: pt, + seq_nr: seq, + payload, + sent_at: Instant::now(), + transmissions: tx, + }; + self.outbox.push(self.encode(&p)); + } + + /// Emit a FIN once the send buffer has drained, then mark FinSent. + fn maybe_send_fin(&mut self) { + if self.want_fin && self.state == State::Connected && self.send_buf.is_empty() { + self.transmit_new(PacketType::Fin, Vec::new()); + self.state = State::FinSent; + } + } + + /// Transition a half-closed connection to fully closed once our FIN is + /// acked and we've seen the peer's FIN, so the driver can wind down. + fn maybe_finish(&mut self) { + if self.state == State::FinSent && self.unacked.is_empty() && self.eof { + self.state = State::Closed; + } + } + + /// Next instant the driver must wake to do timer work, if any. + fn next_deadline(&self) -> Option { + self.unacked.front().map(|p| p.sent_at + self.rto) + } + + fn fail(&mut self, kind: io::ErrorKind) { + if self.error.is_none() { + self.error = Some(kind); + } + self.state = State::Closed; + self.eof = true; + if let Some(tx) = self.connect_notify.take() { + let _ = tx.send(Err(io::Error::from(kind))); + } + self.notify_read(); + self.notify_write(); + } + + /// Seed responder state from the initiating SYN: it consumed `syn.seq_nr`, + /// so our first expected DATA is the next sequence number. + pub(crate) fn seed_responder(&mut self, syn: &UtpHeader) { + self.ack_nr = syn.seq_nr; + self.peer_wnd = syn.wnd_size; + self.reply_micros = now_micros().wrapping_sub(syn.timestamp_micros); + } + + /// Abandon the connection immediately (e.g. on connect timeout) so the + /// driver exits promptly instead of retransmitting to a dead peer. + pub(crate) fn force_close(&mut self) { + self.state = State::Closed; + self.error.get_or_insert(io::ErrorKind::TimedOut); + self.unacked.clear(); + self.send_buf.clear(); + } + + fn notify_read(&mut self) { + if let Some(w) = self.read_waker.take() { + w.wake(); + } + } + + fn notify_write(&mut self) { + if let Some(w) = self.write_waker.take() { + w.wake(); + } + } +} + +/// Shared between the [`UtpStream`] handle and its driver task. +pub(crate) struct Shared { + pub(crate) state: Mutex, + /// Nudges the driver after the app writes / requests shutdown. + pub(crate) nudge: Notify, +} + +/// Whether a freshly-created connection initiates (sends a SYN) or responds. +/// Carries the establishment notifier for the initiator; consumed by [`drive`]. +pub(crate) enum Role { + /// Outgoing dial; the driver sends a SYN and reports establishment here. + Initiator(oneshot::Sender>), + /// Inbound connection accepted from a peer's SYN; already Connected. + Responder, +} + +/// `Copy` view of [`Role`] used to pick a connection's initial state without +/// consuming the (non-`Clone`) establishment notifier. +#[derive(Clone, Copy)] +pub(crate) enum RoleKind { + Initiator, + Responder, +} + +/// Configuration handed to a connection driver by the socket layer. +pub(crate) struct DriverConfig { + pub udp: Arc, + pub remote: SocketAddr, + pub incoming: mpsc::UnboundedReceiver<(UtpHeader, Vec)>, + pub registry: ConnRegistry, + pub key: ConnKey, +} + +/// Create the shared state for a new connection. +pub(crate) fn new_shared(remote: SocketAddr, conn_id_send: u16, kind: RoleKind) -> Arc { + let state = match kind { + RoleKind::Initiator => State::SynSent, + RoleKind::Responder => State::Connected, + }; + Arc::new(Shared { + state: Mutex::new(ConnState { + state, + remote, + conn_id_send, + // Initiator's SYN consumes seq 1, so the next DATA is seq 2. + // Responder picks a random initial sequence. + seq_nr: match kind { + RoleKind::Initiator => 2, + RoleKind::Responder => rand::random::() | 1, + }, + ack_nr: 0, + send_buf: VecDeque::new(), + unacked: VecDeque::new(), + recv_ready: VecDeque::new(), + reorder: BTreeMap::new(), + peer_wnd: RECV_BUF_MAX as u32, + max_window: INITIAL_CWND, + base_delay: 0, + rtt: 0.0, + rtt_var: 0.0, + rto: INITIAL_RTO, + reply_micros: 0, + needs_ack: false, + want_fin: false, + peer_fin: None, + eof: false, + error: None, + outbox: Vec::new(), + read_waker: None, + write_waker: None, + connect_notify: None, + }), + nudge: Notify::new(), + }) +} + +/// The single task that owns a connection's wire traffic for its lifetime. +pub(crate) async fn drive(shared: Arc, mut cfg: DriverConfig, role: Role) { + // Kick off the handshake / initial ack and arm the connect notifier. + { + let mut st = shared.state.lock(); + if let Role::Initiator(tx) = role { + st.connect_notify = Some(tx); + // Send the SYN (seq 1). It lives in `unacked` for retransmission. + let syn = OutPacket { + packet_type: PacketType::Syn, + seq_nr: 1, + payload: Vec::new(), + sent_at: Instant::now(), + transmissions: 1, + }; + let syn_bytes = st.encode(&syn); + st.outbox.push(syn_bytes); + st.unacked.push_back(syn); + } else { + // Responder: ack_nr was set by the socket from the SYN; send STATE. + let state_bytes = st.encode_state(); + st.outbox.push(state_bytes); + } + } + flush(&shared, &cfg).await; + + let mut closed_since: Option = None; + loop { + let deadline = { + let st = shared.state.lock(); + if st.state == State::Closed && closed_since.is_none() { + closed_since = Some(Instant::now()); + } + st.next_deadline() + }; + + // Stop lingering once closed and drained. + if let Some(since) = closed_since { + let drained = { + let st = shared.state.lock(); + st.unacked.is_empty() && st.send_buf.is_empty() + }; + if drained || since.elapsed() > LINGER_TIMEOUT { + break; + } + } + + let sleep = async { + match deadline { + Some(d) => { + let now = Instant::now(); + if d > now { + tokio::time::sleep(d - now).await; + } + } + None => std::future::pending::<()>().await, + } + }; + + tokio::select! { + pkt = cfg.incoming.recv() => { + match pkt { + Some((header, payload)) => { + let mut st = shared.state.lock(); + st.handle_packet(&header, &payload); + } + None => { + // Router dropped our channel; nothing more will arrive. + shared.state.lock().fail(io::ErrorKind::ConnectionAborted); + } + } + } + _ = shared.nudge.notified() => {} + _ = sleep => { + shared.state.lock().check_retransmit(); + } + } + + // Do per-iteration work: drain app writes, emit FIN if requested, + // send a standalone ack if we owe one. + { + let mut st = shared.state.lock(); + st.fill_send_window(); + st.maybe_send_fin(); + st.fill_send_window(); + if st.needs_ack && st.state != State::SynSent { + let ack = st.encode_state(); + st.outbox.push(ack); + st.needs_ack = false; + } + st.maybe_finish(); + } + flush(&shared, &cfg).await; + } + + cfg.registry.lock().remove(&cfg.key); +} + +/// Drain the outbox to the wire. Datagrams are collected under the lock and +/// sent after releasing it so UDP I/O never blocks the state mutex. +async fn flush(shared: &Arc, cfg: &DriverConfig) { + let datagrams: Vec> = { + let mut st = shared.state.lock(); + std::mem::take(&mut st.outbox) + }; + for d in datagrams { + let _ = cfg.udp.send_to(&d, cfg.remote).await; + } +} + +/// A µTP connection presented as an async byte stream. Plugs into the peer +/// connection layer wherever a `TcpStream` would go. +pub struct UtpStream { + shared: Arc, +} + +impl UtpStream { + pub(crate) fn new(shared: Arc) -> Self { + Self { shared } + } + + pub fn peer_addr(&self) -> SocketAddr { + self.shared.state.lock().remote + } +} + +impl std::fmt::Debug for UtpStream { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("UtpStream") + .field("peer", &self.peer_addr()) + .finish() + } +} + +impl AsyncRead for UtpStream { + fn poll_read( + self: Pin<&mut Self>, + cx: &mut Context<'_>, + buf: &mut ReadBuf<'_>, + ) -> Poll> { + let mut st = self.shared.state.lock(); + if !st.recv_ready.is_empty() { + let n = st.recv_ready.len().min(buf.remaining()); + let (first, second) = st.recv_ready.as_slices(); + let first_n = first.len().min(n); + buf.put_slice(&first[..first_n]); + if first_n < n { + buf.put_slice(&second[..n - first_n]); + } + st.recv_ready.drain(..n); + // Reading frees receive-buffer space; the peer learns the larger + // window on our next outgoing packet, so nudge a fresh ack. + self.shared.nudge.notify_one(); + return Poll::Ready(Ok(())); + } + if let Some(kind) = st.error { + return Poll::Ready(Err(io::Error::from(kind))); + } + if st.eof { + return Poll::Ready(Ok(())); // clean EOF (empty read) + } + st.read_waker = Some(cx.waker().clone()); + Poll::Pending + } +} + +impl AsyncWrite for UtpStream { + fn poll_write( + self: Pin<&mut Self>, + cx: &mut Context<'_>, + data: &[u8], + ) -> Poll> { + let mut st = self.shared.state.lock(); + if let Some(kind) = st.error { + return Poll::Ready(Err(io::Error::from(kind))); + } + if matches!(st.state, State::FinSent | State::Closed) || st.want_fin { + return Poll::Ready(Err(io::Error::from(io::ErrorKind::BrokenPipe))); + } + let room = SEND_BUF_MAX.saturating_sub(st.send_buf.len()); + if room == 0 { + st.write_waker = Some(cx.waker().clone()); + return Poll::Pending; + } + let n = data.len().min(room); + st.send_buf.extend(&data[..n]); + drop(st); + self.shared.nudge.notify_one(); + Poll::Ready(Ok(n)) + } + + fn poll_flush(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + let mut st = self.shared.state.lock(); + if let Some(kind) = st.error { + return Poll::Ready(Err(io::Error::from(kind))); + } + if st.send_buf.is_empty() && st.bytes_in_flight() == 0 { + return Poll::Ready(Ok(())); + } + st.write_waker = Some(cx.waker().clone()); + drop(st); + self.shared.nudge.notify_one(); + Poll::Pending + } + + fn poll_shutdown(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + let mut st = self.shared.state.lock(); + if let Some(kind) = st.error { + return Poll::Ready(Err(io::Error::from(kind))); + } + if st.state == State::Closed { + return Poll::Ready(Ok(())); + } + st.want_fin = true; + // Shutdown completes once the FIN has been sent and acked (no more + // unacked packets) and the send buffer is empty. + if st.state == State::FinSent && st.unacked.is_empty() { + return Poll::Ready(Ok(())); + } + st.write_waker = Some(cx.waker().clone()); + drop(st); + self.shared.nudge.notify_one(); + Poll::Pending + } +} + +impl Drop for UtpStream { + fn drop(&mut self) { + let mut st = self.shared.state.lock(); + st.want_fin = true; + st.read_waker = None; + st.write_waker = None; + drop(st); + // Wake the driver so it can emit a FIN and tear down cleanly. + self.shared.nudge.notify_one(); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn seq_after_handles_wraparound() { + assert!(seq_after(5, 4)); + assert!(!seq_after(4, 5)); + assert!(!seq_after(4, 4)); + // Wraparound: 1 is after 65535. + assert!(seq_after(1, 65535)); + assert!(!seq_after(65535, 1)); + } +} diff --git a/src-tauri/risuko-bt/src/wire/extended.rs b/src-tauri/risuko-bt/src/wire/extended.rs index fa0a9fee..5f376772 100644 --- a/src-tauri/risuko-bt/src/wire/extended.rs +++ b/src-tauri/risuko-bt/src/wire/extended.rs @@ -6,6 +6,7 @@ //! per-peer extension message types negotiated via the handshake's `m` dict use std::collections::HashMap; +use std::net::IpAddr; use bytes::{BufMut, Bytes, BytesMut}; @@ -31,6 +32,10 @@ pub struct ExtHandshake { pub metadata_size: Option, /// Peer-advertised client string ("v" key) pub client: Option, + /// BEP-10 `yourip`: the peer's public address as we observed it. Some real-world + /// clients (notably some CN BT implementations) only engage with a remote that + /// echoes their address back here + pub yourip: Option, } impl ExtHandshake { @@ -48,9 +53,17 @@ impl ExtHandshake { env!("CARGO_PKG_NAME"), env!("CARGO_PKG_VERSION") )), + yourip: None, } } + /// Set `yourip` to the peer's address (compact-encoded on the wire). Builder + /// helper used by the connection layer per dial / accept + pub fn with_yourip(mut self, ip: IpAddr) -> Self { + self.yourip = Some(ip); + self + } + pub fn encode(&self) -> Bytes { let mut m_entries: Vec<(Vec, Value)> = self .supported @@ -58,6 +71,9 @@ impl ExtHandshake { .map(|(k, v)| (k.clone(), Value::Int(*v as i64))) .collect(); m_entries.sort_by(|a, b| a.0.cmp(&b.0)); + // Bencode dictionaries must be lexicographically sorted by key. The keys we + // emit are `m`, `metadata_size`, `v`, `yourip`—all distinct and already in + // sorted order, so we just push in that fixed sequence let mut dict = vec![(b"m".to_vec(), Value::Dict(m_entries))]; if let Some(sz) = self.metadata_size { dict.push((b"metadata_size".to_vec(), Value::Int(sz as i64))); @@ -65,6 +81,13 @@ impl ExtHandshake { if let Some(v) = &self.client { dict.push((b"v".to_vec(), Value::Bytes(v.as_bytes().to_vec()))); } + if let Some(ip) = &self.yourip { + let bytes = match ip { + IpAddr::V4(v4) => v4.octets().to_vec(), + IpAddr::V6(v6) => v6.octets().to_vec(), + }; + dict.push((b"yourip".to_vec(), Value::Bytes(bytes))); + } Bytes::from(encode_to_vec(&Value::Dict(dict))) } @@ -94,10 +117,26 @@ impl ExtHandshake { .iter() .find(|(k, _)| k == b"v") .and_then(|(_, v)| v.as_str().map(String::from)); + let yourip = dict + .iter() + .find(|(k, _)| k == b"yourip") + .and_then(|(_, v)| v.as_bytes()) + .and_then(|bytes| match bytes.len() { + 4 => { + let arr: [u8; 4] = bytes.try_into().ok()?; + Some(IpAddr::V4(std::net::Ipv4Addr::from(arr))) + } + 16 => { + let arr: [u8; 16] = bytes.try_into().ok()?; + Some(IpAddr::V6(std::net::Ipv6Addr::from(arr))) + } + _ => None, + }); Some(Self { supported, metadata_size, client, + yourip, }) } @@ -272,6 +311,24 @@ mod tests { assert!(parsed.block.is_empty()); } + #[test] + fn yourip_ipv4_round_trip() { + let ip = std::net::IpAddr::V4("192.168.1.42".parse().unwrap()); + let out = ExtHandshake::new_outgoing(3, 4, None).with_yourip(ip); + let bytes = out.encode(); + let parsed = ExtHandshake::decode(&bytes).unwrap(); + assert_eq!(parsed.yourip, Some(ip)); + } + + #[test] + fn yourip_ipv6_round_trip() { + let ip = std::net::IpAddr::V6("2001:db8::1".parse().unwrap()); + let out = ExtHandshake::new_outgoing(3, 4, None).with_yourip(ip); + let bytes = out.encode(); + let parsed = ExtHandshake::decode(&bytes).unwrap(); + assert_eq!(parsed.yourip, Some(ip)); + } + #[test] fn ut_pex_round_trip() { let addrs = vec![ diff --git a/src-tauri/risuko-bt/src/wire/handshake.rs b/src-tauri/risuko-bt/src/wire/handshake.rs index 5cc0458e..98550166 100644 --- a/src-tauri/risuko-bt/src/wire/handshake.rs +++ b/src-tauri/risuko-bt/src/wire/handshake.rs @@ -47,13 +47,24 @@ 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 + // (pure-v2 swarm)—empirically, blanket-setting it on v1 / hybrid connections + // causes some peers (notably Thunder / Xunlei-style clients common in CN swarms) + // to close the socket right after the BT handshake exchange. We never set the + // BEP-5 DHT bit because we do not act on inbound `Port` messages from the + // BT layer let mut reserved = [0u8; 8]; let (b, m) = reserved::EXT_PROTOCOL; reserved[b] |= m; - let (b, m) = reserved::DHT; - reserved[b] |= m; - let (b, m) = reserved::V2; - reserved[b] |= m; + if advertise_v2 { + let (b, m) = reserved::V2; + reserved[b] |= m; + } Self { reserved, info_hash, @@ -111,16 +122,35 @@ mod tests { use super::*; #[test] - fn round_trip() { + fn round_trip_advertises_ext_and_optionally_v2() { let hs = Handshake::new(Id20([0xaau8; 20]), Id20([0xbbu8; 20])); let bytes = hs.to_bytes(); let parsed = Handshake::parse(&bytes).unwrap(); assert_eq!(hs, parsed); assert!(parsed.has_ext_protocol()); - assert!(parsed.has_dht()); + // 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 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); + let hs_on = Handshake::new_with_v2(Id20([0xaau8; 20]), Id20([0xbbu8; 20]), true); + assert!(hs_off.has_ext_protocol()); + assert!(!hs_off.has_v2()); + assert!(hs_on.has_v2()); + } + #[test] fn rejects_bad_protocol() { let mut bytes = [0u8; HANDSHAKE_LEN]; diff --git a/src-tauri/risuko-cookies/Cargo.toml b/src-tauri/risuko-cookies/Cargo.toml index 983a2b3e..e5228eef 100644 --- a/src-tauri/risuko-cookies/Cargo.toml +++ b/src-tauri/risuko-cookies/Cargo.toml @@ -14,5 +14,7 @@ serde = { version = "1", features = ["derive"] } serde_json = "1" tracing = "0.1" tokio = { version = "1", features = ["rt"] } -rookie = "0.5" url = "2" + +[target.'cfg(not(target_os = "android"))'.dependencies] +rookie = "0.5" diff --git a/src-tauri/risuko-cookies/src/lib.rs b/src-tauri/risuko-cookies/src/lib.rs index 6a73487a..105e8a41 100644 --- a/src-tauri/risuko-cookies/src/lib.rs +++ b/src-tauri/risuko-cookies/src/lib.rs @@ -5,9 +5,11 @@ //! engine and the Tauri command layer pull from here use serde::{Deserialize, Serialize}; -use tokio::task::spawn_blocking; use url::Url; +#[cfg(not(target_os = "android"))] +use tokio::task::spawn_blocking; + #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub struct Cookie { pub name: String, @@ -19,6 +21,7 @@ pub struct Cookie { pub expires: Option, } +#[cfg(not(target_os = "android"))] impl From for Cookie { fn from(c: rookie::enums::Cookie) -> Self { Self { @@ -55,9 +58,17 @@ pub fn cookies_to_header(cookies: &[Cookie]) -> String { /// a quick probe (no domain filter) returned without an error; sandbox /// failures or missing profiles surface as `false` pub async fn list_browsers() -> Vec { - spawn_blocking(list_browsers_sync).await.unwrap_or_default() + #[cfg(target_os = "android")] + { + Vec::new() + } + #[cfg(not(target_os = "android"))] + { + spawn_blocking(list_browsers_sync).await.unwrap_or_default() + } } +#[cfg(not(target_os = "android"))] fn list_browsers_sync() -> Vec { let mut out: Vec = Vec::new(); @@ -134,13 +145,22 @@ fn list_browsers_sync() -> Vec { /// Pull cookies for `host` (and parent domains) from the named browser. /// Returns `Err(message)` on rookie failure so callers can surface it pub async fn cookies_for_host(browser: &str, host: &str) -> Result, String> { - let browser = browser.to_string(); - let host = host.to_string(); - spawn_blocking(move || cookies_for_host_sync(&browser, &host)) - .await - .map_err(|e| format!("join error: {e}"))? + #[cfg(target_os = "android")] + { + let _ = (browser, host); + Err("Browser cookie import is not supported on Android".to_string()) + } + #[cfg(not(target_os = "android"))] + { + let browser = browser.to_string(); + let host = host.to_string(); + spawn_blocking(move || cookies_for_host_sync(&browser, &host)) + .await + .map_err(|e| format!("join error: {e}"))? + } } +#[cfg(not(target_os = "android"))] fn cookies_for_host_sync(browser: &str, host: &str) -> Result, String> { // rookie does a substring match on the cookie's domain field. Passing // both the bare host and its eTLD+1 catches `example.com` and @@ -198,6 +218,7 @@ fn cookies_for_host_sync(browser: &str, host: &str) -> Result, Strin /// cookie domain. Leading dots on the cookie domain (legacy form) are /// stripped before comparison. Hosts and domains are matched /// case-insensitively +#[cfg(not(target_os = "android"))] fn cookie_domain_matches_host(cookie_domain: &str, host: &str) -> bool { let host_l = host.trim().to_ascii_lowercase(); let domain_l = cookie_domain @@ -231,6 +252,7 @@ pub struct HostCookies { pub cookies: Vec, } +#[cfg(not(target_os = "android"))] fn call_rookie( browser: &str, domains: Option>, @@ -305,6 +327,7 @@ fn extract_host(target: &str) -> Option { /// sites. The `cookie_domain_matches_host` post-filter in /// `cookies_for_host_sync` enforces correctness for whatever rookie /// returns +#[cfg(not(target_os = "android"))] fn registrable_domain(host: &str) -> Option { let labels: Vec<&str> = host.split('.').collect(); if labels.len() < 2 { @@ -321,6 +344,7 @@ fn registrable_domain(host: &str) -> Option { /// shaped like `.` (`co.uk`, `com.au`, `ac.jp`, ...). Not /// exhaustive; this is a conservative widening guard, not a security /// boundary +#[cfg(not(target_os = "android"))] fn is_multi_label_public_suffix(role: &str, cc: &str) -> bool { if cc.len() != 2 { return false; diff --git a/src-tauri/risuko-engine/src/config/defaults.rs b/src-tauri/risuko-engine/src/config/defaults.rs index 27cfd3ff..fdee2dae 100644 --- a/src-tauri/risuko-engine/src/config/defaults.rs +++ b/src-tauri/risuko-engine/src/config/defaults.rs @@ -1,12 +1,12 @@ use serde_json::{json, Map, Value}; pub fn system_defaults() -> Map { - let downloads_dir = dirs::download_dir() - .or_else(|| dirs::home_dir().map(|p| p.join("Downloads"))) - .or_else(|| std::env::current_dir().ok().map(|p| p.join("Downloads"))) - .unwrap_or_else(|| std::env::temp_dir().join("Downloads")) - .to_string_lossy() - .to_string(); + let downloads_dir = default_download_dir().to_string_lossy().to_string(); + let file_allocation = if cfg!(target_os = "android") { + "none" + } else { + "falloc" + }; let mut m = Map::new(); m.insert("all-proxy".into(), json!("")); @@ -33,6 +33,7 @@ pub fn system_defaults() -> Map { m.insert("enable-dht".into(), json!(true)); m.insert("enable-dht6".into(), json!(true)); m.insert("enable-peer-exchange".into(), json!(true)); + m.insert("file-allocation".into(), json!(file_allocation)); m.insert("follow-torrent".into(), json!(true)); m.insert("listen-port".into(), json!(21301)); m.insert("max-concurrent-downloads".into(), json!(5)); @@ -53,6 +54,64 @@ pub fn system_defaults() -> Map { m } +fn default_download_dir() -> std::path::PathBuf { + #[cfg(target_os = "android")] + { + // On Android, `dirs::download_dir()` resolves to "$HOME/Downloads" + // which is not a valid path (HOME is typically the app's private + // data dir or "/"). Use the well-known shared public Downloads + // folder so files land somewhere the user can browse with any + // file manager. The app needs storage permission to write there + // on Android 10+; if missing, the user can pick another folder + // via the directory picker. + let public_downloads = std::path::PathBuf::from("/storage/emulated/0/Download/Risuko"); + if let Some(parent) = public_downloads.parent() { + if is_writable_dir(parent) { + return public_downloads; + } + } + // Fallback: app-specific external storage (no permission needed, + // but hidden from most file managers). + let app_external = std::path::PathBuf::from( + "/storage/emulated/0/Android/data/app.risuko.mobile/files/Download", + ); + if let Some(parent) = app_external.parent() { + if is_writable_dir(parent) { + return app_external; + } + } + // Last resort: app-private internal storage. + if let Some(home) = dirs::home_dir() { + return home.join("Download"); + } + } + + dirs::download_dir() + .or_else(|| dirs::home_dir().map(|p| p.join("Downloads"))) + .or_else(|| std::env::current_dir().ok().map(|p| p.join("Downloads"))) + .unwrap_or_else(|| std::env::temp_dir().join("Downloads")) +} + +#[cfg(target_os = "android")] +fn is_writable_dir(path: &std::path::Path) -> bool { + if !path.is_dir() { + return false; + } + let probe = path.join(".risuko-write-test"); + match std::fs::OpenOptions::new() + .create(true) + .write(true) + .truncate(false) + .open(&probe) + { + Ok(_) => { + let _ = std::fs::remove_file(probe); + true + } + Err(_) => false, + } +} + pub fn user_defaults() -> Map { let is_macos = cfg!(target_os = "macos"); let is_not_macos = !is_macos; @@ -72,7 +131,8 @@ pub fn user_defaults() -> Map { m.insert("keep-window-state".into(), json!(false)); m.insert("last-check-update-time".into(), json!(0)); m.insert("last-sync-tracker-time".into(), json!(0)); - m.insert("locale".into(), json!("en-US")); + m.insert("locale".into(), json!("auto")); + m.insert("log-dir-override".into(), json!("")); m.insert("log-level".into(), json!("warn")); m.insert("low-speed-threshold".into(), json!(20)); m.insert("new-task-show-downloading".into(), json!(true)); @@ -188,7 +248,7 @@ mod tests { fn user_defaults_sensible_values() { let user = user_defaults(); assert_eq!(user.get("theme").unwrap(), "auto"); - assert_eq!(user.get("locale").unwrap(), "en-US"); + assert_eq!(user.get("locale").unwrap(), "auto"); assert_eq!(user.get("keep-seeding").unwrap(), false); assert_eq!(user.get("rpc-host").unwrap(), "127.0.0.1"); assert_eq!(user.get("m3u8-output-format").unwrap(), "ts"); diff --git a/src-tauri/risuko-engine/src/config/mod.rs b/src-tauri/risuko-engine/src/config/mod.rs index a3054d76..c36f6d6a 100644 --- a/src-tauri/risuko-engine/src/config/mod.rs +++ b/src-tauri/risuko-engine/src/config/mod.rs @@ -321,7 +321,7 @@ mod tests { let merged = mgr.get_merged_config(); let map = merged.as_object().unwrap(); - assert_eq!(map.get("locale"), Some(&json!("en-US"))); + assert_eq!(map.get("locale"), Some(&json!("auto"))); } #[test] diff --git a/src-tauri/risuko-engine/src/engine/http.rs b/src-tauri/risuko-engine/src/engine/http.rs index 4db799f0..9fd27a74 100644 --- a/src-tauri/risuko-engine/src/engine/http.rs +++ b/src-tauri/risuko-engine/src/engine/http.rs @@ -7,7 +7,8 @@ use bytes::Bytes; use futures_util::StreamExt; use risuko_http::header::{ HeaderMap, HeaderName, HeaderValue, ACCEPT_ENCODING, ACCEPT_RANGES, CONTENT_ENCODING, - CONTENT_LENGTH, CONTENT_RANGE, ETAG, IF_MATCH, LAST_MODIFIED, RANGE, TRANSFER_ENCODING, + CONTENT_LENGTH, CONTENT_RANGE, CONTENT_TYPE, ETAG, IF_MATCH, LAST_MODIFIED, RANGE, + TRANSFER_ENCODING, }; use risuko_http::Client; use serde_json::{Map, Value}; @@ -874,7 +875,22 @@ async fn run_single_uri_download( apply_netrc_auth(&mut headers, uri, options); let headers = headers; let stall = StallWatchdog::from_options(options); - let falloc_mode = super::falloc::Mode::from_option(options.get("file-allocation")); + let falloc_mode = { + let mode = super::falloc::Mode::from_option(options.get("file-allocation")); + #[cfg(target_os = "android")] + { + // Android fallocate support varies by filesystem/device, so avoid blocking preallocation there + if mode == super::falloc::Mode::Falloc { + super::falloc::Mode::None + } else { + mode + } + } + #[cfg(not(target_os = "android"))] + { + mode + } + }; // Optional integrity checks. Bad input is rejected up-front so a typo // doesn't silently disable verification: the user immediately sees the @@ -937,6 +953,22 @@ async fn run_single_uri_download( } } + if filename_was_url_derived { + if let Some(content_type) = probe_for_name + .as_ref() + .and_then(|p| p.content_type.as_deref()) + { + if let Some((new_name, new_part)) = + adopt_content_type_extension(&filename, content_type, &part_path, dir_path) + { + tracing::info!("Adding Content-Type extension: {filename:?} -> {new_name:?}"); + *adopted_filename.lock() = Some(new_name.clone()); + filename = new_name; + part_path = new_part; + } + } + } + if is_http && split > 1 { match probe_for_name.clone() { Some(probe) @@ -1025,6 +1057,7 @@ async fn run_single_uri_download( global_limiter.clone(), task_limiter.clone(), stall.clone(), + filename_was_url_derived, ) .await; @@ -1096,6 +1129,7 @@ async fn run_single_uri_download( global_limiter, task_limiter, stall, + filename_was_url_derived, ) .await } @@ -1135,6 +1169,7 @@ struct ProbeResult { /// when present. Overrides URL-path inference for opaque endpoints /// like `download?version=N` suggested_filename: Option, + content_type: Option, /// True when the response confirms range support (206 with valid /// Content-Range, or 200 + Accept-Ranges + Content-Length). When /// false the caller must fall back to a single-connection stream; @@ -1225,6 +1260,7 @@ async fn probe_range_support( .and_then(|v| v.to_str().ok()) .map(|s| s.to_string()); let suggested_filename = filename_from_content_disposition(resp.headers()); + let content_type = content_type_from_headers(resp.headers()); // The fallback we hand back whenever range support isn't confirmed. // Carries the filename / ETag / last-modified info so the streaming @@ -1234,6 +1270,7 @@ async fn probe_range_support( etag: etag.clone(), last_modified: last_modified.clone(), suggested_filename: suggested_filename.clone(), + content_type: content_type.clone(), range_supported: false, }; @@ -1282,6 +1319,7 @@ async fn probe_range_support( etag, last_modified, suggested_filename, + content_type, range_supported: true, }); } @@ -1310,6 +1348,7 @@ async fn probe_range_support( etag, last_modified, suggested_filename, + content_type, range_supported: true, }); } @@ -1964,6 +2003,7 @@ async fn run_single_download( global_limiter: Arc, task_limiter: Arc, stall: StallWatchdog, + filename_was_url_derived: bool, ) -> Result<(PathBuf, Option), String> { let existing_size = if part_path.exists() { fs::metadata(part_path).map(|m| m.len()).unwrap_or(0) @@ -2007,6 +2047,15 @@ async fn run_single_download( .get(LAST_MODIFIED) .and_then(|v| v.to_str().ok()) .map(|s| s.to_string()); + let final_filename = if filename_was_url_derived { + filename_with_content_type_extension( + filename, + content_type_from_headers(resp.headers()).as_deref(), + ) + .unwrap_or_else(|| filename.to_string()) + } else { + filename.to_string() + }; // Update total from Content-Length if let Some(cl) = resp.content_length() { @@ -2105,7 +2154,7 @@ async fn run_single_download( } result?; - let final_path = finalize_download(part_path, filename, dir_path)?; + let final_path = finalize_download(part_path, &final_filename, dir_path)?; Ok((final_path, resp_last_modified)) } @@ -2345,6 +2394,105 @@ fn adopt_suggested_filename( Some((candidate, new_part)) } +fn adopt_content_type_extension( + current_filename: &str, + content_type: &str, + current_part_path: &Path, + dir_path: &Path, +) -> Option<(String, std::path::PathBuf)> { + let candidate = filename_with_content_type_extension(current_filename, Some(content_type))?; + let new_part = dir_path.join(format!("{candidate}{PART_SUFFIX}")); + if new_part != current_part_path + && new_part.exists() + && fs::metadata(&new_part) + .map(|m| m.len() > 0) + .unwrap_or(false) + { + return None; + } + if current_part_path.exists() + && fs::metadata(current_part_path) + .map(|m| m.len() > 0) + .unwrap_or(false) + { + return Some((candidate, current_part_path.to_path_buf())); + } + Some((candidate, new_part)) +} + +fn filename_with_content_type_extension( + filename: &str, + content_type: Option<&str>, +) -> Option { + if filename_has_extension(filename) { + return None; + } + let ext = extension_from_content_type(content_type?)?; + let base = filename.strip_suffix(PART_SUFFIX).unwrap_or(filename); + Some(format!("{base}.{ext}")) +} + +fn filename_has_extension(filename: &str) -> bool { + let base = filename.strip_suffix(PART_SUFFIX).unwrap_or(filename); + Path::new(base) + .extension() + .and_then(|ext| ext.to_str()) + .map(|ext| !ext.trim().is_empty()) + .unwrap_or(false) +} + +fn content_type_from_headers(headers: &HeaderMap) -> Option { + let value = headers.get(CONTENT_TYPE)?.to_str().ok()?; + let mime = value.split(';').next()?.trim().to_ascii_lowercase(); + if mime.is_empty() { + None + } else { + Some(mime) + } +} + +fn extension_from_content_type(content_type: &str) -> Option<&'static str> { + match content_type + .split(';') + .next()? + .trim() + .to_ascii_lowercase() + .as_str() + { + "image/png" => Some("png"), + "image/jpeg" | "image/jpg" => Some("jpg"), + "image/gif" => Some("gif"), + "image/webp" => Some("webp"), + "image/bmp" => Some("bmp"), + "image/svg+xml" => Some("svg"), + "image/avif" => Some("avif"), + "video/mp4" => Some("mp4"), + "video/x-matroska" => Some("mkv"), + "video/webm" => Some("webm"), + "video/quicktime" => Some("mov"), + "video/x-msvideo" => Some("avi"), + "audio/mpeg" => Some("mp3"), + "audio/aac" => Some("aac"), + "audio/ogg" => Some("ogg"), + "audio/wav" | "audio/x-wav" => Some("wav"), + "audio/flac" => Some("flac"), + "application/pdf" => Some("pdf"), + "application/zip" => Some("zip"), + "application/gzip" => Some("gz"), + "application/x-7z-compressed" => Some("7z"), + "application/vnd.rar" => Some("rar"), + "application/json" => Some("json"), + "application/xml" | "text/xml" => Some("xml"), + "application/x-bittorrent" => Some("torrent"), + "text/plain" => Some("txt"), + "text/html" => Some("html"), + "text/css" => Some("css"), + "text/csv" => Some("csv"), + "text/vtt" => Some("vtt"), + _ => None, + } +} + pub fn infer_filename_from_uri(uri: &str) -> String { let without_hash = uri.split('#').next().unwrap_or(uri); let without_query = without_hash.split('?').next().unwrap_or(without_hash); @@ -2536,6 +2684,34 @@ mod tests { assert_eq!(filename_from_content_disposition(&headers), None); } + #[test] + fn content_type_adds_extension_to_extensionless_filename() { + assert_eq!( + filename_with_content_type_extension("download", Some("image/png")).as_deref(), + Some("download.png") + ); + assert_eq!( + filename_with_content_type_extension("download.part", Some("image/png")).as_deref(), + Some("download.png") + ); + } + + #[test] + fn content_type_does_not_replace_existing_extension() { + assert_eq!( + filename_with_content_type_extension("photo.jpg", Some("image/png")), + None + ); + } + + #[test] + fn content_type_ignores_unknown_mime() { + assert_eq!( + filename_with_content_type_extension("download", Some("application/octet-stream")), + None + ); + } + #[test] fn cloudflare_detection_cf_ray_403() { let headers = h(&[("cf-ray", "abc-IAD"), ("server", "cloudflare")]); diff --git a/src-tauri/risuko-engine/src/engine/manager.rs b/src-tauri/risuko-engine/src/engine/manager.rs index c3c368bc..3bbba5c3 100644 --- a/src-tauri/risuko-engine/src/engine/manager.rs +++ b/src-tauri/risuko-engine/src/engine/manager.rs @@ -3,6 +3,7 @@ use std::collections::HashMap; use std::path::Path; use std::sync::atomic::{AtomicBool, AtomicU32, AtomicU64, Ordering}; use std::sync::Arc; +use std::time::Duration; use tokio::sync::RwLock; use tokio_util::sync::CancellationToken; @@ -23,6 +24,9 @@ use super::upload::UploadFileSnapshot; use super::youtube; use std::collections::HashSet; +const MAGNET_METADATA_ATTEMPT_TIMEOUT_SECS: u64 = 60; +const MAGNET_METADATA_RETRY_DELAY_SECS: u64 = 15; + struct ActiveDownload { cancel: Arc, cancel_token: CancellationToken, @@ -42,6 +46,7 @@ pub struct TaskManager { tasks: Arc>>, active_downloads: Arc>>, torrent_ids: Arc>>, + pending_magnets: Arc>>, purged_hashes: Arc>>, options: Arc>, events: EventBroadcaster, @@ -163,6 +168,7 @@ impl TaskManager { tasks: Arc::new(RwLock::new(saved_tasks)), active_downloads: Arc::new(RwLock::new(HashMap::new())), torrent_ids: Arc::new(RwLock::new(HashMap::new())), + pending_magnets: Arc::new(RwLock::new(HashSet::new())), purged_hashes: Arc::new(RwLock::new(purged_hashes)), options: Arc::new(RwLock::new(options)), events, @@ -172,7 +178,6 @@ impl TaskManager { cookie_store: Arc::new(CookieStore::new(config_dir)), }; - // Restore torrent_ids mapping from persisted librqbit session if manager.restore_torrent_mappings().await { manager.purged_hashes.write().await.clear(); } @@ -180,10 +185,6 @@ impl TaskManager { Ok(manager) } - /// restarted, match persisted librqbit torrents back to saved tasks by info_hash. - /// Any persisted librqbit torrent with no matching live task is purged from - /// the librqbit session so it does not silently resume downloading on startup. - /// purge provenance stays on the manager until this cleanup completes successfully. async fn restore_torrent_mappings(&self) -> bool { let te_guard = self.torrent_engine.read().await; let Some(ref te) = *te_guard else { @@ -204,23 +205,21 @@ impl TaskManager { let mut tasks = self.tasks.write().await; let mut ids = self.torrent_ids.write().await; - for (librqbit_id, info_hash) in &managed { + for (torrent_id, info_hash) in &managed { let mut matched = false; for task in tasks.iter_mut() { if task.kind == TaskKind::Torrent && task.info_hash.as_deref() == Some(info_hash.as_str()) && task.status != TaskStatus::Removed { - ids.insert(task.gid.clone(), *librqbit_id); - // Session load sets active to paused, but librqbit is still running - // Restore active so update_progress can track it + ids.insert(task.gid.clone(), *torrent_id); if task.status == TaskStatus::Paused { task.status = TaskStatus::Active; } log::info!( - "Restored torrent mapping: gid={} -> librqbit_id={} ({})", + "Restored torrent mapping: gid={} -> torrent_id={} ({})", task.gid, - librqbit_id, + torrent_id, info_hash ); matched = true; @@ -228,7 +227,7 @@ impl TaskManager { } } if !matched { - orphans.push((*librqbit_id, info_hash.clone())); + orphans.push((*torrent_id, info_hash.clone())); } } @@ -240,24 +239,24 @@ impl TaskManager { ); } - // Purge orphan librqbit torrents (persisted but no live Motrix task). - // Without this, librqbit auto-resumes them on startup and writes files + // Purge orphan torrents (persisted but no live task). + // Without this, the torrent engine auto-resumes them on startup and writes files // even though the user has deleted or never had the task in Motrix. // However, if the orphan came from purge_record_on_start, preserve files. - for (librqbit_id, info_hash) in orphans { + for (torrent_id, info_hash) in orphans { let delete_files = !purged_hashes.contains(&info_hash); - let removal_result = te.remove(librqbit_id, delete_files).await; + let removal_result = te.remove(torrent_id, delete_files).await; let removal_failed = removal_result.is_err(); match removal_result { Ok(()) => log::info!( - "Purged orphan persisted torrent: librqbit_id={} ({}) [delete_files={}]", - librqbit_id, + "Purged orphan persisted torrent: torrent_id={} ({}) [delete_files={}]", + torrent_id, info_hash, delete_files ), Err(e) => log::warn!( - "Failed to purge orphan torrent librqbit_id={} ({}): {}", - librqbit_id, + "Failed to purge orphan torrent torrent_id={} ({}): {}", + torrent_id, info_hash, e ), @@ -472,33 +471,41 @@ impl TaskManager { let mut task = DownloadTask::new_torrent(gid.clone(), dir.clone(), tag, options.clone()); task.uris = vec![magnet_uri.to_string()]; - let te_guard = self.torrent_engine.read().await; - if let Some(ref te) = *te_guard { - match te.add_magnet(magnet_uri, &merged).await { - Ok(handle) => { - self.torrent_ids - .write() - .await - .insert(gid.clone(), handle.id); - task.info_hash = handle.info_hash; - task.info_hash_v2 = handle.info_hash_v2; - task.meta_version = handle.meta_version; - task.status = TaskStatus::Active; - } - Err(e) => { - task.status = TaskStatus::Error; - task.error_code = Some(classify_error(&e, "torrent").to_string()); - task.error_message = Some(e); - } + let should_spawn_resolver = match torrent::inspect_magnet(magnet_uri) { + Ok(info) => { + task.info_hash = Some(info.info_hash); + task.info_hash_v2 = info.info_hash_v2; + task.bt_name = info.display_name; + task.status = TaskStatus::Active; + true } - } else { + Err(e) => { + task.status = TaskStatus::Error; + task.error_code = Some(classify_error(&e, "torrent").to_string()); + task.error_message = Some(e); + false + } + }; + + if should_spawn_resolver && self.torrent_engine.read().await.is_none() { task.status = TaskStatus::Error; task.error_code = Some(super::error_code::ErrorCode::ENGINE_NOT_RUNNING.to_string()); task.error_message = Some("Torrent engine not available".to_string()); } - drop(te_guard); + + let should_start_resolver = task.status == TaskStatus::Active; self.tasks.write().await.push(task); + if should_start_resolver { + self.spawn_magnet_metadata_resolver( + gid.clone(), + magnet_uri.to_string(), + merged.clone(), + ) + .await; + } else { + self.pending_magnets.write().await.remove(&gid); + } self.events .send(EngineEvent::DownloadStart { gid: gid.clone() }); @@ -520,6 +527,120 @@ impl TaskManager { } } + async fn spawn_magnet_metadata_resolver( + &self, + gid: String, + magnet_uri: String, + options: Map, + ) { + { + let mut guard = self.pending_magnets.write().await; + if !guard.insert(gid.clone()) { + return; + } + } + + let pending = self.pending_magnets.clone(); + let tasks = self.tasks.clone(); + let torrent_ids = self.torrent_ids.clone(); + let torrent_engine = self.torrent_engine.clone(); + let events = self.events.clone(); + + tokio::spawn(async move { + loop { + let still_active = { + let guard = tasks.read().await; + guard.iter().any(|task| { + task.gid == gid + && task.kind == TaskKind::Torrent + && task.status == TaskStatus::Active + && task.uris.iter().any(|uri| uri == &magnet_uri) + }) + }; + if !still_active { + break; + } + + let engine = torrent_engine.read().await.clone(); + let Some(engine) = engine else { + let mut guard = tasks.write().await; + if let Some(task) = guard.iter_mut().find(|task| { + task.gid == gid + && task.kind == TaskKind::Torrent + && task.status == TaskStatus::Active + && task.uris.iter().any(|uri| uri == &magnet_uri) + }) { + task.status = TaskStatus::Error; + task.error_code = + Some(super::error_code::ErrorCode::ENGINE_NOT_RUNNING.to_string()); + task.error_message = Some("Torrent engine not available".to_string()); + events.send(EngineEvent::DownloadError { gid: gid.clone() }); + } + break; + }; + + match engine + .resolve_and_add_magnet( + &magnet_uri, + &options, + MAGNET_METADATA_ATTEMPT_TIMEOUT_SECS, + ) + .await + { + Ok(handle) => { + let mut attached = false; + { + let mut guard = tasks.write().await; + if let Some(task) = guard.iter_mut().find(|task| { + task.gid == gid + && task.kind == TaskKind::Torrent + && task.status == TaskStatus::Active + && task.uris.iter().any(|uri| uri == &magnet_uri) + }) { + if let Some(info_hash) = handle.info_hash.clone() { + task.info_hash = Some(info_hash); + } + task.info_hash_v2 = handle.info_hash_v2.clone(); + task.meta_version = handle.meta_version.clone(); + task.error_code = None; + task.error_message = None; + attached = true; + } + } + + if attached { + torrent_ids.write().await.insert(gid.clone(), handle.id); + } else { + let _ = engine.remove(handle.id, false).await; + } + break; + } + Err(e) => { + if !is_retryable_magnet_resolution_error(&e) { + let mut guard = tasks.write().await; + if let Some(task) = guard.iter_mut().find(|task| { + task.gid == gid + && task.kind == TaskKind::Torrent + && task.status == TaskStatus::Active + && task.uris.iter().any(|uri| uri == &magnet_uri) + }) { + task.status = TaskStatus::Error; + task.error_code = Some(classify_error(&e, "torrent").to_string()); + task.error_message = Some(e); + events.send(EngineEvent::DownloadError { gid: gid.clone() }); + } + break; + } + tokio::time::sleep(Duration::from_secs(MAGNET_METADATA_RETRY_DELAY_SECS)) + .await; + } + } + } + + pending.write().await.remove(&gid); + }); + } + pub async fn add_ed2k_task( &self, uri: &str, @@ -2164,9 +2285,48 @@ impl TaskManager { } } // Start any waiting tasks if download slots are available + self.ensure_active_magnet_resolvers().await; self.try_start_next().await; } + async fn ensure_active_magnet_resolvers(&self) { + let jobs = { + // Acquire in the same order as remove() (torrent_ids -> pending_magnets -> tasks) + // to avoid a deadlock where remove() holds torrent_ids.write() while waiting + // for tasks.write() and we hold tasks.read() while waiting for torrent_ids.read(). + let torrent_ids = self.torrent_ids.read().await; + let pending = self.pending_magnets.read().await; + let tasks = self.tasks.read().await; + let options = self.options.read().await; + + tasks + .iter() + .filter(|task| { + task.kind == TaskKind::Torrent + && task.status == TaskStatus::Active + && !torrent_ids.contains_key(&task.gid) + && !pending.contains(&task.gid) + }) + .filter_map(|task| { + let uri = task + .uris + .iter() + .find(|uri| torrent::is_magnet_uri(uri))? + .clone(); + Some(( + task.gid.clone(), + uri, + options.merge_task_options(&task.options), + )) + }) + .collect::>() + }; + + for (gid, uri, options) in jobs { + self.spawn_magnet_metadata_resolver(gid, uri, options).await; + } + } + pub async fn pause(&self, gid: &str) -> Result<(), String> { // Cancel active HTTP download { @@ -2232,6 +2392,8 @@ impl TaskManager { if let Some(ref te) = *te_guard { te.unpause(tid).await.ok(); } + } else { + self.ensure_active_magnet_resolvers().await; } } else { self.try_start_next().await; @@ -2319,6 +2481,7 @@ impl TaskManager { } } self.torrent_ids.write().await.remove(gid); + self.pending_magnets.write().await.remove(gid); let mut tasks = self.tasks.write().await; if let Some(task) = tasks.iter_mut().find(|t| t.gid == gid) { @@ -3019,6 +3182,15 @@ fn looks_like_url(path: &str) -> bool { || path.starts_with("ed2k://") } +fn is_retryable_magnet_resolution_error(err: &str) -> bool { + let lower = err.to_ascii_lowercase(); + lower.contains("failed to fetch metadata") + || lower.contains("timed out") + || lower.contains("timeout") + || lower.contains("no peers") + || 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 { @@ -3130,6 +3302,7 @@ mod tests { tasks: Arc::new(RwLock::new(tasks)), active_downloads: Arc::new(RwLock::new(HashMap::new())), torrent_ids: Arc::new(RwLock::new(HashMap::new())), + pending_magnets: Arc::new(RwLock::new(HashSet::new())), purged_hashes: Arc::new(RwLock::new(HashSet::new())), options: Arc::new(RwLock::new(options)), events, @@ -3152,6 +3325,51 @@ mod tests { task } + async fn make_test_manager_with_engine() -> (TaskManager, tempfile::TempDir) { + let dir = tempfile::TempDir::new().unwrap(); + let mut system = Map::new(); + system.insert( + "dir".to_string(), + Value::String(dir.path().join("downloads").to_string_lossy().to_string()), + ); + system.insert("bt-enable-upnp".to_string(), Value::Bool(false)); + system.insert("bt-enable-lsd".to_string(), Value::Bool(false)); + let options = EngineOptions::from_config(&system, &Map::new()); + let manager = TaskManager::new(dir.path(), options, EventBroadcaster::new(16)) + .await + .unwrap(); + (manager, dir) + } + + #[tokio::test] + async fn add_magnet_task_returns_before_metadata_is_resolved() { + let (mgr, _dir) = make_test_manager_with_engine().await; + let uri = "magnet:?xt=urn:btih:cab507494d02ebb1178b38f2e9d7be299c86b862&dn=Metadata+Later"; + let started = std::time::Instant::now(); + + let gid = tokio::time::timeout( + std::time::Duration::from_secs(2), + mgr.add_magnet_task(uri, Map::new()), + ) + .await + .expect("add_magnet_task should not wait for metadata") + .expect("valid magnet should create a task"); + + assert!(started.elapsed() < std::time::Duration::from_secs(2)); + assert!(!mgr.torrent_ids.read().await.contains_key(&gid)); + assert!(mgr.pending_magnets.read().await.contains(&gid)); + + let tasks = mgr.tasks.read().await; + let task = tasks.iter().find(|task| task.gid == gid).unwrap(); + assert_eq!(task.status, TaskStatus::Active); + assert_eq!( + task.info_hash.as_deref(), + Some("cab507494d02ebb1178b38f2e9d7be299c86b862") + ); + assert_eq!(task.bt_name.as_deref(), Some("Metadata Later")); + assert_eq!(task.uris, vec![uri.to_string()]); + } + #[tokio::test] async fn tell_active_returns_only_active() { let mgr = make_test_manager(vec![ diff --git a/src-tauri/risuko-engine/src/engine/torrent.rs b/src-tauri/risuko-engine/src/engine/torrent.rs index 7e028e1c..91ea747e 100644 --- a/src-tauri/risuko-engine/src/engine/torrent.rs +++ b/src-tauri/risuko-engine/src/engine/torrent.rs @@ -33,6 +33,7 @@ pub struct BtHealthSnapshot { } /// BitTorrent download management via the in-tree `risuko-bt` engine +#[derive(Clone)] pub struct TorrentEngine { session: Option>, output_dir: PathBuf, @@ -227,15 +228,14 @@ impl TorrentEngine { log::info!("Adding magnet to dir={}: {}", dir, magnet_uri); + // When bt-save-metadata is enabled, resolve the magnet ourselves first + // so we can write the synthesized .torrent file next to the payload. + // The resulting bytes are reused to add the torrent, so we do not pay + // the resolve cost twice let save_metadata = options .get("bt-save-metadata") .and_then(|v| v.as_bool()) .unwrap_or(false); - - // When bt-save-metadata is enabled, resolve the magnet ourselves first - // so we can write the synthesized .torrent file next to the payload. - // The resulting bytes are reused to add the torrent, so we do not pay - // the resolve cost twice. if save_metadata { let enc = encryption_policy_from_str( options.get("bt-encryption-policy").and_then(|v| v.as_str()), @@ -247,34 +247,7 @@ impl TorrentEngine { &resolved.trackers, &resolved.piece_layers, ); - if let Ok(meta) = bt::parse_torrent(&bytes) { - let name = if meta.info.name.is_empty() { - format!("{:?}", meta.info_hash) - } else { - meta.info.name.clone() - }; - let safe = sanitize_file_stem(&name); - let path = Path::new(dir).join(format!("{}.torrent", safe)); - // Ensure custom `dir` values exist before writing the - // synthesized .torrent file. - if let Some(parent) = path.parent() { - if let Err(e) = tokio::fs::create_dir_all(parent).await { - log::warn!( - "Failed to create metadata dir {}: {}", - parent.display(), - e - ); - } - } - match tokio::fs::write(&path, &bytes).await { - Ok(()) => log::info!("Saved torrent metadata to {}", path.display()), - Err(e) => log::warn!( - "Failed to save torrent metadata to {}: {}", - path.display(), - e - ), - } - } + save_torrent_metadata_if_enabled(&bytes, options, &self.output_dir).await; return self.add_torrent_bytes(&bytes, options).await; } Err(e) => { @@ -297,6 +270,36 @@ impl TorrentEngine { Ok(handle) } + pub async fn resolve_and_add_magnet( + &self, + magnet_uri: &str, + options: &Map, + timeout_secs: u64, + ) -> Result { + let trackers = Self::parse_trackers(options); + let enc = encryption_policy_from_str( + options.get("bt-encryption-policy").and_then(|v| v.as_str()), + ); + let resolved = bt::magnet::resolve( + magnet_uri, + &trackers, + Duration::from_secs(timeout_secs), + enc, + ) + .await + .map_err(|e| format!("Failed to resolve magnet: {}", e))?; + + let bytes = bt::magnet::synth_torrent_bytes( + &resolved.info_bytes, + &resolved.trackers, + &resolved.piece_layers, + ); + + save_torrent_metadata_if_enabled(&bytes, options, &self.output_dir).await; + + self.add_torrent_bytes(&bytes, options).await + } + pub async fn resolve_magnet( &self, magnet_uri: &str, @@ -564,6 +567,12 @@ pub struct PeerSnapshot { pub seeder: bool, } +pub struct MagnetInfo { + pub info_hash: String, + pub info_hash_v2: Option, + pub display_name: Option, +} + pub struct TorrentMetadataInfo { pub piece_length: u32, pub num_pieces: u32, @@ -576,6 +585,15 @@ pub fn is_magnet_uri(uri: &str) -> bool { uri.trim().to_lowercase().starts_with("magnet:") } +pub fn inspect_magnet(uri: &str) -> Result { + let magnet = bt::Magnet::parse(uri).map_err(|e| e.to_string())?; + Ok(MagnetInfo { + info_hash: magnet.info_hash().as_string(), + info_hash_v2: magnet.info_hash_v2().map(|h| h.as_string()), + display_name: magnet.display_name.clone(), + }) +} + /// Strip filesystem-unsafe characters from a torrent name so it can be used /// as a filename stem on all platforms. Returns a non-empty placeholder for /// names that reduce to whitespace @@ -596,6 +614,50 @@ fn sanitize_file_stem(name: &str) -> String { } } +/// Write a synthesized `.torrent` file when `bt-save-metadata` is enabled. +/// Parses `bytes` to extract the torrent name, builds a safe stem, creates +/// the parent directory, and writes the file via `tokio::fs::write`. +/// All failures are logged as warnings so callers are never blocked. +async fn save_torrent_metadata_if_enabled( + bytes: &[u8], + options: &Map, + output_dir: &Path, +) { + let save_metadata = options + .get("bt-save-metadata") + .and_then(|v| v.as_bool()) + .unwrap_or(false); + if !save_metadata { + return; + } + let dir = options + .get("dir") + .and_then(|v| v.as_str()) + .unwrap_or_else(|| output_dir.to_str().unwrap_or(".")); + if let Ok(meta) = bt::parse_torrent(bytes) { + let name = if meta.info.name.is_empty() { + format!("{:?}", meta.info_hash) + } else { + meta.info.name.clone() + }; + let safe = sanitize_file_stem(&name); + let path = Path::new(dir).join(format!("{}.torrent", safe)); + if let Some(parent) = path.parent() { + if let Err(e) = tokio::fs::create_dir_all(parent).await { + log::warn!("Failed to create metadata dir {}: {}", parent.display(), e); + } + } + match tokio::fs::write(&path, bytes).await { + Ok(()) => log::info!("Saved torrent metadata to {}", path.display()), + Err(e) => log::warn!( + "Failed to save torrent metadata to {}: {}", + path.display(), + e + ), + } + } +} + /// Map an optional config string to a concrete BitTorrent encryption /// policy. Unknown / missing values fall back to `Prefer` (MSE first, /// plaintext fallback) which matches the system default diff --git a/src-tauri/src/commands/android_intent.rs b/src-tauri/src/commands/android_intent.rs new file mode 100644 index 00000000..ee5fcf74 --- /dev/null +++ b/src-tauri/src/commands/android_intent.rs @@ -0,0 +1,353 @@ +//! Android `Intent` helpers for opening files and folders +//! +//! `tauri-plugin-shell::open` only constructs `Intent(ACTION_VIEW, uri)` +//! without an explicit MIME type or any extras. On Android, that is +//! enough on a real device with a file manager (Files by Google, +//! Material Files, etc.) preinstalled — the system narrows candidates +//! by querying the documents provider's MIME and dispatches to the +//! right viewer. But on the AOSP emulator or stripped-down Android +//! builds, the Messages app's `mimeType="*/*"` filter wins because no +//! other app advertises a more specific match +//! +//! File opens are delegated to `MainActivity.openFile`, which builds and +//! starts the Android `Intent` on the UI thread with detailed logcat output. +//! +//! ## JNI bootstrap +//! +//! Tauri 2 mobile does not initialize `ndk-context` on this path +//! We capture the `JavaVM` in `JNI_OnLoad`, keep it in a `OnceLock`, then attach threads as needed +//! When Rust needs a context, we resolve the current `Application` through `ActivityThread` + +#![cfg(target_os = "android")] + +use std::collections::HashMap; +use std::os::raw::c_void; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::{Mutex, OnceLock}; +use std::time::Duration; + +use jni::objects::{JClass, JObject, JString, JValue}; +use jni::sys::{jint, jstring, JNI_VERSION_1_6}; +use jni::{JNIEnv, JavaVM}; + +static JAVA_VM: OnceLock = OnceLock::new(); +static DIRECTORY_PICKERS: OnceLock< + Mutex>>>, +> = OnceLock::new(); +static DIRECTORY_PICKER_COUNTER: AtomicU64 = AtomicU64::new(1); + +/// JNI entry point called when Android loads `libapp_lib.so` +/// Store the `JavaVM` so background Rust threads can call back into Kotlin +/// +/// `#[no_mangle]` keeps the symbol name visible to the dynamic linker +/// Return `JNI_VERSION_1_6`, the lowest version we need +#[no_mangle] +pub extern "system" fn JNI_OnLoad(vm: *mut jni::sys::JavaVM, _: *mut c_void) -> jint { + // SAFETY: Android gives us a process-lifetime `JavaVM*` + if let Ok(vm) = unsafe { JavaVM::from_raw(vm) } { + let _ = JAVA_VM.set(vm); + } + JNI_VERSION_1_6 +} + +#[no_mangle] +pub extern "system" fn Java_app_risuko_mobile_MainActivity_nativeOnDirectoryPicked( + mut env: JNIEnv, + _activity: JObject, + request_id: jstring, + uri: jstring, +) { + let request_id = if request_id.is_null() { + String::new() + } else { + let request_id = unsafe { JString::from_raw(request_id) }; + env.get_string(&request_id) + .map(|s| s.to_string_lossy().into_owned()) + .unwrap_or_default() + }; + let uri = if uri.is_null() { + None + } else { + let uri = unsafe { JString::from_raw(uri) }; + env.get_string(&uri) + .map(|s| s.to_string_lossy().into_owned()) + .ok() + }; + if request_id.is_empty() { + return; + } + if let Ok(mut pending) = directory_pickers().lock() { + if let Some(tx) = pending.remove(&request_id) { + let _ = tx.send(uri); + } + } +} + +fn directory_pickers( +) -> &'static Mutex>>> { + DIRECTORY_PICKERS.get_or_init(|| Mutex::new(HashMap::new())) +} + +pub async fn pick_directory() -> Result, String> { + let request_id = format!( + "{}-{}", + std::process::id(), + DIRECTORY_PICKER_COUNTER.fetch_add(1, Ordering::Relaxed) + ); + let (tx, rx) = tokio::sync::oneshot::channel(); + directory_pickers() + .lock() + .map_err(|e| format!("directory picker lock poisoned: {e}"))? + .insert(request_id.clone(), tx); + + if let Err(err) = start_directory_picker(&request_id) { + if let Ok(mut pending) = directory_pickers().lock() { + pending.remove(&request_id); + } + return Err(err); + } + + match tokio::time::timeout(Duration::from_secs(300), rx).await { + Ok(Ok(uri)) => Ok(uri), + Ok(Err(_)) => Err("Android directory picker was interrupted".to_string()), + Err(_) => { + if let Ok(mut pending) = directory_pickers().lock() { + pending.remove(&request_id); + } + Err("Android directory picker timed out".to_string()) + } + } +} + +fn start_directory_picker(request_id: &str) -> Result<(), String> { + let vm = JAVA_VM + .get() + .ok_or_else(|| "JavaVM not captured (JNI_OnLoad didn't run?)".to_string())?; + let mut env = vm + .attach_current_thread() + .map_err(|e| format!("attach thread: {e}"))?; + let activity = main_activity_class(&mut env)?; + let request_id = env + .new_string(request_id) + .map_err(|e| format!("new_string request_id: {e}"))?; + let started = env + .call_static_method( + activity, + "pickDirectory", + "(Ljava/lang/String;)Z", + &[JValue::Object(&request_id)], + ) + .map_err(|e| format!("MainActivity.pickDirectory: {e}"))? + .z() + .map_err(|e| format!("pickDirectory result: {e}"))?; + if started { + Ok(()) + } else { + Err("Android activity is not ready".to_string()) + } +} + +pub fn set_system_bars(dark_mode: bool) -> Result<(), String> { + let vm = JAVA_VM + .get() + .ok_or_else(|| "JavaVM not captured (JNI_OnLoad didn't run?)".to_string())?; + let mut env = vm + .attach_current_thread() + .map_err(|e| format!("attach thread: {e}"))?; + let activity = main_activity_class(&mut env)?; + env.call_static_method( + activity, + "setSystemBars", + "(Z)V", + &[JValue::Bool(if dark_mode { 1 } else { 0 })], + ) + .map_err(|e| format!("MainActivity.setSystemBars: {e}"))?; + Ok(()) +} + +pub fn ensure_all_files_access() -> Result { + let vm = JAVA_VM + .get() + .ok_or_else(|| "JavaVM not captured (JNI_OnLoad didn't run?)".to_string())?; + let mut env = vm + .attach_current_thread() + .map_err(|e| format!("attach thread: {e}"))?; + let activity = main_activity_class(&mut env)?; + let granted = env + .call_static_method(activity, "requestAllFilesAccess", "()Z", &[]) + .map_err(|e| format!("MainActivity.requestAllFilesAccess: {e}"))? + .z() + .map_err(|e| format!("requestAllFilesAccess result: {e}"))?; + Ok(granted) +} + +pub fn show_download_notification( + progress: u32, + active_count: u32, + detail: &str, +) -> Result<(), String> { + let vm = JAVA_VM + .get() + .ok_or_else(|| "JavaVM not captured (JNI_OnLoad didn't run?)".to_string())?; + let mut env = vm + .attach_current_thread() + .map_err(|e| format!("attach thread: {e}"))?; + let activity = main_activity_class(&mut env)?; + let detail = env + .new_string(detail) + .map_err(|e| format!("new_string detail: {e}"))?; + env.call_static_method( + activity, + "showDownloadNotification", + "(IILjava/lang/String;)V", + &[ + JValue::Int(progress.min(100) as i32), + JValue::Int(active_count.min(i32::MAX as u32) as i32), + JValue::Object(&detail), + ], + ) + .map_err(|e| format!("MainActivity.showDownloadNotification: {e}"))?; + Ok(()) +} + +pub fn hide_download_notification() -> Result<(), String> { + let vm = JAVA_VM + .get() + .ok_or_else(|| "JavaVM not captured (JNI_OnLoad didn't run?)".to_string())?; + let mut env = vm + .attach_current_thread() + .map_err(|e| format!("attach thread: {e}"))?; + let activity = main_activity_class(&mut env)?; + env.call_static_method(activity, "hideDownloadNotification", "()V", &[]) + .map_err(|e| format!("MainActivity.hideDownloadNotification: {e}"))?; + Ok(()) +} + +/// Open `path` in a system file manager via `MainActivity.revealFolder` +/// +/// The Kotlin helper tries several intent shapes in order: a chooser with +/// `vnd.android.document/directory` MIME, direct dispatch with the same +/// MIME, then direct dispatch with no MIME. Each attempt logs to logcat +/// under the `RisukoReveal` tag so we can trace whatever the device did. +/// Returns `"ok"` on success, or a diagnostic string we pass back +/// verbatim. The renderer already logs the error and shows a localized +/// toast +pub fn reveal_folder(path: &str) -> Result<(), String> { + let vm = JAVA_VM + .get() + .ok_or_else(|| "JavaVM not captured (JNI_OnLoad didn't run?)".to_string())?; + let mut env = vm + .attach_current_thread() + .map_err(|e| format!("attach thread: {e}"))?; + let activity = main_activity_class(&mut env)?; + let path_str = env + .new_string(path) + .map_err(|e| format!("new_string path: {e}"))?; + let value = env + .call_static_method( + activity, + "revealFolder", + "(Ljava/lang/String;)Ljava/lang/String;", + &[JValue::Object(&path_str)], + ) + .map_err(|e| format!("MainActivity.revealFolder: {e}"))? + .l() + .map_err(|e| format!("revealFolder result not object: {e}"))?; + if value.is_null() { + return Err("revealFolder returned null".to_string()); + } + let value_str = JString::from(value); + let outcome = env + .get_string(&value_str) + .map_err(|e| format!("get_string revealFolder: {e}"))? + .to_string_lossy() + .into_owned(); + if outcome == "ok" { + Ok(()) + } else { + log::warn!("[Risuko] revealFolder({path}) -> {outcome}"); + Err(outcome) + } +} + +pub fn open_file(path: &str, mime: &str) -> Result<(), String> { + let vm = JAVA_VM + .get() + .ok_or_else(|| "JavaVM not captured (JNI_OnLoad didn't run?)".to_string())?; + let mut env = vm + .attach_current_thread() + .map_err(|e| format!("attach thread: {e}"))?; + let activity = main_activity_class(&mut env)?; + let path_str = env + .new_string(path) + .map_err(|e| format!("new_string path: {e}"))?; + let mime_str = env + .new_string(mime) + .map_err(|e| format!("new_string mime: {e}"))?; + let value = env + .call_static_method( + activity, + "openFile", + "(Ljava/lang/String;Ljava/lang/String;)Ljava/lang/String;", + &[JValue::Object(&path_str), JValue::Object(&mime_str)], + ) + .map_err(|e| format!("MainActivity.openFile: {e}"))? + .l() + .map_err(|e| format!("openFile result not object: {e}"))?; + if value.is_null() { + return Err("openFile returned null".to_string()); + } + let value_str = JString::from(value); + let outcome = env + .get_string(&value_str) + .map_err(|e| format!("get_string openFile: {e}"))? + .to_string_lossy() + .into_owned(); + if outcome == "ok" { + Ok(()) + } else { + log::warn!("[Risuko] openFile({path}, {mime}) -> {outcome}"); + Err(outcome) + } +} + +fn main_activity_class<'env>(env: &mut JNIEnv<'env>) -> Result, String> { + let app = current_application(env).map_err(|e| format!("get application: {e}"))?; + let class_loader = env + .call_method(&app, "getClassLoader", "()Ljava/lang/ClassLoader;", &[]) + .map_err(|e| format!("getClassLoader: {e}"))? + .l() + .map_err(|e| format!("classLoader not object: {e}"))?; + let class_name = env + .new_string("app.risuko.mobile.MainActivity") + .map_err(|e| format!("new_string class_name: {e}"))?; + let activity = env + .call_method( + class_loader, + "loadClass", + "(Ljava/lang/String;)Ljava/lang/Class;", + &[JValue::Object(&class_name)], + ) + .map_err(|e| format!("load MainActivity: {e}"))? + .l() + .map_err(|e| format!("MainActivity class not object: {e}"))?; + Ok(JClass::from(activity)) +} + +/// Resolve the current `Application` through `ActivityThread` +/// Background Rust threads do not hold an `Activity`, so helpers resolve the app context first +fn current_application<'env>(env: &mut JNIEnv<'env>) -> jni::errors::Result> { + let class = env.find_class("android/app/ActivityThread")?; + let thread = env + .call_static_method( + class, + "currentActivityThread", + "()Landroid/app/ActivityThread;", + &[], + )? + .l()?; + let app = env + .call_method(thread, "getApplication", "()Landroid/app/Application;", &[])? + .l()?; + Ok(app) +} diff --git a/src-tauri/src/commands/app_cmds.rs b/src-tauri/src/commands/app_cmds.rs index fc901a5d..c55baa72 100644 --- a/src-tauri/src/commands/app_cmds.rs +++ b/src-tauri/src/commands/app_cmds.rs @@ -150,3 +150,57 @@ pub fn toggle_app_menu(handle: AppHandle, hidden: bool) -> Result<(), String> { pub fn is_opened_at_login() -> bool { std::env::args().any(|arg| arg == "--opened-at-login=1") } + +#[tauri::command] +pub fn set_android_system_bars(dark_mode: bool) -> Result<(), String> { + #[cfg(target_os = "android")] + { + crate::commands::android_intent::set_system_bars(dark_mode) + } + #[cfg(not(target_os = "android"))] + { + let _ = dark_mode; + Ok(()) + } +} + +#[tauri::command] +pub fn ensure_android_storage_access() -> Result { + #[cfg(target_os = "android")] + { + crate::commands::android_intent::ensure_all_files_access() + } + #[cfg(not(target_os = "android"))] + { + Ok(true) + } +} + +#[tauri::command] +pub fn update_android_download_notification( + progress: u32, + active_count: u32, + detail: String, +) -> Result<(), String> { + #[cfg(target_os = "android")] + { + crate::commands::android_intent::show_download_notification(progress, active_count, &detail) + } + #[cfg(not(target_os = "android"))] + { + let _ = (progress, active_count, detail); + Ok(()) + } +} + +#[tauri::command] +pub fn clear_android_download_notification() -> Result<(), String> { + #[cfg(target_os = "android")] + { + crate::commands::android_intent::hide_download_notification() + } + #[cfg(not(target_os = "android"))] + { + Ok(()) + } +} diff --git a/src-tauri/src/commands/config_cmds.rs b/src-tauri/src/commands/config_cmds.rs index 9228c356..9edc275b 100644 --- a/src-tauri/src/commands/config_cmds.rs +++ b/src-tauri/src/commands/config_cmds.rs @@ -1,5 +1,7 @@ use serde_json::{Map, Value}; use tauri::{AppHandle, State}; + +#[cfg(not(target_os = "android"))] use tauri_plugin_autostart::ManagerExt; use crate::{config::parse_keep_seeding_option, state::AppState}; @@ -9,7 +11,7 @@ pub fn get_app_config(handle: AppHandle, state: State<'_, AppState>) -> Result Result { } fn apply_open_at_login(handle: &AppHandle, enabled: bool) -> Result<(), String> { - if enabled { - handle.autolaunch().enable().map_err(|e| e.to_string())?; - } else { - handle.autolaunch().disable().map_err(|e| e.to_string())?; + #[cfg(target_os = "android")] + { + let _ = (handle, enabled); + return Ok(()); } + #[cfg(not(target_os = "android"))] + { + if enabled { + handle.autolaunch().enable().map_err(|e| e.to_string())?; + } else { + handle.autolaunch().disable().map_err(|e| e.to_string())?; + } - Ok(()) + Ok(()) + } +} + +fn is_open_at_login_enabled(handle: &AppHandle) -> Result { + #[cfg(target_os = "android")] + { + let _ = handle; + Ok(false) + } + #[cfg(not(target_os = "android"))] + { + handle.autolaunch().is_enabled().map_err(|e| e.to_string()) + } } #[cfg(test)] diff --git a/src-tauri/src/commands/event_cmds.rs b/src-tauri/src/commands/event_cmds.rs index 114625dd..271da66f 100644 --- a/src-tauri/src/commands/event_cmds.rs +++ b/src-tauri/src/commands/event_cmds.rs @@ -11,7 +11,9 @@ use std::{ sync::{mpsc, OnceLock}, thread, }; -use tauri::{AppHandle, Manager}; +use tauri::AppHandle; +#[cfg(not(target_os = "android"))] +use tauri::Manager; #[tauri::command] pub fn on_download_status_change( @@ -42,40 +44,56 @@ pub fn on_speed_change( download_label: Option, upload_label: Option, ) -> Result<(), String> { - let app_name = app_name - .map(|value| value.trim().to_string()) - .filter(|value| !value.is_empty()) - .unwrap_or_else(|| "Risuko".to_string()); - let download_label = download_label - .map(|value| value.trim().to_string()) - .filter(|value| !value.is_empty()) - .unwrap_or_else(|| "Download".to_string()); - let upload_label = upload_label - .map(|value| value.trim().to_string()) - .filter(|value| !value.is_empty()) - .unwrap_or_else(|| "Upload".to_string()); - - if let Some(tray) = handle.tray_by_id("main") { - if !show_tray_speed { - let _ = tray.set_tooltip(Some(&app_name)); - return Ok(()); - } + #[cfg(target_os = "android")] + { + let _ = ( + handle, + upload_speed, + download_speed, + show_tray_speed, + app_name, + download_label, + upload_label, + ); + return Ok(()); + } + #[cfg(not(target_os = "android"))] + { + let app_name = app_name + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()) + .unwrap_or_else(|| "Risuko".to_string()); + let download_label = download_label + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()) + .unwrap_or_else(|| "Download".to_string()); + let upload_label = upload_label + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()) + .unwrap_or_else(|| "Upload".to_string()); + + if let Some(tray) = handle.tray_by_id("main") { + if !show_tray_speed { + let _ = tray.set_tooltip(Some(&app_name)); + return Ok(()); + } - let tooltip = if upload_speed > 0 || download_speed > 0 { - format!( - "{}\n{}: {}/s {}: {}/s", - app_name, - download_label, - format_speed(download_speed), - upload_label, - format_speed(upload_speed) - ) - } else { - app_name.clone() - }; - let _ = tray.set_tooltip(Some(&tooltip)); + let tooltip = if upload_speed > 0 || download_speed > 0 { + format!( + "{}\n{}: {}/s {}: {}/s", + app_name, + download_label, + format_speed(download_speed), + upload_label, + format_speed(upload_speed) + ) + } else { + app_name.clone() + }; + let _ = tray.set_tooltip(Some(&tooltip)); + } + Ok(()) } - Ok(()) } #[tauri::command] @@ -84,21 +102,29 @@ pub fn on_progress_change( progress: f64, show_progress_bar: bool, ) -> Result<(), String> { - if let Some(window) = handle.get_webview_window("main") { - let (status, prog) = if show_progress_bar && (0.0..=1.0).contains(&progress) { - ( - tauri::window::ProgressBarStatus::Normal, - Some((progress * 100.0) as u64), - ) - } else { - (tauri::window::ProgressBarStatus::None, None) - }; - let _ = window.set_progress_bar(tauri::window::ProgressBarState { - status: Some(status), - progress: prog, - }); + #[cfg(target_os = "android")] + { + let _ = (handle, progress, show_progress_bar); + return Ok(()); + } + #[cfg(not(target_os = "android"))] + { + if let Some(window) = handle.get_webview_window("main") { + let (status, prog) = if show_progress_bar && (0.0..=1.0).contains(&progress) { + ( + tauri::window::ProgressBarStatus::Normal, + Some((progress * 100.0) as u64), + ) + } else { + (tauri::window::ProgressBarStatus::None, None) + }; + let _ = window.set_progress_bar(tauri::window::ProgressBarState { + status: Some(status), + progress: prog, + }); + } + Ok(()) } - Ok(()) } #[tauri::command] @@ -114,11 +140,19 @@ pub fn update_tray( width: u32, height: u32, ) -> Result<(), String> { - if let Some(tray) = handle.tray_by_id("main") { - let image = tauri::image::Image::new_owned(image_data, width, height); - let _ = tray.set_icon(Some(image)); + #[cfg(target_os = "android")] + { + let _ = (handle, image_data, width, height); + return Ok(()); + } + #[cfg(not(target_os = "android"))] + { + if let Some(tray) = handle.tray_by_id("main") { + let image = tauri::image::Image::new_owned(image_data, width, height); + let _ = tray.set_icon(Some(image)); + } + Ok(()) } - Ok(()) } #[tauri::command] @@ -137,6 +171,7 @@ pub fn update_app_menu_labels( crate::managers::menu::update_menu_labels(&handle, &labels) } +#[cfg(any(test, not(target_os = "android")))] fn format_speed(bytes: u64) -> String { const KB: u64 = 1024; const MB: u64 = 1024 * KB; diff --git a/src-tauri/src/commands/file_cmds.rs b/src-tauri/src/commands/file_cmds.rs index f7feb222..b67ff29f 100644 --- a/src-tauri/src/commands/file_cmds.rs +++ b/src-tauri/src/commands/file_cmds.rs @@ -106,71 +106,163 @@ fn ensure_torrent_extension(path: &Path) -> Result<(), String> { } #[tauri::command] -pub fn reveal_in_folder(path: String) -> Result<(), String> { - let p = PathBuf::from(&path); - if !p.exists() { - return Err("Path does not exist".to_string()); - } +pub fn reveal_in_folder(handle: AppHandle, path: String) -> Result<(), String> { + #[cfg(target_os = "android")] + { + // Hand the raw filesystem path to Kotlin. `MainActivity.revealFolder` + // builds the SAF document URI on the UI thread, tries several intent + // shapes (chooser/no-chooser crossed with dirmime/no-mime), and + // reports the first one that actually resolves. Doing this in + // Kotlin lets us call `queryIntentActivities` to skip hopeless + // attempts and catch `ActivityNotFoundException` directly. Both are + // awkward through raw JNI from a Tauri worker thread + let _ = handle; + return crate::commands::android_intent::reveal_folder(&path); + } + + #[cfg(not(target_os = "android"))] + { + let p = PathBuf::from(&path); + if !p.exists() { + return Err("Path does not exist".to_string()); + } - let is_dir = p.is_dir(); + let is_dir = p.is_dir(); - #[cfg(target_os = "macos")] - { - if is_dir { - std::process::Command::new("open") - .arg(&path) - .spawn() - .map_err(|e| e.to_string())?; - } else { - std::process::Command::new("open") - .args(["-R", &path]) - .spawn() - .map_err(|e| e.to_string())?; + #[cfg(target_os = "macos")] + { + let _ = handle; + if is_dir { + std::process::Command::new("open") + .arg(&path) + .spawn() + .map_err(|e| e.to_string())?; + } else { + std::process::Command::new("open") + .args(["-R", &path]) + .spawn() + .map_err(|e| e.to_string())?; + } } - } - #[cfg(target_os = "windows")] - { - use std::os::windows::process::CommandExt; + #[cfg(target_os = "windows")] + { + use std::os::windows::process::CommandExt; + let _ = handle; - // Normalize separators so explorer.exe parses the path reliably. - let normalized_path = path.replace('/', "\\"); + // Normalize separators so explorer.exe parses the path reliably. + let normalized_path = path.replace('/', "\\"); - if is_dir { - // Use ShellExecute via `open` to avoid explorer.exe quirks - // (e.g. non-zero exit codes, race conditions when an Explorer - // window is already focused on the same directory). - open::that(&normalized_path).map_err(|e| e.to_string())?; - } else { - // explorer.exe parses its command line manually and expects the - // form: /select,"". Rust's standard argument escaping - // mangles the embedded quotes, so use raw_arg to pass the - // command line through verbatim. - let raw = format!("/select,\"{}\"", normalized_path); - std::process::Command::new("explorer") - .raw_arg(raw) - .spawn() - .map_err(|e| e.to_string())?; + if is_dir { + // Use ShellExecute via `open` to avoid explorer.exe quirks + // (e.g. non-zero exit codes, race conditions when an Explorer + // window is already focused on the same directory). + open::that(&normalized_path).map_err(|e| e.to_string())?; + } else { + // explorer.exe parses its command line manually and expects the + // form: /select,"". Rust's standard argument escaping + // mangles the embedded quotes, so use raw_arg to pass the + // command line through verbatim. + let raw = format!("/select,\"{}\"", normalized_path); + std::process::Command::new("explorer") + .raw_arg(raw) + .spawn() + .map_err(|e| e.to_string())?; + } } - } - #[cfg(target_os = "linux")] - { - if is_dir { - open::that(path).map_err(|e| e.to_string())?; - } else if let Some(parent) = p.parent() { - open::that(parent.to_string_lossy().as_ref()).map_err(|e| e.to_string())?; - } else { - return Err("Path has no parent directory".to_string()); + #[cfg(target_os = "linux")] + { + let _ = handle; + if is_dir { + open::that(path).map_err(|e| e.to_string())?; + } else if let Some(parent) = p.parent() { + open::that(parent.to_string_lossy().as_ref()).map_err(|e| e.to_string())?; + } else { + return Err("Path has no parent directory".to_string()); + } } + + Ok(()) } +} - Ok(()) +#[tauri::command] +pub async fn select_android_directory() -> Result, String> { + #[cfg(target_os = "android")] + { + crate::commands::android_intent::pick_directory().await + } + #[cfg(not(target_os = "android"))] + { + Err("Android directory picker is only available on Android".to_string()) + } } #[tauri::command] -pub fn open_path(path: String) -> Result<(), String> { - open::that(&path).map_err(|e| e.to_string()) +pub fn open_path(handle: AppHandle, path: String) -> Result<(), String> { + #[cfg(target_os = "android")] + { + // URI in, intent out + // For real file paths, guess the MIME type first so Android shows useful viewers + let _ = handle; + let mime = guess_android_mime(&path); + return crate::commands::android_intent::open_file(&path, &mime); + } + #[cfg(not(target_os = "android"))] + { + let _ = handle; + open::that(&path).map_err(|e| e.to_string()) + } +} + +/// Map a filename extension to a MIME type Android understands +/// Fall back to `*/*` so unknown files still get a chooser +#[cfg(target_os = "android")] +fn guess_android_mime(path: &str) -> String { + let ext = std::path::Path::new(path) + .extension() + .and_then(|s| s.to_str()) + .map(|s| s.to_ascii_lowercase()) + .unwrap_or_default(); + let mime: &str = match ext.as_str() { + // Video + "mp4" | "m4v" => "video/mp4", + "mkv" => "video/x-matroska", + "webm" => "video/webm", + "avi" => "video/x-msvideo", + "mov" => "video/quicktime", + "wmv" => "video/x-ms-wmv", + "flv" => "video/x-flv", + "ts" => "video/mp2t", + // Audio + "mp3" => "audio/mpeg", + "m4a" | "aac" => "audio/aac", + "ogg" | "opus" => "audio/ogg", + "wav" => "audio/wav", + "flac" => "audio/flac", + // Image + "jpg" | "jpeg" => "image/jpeg", + "png" => "image/png", + "gif" => "image/gif", + "webp" => "image/webp", + "bmp" => "image/bmp", + "svg" => "image/svg+xml", + // Documents / archives + "pdf" => "application/pdf", + "txt" | "log" | "md" => "text/plain", + "zip" => "application/zip", + "tar" => "application/x-tar", + "gz" | "tgz" => "application/gzip", + "rar" => "application/vnd.rar", + "7z" => "application/x-7z-compressed", + // Subtitles + "srt" => "application/x-subrip", + "vtt" => "text/vtt", + // Fallback for generic handlers + _ => "*/*", + }; + mime.to_string() } #[tauri::command] @@ -185,7 +277,7 @@ pub fn trash_item(path: String) -> Result { } return Ok(false); } - trash::delete(&path).map_err(|e| e.to_string())?; + delete_path(p)?; // Clean up multi-chunk resume sidecar alongside .part file if path.ends_with(TEMP_DOWNLOAD_SUFFIX) { let chunks_path = format!("{}{}", path, CHUNK_META_SUFFIX); @@ -915,7 +1007,21 @@ fn bytes_to_lower_hex(bytes: &[u8]) -> String { } fn delete_file_best_effort(path: &Path) -> bool { - trash::delete(path).is_ok() || std::fs::remove_file(path).is_ok() + delete_path(path).is_ok() || std::fs::remove_file(path).is_ok() +} + +#[cfg(target_os = "android")] +fn delete_path(path: &Path) -> Result<(), String> { + if path.is_dir() { + std::fs::remove_dir_all(path).map_err(|e| e.to_string()) + } else { + std::fs::remove_file(path).map_err(|e| e.to_string()) + } +} + +#[cfg(not(target_os = "android"))] +fn delete_path(path: &Path) -> Result<(), String> { + trash::delete(path).map_err(|e| e.to_string()) } fn extract_btih_token(input: &str) -> Option { diff --git a/src-tauri/src/commands/health_cmds.rs b/src-tauri/src/commands/health_cmds.rs index ef8cb0e8..874f3f1e 100644 --- a/src-tauri/src/commands/health_cmds.rs +++ b/src-tauri/src/commands/health_cmds.rs @@ -14,6 +14,8 @@ use std::time::{Duration, SystemTime}; use serde::Serialize; use serde_json::Value; use tauri::{AppHandle, State}; + +#[cfg(not(target_os = "android"))] use tauri_plugin_autostart::ManagerExt; use risuko_engine::engine::{self, options::EngineOptions, torrent::BtHealthSnapshot, youtube}; @@ -173,7 +175,7 @@ pub async fn run_health_checks( ) }; let options = EngineOptions::from_config(&system_cfg, &user_cfg); - let autostart_enabled = handle.autolaunch().is_enabled().unwrap_or(false); + let autostart_enabled = autostart_enabled(&handle); let prevent_sleep_while_downloading = parse_boolish(user_cfg.get("prevent-sleep-while-downloading"), true); @@ -237,7 +239,7 @@ pub async fn run_health_checks( if want("logs") { cats.push(HealthCategory::from_checks("logs", check_logs(&log_dir))); } - if want("tools") { + if want("tools") && !cfg!(target_os = "android") { cats.push(HealthCategory::from_checks("tools", check_tools().await)); } @@ -864,6 +866,18 @@ fn check_system(autostart: bool, prevent_sleep_while_downloading: bool) -> Vec bool { + #[cfg(target_os = "android")] + { + let _ = handle; + false + } + #[cfg(not(target_os = "android"))] + { + handle.autolaunch().is_enabled().unwrap_or(false) + } +} + // Config fn check_config( diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index 27c174e9..d08c958e 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -1,3 +1,5 @@ +#[cfg(target_os = "android")] +pub mod android_intent; pub mod app_cmds; pub mod completion_script_cmds; pub mod config_cmds; diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 31cceb96..ae4f9b4e 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -11,11 +11,108 @@ pub use risuko_engine::engine; use std::sync::atomic::Ordering; use std::sync::Arc; -use tauri::{Emitter, Manager}; +#[cfg(not(target_os = "android"))] +use tauri::Emitter; +use tauri::Manager; + +#[cfg(not(target_os = "android"))] use tauri_plugin_autostart::{MacosLauncher, ManagerExt}; use risuko_engine::engine::rss::RssManager; +#[cfg(not(target_os = "android"))] +fn with_nosleep_plugin(builder: tauri::Builder) -> tauri::Builder { + builder.plugin(tauri_plugin_nosleep::init()) +} + +#[cfg(target_os = "android")] +fn with_nosleep_plugin(builder: tauri::Builder) -> tauri::Builder { + builder +} + +#[cfg(not(target_os = "android"))] +fn with_desktop_plugins(builder: tauri::Builder) -> tauri::Builder { + builder + .plugin(tauri_plugin_autostart::init( + MacosLauncher::LaunchAgent, + Some(vec!["--opened-at-login=1"]), + )) + .plugin(tauri_plugin_single_instance::init(|app, args, _cwd| { + if let Some(window) = app.get_webview_window("main") { + let _ = window.show(); + let _ = window.set_focus(); + + if let Some(path) = args.get(1) { + if std::path::Path::new(path).exists() { + log::info!("Single instance received file: {}", path); + let _ = window.emit("open-file", path); + } + } + } + })) +} + +#[cfg(target_os = "android")] +fn with_desktop_plugins(builder: tauri::Builder) -> tauri::Builder { + builder +} + +/// Resolve which directory the log appender should write to +/// +/// `log-dir-override` lets the user point logs at any writable directory. +/// That matters most on Android, where the default `app_log_dir` lives in +/// the app's private data dir and stays invisible to file managers. An +/// empty value means "use the OS default". A non-empty value that we +/// can't create or write falls back to the default too. Logging should +/// not fail just because the override went stale (removed SD card, +/// revoked permission, typo'd path) +fn resolve_log_dir( + config: &risuko_engine::config::ConfigManager, + default_log_dir: &std::path::Path, +) -> std::path::PathBuf { + let override_value = config + .get_user_config() + .get("log-dir-override") + .and_then(|v| v.as_str()) + .map(|s| s.trim().to_string()) + .unwrap_or_default(); + if override_value.is_empty() { + return default_log_dir.to_path_buf(); + } + let candidate = std::path::PathBuf::from(&override_value); + if !candidate.is_absolute() { + eprintln!( + "log-dir-override must be an absolute path; got '{}'. Falling back to default.", + override_value + ); + return default_log_dir.to_path_buf(); + } + if let Err(e) = std::fs::create_dir_all(&candidate) { + eprintln!( + "log-dir-override '{}' is not writable ({}). Falling back to default.", + candidate.display(), + e + ); + return default_log_dir.to_path_buf(); + } + let probe = candidate.join(".risuko-log-write-test"); + if let Err(e) = std::fs::OpenOptions::new() + .create(true) + .write(true) + .truncate(false) + .open(&probe) + { + eprintln!( + "log-dir-override '{}' is not writable ({}). Falling back to default.", + candidate.display(), + e + ); + return default_log_dir.to_path_buf(); + } + let _ = std::fs::remove_file(probe); + candidate +} + /// Set up tracing subscriber with both stdout and file output. /// Returns a guard that must be held for the lifetime of the application. fn init_logging( @@ -69,330 +166,335 @@ fn apply_linux_webkit_workarounds() { } } +#[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { #[cfg(target_os = "linux")] apply_linux_webkit_workarounds(); - let app = tauri::Builder::default() - .plugin(tauri_plugin_store::Builder::default().build()) - .plugin(tauri_plugin_dialog::init()) - .plugin(tauri_plugin_shell::init()) - .plugin(tauri_plugin_fs::init()) - .plugin(tauri_plugin_process::init()) - .plugin(tauri_plugin_os::init()) - .plugin(tauri_plugin_notification::init()) - .plugin(tauri_plugin_nosleep::init()) - .plugin(tauri_plugin_autostart::init( - MacosLauncher::LaunchAgent, - Some(vec!["--opened-at-login=1"]), - )) - .plugin(tauri_plugin_clipboard_manager::init()) - .plugin(tauri_plugin_single_instance::init(|app, args, _cwd| { - if let Some(window) = app.get_webview_window("main") { - let _ = window.show(); - let _ = window.set_focus(); - - if let Some(path) = args.get(1) { - if std::path::Path::new(path).exists() { - log::info!("Single instance received file: {}", path); - let _ = window.emit("open-file", path); - } - } - } - })) - .plugin(tauri_plugin_deep_link::init()) - .setup(|app| { - let handle = app.handle(); - - // Create Tauri-backed trait implementations - let config_dir_provider = bridge::TauriConfigDir::new(handle); - let event_sink: Arc = - Arc::new(bridge::TauriEventSink::new(handle)); - let storage: Arc = - Arc::new(bridge::TauriStorage::new(handle)); - - // Resolve log directory and read log level from user config before AppState - let log_dir = handle.path().app_log_dir().unwrap_or_else(|_| { - handle - .path() - .app_config_dir() - .unwrap_or_else(|_| std::path::PathBuf::from(".")) - .join("logs") - }); - let config = risuko_engine::config::ConfigManager::new(&config_dir_provider) - .map_err(|e| e.to_string())?; - let log_level = config - .get_user_config() - .get("log-level") - .and_then(|v| v.as_str()) - .unwrap_or("warn") - .to_string(); - - let log_guard = init_logging(&log_dir, &log_level); + let app = with_desktop_plugins(with_nosleep_plugin( + tauri::Builder::default() + .plugin(tauri_plugin_store::Builder::default().build()) + .plugin(tauri_plugin_dialog::init()) + .plugin(tauri_plugin_shell::init()) + .plugin(tauri_plugin_fs::init()) + .plugin(tauri_plugin_process::init()) + .plugin(tauri_plugin_os::init()) + .plugin(tauri_plugin_notification::init()), + )) + .plugin(tauri_plugin_clipboard_manager::init()) + .plugin(tauri_plugin_deep_link::init()) + .setup(|app| { + let handle = app.handle(); + + // Create Tauri-backed trait implementations + let config_dir_provider = bridge::TauriConfigDir::new(handle); + let event_sink: Arc = + Arc::new(bridge::TauriEventSink::new(handle)); + let storage: Arc = + Arc::new(bridge::TauriStorage::new(handle)); + + // Resolve the log directory and log level from user config before + // building AppState. Default to the OS-specific app log dir. The + // user can override via `log-dir-override`, for instance pointing + // at `/storage/emulated/0/Download/Risuko-logs` on Android so logs + // show up in any file manager. The override only applies if we + // can create and write to it. Otherwise the default kicks in so + // we always have somewhere to log + let default_log_dir = handle.path().app_log_dir().unwrap_or_else(|_| { + handle + .path() + .app_config_dir() + .unwrap_or_else(|_| std::path::PathBuf::from(".")) + .join("logs") + }); + let config = risuko_engine::config::ConfigManager::new(&config_dir_provider) + .map_err(|e| e.to_string())?; + let log_level = config + .get_user_config() + .get("log-level") + .and_then(|v| v.as_str()) + .unwrap_or("warn") + .to_string(); + let log_dir = resolve_log_dir(&config, &default_log_dir); + + let log_guard = init_logging(&log_dir, &log_level); + if log_dir != default_log_dir { + log::info!( + "Log directory: {} (override of {})", + log_dir.display(), + default_log_dir.display() + ); + } else { log::info!("Log directory: {}", log_dir.display()); + } - let app_state = state::AppState::new(config, storage.clone(), log_dir, log_guard)?; - app.manage(app_state); - sync_open_at_login_setting(app); + let app_state = state::AppState::new(config, storage.clone(), log_dir, log_guard)?; + app.manage(app_state); + sync_open_at_login_setting(app); - // Windows/Linux use a custom title bar, so disable native decorations - // macOS keeps decorations and uses `titleBarStyle: Overlay` - #[cfg(not(target_os = "macos"))] + // Windows/Linux use a custom title bar, so disable native decorations + // macOS keeps decorations and uses `titleBarStyle: Overlay` + #[cfg(not(any(target_os = "macos", target_os = "android")))] + if let Some(window) = app.get_webview_window("main") { + let _ = window.set_decorations(false); + } + + let opened_at_login = std::env::args().any(|arg| arg == "--opened-at-login=1"); + if opened_at_login { if let Some(window) = app.get_webview_window("main") { - let _ = window.set_decorations(false); + let _ = window.hide(); } + } + + let config_guard = app.state::(); + let should_start = { + let config = config_guard.config.lock().unwrap(); + risuko_engine::engine::should_start_embedded_engine(&config) + }; + + // Push the live Tauri event sink into the upload manager so + // upload events reach the frontend (we constructed it with a + // NoopEventSink before AppHandle was available). This must run + // regardless of whether the embedded engine auto-starts so that + // sink configuration UI receives upload events when the user + // starts the engine later. + // + // Fail fast on a poisoned lock or missing manager: the previous + // `lock().ok().and_then(...)` swallowed both, leaving the app + // running with no upload event plumbing and no obvious symptom + let event_sink_clone = event_sink.clone(); + let upload_mgr = { + let state = app.state::(); + let guard = state + .upload_sinks + .lock() + .map_err(|e| format!("upload_sinks lock poisoned: {e}"))?; + guard + .clone() + .ok_or_else(|| "upload_sinks not initialized".to_string())? + }; + upload_mgr.set_event_sink(event_sink_clone.clone()); + let upload_mgr = Some(upload_mgr); + + // Inject SFTP/FTP/WebDAV/S3 secrets stored in the OS keychain + // back into the upload manager. The on-disk records omit them + // (`skip_serializing` on the protocol Configs) so without this + // pass the user has to re-enter every password after a restart + if let Some(mgr) = upload_mgr.clone() { + let state = app.state::(); + let vault = state.vault.clone(); + tauri::async_runtime::block_on(commands::upload_cmds::rehydrate_upload_sinks( + &mgr, &vault, + )); + } - let opened_at_login = std::env::args().any(|arg| arg == "--opened-at-login=1"); - if opened_at_login { - if let Some(window) = app.get_webview_window("main") { - let _ = window.hide(); + if should_start { + let config_ref = config_guard.config.lock().unwrap(); + let config_dir = config_ref.config_dir().to_path_buf(); + drop(config_ref); + + let storage_clone = storage.clone(); + tauri::async_runtime::spawn(async move { + let config = match risuko_engine::config::ConfigManager::with_dir(config_dir) { + Ok(c) => c, + Err(e) => { + log::error!("Failed to create ConfigManager: {}", e); + return; + } + }; + if let Err(e) = risuko_engine::engine::start_engine( + &config, + event_sink_clone, + storage_clone, + upload_mgr, + ) + .await + { + log::error!("Failed to start engine: {}", e); } - } + }); + } - let config_guard = app.state::(); - let should_start = { - let config = config_guard.config.lock().unwrap(); - risuko_engine::engine::should_start_embedded_engine(&config) - }; - - // Push the live Tauri event sink into the upload manager so - // upload events reach the frontend (we constructed it with a - // NoopEventSink before AppHandle was available). This must run - // regardless of whether the embedded engine auto-starts so that - // sink configuration UI receives upload events when the user - // starts the engine later. - // - // Fail fast on a poisoned lock or missing manager: the previous - // `lock().ok().and_then(...)` swallowed both, leaving the app - // running with no upload event plumbing and no obvious symptom - let event_sink_clone = event_sink.clone(); - let upload_mgr = { - let state = app.state::(); - let guard = state - .upload_sinks - .lock() - .map_err(|e| format!("upload_sinks lock poisoned: {e}"))?; - guard - .clone() - .ok_or_else(|| "upload_sinks not initialized".to_string())? - }; - upload_mgr.set_event_sink(event_sink_clone.clone()); - let upload_mgr = Some(upload_mgr); - - // Inject SFTP/FTP/WebDAV/S3 secrets stored in the OS keychain - // back into the upload manager. The on-disk records omit them - // (`skip_serializing` on the protocol Configs) so without this - // pass the user has to re-enter every password after a restart - if let Some(mgr) = upload_mgr.clone() { - let state = app.state::(); - let vault = state.vault.clone(); - tauri::async_runtime::block_on(commands::upload_cmds::rehydrate_upload_sinks( - &mgr, &vault, - )); + managers::menu::setup_menu(app)?; + + // On non-macOS, respect the hide-app-menu user preference + #[cfg(not(any(target_os = "macos", target_os = "android")))] + { + let hide_menu = app + .state::() + .config + .lock() + .ok() + .and_then(|cfg| { + cfg.get_user_config() + .get("hide-app-menu") + .and_then(|v| v.as_bool()) + }) + .unwrap_or(true); + if hide_menu { + let _ = app.handle().remove_menu(); } + } - if should_start { - let config_ref = config_guard.config.lock().unwrap(); - let config_dir = config_ref.config_dir().to_path_buf(); - drop(config_ref); + managers::tray::setup_tray(app)?; - let storage_clone = storage.clone(); + // Start RSS background polling + if let Ok(guard) = app.state::().rss.lock() { + if let Some(rss) = guard.clone() { + // Must spawn into Tauri's async runtime so the tokio + // reactor is available for the inner tokio::spawn call. tauri::async_runtime::spawn(async move { - let config = match risuko_engine::config::ConfigManager::with_dir(config_dir) { - Ok(c) => c, - Err(e) => { - log::error!("Failed to create ConfigManager: {}", e); - return; - } - }; - if let Err(e) = risuko_engine::engine::start_engine( - &config, - event_sink_clone, - storage_clone, - upload_mgr, - ) - .await - { - log::error!("Failed to start engine: {}", e); - } + std::mem::drop(RssManager::start_polling(rss)); }); } + } - managers::menu::setup_menu(app)?; - - // On non-macOS, respect the hide-app-menu user preference - #[cfg(not(target_os = "macos"))] - { - let hide_menu = app - .state::() - .config - .lock() - .ok() - .and_then(|cfg| { - cfg.get_user_config() - .get("hide-app-menu") - .and_then(|v| v.as_bool()) - }) - .unwrap_or(true); - if hide_menu { - let _ = app.handle().remove_menu(); - } - } - - managers::tray::setup_tray(app)?; - - // Start RSS background polling - if let Ok(guard) = app.state::().rss.lock() { - if let Some(rss) = guard.clone() { - // Must spawn into Tauri's async runtime so the tokio - // reactor is available for the inner tokio::spawn call. - tauri::async_runtime::spawn(async move { - std::mem::drop(RssManager::start_polling(rss)); - }); - } - } - - Ok(()) - }) - .on_window_event(|window, event| { - if let tauri::WindowEvent::CloseRequested { api, .. } = event { - let quitting = window - .app_handle() - .state::() - .is_quitting - .load(Ordering::SeqCst); - if quitting { - return; - } - api.prevent_close(); - let _ = commands::app_cmds::hide_main_window(window.app_handle()); + Ok(()) + }) + .on_window_event(|window, event| { + if let tauri::WindowEvent::CloseRequested { api, .. } = event { + let quitting = window + .app_handle() + .state::() + .is_quitting + .load(Ordering::SeqCst); + if quitting { + return; } - }) - .invoke_handler(tauri::generate_handler![ - commands::config_cmds::get_app_config, - commands::config_cmds::save_preference, - commands::config_cmds::prepare_preference_patch, - commands::app_cmds::relaunch_app, - commands::app_cmds::quit_app, - 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, - commands::app_cmds::is_opened_at_login, - commands::file_cmds::reveal_in_folder, - commands::file_cmds::open_path, - commands::file_cmds::trash_item, - commands::file_cmds::rename_path, - commands::file_cmds::read_binary_file, - commands::file_cmds::resolve_torrent_path, - commands::file_cmds::trash_generated_torrent_sidecars, - commands::file_cmds::cleanup_generated_torrent_sidecars_for_task, - commands::engine_cmds::restart_engine, - commands::engine_cmds::get_engine_status, - commands::health_cmds::run_health_checks, - commands::engine_cmds::add_uri, - commands::engine_cmds::add_youtube, - commands::engine_cmds::get_youtube_video_info, - commands::engine_cmds::add_torrent_by_path, - commands::engine_cmds::add_torrents_by_paths, - commands::engine_cmds::resolve_magnet, - commands::engine_cmds::probe_m3u8, - commands::engine_cmds::calculate_active_task_progress, - commands::engine_cmds::evaluate_low_speed_tasks, - commands::engine_cmds::plan_auto_retry, - commands::engine_cmds::sync_selected_task_order, - commands::engine_cmds::tell_status, - commands::engine_cmds::tell_active, - commands::engine_cmds::tell_waiting, - commands::engine_cmds::tell_stopped, - commands::engine_cmds::pause_task, - commands::engine_cmds::unpause_task, - commands::engine_cmds::remove_task, - commands::engine_cmds::change_option, - commands::engine_cmds::change_global_option_engine, - commands::engine_cmds::get_option_engine, - commands::engine_cmds::get_global_option_engine, - commands::engine_cmds::get_global_stat, - commands::engine_cmds::change_position, - commands::engine_cmds::save_session, - commands::engine_cmds::get_version, - commands::engine_cmds::pause_all_tasks, - commands::engine_cmds::unpause_all_tasks, - commands::engine_cmds::purge_download_result, - commands::engine_cmds::remove_download_result, - commands::engine_cmds::get_peers, - commands::engine_cmds::multicall_engine, - commands::engine_cmds::infer_out_from_uri, - commands::engine_cmds::resolve_file_category, - commands::cookie_cmds::list_browsers_cmd, - commands::cookie_cmds::import_browser_cookies, - commands::cookie_cmds::list_cookie_entries, - commands::cookie_cmds::delete_cookie_entry, - commands::cookie_cmds::clear_cookie_entries, - commands::cookie_cmds::retry_with_cookies, - commands::cookie_cmds::capture_user_agent, - commands::engine_cmds::list_routing_rules, - commands::engine_cmds::add_routing_rule, - commands::engine_cmds::update_routing_rule, - commands::engine_cmds::remove_routing_rule, - commands::engine_cmds::resolve_routing, - commands::event_cmds::on_download_status_change, - commands::event_cmds::set_sleep_inhibit_flag, - commands::event_cmds::on_speed_change, - commands::event_cmds::on_progress_change, - commands::event_cmds::on_task_download_complete, - commands::completion_script_cmds::run_completion_script, - commands::completion_script_cmds::test_completion_script, - commands::event_cmds::update_tray, - commands::event_cmds::update_tray_menu_labels, - commands::event_cmds::update_app_menu_labels, - commands::rss_cmds::add_rss_feed, - commands::rss_cmds::remove_rss_feed, - commands::rss_cmds::refresh_rss_feed, - commands::rss_cmds::refresh_all_rss_feeds, - commands::rss_cmds::get_rss_feeds, - commands::rss_cmds::get_rss_items, - commands::rss_cmds::update_rss_feed_settings, - commands::rss_cmds::add_rss_rule, - commands::rss_cmds::update_rss_rule, - commands::rss_cmds::reorder_rss_rules, - commands::rss_cmds::dry_run_rss_rule, - commands::rss_cmds::parse_rss_item_title, - commands::rss_cmds::remove_rss_rule, - commands::rss_cmds::get_rss_rules, - commands::rss_cmds::download_rss_item, - commands::rss_cmds::delete_rss_items, - commands::rss_cmds::mark_rss_downloaded, - commands::rss_cmds::clear_rss_download, - commands::rss_cmds::read_rss_download, - commands::rss_cmds::download_rss_item_tracked, - commands::rss_cmds::mark_rss_item_read, - commands::rss_cmds::mark_rss_items_read, - commands::upload_cmds::list_upload_sinks, - commands::upload_cmds::add_upload_sink, - commands::upload_cmds::update_upload_sink, - commands::upload_cmds::remove_upload_sink, - commands::upload_cmds::test_upload_sink, - commands::upload_cmds::get_default_upload_sink, - commands::upload_cmds::set_default_upload_sink, - commands::upload_cmds::set_upload_max_concurrency, - commands::upload_cmds::list_upload_rules, - commands::upload_cmds::add_upload_rule, - commands::upload_cmds::update_upload_rule, - commands::upload_cmds::remove_upload_rule, - commands::upload_cmds::list_upload_jobs, - commands::upload_cmds::cancel_upload_job, - commands::upload_cmds::clear_upload_history, - commands::vault_cmds::vault_status, - commands::vault_cmds::vault_put_credential, - commands::vault_cmds::vault_get_credential, - commands::vault_cmds::vault_remove_credential, - ]) - .build(tauri::generate_context!()) - .expect("error while building Risuko"); + api.prevent_close(); + let _ = commands::app_cmds::hide_main_window(window.app_handle()); + } + }) + .invoke_handler(tauri::generate_handler![ + commands::config_cmds::get_app_config, + commands::config_cmds::save_preference, + commands::config_cmds::prepare_preference_patch, + commands::app_cmds::relaunch_app, + commands::app_cmds::quit_app, + 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, + commands::app_cmds::is_opened_at_login, + commands::app_cmds::set_android_system_bars, + commands::app_cmds::ensure_android_storage_access, + commands::app_cmds::update_android_download_notification, + commands::app_cmds::clear_android_download_notification, + commands::file_cmds::reveal_in_folder, + commands::file_cmds::select_android_directory, + commands::file_cmds::open_path, + commands::file_cmds::trash_item, + commands::file_cmds::rename_path, + commands::file_cmds::read_binary_file, + commands::file_cmds::resolve_torrent_path, + commands::file_cmds::trash_generated_torrent_sidecars, + commands::file_cmds::cleanup_generated_torrent_sidecars_for_task, + commands::engine_cmds::restart_engine, + commands::engine_cmds::get_engine_status, + commands::health_cmds::run_health_checks, + commands::engine_cmds::add_uri, + commands::engine_cmds::add_youtube, + commands::engine_cmds::get_youtube_video_info, + commands::engine_cmds::add_torrent_by_path, + commands::engine_cmds::add_torrents_by_paths, + commands::engine_cmds::resolve_magnet, + commands::engine_cmds::probe_m3u8, + commands::engine_cmds::calculate_active_task_progress, + commands::engine_cmds::evaluate_low_speed_tasks, + commands::engine_cmds::plan_auto_retry, + commands::engine_cmds::sync_selected_task_order, + commands::engine_cmds::tell_status, + commands::engine_cmds::tell_active, + commands::engine_cmds::tell_waiting, + commands::engine_cmds::tell_stopped, + commands::engine_cmds::pause_task, + commands::engine_cmds::unpause_task, + commands::engine_cmds::remove_task, + commands::engine_cmds::change_option, + commands::engine_cmds::change_global_option_engine, + commands::engine_cmds::get_option_engine, + commands::engine_cmds::get_global_option_engine, + commands::engine_cmds::get_global_stat, + commands::engine_cmds::change_position, + commands::engine_cmds::save_session, + commands::engine_cmds::get_version, + commands::engine_cmds::pause_all_tasks, + commands::engine_cmds::unpause_all_tasks, + commands::engine_cmds::purge_download_result, + commands::engine_cmds::remove_download_result, + commands::engine_cmds::get_peers, + commands::engine_cmds::multicall_engine, + commands::engine_cmds::infer_out_from_uri, + commands::engine_cmds::resolve_file_category, + commands::cookie_cmds::list_browsers_cmd, + commands::cookie_cmds::import_browser_cookies, + commands::cookie_cmds::list_cookie_entries, + commands::cookie_cmds::delete_cookie_entry, + commands::cookie_cmds::clear_cookie_entries, + commands::cookie_cmds::retry_with_cookies, + commands::cookie_cmds::capture_user_agent, + commands::engine_cmds::list_routing_rules, + commands::engine_cmds::add_routing_rule, + commands::engine_cmds::update_routing_rule, + commands::engine_cmds::remove_routing_rule, + commands::engine_cmds::resolve_routing, + commands::event_cmds::on_download_status_change, + commands::event_cmds::set_sleep_inhibit_flag, + commands::event_cmds::on_speed_change, + commands::event_cmds::on_progress_change, + commands::event_cmds::on_task_download_complete, + commands::completion_script_cmds::run_completion_script, + commands::completion_script_cmds::test_completion_script, + commands::event_cmds::update_tray, + commands::event_cmds::update_tray_menu_labels, + commands::event_cmds::update_app_menu_labels, + commands::rss_cmds::add_rss_feed, + commands::rss_cmds::remove_rss_feed, + commands::rss_cmds::refresh_rss_feed, + commands::rss_cmds::refresh_all_rss_feeds, + commands::rss_cmds::get_rss_feeds, + commands::rss_cmds::get_rss_items, + commands::rss_cmds::update_rss_feed_settings, + commands::rss_cmds::add_rss_rule, + commands::rss_cmds::update_rss_rule, + commands::rss_cmds::reorder_rss_rules, + commands::rss_cmds::dry_run_rss_rule, + commands::rss_cmds::parse_rss_item_title, + commands::rss_cmds::remove_rss_rule, + commands::rss_cmds::get_rss_rules, + commands::rss_cmds::download_rss_item, + commands::rss_cmds::delete_rss_items, + commands::rss_cmds::mark_rss_downloaded, + commands::rss_cmds::clear_rss_download, + commands::rss_cmds::read_rss_download, + commands::rss_cmds::download_rss_item_tracked, + commands::rss_cmds::mark_rss_item_read, + commands::rss_cmds::mark_rss_items_read, + commands::upload_cmds::list_upload_sinks, + commands::upload_cmds::add_upload_sink, + commands::upload_cmds::update_upload_sink, + commands::upload_cmds::remove_upload_sink, + commands::upload_cmds::test_upload_sink, + commands::upload_cmds::get_default_upload_sink, + commands::upload_cmds::set_default_upload_sink, + commands::upload_cmds::set_upload_max_concurrency, + commands::upload_cmds::list_upload_rules, + commands::upload_cmds::add_upload_rule, + commands::upload_cmds::update_upload_rule, + commands::upload_cmds::remove_upload_rule, + commands::upload_cmds::list_upload_jobs, + commands::upload_cmds::cancel_upload_job, + commands::upload_cmds::clear_upload_history, + commands::vault_cmds::vault_status, + commands::vault_cmds::vault_put_credential, + commands::vault_cmds::vault_get_credential, + commands::vault_cmds::vault_remove_credential, + ]) + .build(tauri::generate_context!()) + .expect("error while building Risuko"); app.run(|_, event| { if matches!( @@ -405,38 +507,46 @@ pub fn run() { } fn sync_open_at_login_setting(app: &tauri::App) { - let desired = app - .state::() - .config - .lock() - .ok() - .and_then(|cfg| { - cfg.get_user_config() - .get("open-at-login") - .and_then(|v| v.as_bool()) - }); - - let Some(desired) = desired else { + #[cfg(target_os = "android")] + { + let _ = app; return; - }; + } + #[cfg(not(target_os = "android"))] + { + let desired = app + .state::() + .config + .lock() + .ok() + .and_then(|cfg| { + cfg.get_user_config() + .get("open-at-login") + .and_then(|v| v.as_bool()) + }); - let autolaunch = app.autolaunch(); - let needs_update = match autolaunch.is_enabled() { - Ok(current) => current != desired, - Err(_) => true, - }; + let Some(desired) = desired else { + return; + }; - if !needs_update { - return; - } + let autolaunch = app.autolaunch(); + let needs_update = match autolaunch.is_enabled() { + Ok(current) => current != desired, + Err(_) => true, + }; - let result = if desired { - autolaunch.enable() - } else { - autolaunch.disable() - }; + if !needs_update { + return; + } - if let Err(err) = result { - log::warn!("Failed to sync open-at-login setting: {}", err); + let result = if desired { + autolaunch.enable() + } else { + autolaunch.disable() + }; + + if let Err(err) = result { + log::warn!("Failed to sync open-at-login setting: {}", err); + } } } diff --git a/src-tauri/src/managers/menu.rs b/src-tauri/src/managers/menu.rs index 9feffa27..2871b2e3 100644 --- a/src-tauri/src/managers/menu.rs +++ b/src-tauri/src/managers/menu.rs @@ -1,15 +1,23 @@ use std::collections::HashMap; +#[cfg(not(target_os = "android"))] use std::sync::Mutex; +#[cfg(not(target_os = "android"))] use tauri::{ menu::{AboutMetadataBuilder, Menu, MenuBuilder, MenuItemBuilder, Submenu, SubmenuBuilder}, App, AppHandle, Emitter, Manager, }; +#[cfg(target_os = "android")] +use tauri::{App, AppHandle}; + +#[cfg(not(target_os = "android"))] use super::{emit_command, show_and_emit}; +#[cfg(not(target_os = "android"))] static CACHED_LABELS: Mutex>> = Mutex::new(None); +#[cfg(not(target_os = "android"))] fn get_menu_text(labels: &HashMap, id: &str, fallback: &str) -> String { labels .get(id) @@ -19,6 +27,7 @@ fn get_menu_text(labels: &HashMap, id: &str, fallback: &str) -> .to_string() } +#[cfg(not(target_os = "android"))] pub fn setup_menu(app: &App) -> Result<(), Box> { let handle = app.handle(); let menu = build_menu(handle, &HashMap::new())?; @@ -27,6 +36,12 @@ pub fn setup_menu(app: &App) -> Result<(), Box> { Ok(()) } +#[cfg(target_os = "android")] +pub fn setup_menu(_app: &App) -> Result<(), Box> { + Ok(()) +} + +#[cfg(not(target_os = "android"))] pub fn update_menu_labels( handle: &AppHandle, labels: &HashMap, @@ -40,6 +55,15 @@ pub fn update_menu_labels( Ok(()) } +#[cfg(target_os = "android")] +pub fn update_menu_labels( + _handle: &AppHandle, + _labels: &HashMap, +) -> Result<(), String> { + Ok(()) +} + +#[cfg(not(target_os = "android"))] pub fn toggle_app_menu(handle: &AppHandle, hidden: bool) -> Result<(), String> { if hidden { handle.remove_menu().map_err(|e| e.to_string())?; @@ -55,6 +79,12 @@ pub fn toggle_app_menu(handle: &AppHandle, hidden: bool) -> Result<(), String> { Ok(()) } +#[cfg(target_os = "android")] +pub fn toggle_app_menu(_handle: &AppHandle, _hidden: bool) -> Result<(), String> { + Ok(()) +} + +#[cfg(not(target_os = "android"))] fn build_menu( handle: &AppHandle, labels: &HashMap, @@ -66,6 +96,7 @@ fn build_menu( } } +#[cfg(not(target_os = "android"))] fn build_macos_menu( handle: &AppHandle, labels: &HashMap, @@ -137,6 +168,7 @@ fn build_macos_menu( Ok(menu) } +#[cfg(not(target_os = "android"))] fn build_default_menu( handle: &AppHandle, labels: &HashMap, @@ -200,6 +232,7 @@ fn build_default_menu( Ok(menu) } +#[cfg(not(target_os = "android"))] fn build_task_submenu( handle: &tauri::AppHandle, include_clear_recent: bool, @@ -307,6 +340,7 @@ fn build_task_submenu( Ok(builder.build()?) } +#[cfg(not(target_os = "android"))] fn build_edit_submenu( handle: &tauri::AppHandle, labels: &HashMap, @@ -324,6 +358,7 @@ fn build_edit_submenu( ) } +#[cfg(not(target_os = "android"))] fn build_help_submenu( handle: &tauri::AppHandle, labels: &HashMap, @@ -370,6 +405,7 @@ fn build_help_submenu( Ok(builder.build()?) } +#[cfg(not(target_os = "android"))] fn setup_menu_event_handler(app: &App) { app.on_menu_event(move |app, event| { let id = event.id().as_ref(); diff --git a/src-tauri/src/managers/mod.rs b/src-tauri/src/managers/mod.rs index 0cedc5a1..b9d2e6db 100644 --- a/src-tauri/src/managers/mod.rs +++ b/src-tauri/src/managers/mod.rs @@ -2,12 +2,15 @@ pub mod menu; pub mod tray; pub mod vault; +#[cfg(not(target_os = "android"))] use tauri::Emitter; +#[cfg(not(target_os = "android"))] pub fn emit_command(app: &tauri::AppHandle, command: &str) { let _ = app.emit("command", serde_json::json!({ "command": command })); } +#[cfg(not(target_os = "android"))] pub fn show_and_emit(app: &tauri::AppHandle, command: &str) { let _ = crate::commands::app_cmds::show_main_window(app); emit_command(app, command); diff --git a/src-tauri/src/managers/tray.rs b/src-tauri/src/managers/tray.rs index af9d75ec..a20ff9c2 100644 --- a/src-tauri/src/managers/tray.rs +++ b/src-tauri/src/managers/tray.rs @@ -1,5 +1,6 @@ use std::collections::HashMap; +#[cfg(not(target_os = "android"))] use tauri::{ image::Image, menu::{Menu, MenuBuilder, MenuItemBuilder, PredefinedMenuItem}, @@ -7,8 +8,13 @@ use tauri::{ App, AppHandle, Emitter, Manager, }; +#[cfg(target_os = "android")] +use tauri::{App, AppHandle}; + +#[cfg(not(target_os = "android"))] use super::{emit_command, show_and_emit}; +#[cfg(not(target_os = "android"))] fn toggle_main_window(app: &AppHandle) { if let Some(window) = app.get_webview_window("main") { let is_visible = window.is_visible().unwrap_or(false); @@ -20,6 +26,7 @@ fn toggle_main_window(app: &AppHandle) { } } +#[cfg(not(target_os = "android"))] fn get_tray_menu_text(labels: &HashMap, id: &str, fallback: &str) -> String { labels .get(id) @@ -29,6 +36,7 @@ fn get_tray_menu_text(labels: &HashMap, id: &str, fallback: &str .to_string() } +#[cfg(not(target_os = "android"))] fn build_tray_menu( handle: &AppHandle, labels: &HashMap, @@ -100,6 +108,7 @@ fn build_tray_menu( Ok(menu) } +#[cfg(not(target_os = "android"))] pub fn setup_tray(app: &App) -> Result<(), Box> { let handle = app.handle(); let menu = build_tray_menu(handle, &HashMap::new())?; @@ -150,6 +159,12 @@ pub fn setup_tray(app: &App) -> Result<(), Box> { Ok(()) } +#[cfg(target_os = "android")] +pub fn setup_tray(_app: &App) -> Result<(), Box> { + Ok(()) +} + +#[cfg(not(target_os = "android"))] pub fn update_tray_menu_labels( handle: &AppHandle, labels: &HashMap, @@ -161,3 +176,11 @@ pub fn update_tray_menu_labels( let menu = build_tray_menu(handle, labels).map_err(|e| e.to_string())?; tray.set_menu(Some(menu)).map_err(|e| e.to_string()) } + +#[cfg(target_os = "android")] +pub fn update_tray_menu_labels( + _handle: &AppHandle, + _labels: &HashMap, +) -> Result<(), String> { + Ok(()) +} diff --git a/src-tauri/tauri.android.conf.json b/src-tauri/tauri.android.conf.json new file mode 100644 index 00000000..f3b45caf --- /dev/null +++ b/src-tauri/tauri.android.conf.json @@ -0,0 +1,3 @@ +{ + "identifier": "app.risuko.mobile" +} diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 41999418..320f8812 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.3", + "version": "0.3.4", "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 8ee11e2d..835b8bdd 100644 --- a/src/renderer/api/Api.ts +++ b/src/renderer/api/Api.ts @@ -217,6 +217,18 @@ export default class Api { return invoke("calculate_active_task_progress", { tasks }); } + updateAndroidDownloadNotification(params: { + progress: number; + activeCount: number; + detail: string; + }) { + return invoke("update_android_download_notification", params); + } + + clearAndroidDownloadNotification() { + return invoke("clear_android_download_notification"); + } + evaluateLowSpeedTasks( params: { tasks?: DownloadTask[]; diff --git a/src/renderer/assets/logo.svg b/src/renderer/assets/logo.svg index f2e20ab5..b9d48ee0 100644 --- a/src/renderer/assets/logo.svg +++ b/src/renderer/assets/logo.svg @@ -1 +1 @@ - + diff --git a/src/renderer/components/DragSelect/Index.vue b/src/renderer/components/DragSelect/Index.vue index c072fd83..1e237a59 100644 --- a/src/renderer/components/DragSelect/Index.vue +++ b/src/renderer/components/DragSelect/Index.vue @@ -1,13 +1,15 @@
diff --git a/src/renderer/components/Native/SelectDirectory.vue b/src/renderer/components/Native/SelectDirectory.vue index 0f6c9c73..388b2fdd 100644 --- a/src/renderer/components/Native/SelectDirectory.vue +++ b/src/renderer/components/Native/SelectDirectory.vue @@ -6,14 +6,47 @@ class="select-directory" @click.stop="onFolderClick" > - - + + diff --git a/src/renderer/pages/index/favicon.ico b/src/renderer/pages/index/favicon.ico new file mode 100644 index 00000000..24fbd10f Binary files /dev/null and b/src/renderer/pages/index/favicon.ico differ diff --git a/src/renderer/pages/index/index.html b/src/renderer/pages/index/index.html index d72fb6f2..b30c434c 100644 --- a/src/renderer/pages/index/index.html +++ b/src/renderer/pages/index/index.html @@ -4,6 +4,7 @@ Risuko +