diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index ac9ac377..775588c4 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -13,6 +13,108 @@ 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 + + - 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 --no-frozen-lockfile + + - name: Build Android release APKs + run: pnpm android:build + env: + ANDROID_NDK_VERSION: 27.2.12479018 + ANDROID_API_LEVEL: 35 + + - 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 }} 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/package.json b/package.json index e25c9ec2..069d6a7d 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", diff --git a/packages/risuko-app/package.json b/packages/risuko-app/package.json index 04dfbe46..23fd08de 100644 --- a/packages/risuko-app/package.json +++ b/packages/risuko-app/package.json @@ -1,6 +1,6 @@ { "name": "@risuko/app", - "version": "0.3.3", + "version": "0.3.4", "description": "Risuko download manager — launches the desktop app, downloading it from GitHub Releases on first run", "license": "MIT", "repository": { diff --git a/packages/risuko-cli/npm/darwin-arm64/package.json b/packages/risuko-cli/npm/darwin-arm64/package.json index d18d5d19..b22afb97 100644 --- a/packages/risuko-cli/npm/darwin-arm64/package.json +++ b/packages/risuko-cli/npm/darwin-arm64/package.json @@ -1,6 +1,6 @@ { "name": "@risuko/cli-darwin-arm64", - "version": "0.3.3", + "version": "0.3.4", "description": "Risuko CLI binary for macOS ARM64", "repository": { "type": "git", diff --git a/packages/risuko-cli/npm/darwin-x64/package.json b/packages/risuko-cli/npm/darwin-x64/package.json index 32438324..cbc31f60 100644 --- a/packages/risuko-cli/npm/darwin-x64/package.json +++ b/packages/risuko-cli/npm/darwin-x64/package.json @@ -1,6 +1,6 @@ { "name": "@risuko/cli-darwin-x64", - "version": "0.3.3", + "version": "0.3.4", "description": "Risuko CLI binary for macOS x64", "repository": { "type": "git", diff --git a/packages/risuko-cli/npm/linux-arm64-gnu/package.json b/packages/risuko-cli/npm/linux-arm64-gnu/package.json index 8fa156ed..e6c1ecf5 100644 --- a/packages/risuko-cli/npm/linux-arm64-gnu/package.json +++ b/packages/risuko-cli/npm/linux-arm64-gnu/package.json @@ -1,6 +1,6 @@ { "name": "@risuko/cli-linux-arm64-gnu", - "version": "0.3.3", + "version": "0.3.4", "description": "Risuko CLI binary for Linux ARM64", "repository": { "type": "git", diff --git a/packages/risuko-cli/npm/linux-x64-gnu/package.json b/packages/risuko-cli/npm/linux-x64-gnu/package.json index 48931c4c..e204df4f 100644 --- a/packages/risuko-cli/npm/linux-x64-gnu/package.json +++ b/packages/risuko-cli/npm/linux-x64-gnu/package.json @@ -1,6 +1,6 @@ { "name": "@risuko/cli-linux-x64-gnu", - "version": "0.3.3", + "version": "0.3.4", "description": "Risuko CLI binary for Linux x64", "repository": { "type": "git", diff --git a/packages/risuko-cli/npm/win32-arm64-msvc/package.json b/packages/risuko-cli/npm/win32-arm64-msvc/package.json index 722661e2..c79e0b42 100644 --- a/packages/risuko-cli/npm/win32-arm64-msvc/package.json +++ b/packages/risuko-cli/npm/win32-arm64-msvc/package.json @@ -1,6 +1,6 @@ { "name": "@risuko/cli-win32-arm64-msvc", - "version": "0.3.3", + "version": "0.3.4", "description": "Risuko CLI binary for Windows ARM64", "repository": { "type": "git", diff --git a/packages/risuko-cli/npm/win32-x64-msvc/package.json b/packages/risuko-cli/npm/win32-x64-msvc/package.json index 3419cb77..f0a04bce 100644 --- a/packages/risuko-cli/npm/win32-x64-msvc/package.json +++ b/packages/risuko-cli/npm/win32-x64-msvc/package.json @@ -1,6 +1,6 @@ { "name": "@risuko/cli-win32-x64-msvc", - "version": "0.3.3", + "version": "0.3.4", "description": "Risuko CLI binary for Windows x64", "repository": { "type": "git", diff --git a/packages/risuko-cli/package.json b/packages/risuko-cli/package.json index 6e2dbaec..bba3f6d5 100644 --- a/packages/risuko-cli/package.json +++ b/packages/risuko-cli/package.json @@ -1,6 +1,6 @@ { "name": "@risuko/cli", - "version": "0.3.3", + "version": "0.3.4", "description": "Risuko download engine CLI — multi-protocol downloads (HTTP, BitTorrent, ED2K, M3U8, FTP/SFTP)", "license": "MIT", "repository": { @@ -24,12 +24,12 @@ "bin.js" ], "optionalDependencies": { - "@risuko/cli-darwin-arm64": "0.3.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" + "@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..20ad4015 100644 --- a/packages/risuko-js/npm/darwin-arm64/package.json +++ b/packages/risuko-js/npm/darwin-arm64/package.json @@ -1,6 +1,6 @@ { "name": "@risuko/js-darwin-arm64", - "version": "0.3.3", + "version": "0.3.4", "description": "Risuko JS native module for macOS ARM64", "repository": { "type": "git", diff --git a/packages/risuko-js/npm/darwin-x64/package.json b/packages/risuko-js/npm/darwin-x64/package.json index 330fdcad..02dcadc8 100644 --- a/packages/risuko-js/npm/darwin-x64/package.json +++ b/packages/risuko-js/npm/darwin-x64/package.json @@ -1,6 +1,6 @@ { "name": "@risuko/js-darwin-x64", - "version": "0.3.3", + "version": "0.3.4", "description": "Risuko JS native module for macOS x64", "repository": { "type": "git", diff --git a/packages/risuko-js/npm/linux-arm64-gnu/package.json b/packages/risuko-js/npm/linux-arm64-gnu/package.json index 1d5f0935..604ac4ee 100644 --- a/packages/risuko-js/npm/linux-arm64-gnu/package.json +++ b/packages/risuko-js/npm/linux-arm64-gnu/package.json @@ -1,6 +1,6 @@ { "name": "@risuko/js-linux-arm64-gnu", - "version": "0.3.3", + "version": "0.3.4", "description": "Risuko JS native module for Linux ARM64", "repository": { "type": "git", diff --git a/packages/risuko-js/npm/linux-x64-gnu/package.json b/packages/risuko-js/npm/linux-x64-gnu/package.json index 8a516fa4..6a2d90a9 100644 --- a/packages/risuko-js/npm/linux-x64-gnu/package.json +++ b/packages/risuko-js/npm/linux-x64-gnu/package.json @@ -1,6 +1,6 @@ { "name": "@risuko/js-linux-x64-gnu", - "version": "0.3.3", + "version": "0.3.4", "description": "Risuko JS native module for Linux x64", "repository": { "type": "git", diff --git a/packages/risuko-js/npm/win32-arm64-msvc/package.json b/packages/risuko-js/npm/win32-arm64-msvc/package.json index 17c36271..c3fdcc47 100644 --- a/packages/risuko-js/npm/win32-arm64-msvc/package.json +++ b/packages/risuko-js/npm/win32-arm64-msvc/package.json @@ -1,6 +1,6 @@ { "name": "@risuko/js-win32-arm64-msvc", - "version": "0.3.3", + "version": "0.3.4", "description": "Risuko JS native module for Windows ARM64", "repository": { "type": "git", diff --git a/packages/risuko-js/npm/win32-x64-msvc/package.json b/packages/risuko-js/npm/win32-x64-msvc/package.json index c0eac006..f1931cd4 100644 --- a/packages/risuko-js/npm/win32-x64-msvc/package.json +++ b/packages/risuko-js/npm/win32-x64-msvc/package.json @@ -1,6 +1,6 @@ { "name": "@risuko/js-win32-x64-msvc", - "version": "0.3.3", + "version": "0.3.4", "description": "Risuko JS native module for Windows x64", "repository": { "type": "git", diff --git a/packages/risuko-js/package.json b/packages/risuko-js/package.json index 0e2f72a5..d47e5cb4 100644 --- a/packages/risuko-js/package.json +++ b/packages/risuko-js/package.json @@ -1,6 +1,6 @@ { "name": "@risuko/risuko-js", - "version": "0.3.3", + "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", @@ -34,12 +34,12 @@ } }, "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" + "@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": ">= 18" diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 73e86376..868d375a 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.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' + - '@risuko/js-darwin-arm64@0.3.3' + - '@risuko/js-darwin-x64@0.3.3' + - '@risuko/js-linux-arm64-gnu@0.3.3' + - '@risuko/js-linux-x64-gnu@0.3.3' + - '@risuko/js-win32-arm64-msvc@0.3.3' + - '@risuko/js-win32-x64-msvc@0.3.3' diff --git a/scripts/android-env.mjs b/scripts/android-env.mjs new file mode 100644 index 00000000..c066af59 --- /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 { 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 ? `${javaBin}:` : ""}${llvmBin}:${process.env.PATH || ""}`, + 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=${env.JAVA_HOME}`); + } + 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..db6e3e25 --- /dev/null +++ b/scripts/sign-android-apks.mjs @@ -0,0 +1,161 @@ +#!/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"; + +const projectRoot = resolve(new URL("..", import.meta.url).pathname); + +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) { + console.error(message); + process.exit(1); +} + +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 versions = readdirSync(buildToolsRoot) + .filter((entry) => existsSync(join(buildToolsRoot, entry, name))) + .sort(compareVersions) + .reverse(); + if (versions.length === 0) { + fail(`${name} not found under ${buildToolsRoot}`); + } + return join(buildToolsRoot, versions[0], name); +} + +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 keystore = resolveKeystore(); +const unsignedApks = findUnsignedApks(apkRoot); + +if (unsignedApks.length === 0) { + fail(`No unsigned release APKs found under ${apkRoot}`); +} + +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..33e22398 --- /dev/null +++ b/src-tauri/gen/android/app/src/main/java/app/risuko/mobile/MainActivity.kt @@ -0,0 +1,397 @@ +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.view.WindowCompat +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 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-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..c8417c56 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,44 @@ 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 parent.exists() { + 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 parent.exists() { + 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")) +} + pub fn user_defaults() -> Map { let is_macos = cfg!(target_os = "macos"); let is_not_macos = !is_macos; @@ -72,7 +111,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 +228,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..9d280cf2 100644 --- a/src-tauri/risuko-engine/src/engine/http.rs +++ b/src-tauri/risuko-engine/src/engine/http.rs @@ -874,7 +874,21 @@ 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")] + { + 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 diff --git a/src-tauri/src/commands/android_intent.rs b/src-tauri/src/commands/android_intent.rs new file mode 100644 index 00000000..163186fd --- /dev/null +++ b/src-tauri/src/commands/android_intent.rs @@ -0,0 +1,540 @@ +//! 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 +//! +//! We build the `Intent` ourselves via JNI: +//! - We attach the file's actual MIME (e.g. `video/mp4`) via +//! `Intent.setDataAndType`, which narrows the candidate set +//! - We wrap the result in `Intent.createChooser` so even when several +//! apps still match, the user gets the standard "Open with" sheet +//! (the behavior the user expects from Android) +//! - We add `FLAG_GRANT_READ_URI_PERMISSION` so the receiving app can +//! read the URI we built ourselves (without this, our hand-built +//! externalstorage URIs come through as 0-byte files in the viewer) +//! +//! ## 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}; + +const FLAG_ACTIVITY_NEW_TASK: i32 = 0x10000000; +const FLAG_GRANT_READ_URI_PERMISSION: i32 = 0x00000001; + +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) + } +} + +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`, but the application context works with `FLAG_ACTIVITY_NEW_TASK` +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) +} + +/// Dispatch `ACTION_VIEW` with an explicit MIME and an Android chooser +/// +/// The `chooser_title` is shown at the top of the chooser sheet +/// (e.g. "Open file with"). `mime` should be a concrete MIME type +/// (`video/mp4`, `image/jpeg`, `vnd.android.document/directory`...) +pub fn dispatch_view_with_chooser( + uri: &str, + mime: &str, + chooser_title: &str, +) -> Result<(), String> { + dispatch_uri_with_chooser(uri, mime, chooser_title, uri.starts_with("content://")) +} + +pub fn dispatch_file_path_with_chooser( + path: &str, + mime: &str, + chooser_title: &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 = current_application(&mut env).map_err(|e| format!("get application: {e}"))?; + let authority = package_file_provider_authority(&mut env, &activity) + .map_err(|e| format!("get file provider authority: {e}"))?; + let path_str = env + .new_string(path) + .map_err(|e| format!("new_string path: {e}"))?; + let authority_str = env + .new_string(authority) + .map_err(|e| format!("new_string authority: {e}"))?; + let file_class = env + .find_class("java/io/File") + .map_err(|e| format!("find File: {e}"))?; + let file = env + .new_object( + &file_class, + "(Ljava/lang/String;)V", + &[JValue::Object(&path_str)], + ) + .map_err(|e| format!("new File: {e}"))?; + let provider_class = env + .find_class("androidx/core/content/FileProvider") + .map_err(|e| format!("find FileProvider: {e}"))?; + let uri = env + .call_static_method( + &provider_class, + "getUriForFile", + "(Landroid/content/Context;Ljava/lang/String;Ljava/io/File;)Landroid/net/Uri;", + &[ + JValue::Object(&activity), + JValue::Object(&authority_str), + JValue::Object(&file), + ], + ) + .map_err(|e| format!("FileProvider.getUriForFile: {e}"))? + .l() + .map_err(|e| format!("FileProvider URI not object: {e}"))?; + + dispatch_jni_uri_with_chooser(&mut env, activity, uri, mime, chooser_title, true) +} + +fn dispatch_uri_with_chooser( + uri: &str, + mime: &str, + chooser_title: &str, + grant_read_permission: 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 = current_application(&mut env).map_err(|e| format!("get application: {e}"))?; + + let uri_str = env + .new_string(uri) + .map_err(|e| format!("new_string uri: {e}"))?; + let uri_class = env + .find_class("android/net/Uri") + .map_err(|e| format!("find Uri: {e}"))?; + let parsed_uri = env + .call_static_method( + &uri_class, + "parse", + "(Ljava/lang/String;)Landroid/net/Uri;", + &[JValue::Object(&uri_str)], + ) + .map_err(|e| format!("Uri.parse: {e}"))? + .l() + .map_err(|e| format!("Uri.parse not object: {e}"))?; + + dispatch_jni_uri_with_chooser( + &mut env, + activity, + parsed_uri, + mime, + chooser_title, + grant_read_permission, + ) +} + +fn dispatch_jni_uri_with_chooser( + env: &mut JNIEnv, + activity: JObject, + parsed_uri: JObject, + mime: &str, + chooser_title: &str, + grant_read_permission: bool, +) -> Result<(), String> { + // Build the inner `Intent(ACTION_VIEW, uri)` with explicit MIME. + let action = env + .new_string("android.intent.action.VIEW") + .map_err(|e| format!("new_string action: {e}"))?; + let mime_str = env + .new_string(mime) + .map_err(|e| format!("new_string mime: {e}"))?; + let intent_class = env + .find_class("android/content/Intent") + .map_err(|e| format!("find Intent: {e}"))?; + let intent = env + .new_object( + &intent_class, + "(Ljava/lang/String;)V", + &[JValue::Object(&action)], + ) + .map_err(|e| format!("new Intent: {e}"))?; + + env.call_method( + &intent, + "setDataAndType", + "(Landroid/net/Uri;Ljava/lang/String;)Landroid/content/Intent;", + &[JValue::Object(&parsed_uri), JValue::Object(&mime_str)], + ) + .map_err(|e| format!("setDataAndType: {e}"))?; + + if grant_read_permission { + env.call_method( + &intent, + "addFlags", + "(I)Landroid/content/Intent;", + &[JValue::Int(FLAG_GRANT_READ_URI_PERMISSION)], + ) + .map_err(|e| format!("addFlags grant: {e}"))?; + } + + // Wrap in `Intent.createChooser(intent, title)` so the user sees the + // standard "Open with" sheet even when a single app has been set as + // default (matching desktop double-click semantics where the user can + // change the handler at any time). + let title = env + .new_string(chooser_title) + .map_err(|e| format!("new_string title: {e}"))?; + let chooser = env + .call_static_method( + &intent_class, + "createChooser", + "(Landroid/content/Intent;Ljava/lang/CharSequence;)Landroid/content/Intent;", + &[JValue::Object(&intent), JValue::Object(&title)], + ) + .map_err(|e| format!("createChooser: {e}"))? + .l() + .map_err(|e| format!("createChooser not object: {e}"))?; + + // The chooser is started from an Activity that may not be in the + // foreground task; mark it as a fresh task so Android grants the + // necessary launch context. + env.call_method( + &chooser, + "addFlags", + "(I)Landroid/content/Intent;", + &[JValue::Int(FLAG_ACTIVITY_NEW_TASK)], + ) + .map_err(|e| format!("addFlags new_task: {e}"))?; + + if grant_read_permission { + env.call_method( + &chooser, + "addFlags", + "(I)Landroid/content/Intent;", + &[JValue::Int(FLAG_GRANT_READ_URI_PERMISSION)], + ) + .map_err(|e| format!("addFlags chooser grant: {e}"))?; + } + + env.call_method( + &activity, + "startActivity", + "(Landroid/content/Intent;)V", + &[JValue::Object(&chooser)], + ) + .map_err(|e| format!("startActivity: {e}"))?; + + if env.exception_check().unwrap_or(false) { + let _ = env.exception_describe(); + let _ = env.exception_clear(); + return Err("startActivity threw".into()); + } + + Ok(()) +} + +fn package_file_provider_authority( + env: &mut JNIEnv, + activity: &JObject, +) -> jni::errors::Result { + let package_name = env + .call_method(activity, "getPackageName", "()Ljava/lang/String;", &[])? + .l()?; + let package_name = env.get_string((&package_name).into())?; + Ok(format!("{}.fileprovider", package_name.to_string_lossy())) +} 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..40ece72d 100644 --- a/src-tauri/src/commands/file_cmds.rs +++ b/src-tauri/src/commands/file_cmds.rs @@ -106,71 +106,180 @@ 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 + if path.starts_with("content://") + || path.starts_with("http://") + || path.starts_with("https://") + || path.starts_with("file://") + { + let _ = handle; + let mime = guess_android_mime(&path); + return crate::commands::android_intent::dispatch_view_with_chooser( + &path, + &mime, + "Open file with", + ); + } + let _ = handle; + let mime = guess_android_mime(&path); + return crate::commands::android_intent::dispatch_file_path_with_chooser( + &path, + &mime, + "Open file with", + ); + } + #[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 +294,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 +1024,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..65c92eed 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -11,11 +11,93 @@ 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(); + } + 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 +151,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 +492,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/main.ts b/src/renderer/pages/index/main.ts index 5596a481..703ba8d4 100644 --- a/src/renderer/pages/index/main.ts +++ b/src/renderer/pages/index/main.ts @@ -1,3 +1,4 @@ +import { getLanguage } from "@shared/locales"; import type { AppConfig } from "@shared/types/config"; import logger from "@shared/utils/logger"; import { invoke } from "@tauri-apps/api/core"; @@ -126,7 +127,7 @@ function initTrayWorker() { } async function init(config: AppConfig) { - const locale = config?.locale || "en-US"; + const locale = getLanguage(config?.locale || "auto"); const localeManager = getLocaleManager(); await localeManager.changeLanguageByLocale(locale); const i18n = localeManager.getI18n(); diff --git a/src/renderer/router/index.ts b/src/renderer/router/index.ts index 706c537f..df928352 100644 --- a/src/renderer/router/index.ts +++ b/src/renderer/router/index.ts @@ -38,6 +38,7 @@ export default createRouter({ path: "/preference", name: "preference", component: () => import("@/components/Preference/Index.vue"), + redirect: "/preference/basic", props: true, children: [ { diff --git a/src/renderer/shims/platform.ts b/src/renderer/shims/platform.ts index 9ecb6d14..ecb71641 100644 --- a/src/renderer/shims/platform.ts +++ b/src/renderer/shims/platform.ts @@ -4,6 +4,9 @@ const isRendererProcess = const platform = (() => { if (typeof navigator !== "undefined") { const ua = navigator.userAgent.toLowerCase(); + if (ua.includes("android")) { + return "android"; + } if (ua.includes("mac")) { return "macos"; } @@ -24,6 +27,7 @@ const fallback = { macOS: () => platform === "macos", windows: () => platform === "windows", linux: () => platform === "linux", + android: () => platform === "android", mas: () => false, }; diff --git a/src/renderer/store/app.ts b/src/renderer/store/app.ts index 2b580ce2..6923fe10 100644 --- a/src/renderer/store/app.ts +++ b/src/renderer/store/app.ts @@ -1,8 +1,10 @@ import { ADD_TASK_TYPE } from "@shared/constants"; -import type { EngineInfo } from "@shared/types/task"; +import type { DownloadTask, EngineInfo } from "@shared/types/task"; +import { bytesToSize } from "@shared/utils"; import logger from "@shared/utils/logger"; import { defineStore } from "pinia"; import api from "@/api"; +import is from "@/shims/platform"; import type { BatchQueueItem } from "@/store/batchQueue"; import { useTaskStore } from "@/store/task"; import { getSystemTheme } from "@/utils/native"; @@ -21,10 +23,7 @@ const normalizeNonNegativeNumber = (value: unknown): number => { }; const calcRendererProgress = ( - tasks: Pick< - import("@shared/types/task").DownloadTask, - "totalLength" | "completedLength" - >[] = [], + tasks: Pick[] = [], ) => { if (tasks.length === 0) { return -1; @@ -74,6 +73,7 @@ export const useAppStore = defineStore("app", { addTaskQueue: [] as BatchQueueItem[], addTaskOptions: {}, progress: 0, + androidDownloadNotificationVisible: false, cloudflareDialog: { visible: false, gid: "", @@ -265,6 +265,64 @@ export const useAppStore = defineStore("app", { resetInterval() { this.interval = BASE_INTERVAL; }, + async syncAndroidDownloadNotification({ + progress, + tasks, + }: { + progress: number; + tasks: Pick[]; + }) { + if (!is.android()) { + return; + } + if (tasks.length === 0) { + if (!this.androidDownloadNotificationVisible) { + return; + } + try { + await api.clearAndroidDownloadNotification(); + this.androidDownloadNotificationVisible = false; + } catch (err: unknown) { + logger.warn( + "[Risuko] clearAndroidDownloadNotification failed:", + (err as Error).message, + ); + } + return; + } + + let total = 0; + let completed = 0; + for (const task of tasks) { + total += normalizeNonNegativeNumber(task?.totalLength); + completed += normalizeNonNegativeNumber(task?.completedLength); + } + + const normalizedProgress = + Number.isFinite(progress) && progress >= 0 ? progress : 0; + const percent = Math.max( + 0, + Math.min(100, Math.round(normalizedProgress * 100)), + ); + const detail = + total > 0 + ? `${bytesToSize(completed, 1)} / ${bytesToSize(total, 1)} · ${percent}%` + : `${percent}% complete`; + + try { + await api.updateAndroidDownloadNotification({ + progress: percent, + activeCount: tasks.length, + detail, + }); + this.androidDownloadNotificationVisible = true; + } catch (err: unknown) { + logger.warn( + "[Risuko] updateAndroidDownloadNotification failed:", + (err as Error).message, + ); + } + }, async fetchProgress() { try { const data = await api.fetchActiveTaskList({ @@ -275,6 +333,7 @@ export const useAppStore = defineStore("app", { if (tasks.length === 0) { this.progress = -1; + await this.syncAndroidDownloadNotification({ progress: -1, tasks }); return; } @@ -296,6 +355,10 @@ export const useAppStore = defineStore("app", { } this.progress = progress === 2 ? -1 : progress; + await this.syncAndroidDownloadNotification({ + progress: this.progress, + tasks, + }); } catch (err: unknown) { logger.warn("[Risuko] fetchProgress failed:", (err as Error).message); } diff --git a/src/renderer/store/preference.ts b/src/renderer/store/preference.ts index fe201450..5cb7f432 100644 --- a/src/renderer/store/preference.ts +++ b/src/renderer/store/preference.ts @@ -3,6 +3,7 @@ import { MAX_NUM_OF_DIRECTORIES, MAX_NUM_OF_SAVED_CREDENTIALS, } from "@shared/constants"; +import { getLanguage } from "@shared/locales"; import type { AppConfig } from "@shared/types/config"; import { CREDENTIAL_SECRET_FIELDS, @@ -30,13 +31,13 @@ export const usePreferenceStore = defineStore("preference", { engineMode: "MAX", vaultEnabled: false, config: { - locale: "en-US", + locale: "auto", } as AppConfig, }), getters: { theme: (state) => state.config.theme, locale: (state) => state.config.locale, - direction: (state) => getLangDirection(state.config.locale), + direction: (state) => getLangDirection(getLanguage(state.config.locale)), }, actions: { async fetchPreference(): Promise { @@ -335,7 +336,7 @@ export const usePreferenceStore = defineStore("preference", { this.updatePreference({ theme }); }, updateAppLocale(locale: string) { - this.updatePreference({ locale: locale || "en-US" }); + this.updatePreference({ locale: locale || "auto" }); }, updatePreference(config: Partial) { this.config = { ...this.config, ...config }; diff --git a/src/renderer/store/task.ts b/src/renderer/store/task.ts index fbe4a3c8..5f0ed736 100644 --- a/src/renderer/store/task.ts +++ b/src/renderer/store/task.ts @@ -5,7 +5,7 @@ import type { PeerInfo, SyncOrderResult, } from "@shared/types/task"; -import { checkTaskIsBT, getTaskName, intersection } from "@shared/utils"; +import { checkTaskIsBT, getTaskName } from "@shared/utils"; import logger from "@shared/utils/logger"; import { defineStore } from "pinia"; import api from "@/api"; @@ -17,12 +17,12 @@ const TASKS_PER_PAGE_STORAGE_KEY = "risuko.tasks-per-page"; const SORT_BY_STORAGE_KEY = "risuko.task-sort-by"; const SORT_ORDER_STORAGE_KEY = "risuko.task-sort-order"; -/** Maximum number of speed samples retained per task */ +/** Max speed samples kept per task */ export const SPEED_HISTORY_LIMIT = 60; type SpeedSample = { download: number; upload: number }; -/** Module cache: gid -> speed samples. */ +/** Module cache: gid -> speed samples */ // please work please work please work const speedHistoryCache = new Map(); @@ -34,9 +34,9 @@ export function deleteSpeedHistory(gid: string): void { speedHistoryCache.delete(gid); } -/** Number of rows shown in the task list for the given backend tasks. A - * multi-file BT torrent expands into one row per selected file (matching - * `displayTaskList`); everything else counts as one row. */ +/** Row count for the task list + * Multi-file BT torrents expand to one row per selected file, matching `displayTaskList` + * Everything else is one row */ function countDisplayRows(tasks: DownloadTask[]): number { let count = 0; for (const task of tasks) { @@ -154,6 +154,9 @@ export const useTaskStore = defineStore("task", { currentTaskPeers: [] as PeerInfo[], seedingList: [] as string[], taskList: [] as DownloadTask[], + // Selection lives at row level + // Single-file tasks use ``, while BT file rows use `#f` + // Use `selectedGids` when aria2 needs the deduped torrent gid list selectedGidList: [] as string[], speedHistoryRev: 0, taskOrderMap: { @@ -195,12 +198,9 @@ export const useTaskStore = defineStore("task", { const selectedFiles = files.filter( (f: DownloadFile) => f.selected !== "false", ); - // Expand multi-file BT torrents into per-file rows whenever the - // torrent itself contains multiple files. Even if the user has - // pared down to a single selected file, keep the row in file-entry - // form so the title stays as the file name (not the torrent - // folder) and per-row delete keeps deselecting instead of - // removing the whole torrent. + // Show each selected file in a multi-file BT torrent as its own row + // Keep that shape even when only one file remains selected + // The title stays as the file name, and row delete deselects instead of removing the torrent if (isBT && files.length > 1 && selectedFiles.length > 0) { const parentDown = Math.max(0, Number(task.downloadSpeed || 0)); const parentUp = Math.max(0, Number(task.uploadSpeed || 0)); @@ -217,9 +217,8 @@ export const useTaskStore = defineStore("task", { totalRemaining > 0 ? Math.round((parentDown * remaining) / totalRemaining) : 0; - // Upload happens at the piece level (not per-file). Only - // surface upload speed once on the first row to avoid - // triple-counting in the UI. + // Upload is piece-level, not file-level + // Show it once on the first row so the UI does not double-count it const upShare = idx === 0 ? parentUp : 0; result.push({ ...task, @@ -231,7 +230,7 @@ export const useTaskStore = defineStore("task", { uploadSpeed: String(upShare), files: [file], bittorrent: { - ...(task.bittorrent || {}), + ...task.bittorrent, info: {}, }, }); @@ -299,6 +298,28 @@ export const useTaskStore = defineStore("task", { const end = start + state.tasksPerPage; return this.sortedTaskList.slice(start, end); }, + // Underlying gids from the row selection + // Use this for aria2 calls like pause, resume, and remove + // Raw `selectedGidList` can include file-row suffixes + selectedGids(state): string[] { + const set = new Set(); + for (const key of state.selectedGidList) { + const hashIdx = key.indexOf("#"); + set.add(hashIdx === -1 ? key : key.slice(0, hashIdx)); + } + return [...set]; + }, + // Selected rows as `DisplayTask` objects + // Callers get the same shape as a clicked row, including `_isFileEntry` and per-file `files` + selectedTaskRows(state): DisplayTask[] { + if (state.selectedGidList.length === 0) { + return []; + } + const want = new Set(state.selectedGidList); + return (this.displayTaskList as DisplayTask[]).filter((task) => + want.has(task._displayKey || task.gid), + ); + }, }, actions: { applyTaskOrder(type: string, tasks: DownloadTask[] = []) { @@ -419,7 +440,7 @@ export const useTaskStore = defineStore("task", { type: fetchType, })) as DownloadTask[]; - // Discard stale results when the user switched tabs mid-flight. + // Drop stale results if the user switched tabs mid-flight if (type !== this.currentList) { return []; } @@ -439,9 +460,7 @@ export const useTaskStore = defineStore("task", { const orderedData = this.applyTaskOrder(type, data); this.taskList = orderedData; - // Count display rows (per-file rows for multi-file BT torrents) - // instead of raw backend tasks so the sidebar matches what the - // user actually sees in the list. + // Count visible rows, not raw backend tasks, so the sidebar matches the list this.taskCountMap = { ...this.taskCountMap, [type]: countDisplayRows(orderedData), @@ -452,8 +471,14 @@ export const useTaskStore = defineStore("task", { orderedData.map((task) => task.gid), ); - const gids = orderedData.map((task) => task.gid); - this.selectedGidList = intersection(this.selectedGidList, gids); + // Keep selected rows only when their underlying gid still exists + // Row keys can include `#f`, so strip that before checking + const gids = new Set(orderedData.map((task) => task.gid)); + this.selectedGidList = this.selectedGidList.filter((key) => { + const hashIdx = key.indexOf("#"); + const gid = hashIdx === -1 ? key : key.slice(0, hashIdx); + return gids.has(gid); + }); return orderedData; } catch (err: unknown) { logger.warn("[Risuko] fetchList failed:", (err as Error).message); @@ -476,10 +501,8 @@ export const useTaskStore = defineStore("task", { let stoppedCount = this.taskCountMap.stopped || 0; let allCount = numActive + numWaiting + numStoppedTotal; - // Fetch lightweight projections of all three lists so the sidebar - // can show *display rows* (a multi-file BT torrent expands into one - // row per selected file) instead of raw backend task counts. Skip - // the fetch entirely when there is nothing to count. + // Fetch small projections so sidebar counts match visible rows + // Skip the fetch when aria2 says there is nothing to count const needFetch = numActive + numWaiting + numStoppedTotal > 0; if (needFetch) { try { @@ -518,7 +541,7 @@ export const useTaskStore = defineStore("task", { stoppedCount = countDisplayRows(stoppedOnlyArr); allCount = activeCount + waitingCount + completedCount + stoppedCount; } catch { - // keep previous counts on failure + // Keep previous counts on failure activeCount = this.taskCountMap.active || 0; waitingCount = this.taskCountMap.waiting || 0; completedCount = this.taskCountMap.completed || 0; @@ -539,12 +562,12 @@ export const useTaskStore = defineStore("task", { }; }, /** - * Sample speeds for all active/seeding tasks. - * Called every polling tick from EngineClient. + * Sample speeds for all active/seeding tasks + * Called every polling tick from EngineClient */ async sampleActiveSpeeds() { try { - // If we're on the active list, taskList already has speeds + // The active list already has speeds if (this.currentList === "active" && this.taskList.length > 0) { if (sampleSpeedsFromTasks(this.taskList)) { this.speedHistoryRev++; @@ -552,7 +575,7 @@ export const useTaskStore = defineStore("task", { return; } - // Otherwise fetch for active tasks + // Other tabs fetch a small active-task projection const tasks = (await api.fetchTaskList({ type: "active", keys: [ @@ -568,14 +591,17 @@ export const useTaskStore = defineStore("task", { this.speedHistoryRev++; } } catch { - // Sampling is best-effort + // Sampling is best effort } }, selectTasks(list: string[]) { this.selectedGidList = list; }, selectAllTask() { - this.selectedGidList = this.paginatedTaskList.map((task) => task.gid); + // Select visible row keys, including each selected file row in a BT torrent + this.selectedGidList = this.paginatedTaskList.map( + (task) => task._displayKey || task.gid, + ); }, async fetchItem(gid: string) { try { @@ -709,9 +735,8 @@ export const useTaskStore = defineStore("task", { const { gid } = task; const displayTask = task as DisplayTask; - // For per-file rows of a multi-file BT torrent, "delete" should only - // deselect that file rather than killing the whole torrent. Fall back - // to full removal when nothing remains selected. + // For a BT file row, delete means deselect that file + // If no files remain selected, fall back to removing the torrent if (displayTask._isFileEntry) { const fileEntry = Array.isArray(task.files) ? task.files[0] : null; const parent = this.taskList.find((t: DownloadTask) => t.gid === gid); @@ -740,9 +765,8 @@ export const useTaskStore = defineStore("task", { return api .removeTask({ gid }) .then(() => - // Engine's remove() marks the task as Removed but keeps it - // in the list. Drop the record so it disappears from every - // view including "all". + // Engine `remove()` only marks the task as Removed + // Drop the record too so it disappears from every view, including all api.removeTaskRecord({ gid }).catch(() => undefined), ) .finally(() => { @@ -852,7 +876,9 @@ export const useTaskStore = defineStore("task", { } }, batchResumeSelectedTasks() { - const gids: string[] = [...new Set(this.selectedGidList)]; + // `selectedGids` already dedupes per-row keys back to gid level + // so we don't call aria2 with the same gid twice + const gids: string[] = this.selectedGids; if (gids.length === 0) { return; } @@ -863,7 +889,7 @@ export const useTaskStore = defineStore("task", { }); }, batchPauseSelectedTasks() { - const gids: string[] = [...new Set(this.selectedGidList)]; + const gids: string[] = this.selectedGids; if (gids.length === 0) { return; } @@ -883,7 +909,7 @@ export const useTaskStore = defineStore("task", { return api .batchRemoveTask({ gids }) .then(() => - // Drop records so removed rows disappear from every view. + // Drop records so removed rows disappear from every view Promise.all( gids.map((gid) => api.removeTaskRecord({ gid }).catch(() => undefined), @@ -944,7 +970,9 @@ export const useTaskStore = defineStore("task", { options: { onSyncError?: (error: unknown) => void } = {}, ) { const { onSyncError } = options; - const selectedGids = [...this.selectedGidList]; + // Move underlying torrent gids, not individual file rows + // Multiple selected files inside one torrent collapse to that one torrent + const selectedGids = this.selectedGids; if (selectedGids.length === 0) { return 0; } diff --git a/src/renderer/styles/android.css b/src/renderer/styles/android.css new file mode 100644 index 00000000..152586b5 --- /dev/null +++ b/src/renderer/styles/android.css @@ -0,0 +1,1310 @@ +/* + * Android / mobile-phone styles. + */ +@media (max-width: 768px), (pointer: coarse) { + html.platform-android, + html.mobile-phone { + --android-surface: #fdf8ff; + --android-surface-container: #f3edf7; + --android-surface-container-high: #ece6f0; + --android-surface-container-highest: #e6e0e9; + --android-on-surface: #1d1b20; + --android-on-surface-variant: #49454f; + --android-primary: #6750a4; + --android-primary-container: #eaddff; + --android-on-primary-container: #21005d; + --android-outline: #79747e; + --android-outline-variant: #cac4d0; + + --android-radius-xl: 28px; + --android-radius-lg: 20px; + --android-radius-md: 16px; + --android-radius-sm: 12px; + --android-radius-pill: 999px; + + --android-text-display: 22px; + --android-text-headline: 18px; + --android-text-title: 16px; + --android-text-body: 15px; + --android-text-label: 13px; + --android-text-caption: 12px; + + --android-bottom-bar: 128px; + + background: var(--android-surface); + color: var(--android-on-surface); + + /* Improve text contrast: route legacy "muted" tokens to the + Material 3 on-surface-variant which stays readable on light + surface backgrounds. */ + --mo-no-task-color: var(--android-on-surface-variant); + --mo-task-action-color: var(--android-on-surface-variant); + --muted-foreground: var(--android-on-surface-variant); + } + + html.platform-android.dark, + html.mobile-phone.dark { + --android-surface: #141218; + --android-surface-container: #211f26; + --android-surface-container-high: #2b2930; + --android-surface-container-highest: #36343b; + --android-on-surface: #e6e0e9; + --android-on-surface-variant: #cac4d0; + --android-primary: #d0bcff; + --android-primary-container: #4f378b; + --android-on-primary-container: #eaddff; + --android-outline: #938f99; + --android-outline-variant: #49454f; + } + + html.platform-android body, + html.mobile-phone body { + background: var(--android-surface); + overscroll-behavior-y: contain; + } + + html.platform-android #app, + html.mobile-phone #app, + html.platform-android #container, + html.mobile-phone #container { + min-height: 100dvh; + background: + linear-gradient( + 180deg, + color-mix(in srgb, var(--android-primary) 12%, var(--android-surface)) 0, + var(--android-surface) 148px + ), + radial-gradient( + circle at 20% 0%, + rgba(103, 80, 164, 0.14), + transparent 34% + ), + var(--android-surface); + } + + /* layout-root becomes a flex column with a strict viewport height + so children (the .page-view) can own their own scroll regions. */ + html.platform-android .layout-root, + html.mobile-phone .layout-root { + display: flex !important; + flex-direction: column; + height: 100dvh; + min-height: 0; + padding: env(safe-area-inset-top) 12px + calc(var(--android-bottom-bar) + env(safe-area-inset-bottom) + 18px); + overflow: hidden; + } + + html.platform-android .aside, + html.mobile-phone .aside, + html.platform-android .mo-title-bar, + html.mobile-phone .mo-title-bar, + html.platform-android .speedometer, + html.mobile-phone .speedometer, + html.platform-android .mo-speedometer, + html.mobile-phone .mo-speedometer, + html.platform-android .dragger, + html.mobile-phone .dragger { + display: none !important; + } + + html.platform-android .page-view, + html.mobile-phone .page-view { + flex: 1 1 auto; + display: flex; + width: 100%; + min-width: 0; + min-height: 0; + height: auto; + border-radius: 0; + background: transparent; + box-shadow: none; + overflow: hidden; + } + + html.platform-android.dark .page-view, + html.mobile-phone.dark .page-view { + background: transparent; + box-shadow: none; + } + + /* Make sure the .main container inside .page-view stretches and + participates in the flex column layout for inner scrolling. */ + html.platform-android .page-view > .main, + html.mobile-phone .page-view > .main { + flex: 1 1 auto; + width: 100%; + min-width: 0; + min-height: 0; + display: flex; + flex-direction: column; + overflow: hidden; + } + + /* Strip the blue gradient under .panel-header on mobile and use a + simpler material header without the accent underline. Lay out the + title (or subnav switcher) on the left and the bulk-action buttons + on the right -- all on the SAME row. flex-wrap stays off so the + row never collapses; the title shrinks with ellipsis instead. */ + html.platform-android .panel .panel-header, + html.mobile-phone .panel .panel-header { + flex-shrink: 0; + display: flex; + flex-wrap: nowrap; + align-items: center; + gap: 8px; + margin: 0; + padding: 6px 16px 8px; + border-bottom: 0; + } + + html.platform-android .panel-header > .subnav-switch-trigger, + html.mobile-phone .panel-header > .subnav-switch-trigger, + html.platform-android .panel-header > .task-mobile-subnav-switcher, + html.mobile-phone .panel-header > .task-mobile-subnav-switcher, + html.platform-android .panel-header > .task-title, + html.mobile-phone .panel-header > .task-title, + html.platform-android .panel-header > .rss-title, + html.mobile-phone .panel-header > .rss-title { + flex: 1 1 0; + min-width: 0; + } + html.platform-android .task-mobile-subnav-switcher, + html.mobile-phone .task-mobile-subnav-switcher { + display: flex; + } + html.platform-android .task-mobile-subnav-switcher .subnav-switch-trigger, + html.mobile-phone .task-mobile-subnav-switcher .subnav-switch-trigger { + width: 100%; + min-width: 0; + justify-content: flex-start; + max-width: none; + } + html.platform-android .task-total-progress-desktop, + html.mobile-phone .task-total-progress-desktop { + display: none; + } + /* The bulk-action row is on the right with no wrapping; icons shrink + if room is tight. */ + html.platform-android .panel-header > .task-actions, + html.mobile-phone .panel-header > .task-actions { + flex: 0 0 auto; + flex-wrap: nowrap; + justify-content: flex-end; + align-items: center; + gap: 2px; + margin: 0; + } + html.platform-android .panel-header > .task-actions > *, + html.mobile-phone .panel-header > .task-actions > * { + flex: 0 0 auto; + } + + /* The overall download progress lives on its own row right below the + header so it never pushes the action icons around when transitioning + between Downloading/All/etc. */ + html.platform-android .task-total-progress-row, + html.mobile-phone .task-total-progress-row { + display: flex; + align-items: center; + gap: 6px; + width: 100%; + min-height: 32px; + padding: 0 12px; + border-radius: calc(var(--android-radius-md) - 4px); + background: var(--android-surface-container); + font-size: var(--android-text-caption); + color: var(--android-on-surface-variant); + } + html.platform-android .task-total-progress-row .task-total-progress-sep, + html.mobile-phone .task-total-progress-row .task-total-progress-sep { + opacity: 0.6; + } + html.platform-android .task-total-progress-row .task-total-progress-percent, + html.mobile-phone .task-total-progress-row .task-total-progress-percent { + font-weight: 600; + color: var(--android-on-surface); + } + html.platform-android .panel .panel-header::after, + html.mobile-phone .panel .panel-header::after { + display: none !important; + } + html.platform-android .panel .panel-header h4, + html.mobile-phone .panel .panel-header h4 { + font-size: var(--android-text-headline); + font-weight: 700; + line-height: 1.25; + letter-spacing: -0.01em; + color: var(--android-on-surface); + } + + html.platform-android .panel-content, + html.mobile-phone .panel-content { + flex: 1 1 0; + min-height: 0; + overflow-y: auto; + overflow-x: hidden; + -webkit-overflow-scrolling: touch; + } + + /* Tasks/RSS panel-layout--h root becomes a column on mobile because + the aside is hidden. */ + html.platform-android .main.panel-layout--h, + html.mobile-phone .main.panel-layout--h { + flex-direction: column; + overflow: hidden; + } + html.platform-android .main.panel-layout--h > .content.panel-layout--v, + html.mobile-phone .main.panel-layout--h > .content.panel-layout--v { + flex: 1 1 auto; + min-height: 0; + display: flex; + flex-direction: column; + overflow: hidden; + } + + .android-bottom-nav { + position: fixed; + left: 12px; + right: 12px; + bottom: calc(env(safe-area-inset-bottom) + 28px); + z-index: 80; + display: grid; + grid-template-columns: 1fr 1fr 72px 1fr 1fr; + align-items: center; + min-height: 76px; + padding: 8px 10px; + border: 1px solid rgba(121, 116, 126, 0.18); + border-radius: var(--android-radius-xl); + background: rgba(243, 237, 247, 0.94); + box-shadow: 0 12px 40px rgba(29, 27, 32, 0.18); + backdrop-filter: blur(18px) saturate(1.15); + } + + html.dark .android-bottom-nav { + border-color: rgba(202, 196, 208, 0.14); + background: rgba(33, 31, 38, 0.94); + } + + .android-nav-item, + .android-fab { + -webkit-tap-highlight-color: transparent; + border: 0; + font: inherit; + } + + .android-nav-item { + display: flex; + min-width: 0; + min-height: 56px; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 3px; + border-radius: 18px; + background: transparent; + color: var(--android-on-surface-variant); + font-size: 11px; + font-weight: 650; + letter-spacing: 0.01em; + } + + .android-nav-item svg { + padding: 5px 13px; + box-sizing: content-box; + border-radius: 999px; + } + + .android-nav-item.active { + color: var(--android-on-primary-container); + } + + .android-nav-item.active svg { + background: var(--android-primary-container); + } + + .android-fab { + display: grid; + place-items: center; + width: 64px; + height: 64px; + margin: -18px auto 0; + border-radius: 22px; + background: var(--android-primary); + color: #fff; + box-shadow: 0 12px 28px rgba(103, 80, 164, 0.34); + } + + html.dark .android-fab { + color: #381e72; + } + + /* Touch target floor for clickable controls, except interactive + icons that have an explicit size (checkbox, radio, switch, etc.). + The previous rule stretched the 16px shadcn checkbox into a + 16x44 vertical bar because the underlying element is a