diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 5a7ca50..31c1250 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -1,14 +1,14 @@ # โœจ Pull Request -### ๐Ÿ““ Referenced Issue +## ๐Ÿ““ Referenced Issue -### โ„น๏ธ About the PR +## โ„น๏ธ About the PR -### ๐Ÿ”„ Type of Change +## ๐Ÿ”„ Type of Change @@ -19,11 +19,11 @@ - [ ] ๐Ÿงน Code cleanup/refactoring - [ ] โšก Performance improvement -### ๐Ÿ–ผ๏ธ Testing Scenarios / Screenshots +## ๐Ÿ–ผ๏ธ Testing Scenarios / Screenshots -### โœ… Checklist +## โœ… Checklist @@ -36,11 +36,11 @@ - [ ] New and existing unit tests pass locally with my changes - [ ] Any dependent changes have been merged and published -### ๐Ÿงช How Has This Been Tested? +## ๐Ÿงช How Has This Been Tested? -### ๐Ÿ“ฑ Platform Testing +## ๐Ÿ“ฑ Platform Testing @@ -49,6 +49,6 @@ - [ ] Windows (if applicable) - [ ] Linux (if applicable) -### ๐Ÿ“‹ Additional Notes +## ๐Ÿ“‹ Additional Notes diff --git a/.github/workflows/lint-rs.yml b/.github/workflows/lint-rs.yml index 6303be0..59da1ab 100644 --- a/.github/workflows/lint-rs.yml +++ b/.github/workflows/lint-rs.yml @@ -76,7 +76,7 @@ jobs: if [[ "${{ matrix.os }}" == "ubuntu-latest" ]]; then pnpm run build:linux-x64 elif [[ "${{ matrix.os }}" == "macos-latest" ]]; then - pnpm run build:aarch64 + pnpm run build:universal elif [[ "${{ matrix.os }}" == "windows-latest" ]]; then pnpm run build:win-x64 fi @@ -88,7 +88,7 @@ jobs: if [[ "${{ matrix.os }}" == "ubuntu-latest" ]]; then cp nodecar/dist/nodecar src-tauri/binaries/nodecar-x86_64-unknown-linux-gnu elif [[ "${{ matrix.os }}" == "macos-latest" ]]; then - cp nodecar/dist/nodecar src-tauri/binaries/nodecar-aarch64-apple-darwin + cp nodecar/dist/nodecar src-tauri/binaries/nodecar-universal-apple-darwin elif [[ "${{ matrix.os }}" == "windows-latest" ]]; then cp nodecar/dist/nodecar.exe src-tauri/binaries/nodecar-x86_64-pc-windows-msvc.exe fi diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 4cfcfec..f1dd877 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -57,19 +57,25 @@ jobs: target: "x86_64-apple-darwin" pkg_target: "latest-macos-x64" nodecar_script: "build:x86_64" + - platform: "macos-latest" + args: "--target universal-apple-darwin" + arch: "aarch64" + target: "aarch64-apple-darwin,x86_64-apple-darwin" + pkg_target: "universal" + nodecar_script: "build:universal" + - platform: "ubuntu-20.04" + args: "--target x86_64-unknown-linux-gnu" + arch: "x86_64" + target: "x86_64-unknown-linux-gnu" + pkg_target: "latest-linux-x64" + nodecar_script: "build:linux-x64" + - platform: "ubuntu-20.04" + args: "--target aarch64-unknown-linux-gnu" + arch: "aarch64" + target: "aarch64-unknown-linux-gnu" + pkg_target: "latest-linux-arm64" + nodecar_script: "build:linux-arm64" # Future platforms can be added here: - # - platform: "ubuntu-20.04" - # args: "--target x86_64-unknown-linux-gnu" - # arch: "x86_64" - # target: "x86_64-unknown-linux-gnu" - # pkg_target: "latest-linux-x64" - # nodecar_script: "build:linux-x64" - # - platform: "ubuntu-20.04" - # args: "--target aarch64-unknown-linux-gnu" - # arch: "aarch64" - # target: "aarch64-unknown-linux-gnu" - # pkg_target: "latest-linux-arm64" - # nodecar_script: "build:linux-arm64" # - platform: "windows-latest" # args: "--target x86_64-pc-windows-msvc" # arch: "x86_64" @@ -105,6 +111,18 @@ jobs: run: | sudo apt-get update sudo apt-get install -y libwebkit2gtk-4.1-dev libgtk-3-dev libayatana-appindicator3-dev librsvg2-dev + # Install cross-compilation tools for ARM64 + if [[ "${{ matrix.arch }}" == "aarch64" ]]; then + sudo apt-get install -y gcc-aarch64-linux-gnu + fi + + - name: Setup cross-compilation environment (Ubuntu ARM64 only) + if: matrix.platform == 'ubuntu-20.04' && matrix.arch == 'aarch64' + run: | + echo "CC_aarch64_unknown_linux_gnu=aarch64-linux-gnu-gcc" >> $GITHUB_ENV + echo "CXX_aarch64_unknown_linux_gnu=aarch64-linux-gnu-g++" >> $GITHUB_ENV + echo "AR_aarch64_unknown_linux_gnu=aarch64-linux-gnu-ar" >> $GITHUB_ENV + echo "CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_LINKER=aarch64-linux-gnu-gcc" >> $GITHUB_ENV - name: Rust cache uses: swatinem/rust-cache@v2 diff --git a/.github/workflows/rolling-release.yml b/.github/workflows/rolling-release.yml index bece067..aac8701 100644 --- a/.github/workflows/rolling-release.yml +++ b/.github/workflows/rolling-release.yml @@ -56,6 +56,24 @@ jobs: target: "x86_64-apple-darwin" pkg_target: "latest-macos-x64" nodecar_script: "build:x86_64" + - platform: "macos-latest" + args: "--target universal-apple-darwin" + arch: "aarch64" + target: "aarch64-apple-darwin,x86_64-apple-darwin" + pkg_target: "universal" + nodecar_script: "build:universal" + - platform: "ubuntu-20.04" + args: "--target x86_64-unknown-linux-gnu" + arch: "x86_64" + target: "x86_64-unknown-linux-gnu" + pkg_target: "latest-linux-x64" + nodecar_script: "build:linux-x64" + - platform: "ubuntu-20.04" + args: "--target aarch64-unknown-linux-gnu" + arch: "aarch64" + target: "aarch64-unknown-linux-gnu" + pkg_target: "latest-linux-arm64" + nodecar_script: "build:linux-arm64" runs-on: ${{ matrix.platform }} steps: @@ -74,6 +92,24 @@ jobs: with: targets: ${{ matrix.target }} + - name: Install dependencies (Ubuntu only) + if: matrix.platform == 'ubuntu-20.04' + run: | + sudo apt-get update + sudo apt-get install -y libwebkit2gtk-4.1-dev libgtk-3-dev libayatana-appindicator3-dev librsvg2-dev + # Install cross-compilation tools for ARM64 + if [[ "${{ matrix.arch }}" == "aarch64" ]]; then + sudo apt-get install -y gcc-aarch64-linux-gnu + fi + + - name: Setup cross-compilation environment (Ubuntu ARM64 only) + if: matrix.platform == 'ubuntu-20.04' && matrix.arch == 'aarch64' + run: | + echo "CC_aarch64_unknown_linux_gnu=aarch64-linux-gnu-gcc" >> $GITHUB_ENV + echo "CXX_aarch64_unknown_linux_gnu=aarch64-linux-gnu-g++" >> $GITHUB_ENV + echo "AR_aarch64_unknown_linux_gnu=aarch64-linux-gnu-ar" >> $GITHUB_ENV + echo "CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_LINKER=aarch64-linux-gnu-gcc" >> $GITHUB_ENV + - name: Rust cache uses: swatinem/rust-cache@v2 with: @@ -97,7 +133,11 @@ jobs: shell: bash run: | mkdir -p src-tauri/binaries - cp nodecar/dist/nodecar src-tauri/binaries/nodecar-${{ matrix.target }} + if [[ "${{ matrix.platform }}" == "windows-latest" ]]; then + cp nodecar/dist/nodecar.exe src-tauri/binaries/nodecar-${{ matrix.target }}.exe + else + cp nodecar/dist/nodecar src-tauri/binaries/nodecar-${{ matrix.target }} + fi - name: Build frontend run: pnpm build diff --git a/.gitignore b/.gitignore index 48e1df7..e4649ab 100644 --- a/.gitignore +++ b/.gitignore @@ -46,4 +46,7 @@ yarn-error.log* # typescript *.tsbuildinfo +# eslint +.eslintcache + !**/.gitkeep \ No newline at end of file diff --git a/.husky/pre-commit b/.husky/pre-commit index cb2c84d..5ee7abd 100755 --- a/.husky/pre-commit +++ b/.husky/pre-commit @@ -1 +1 @@ -pnpm lint-staged +pnpm exec lint-staged diff --git a/.node-version b/.node-version index eb08e72..853058d 100644 --- a/.node-version +++ b/.node-version @@ -1,2 +1,2 @@ -22 +23 diff --git a/.nvmrc b/.nvmrc new file mode 100644 index 0000000..03ff60b --- /dev/null +++ b/.nvmrc @@ -0,0 +1 @@ +23 \ No newline at end of file diff --git a/.vscode/extensions.json b/.vscode/extensions.json new file mode 100644 index 0000000..30d2eb1 --- /dev/null +++ b/.vscode/extensions.json @@ -0,0 +1,11 @@ +{ + "recommendations": [ + "biomejs.biome", + "streetsidesoftware.code-spell-checker", + "usernamehw.errorlens", + "heybourn.headwind", + "yoavbls.pretty-ts-errors", + "rust-lang.rust-analyzer", + "bradlc.vscode-tailwindcss" + ] +} diff --git a/.vscode/settings.json b/.vscode/settings.json index e40f2ea..f7601fb 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -9,13 +9,20 @@ "checkin", "clippy", "codegen", + "devedition", "donutbrowser", "dtolnay", "elif", "esbuild", + "eslintcache", + "frontmost", "gifs", + "gsettings", + "idletime", + "KHTML", "launchservices", "mountpoint", + "msys", "Mullvad", "nodecar", "ntlm", @@ -33,6 +40,7 @@ "sonner", "sspi", "staticlib", + "subdirs", "swatinem", "sysinfo", "systempreferences", @@ -42,6 +50,7 @@ "turbopack", "unlisten", "unrs", + "vercel", "wiremock", "xattr", "zhom" diff --git a/nodecar/package.json b/nodecar/package.json index 2b05667..5a07651 100644 --- a/nodecar/package.json +++ b/nodecar/package.json @@ -10,6 +10,7 @@ "build": "tsc && pkg ./dist/index.js --targets latest-macos-arm64 --output dist/nodecar", "build:aarch64": "tsc && pkg ./dist/index.js --targets latest-macos-arm64 --output dist/nodecar", "build:x86_64": "tsc && pkg ./dist/index.js --targets latest-macos-x64 --output dist/nodecar", + "build:universal": "tsc && pkg ./dist/index.js --targets latest-macos-arm64 --output dist/nodecar-arm64 && pkg ./dist/index.js --targets latest-macos-x64 --output dist/nodecar-x64 && lipo -create -output dist/nodecar dist/nodecar-arm64 dist/nodecar-x64 && rm dist/nodecar-arm64 dist/nodecar-x64", "build:linux-x64": "tsc && pkg ./dist/index.js --targets latest-linux-x64 --output dist/nodecar", "build:linux-arm64": "tsc && pkg ./dist/index.js --targets latest-linux-arm64 --output dist/nodecar", "build:win-x64": "tsc && pkg ./dist/index.js --targets latest-win-x64 --output dist/nodecar", @@ -18,7 +19,6 @@ "keywords": [], "author": "", "license": "AGPL-3.0", - "packageManager": "pnpm@10.11.1+sha512.e519b9f7639869dc8d5c3c5dfef73b3f091094b0a006d7317353c72b124e80e1afd429732e28705ad6bfa1ee879c1fce46c128ccebd3192101f43dd67c667912", "dependencies": { "@types/node": "^22.15.29", "@yao-pkg/pkg": "^6.5.1", diff --git a/nodecar/rename-binary.js b/nodecar/rename-binary.js deleted file mode 100644 index 4edd193..0000000 --- a/nodecar/rename-binary.js +++ /dev/null @@ -1,14 +0,0 @@ -import { execSync } from "child_process"; -import fs from "fs"; - -const ext = process.platform === "win32" ? ".exe" : ""; - -const rustInfo = execSync("rustc -vV"); -const targetTriple = /host: (\S+)/g.exec(rustInfo)[1]; -if (!targetTriple) { - console.error("Failed to determine platform target triple"); -} -fs.renameSync( - `dist/nodecar${ext}`, - `../src-tauri/binaries/nodecar-${targetTriple}${ext}` -); diff --git a/package.json b/package.json index d1d8b87..e7e7d9e 100644 --- a/package.json +++ b/package.json @@ -8,16 +8,19 @@ "dev": "next dev --turbopack", "build": "next build", "start": "next start", + "test": "pnpm test:rust", + "test:rust": "cd src-tauri && cargo test", "lint": "pnpm lint:js && pnpm lint:rust", "lint:js": "biome check src/ && tsc --noEmit && next lint", "lint:rust": "cd src-tauri && cargo clippy --all-targets --all-features -- -D warnings -D clippy::all && cargo fmt --all", "tauri": "tauri", "shadcn:add": "pnpm dlx shadcn@latest add", - "prepare": "husky", + "prepare": "husky && husky install", "format:rust": "cd src-tauri && cargo clippy --fix --allow-dirty --all-targets --all-features -- -D warnings -D clippy::all && cargo fmt --all", "format:js": "biome check src/ --fix", "format": "pnpm format:js && pnpm format:rust", - "cargo": "cd src-tauri && cargo" + "cargo": "cd src-tauri && cargo", + "check-unused-commands": "cd src-tauri && cargo run --bin check_unused_commands" }, "dependencies": { "@radix-ui/react-checkbox": "^1.3.2", @@ -54,7 +57,7 @@ "@tauri-apps/cli": "^2.5.0", "@types/node": "^22.15.29", "@types/react": "^19.1.6", - "@types/react-dom": "^19.1.5", + "@types/react-dom": "^19.1.6", "@typescript-eslint/eslint-plugin": "^8.33.1", "@typescript-eslint/parser": "^8.33.1", "@vitejs/plugin-react": "^4.5.1", @@ -64,14 +67,15 @@ "husky": "^9.1.7", "lint-staged": "^16.1.0", "tailwindcss": "^4.1.8", - "tw-animate-css": "^1.3.3", + "tw-animate-css": "^1.3.4", "typescript": "~5.8.3", "typescript-eslint": "^8.33.1" }, - "packageManager": "pnpm@10.11.0+sha512.6540583f41cc5f628eb3d9773ecee802f4f9ef9923cc45b69890fb47991d4b092964694ec3a4f738a420c918a333062c8b925d312f42e4f0c263eb603551f977", + "packageManager": "pnpm@10.11.1", "lint-staged": { "src/**/*.{js,jsx,ts,tsx,json,css,md}": [ - "biome check --fix" + "biome check --fix", + "eslint --cache --fix" ], "src-tauri/**/*.rs": [ "cd src-tauri && cargo fmt --all", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e29bc43..f07ca38 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -10,34 +10,34 @@ importers: dependencies: '@radix-ui/react-checkbox': specifier: ^1.3.2 - version: 1.3.2(@types/react-dom@19.1.5(@types/react@19.1.6))(@types/react@19.1.6)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + version: 1.3.2(@types/react-dom@19.1.6(@types/react@19.1.6))(@types/react@19.1.6)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) '@radix-ui/react-dialog': specifier: ^1.1.14 - version: 1.1.14(@types/react-dom@19.1.5(@types/react@19.1.6))(@types/react@19.1.6)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + version: 1.1.14(@types/react-dom@19.1.6(@types/react@19.1.6))(@types/react@19.1.6)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) '@radix-ui/react-dropdown-menu': specifier: ^2.1.15 - version: 2.1.15(@types/react-dom@19.1.5(@types/react@19.1.6))(@types/react@19.1.6)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + version: 2.1.15(@types/react-dom@19.1.6(@types/react@19.1.6))(@types/react@19.1.6)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) '@radix-ui/react-label': specifier: ^2.1.7 - version: 2.1.7(@types/react-dom@19.1.5(@types/react@19.1.6))(@types/react@19.1.6)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + version: 2.1.7(@types/react-dom@19.1.6(@types/react@19.1.6))(@types/react@19.1.6)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) '@radix-ui/react-popover': specifier: ^1.1.14 - version: 1.1.14(@types/react-dom@19.1.5(@types/react@19.1.6))(@types/react@19.1.6)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + version: 1.1.14(@types/react-dom@19.1.6(@types/react@19.1.6))(@types/react@19.1.6)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) '@radix-ui/react-progress': specifier: ^1.1.7 - version: 1.1.7(@types/react-dom@19.1.5(@types/react@19.1.6))(@types/react@19.1.6)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + version: 1.1.7(@types/react-dom@19.1.6(@types/react@19.1.6))(@types/react@19.1.6)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) '@radix-ui/react-scroll-area': specifier: ^1.2.9 - version: 1.2.9(@types/react-dom@19.1.5(@types/react@19.1.6))(@types/react@19.1.6)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + version: 1.2.9(@types/react-dom@19.1.6(@types/react@19.1.6))(@types/react@19.1.6)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) '@radix-ui/react-select': specifier: ^2.2.5 - version: 2.2.5(@types/react-dom@19.1.5(@types/react@19.1.6))(@types/react@19.1.6)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + version: 2.2.5(@types/react-dom@19.1.6(@types/react@19.1.6))(@types/react@19.1.6)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) '@radix-ui/react-slot': specifier: ^1.2.3 version: 1.2.3(@types/react@19.1.6)(react@19.1.0) '@radix-ui/react-tooltip': specifier: ^1.2.7 - version: 1.2.7(@types/react-dom@19.1.5(@types/react@19.1.6))(@types/react@19.1.6)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + version: 1.2.7(@types/react-dom@19.1.6(@types/react@19.1.6))(@types/react@19.1.6)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) '@tanstack/react-table': specifier: ^8.21.3 version: 8.21.3(react-dom@19.1.0(react@19.1.0))(react@19.1.0) @@ -58,7 +58,7 @@ importers: version: 2.1.1 cmdk: specifier: ^1.1.1 - version: 1.1.1(@types/react-dom@19.1.5(@types/react@19.1.6))(@types/react@19.1.6)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + version: 1.1.1(@types/react-dom@19.1.6(@types/react@19.1.6))(@types/react@19.1.6)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) next: specifier: ^15.3.3 version: 15.3.3(@babel/core@7.27.4)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) @@ -106,8 +106,8 @@ importers: specifier: ^19.1.6 version: 19.1.6 '@types/react-dom': - specifier: ^19.1.5 - version: 19.1.5(@types/react@19.1.6) + specifier: ^19.1.6 + version: 19.1.6(@types/react@19.1.6) '@typescript-eslint/eslint-plugin': specifier: ^8.33.1 version: 8.33.1(@typescript-eslint/parser@8.33.1(eslint@9.28.0(jiti@2.4.2))(typescript@5.8.3))(eslint@9.28.0(jiti@2.4.2))(typescript@5.8.3) @@ -136,8 +136,8 @@ importers: specifier: ^4.1.8 version: 4.1.8 tw-animate-css: - specifier: ^1.3.3 - version: 1.3.3 + specifier: ^1.3.4 + version: 1.3.4 typescript: specifier: ~5.8.3 version: 5.8.3 @@ -1495,8 +1495,8 @@ packages: '@types/node@22.15.29': resolution: {integrity: sha512-LNdjOkUDlU1RZb8e1kOIUpN1qQUlzGkEtbVNo53vbrwDg5om6oduhm4SiUaPW5ASTXhAiP0jInWG8Qx9fVlOeQ==} - '@types/react-dom@19.1.5': - resolution: {integrity: sha512-CMCjrWucUBZvohgZxkjd6S9h0nZxXjzus6yDfUb+xLxYM7VvjKNH1tQrE9GWLql1XoOP4/Ds3bwFqShHUYraGg==} + '@types/react-dom@19.1.6': + resolution: {integrity: sha512-4hOiT/dwO8Ko0gV1m/TJZYk3y0KBnY9vzDh7W+DH17b2HFSOGgdj33dhihPeuy3l0q23+4e+hoXHV6hCC4dCXw==} peerDependencies: '@types/react': ^19.0.0 @@ -1830,8 +1830,8 @@ packages: resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==} engines: {node: '>=6'} - caniuse-lite@1.0.30001720: - resolution: {integrity: sha512-Ec/2yV2nNPwb4DnTANEV99ZWwm3ZWfdlfkQbWSDDt+PsXEVYwlhPH8tdMaPunYTKKmz7AnHi2oNEi1GcmKCD8g==} + caniuse-lite@1.0.30001721: + resolution: {integrity: sha512-cOuvmUVtKrtEaoKiO0rSc29jcjwMwX5tOHDy4MgVFEWiUXj4uBMJkwI8MDySkgXidpMiHUcviogAvFi4pA2hDQ==} chalk@4.1.2: resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} @@ -1996,8 +1996,8 @@ packages: duplexer2@0.1.4: resolution: {integrity: sha512-asLFVfWWtJ90ZyOUHMqk7/S2w2guQKxUI2itj3d92ADHhxUSbCMGi1f1cBcJ7xM1To+pE/Khbwo1yuNbMEPKeA==} - electron-to-chromium@1.5.162: - resolution: {integrity: sha512-hQA+Zb5QQwoSaXJWEAGEw1zhk//O7qDzib05Z4qTqZfNju/FAkrm5ZInp0JbTp4Z18A6bilopdZWEYrFSsfllA==} + electron-to-chromium@1.5.165: + resolution: {integrity: sha512-naiMx1Z6Nb2TxPU6fiFrUrDTjyPMLdTtaOd2oLmG8zVSg2hCWGkhPyxwk+qRmZ1ytwVqUv0u7ZcDA5+ALhaUtw==} emoji-regex@10.4.0: resolution: {integrity: sha512-EC+0oUMY1Rqm4O6LLrgjtYDvcVYTy7chDnM4Q7030tP4Kwj3u/pR6gP9ygnp2CJMK5Gq+9Q2oqmrFJAz01DXjw==} @@ -3368,8 +3368,8 @@ packages: tunnel-agent@0.6.0: resolution: {integrity: sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==} - tw-animate-css@1.3.3: - resolution: {integrity: sha512-tXE2TRWrskc4TU3RDd7T8n8Np/wCfoeH9gz22c7PzYqNPQ9FBGFbWWzwL0JyHcFp+jHozmF76tbHfPAx22ua2Q==} + tw-animate-css@1.3.4: + resolution: {integrity: sha512-dd1Ht6/YQHcNbq0znIT6dG8uhO7Ce+VIIhZUhjsryXsMPJQz3bZg7Q2eNzLwipb25bRZslGb2myio5mScd1TFg==} type-check@0.4.0: resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} @@ -4057,22 +4057,22 @@ snapshots: '@radix-ui/primitive@1.1.2': {} - '@radix-ui/react-arrow@1.1.7(@types/react-dom@19.1.5(@types/react@19.1.6))(@types/react@19.1.6)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': + '@radix-ui/react-arrow@1.1.7(@types/react-dom@19.1.6(@types/react@19.1.6))(@types/react@19.1.6)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': dependencies: - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.1.5(@types/react@19.1.6))(@types/react@19.1.6)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.1.6(@types/react@19.1.6))(@types/react@19.1.6)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) react: 19.1.0 react-dom: 19.1.0(react@19.1.0) optionalDependencies: '@types/react': 19.1.6 - '@types/react-dom': 19.1.5(@types/react@19.1.6) + '@types/react-dom': 19.1.6(@types/react@19.1.6) - '@radix-ui/react-checkbox@1.3.2(@types/react-dom@19.1.5(@types/react@19.1.6))(@types/react@19.1.6)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': + '@radix-ui/react-checkbox@1.3.2(@types/react-dom@19.1.6(@types/react@19.1.6))(@types/react@19.1.6)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': dependencies: '@radix-ui/primitive': 1.1.2 '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.1.6)(react@19.1.0) '@radix-ui/react-context': 1.1.2(@types/react@19.1.6)(react@19.1.0) - '@radix-ui/react-presence': 1.1.4(@types/react-dom@19.1.5(@types/react@19.1.6))(@types/react@19.1.6)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.1.5(@types/react@19.1.6))(@types/react@19.1.6)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-presence': 1.1.4(@types/react-dom@19.1.6(@types/react@19.1.6))(@types/react@19.1.6)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.1.6(@types/react@19.1.6))(@types/react@19.1.6)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.1.6)(react@19.1.0) '@radix-ui/react-use-previous': 1.1.1(@types/react@19.1.6)(react@19.1.0) '@radix-ui/react-use-size': 1.1.1(@types/react@19.1.6)(react@19.1.0) @@ -4080,19 +4080,19 @@ snapshots: react-dom: 19.1.0(react@19.1.0) optionalDependencies: '@types/react': 19.1.6 - '@types/react-dom': 19.1.5(@types/react@19.1.6) + '@types/react-dom': 19.1.6(@types/react@19.1.6) - '@radix-ui/react-collection@1.1.7(@types/react-dom@19.1.5(@types/react@19.1.6))(@types/react@19.1.6)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': + '@radix-ui/react-collection@1.1.7(@types/react-dom@19.1.6(@types/react@19.1.6))(@types/react@19.1.6)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': dependencies: '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.1.6)(react@19.1.0) '@radix-ui/react-context': 1.1.2(@types/react@19.1.6)(react@19.1.0) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.1.5(@types/react@19.1.6))(@types/react@19.1.6)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.1.6(@types/react@19.1.6))(@types/react@19.1.6)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) '@radix-ui/react-slot': 1.2.3(@types/react@19.1.6)(react@19.1.0) react: 19.1.0 react-dom: 19.1.0(react@19.1.0) optionalDependencies: '@types/react': 19.1.6 - '@types/react-dom': 19.1.5(@types/react@19.1.6) + '@types/react-dom': 19.1.6(@types/react@19.1.6) '@radix-ui/react-compose-refs@1.1.2(@types/react@19.1.6)(react@19.1.0)': dependencies: @@ -4106,18 +4106,18 @@ snapshots: optionalDependencies: '@types/react': 19.1.6 - '@radix-ui/react-dialog@1.1.14(@types/react-dom@19.1.5(@types/react@19.1.6))(@types/react@19.1.6)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': + '@radix-ui/react-dialog@1.1.14(@types/react-dom@19.1.6(@types/react@19.1.6))(@types/react@19.1.6)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': dependencies: '@radix-ui/primitive': 1.1.2 '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.1.6)(react@19.1.0) '@radix-ui/react-context': 1.1.2(@types/react@19.1.6)(react@19.1.0) - '@radix-ui/react-dismissable-layer': 1.1.10(@types/react-dom@19.1.5(@types/react@19.1.6))(@types/react@19.1.6)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-dismissable-layer': 1.1.10(@types/react-dom@19.1.6(@types/react@19.1.6))(@types/react@19.1.6)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) '@radix-ui/react-focus-guards': 1.1.2(@types/react@19.1.6)(react@19.1.0) - '@radix-ui/react-focus-scope': 1.1.7(@types/react-dom@19.1.5(@types/react@19.1.6))(@types/react@19.1.6)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-focus-scope': 1.1.7(@types/react-dom@19.1.6(@types/react@19.1.6))(@types/react@19.1.6)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) '@radix-ui/react-id': 1.1.1(@types/react@19.1.6)(react@19.1.0) - '@radix-ui/react-portal': 1.1.9(@types/react-dom@19.1.5(@types/react@19.1.6))(@types/react@19.1.6)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - '@radix-ui/react-presence': 1.1.4(@types/react-dom@19.1.5(@types/react@19.1.6))(@types/react@19.1.6)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.1.5(@types/react@19.1.6))(@types/react@19.1.6)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-portal': 1.1.9(@types/react-dom@19.1.6(@types/react@19.1.6))(@types/react@19.1.6)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-presence': 1.1.4(@types/react-dom@19.1.6(@types/react@19.1.6))(@types/react@19.1.6)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.1.6(@types/react@19.1.6))(@types/react@19.1.6)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) '@radix-ui/react-slot': 1.2.3(@types/react@19.1.6)(react@19.1.0) '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.1.6)(react@19.1.0) aria-hidden: 1.2.6 @@ -4126,7 +4126,7 @@ snapshots: react-remove-scroll: 2.7.1(@types/react@19.1.6)(react@19.1.0) optionalDependencies: '@types/react': 19.1.6 - '@types/react-dom': 19.1.5(@types/react@19.1.6) + '@types/react-dom': 19.1.6(@types/react@19.1.6) '@radix-ui/react-direction@1.1.1(@types/react@19.1.6)(react@19.1.0)': dependencies: @@ -4134,33 +4134,33 @@ snapshots: optionalDependencies: '@types/react': 19.1.6 - '@radix-ui/react-dismissable-layer@1.1.10(@types/react-dom@19.1.5(@types/react@19.1.6))(@types/react@19.1.6)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': + '@radix-ui/react-dismissable-layer@1.1.10(@types/react-dom@19.1.6(@types/react@19.1.6))(@types/react@19.1.6)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': dependencies: '@radix-ui/primitive': 1.1.2 '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.1.6)(react@19.1.0) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.1.5(@types/react@19.1.6))(@types/react@19.1.6)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.1.6(@types/react@19.1.6))(@types/react@19.1.6)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.1.6)(react@19.1.0) '@radix-ui/react-use-escape-keydown': 1.1.1(@types/react@19.1.6)(react@19.1.0) react: 19.1.0 react-dom: 19.1.0(react@19.1.0) optionalDependencies: '@types/react': 19.1.6 - '@types/react-dom': 19.1.5(@types/react@19.1.6) + '@types/react-dom': 19.1.6(@types/react@19.1.6) - '@radix-ui/react-dropdown-menu@2.1.15(@types/react-dom@19.1.5(@types/react@19.1.6))(@types/react@19.1.6)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': + '@radix-ui/react-dropdown-menu@2.1.15(@types/react-dom@19.1.6(@types/react@19.1.6))(@types/react@19.1.6)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': dependencies: '@radix-ui/primitive': 1.1.2 '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.1.6)(react@19.1.0) '@radix-ui/react-context': 1.1.2(@types/react@19.1.6)(react@19.1.0) '@radix-ui/react-id': 1.1.1(@types/react@19.1.6)(react@19.1.0) - '@radix-ui/react-menu': 2.1.15(@types/react-dom@19.1.5(@types/react@19.1.6))(@types/react@19.1.6)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.1.5(@types/react@19.1.6))(@types/react@19.1.6)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-menu': 2.1.15(@types/react-dom@19.1.6(@types/react@19.1.6))(@types/react@19.1.6)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.1.6(@types/react@19.1.6))(@types/react@19.1.6)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.1.6)(react@19.1.0) react: 19.1.0 react-dom: 19.1.0(react@19.1.0) optionalDependencies: '@types/react': 19.1.6 - '@types/react-dom': 19.1.5(@types/react@19.1.6) + '@types/react-dom': 19.1.6(@types/react@19.1.6) '@radix-ui/react-focus-guards@1.1.2(@types/react@19.1.6)(react@19.1.0)': dependencies: @@ -4168,16 +4168,16 @@ snapshots: optionalDependencies: '@types/react': 19.1.6 - '@radix-ui/react-focus-scope@1.1.7(@types/react-dom@19.1.5(@types/react@19.1.6))(@types/react@19.1.6)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': + '@radix-ui/react-focus-scope@1.1.7(@types/react-dom@19.1.6(@types/react@19.1.6))(@types/react@19.1.6)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': dependencies: '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.1.6)(react@19.1.0) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.1.5(@types/react@19.1.6))(@types/react@19.1.6)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.1.6(@types/react@19.1.6))(@types/react@19.1.6)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.1.6)(react@19.1.0) react: 19.1.0 react-dom: 19.1.0(react@19.1.0) optionalDependencies: '@types/react': 19.1.6 - '@types/react-dom': 19.1.5(@types/react@19.1.6) + '@types/react-dom': 19.1.6(@types/react@19.1.6) '@radix-ui/react-id@1.1.1(@types/react@19.1.6)(react@19.1.0)': dependencies: @@ -4186,31 +4186,31 @@ snapshots: optionalDependencies: '@types/react': 19.1.6 - '@radix-ui/react-label@2.1.7(@types/react-dom@19.1.5(@types/react@19.1.6))(@types/react@19.1.6)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': + '@radix-ui/react-label@2.1.7(@types/react-dom@19.1.6(@types/react@19.1.6))(@types/react@19.1.6)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': dependencies: - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.1.5(@types/react@19.1.6))(@types/react@19.1.6)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.1.6(@types/react@19.1.6))(@types/react@19.1.6)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) react: 19.1.0 react-dom: 19.1.0(react@19.1.0) optionalDependencies: '@types/react': 19.1.6 - '@types/react-dom': 19.1.5(@types/react@19.1.6) + '@types/react-dom': 19.1.6(@types/react@19.1.6) - '@radix-ui/react-menu@2.1.15(@types/react-dom@19.1.5(@types/react@19.1.6))(@types/react@19.1.6)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': + '@radix-ui/react-menu@2.1.15(@types/react-dom@19.1.6(@types/react@19.1.6))(@types/react@19.1.6)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': dependencies: '@radix-ui/primitive': 1.1.2 - '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.1.5(@types/react@19.1.6))(@types/react@19.1.6)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.1.6(@types/react@19.1.6))(@types/react@19.1.6)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.1.6)(react@19.1.0) '@radix-ui/react-context': 1.1.2(@types/react@19.1.6)(react@19.1.0) '@radix-ui/react-direction': 1.1.1(@types/react@19.1.6)(react@19.1.0) - '@radix-ui/react-dismissable-layer': 1.1.10(@types/react-dom@19.1.5(@types/react@19.1.6))(@types/react@19.1.6)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-dismissable-layer': 1.1.10(@types/react-dom@19.1.6(@types/react@19.1.6))(@types/react@19.1.6)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) '@radix-ui/react-focus-guards': 1.1.2(@types/react@19.1.6)(react@19.1.0) - '@radix-ui/react-focus-scope': 1.1.7(@types/react-dom@19.1.5(@types/react@19.1.6))(@types/react@19.1.6)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-focus-scope': 1.1.7(@types/react-dom@19.1.6(@types/react@19.1.6))(@types/react@19.1.6)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) '@radix-ui/react-id': 1.1.1(@types/react@19.1.6)(react@19.1.0) - '@radix-ui/react-popper': 1.2.7(@types/react-dom@19.1.5(@types/react@19.1.6))(@types/react@19.1.6)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - '@radix-ui/react-portal': 1.1.9(@types/react-dom@19.1.5(@types/react@19.1.6))(@types/react@19.1.6)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - '@radix-ui/react-presence': 1.1.4(@types/react-dom@19.1.5(@types/react@19.1.6))(@types/react@19.1.6)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.1.5(@types/react@19.1.6))(@types/react@19.1.6)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - '@radix-ui/react-roving-focus': 1.1.10(@types/react-dom@19.1.5(@types/react@19.1.6))(@types/react@19.1.6)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-popper': 1.2.7(@types/react-dom@19.1.6(@types/react@19.1.6))(@types/react@19.1.6)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-portal': 1.1.9(@types/react-dom@19.1.6(@types/react@19.1.6))(@types/react@19.1.6)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-presence': 1.1.4(@types/react-dom@19.1.6(@types/react@19.1.6))(@types/react@19.1.6)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.1.6(@types/react@19.1.6))(@types/react@19.1.6)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-roving-focus': 1.1.10(@types/react-dom@19.1.6(@types/react@19.1.6))(@types/react@19.1.6)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) '@radix-ui/react-slot': 1.2.3(@types/react@19.1.6)(react@19.1.0) '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.1.6)(react@19.1.0) aria-hidden: 1.2.6 @@ -4219,21 +4219,21 @@ snapshots: react-remove-scroll: 2.7.1(@types/react@19.1.6)(react@19.1.0) optionalDependencies: '@types/react': 19.1.6 - '@types/react-dom': 19.1.5(@types/react@19.1.6) + '@types/react-dom': 19.1.6(@types/react@19.1.6) - '@radix-ui/react-popover@1.1.14(@types/react-dom@19.1.5(@types/react@19.1.6))(@types/react@19.1.6)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': + '@radix-ui/react-popover@1.1.14(@types/react-dom@19.1.6(@types/react@19.1.6))(@types/react@19.1.6)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': dependencies: '@radix-ui/primitive': 1.1.2 '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.1.6)(react@19.1.0) '@radix-ui/react-context': 1.1.2(@types/react@19.1.6)(react@19.1.0) - '@radix-ui/react-dismissable-layer': 1.1.10(@types/react-dom@19.1.5(@types/react@19.1.6))(@types/react@19.1.6)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-dismissable-layer': 1.1.10(@types/react-dom@19.1.6(@types/react@19.1.6))(@types/react@19.1.6)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) '@radix-ui/react-focus-guards': 1.1.2(@types/react@19.1.6)(react@19.1.0) - '@radix-ui/react-focus-scope': 1.1.7(@types/react-dom@19.1.5(@types/react@19.1.6))(@types/react@19.1.6)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-focus-scope': 1.1.7(@types/react-dom@19.1.6(@types/react@19.1.6))(@types/react@19.1.6)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) '@radix-ui/react-id': 1.1.1(@types/react@19.1.6)(react@19.1.0) - '@radix-ui/react-popper': 1.2.7(@types/react-dom@19.1.5(@types/react@19.1.6))(@types/react@19.1.6)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - '@radix-ui/react-portal': 1.1.9(@types/react-dom@19.1.5(@types/react@19.1.6))(@types/react@19.1.6)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - '@radix-ui/react-presence': 1.1.4(@types/react-dom@19.1.5(@types/react@19.1.6))(@types/react@19.1.6)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.1.5(@types/react@19.1.6))(@types/react@19.1.6)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-popper': 1.2.7(@types/react-dom@19.1.6(@types/react@19.1.6))(@types/react@19.1.6)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-portal': 1.1.9(@types/react-dom@19.1.6(@types/react@19.1.6))(@types/react@19.1.6)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-presence': 1.1.4(@types/react-dom@19.1.6(@types/react@19.1.6))(@types/react@19.1.6)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.1.6(@types/react@19.1.6))(@types/react@19.1.6)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) '@radix-ui/react-slot': 1.2.3(@types/react@19.1.6)(react@19.1.0) '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.1.6)(react@19.1.0) aria-hidden: 1.2.6 @@ -4242,15 +4242,15 @@ snapshots: react-remove-scroll: 2.7.1(@types/react@19.1.6)(react@19.1.0) optionalDependencies: '@types/react': 19.1.6 - '@types/react-dom': 19.1.5(@types/react@19.1.6) + '@types/react-dom': 19.1.6(@types/react@19.1.6) - '@radix-ui/react-popper@1.2.7(@types/react-dom@19.1.5(@types/react@19.1.6))(@types/react@19.1.6)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': + '@radix-ui/react-popper@1.2.7(@types/react-dom@19.1.6(@types/react@19.1.6))(@types/react@19.1.6)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': dependencies: '@floating-ui/react-dom': 2.1.3(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - '@radix-ui/react-arrow': 1.1.7(@types/react-dom@19.1.5(@types/react@19.1.6))(@types/react@19.1.6)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-arrow': 1.1.7(@types/react-dom@19.1.6(@types/react@19.1.6))(@types/react@19.1.6)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.1.6)(react@19.1.0) '@radix-ui/react-context': 1.1.2(@types/react@19.1.6)(react@19.1.0) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.1.5(@types/react@19.1.6))(@types/react@19.1.6)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.1.6(@types/react@19.1.6))(@types/react@19.1.6)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.1.6)(react@19.1.0) '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.1.6)(react@19.1.0) '@radix-ui/react-use-rect': 1.1.1(@types/react@19.1.6)(react@19.1.0) @@ -4260,19 +4260,19 @@ snapshots: react-dom: 19.1.0(react@19.1.0) optionalDependencies: '@types/react': 19.1.6 - '@types/react-dom': 19.1.5(@types/react@19.1.6) + '@types/react-dom': 19.1.6(@types/react@19.1.6) - '@radix-ui/react-portal@1.1.9(@types/react-dom@19.1.5(@types/react@19.1.6))(@types/react@19.1.6)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': + '@radix-ui/react-portal@1.1.9(@types/react-dom@19.1.6(@types/react@19.1.6))(@types/react@19.1.6)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': dependencies: - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.1.5(@types/react@19.1.6))(@types/react@19.1.6)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.1.6(@types/react@19.1.6))(@types/react@19.1.6)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.1.6)(react@19.1.0) react: 19.1.0 react-dom: 19.1.0(react@19.1.0) optionalDependencies: '@types/react': 19.1.6 - '@types/react-dom': 19.1.5(@types/react@19.1.6) + '@types/react-dom': 19.1.6(@types/react@19.1.6) - '@radix-ui/react-presence@1.1.4(@types/react-dom@19.1.5(@types/react@19.1.6))(@types/react@19.1.6)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': + '@radix-ui/react-presence@1.1.4(@types/react-dom@19.1.6(@types/react@19.1.6))(@types/react@19.1.6)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': dependencies: '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.1.6)(react@19.1.0) '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.1.6)(react@19.1.0) @@ -4280,89 +4280,89 @@ snapshots: react-dom: 19.1.0(react@19.1.0) optionalDependencies: '@types/react': 19.1.6 - '@types/react-dom': 19.1.5(@types/react@19.1.6) + '@types/react-dom': 19.1.6(@types/react@19.1.6) - '@radix-ui/react-primitive@2.1.3(@types/react-dom@19.1.5(@types/react@19.1.6))(@types/react@19.1.6)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': + '@radix-ui/react-primitive@2.1.3(@types/react-dom@19.1.6(@types/react@19.1.6))(@types/react@19.1.6)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': dependencies: '@radix-ui/react-slot': 1.2.3(@types/react@19.1.6)(react@19.1.0) react: 19.1.0 react-dom: 19.1.0(react@19.1.0) optionalDependencies: '@types/react': 19.1.6 - '@types/react-dom': 19.1.5(@types/react@19.1.6) + '@types/react-dom': 19.1.6(@types/react@19.1.6) - '@radix-ui/react-progress@1.1.7(@types/react-dom@19.1.5(@types/react@19.1.6))(@types/react@19.1.6)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': + '@radix-ui/react-progress@1.1.7(@types/react-dom@19.1.6(@types/react@19.1.6))(@types/react@19.1.6)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': dependencies: '@radix-ui/react-context': 1.1.2(@types/react@19.1.6)(react@19.1.0) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.1.5(@types/react@19.1.6))(@types/react@19.1.6)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.1.6(@types/react@19.1.6))(@types/react@19.1.6)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) react: 19.1.0 react-dom: 19.1.0(react@19.1.0) optionalDependencies: '@types/react': 19.1.6 - '@types/react-dom': 19.1.5(@types/react@19.1.6) + '@types/react-dom': 19.1.6(@types/react@19.1.6) - '@radix-ui/react-roving-focus@1.1.10(@types/react-dom@19.1.5(@types/react@19.1.6))(@types/react@19.1.6)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': + '@radix-ui/react-roving-focus@1.1.10(@types/react-dom@19.1.6(@types/react@19.1.6))(@types/react@19.1.6)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': dependencies: '@radix-ui/primitive': 1.1.2 - '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.1.5(@types/react@19.1.6))(@types/react@19.1.6)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.1.6(@types/react@19.1.6))(@types/react@19.1.6)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.1.6)(react@19.1.0) '@radix-ui/react-context': 1.1.2(@types/react@19.1.6)(react@19.1.0) '@radix-ui/react-direction': 1.1.1(@types/react@19.1.6)(react@19.1.0) '@radix-ui/react-id': 1.1.1(@types/react@19.1.6)(react@19.1.0) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.1.5(@types/react@19.1.6))(@types/react@19.1.6)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.1.6(@types/react@19.1.6))(@types/react@19.1.6)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.1.6)(react@19.1.0) '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.1.6)(react@19.1.0) react: 19.1.0 react-dom: 19.1.0(react@19.1.0) optionalDependencies: '@types/react': 19.1.6 - '@types/react-dom': 19.1.5(@types/react@19.1.6) + '@types/react-dom': 19.1.6(@types/react@19.1.6) - '@radix-ui/react-scroll-area@1.2.9(@types/react-dom@19.1.5(@types/react@19.1.6))(@types/react@19.1.6)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': + '@radix-ui/react-scroll-area@1.2.9(@types/react-dom@19.1.6(@types/react@19.1.6))(@types/react@19.1.6)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': dependencies: '@radix-ui/number': 1.1.1 '@radix-ui/primitive': 1.1.2 '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.1.6)(react@19.1.0) '@radix-ui/react-context': 1.1.2(@types/react@19.1.6)(react@19.1.0) '@radix-ui/react-direction': 1.1.1(@types/react@19.1.6)(react@19.1.0) - '@radix-ui/react-presence': 1.1.4(@types/react-dom@19.1.5(@types/react@19.1.6))(@types/react@19.1.6)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.1.5(@types/react@19.1.6))(@types/react@19.1.6)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-presence': 1.1.4(@types/react-dom@19.1.6(@types/react@19.1.6))(@types/react@19.1.6)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.1.6(@types/react@19.1.6))(@types/react@19.1.6)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.1.6)(react@19.1.0) '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.1.6)(react@19.1.0) react: 19.1.0 react-dom: 19.1.0(react@19.1.0) optionalDependencies: '@types/react': 19.1.6 - '@types/react-dom': 19.1.5(@types/react@19.1.6) + '@types/react-dom': 19.1.6(@types/react@19.1.6) - '@radix-ui/react-select@2.2.5(@types/react-dom@19.1.5(@types/react@19.1.6))(@types/react@19.1.6)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': + '@radix-ui/react-select@2.2.5(@types/react-dom@19.1.6(@types/react@19.1.6))(@types/react@19.1.6)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': dependencies: '@radix-ui/number': 1.1.1 '@radix-ui/primitive': 1.1.2 - '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.1.5(@types/react@19.1.6))(@types/react@19.1.6)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.1.6(@types/react@19.1.6))(@types/react@19.1.6)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.1.6)(react@19.1.0) '@radix-ui/react-context': 1.1.2(@types/react@19.1.6)(react@19.1.0) '@radix-ui/react-direction': 1.1.1(@types/react@19.1.6)(react@19.1.0) - '@radix-ui/react-dismissable-layer': 1.1.10(@types/react-dom@19.1.5(@types/react@19.1.6))(@types/react@19.1.6)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-dismissable-layer': 1.1.10(@types/react-dom@19.1.6(@types/react@19.1.6))(@types/react@19.1.6)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) '@radix-ui/react-focus-guards': 1.1.2(@types/react@19.1.6)(react@19.1.0) - '@radix-ui/react-focus-scope': 1.1.7(@types/react-dom@19.1.5(@types/react@19.1.6))(@types/react@19.1.6)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-focus-scope': 1.1.7(@types/react-dom@19.1.6(@types/react@19.1.6))(@types/react@19.1.6)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) '@radix-ui/react-id': 1.1.1(@types/react@19.1.6)(react@19.1.0) - '@radix-ui/react-popper': 1.2.7(@types/react-dom@19.1.5(@types/react@19.1.6))(@types/react@19.1.6)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - '@radix-ui/react-portal': 1.1.9(@types/react-dom@19.1.5(@types/react@19.1.6))(@types/react@19.1.6)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.1.5(@types/react@19.1.6))(@types/react@19.1.6)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-popper': 1.2.7(@types/react-dom@19.1.6(@types/react@19.1.6))(@types/react@19.1.6)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-portal': 1.1.9(@types/react-dom@19.1.6(@types/react@19.1.6))(@types/react@19.1.6)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.1.6(@types/react@19.1.6))(@types/react@19.1.6)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) '@radix-ui/react-slot': 1.2.3(@types/react@19.1.6)(react@19.1.0) '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.1.6)(react@19.1.0) '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.1.6)(react@19.1.0) '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.1.6)(react@19.1.0) '@radix-ui/react-use-previous': 1.1.1(@types/react@19.1.6)(react@19.1.0) - '@radix-ui/react-visually-hidden': 1.2.3(@types/react-dom@19.1.5(@types/react@19.1.6))(@types/react@19.1.6)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-visually-hidden': 1.2.3(@types/react-dom@19.1.6(@types/react@19.1.6))(@types/react@19.1.6)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) aria-hidden: 1.2.6 react: 19.1.0 react-dom: 19.1.0(react@19.1.0) react-remove-scroll: 2.7.1(@types/react@19.1.6)(react@19.1.0) optionalDependencies: '@types/react': 19.1.6 - '@types/react-dom': 19.1.5(@types/react@19.1.6) + '@types/react-dom': 19.1.6(@types/react@19.1.6) '@radix-ui/react-slot@1.2.3(@types/react@19.1.6)(react@19.1.0)': dependencies: @@ -4371,25 +4371,25 @@ snapshots: optionalDependencies: '@types/react': 19.1.6 - '@radix-ui/react-tooltip@1.2.7(@types/react-dom@19.1.5(@types/react@19.1.6))(@types/react@19.1.6)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': + '@radix-ui/react-tooltip@1.2.7(@types/react-dom@19.1.6(@types/react@19.1.6))(@types/react@19.1.6)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': dependencies: '@radix-ui/primitive': 1.1.2 '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.1.6)(react@19.1.0) '@radix-ui/react-context': 1.1.2(@types/react@19.1.6)(react@19.1.0) - '@radix-ui/react-dismissable-layer': 1.1.10(@types/react-dom@19.1.5(@types/react@19.1.6))(@types/react@19.1.6)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-dismissable-layer': 1.1.10(@types/react-dom@19.1.6(@types/react@19.1.6))(@types/react@19.1.6)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) '@radix-ui/react-id': 1.1.1(@types/react@19.1.6)(react@19.1.0) - '@radix-ui/react-popper': 1.2.7(@types/react-dom@19.1.5(@types/react@19.1.6))(@types/react@19.1.6)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - '@radix-ui/react-portal': 1.1.9(@types/react-dom@19.1.5(@types/react@19.1.6))(@types/react@19.1.6)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - '@radix-ui/react-presence': 1.1.4(@types/react-dom@19.1.5(@types/react@19.1.6))(@types/react@19.1.6)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.1.5(@types/react@19.1.6))(@types/react@19.1.6)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-popper': 1.2.7(@types/react-dom@19.1.6(@types/react@19.1.6))(@types/react@19.1.6)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-portal': 1.1.9(@types/react-dom@19.1.6(@types/react@19.1.6))(@types/react@19.1.6)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-presence': 1.1.4(@types/react-dom@19.1.6(@types/react@19.1.6))(@types/react@19.1.6)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.1.6(@types/react@19.1.6))(@types/react@19.1.6)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) '@radix-ui/react-slot': 1.2.3(@types/react@19.1.6)(react@19.1.0) '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.1.6)(react@19.1.0) - '@radix-ui/react-visually-hidden': 1.2.3(@types/react-dom@19.1.5(@types/react@19.1.6))(@types/react@19.1.6)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-visually-hidden': 1.2.3(@types/react-dom@19.1.6(@types/react@19.1.6))(@types/react@19.1.6)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) react: 19.1.0 react-dom: 19.1.0(react@19.1.0) optionalDependencies: '@types/react': 19.1.6 - '@types/react-dom': 19.1.5(@types/react@19.1.6) + '@types/react-dom': 19.1.6(@types/react@19.1.6) '@radix-ui/react-use-callback-ref@1.1.1(@types/react@19.1.6)(react@19.1.0)': dependencies: @@ -4445,14 +4445,14 @@ snapshots: optionalDependencies: '@types/react': 19.1.6 - '@radix-ui/react-visually-hidden@1.2.3(@types/react-dom@19.1.5(@types/react@19.1.6))(@types/react@19.1.6)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': + '@radix-ui/react-visually-hidden@1.2.3(@types/react-dom@19.1.6(@types/react@19.1.6))(@types/react@19.1.6)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': dependencies: - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.1.5(@types/react@19.1.6))(@types/react@19.1.6)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.1.6(@types/react@19.1.6))(@types/react@19.1.6)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) react: 19.1.0 react-dom: 19.1.0(react@19.1.0) optionalDependencies: '@types/react': 19.1.6 - '@types/react-dom': 19.1.5(@types/react@19.1.6) + '@types/react-dom': 19.1.6(@types/react@19.1.6) '@radix-ui/rect@1.1.1': {} @@ -4709,7 +4709,7 @@ snapshots: dependencies: undici-types: 6.21.0 - '@types/react-dom@19.1.5(@types/react@19.1.6)': + '@types/react-dom@19.1.6(@types/react@19.1.6)': dependencies: '@types/react': 19.1.6 @@ -5070,8 +5070,8 @@ snapshots: browserslist@4.25.0: dependencies: - caniuse-lite: 1.0.30001720 - electron-to-chromium: 1.5.162 + caniuse-lite: 1.0.30001721 + electron-to-chromium: 1.5.165 node-releases: 2.0.19 update-browserslist-db: 1.1.3(browserslist@4.25.0) @@ -5103,7 +5103,7 @@ snapshots: callsites@3.1.0: {} - caniuse-lite@1.0.30001720: {} + caniuse-lite@1.0.30001721: {} chalk@4.1.2: dependencies: @@ -5151,12 +5151,12 @@ snapshots: clsx@2.1.1: {} - cmdk@1.1.1(@types/react-dom@19.1.5(@types/react@19.1.6))(@types/react@19.1.6)(react-dom@19.1.0(react@19.1.0))(react@19.1.0): + cmdk@1.1.1(@types/react-dom@19.1.6(@types/react@19.1.6))(@types/react@19.1.6)(react-dom@19.1.0(react@19.1.0))(react@19.1.0): dependencies: '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.1.6)(react@19.1.0) - '@radix-ui/react-dialog': 1.1.14(@types/react-dom@19.1.5(@types/react@19.1.6))(@types/react@19.1.6)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-dialog': 1.1.14(@types/react-dom@19.1.6(@types/react@19.1.6))(@types/react@19.1.6)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) '@radix-ui/react-id': 1.1.1(@types/react@19.1.6)(react@19.1.0) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.1.5(@types/react@19.1.6))(@types/react@19.1.6)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.1.6(@types/react@19.1.6))(@types/react@19.1.6)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) react: 19.1.0 react-dom: 19.1.0(react@19.1.0) transitivePeerDependencies: @@ -5273,7 +5273,7 @@ snapshots: dependencies: readable-stream: 2.3.8 - electron-to-chromium@1.5.162: {} + electron-to-chromium@1.5.165: {} emoji-regex@10.4.0: {} @@ -6193,7 +6193,7 @@ snapshots: '@swc/counter': 0.1.3 '@swc/helpers': 0.5.15 busboy: 1.6.0 - caniuse-lite: 1.0.30001720 + caniuse-lite: 1.0.30001721 postcss: 8.4.31 react: 19.1.0 react-dom: 19.1.0(react@19.1.0) @@ -6899,7 +6899,7 @@ snapshots: dependencies: safe-buffer: 5.2.1 - tw-animate-css@1.3.3: {} + tw-animate-css@1.3.4: {} type-check@0.4.0: dependencies: diff --git a/rename-binary.sh b/rename-binary.sh new file mode 100755 index 0000000..1327312 --- /dev/null +++ b/rename-binary.sh @@ -0,0 +1,21 @@ +#!/bin/bash + +# Determine file extension based on platform +if [[ "$OSTYPE" == "msys" || "$OSTYPE" == "win32" || "$OSTYPE" == "cygwin" ]]; then + EXT=".exe" +else + EXT="" +fi + +# Get Rust target triple +RUST_INFO=$(rustc -vV) +TARGET_TRIPLE=$(echo "$RUST_INFO" | grep -o 'host: [^ ]*' | cut -d' ' -f2) + +# Check if target triple was found +if [ -z "$TARGET_TRIPLE" ]; then + echo "Failed to determine platform target triple" >&2 + exit 1 +fi + +# Rename the file +mv "nodecar/dist/nodecar${EXT}" "src-tauri/binaries/nodecar-${TARGET_TRIPLE}${EXT}" \ No newline at end of file diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 7ed8d81..c97374a 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,9 +1,10 @@ [package] name = "donutbrowser" version = "0.2.5" -description = "Browser Orchestrator" +description = "Simple Yet Powerful Browser Orchestrator" authors = ["zhom@github"] edition = "2021" +default-run = "donutbrowser" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html diff --git a/src-tauri/donutbrowser.desktop b/src-tauri/donutbrowser.desktop new file mode 100644 index 0000000..67a0885 --- /dev/null +++ b/src-tauri/donutbrowser.desktop @@ -0,0 +1,13 @@ +[Desktop Entry] +Version=1.0 +Type=Application +Name=Donut Browser +Comment=Simple Yet Powerful Browser Orchestrator +Exec=donutbrowser %u +Icon=donutbrowser +StartupNotify=true +NoDisplay=false +Categories=Network;WebBrowser;Productivity; +MimeType=x-scheme-handler/http;x-scheme-handler/https;text/html;application/xhtml+xml; +StartupWMClass=donutbrowser +Keywords=browser;web;internet;productivity; \ No newline at end of file diff --git a/src-tauri/src/api_client.rs b/src-tauri/src/api_client.rs index b20cbd7..2f10e24 100644 --- a/src-tauri/src/api_client.rs +++ b/src-tauri/src/api_client.rs @@ -224,13 +224,13 @@ pub fn sort_github_releases(releases: &mut [GithubRelease]) { }); } -pub fn is_alpha_version(version: &str) -> bool { +pub fn is_nightly_version(version: &str) -> bool { let version_comp = VersionComponent::parse(version); version_comp.pre_release.is_some() } // Browser-specific alpha version detection for Zen Browser -pub fn is_zen_alpha_version(version: &str) -> bool { +pub fn is_zen_nightly_version(version: &str) -> bool { // For Zen Browser, only "twilight" is considered alpha/pre-release version.to_lowercase() == "twilight" } @@ -449,7 +449,7 @@ impl ApiClient { BrowserRelease { version: version.clone(), date: "".to_string(), // Cache doesn't store dates - is_prerelease: is_alpha_version(&version), + is_prerelease: is_nightly_version(&version), download_url: Some(format!( "{}/?product=firefox-{}&os=osx&lang=en-US", self.mozilla_download_base, version @@ -467,7 +467,7 @@ impl ApiClient { let response = self .client .get(url) - .header("User-Agent", "donutbrowser") + .header("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36") .send() .await?; @@ -534,7 +534,7 @@ impl ApiClient { BrowserRelease { version: version.clone(), date: "".to_string(), // Cache doesn't store dates - is_prerelease: is_alpha_version(&version), + is_prerelease: is_nightly_version(&version), download_url: Some(format!( "{}/?product=devedition-{}&os=osx&lang=en-US", self.mozilla_download_base, version @@ -552,7 +552,7 @@ impl ApiClient { let response = self .client .get(url) - .header("User-Agent", "donutbrowser") + .header("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36") .send() .await?; @@ -624,13 +624,13 @@ impl ApiClient { println!("Fetching Mullvad releases from GitHub API..."); let url = format!( - "{}/repos/mullvad/mullvad-browser/releases", + "{}/repos/mullvad/mullvad-browser/releases?per_page=100", self.github_api_base ); let releases = self .client .get(url) - .header("User-Agent", "donutbrowser") + .header("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36") .send() .await? .json::>() @@ -639,7 +639,7 @@ impl ApiClient { let mut releases: Vec = releases .into_iter() .map(|mut release| { - release.is_alpha = release.prerelease; + release.is_nightly = release.prerelease; release }) .collect(); @@ -670,13 +670,13 @@ impl ApiClient { println!("Fetching Zen releases from GitHub API..."); let url = format!( - "{}/repos/zen-browser/desktop/releases", + "{}/repos/zen-browser/desktop/releases?per_page=100", self.github_api_base ); let mut releases = self .client .get(url) - .header("User-Agent", "donutbrowser") + .header("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36") .send() .await? .json::>() @@ -684,8 +684,8 @@ impl ApiClient { // Check for twilight updates and mark alpha releases for release in &mut releases { - // Use browser-specific alpha detection for Zen Browser - release.is_alpha = is_zen_alpha_version(&release.tag_name) || release.prerelease; + // Use browser-specific alpha detection for Zen Browser - only "twilight" is nightly + release.is_nightly = is_zen_nightly_version(&release.tag_name); // Check for twilight update if this is a twilight release if release.tag_name.to_lowercase() == "twilight" { @@ -726,32 +726,32 @@ impl ApiClient { println!("Fetching Brave releases from GitHub API..."); let url = format!( - "{}/repos/brave/brave-browser/releases", + "{}/repos/brave/brave-browser/releases?per_page=100", self.github_api_base ); let releases = self .client .get(url) - .header("User-Agent", "donutbrowser") + .header("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36") .send() .await? .json::>() .await?; - // Filter releases that have universal macOS DMG assets + // Get platform info to filter appropriate releases + let (os, arch) = Self::get_platform_info(); + + // Filter releases that have assets compatible with the current platform let mut filtered_releases: Vec = releases .into_iter() .filter_map(|mut release| { - // Check if this release has a universal DMG asset - let has_universal_dmg = release - .assets - .iter() - .any(|asset| asset.name.contains(".dmg") && asset.name.contains("universal")); + // Check if this release has compatible assets for the current platform + let has_compatible_asset = Self::has_compatible_brave_asset(&release.assets, &os, &arch); - if has_universal_dmg { - // Set is_alpha based on the release name - // Nightly releases contain "Nightly", stable contain "Release" - release.is_alpha = release.name.to_lowercase().contains("nightly"); + if has_compatible_asset { + // Set is_nightly based on the release name + // Stable releases start with "Release", everything else is nightly + release.is_nightly = !release.name.starts_with("Release"); Some(release) } else { None @@ -772,6 +772,83 @@ impl ApiClient { Ok(filtered_releases) } + /// Check if a Brave release has compatible assets for the given platform and architecture + fn has_compatible_brave_asset( + assets: &[crate::browser::GithubAsset], + os: &str, + arch: &str, + ) -> bool { + match os { + "windows" => { + // For Windows, look for standalone setup EXE (not the auto-updater one) + assets + .iter() + .any(|asset| { + let name = asset.name.to_lowercase(); + name.contains("standalone") && name.ends_with(".exe") && !name.contains("silent") + }) + || assets.iter().any(|asset| asset.name.ends_with(".exe")) + } + "macos" => { + // For macOS, prefer universal DMG + assets + .iter() + .any(|asset| { + let name = asset.name.to_lowercase(); + name.contains("universal") && name.ends_with(".dmg") + }) + || assets.iter().any(|asset| asset.name.ends_with(".dmg")) + } + "linux" => { + // For Linux, check for architecture-specific packages (prefer ZIP for stable releases) + let arch_pattern = if arch == "arm64" { "arm64" } else { "amd64" }; + + assets + .iter() + .any(|asset| { + let name = asset.name.to_lowercase(); + name.contains("linux") && name.contains(arch_pattern) && name.ends_with(".zip") + }) + || assets.iter().any(|asset| { + let name = asset.name.to_lowercase(); + name.contains(arch_pattern) && (name.ends_with(".deb") || name.ends_with(".rpm")) + }) + || assets.iter().any(|asset| { + let name = asset.name.to_lowercase(); + name.contains("linux") && name.ends_with(".zip") + }) + || assets.iter().any(|asset| { + let name = asset.name.to_lowercase(); + name.ends_with(".deb") || name.ends_with(".rpm") + }) + } + _ => false, + } + } + + /// Get platform and architecture information + fn get_platform_info() -> (String, String) { + let os = if cfg!(target_os = "windows") { + "windows" + } else if cfg!(target_os = "linux") { + "linux" + } else if cfg!(target_os = "macos") { + "macos" + } else { + "unknown" + }; + + let arch = if cfg!(target_arch = "x86_64") { + "x64" + } else if cfg!(target_arch = "aarch64") { + "arm64" + } else { + "unknown" + }; + + (os.to_string(), arch.to_string()) + } + pub async fn fetch_chromium_latest_version( &self, ) -> Result> { @@ -785,7 +862,7 @@ impl ApiClient { let version = self .client .get(&url) - .header("User-Agent", "donutbrowser") + .header("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36") .send() .await? .text() @@ -885,7 +962,7 @@ impl ApiClient { let html = self .client .get(url) - .header("User-Agent", "donutbrowser") + .header("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36") .send() .await? .text() @@ -965,7 +1042,7 @@ impl ApiClient { let html = self .client .get(&url) - .header("User-Agent", "donutbrowser") + .header("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36") .send() .await? .text() @@ -1032,12 +1109,31 @@ impl ApiClient { Ok(false) // No update detected } + + pub fn clear_all_cache(&self) -> Result<(), Box> { + let cache_dir = Self::get_cache_dir()?; + + if cache_dir.exists() { + // Remove all cache files + for entry in fs::read_dir(&cache_dir)? { + let entry = entry?; + let path = entry.path(); + if path.is_file() { + fs::remove_file(&path)?; + println!("Removed cache file: {path:?}"); + } + } + println!("All version cache cleared successfully"); + } + + Ok(()) + } } #[cfg(test)] mod tests { use super::*; - use wiremock::matchers::{header, method, path}; + use wiremock::matchers::{method, path, query_param}; use wiremock::{Mock, MockServer, ResponseTemplate}; async fn setup_mock_server() -> MockServer { @@ -1215,7 +1311,6 @@ mod tests { Mock::given(method("GET")) .and(path("/firefox.json")) - .and(header("user-agent", "donutbrowser")) .respond_with( ResponseTemplate::new(200) .set_body_string(mock_response) @@ -1226,6 +1321,9 @@ mod tests { let result = client.fetch_firefox_releases_with_caching(true).await; + if let Err(e) = &result { + println!("Firefox API test error: {e}"); + } assert!(result.is_ok()); let releases = result.unwrap(); assert!(!releases.is_empty()); @@ -1259,7 +1357,6 @@ mod tests { Mock::given(method("GET")) .and(path("/devedition.json")) - .and(header("user-agent", "donutbrowser")) .respond_with( ResponseTemplate::new(200) .set_body_string(mock_response) @@ -1272,6 +1369,9 @@ mod tests { .fetch_firefox_developer_releases_with_caching(true) .await; + if let Err(e) = &result { + println!("Firefox Developer API test error: {e}"); + } assert!(result.is_ok()); let releases = result.unwrap(); assert!(!releases.is_empty()); @@ -1307,7 +1407,7 @@ mod tests { Mock::given(method("GET")) .and(path("/repos/mullvad/mullvad-browser/releases")) - .and(header("user-agent", "donutbrowser")) + .and(query_param("per_page", "100")) .respond_with( ResponseTemplate::new(200) .set_body_string(mock_response) @@ -1322,7 +1422,7 @@ mod tests { let releases = result.unwrap(); assert!(!releases.is_empty()); assert_eq!(releases[0].tag_name, "14.5a6"); - assert!(releases[0].is_alpha); + assert!(releases[0].is_nightly); } #[tokio::test] @@ -1348,7 +1448,7 @@ mod tests { Mock::given(method("GET")) .and(path("/repos/zen-browser/desktop/releases")) - .and(header("user-agent", "donutbrowser")) + .and(query_param("per_page", "100")) .respond_with( ResponseTemplate::new(200) .set_body_string(mock_response) @@ -1388,7 +1488,7 @@ mod tests { Mock::given(method("GET")) .and(path("/repos/brave/brave-browser/releases")) - .and(header("user-agent", "donutbrowser")) + .and(query_param("per_page", "100")) .respond_with( ResponseTemplate::new(200) .set_body_string(mock_response) @@ -1399,11 +1499,14 @@ mod tests { let result = client.fetch_brave_releases_with_caching(true).await; + if let Err(e) = &result { + println!("Brave API test error: {e}"); + } assert!(result.is_ok()); let releases = result.unwrap(); assert!(!releases.is_empty()); assert_eq!(releases[0].tag_name, "v1.81.9"); - assert!(!releases[0].is_alpha); + assert!(!releases[0].is_nightly); } #[tokio::test] @@ -1419,7 +1522,6 @@ mod tests { Mock::given(method("GET")) .and(path(format!("/{arch}/LAST_CHANGE"))) - .and(header("user-agent", "donutbrowser")) .respond_with( ResponseTemplate::new(200) .set_body_string("1465660") @@ -1448,7 +1550,6 @@ mod tests { Mock::given(method("GET")) .and(path(format!("/{arch}/LAST_CHANGE"))) - .and(header("user-agent", "donutbrowser")) .respond_with( ResponseTemplate::new(200) .set_body_string("1465660") @@ -1491,7 +1592,6 @@ mod tests { Mock::given(method("GET")) .and(path("/")) - .and(header("user-agent", "donutbrowser")) .respond_with( ResponseTemplate::new(200) .set_body_string(mock_html) @@ -1502,7 +1602,6 @@ mod tests { Mock::given(method("GET")) .and(path("/14.0.4/")) - .and(header("user-agent", "donutbrowser")) .respond_with( ResponseTemplate::new(200) .set_body_string(version_html) @@ -1513,7 +1612,6 @@ mod tests { Mock::given(method("GET")) .and(path("/14.0.3/")) - .and(header("user-agent", "donutbrowser")) .respond_with( ResponseTemplate::new(200) .set_body_string(version_html.replace("14.0.4", "14.0.3")) @@ -1551,7 +1649,6 @@ mod tests { Mock::given(method("GET")) .and(path("/14.0.4/")) - .and(header("user-agent", "donutbrowser")) .respond_with( ResponseTemplate::new(200) .set_body_string(version_html) @@ -1581,7 +1678,6 @@ mod tests { Mock::given(method("GET")) .and(path("/14.0.5/")) - .and(header("user-agent", "donutbrowser")) .respond_with( ResponseTemplate::new(200) .set_body_string(version_html) @@ -1597,24 +1693,24 @@ mod tests { } #[test] - fn test_is_alpha_version() { - assert!(is_alpha_version("1.2.3a1")); - assert!(is_alpha_version("137.0b5")); - assert!(is_alpha_version("140.0rc1")); - assert!(!is_alpha_version("139.0")); - assert!(!is_alpha_version("1.2.3")); + fn test_is_nightly_version() { + assert!(is_nightly_version("1.2.3a1")); + assert!(is_nightly_version("137.0b5")); + assert!(is_nightly_version("140.0rc1")); + assert!(!is_nightly_version("139.0")); + assert!(!is_nightly_version("1.2.3")); } #[test] - fn test_is_zen_alpha_version() { - // Only "twilight" should be considered alpha for Zen Browser - assert!(is_zen_alpha_version("twilight")); - assert!(is_zen_alpha_version("TWILIGHT")); // Case insensitive + fn test_is_zen_nightly_version() { + // Only "twilight" should be considered nightly for Zen Browser + assert!(is_zen_nightly_version("twilight")); + assert!(is_zen_nightly_version("TWILIGHT")); // Case insensitive - // Versions with "b" should NOT be considered alpha for Zen Browser - assert!(!is_zen_alpha_version("1.12.8b")); - assert!(!is_zen_alpha_version("1.0.0b1")); - assert!(!is_zen_alpha_version("2.0.0")); + // Versions with "b" should NOT be considered nightly for Zen Browser + assert!(!is_zen_nightly_version("1.12.8b")); + assert!(!is_zen_nightly_version("1.0.0b1")); + assert!(!is_zen_nightly_version("2.0.0")); } #[tokio::test] @@ -1624,7 +1720,6 @@ mod tests { Mock::given(method("GET")) .and(path("/firefox.json")) - .and(header("user-agent", "donutbrowser")) .respond_with(ResponseTemplate::new(404)) .mount(&server) .await; @@ -1640,7 +1735,6 @@ mod tests { Mock::given(method("GET")) .and(path("/firefox.json")) - .and(header("user-agent", "donutbrowser")) .respond_with( ResponseTemplate::new(200) .set_body_string("invalid json") @@ -1660,7 +1754,7 @@ mod tests { Mock::given(method("GET")) .and(path("/repos/zen-browser/desktop/releases")) - .and(header("user-agent", "donutbrowser")) + .and(query_param("per_page", "100")) .respond_with(ResponseTemplate::new(429).insert_header("retry-after", "60")) .mount(&server) .await; diff --git a/src-tauri/src/app_auto_updater.rs b/src-tauri/src/app_auto_updater.rs index 8db8d4f..d6c1ab2 100644 --- a/src-tauri/src/app_auto_updater.rs +++ b/src-tauri/src/app_auto_updater.rs @@ -156,7 +156,7 @@ impl AppAutoUpdater { let response = self .client .get(url) - .header("User-Agent", "donutbrowser") + .header("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36") .send() .await?; @@ -227,6 +227,45 @@ impl AppAutoUpdater { /// Get the appropriate download URL for the current platform fn get_download_url_for_platform(&self, assets: &[AppReleaseAsset]) -> Option { + println!("Looking for macOS universal binary assets"); + for asset in assets { + println!("Found asset: {}", asset.name); + } + + // Priority 1: Look for universal macOS DMG (preferred) + for asset in assets { + if asset.name.contains(".dmg") + && (asset.name.contains("universal") + || asset.name.contains("Universal") + || asset.name.contains("_universal.dmg") + || asset.name.contains("-universal.dmg") + || asset.name.contains("_universal_") + || asset.name.contains("-universal-")) + { + println!("Found universal binary: {}", asset.name); + return Some(asset.browser_download_url.clone()); + } + } + + // Priority 2: Look for generic macOS DMG without architecture specification + // This would be the case for universal binaries that don't explicitly mention "universal" + for asset in assets { + if asset.name.contains(".dmg") + && (asset.name.to_lowercase().contains("macos") + || asset.name.to_lowercase().contains("darwin")) + && !asset.name.contains("x64") + && !asset.name.contains("x86_64") + && !asset.name.contains("x86-64") + && !asset.name.contains("aarch64") + && !asset.name.contains("arm64") + && !asset.name.contains(".app.tar.gz") + { + println!("Found generic macOS DMG (likely universal): {}", asset.name); + return Some(asset.browser_download_url.clone()); + } + } + + // Priority 3: Fallback to current architecture-specific binary for backward compatibility let arch = if cfg!(target_arch = "aarch64") { "aarch64" } else if cfg!(target_arch = "x86_64") { @@ -235,12 +274,9 @@ impl AppAutoUpdater { "unknown" }; - println!("Looking for assets with architecture: {arch}"); - for asset in assets { - println!("Found asset: {}", asset.name); - } + println!("Falling back to architecture-specific search for: {arch}"); - // Priority 1: Look for exact architecture match in DMG + // Look for exact architecture match in DMG for asset in assets { if asset.name.contains(".dmg") && (asset.name.contains(&format!("_{arch}.dmg")) @@ -253,7 +289,7 @@ impl AppAutoUpdater { } } - // Priority 2: Look for x86_64 variations if we're looking for x64 + // Look for x86_64 variations if we're looking for x64 if arch == "x64" { for asset in assets { if asset.name.contains(".dmg") @@ -265,7 +301,7 @@ impl AppAutoUpdater { } } - // Priority 3: Look for arm64 variations if we're looking for aarch64 + // Look for arm64 variations if we're looking for aarch64 if arch == "aarch64" { for asset in assets { if asset.name.contains(".dmg") @@ -277,7 +313,7 @@ impl AppAutoUpdater { } } - // Priority 4: Fallback to any macOS DMG + // Priority 4: Final fallback to any macOS DMG for asset in assets { if asset.name.contains(".dmg") && (asset.name.to_lowercase().contains("macos") @@ -356,7 +392,7 @@ impl AppAutoUpdater { let response = self .client .get(download_url) - .header("User-Agent", "donutbrowser") + .header("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36") .send() .await?; @@ -390,7 +426,16 @@ impl AppAutoUpdater { .unwrap_or(""); match extension { - "dmg" => extractor.extract_dmg(archive_path, dest_dir).await, + "dmg" => { + #[cfg(target_os = "macos")] + { + extractor.extract_dmg(archive_path, dest_dir).await + } + #[cfg(not(target_os = "macos"))] + { + Err("DMG extraction is only supported on macOS".into()) + } + } "zip" => extractor.extract_zip(archive_path, dest_dir).await, _ => Err(format!("Unsupported archive format: {extension}").into()), } @@ -535,14 +580,6 @@ pub async fn download_and_install_app_update( .map_err(|e| format!("Failed to install app update: {e}")) } -#[tauri::command] -pub fn get_app_version_info() -> Result<(String, bool), String> { - Ok(( - AppAutoUpdater::get_current_version(), - AppAutoUpdater::is_nightly_build(), - )) -} - #[tauri::command] pub async fn check_for_app_updates_manual() -> Result, String> { println!("Manual app update check triggered"); @@ -651,14 +688,50 @@ mod tests { browser_download_url: "https://example.com/aarch64.dmg".to_string(), size: 12345, }, + AppReleaseAsset { + name: "Donut.Browser_0.1.0_universal.dmg".to_string(), + browser_download_url: "https://example.com/universal.dmg".to_string(), + size: 12345, + }, ]; let url = updater.get_download_url_for_platform(&assets); assert!(url.is_some()); - // The exact URL depends on the target architecture + // Should prefer universal binary over architecture-specific ones let url = url.unwrap(); - assert!(url.contains(".dmg")); + assert_eq!(url, "https://example.com/universal.dmg"); + + // Test with generic macOS DMG (no architecture specified) + let generic_assets = vec![AppReleaseAsset { + name: "Donut.Browser_0.1.0_macos.dmg".to_string(), + browser_download_url: "https://example.com/macos.dmg".to_string(), + size: 12345, + }]; + + let generic_url = updater.get_download_url_for_platform(&generic_assets); + assert!(generic_url.is_some()); + assert_eq!(generic_url.unwrap(), "https://example.com/macos.dmg"); + + // Test fallback to architecture-specific when no universal is available + let arch_specific_assets = vec![ + AppReleaseAsset { + name: "Donut.Browser_0.1.0_x64.dmg".to_string(), + browser_download_url: "https://example.com/x64.dmg".to_string(), + size: 12345, + }, + AppReleaseAsset { + name: "Donut.Browser_0.1.0_aarch64.dmg".to_string(), + browser_download_url: "https://example.com/aarch64.dmg".to_string(), + size: 12345, + }, + ]; + + let arch_url = updater.get_download_url_for_platform(&arch_specific_assets); + assert!(arch_url.is_some()); + // The exact URL depends on the target architecture, but should be one of the available ones + let arch_url = arch_url.unwrap(); + assert!(arch_url.contains(".dmg")); } #[test] diff --git a/src-tauri/src/auto_updater.rs b/src-tauri/src/auto_updater.rs index d75f2de..a8da61d 100644 --- a/src-tauri/src/auto_updater.rs +++ b/src-tauri/src/auto_updater.rs @@ -112,7 +112,7 @@ impl AutoUpdater { available_versions: &[BrowserVersionInfo], ) -> Result, Box> { let current_version = &profile.version; - let is_current_stable = !self.is_alpha_version(current_version); + let is_current_stable = !self.is_nightly_version(current_version); // Find the best available update let best_update = available_versions @@ -218,40 +218,6 @@ impl AutoUpdater { Ok(state.auto_update_downloads.contains(&download_key)) } - /// Start browser update process - pub async fn start_browser_update( - &self, - browser: &str, - new_version: &str, - ) -> Result<(), Box> { - // Add browser to disabled list to prevent conflicts during update - let mut state = self.load_auto_update_state()?; - state.disabled_browsers.insert(browser.to_string()); - - // Mark this download as auto-update for toast suppression - let download_key = format!("{browser}-{new_version}"); - state.auto_update_downloads.insert(download_key); - - self.save_auto_update_state(&state)?; - - // The actual download will be triggered by the frontend - // This function now just marks the browser as updating to prevent conflicts - Ok(()) - } - - /// Complete browser update process - pub async fn complete_browser_update( - &self, - browser: &str, - ) -> Result<(), Box> { - // Remove browser from disabled list - let mut state = self.load_auto_update_state()?; - state.disabled_browsers.remove(browser); - self.save_auto_update_state(&state)?; - - Ok(()) - } - /// Automatically update all affected profile versions after browser download pub async fn auto_update_profile_versions( &self, @@ -312,9 +278,51 @@ impl AutoUpdater { state.auto_update_downloads.remove(&download_key); self.save_auto_update_state(&state)?; + // Check if auto-delete of unused binaries is enabled and perform cleanup + let settings = self + .settings_manager + .load_settings() + .map_err(|e| format!("Failed to load settings: {e}"))?; + if settings.auto_delete_unused_binaries { + // Perform cleanup in the background - don't fail the update if cleanup fails + if let Err(e) = self.cleanup_unused_binaries_internal() { + eprintln!("Warning: Failed to cleanup unused binaries after auto-update: {e}"); + } + } + Ok(updated_profiles) } + /// Internal method to cleanup unused binaries (used by auto-cleanup) + fn cleanup_unused_binaries_internal( + &self, + ) -> Result, Box> { + // Load current profiles + let profiles = self + .browser_runner + .list_profiles() + .map_err(|e| format!("Failed to load profiles: {e}"))?; + + // Load registry + let mut registry = crate::downloaded_browsers::DownloadedBrowsersRegistry::load() + .map_err(|e| format!("Failed to load browser registry: {e}"))?; + + // Get active browser versions + let active_versions = registry.get_active_browser_versions(&profiles); + + // Cleanup unused binaries + let cleaned_up = registry + .cleanup_unused_binaries(&active_versions) + .map_err(|e| format!("Failed to cleanup unused binaries: {e}"))?; + + // Save updated registry + registry + .save() + .map_err(|e| format!("Failed to save registry: {e}"))?; + + Ok(cleaned_up) + } + /// Check if browser is disabled due to ongoing update pub fn is_browser_disabled( &self, @@ -337,7 +345,7 @@ impl AutoUpdater { // Helper methods - fn is_alpha_version(&self, version: &str) -> bool { + fn is_nightly_version(&self, version: &str) -> bool { version.contains("alpha") || version.contains("beta") || version.contains("rc") @@ -414,24 +422,6 @@ pub async fn check_for_browser_updates() -> Result, Stri Ok(grouped) } -#[tauri::command] -pub async fn start_browser_update(browser: String, new_version: String) -> Result<(), String> { - let updater = AutoUpdater::new(); - updater - .start_browser_update(&browser, &new_version) - .await - .map_err(|e| format!("Failed to start browser update: {e}")) -} - -#[tauri::command] -pub async fn complete_browser_update(browser: String) -> Result<(), String> { - let updater = AutoUpdater::new(); - updater - .complete_browser_update(&browser) - .await - .map_err(|e| format!("Failed to complete browser update: {e}")) -} - #[tauri::command] pub async fn is_browser_disabled_for_update(browser: String) -> Result { let updater = AutoUpdater::new(); @@ -509,18 +499,18 @@ mod tests { } #[test] - fn test_is_alpha_version() { + fn test_is_nightly_version() { let updater = AutoUpdater::new(); - assert!(updater.is_alpha_version("1.0.0-alpha")); - assert!(updater.is_alpha_version("1.0.0-beta")); - assert!(updater.is_alpha_version("1.0.0-rc")); - assert!(updater.is_alpha_version("1.0.0a1")); - assert!(updater.is_alpha_version("1.0.0b1")); - assert!(updater.is_alpha_version("1.0.0-dev")); + assert!(updater.is_nightly_version("1.0.0-alpha")); + assert!(updater.is_nightly_version("1.0.0-beta")); + assert!(updater.is_nightly_version("1.0.0-rc")); + assert!(updater.is_nightly_version("1.0.0a1")); + assert!(updater.is_nightly_version("1.0.0b1")); + assert!(updater.is_nightly_version("1.0.0-dev")); - assert!(!updater.is_alpha_version("1.0.0")); - assert!(!updater.is_alpha_version("1.2.3")); + assert!(!updater.is_nightly_version("1.0.0")); + assert!(!updater.is_nightly_version("1.2.3")); } #[test] diff --git a/src-tauri/src/browser.rs b/src-tauri/src/browser.rs index db187b5..54438d1 100644 --- a/src-tauri/src/browser.rs +++ b/src-tauri/src/browser.rs @@ -50,7 +50,6 @@ impl BrowserType { } pub trait Browser: Send + Sync { - fn browser_type(&self) -> BrowserType; fn get_executable_path(&self, install_dir: &Path) -> Result>; fn create_launch_args( &self, @@ -59,24 +58,17 @@ pub trait Browser: Send + Sync { url: Option, ) -> Result, Box>; fn is_version_downloaded(&self, version: &str, binaries_dir: &Path) -> bool; + fn prepare_executable(&self, executable_path: &Path) -> Result<(), Box>; } -pub struct FirefoxBrowser { - browser_type: BrowserType, -} +// Platform-specific modules +#[cfg(target_os = "macos")] +mod macos { + use super::*; -impl FirefoxBrowser { - pub fn new(browser_type: BrowserType) -> Self { - Self { browser_type } - } -} - -impl Browser for FirefoxBrowser { - fn browser_type(&self) -> BrowserType { - self.browser_type.clone() - } - - fn get_executable_path(&self, install_dir: &Path) -> Result> { + pub fn get_firefox_executable_path( + install_dir: &Path, + ) -> Result> { // Find the .app directory let app_path = std::fs::read_dir(install_dir)? .filter_map(Result::ok) @@ -106,6 +98,439 @@ impl Browser for FirefoxBrowser { Ok(executable_path) } + pub fn get_chromium_executable_path( + install_dir: &Path, + ) -> Result> { + // Find the .app directory + let app_path = std::fs::read_dir(install_dir)? + .filter_map(Result::ok) + .find(|entry| entry.path().extension().is_some_and(|ext| ext == "app")) + .ok_or("Browser app not found")?; + + // Construct the browser executable path + let mut executable_dir = app_path.path(); + executable_dir.push("Contents"); + executable_dir.push("MacOS"); + + // Find the first executable in the MacOS directory + let executable_path = std::fs::read_dir(&executable_dir)? + .filter_map(Result::ok) + .find(|entry| { + let binding = entry.file_name(); + let name = binding.to_string_lossy(); + name.contains("Chromium") || name.contains("Brave") || name.contains("Google Chrome") + }) + .map(|entry| entry.path()) + .ok_or("No executable found in MacOS directory")?; + + Ok(executable_path) + } + + pub fn is_firefox_version_downloaded(install_dir: &Path) -> bool { + // On macOS, check for .app files + if let Ok(entries) = std::fs::read_dir(install_dir) { + for entry in entries.flatten() { + if entry.path().extension().is_some_and(|ext| ext == "app") { + return true; + } + } + } + false + } + + pub fn is_chromium_version_downloaded(install_dir: &Path) -> bool { + // On macOS, check for .app files + if let Ok(entries) = std::fs::read_dir(install_dir) { + for entry in entries.flatten() { + if entry.path().extension().is_some_and(|ext| ext == "app") { + return true; + } + } + } + false + } + + pub fn prepare_executable(_executable_path: &Path) -> Result<(), Box> { + // On macOS, no special preparation needed + Ok(()) + } +} + +#[cfg(target_os = "linux")] +mod linux { + use super::*; + use std::os::unix::fs::PermissionsExt; + + pub fn get_firefox_executable_path( + install_dir: &Path, + browser_type: &BrowserType, + ) -> Result> { + // Expected structure: install_dir// + let browser_subdir = install_dir.join(browser_type.as_str()); + + // Try firefox first (preferred), then firefox-bin + let possible_executables = match browser_type { + BrowserType::Firefox | BrowserType::FirefoxDeveloper => { + vec![ + browser_subdir.join("firefox"), + browser_subdir.join("firefox-bin"), + ] + } + BrowserType::MullvadBrowser => { + vec![ + browser_subdir.join("firefox"), + browser_subdir.join("mullvad-browser"), + browser_subdir.join("firefox-bin"), + ] + } + BrowserType::Zen => { + vec![browser_subdir.join("zen"), browser_subdir.join("zen-bin")] + } + BrowserType::TorBrowser => { + vec![ + browser_subdir.join("firefox"), + browser_subdir.join("tor-browser"), + browser_subdir.join("firefox-bin"), + ] + } + _ => vec![], + }; + + for executable_path in &possible_executables { + if executable_path.exists() && executable_path.is_file() { + return Ok(executable_path.clone()); + } + } + + Err( + format!( + "Firefox executable not found in {}/{}", + install_dir.display(), + browser_type.as_str() + ) + .into(), + ) + } + + pub fn get_chromium_executable_path( + install_dir: &Path, + browser_type: &BrowserType, + ) -> Result> { + // Expected structure: install_dir// + let browser_subdir = install_dir.join(browser_type.as_str()); + + let possible_executables = match browser_type { + BrowserType::Chromium => vec![ + browser_subdir.join("chromium"), + browser_subdir.join("chrome"), + ], + BrowserType::Brave => vec![ + browser_subdir.join("brave"), + browser_subdir.join("brave-browser"), + ], + _ => vec![], + }; + + for executable_path in &possible_executables { + if executable_path.exists() && executable_path.is_file() { + return Ok(executable_path.clone()); + } + } + + Err( + format!( + "Chromium executable not found in {}/{}", + install_dir.display(), + browser_type.as_str() + ) + .into(), + ) + } + + pub fn is_firefox_version_downloaded(install_dir: &Path, browser_type: &BrowserType) -> bool { + // Expected structure: install_dir// + let browser_subdir = install_dir.join(browser_type.as_str()); + + if !browser_subdir.exists() || !browser_subdir.is_dir() { + return false; + } + + let possible_executables = match browser_type { + BrowserType::Firefox | BrowserType::FirefoxDeveloper => { + vec![ + browser_subdir.join("firefox-bin"), + browser_subdir.join("firefox"), + ] + } + BrowserType::MullvadBrowser => { + vec![ + browser_subdir.join("mullvad-browser"), + browser_subdir.join("firefox-bin"), + browser_subdir.join("firefox"), + ] + } + BrowserType::Zen => { + vec![browser_subdir.join("zen"), browser_subdir.join("zen-bin")] + } + BrowserType::TorBrowser => { + vec![ + browser_subdir.join("tor-browser"), + browser_subdir.join("firefox-bin"), + browser_subdir.join("firefox"), + ] + } + _ => vec![], + }; + + for exe_path in &possible_executables { + if exe_path.exists() && exe_path.is_file() { + return true; + } + } + + false + } + + pub fn is_chromium_version_downloaded(install_dir: &Path, browser_type: &BrowserType) -> bool { + // Expected structure: install_dir// + let browser_subdir = install_dir.join(browser_type.as_str()); + + if !browser_subdir.exists() || !browser_subdir.is_dir() { + return false; + } + + let possible_executables = match browser_type { + BrowserType::Chromium => vec![ + browser_subdir.join("chromium"), + browser_subdir.join("chrome"), + ], + BrowserType::Brave => vec![ + browser_subdir.join("brave"), + browser_subdir.join("brave-browser"), + ], + _ => vec![], + }; + + for exe_path in &possible_executables { + if exe_path.exists() && exe_path.is_file() { + return true; + } + } + + false + } + + pub fn prepare_executable(executable_path: &Path) -> Result<(), Box> { + // On Linux, ensure the executable has proper permissions + println!("Setting execute permissions for: {:?}", executable_path); + + let metadata = std::fs::metadata(executable_path)?; + let mut permissions = metadata.permissions(); + + // Add execute permissions for owner, group, and others + let mode = permissions.mode(); + permissions.set_mode(mode | 0o755); + + std::fs::set_permissions(executable_path, permissions)?; + + println!( + "Execute permissions set successfully for: {:?}", + executable_path + ); + Ok(()) + } +} + +#[cfg(target_os = "windows")] +mod windows { + use super::*; + + pub fn get_firefox_executable_path( + install_dir: &Path, + ) -> Result> { + // On Windows, look for firefox.exe + let possible_paths = [ + install_dir.join("firefox.exe"), + install_dir.join("firefox").join("firefox.exe"), + install_dir.join("bin").join("firefox.exe"), + ]; + + for path in &possible_paths { + if path.exists() && path.is_file() { + return Ok(path.clone()); + } + } + + // Look for any .exe file that might be the browser + if let Ok(entries) = std::fs::read_dir(install_dir) { + for entry in entries.flatten() { + let path = entry.path(); + if path.extension().is_some_and(|ext| ext == "exe") { + let name = path.file_stem().unwrap_or_default().to_string_lossy(); + if name.starts_with("firefox") + || name.starts_with("mullvad") + || name.starts_with("zen") + || name.starts_with("tor") + || name.contains("browser") + { + return Ok(path); + } + } + } + } + + Err("Firefox executable not found in Windows installation directory".into()) + } + + pub fn get_chromium_executable_path( + install_dir: &Path, + browser_type: &BrowserType, + ) -> Result> { + // On Windows, look for .exe files + let possible_paths = match browser_type { + BrowserType::Chromium => vec![ + install_dir.join("chromium.exe"), + install_dir.join("chrome.exe"), + install_dir.join("chromium-browser.exe"), + install_dir.join("bin").join("chromium.exe"), + ], + BrowserType::Brave => vec![ + install_dir.join("brave.exe"), + install_dir.join("brave-browser.exe"), + install_dir.join("bin").join("brave.exe"), + ], + _ => vec![], + }; + + for path in &possible_paths { + if path.exists() && path.is_file() { + return Ok(path.clone()); + } + } + + // Look for any .exe file that might be the browser + if let Ok(entries) = std::fs::read_dir(install_dir) { + for entry in entries.flatten() { + let path = entry.path(); + if path.extension().is_some_and(|ext| ext == "exe") { + let name = path.file_stem().unwrap_or_default().to_string_lossy(); + if name.contains("chromium") || name.contains("brave") || name.contains("chrome") { + return Ok(path); + } + } + } + } + + Err("Chromium/Brave executable not found in Windows installation directory".into()) + } + + pub fn is_firefox_version_downloaded(install_dir: &Path) -> bool { + // On Windows, check for .exe files + let possible_executables = [ + install_dir.join("firefox.exe"), + install_dir.join("firefox").join("firefox.exe"), + install_dir.join("bin").join("firefox.exe"), + ]; + + for exe_path in &possible_executables { + if exe_path.exists() && exe_path.is_file() { + return true; + } + } + + // Check for any .exe file that looks like a browser + if let Ok(entries) = std::fs::read_dir(install_dir) { + for entry in entries.flatten() { + let path = entry.path(); + + if path.extension().is_some_and(|ext| ext == "exe") { + let name = path.file_stem().unwrap_or_default().to_string_lossy(); + if name.starts_with("firefox") + || name.starts_with("mullvad") + || name.starts_with("zen") + || name.starts_with("tor") + || name.contains("browser") + { + return true; + } + } + } + } + + false + } + + pub fn is_chromium_version_downloaded(install_dir: &Path, browser_type: &BrowserType) -> bool { + // On Windows, check for .exe files + let possible_executables = match browser_type { + BrowserType::Chromium => vec![ + install_dir.join("chromium.exe"), + install_dir.join("chrome.exe"), + install_dir.join("chromium-browser.exe"), + install_dir.join("bin").join("chromium.exe"), + ], + BrowserType::Brave => vec![ + install_dir.join("brave.exe"), + install_dir.join("brave-browser.exe"), + install_dir.join("bin").join("brave.exe"), + ], + _ => vec![], + }; + + for exe_path in &possible_executables { + if exe_path.exists() && exe_path.is_file() { + return true; + } + } + + // Check for any .exe file that looks like the browser + if let Ok(entries) = std::fs::read_dir(install_dir) { + for entry in entries.flatten() { + let path = entry.path(); + + if path.extension().is_some_and(|ext| ext == "exe") { + let name = path.file_stem().unwrap_or_default().to_string_lossy(); + if name.contains("chromium") || name.contains("brave") || name.contains("chrome") { + return true; + } + } + } + } + + false + } + + pub fn prepare_executable(_executable_path: &Path) -> Result<(), Box> { + // On Windows, no special preparation needed + Ok(()) + } +} + +pub struct FirefoxBrowser { + browser_type: BrowserType, +} + +impl FirefoxBrowser { + pub fn new(browser_type: BrowserType) -> Self { + Self { browser_type } + } +} + +impl Browser for FirefoxBrowser { + fn get_executable_path(&self, install_dir: &Path) -> Result> { + #[cfg(target_os = "macos")] + return macos::get_firefox_executable_path(install_dir); + + #[cfg(target_os = "linux")] + return linux::get_firefox_executable_path(install_dir, &self.browser_type); + + #[cfg(target_os = "windows")] + return windows::get_firefox_executable_path(install_dir); + + #[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))] + Err("Unsupported platform".into()) + } + fn create_launch_args( &self, profile_path: &str, @@ -135,34 +560,52 @@ impl Browser for FirefoxBrowser { } fn is_version_downloaded(&self, version: &str, binaries_dir: &Path) -> bool { - let browser_dir = binaries_dir - .join(self.browser_type().as_str()) - .join(version); + // Expected structure: binaries// + let browser_dir = binaries_dir.join(self.browser_type.as_str()).join(version); println!("Firefox browser checking version {version} in directory: {browser_dir:?}"); - // Only check if directory exists and contains a .app file - if browser_dir.exists() { - println!("Directory exists, checking for .app files..."); - if let Ok(entries) = std::fs::read_dir(&browser_dir) { - for entry in entries.flatten() { - println!(" Found entry: {:?}", entry.path()); - if entry.path().extension().is_some_and(|ext| ext == "app") { - println!(" Found .app file: {:?}", entry.path()); - return true; - } - } - } - println!("No .app files found in directory"); - } else { + if !browser_dir.exists() { println!("Directory does not exist: {browser_dir:?}"); + return false; } - false + + println!("Directory exists, checking for browser files..."); + + #[cfg(target_os = "macos")] + return macos::is_firefox_version_downloaded(&browser_dir); + + #[cfg(target_os = "linux")] + return linux::is_firefox_version_downloaded(&browser_dir, &self.browser_type); + + #[cfg(target_os = "windows")] + return windows::is_firefox_version_downloaded(&browser_dir); + + #[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))] + { + println!("Unsupported platform for browser verification"); + false + } + } + + fn prepare_executable(&self, executable_path: &Path) -> Result<(), Box> { + #[cfg(target_os = "macos")] + return macos::prepare_executable(executable_path); + + #[cfg(target_os = "linux")] + return linux::prepare_executable(executable_path); + + #[cfg(target_os = "windows")] + return windows::prepare_executable(executable_path); + + #[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))] + Err("Unsupported platform".into()) } } // Chromium-based browsers (Chromium, Brave) pub struct ChromiumBrowser { + #[allow(dead_code)] browser_type: BrowserType, } @@ -173,34 +616,18 @@ impl ChromiumBrowser { } impl Browser for ChromiumBrowser { - fn browser_type(&self) -> BrowserType { - self.browser_type.clone() - } - fn get_executable_path(&self, install_dir: &Path) -> Result> { - // Find the .app directory - let app_path = std::fs::read_dir(install_dir)? - .filter_map(Result::ok) - .find(|entry| entry.path().extension().is_some_and(|ext| ext == "app")) - .ok_or("Browser app not found")?; + #[cfg(target_os = "macos")] + return macos::get_chromium_executable_path(install_dir); - // Construct the browser executable path - let mut executable_dir = app_path.path(); - executable_dir.push("Contents"); - executable_dir.push("MacOS"); + #[cfg(target_os = "linux")] + return linux::get_chromium_executable_path(install_dir, &self.browser_type); - // Find the first executable in the MacOS directory - let executable_path = std::fs::read_dir(&executable_dir)? - .filter_map(Result::ok) - .find(|entry| { - let binding = entry.file_name(); - let name = binding.to_string_lossy(); - name.contains("Chromium") || name.contains("Brave") || name.contains("Google Chrome") - }) - .map(|entry| entry.path()) - .ok_or("No executable found in MacOS directory")?; + #[cfg(target_os = "windows")] + return windows::get_chromium_executable_path(install_dir, &self.browser_type); - Ok(executable_path) + #[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))] + Err("Unsupported platform".into()) } fn create_launch_args( @@ -240,35 +667,46 @@ impl Browser for ChromiumBrowser { } fn is_version_downloaded(&self, version: &str, binaries_dir: &Path) -> bool { - let browser_dir = binaries_dir - .join(self.browser_type().as_str()) - .join(version); + // Expected structure: binaries// + let browser_dir = binaries_dir.join(self.browser_type.as_str()).join(version); println!("Chromium browser checking version {version} in directory: {browser_dir:?}"); - // Check if directory exists and contains at least one .app file - if browser_dir.exists() { - println!("Directory exists, checking for .app files..."); - if let Ok(entries) = std::fs::read_dir(&browser_dir) { - for entry in entries.flatten() { - println!(" Found entry: {:?}", entry.path()); - if entry.path().extension().is_some_and(|ext| ext == "app") { - println!(" Found .app file: {:?}", entry.path()); - // Try to get the executable path as a final verification - if self.get_executable_path(&browser_dir).is_ok() { - println!(" Executable path verification successful"); - return true; - } else { - println!(" Executable path verification failed"); - } - } - } - } - println!("No valid .app files found in directory"); - } else { + if !browser_dir.exists() { println!("Directory does not exist: {browser_dir:?}"); + return false; } - false + + println!("Directory exists, checking for browser files..."); + + #[cfg(target_os = "macos")] + return macos::is_chromium_version_downloaded(&browser_dir); + + #[cfg(target_os = "linux")] + return linux::is_chromium_version_downloaded(&browser_dir, &self.browser_type); + + #[cfg(target_os = "windows")] + return windows::is_chromium_version_downloaded(&browser_dir, &self.browser_type); + + #[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))] + { + println!("Unsupported platform for browser verification"); + false + } + } + + fn prepare_executable(&self, executable_path: &Path) -> Result<(), Box> { + #[cfg(target_os = "macos")] + return macos::prepare_executable(executable_path); + + #[cfg(target_os = "linux")] + return linux::prepare_executable(executable_path); + + #[cfg(target_os = "windows")] + return windows::prepare_executable(executable_path); + + #[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))] + Err("Unsupported platform".into()) } } @@ -294,7 +732,7 @@ pub struct GithubRelease { #[serde(default)] pub published_at: String, #[serde(default)] - pub is_alpha: bool, + pub is_nightly: bool, #[serde(default)] pub prerelease: bool, } @@ -354,56 +792,6 @@ mod tests { assert!(BrowserType::from_str("Firefox").is_err()); // Case sensitive } - #[test] - fn test_firefox_browser_creation() { - let browser = FirefoxBrowser::new(BrowserType::Firefox); - assert_eq!(browser.browser_type(), BrowserType::Firefox); - - let browser = FirefoxBrowser::new(BrowserType::MullvadBrowser); - assert_eq!(browser.browser_type(), BrowserType::MullvadBrowser); - - let browser = FirefoxBrowser::new(BrowserType::TorBrowser); - assert_eq!(browser.browser_type(), BrowserType::TorBrowser); - - let browser = FirefoxBrowser::new(BrowserType::Zen); - assert_eq!(browser.browser_type(), BrowserType::Zen); - } - - #[test] - fn test_chromium_browser_creation() { - let browser = ChromiumBrowser::new(BrowserType::Chromium); - assert_eq!(browser.browser_type(), BrowserType::Chromium); - - let browser = ChromiumBrowser::new(BrowserType::Brave); - assert_eq!(browser.browser_type(), BrowserType::Brave); - } - - #[test] - fn test_browser_factory() { - // Test Firefox-based browsers - let browser = create_browser(BrowserType::Firefox); - assert_eq!(browser.browser_type(), BrowserType::Firefox); - - let browser = create_browser(BrowserType::MullvadBrowser); - assert_eq!(browser.browser_type(), BrowserType::MullvadBrowser); - - let browser = create_browser(BrowserType::Zen); - assert_eq!(browser.browser_type(), BrowserType::Zen); - - let browser = create_browser(BrowserType::TorBrowser); - assert_eq!(browser.browser_type(), BrowserType::TorBrowser); - - let browser = create_browser(BrowserType::FirefoxDeveloper); - assert_eq!(browser.browser_type(), BrowserType::FirefoxDeveloper); - - // Test Chromium-based browsers - let browser = create_browser(BrowserType::Chromium); - assert_eq!(browser.browser_type(), BrowserType::Chromium); - - let browser = create_browser(BrowserType::Brave); - assert_eq!(browser.browser_type(), BrowserType::Brave); - } - #[test] fn test_firefox_launch_args() { // Test regular Firefox (should not use -no-remote) @@ -509,7 +897,7 @@ mod tests { let temp_dir = TempDir::new().unwrap(); let binaries_dir = temp_dir.path(); - // Create a mock Firefox browser installation + // Create a mock Firefox browser installation with new path structure: binaries/// let browser_dir = binaries_dir.join("firefox").join("139.0"); fs::create_dir_all(&browser_dir).unwrap(); @@ -521,7 +909,7 @@ mod tests { assert!(browser.is_version_downloaded("139.0", binaries_dir)); assert!(!browser.is_version_downloaded("140.0", binaries_dir)); - // Test with Chromium browser + // Test with Chromium browser with new path structure let chromium_dir = binaries_dir.join("chromium").join("1465660"); fs::create_dir_all(&chromium_dir).unwrap(); let chromium_app_dir = chromium_dir.join("Chromium.app"); @@ -544,7 +932,7 @@ mod tests { let temp_dir = TempDir::new().unwrap(); let binaries_dir = temp_dir.path(); - // Create browser directory but no .app directory + // Create browser directory but no .app directory with new path structure let browser_dir = binaries_dir.join("firefox").join("139.0"); fs::create_dir_all(&browser_dir).unwrap(); diff --git a/src-tauri/src/browser_runner.rs b/src-tauri/src/browser_runner.rs index 50b5c9e..b85bc99 100644 --- a/src-tauri/src/browser_runner.rs +++ b/src-tauri/src/browser_runner.rs @@ -70,6 +70,14 @@ mod macos { } } + pub async fn launch_browser_process( + executable_path: &std::path::Path, + args: &[String], + ) -> Result> { + println!("Launching browser on macOS: {executable_path:?} with args: {args:?}"); + Ok(Command::new(executable_path).args(args).spawn()?) + } + pub async fn open_url_in_existing_browser_firefox_like( profile: &BrowserProfile, url: &str, @@ -484,6 +492,17 @@ mod windows { false } + pub async fn launch_browser_process( + executable_path: &std::path::Path, + args: &[String], + ) -> Result> { + println!( + "Launching browser on Windows: {:?} with args: {:?}", + executable_path, args + ); + Ok(Command::new(executable_path).args(args).spawn()?) + } + pub async fn open_url_in_existing_browser_firefox_like( profile: &BrowserProfile, url: &str, @@ -580,6 +599,126 @@ mod linux { false } + pub async fn launch_browser_process( + executable_path: &std::path::Path, + args: &[String], + ) -> Result> { + println!( + "Launching browser on Linux: {:?} with args: {:?}", + executable_path, args + ); + + // Check if the executable exists and is executable + if !executable_path.exists() { + return Err(format!("Browser executable not found: {:?}", executable_path).into()); + } + + // Check if we can read the executable to detect architecture issues early + if let Err(e) = std::fs::File::open(executable_path) { + return Err(format!("Cannot access browser executable: {}", e).into()); + } + + // Ensure the executable has proper permissions + if let Err(e) = std::fs::metadata(executable_path) { + return Err(format!("Cannot get executable metadata: {}", e).into()); + } + + // On Linux, we might need to set LD_LIBRARY_PATH for some browsers + let mut cmd = Command::new(executable_path); + cmd.args(args); + + // For Firefox-based browsers, ensure library path includes the installation directory + if let Some(install_dir) = executable_path.parent() { + let mut ld_library_path = Vec::new(); + + // Add multiple potential library directories + let lib_dirs = [ + install_dir.join("lib"), + install_dir.join("../lib"), // Parent directory lib + install_dir.join("../../lib"), // Grandparent directory lib + install_dir.to_path_buf(), // Installation directory itself + ]; + + for lib_dir in &lib_dirs { + if lib_dir.exists() { + ld_library_path.push(lib_dir.to_string_lossy().to_string()); + } + } + + // For Firefox specifically, add common system library paths that might be needed + let firefox_lib_paths = [ + "/usr/lib/firefox", + "/usr/lib/x86_64-linux-gnu", + "/usr/lib/aarch64-linux-gnu", + "/lib/x86_64-linux-gnu", + "/lib/aarch64-linux-gnu", + ]; + + for lib_path in &firefox_lib_paths { + let path = std::path::Path::new(lib_path); + if path.exists() { + ld_library_path.push(lib_path.to_string()); + } + } + + // Preserve existing LD_LIBRARY_PATH + if let Ok(existing_path) = std::env::var("LD_LIBRARY_PATH") { + ld_library_path.push(existing_path); + } + + // Set the combined LD_LIBRARY_PATH + if !ld_library_path.is_empty() { + cmd.env("LD_LIBRARY_PATH", ld_library_path.join(":")); + println!("Set LD_LIBRARY_PATH to: {}", ld_library_path.join(":")); + } + } + + // Additional Linux-specific environment variables for better compatibility + cmd.env( + "DISPLAY", + std::env::var("DISPLAY").unwrap_or(":0".to_string()), + ); + + // Set MOZ_ENABLE_WAYLAND for better Wayland support + if std::env::var("WAYLAND_DISPLAY").is_ok() { + cmd.env("MOZ_ENABLE_WAYLAND", "1"); + } + + // Disable GPU acceleration if running in headless environments + if std::env::var("DISPLAY").is_err() || std::env::var("WAYLAND_DISPLAY").is_err() { + println!("No display detected, browser may fail to start"); + } + + // Attempt to spawn with better error handling for architecture issues + match cmd.spawn() { + Ok(child) => Ok(child), + Err(e) => { + // Detect architecture mismatch errors + if e.kind() == std::io::ErrorKind::Other { + let error_msg = e.to_string(); + if error_msg.contains("Exec format error") { + return Err(format!( + "Architecture mismatch: The browser executable is not compatible with your system architecture ({}). \ + This typically happens when trying to run x86_64 binaries on ARM64 systems. \ + Please use a browser that supports your architecture, such as Zen Browser or Brave. \ + Executable: {:?}", + std::env::consts::ARCH, + executable_path + ).into()); + } else if error_msg.contains("No such file or directory") { + return Err(format!( + "Executable or required library not found. This might be due to missing dependencies or incorrect executable path. \ + Try installing missing libraries or verify the browser installation. \ + Executable: {:?}, Error: {}", + executable_path, error_msg + ).into()); + } + } + Err(format!("Failed to launch browser: {}", e).into()) + } + } + } + pub async fn open_url_in_existing_browser_firefox_like( profile: &BrowserProfile, url: &str, @@ -854,9 +993,38 @@ impl BrowserRunner { // Save the updated profile self.save_profile(&profile)?; + // Check if auto-delete of unused binaries is enabled + let settings_manager = crate::settings_manager::SettingsManager::new(); + if let Ok(settings) = settings_manager.load_settings() { + if settings.auto_delete_unused_binaries { + // Perform cleanup in the background + let _ = self.cleanup_unused_binaries_internal(); + } + } + Ok(profile) } + /// Internal method to cleanup unused binaries (used by auto-cleanup) + fn cleanup_unused_binaries_internal(&self) -> Result, Box> { + // Load current profiles + let profiles = self.list_profiles()?; + + // Load registry + let mut registry = crate::downloaded_browsers::DownloadedBrowsersRegistry::load()?; + + // Get active browser versions + let active_versions = registry.get_active_browser_versions(&profiles); + + // Cleanup unused binaries + let cleaned_up = registry.cleanup_unused_binaries(&active_versions)?; + + // Save updated registry + registry.save()?; + + Ok(cleaned_up) + } + fn get_common_firefox_preferences(&self) -> Vec { vec![ // Disable default browser check @@ -1018,7 +1186,7 @@ impl BrowserRunner { .map_err(|_| format!("Invalid browser type: {}", profile.browser))?; let browser = create_browser(browser_type.clone()); - // Get executable path + // Get executable path - path structure: binaries/// let mut browser_dir = self.get_binaries_dir(); browser_dir.push(&profile.browser); browser_dir.push(&profile.version); @@ -1027,13 +1195,40 @@ impl BrowserRunner { .get_executable_path(&browser_dir) .expect("Failed to get executable path"); + // Prepare the executable (set permissions, etc.) + if let Err(e) = browser.prepare_executable(&executable_path) { + println!("Warning: Failed to prepare executable: {e}"); + // Continue anyway, the error might not be critical + } + // Get launch arguments let browser_args = browser .create_launch_args(&profile.profile_path, profile.proxy.as_ref(), url) .expect("Failed to create launch arguments"); - // Launch browser - let child = Command::new(executable_path).args(&browser_args).spawn()?; + // Launch browser using platform-specific method + let child = { + #[cfg(target_os = "macos")] + { + macos::launch_browser_process(&executable_path, &browser_args).await? + } + + #[cfg(target_os = "windows")] + { + windows::launch_browser_process(&executable_path, &browser_args).await? + } + + #[cfg(target_os = "linux")] + { + linux::launch_browser_process(&executable_path, &browser_args).await? + } + + #[cfg(not(any(target_os = "macos", target_os = "windows", target_os = "linux")))] + { + return Err("Unsupported platform for browser launching".into()); + } + }; + let launcher_pid = child.id(); println!( @@ -1159,7 +1354,7 @@ impl BrowserRunner { let browser_type = BrowserType::from_str(&updated_profile.browser) .map_err(|_| format!("Invalid browser type: {}", updated_profile.browser))?; - // Get browser directory for all platforms + // Get browser directory for all platforms - path structure: binaries/// let mut browser_dir = self.get_binaries_dir(); browser_dir.push(&updated_profile.browser); browser_dir.push(&updated_profile.version); @@ -1385,7 +1580,7 @@ impl BrowserRunner { profile.name = new_name.to_string(); // Create new paths - let _new_profile_file = profiles_dir.join(format!( + let _ = profiles_dir.join(format!( "{}.json", new_name.to_lowercase().replace(" ", "_") )); @@ -1410,44 +1605,6 @@ impl BrowserRunner { Ok(profile) } - pub fn get_saved_mullvad_releases(&self) -> Result, Box> { - let mut data_path = self.base_dirs.data_local_dir().to_path_buf(); - data_path.push(if cfg!(debug_assertions) { - "DonutBrowserDev" - } else { - "DonutBrowser" - }); - data_path.push("data"); - let releases_file = data_path.join("mullvad_releases.json"); - - if !releases_file.exists() { - return Ok(vec![]); - } - - let mut versions = Vec::new(); - let mut browser_dir = self.base_dirs.data_local_dir().to_path_buf(); - browser_dir.push(if cfg!(debug_assertions) { - "DonutBrowserDev" - } else { - "DonutBrowser" - }); - browser_dir.push("binaries"); - browser_dir.push("mullvad-browser"); - for entry in fs::read_dir(browser_dir)? { - let entry = entry?; - if entry.path().is_dir() { - if let Some(version_str) = entry.file_name().to_str() { - versions.push(version_str.to_string()); - } - } - } - - // Sort versions in descending order (newest first) - versions.sort_by(|a, b| b.cmp(a)); - - Ok(versions) - } - fn save_process_info(&self, profile: &BrowserProfile) -> Result<(), Box> { let profiles_dir = self.get_profiles_dir(); let profile_file = profiles_dir.join(format!( @@ -1477,6 +1634,15 @@ impl BrowserRunner { fs::remove_file(profile_file)? } + // Check if auto-delete of unused binaries is enabled + let settings_manager = crate::settings_manager::SettingsManager::new(); + if let Ok(settings) = settings_manager.load_settings() { + if settings.auto_delete_unused_binaries { + // Perform cleanup in the background after profile deletion + let _ = self.cleanup_unused_binaries_internal(); + } + } + Ok(()) } @@ -1802,7 +1968,16 @@ pub async fn launch_browser_profile( let updated_profile = browser_runner .launch_or_open_url(app_handle.clone(), &profile, url) .await - .expect("Failed to launch browser or open URL"); + .map_err(|e| { + // Check if this is an architecture compatibility issue + if let Some(io_error) = e.downcast_ref::() { + if io_error.kind() == std::io::ErrorKind::Other + && io_error.to_string().contains("Exec format error") { + return format!("Failed to launch browser: Executable format error. This browser version is not compatible with your system architecture ({}). Please try a different browser or version that supports your platform.", std::env::consts::ARCH); + } + } + format!("Failed to launch browser or open URL: {e}") + })?; // If the profile has proxy settings, start a proxy for it if let Some(proxy) = &profile.proxy { @@ -1839,15 +2014,6 @@ pub async fn launch_browser_profile( Ok(updated_profile) } -// Add Tauri command to get saved releases -#[tauri::command] -pub fn get_saved_mullvad_releases() -> Result, String> { - let browser_runner = BrowserRunner::new(); - browser_runner - .get_saved_mullvad_releases() - .map_err(|e| e.to_string()) -} - #[tauri::command] pub fn update_profile_proxy( profile_name: String, @@ -1903,27 +2069,17 @@ pub fn delete_profile(_app_handle: tauri::AppHandle, profile_name: String) -> Re } #[tauri::command] -pub fn get_supported_browsers() -> Result, String> { - Ok(vec![ - BrowserType::MullvadBrowser.as_str(), - BrowserType::Firefox.as_str(), - BrowserType::FirefoxDeveloper.as_str(), - BrowserType::Chromium.as_str(), - BrowserType::Brave.as_str(), - BrowserType::Zen.as_str(), - BrowserType::TorBrowser.as_str(), - ]) +pub fn get_supported_browsers() -> Result, String> { + let service = BrowserVersionService::new(); + Ok(service.get_supported_browsers()) } #[tauri::command] -pub async fn fetch_browser_versions_detailed( - browser_str: String, -) -> Result, String> { +pub fn is_browser_supported_on_platform(browser_str: String) -> Result { let service = BrowserVersionService::new(); service - .fetch_browser_versions_detailed(&browser_str, false) - .await - .map_err(|e| format!("Failed to fetch detailed browser versions: {e}")) + .is_browser_supported(&browser_str) + .map_err(|e| format!("Failed to check browser support: {e}")) } #[tauri::command] @@ -1996,20 +2152,6 @@ pub async fn fetch_browser_versions_with_count_cached_first( } } -#[tauri::command] -pub fn get_cached_browser_versions_detailed( - browser_str: String, -) -> Result>, String> { - let service = BrowserVersionService::new(); - Ok(service.get_cached_browser_versions_detailed(&browser_str)) -} - -#[tauri::command] -pub fn should_update_browser_cache(browser_str: String) -> Result { - let service = BrowserVersionService::new(); - Ok(service.should_update_cache(&browser_str)) -} - #[tauri::command] pub async fn download_browser( app_handle: tauri::AppHandle, @@ -2029,8 +2171,22 @@ pub async fn download_browser( return Ok(version); } - // Use the centralized browser version service for download info + // Check if browser is supported on current platform before attempting download let version_service = BrowserVersionService::new(); + + if !version_service + .is_browser_supported(&browser_str) + .unwrap_or(false) + { + return Err(format!( + "Browser '{}' is not supported on your platform ({} {}). Supported browsers: {}", + browser_str, + std::env::consts::OS, + std::env::consts::ARCH, + version_service.get_supported_browsers().join(", ") + )); + } + let download_info = version_service .get_download_info(&browser_str, &version) .map_err(|e| format!("Failed to get download info: {e}"))?; @@ -2203,15 +2359,6 @@ pub fn create_browser_profile_new( create_browser_profile(name, browser_type.as_str().to_string(), version, proxy) } -#[tauri::command] -pub async fn fetch_browser_versions(browser_str: String) -> Result, String> { - let service = BrowserVersionService::new(); - service - .fetch_browser_versions(&browser_str, false) - .await - .map_err(|e| format!("Failed to fetch browser versions: {e}")) -} - #[tauri::command] pub async fn fetch_browser_versions_with_count( browser_str: String, @@ -2230,6 +2377,14 @@ pub fn get_downloaded_browser_versions(browser_str: String) -> Result Result, String> { + let browser_runner = BrowserRunner::new(); + browser_runner + .cleanup_unused_binaries_internal() + .map_err(|e| format!("Failed to cleanup unused binaries: {e}")) +} + #[cfg(test)] mod tests { use super::*; @@ -2367,7 +2522,7 @@ mod tests { let (runner, _temp_dir) = create_test_browser_runner(); // Create profile - let _profile = runner + let _ = runner .create_profile("Original Name", "firefox", "139.0", None) .unwrap(); @@ -2387,7 +2542,7 @@ mod tests { let (runner, _temp_dir) = create_test_browser_runner(); // Create profile - let _profile = runner + let _ = runner .create_profile("To Delete", "firefox", "139.0", None) .unwrap(); @@ -2421,13 +2576,13 @@ mod tests { let (runner, _temp_dir) = create_test_browser_runner(); // Create multiple profiles - let _profile1 = runner + let _ = runner .create_profile("Profile 1", "firefox", "139.0", None) .unwrap(); - let _profile2 = runner + let _ = runner .create_profile("Profile 2", "chromium", "1465660", None) .unwrap(); - let _profile3 = runner + let _ = runner .create_profile("Profile 3", "brave", "v1.81.9", None) .unwrap(); @@ -2446,10 +2601,10 @@ mod tests { let (runner, _temp_dir) = create_test_browser_runner(); // Test that we can't rename to an existing profile name - let _profile1 = runner + let _ = runner .create_profile("Profile 1", "firefox", "139.0", None) .unwrap(); - let _profile2 = runner + let _ = runner .create_profile("Profile 2", "firefox", "139.0", None) .unwrap(); diff --git a/src-tauri/src/browser_version_service.rs b/src-tauri/src/browser_version_service.rs index 9b00f34..b6e7039 100644 --- a/src-tauri/src/browser_version_service.rs +++ b/src-tauri/src/browser_version_service.rs @@ -40,6 +40,70 @@ impl BrowserVersionService { Self { api_client } } + /// Check if a browser is supported on the current platform and architecture + pub fn is_browser_supported( + &self, + browser: &str, + ) -> Result> { + let (os, arch) = Self::get_platform_info(); + + match browser { + "firefox" | "firefox-developer" => Ok(true), + "mullvad-browser" => { + // Mullvad doesn't support ARM64 on Windows and Linux + if arch == "arm64" && (os == "windows" || os == "linux") { + Ok(false) + } else { + Ok(true) + } + } + "zen" => { + // Zen supports all platforms and architectures + Ok(true) + } + "brave" => { + // Brave supports all platforms and architectures + Ok(true) + } + "chromium" => { + // Chromium doesn't support ARM64 on Linux + if arch == "arm64" && os == "linux" { + Ok(false) + } else { + Ok(true) + } + } + "tor-browser" => { + // TOR Browser doesn't support ARM64 on Windows and Linux + if arch == "arm64" && (os == "windows" || os == "linux") { + Ok(false) + } else { + Ok(true) + } + } + _ => Err(format!("Unknown browser: {browser}").into()), + } + } + + /// Get list of browsers supported on the current platform + pub fn get_supported_browsers(&self) -> Vec { + let all_browsers = vec![ + "firefox", + "firefox-developer", + "mullvad-browser", + "zen", + "brave", + "chromium", + "tor-browser", + ]; + + all_browsers + .into_iter() + .filter(|browser| self.is_browser_supported(browser).unwrap_or(false)) + .map(|s| s.to_string()) + .collect() + } + /// Get cached browser versions immediately (returns None if no cache exists) pub fn get_cached_browser_versions(&self, browser: &str) -> Option> { self.api_client.load_cached_versions(browser) @@ -58,7 +122,7 @@ impl BrowserVersionService { .map(|version| { BrowserVersionInfo { version: version.clone(), - is_prerelease: crate::api_client::is_alpha_version(&version), + is_prerelease: crate::api_client::is_nightly_version(&version), date: "".to_string(), // Cache doesn't store dates } }) @@ -176,7 +240,7 @@ impl BrowserVersionService { } else { BrowserVersionInfo { version: version.clone(), - is_prerelease: crate::api_client::is_alpha_version(&version), + is_prerelease: crate::api_client::is_nightly_version(&version), date: "".to_string(), } } @@ -197,7 +261,7 @@ impl BrowserVersionService { } else { BrowserVersionInfo { version: version.clone(), - is_prerelease: crate::api_client::is_alpha_version(&version), + is_prerelease: crate::api_client::is_nightly_version(&version), date: "".to_string(), } } @@ -212,7 +276,7 @@ impl BrowserVersionService { if let Some(release) = releases.iter().find(|r| r.tag_name == version) { BrowserVersionInfo { version: release.tag_name.clone(), - is_prerelease: release.is_alpha, + is_prerelease: release.is_nightly, date: release.published_at.clone(), } } else { @@ -233,7 +297,7 @@ impl BrowserVersionService { if let Some(release) = releases.iter().find(|r| r.tag_name == version) { BrowserVersionInfo { version: release.tag_name.clone(), - is_prerelease: release.prerelease, + is_prerelease: release.is_nightly, date: release.published_at.clone(), } } else { @@ -254,7 +318,7 @@ impl BrowserVersionService { if let Some(release) = releases.iter().find(|r| r.tag_name == version) { BrowserVersionInfo { version: release.tag_name.clone(), - is_prerelease: release.prerelease, + is_prerelease: release.is_nightly, date: release.published_at.clone(), } } else { @@ -281,7 +345,7 @@ impl BrowserVersionService { } else { BrowserVersionInfo { version: version.clone(), - is_prerelease: false, // Chromium versions are usually stable + is_prerelease: false, // Chromium usually stable releases date: "".to_string(), } } @@ -302,7 +366,7 @@ impl BrowserVersionService { } else { BrowserVersionInfo { version: version.clone(), - is_prerelease: version.contains("alpha") || version.contains("rc"), + is_prerelease: false, // TOR Browser usually stable releases date: "".to_string(), } } @@ -355,151 +419,264 @@ impl BrowserVersionService { browser: &str, version: &str, ) -> Result> { + let (os, arch) = Self::get_platform_info(); + match browser { "firefox" => { - #[cfg(target_os = "macos")] - return Ok(DownloadInfo { - url: format!("https://download.mozilla.org/?product=firefox-{version}&os=osx&lang=en-US"), - filename: format!("firefox-{version}.dmg"), - is_archive: true, - }); + let os_param = match (&os[..], &arch[..]) { + ("windows", _) => "win64", + ("linux", "x64") => "linux64", + ("linux", "arm64") => "linux64-aarch64", + ("macos", _) => "osx", + _ => { + return Err( + format!("Unsupported platform/architecture for Firefox: {os}/{arch}").into(), + ) + } + }; - #[cfg(target_os = "windows")] - return Ok(DownloadInfo { - url: format!("https://download.mozilla.org/?product=firefox-{version}&os=win64&lang=en-US"), - filename: format!("firefox-{version}.exe"), - is_archive: false, - }); + let (filename, is_archive) = match os.as_str() { + "windows" => (format!("firefox-{version}.exe"), false), + "linux" => (format!("firefox-{version}.tar.xz"), true), + "macos" => (format!("firefox-{version}.dmg"), true), + _ => return Err(format!("Unsupported platform for Firefox: {os}").into()), + }; - #[cfg(target_os = "linux")] - return Ok(DownloadInfo { - url: format!("https://download.mozilla.org/?product=firefox-{version}&os=linux64&lang=en-US"), - filename: format!("firefox-{version}.tar.bz2"), - is_archive: true, - }); - - #[cfg(not(any(target_os = "macos", target_os = "windows", target_os = "linux")))] - return Err("Unsupported platform for Firefox".into()); - } - "firefox-developer" => { - #[cfg(target_os = "macos")] - return Ok(DownloadInfo { - url: format!("https://download.mozilla.org/?product=devedition-{version}&os=osx&lang=en-US"), - filename: format!("firefox-developer-{version}.dmg"), - is_archive: true, - }); - - #[cfg(target_os = "windows")] - return Ok(DownloadInfo { - url: format!("https://download.mozilla.org/?product=devedition-{version}&os=win64&lang=en-US"), - filename: format!("firefox-developer-{version}.exe"), - is_archive: false, - }); - - #[cfg(target_os = "linux")] - return Ok(DownloadInfo { - url: format!("https://download.mozilla.org/?product=devedition-{version}&os=linux64&lang=en-US"), - filename: format!("firefox-developer-{version}.tar.bz2"), - is_archive: true, - }); - - #[cfg(not(any(target_os = "macos", target_os = "windows", target_os = "linux")))] - return Err("Unsupported platform for Firefox Developer".into()); - } - "mullvad-browser" => { - #[cfg(target_os = "macos")] - return Ok(DownloadInfo { - url: format!( - "https://github.com/mullvad/mullvad-browser/releases/download/{version}/mullvad-browser-macos-{version}.dmg" - ), - filename: format!("mullvad-browser-{version}.dmg"), - is_archive: true, - }); - - #[cfg(target_os = "windows")] - return Ok(DownloadInfo { - url: format!( - "https://github.com/mullvad/mullvad-browser/releases/download/{version}/mullvad-browser-windows-{version}.exe" - ), - filename: format!("mullvad-browser-{version}.exe"), - is_archive: false, - }); - - #[cfg(target_os = "linux")] - return Ok(DownloadInfo { - url: format!( - "https://github.com/mullvad/mullvad-browser/releases/download/{version}/mullvad-browser-linux-{version}.tar.xz" - ), - filename: format!("mullvad-browser-{version}.tar.xz"), - is_archive: true, - }); - - #[cfg(not(any(target_os = "macos", target_os = "windows", target_os = "linux")))] - return Err("Unsupported platform for Mullvad Browser".into()); - } - "zen" => { - #[cfg(target_os = "macos")] - return Ok(DownloadInfo { - url: format!( - "https://github.com/zen-browser/desktop/releases/download/{version}/zen.macos-universal.dmg" - ), - filename: format!("zen-{version}.dmg"), - is_archive: true, - }); - - #[cfg(target_os = "windows")] - return Ok(DownloadInfo { - url: format!( - "https://github.com/zen-browser/desktop/releases/download/{version}/zen.win.x64.zip" - ), - filename: format!("zen-{version}.zip"), - is_archive: true, - }); - - #[cfg(target_os = "linux")] - return Ok(DownloadInfo { - url: format!( - "https://github.com/zen-browser/desktop/releases/download/{version}/zen.linux-x86_64.AppImage" - ), - filename: format!("zen-{version}.AppImage"), - is_archive: false, - }); - - #[cfg(not(any(target_os = "macos", target_os = "windows", target_os = "linux")))] - return Err("Unsupported platform for Zen Browser".into()); - } - "brave" => { - // For Brave, we use a placeholder URL since we need to resolve the actual asset URL dynamically - // The actual URL will be resolved in the download service using the GitHub API Ok(DownloadInfo { url: format!( - "https://github.com/brave/brave-browser/releases/download/{version}/Brave-Browser-universal.dmg" + "https://download.mozilla.org/?product=firefox-{version}&os={os_param}&lang=en-US" ), - filename: format!("brave-{version}.dmg"), - is_archive: true, + filename, + is_archive, + }) + } + "firefox-developer" => { + let os_param = match (&os[..], &arch[..]) { + ("windows", _) => "win64", + ("linux", "x64") => "linux64", + ("linux", "arm64") => "linux64-aarch64", + ("macos", _) => "osx", + _ => { + return Err( + format!("Unsupported platform/architecture for Firefox Developer: {os}/{arch}") + .into(), + ) + } + }; + + let (filename, is_archive) = match os.as_str() { + "windows" => (format!("firefox-developer-{version}.exe"), false), + "linux" => (format!("firefox-developer-{version}.tar.xz"), true), + "macos" => (format!("firefox-developer-{version}.dmg"), true), + _ => return Err(format!("Unsupported platform for Firefox Developer: {os}").into()), + }; + + Ok(DownloadInfo { + url: format!( + "https://download.mozilla.org/?product=firefox-devedition-{version}&os={os_param}&lang=en-US" + ), + filename, + is_archive, + }) + } + "mullvad-browser" => { + // Mullvad Browser doesn't support ARM64 on Windows and Linux + if arch == "arm64" && (os == "windows" || os == "linux") { + return Err(format!("Mullvad Browser doesn't support ARM64 on {os}").into()); + } + + let (platform_str, filename, is_archive) = match os.as_str() { + "windows" => { + if arch == "arm64" { + return Err("Mullvad Browser doesn't support ARM64 on Windows".into()); + } + ( + "windows-x86_64", + format!("mullvad-browser-windows-x86_64-{version}.exe"), + false, + ) + } + "linux" => { + if arch == "arm64" { + return Err("Mullvad Browser doesn't support ARM64 on Linux".into()); + } + ( + "x86_64", + format!("mullvad-browser-x86_64-{version}.tar.xz"), + true, + ) + } + "macos" => ( + "macos", + format!("mullvad-browser-macos-{version}.dmg"), + true, + ), + _ => return Err(format!("Unsupported platform for Mullvad Browser: {os}").into()), + }; + + Ok(DownloadInfo { + url: format!( + "https://github.com/mullvad/mullvad-browser/releases/download/{version}/mullvad-browser-{platform_str}-{version}{}", + if os == "windows" { ".exe" } else if os == "linux" { ".tar.xz" } else { ".dmg" } + ), + filename, + is_archive, + }) + } + "zen" => { + let (asset_name, filename, is_archive) = match (&os[..], &arch[..]) { + ("windows", "x64") => ("zen.installer.exe", format!("zen-{version}.exe"), false), + ("windows", "arm64") => ( + "zen.installer-arm64.exe", + format!("zen-{version}-arm64.exe"), + false, + ), + ("linux", "x64") => ( + "zen.linux-x86_64.tar.xz", + format!("zen-{version}-x86_64.tar.xz"), + true, + ), + ("linux", "arm64") => ( + "zen.linux-aarch64.tar.xz", + format!("zen-{version}-aarch64.tar.xz"), + true, + ), + ("macos", _) => ( + "zen.macos-universal.dmg", + format!("zen-{version}.dmg"), + true, + ), + _ => { + return Err(format!("Unsupported platform/architecture for Zen: {os}/{arch}").into()) + } + }; + + Ok(DownloadInfo { + url: format!( + "https://github.com/zen-browser/desktop/releases/download/{version}/{asset_name}" + ), + filename, + is_archive, + }) + } + "brave" => { + // Brave uses different asset naming conventions + // The actual URL will be resolved dynamically in the download service + let (filename, is_archive) = match (&os[..], &arch[..]) { + ("windows", _) => (format!("brave-{version}.exe"), false), + ("linux", "x64") => (format!("brave-browser-{version}-linux-amd64.zip"), true), + ("linux", "arm64") => (format!("brave-browser-{version}-linux-arm64.zip"), true), + ("macos", _) => (format!("Brave-Browser-universal.dmg"), true), + _ => { + return Err(format!("Unsupported platform/architecture for Brave: {os}/{arch}").into()) + } + }; + + Ok(DownloadInfo { + url: format!( + "https://github.com/brave/brave-browser/releases/download/{version}/brave-placeholder" + ), + filename, + is_archive, }) } "chromium" => { - let arch = if cfg!(target_arch = "aarch64") { "Mac_Arm" } else { "Mac" }; + let platform_str = match (&os[..], &arch[..]) { + ("windows", "x64") => "Win_x64", + ("windows", "arm64") => "Win_Arm64", + ("linux", "x64") => "Linux_x64", + ("linux", "arm64") => return Err("Chromium doesn't support ARM64 on Linux".into()), + ("macos", "x64") => "Mac", + ("macos", "arm64") => "Mac_Arm", + _ => { + return Err( + format!("Unsupported platform/architecture for Chromium: {os}/{arch}").into(), + ) + } + }; + + let (archive_name, filename) = match os.as_str() { + "windows" => ("chrome-win.zip", format!("chromium-{version}-win.zip")), + "linux" => ("chrome-linux.zip", format!("chromium-{version}-linux.zip")), + "macos" => ("chrome-mac.zip", format!("chromium-{version}-mac.zip")), + _ => return Err(format!("Unsupported platform for Chromium: {os}").into()), + }; + Ok(DownloadInfo { url: format!( - "https://commondatastorage.googleapis.com/chromium-browser-snapshots/{arch}/{version}/chrome-mac.zip" + "https://commondatastorage.googleapis.com/chromium-browser-snapshots/{platform_str}/{version}/{archive_name}" ), - filename: format!("chromium-{version}.zip"), + filename, is_archive: true, }) } - "tor-browser" => Ok(DownloadInfo { - url: format!( - "https://archive.torproject.org/tor-package-archive/torbrowser/{version}/tor-browser-macos-{version}.dmg" - ), - filename: format!("tor-browser-{version}.dmg"), - is_archive: true, - }), + "tor-browser" => { + // TOR Browser doesn't support ARM64 on Windows and Linux + if arch == "arm64" && (os == "windows" || os == "linux") { + return Err(format!("TOR Browser doesn't support ARM64 on {os}").into()); + } + + let (platform_str, filename, is_archive) = match os.as_str() { + "windows" => { + if arch == "arm64" { + return Err("TOR Browser doesn't support ARM64 on Windows".into()); + } + ( + "windows-x86_64-portable", + format!("tor-browser-windows-x86_64-portable-{version}.exe"), + false, + ) + } + "linux" => { + if arch == "arm64" { + return Err("TOR Browser doesn't support ARM64 on Linux".into()); + } + ( + "linux-x86_64", + format!("tor-browser-linux-x86_64-{version}.tar.xz"), + true, + ) + } + "macos" => ("macos", format!("tor-browser-macos-{version}.dmg"), true), + _ => return Err(format!("Unsupported platform for TOR Browser: {os}").into()), + }; + + Ok(DownloadInfo { + url: format!( + "https://archive.torproject.org/tor-package-archive/torbrowser/{version}/tor-browser-{platform_str}-{version}{}", + if os == "windows" { ".exe" } else if os == "linux" { ".tar.xz" } else { ".dmg" } + ), + filename, + is_archive, + }) + } _ => Err(format!("Unsupported browser: {browser}").into()), } } + /// Get platform and architecture information + fn get_platform_info() -> (String, String) { + let os = if cfg!(target_os = "windows") { + "windows" + } else if cfg!(target_os = "linux") { + "linux" + } else if cfg!(target_os = "macos") { + "macos" + } else { + "unknown" + }; + + let arch = if cfg!(target_arch = "x86_64") { + "x64" + } else if cfg!(target_arch = "aarch64") { + "arm64" + } else { + "unknown" + }; + + (os.to_string(), arch.to_string()) + } + // Private helper methods for each browser type async fn fetch_firefox_versions( @@ -634,7 +811,7 @@ impl BrowserVersionService { #[cfg(test)] mod tests { use super::*; - use wiremock::matchers::{header, method, path}; + use wiremock::matchers::{method, path, query_param}; use wiremock::{Mock, MockServer, ResponseTemplate}; async fn setup_mock_server() -> MockServer { @@ -692,7 +869,6 @@ mod tests { Mock::given(method("GET")) .and(path("/firefox.json")) - .and(header("user-agent", "donutbrowser")) .respond_with( ResponseTemplate::new(200) .set_body_string(mock_response) @@ -728,7 +904,6 @@ mod tests { Mock::given(method("GET")) .and(path("/devedition.json")) - .and(header("user-agent", "donutbrowser")) .respond_with( ResponseTemplate::new(200) .set_body_string(mock_response) @@ -770,7 +945,7 @@ mod tests { Mock::given(method("GET")) .and(path("/repos/mullvad/mullvad-browser/releases")) - .and(header("user-agent", "donutbrowser")) + .and(query_param("per_page", "100")) .respond_with( ResponseTemplate::new(200) .set_body_string(mock_response) @@ -812,7 +987,7 @@ mod tests { Mock::given(method("GET")) .and(path("/repos/zen-browser/desktop/releases")) - .and(header("user-agent", "donutbrowser")) + .and(query_param("per_page", "100")) .respond_with( ResponseTemplate::new(200) .set_body_string(mock_response) @@ -825,21 +1000,31 @@ mod tests { async fn setup_brave_mocks(server: &MockServer) { let mock_response = r#"[ { - "tag_name": "v1.81.9", - "name": "Brave Release 1.81.9", + "tag_name": "v1.79.119", + "name": "Release v1.79.119 (Chromium 137.0.7151.68)", "prerelease": false, "published_at": "2024-01-15T10:00:00Z", "assets": [ { - "name": "brave-v1.81.9-universal.dmg", - "browser_download_url": "https://example.com/brave-1.81.9-universal.dmg", + "name": "brave-v1.79.119-universal.dmg", + "browser_download_url": "https://example.com/brave-1.79.119-universal.dmg", "size": 200000000 + }, + { + "name": "brave-browser-1.79.119-linux-amd64.zip", + "browser_download_url": "https://example.com/brave-browser-1.79.119-linux-amd64.zip", + "size": 150000000 + }, + { + "name": "brave-browser-1.79.119-linux-arm64.zip", + "browser_download_url": "https://example.com/brave-browser-1.79.119-linux-arm64.zip", + "size": 145000000 } ] }, { "tag_name": "v1.81.8", - "name": "Brave Release 1.81.8", + "name": "Nightly v1.81.8", "prerelease": false, "published_at": "2024-01-10T10:00:00Z", "assets": [ @@ -854,7 +1039,7 @@ mod tests { Mock::given(method("GET")) .and(path("/repos/brave/brave-browser/releases")) - .and(header("user-agent", "donutbrowser")) + .and(query_param("per_page", "100")) .respond_with( ResponseTemplate::new(200) .set_body_string(mock_response) @@ -873,7 +1058,6 @@ mod tests { Mock::given(method("GET")) .and(path(format!("/{arch}/LAST_CHANGE"))) - .and(header("user-agent", "donutbrowser")) .respond_with( ResponseTemplate::new(200) .set_body_string("1465660") @@ -921,7 +1105,6 @@ mod tests { Mock::given(method("GET")) .and(path("/")) - .and(header("user-agent", "donutbrowser")) .respond_with( ResponseTemplate::new(200) .set_body_string(mock_html) @@ -932,7 +1115,6 @@ mod tests { Mock::given(method("GET")) .and(path("/14.0.4/")) - .and(header("user-agent", "donutbrowser")) .respond_with( ResponseTemplate::new(200) .set_body_string(version_html_144) @@ -943,7 +1125,6 @@ mod tests { Mock::given(method("GET")) .and(path("/14.0.3/")) - .and(header("user-agent", "donutbrowser")) .respond_with( ResponseTemplate::new(200) .set_body_string(version_html_143) @@ -954,7 +1135,6 @@ mod tests { Mock::given(method("GET")) .and(path("/14.0.2/")) - .and(header("user-agent", "donutbrowser")) .respond_with( ResponseTemplate::new(200) .set_body_string(version_html_142) @@ -966,7 +1146,7 @@ mod tests { #[tokio::test] async fn test_browser_version_service_creation() { - let _service = BrowserVersionService::new(); + let _ = BrowserVersionService::new(); // Test passes if we can create the service without panicking } @@ -1304,7 +1484,7 @@ mod tests { let mullvad_info = service .get_download_info("mullvad-browser", "14.5a6") .unwrap(); - assert_eq!(mullvad_info.filename, "mullvad-browser-14.5a6.dmg"); + assert_eq!(mullvad_info.filename, "mullvad-browser-macos-14.5a6.dmg"); assert!(mullvad_info.url.contains("mullvad-browser-macos-14.5a6")); assert!(mullvad_info.is_archive); @@ -1316,20 +1496,20 @@ mod tests { // Test Tor Browser let tor_info = service.get_download_info("tor-browser", "14.0.4").unwrap(); - assert_eq!(tor_info.filename, "tor-browser-14.0.4.dmg"); + assert_eq!(tor_info.filename, "tor-browser-macos-14.0.4.dmg"); assert!(tor_info.url.contains("tor-browser-macos-14.0.4")); assert!(tor_info.is_archive); // Test Chromium let chromium_info = service.get_download_info("chromium", "1465660").unwrap(); - assert_eq!(chromium_info.filename, "chromium-1465660.zip"); + assert_eq!(chromium_info.filename, "chromium-1465660-mac.zip"); assert!(chromium_info.url.contains("chrome-mac.zip")); assert!(chromium_info.is_archive); // Test Brave let brave_info = service.get_download_info("brave", "v1.81.9").unwrap(); assert_eq!(brave_info.filename, "brave-v1.81.9.dmg"); - assert!(brave_info.url.contains("Brave-Browser")); + assert!(brave_info.url.contains("brave-placeholder")); assert!(brave_info.is_archive); // Test unsupported browser diff --git a/src-tauri/src/default_browser.rs b/src-tauri/src/default_browser.rs index 06b02e9..9a2553d 100644 --- a/src-tauri/src/default_browser.rs +++ b/src-tauri/src/default_browser.rs @@ -77,13 +77,139 @@ mod windows { #[cfg(target_os = "linux")] mod linux { + use std::process::Command; + + const APP_DESKTOP_NAME: &str = "donutbrowser.desktop"; + pub fn is_default_browser() -> Result { - // Linux implementation would go here - Err("Linux support not implemented yet".to_string()) + // Check if xdg-mime is available + if !is_xdg_mime_available() { + return Err("xdg-mime utility not found. Please install xdg-utils package.".to_string()); + } + + let schemes = ["http", "https"]; + + for scheme in schemes { + let mime_type = format!("x-scheme-handler/{}", scheme); + + // Query the current default handler for this scheme + let output = Command::new("xdg-mime") + .args(["query", "default", &mime_type]) + .output() + .map_err(|e| format!("Failed to query default handler for {}: {}", scheme, e))?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(format!("xdg-mime query failed for {}: {}", scheme, stderr)); + } + + let current_handler = String::from_utf8_lossy(&output.stdout).trim().to_string(); + + // Check if our app is the default handler + if current_handler != APP_DESKTOP_NAME { + return Ok(false); + } + } + + Ok(true) } pub fn set_as_default_browser() -> Result<(), String> { - Err("Linux support not implemented yet".to_string()) + // Check if xdg-mime is available + if !is_xdg_mime_available() { + return Err("xdg-mime utility not found. Please install xdg-utils package.".to_string()); + } + + // Check if the desktop file exists in common locations + if !check_desktop_file_exists() { + return Err(format!( + "Desktop file '{}' not found in standard locations. Please ensure the application is properly installed. You can manually set Donut Browser as the default browser in your system settings.", + APP_DESKTOP_NAME + )); + } + + let schemes = ["http", "https"]; + let mut all_succeeded = true; + let mut error_messages = Vec::new(); + + for scheme in schemes { + let mime_type = format!("x-scheme-handler/{}", scheme); + + // Set our app as the default handler for this scheme + let output = Command::new("xdg-mime") + .args(["default", APP_DESKTOP_NAME, &mime_type]) + .output() + .map_err(|e| format!("Failed to set default handler for {}: {}", scheme, e))?; + + if !output.status.success() { + all_succeeded = false; + let stderr = String::from_utf8_lossy(&output.stderr); + error_messages.push(format!("Failed to set default for {}: {}", scheme, stderr)); + } + } + + if !all_succeeded { + return Err(format!( + "Some xdg-mime commands failed:\n{}\n\nYou may need to:\n1. Run with appropriate permissions\n2. Manually set the default browser in your desktop environment settings\n3. Restart your desktop session", + error_messages.join("\n") + )); + } + + // Give the system a moment to process the changes + std::thread::sleep(std::time::Duration::from_millis(500)); + + // Verify the changes took effect + match is_default_browser() { + Ok(true) => Ok(()), + Ok(false) => { + // This is the common case where commands succeed but verification fails + Err(format!( + "The xdg-mime commands completed successfully, but Donut Browser is not yet set as the default. This is common on some Linux distributions. Please try one of these options:\n\n1. Restart your desktop session and try again\n2. Log out and log back in\n3. Manually set Donut Browser as the default in your system settings:\n - GNOME: Settings > Default Applications > Web\n - KDE: System Settings > Applications > Default Applications > Web Browser\n - XFCE: Settings > Preferred Applications > Web Browser\n - Or run: xdg-settings set default-web-browser {}\n\nThe changes may take effect automatically after a desktop restart.", + APP_DESKTOP_NAME + )) + } + Err(e) => Err(format!( + "Set as default completed, but verification failed: {}. The changes may still be in effect after restarting your desktop session.", + e + )) + } + } + + fn is_xdg_mime_available() -> bool { + Command::new("which") + .arg("xdg-mime") + .output() + .map(|output| output.status.success()) + .unwrap_or(false) + } + + fn check_desktop_file_exists() -> bool { + let desktop_locations = [ + "~/.local/share/applications/", + "/usr/share/applications/", + "/usr/local/share/applications/", + "/var/lib/flatpak/exports/share/applications/", + "~/.local/share/flatpak/exports/share/applications/", + ]; + + for location in &desktop_locations { + let path = if location.starts_with('~') { + if let Ok(home) = std::env::var("HOME") { + location.replace('~', &home) + } else { + continue; + } + } else { + location.to_string() + }; + + let full_path = format!("{}{}", path, APP_DESKTOP_NAME); + if std::path::Path::new(&full_path).exists() { + return true; + } + } + + false } } diff --git a/src-tauri/src/download.rs b/src-tauri/src/download.rs index ee0a17f..1387050 100644 --- a/src-tauri/src/download.rs +++ b/src-tauri/src/download.rs @@ -51,7 +51,7 @@ impl Downloader { ) -> Result> { match browser_type { BrowserType::Brave => { - // For Brave, we need to find the actual macOS asset + // For Brave, we need to find the actual platform-specific asset let releases = self .api_client .fetch_brave_releases_with_caching(true) @@ -65,19 +65,20 @@ impl Downloader { }) .ok_or(format!("Brave version {version} not found"))?; - // Find the universal macOS DMG asset - let asset = release - .assets - .iter() - .find(|asset| asset.name.contains(".dmg") && asset.name.contains("universal")) + // Get platform and architecture info + let (os, arch) = Self::get_platform_info(); + + // Find the appropriate asset based on platform and architecture + let asset_url = self + .find_brave_asset(&release.assets, &os, &arch) .ok_or(format!( - "No universal macOS DMG asset found for Brave version {version}" + "No compatible asset found for Brave version {version} on {os}/{arch}" ))?; - Ok(asset.browser_download_url.clone()) + Ok(asset_url) } BrowserType::Zen => { - // For Zen, verify the asset exists + // For Zen, verify the asset exists and handle different naming patterns let releases = self .api_client .fetch_zen_releases_with_caching(true) @@ -88,16 +89,17 @@ impl Downloader { .find(|r| r.tag_name == version) .ok_or(format!("Zen version {version} not found"))?; - // Find the macOS universal DMG asset - let asset = release - .assets - .iter() - .find(|asset| asset.name == "zen.macos-universal.dmg") + // Get platform and architecture info + let (os, arch) = Self::get_platform_info(); + + // Find the appropriate asset + let asset_url = self + .find_zen_asset(&release.assets, &os, &arch) .ok_or(format!( - "No macOS universal asset found for Zen version {version}" + "No compatible asset found for Zen version {version} on {os}/{arch}" ))?; - Ok(asset.browser_download_url.clone()) + Ok(asset_url) } BrowserType::MullvadBrowser => { // For Mullvad, verify the asset exists @@ -111,16 +113,17 @@ impl Downloader { .find(|r| r.tag_name == version) .ok_or(format!("Mullvad version {version} not found"))?; - // Find the macOS DMG asset - let asset = release - .assets - .iter() - .find(|asset| asset.name.contains(".dmg") && asset.name.contains("mac")) + // Get platform and architecture info + let (os, arch) = Self::get_platform_info(); + + // Find the appropriate asset + let asset_url = self + .find_mullvad_asset(&release.assets, &os, &arch) .ok_or(format!( - "No macOS asset found for Mullvad version {version}" + "No compatible asset found for Mullvad version {version} on {os}/{arch}" ))?; - Ok(asset.browser_download_url.clone()) + Ok(asset_url) } _ => { // For other browsers, use the provided URL @@ -129,6 +132,202 @@ impl Downloader { } } + /// Get platform and architecture information + fn get_platform_info() -> (String, String) { + let os = if cfg!(target_os = "windows") { + "windows" + } else if cfg!(target_os = "linux") { + "linux" + } else if cfg!(target_os = "macos") { + "macos" + } else { + "unknown" + }; + + let arch = if cfg!(target_arch = "x86_64") { + "x64" + } else if cfg!(target_arch = "aarch64") { + "arm64" + } else { + "unknown" + }; + + (os.to_string(), arch.to_string()) + } + + /// Find the appropriate Brave asset for the current platform and architecture + fn find_brave_asset( + &self, + assets: &[crate::browser::GithubAsset], + os: &str, + arch: &str, + ) -> Option { + // Brave asset naming patterns: + // Windows: BraveBrowserStandaloneNightlySetup.exe, BraveBrowserStandaloneSilentNightlySetup.exe + // macOS: Brave-Browser-Nightly-universal.dmg, Brave-Browser-Nightly-universal.pkg + // Linux: brave-browser-1.79.119-linux-arm64.zip, brave-browser-1.79.119-linux-amd64.zip + + let asset = match os { + "windows" => { + // For Windows, look for standalone setup EXE (not the auto-updater one) + assets + .iter() + .find(|asset| { + let name = asset.name.to_lowercase(); + name.contains("standalone") && name.ends_with(".exe") && !name.contains("silent") + }) + .or_else(|| { + // Fallback to any EXE if standalone not found + assets.iter().find(|asset| asset.name.ends_with(".exe")) + }) + } + "macos" => { + // For macOS, prefer universal DMG + assets + .iter() + .find(|asset| { + let name = asset.name.to_lowercase(); + name.contains("universal") && name.ends_with(".dmg") + }) + .or_else(|| { + // Fallback to any DMG + assets.iter().find(|asset| asset.name.ends_with(".dmg")) + }) + } + "linux" => { + // For Linux, prefer ZIP files matching architecture (new format for stable releases) + let arch_pattern = if arch == "arm64" { "arm64" } else { "amd64" }; + + assets + .iter() + .find(|asset| { + let name = asset.name.to_lowercase(); + name.contains("linux") && name.contains(arch_pattern) && name.ends_with(".zip") + }) + .or_else(|| { + // Fallback to DEB packages + assets + .iter() + .find(|asset| { + let name = asset.name.to_lowercase(); + name.contains(arch_pattern) && name.ends_with(".deb") + }) + }) + .or_else(|| { + // Fallback to any ZIP + assets.iter().find(|asset| { + let name = asset.name.to_lowercase(); + name.contains("linux") && name.ends_with(".zip") + }) + }) + .or_else(|| { + // Fallback to any DEB + assets.iter().find(|asset| asset.name.ends_with(".deb")) + }) + .or_else(|| { + // Last fallback to RPM if no ZIP or DEB found + assets.iter().find(|asset| { + let name = asset.name.to_lowercase(); + name.contains("x86_64") && name.ends_with(".rpm") + }) + }) + } + _ => None, + }; + + asset.map(|a| a.browser_download_url.clone()) + } + + /// Find the appropriate Zen asset for the current platform and architecture + fn find_zen_asset( + &self, + assets: &[crate::browser::GithubAsset], + os: &str, + arch: &str, + ) -> Option { + // Zen asset naming patterns: + // Windows: zen.installer.exe, zen.installer-arm64.exe + // macOS: zen.macos-universal.dmg + // Linux: zen.linux-x86_64.tar.xz, zen.linux-aarch64.tar.xz, zen-x86_64.AppImage, zen-aarch64.AppImage + + let asset = match (os, arch) { + ("windows", "x64") => assets + .iter() + .find(|asset| asset.name == "zen.installer.exe"), + ("windows", "arm64") => assets + .iter() + .find(|asset| asset.name == "zen.installer-arm64.exe"), + ("macos", _) => assets + .iter() + .find(|asset| asset.name == "zen.macos-universal.dmg"), + ("linux", "x64") => { + // Prefer tar.xz, fallback to AppImage + assets + .iter() + .find(|asset| asset.name == "zen.linux-x86_64.tar.xz") + .or_else(|| { + assets + .iter() + .find(|asset| asset.name == "zen-x86_64.AppImage") + }) + } + ("linux", "arm64") => { + // Prefer tar.xz, fallback to AppImage + assets + .iter() + .find(|asset| asset.name == "zen.linux-aarch64.tar.xz") + .or_else(|| { + assets + .iter() + .find(|asset| asset.name == "zen-aarch64.AppImage") + }) + } + _ => None, + }; + + asset.map(|a| a.browser_download_url.clone()) + } + + /// Find the appropriate Mullvad asset for the current platform and architecture + fn find_mullvad_asset( + &self, + assets: &[crate::browser::GithubAsset], + os: &str, + arch: &str, + ) -> Option { + // Mullvad asset naming patterns: + // Windows: mullvad-browser-windows-x86_64-VERSION.exe + // macOS: mullvad-browser-macos-VERSION.dmg + // Linux: mullvad-browser-x86_64-VERSION.tar.xz + + let asset = match (os, arch) { + ("windows", "x64") => assets.iter().find(|asset| { + asset.name.contains("windows") + && asset.name.contains("x86_64") + && asset.name.ends_with(".exe") + }), + ("windows", "arm64") => { + // Mullvad doesn't support ARM64 on Windows + None + } + ("macos", _) => assets + .iter() + .find(|asset| asset.name.contains("macos") && asset.name.ends_with(".dmg")), + ("linux", "x64") => assets.iter().find(|asset| { + asset.name.contains("x86_64") + && asset.name.ends_with(".tar.xz") + && !asset.name.contains("windows") + }), + ("linux", "arm64") => { + // Mullvad doesn't support ARM64 on Linux + None + } + _ => None, + }; + + asset.map(|a| a.browser_download_url.clone()) + } + pub async fn download_browser( &self, app_handle: &tauri::AppHandle, @@ -170,7 +369,7 @@ impl Downloader { let response = self .client .get(&download_url) - .header("User-Agent", "donutbrowser") + .header("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36") .send() .await?; @@ -247,7 +446,7 @@ mod tests { use crate::browser_version_service::DownloadInfo; use tempfile::TempDir; - use wiremock::matchers::{header, method, path}; + use wiremock::matchers::{method, path, query_param}; use wiremock::{Mock, MockServer, ResponseTemplate}; async fn setup_mock_server() -> MockServer { @@ -290,7 +489,7 @@ mod tests { Mock::given(method("GET")) .and(path("/repos/brave/brave-browser/releases")) - .and(header("user-agent", "donutbrowser")) + .and(query_param("per_page", "100")) .respond_with( ResponseTemplate::new(200) .set_body_string(mock_response) @@ -338,7 +537,7 @@ mod tests { Mock::given(method("GET")) .and(path("/repos/zen-browser/desktop/releases")) - .and(header("user-agent", "donutbrowser")) + .and(query_param("per_page", "100")) .respond_with( ResponseTemplate::new(200) .set_body_string(mock_response) @@ -386,7 +585,7 @@ mod tests { Mock::given(method("GET")) .and(path("/repos/mullvad/mullvad-browser/releases")) - .and(header("user-agent", "donutbrowser")) + .and(query_param("per_page", "100")) .respond_with( ResponseTemplate::new(200) .set_body_string(mock_response) @@ -497,7 +696,7 @@ mod tests { Mock::given(method("GET")) .and(path("/repos/brave/brave-browser/releases")) - .and(header("user-agent", "donutbrowser")) + .and(query_param("per_page", "100")) .respond_with( ResponseTemplate::new(200) .set_body_string(mock_response) @@ -547,7 +746,7 @@ mod tests { Mock::given(method("GET")) .and(path("/repos/zen-browser/desktop/releases")) - .and(header("user-agent", "donutbrowser")) + .and(query_param("per_page", "100")) .respond_with( ResponseTemplate::new(200) .set_body_string(mock_response) @@ -570,7 +769,7 @@ mod tests { assert!(result .unwrap_err() .to_string() - .contains("No macOS universal asset found")); + .contains("No compatible asset found")); } #[tokio::test] @@ -589,7 +788,6 @@ mod tests { // Mock the download endpoint Mock::given(method("GET")) .and(path("/test-download")) - .and(header("user-agent", "donutbrowser")) .respond_with( ResponseTemplate::new(200) .set_body_bytes(test_content) @@ -640,7 +838,6 @@ mod tests { // Mock a 404 response Mock::given(method("GET")) .and(path("/missing-file")) - .and(header("user-agent", "donutbrowser")) .respond_with(ResponseTemplate::new(404)) .mount(&server) .await; @@ -691,7 +888,7 @@ mod tests { Mock::given(method("GET")) .and(path("/repos/mullvad/mullvad-browser/releases")) - .and(header("user-agent", "donutbrowser")) + .and(query_param("per_page", "100")) .respond_with( ResponseTemplate::new(200) .set_body_string(mock_response) @@ -714,7 +911,7 @@ mod tests { assert!(result .unwrap_err() .to_string() - .contains("No macOS asset found")); + .contains("No compatible asset found")); } #[tokio::test] @@ -741,7 +938,7 @@ mod tests { Mock::given(method("GET")) .and(path("/repos/brave/brave-browser/releases")) - .and(header("user-agent", "donutbrowser")) + .and(query_param("per_page", "100")) .respond_with( ResponseTemplate::new(200) .set_body_string(mock_response) @@ -780,7 +977,6 @@ mod tests { Mock::given(method("GET")) .and(path("/chunked-download")) - .and(header("user-agent", "donutbrowser")) .respond_with( ResponseTemplate::new(200) .set_body_bytes(test_content.clone()) diff --git a/src-tauri/src/downloaded_browsers.rs b/src-tauri/src/downloaded_browsers.rs index f0d4248..5a9c627 100644 --- a/src-tauri/src/downloaded_browsers.rs +++ b/src-tauri/src/downloaded_browsers.rs @@ -175,6 +175,48 @@ impl DownloadedBrowsersRegistry { } Ok(()) } + + /// Find and remove unused browser binaries that are not referenced by any active profiles + pub fn cleanup_unused_binaries( + &mut self, + active_profiles: &[(String, String)], // (browser, version) pairs + ) -> Result, Box> { + let active_set: std::collections::HashSet<(String, String)> = + active_profiles.iter().cloned().collect(); + let mut cleaned_up = Vec::new(); + + // Collect all downloaded browsers that are not in active profiles + let mut to_remove = Vec::new(); + for (browser, versions) in &self.browsers { + for (version, info) in versions { + if info.verified && !active_set.contains(&(browser.clone(), version.clone())) { + to_remove.push((browser.clone(), version.clone())); + } + } + } + + // Remove unused binaries + for (browser, version) in to_remove { + if let Err(e) = self.cleanup_failed_download(&browser, &version) { + eprintln!("Failed to cleanup unused binary {browser}:{version}: {e}"); + } else { + cleaned_up.push(format!("{browser} {version}")); + } + } + + Ok(cleaned_up) + } + + /// Get all browsers and versions referenced by active profiles + pub fn get_active_browser_versions( + &self, + profiles: &[crate::browser_runner::BrowserProfile], + ) -> Vec<(String, String)> { + profiles + .iter() + .map(|profile| (profile.browser.clone(), profile.version.clone())) + .collect() + } } #[cfg(test)] diff --git a/src-tauri/src/extraction.rs b/src-tauri/src/extraction.rs index a6fdffc..502f34c 100644 --- a/src-tauri/src/extraction.rs +++ b/src-tauri/src/extraction.rs @@ -34,18 +34,146 @@ impl Extractor { }; let _ = app_handle.emit("download-progress", &progress); - let extension = archive_path - .extension() - .and_then(|ext| ext.to_str()) - .unwrap_or(""); + // Try to detect the actual file type by reading the file header + let actual_format = self.detect_file_format(archive_path)?; - match extension { - "dmg" => self.extract_dmg(archive_path, dest_dir).await, + match actual_format.as_str() { + "dmg" => { + #[cfg(target_os = "macos")] + return self.extract_dmg(archive_path, dest_dir).await; + + #[cfg(not(target_os = "macos"))] + return Err("DMG extraction is only supported on macOS".into()); + } "zip" => self.extract_zip(archive_path, dest_dir).await, - _ => Err(format!("Unsupported archive format: {extension}").into()), + "tar.xz" => self.extract_tar_xz(archive_path, dest_dir).await, + "tar.bz2" => self.extract_tar_bz2(archive_path, dest_dir).await, + "tar.gz" => self.extract_tar_gz(archive_path, dest_dir).await, + "exe" => { + // For Windows EXE files, some may be self-extracting archives, others are installers + // For browsers like Firefox, TOR, they're typically installers that don't need extraction + self + .handle_exe_file(archive_path, dest_dir, browser_type) + .await + } + "deb" => { + #[cfg(target_os = "linux")] + return self.extract_deb(archive_path, dest_dir).await; + + #[cfg(not(target_os = "linux"))] + return Err("DEB extraction is only supported on Linux".into()); + } + "appimage" => { + #[cfg(target_os = "linux")] + return self.handle_appimage(archive_path, dest_dir).await; + + #[cfg(not(target_os = "linux"))] + return Err("AppImage is only supported on Linux".into()); + } + _ => { + Err(format!( + "Unsupported archive format: {} (detected: {}). The downloaded file might be corrupted or in an unexpected format.", + archive_path.extension().and_then(|ext| ext.to_str()).unwrap_or("unknown"), + actual_format + ).into()) + } } } + /// Detect the actual file format by reading file headers + fn detect_file_format( + &self, + file_path: &Path, + ) -> Result> { + use std::fs::File; + use std::io::Read; + + let mut file = File::open(file_path)?; + let mut buffer = [0u8; 12]; // Read first 12 bytes for magic number detection + file.read_exact(&mut buffer)?; + + // Check magic numbers for different file types + match &buffer[0..4] { + [0x50, 0x4B, 0x03, 0x04] | [0x50, 0x4B, 0x05, 0x06] | [0x50, 0x4B, 0x07, 0x08] => { + return Ok("zip".to_string()) + } + [0x7F, 0x45, 0x4C, 0x46] => return Ok("appimage".to_string()), // ELF header (AppImage) + [0x4D, 0x5A, _, _] => return Ok("exe".to_string()), // PE header (Windows EXE) + _ => {} + } + + // Check for XZ compressed files + if buffer[0..6] == [0xFD, 0x37, 0x7A, 0x58, 0x5A, 0x00] { + return Ok("tar.xz".to_string()); + } + + // Check for Bzip2 compressed files + if buffer[0..3] == [0x42, 0x5A, 0x68] { + return Ok("tar.bz2".to_string()); + } + + // Check for Gzip compressed files + if buffer[0..3] == [0x1F, 0x8B, 0x08] { + return Ok("tar.gz".to_string()); + } + + // Check for DEB files + if buffer[0..8] == [0x21, 0x3C, 0x61, 0x72, 0x63, 0x68, 0x3E, 0x0A] { + return Ok("deb".to_string()); + } + + // Fallback to file extension + if let Some(ext) = file_path.extension().and_then(|ext| ext.to_str()) { + match ext.to_lowercase().as_str() { + "dmg" => Ok("dmg".to_string()), + "zip" => Ok("zip".to_string()), + "xz" => { + if file_path + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or("") + .ends_with(".tar.xz") + { + Ok("tar.xz".to_string()) + } else { + Ok("xz".to_string()) + } + } + "bz2" => { + if file_path + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or("") + .ends_with(".tar.bz2") + { + Ok("tar.bz2".to_string()) + } else { + Ok("bz2".to_string()) + } + } + "gz" => { + if file_path + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or("") + .ends_with(".tar.gz") + { + Ok("tar.gz".to_string()) + } else { + Ok("gz".to_string()) + } + } + "exe" => Ok("exe".to_string()), + "deb" => Ok("deb".to_string()), + "appimage" => Ok("appimage".to_string()), + _ => Ok("unknown".to_string()), + } + } else { + Ok("unknown".to_string()) + } + } + + #[cfg(target_os = "macos")] pub async fn extract_dmg( &self, dmg_path: &Path, @@ -154,7 +282,56 @@ impl Extractor { zip_path: &Path, dest_dir: &Path, ) -> Result> { - // Use unzip command to extract + // Platform-specific ZIP extraction + #[cfg(target_os = "windows")] + { + self.extract_zip_windows(zip_path, dest_dir).await + } + + #[cfg(not(target_os = "windows"))] + { + self.extract_zip_unix(zip_path, dest_dir).await + } + } + + #[cfg(target_os = "windows")] + async fn extract_zip_windows( + &self, + zip_path: &Path, + dest_dir: &Path, + ) -> Result> { + // Use PowerShell's Expand-Archive on Windows + let output = Command::new("powershell") + .args([ + "-Command", + &format!( + "Expand-Archive -Path '{}' -DestinationPath '{}' -Force", + zip_path.display(), + dest_dir.display() + ), + ]) + .output()?; + + if !output.status.success() { + return Err( + format!( + "Failed to extract zip with PowerShell: {}", + String::from_utf8_lossy(&output.stderr) + ) + .into(), + ); + } + + self.find_extracted_executable(dest_dir).await + } + + #[cfg(not(target_os = "windows"))] + async fn extract_zip_unix( + &self, + zip_path: &Path, + dest_dir: &Path, + ) -> Result> { + // Use unzip command on Unix-like systems let output = Command::new("unzip") .args([ "-q", // quiet @@ -174,16 +351,269 @@ impl Extractor { ); } - // Find the extracted .app directory or Chromium.app specifically - let mut app_path: Option = None; + self.find_extracted_executable(dest_dir).await + } + pub async fn extract_tar_xz( + &self, + tar_path: &Path, + dest_dir: &Path, + ) -> Result> { + create_dir_all(dest_dir)?; + + // Use tar command for more reliable extraction + let output = Command::new("tar") + .args([ + "-xf", + tar_path.to_str().unwrap(), + "-C", + dest_dir.to_str().unwrap(), + ]) + .output()?; + + if !output.status.success() { + return Err( + format!( + "Failed to extract tar.xz: {}", + String::from_utf8_lossy(&output.stderr) + ) + .into(), + ); + } + + // Find the extracted executable and set proper permissions + let executable_path = self.find_extracted_executable(dest_dir).await?; + + // Ensure executable permissions are set correctly for Linux + if cfg!(target_os = "linux") { + self.set_executable_permissions(&executable_path).await?; + } + + Ok(executable_path) + } + + pub async fn extract_tar_bz2( + &self, + tar_path: &Path, + dest_dir: &Path, + ) -> Result> { + create_dir_all(dest_dir)?; + + // Use tar command for more reliable extraction + let output = Command::new("tar") + .args([ + "-xjf", + tar_path.to_str().unwrap(), + "-C", + dest_dir.to_str().unwrap(), + ]) + .output()?; + + if !output.status.success() { + return Err( + format!( + "Failed to extract tar.bz2: {}", + String::from_utf8_lossy(&output.stderr) + ) + .into(), + ); + } + + // Find the extracted executable and set proper permissions + let executable_path = self.find_extracted_executable(dest_dir).await?; + + // Ensure executable permissions are set correctly for Linux + if cfg!(target_os = "linux") { + self.set_executable_permissions(&executable_path).await?; + } + + Ok(executable_path) + } + + pub async fn extract_tar_gz( + &self, + tar_path: &Path, + dest_dir: &Path, + ) -> Result> { + create_dir_all(dest_dir)?; + + // Use tar command for more reliable extraction + let output = Command::new("tar") + .args([ + "-xzf", + tar_path.to_str().unwrap(), + "-C", + dest_dir.to_str().unwrap(), + ]) + .output()?; + + if !output.status.success() { + return Err( + format!( + "Failed to extract tar.gz: {}", + String::from_utf8_lossy(&output.stderr) + ) + .into(), + ); + } + + // Find the extracted executable and set proper permissions + let executable_path = self.find_extracted_executable(dest_dir).await?; + + // Ensure executable permissions are set correctly for Linux + if cfg!(target_os = "linux") { + self.set_executable_permissions(&executable_path).await?; + } + + Ok(executable_path) + } + + #[cfg(target_os = "linux")] + pub async fn extract_deb( + &self, + deb_path: &Path, + dest_dir: &Path, + ) -> Result> { + create_dir_all(dest_dir)?; + + // Extract DEB package using dpkg-deb + let output = Command::new("dpkg-deb") + .args(["-x", deb_path.to_str().unwrap(), dest_dir.to_str().unwrap()]) + .output()?; + + if !output.status.success() { + return Err( + format!( + "Failed to extract DEB: {}", + String::from_utf8_lossy(&output.stderr) + ) + .into(), + ); + } + + // Find the extracted executable and set proper permissions + let executable_path = self.find_extracted_executable(dest_dir).await?; + + // Ensure executable permissions are set correctly + self.set_executable_permissions(&executable_path).await?; + + Ok(executable_path) + } + + #[cfg(target_os = "linux")] + pub async fn handle_appimage( + &self, + appimage_path: &Path, + dest_dir: &Path, + ) -> Result> { + create_dir_all(dest_dir)?; + + // For AppImages, we typically just copy them and make sure they're executable + let dest_file = dest_dir.join( + appimage_path + .file_name() + .unwrap_or_else(|| std::ffi::OsStr::new("app.AppImage")), + ); + + // Copy the AppImage to destination + fs::copy(appimage_path, &dest_file)?; + + // Set executable permissions + self.set_executable_permissions(&dest_file).await?; + + Ok(dest_file) + } + + pub async fn handle_exe_file( + &self, + exe_path: &Path, + dest_dir: &Path, + browser_type: BrowserType, + ) -> Result> { + match browser_type { + BrowserType::Zen => { + // Zen installer EXE needs to be run to install + #[cfg(target_os = "windows")] + { + self.install_zen_windows(exe_path, dest_dir).await + } + #[cfg(not(target_os = "windows"))] + { + Err("Zen EXE installation is only supported on Windows".into()) + } + } + _ => { + // For other browsers (Firefox, TOR, etc.), the EXE is typically just copied + let exe_name = exe_path + .file_name() + .and_then(|name| name.to_str()) + .unwrap_or("browser.exe"); + + let dest_path = dest_dir.join(exe_name); + fs::copy(exe_path, &dest_path)?; + Ok(dest_path) + } + } + } + + #[cfg(target_os = "windows")] + async fn install_zen_windows( + &self, + installer_path: &Path, + dest_dir: &Path, + ) -> Result> { + // For Zen installer, we need to run it silently + // This is a simplified approach - in practice, you might need more sophisticated installer handling + let output = Command::new(installer_path) + .args(["/S", &format!("/D={}", dest_dir.display())]) + .output()?; + + if !output.status.success() { + return Err( + format!( + "Failed to install Zen: {}", + String::from_utf8_lossy(&output.stderr) + ) + .into(), + ); + } + + // Find the installed executable + self.find_extracted_executable(dest_dir).await + } + + async fn find_extracted_executable( + &self, + dest_dir: &Path, + ) -> Result> { + // Platform-specific executable finding logic + #[cfg(target_os = "macos")] + { + self.find_macos_app(dest_dir).await + } + + #[cfg(target_os = "windows")] + { + self.find_windows_executable(dest_dir).await + } + + #[cfg(target_os = "linux")] + { + self.find_linux_executable(dest_dir).await + } + } + + #[cfg(target_os = "macos")] + async fn find_macos_app( + &self, + dest_dir: &Path, + ) -> Result> { // First, try to find any .app file in the destination directory if let Ok(entries) = fs::read_dir(dest_dir) { for entry in entries.flatten() { let path = entry.path(); if path.extension().is_some_and(|ext| ext == "app") { - app_path = Some(path); - break; + return Ok(path); } // For Chromium, check subdirectories (chrome-mac folder) if path.is_dir() { @@ -194,33 +624,280 @@ impl Extractor { // Move the app to the root destination directory let target_path = dest_dir.join(sub_path.file_name().unwrap()); fs::rename(&sub_path, &target_path)?; - app_path = Some(target_path); // Clean up the now-empty subdirectory let _ = fs::remove_dir_all(&path); - break; + return Ok(target_path); } } - if app_path.is_some() { - break; - } } } } } - let app_path = app_path.ok_or("No .app found after extraction")?; + Err("No .app found after extraction".into()) + } - // Remove quarantine attributes - let _ = Command::new("xattr") - .args(["-dr", "com.apple.quarantine", app_path.to_str().unwrap()]) - .output(); + #[cfg(target_os = "windows")] + async fn find_windows_executable( + &self, + dest_dir: &Path, + ) -> Result> { + // Look for .exe files, preferring main browser executables + let exe_names = [ + "chrome.exe", + "firefox.exe", + "zen.exe", + "brave.exe", + "tor.exe", + ]; - let _ = Command::new("xattr") - .args(["-cr", app_path.to_str().unwrap()]) - .output(); + for exe_name in &exe_names { + let exe_path = dest_dir.join(exe_name); + if exe_path.exists() { + return Ok(exe_path); + } + } - Ok(app_path) + // If no specific executable found, look for any .exe file + if let Ok(entries) = fs::read_dir(dest_dir) { + for entry in entries.flatten() { + let path = entry.path(); + if path.extension().is_some_and(|ext| ext == "exe") { + return Ok(path); + } + + // Check subdirectories + if path.is_dir() { + if let Ok(sub_result) = self.find_windows_executable(&path).await { + return Ok(sub_result); + } + } + } + } + + Err("No executable found after extraction".into()) + } + + #[cfg(target_os = "linux")] + async fn find_linux_executable( + &self, + dest_dir: &Path, + ) -> Result> { + // Enhanced list of common browser executable names with better pattern matching + let exe_names = [ + // Firefox variants + "firefox", + "firefox-bin", + "firefox-esr", + "firefox-trunk", + // Chrome/Chromium variants + "chrome", + "google-chrome", + "google-chrome-stable", + "google-chrome-beta", + "google-chrome-unstable", + "chromium", + "chromium-browser", + "chromium-bin", + // Zen Browser + "zen", + "zen-browser", + "zen-bin", + // Brave variants + "brave", + "brave-browser", + "brave-browser-stable", + "brave-browser-beta", + "brave-browser-dev", + "brave-bin", + // Tor Browser variants + "tor-browser", + "torbrowser-launcher", + "tor-browser_en-US", + "start-tor-browser", + "Browser/start-tor-browser", + // Mullvad Browser + "mullvad-browser", + "mullvad-browser-bin", + // AppImage pattern (will be handled specially) + "*.AppImage", + ]; + + // First, try direct lookup in the main directory + for exe_name in &exe_names { + if exe_name.contains('*') { + // Handle glob patterns like *.AppImage + if let Ok(entries) = fs::read_dir(dest_dir) { + for entry in entries.flatten() { + let path = entry.path(); + if let Some(file_name) = path.file_name().and_then(|n| n.to_str()) { + if file_name.ends_with(".AppImage") && self.is_executable(&path) { + return Ok(path); + } + } + } + } + } else { + let exe_path = dest_dir.join(exe_name); + if exe_path.exists() && self.is_executable(&exe_path) { + return Ok(exe_path); + } + } + } + + // Enhanced list of common Linux subdirectories to search + let subdirs = [ + // Standard Unix directories + "bin", + "usr/bin", + "usr/local/bin", + "opt", + "sbin", + "usr/sbin", + // Browser-specific directories + "firefox", + "chrome", + "chromium", + "brave", + "zen", + "tor-browser", + "mullvad-browser", + // Common extraction patterns + ".", + "./", + // Package-specific extraction patterns + "firefox", + "mullvad-browser", + "tor-browser_en-US", + "Browser", + "browser", + // Nested patterns for different distro packaging + "opt/google/chrome", + "opt/brave.com/brave", + "opt/mullvad-browser", + "usr/lib/firefox", + "usr/lib/chromium", + "usr/share/applications", + // AppImage mount patterns + "usr/bin", + "AppRun", + ]; + + // Search in subdirectories with better depth handling + for subdir in &subdirs { + let subdir_path = dest_dir.join(subdir); + if subdir_path.exists() && subdir_path.is_dir() { + for exe_name in &exe_names { + if exe_name.contains('*') { + // Handle glob patterns for AppImages + if let Ok(entries) = fs::read_dir(&subdir_path) { + for entry in entries.flatten() { + let path = entry.path(); + if let Some(file_name) = path.file_name().and_then(|n| n.to_str()) { + if file_name.ends_with(".AppImage") && self.is_executable(&path) { + return Ok(path); + } + } + } + } + } else { + let exe_path = subdir_path.join(exe_name); + if exe_path.exists() && self.is_executable(&exe_path) { + return Ok(exe_path); + } + } + } + } + } + + // Last resort: enhanced recursive search for any executable file + self.find_any_executable_recursive(dest_dir, 0).await + } + + #[cfg(target_os = "linux")] + fn is_executable(&self, path: &Path) -> bool { + if let Ok(metadata) = path.metadata() { + use std::os::unix::fs::PermissionsExt; + return metadata.permissions().mode() & 0o111 != 0; + } + false + } + + /// Set executable permissions on Linux for extracted binaries + #[cfg(target_os = "linux")] + async fn set_executable_permissions( + &self, + path: &Path, + ) -> Result<(), Box> { + use std::os::unix::fs::PermissionsExt; + + if path.exists() { + let mut permissions = path.metadata()?.permissions(); + // Set executable permissions for owner, group, and others if they have read permission + let current_mode = permissions.mode(); + let new_mode = current_mode | 0o111; // Add execute permission + permissions.set_mode(new_mode); + std::fs::set_permissions(path, permissions)?; + } + Ok(()) + } + + #[cfg(not(target_os = "linux"))] + async fn set_executable_permissions( + &self, + _path: &Path, + ) -> Result<(), Box> { + Ok(()) + } + + #[cfg(target_os = "linux")] + async fn find_any_executable_recursive( + &self, + dir: &Path, + depth: usize, + ) -> Result> { + // Limit recursion depth to avoid infinite loops + if depth > 5 { + return Err("Maximum search depth reached".into()); + } + + if let Ok(entries) = fs::read_dir(dir) { + let mut directories = Vec::new(); + + // First pass: look for executable files + for entry in entries.flatten() { + let path = entry.path(); + if path.is_file() && self.is_executable(&path) { + // Prefer files with browser-like names + if let Some(file_name) = path.file_name().and_then(|n| n.to_str()) { + let name_lower = file_name.to_lowercase(); + if name_lower.contains("firefox") + || name_lower.contains("chrome") + || name_lower.contains("brave") + || name_lower.contains("zen") + || name_lower.contains("tor") + || name_lower.contains("mullvad") + || file_name.ends_with(".AppImage") + { + return Ok(path); + } + } + } else if path.is_dir() { + directories.push(path); + } + } + + // Second pass: recursively search directories + for dir_path in directories { + if let Ok(result) = Box::pin(self.find_any_executable_recursive(&dir_path, depth + 1)).await + { + return Ok(result); + } + } + } + + Err("No executable found".into()) } } @@ -232,13 +909,13 @@ mod tests { #[test] fn test_extractor_creation() { - let _extractor = Extractor::new(); + let _ = Extractor::new(); // Just verify we can create an extractor instance } #[test] fn test_unsupported_archive_format() { - let _extractor = Extractor::new(); + let _ = Extractor::new(); let temp_dir = TempDir::new().unwrap(); let fake_archive = temp_dir.path().join("test.rar"); File::create(&fake_archive).unwrap(); diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 3cb6c98..38907e4 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1,6 +1,5 @@ // Learn more about Tauri commands at https://tauri.app/develop/calling-rust/ use std::sync::Mutex; -use std::time::{SystemTime, UNIX_EPOCH}; use tauri::{Emitter, Manager, Runtime, WebviewUrl, WebviewWindow, WebviewWindowBuilder}; use tauri_plugin_deep_link::DeepLinkExt; @@ -19,22 +18,22 @@ mod downloaded_browsers; mod extraction; mod proxy_manager; mod settings_manager; +mod theme_detector; mod version_updater; extern crate lazy_static; use browser_runner::{ - check_browser_exists, check_browser_status, create_browser_profile, create_browser_profile_new, - delete_profile, download_browser, fetch_browser_versions, fetch_browser_versions_cached_first, - fetch_browser_versions_detailed, fetch_browser_versions_with_count, - fetch_browser_versions_with_count_cached_first, get_cached_browser_versions_detailed, - get_downloaded_browser_versions, get_saved_mullvad_releases, get_supported_browsers, - is_browser_downloaded, kill_browser_profile, launch_browser_profile, list_browser_profiles, - rename_profile, should_update_browser_cache, update_profile_proxy, update_profile_version, + check_browser_exists, check_browser_status, cleanup_unused_binaries, create_browser_profile_new, + delete_profile, download_browser, fetch_browser_versions_cached_first, + fetch_browser_versions_with_count, fetch_browser_versions_with_count_cached_first, + get_downloaded_browser_versions, get_supported_browsers, is_browser_supported_on_platform, + kill_browser_profile, launch_browser_profile, list_browser_profiles, rename_profile, + update_profile_proxy, update_profile_version, }; use settings_manager::{ - disable_default_browser_prompt, get_app_settings, get_table_sorting_settings, save_app_settings, + clear_all_version_cache, get_app_settings, get_table_sorting_settings, save_app_settings, save_table_sorting_settings, should_show_settings_on_startup, }; @@ -43,21 +42,21 @@ use default_browser::{ }; use version_updater::{ - check_version_update_needed, force_version_update_check, get_version_update_status, - get_version_updater, trigger_manual_version_update, + get_version_update_status, get_version_updater, trigger_manual_version_update, }; use auto_updater::{ - check_for_browser_updates, complete_browser_update, complete_browser_update_with_auto_update, - dismiss_update_notification, is_auto_update_download, is_browser_disabled_for_update, - mark_auto_update_download, remove_auto_update_download, start_browser_update, + check_for_browser_updates, complete_browser_update_with_auto_update, dismiss_update_notification, + is_auto_update_download, is_browser_disabled_for_update, mark_auto_update_download, + remove_auto_update_download, }; use app_auto_updater::{ check_for_app_updates, check_for_app_updates_manual, download_and_install_app_update, - get_app_version_info, }; +use theme_detector::get_system_theme; + // Trait to extend WebviewWindow with transparent titlebar functionality pub trait WindowExt { #[cfg(target_os = "macos")] @@ -103,13 +102,6 @@ impl WindowExt for WebviewWindow { } } -#[tauri::command] -fn greet() -> String { - let now = SystemTime::now(); - let epoch_ms = now.duration_since(UNIX_EPOCH).unwrap().as_millis(); - format!("Hello world from Rust! Current epoch: {epoch_ms}") -} - #[tauri::command] async fn handle_url_open(app: tauri::AppHandle, url: String) -> Result<(), String> { println!("handle_url_open called with URL: {url}"); @@ -169,61 +161,6 @@ async fn check_and_handle_startup_url(app_handle: tauri::AppHandle) -> Result Result<(), String> { - #[cfg(target_os = "macos")] - { - if let Some(window) = app_handle.get_webview_window("main") { - use objc2::rc::Retained; - use objc2_app_kit::{NSColor, NSWindow}; - - let ns_window: Retained = - unsafe { Retained::retain(window.ns_window().unwrap().cast()).unwrap() }; - - let bg_color = if is_dark_mode { - // Dark mode - pure black background - unsafe { NSColor::colorWithRed_green_blue_alpha(0.0, 0.0, 0.0, 1.0) } - } else { - // Light mode - pure white background - unsafe { NSColor::colorWithRed_green_blue_alpha(1.0, 1.0, 1.0, 1.0) } - }; - - // Ensure this runs on the main thread for immediate visual update - unsafe { - // Set the window background color - ns_window.setBackgroundColor(Some(&bg_color)); - - // Force immediate visual updates using multiple refresh methods - ns_window.invalidateShadow(); - ns_window.display(); - - // Ensure the window content is redrawn - if let Some(content_view) = ns_window.contentView() { - content_view.setNeedsDisplay(true); - content_view.displayIfNeeded(); - } - - // Trigger a window update - ns_window.update(); - } - - // Also emit an event to the frontend to ensure synchronization - let _ = app_handle.emit("window-background-updated", is_dark_mode); - } - } - - #[cfg(not(target_os = "macos"))] - { - // For non-macOS platforms, we can't change the native window background - let _ = (app_handle, is_dark_mode); // Suppress unused variable warnings - } - - Ok(()) -} - #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { tauri::Builder::default() @@ -233,12 +170,14 @@ pub fn run() { .plugin(tauri_plugin_deep_link::init()) .setup(|app| { // Create the main window programmatically + #[allow(unused_variables)] let win_builder = WebviewWindowBuilder::new(app, "main", WebviewUrl::default()) .title("Donut Browser") .inner_size(900.0, 600.0) .resizable(false) .fullscreen(false); + #[allow(unused_variables)] let window = win_builder.build().unwrap(); // Set transparent titlebar for macOS @@ -328,25 +267,19 @@ pub fn run() { Ok(()) }) .invoke_handler(tauri::generate_handler![ - greet, get_supported_browsers, + is_browser_supported_on_platform, download_browser, delete_profile, - is_browser_downloaded, check_browser_exists, + cleanup_unused_binaries, create_browser_profile_new, - create_browser_profile, list_browser_profiles, launch_browser_profile, - fetch_browser_versions, - fetch_browser_versions_detailed, fetch_browser_versions_with_count, fetch_browser_versions_cached_first, fetch_browser_versions_with_count_cached_first, - get_cached_browser_versions_detailed, - should_update_browser_cache, get_downloaded_browser_versions, - get_saved_mullvad_releases, update_profile_proxy, update_profile_version, check_browser_status, @@ -355,22 +288,17 @@ pub fn run() { get_app_settings, save_app_settings, should_show_settings_on_startup, - disable_default_browser_prompt, get_table_sorting_settings, save_table_sorting_settings, + clear_all_version_cache, is_default_browser, open_url_with_profile, set_as_default_browser, smart_open_url, - handle_url_open, check_and_handle_startup_url, trigger_manual_version_update, get_version_update_status, - check_version_update_needed, - force_version_update_check, check_for_browser_updates, - start_browser_update, - complete_browser_update, is_browser_disabled_for_update, dismiss_update_notification, complete_browser_update_with_auto_update, @@ -380,9 +308,169 @@ pub fn run() { check_for_app_updates, check_for_app_updates_manual, download_and_install_app_update, - get_app_version_info, - set_window_background_color, + get_system_theme, ]) .run(tauri::generate_context!()) .expect("error while running tauri application"); } + +#[cfg(test)] +mod tests { + use std::fs; + + #[test] + fn test_no_unused_tauri_commands() { + check_unused_commands(false); // Run in strict mode for CI + } + + #[test] + fn test_unused_tauri_commands_detailed() { + check_unused_commands(true); // Run in verbose mode for development + } + + fn check_unused_commands(verbose: bool) { + // Extract command names from the generate_handler! macro in this file + let lib_rs_content = fs::read_to_string("src/lib.rs").expect("Failed to read lib.rs"); + let commands = extract_tauri_commands(&lib_rs_content); + + // Get all frontend files + let frontend_files = get_frontend_files("../src"); + + // Check which commands are actually used + let mut unused_commands = Vec::new(); + let mut used_commands = Vec::new(); + + for command in &commands { + let mut is_used = false; + + for file_content in &frontend_files { + // More comprehensive search for command usage + if is_command_used(file_content, command) { + is_used = true; + break; + } + } + + if is_used { + used_commands.push(command.clone()); + if verbose { + println!("โœ… {command}"); + } + } else { + unused_commands.push(command.clone()); + if verbose { + println!("โŒ {command} (UNUSED)"); + } + } + } + + if verbose { + println!("\n๐Ÿ“Š Summary:"); + println!(" โœ… Used commands: {}", used_commands.len()); + println!(" โŒ Unused commands: {}", unused_commands.len()); + } + + if !unused_commands.is_empty() { + let message = format!( + "Found {} unused Tauri commands: {}\n\nThese commands are exported in generate_handler! but not used in the frontend.\nConsider removing them or add them to the allowlist if they're used elsewhere.\n\nRun `pnpm check-unused-commands` for detailed analysis.", + unused_commands.len(), + unused_commands.join(", ") + ); + + if verbose { + println!("\n๐Ÿšจ {message}"); + } else { + panic!("{}", message); + } + } else if verbose { + println!("\n๐ŸŽ‰ All exported commands are being used!"); + } else { + println!( + "โœ… All {} exported Tauri commands are being used in the frontend", + commands.len() + ); + } + } + + fn is_command_used(content: &str, command: &str) -> bool { + // Check various patterns for invoke usage + let patterns = vec![ + format!("invoke<{}>(\"{}\"", "", command), // invoke("command" + format!("invoke(\"{}\"", command), // invoke("command" + format!("invoke<{}>(\"{}\",", "", command), // invoke("command", + format!("invoke(\"{}\",", command), // invoke("command", + format!("\"{}\"", command), // Just the command name in quotes + ]; + + for pattern in patterns { + if content.contains(&pattern) { + return true; + } + } + + // Also check for the command name appearing after "invoke" within a reasonable distance + if let Some(invoke_pos) = content.find("invoke") { + let after_invoke = &content[invoke_pos..]; + if let Some(cmd_pos) = after_invoke.find(&format!("\"{command}\"")) { + // If the command appears within 100 characters of "invoke", consider it used + if cmd_pos < 100 { + return true; + } + } + } + + false + } + + fn extract_tauri_commands(content: &str) -> Vec { + let mut commands = Vec::new(); + + // Find the generate_handler! macro + if let Some(start) = content.find("tauri::generate_handler![") { + if let Some(end) = content[start..].find("])") { + let handler_content = &content[start + 25..start + end]; // Skip "tauri::generate_handler![" + + // Extract command names + for line in handler_content.lines() { + let line = line.trim(); + if !line.is_empty() && !line.starts_with("//") { + // Remove trailing comma and whitespace + let command = line.trim_end_matches(',').trim(); + if !command.is_empty() { + commands.push(command.to_string()); + } + } + } + } + } + + commands + } + + fn get_frontend_files(src_dir: &str) -> Vec { + let mut files_content = Vec::new(); + + if let Ok(entries) = fs::read_dir(src_dir) { + for entry in entries.flatten() { + let path = entry.path(); + + if path.is_dir() { + // Recursively read subdirectories + let subdir_files = get_frontend_files(&path.to_string_lossy()); + files_content.extend(subdir_files); + } else if let Some(extension) = path.extension() { + if matches!( + extension.to_str(), + Some("ts") | Some("tsx") | Some("js") | Some("jsx") + ) { + if let Ok(content) = fs::read_to_string(&path) { + files_content.push(content); + } + } + } + } + } + + files_content + } +} diff --git a/src-tauri/src/settings_manager.rs b/src-tauri/src/settings_manager.rs index 13627e5..ed52dad 100644 --- a/src-tauri/src/settings_manager.rs +++ b/src-tauri/src/settings_manager.rs @@ -3,6 +3,8 @@ use serde::{Deserialize, Serialize}; use std::fs::{self, create_dir_all}; use std::path::PathBuf; +use crate::api_client::ApiClient; + #[derive(Debug, Serialize, Deserialize, Clone)] pub struct TableSortingSettings { pub column: String, // Column to sort by: "name", "browser", "status" @@ -28,6 +30,8 @@ pub struct AppSettings { pub theme: String, // "light", "dark", or "system" #[serde(default = "default_auto_updates_enabled")] pub auto_updates_enabled: bool, + #[serde(default = "default_auto_delete_unused_binaries")] + pub auto_delete_unused_binaries: bool, } fn default_show_settings_on_startup() -> bool { @@ -42,6 +46,10 @@ fn default_auto_updates_enabled() -> bool { true } +fn default_auto_delete_unused_binaries() -> bool { + true +} + impl Default for AppSettings { fn default() -> Self { Self { @@ -49,6 +57,7 @@ impl Default for AppSettings { show_settings_on_startup: default_show_settings_on_startup(), theme: default_theme(), auto_updates_enabled: default_auto_updates_enabled(), + auto_delete_unused_binaries: default_auto_delete_unused_binaries(), } } } @@ -163,13 +172,6 @@ impl SettingsManager { // 3. User hasn't explicitly disabled the default browser setting Ok(settings.show_settings_on_startup && !settings.set_as_default_browser) } - - pub fn disable_default_browser_prompt(&self) -> Result<(), Box> { - let mut settings = self.load_settings()?; - settings.show_settings_on_startup = false; - self.save_settings(&settings)?; - Ok(()) - } } #[tauri::command] @@ -196,14 +198,6 @@ pub async fn should_show_settings_on_startup() -> Result { .map_err(|e| format!("Failed to check prompt setting: {e}")) } -#[tauri::command] -pub async fn disable_default_browser_prompt() -> Result<(), String> { - let manager = SettingsManager::new(); - manager - .disable_default_browser_prompt() - .map_err(|e| format!("Failed to disable prompt: {e}")) -} - #[tauri::command] pub async fn get_table_sorting_settings() -> Result { let manager = SettingsManager::new(); @@ -219,3 +213,11 @@ pub async fn save_table_sorting_settings(sorting: TableSortingSettings) -> Resul .save_table_sorting(&sorting) .map_err(|e| format!("Failed to save table sorting settings: {e}")) } + +#[tauri::command] +pub async fn clear_all_version_cache() -> Result<(), String> { + let api_client = ApiClient::new(); + api_client + .clear_all_cache() + .map_err(|e| format!("Failed to clear version cache: {e}")) +} diff --git a/src-tauri/src/theme_detector.rs b/src-tauri/src/theme_detector.rs new file mode 100644 index 0000000..5b8127e --- /dev/null +++ b/src-tauri/src/theme_detector.rs @@ -0,0 +1,539 @@ +use serde::{Deserialize, Serialize}; +use std::process::Command; + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct SystemTheme { + pub theme: String, // "light", "dark", or "unknown" +} + +pub struct ThemeDetector; + +impl ThemeDetector { + pub fn new() -> Self { + Self + } + + /// Detect the system theme preference + pub fn detect_system_theme(&self) -> SystemTheme { + #[cfg(target_os = "linux")] + return linux::detect_system_theme(); + + #[cfg(target_os = "macos")] + return macos::detect_system_theme(); + + #[cfg(target_os = "windows")] + return windows::detect_system_theme(); + + #[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))] + return SystemTheme { + theme: "unknown".to_string(), + }; + } +} + +#[cfg(target_os = "linux")] +mod linux { + use super::*; + + pub fn detect_system_theme() -> SystemTheme { + // Try multiple methods in order of preference + + // 1. Try GNOME/GTK settings via gsettings + if let Ok(theme) = detect_gnome_theme() { + return SystemTheme { theme }; + } + + // 2. Try KDE Plasma settings via kreadconfig5/kreadconfig6 + if let Ok(theme) = detect_kde_theme() { + return SystemTheme { theme }; + } + + // 3. Try XFCE settings via xfconf-query + if let Ok(theme) = detect_xfce_theme() { + return SystemTheme { theme }; + } + + // 4. Try looking at current GTK theme name + if let Ok(theme) = detect_gtk_theme() { + return SystemTheme { theme }; + } + + // 5. Try dconf directly (fallback for GNOME-based systems) + if let Ok(theme) = detect_dconf_theme() { + return SystemTheme { theme }; + } + + // 6. Try environment variables + if let Ok(theme) = detect_env_theme() { + return SystemTheme { theme }; + } + + // 7. Try freedesktop portal + if let Ok(theme) = detect_portal_theme() { + return SystemTheme { theme }; + } + + // 8. Try looking at system color scheme files + if let Ok(theme) = detect_system_files_theme() { + return SystemTheme { theme }; + } + + // Fallback to unknown + SystemTheme { + theme: "unknown".to_string(), + } + } + + fn detect_gnome_theme() -> Result> { + // Check if gsettings is available + if !is_command_available("gsettings") { + return Err("gsettings not available".into()); + } + + // Try GNOME color scheme first (modern way) + if let Ok(output) = Command::new("gsettings") + .args(["get", "org.gnome.desktop.interface", "color-scheme"]) + .output() + { + if output.status.success() { + let scheme = String::from_utf8_lossy(&output.stdout).trim().to_string(); + match scheme.as_str() { + "'prefer-dark'" => return Ok("dark".to_string()), + "'prefer-light'" => return Ok("light".to_string()), + _ => {} + } + } + } + + // Fallback to GTK theme name detection + if let Ok(output) = Command::new("gsettings") + .args(["get", "org.gnome.desktop.interface", "gtk-theme"]) + .output() + { + if output.status.success() { + let theme_name = String::from_utf8_lossy(&output.stdout) + .trim() + .trim_matches('\'') + .to_lowercase(); + + if theme_name.contains("dark") || theme_name.contains("night") { + return Ok("dark".to_string()); + } else if theme_name.contains("light") || theme_name.contains("adwaita") { + return Ok("light".to_string()); + } + } + } + + Err("Could not detect GNOME theme".into()) + } + + fn detect_kde_theme() -> Result> { + // Try KDE Plasma 6 first + if is_command_available("kreadconfig6") { + if let Ok(output) = Command::new("kreadconfig6") + .args([ + "--file", + "kdeglobals", + "--group", + "KDE", + "--key", + "LookAndFeelPackage", + ]) + .output() + { + if output.status.success() { + let theme = String::from_utf8_lossy(&output.stdout) + .trim() + .to_lowercase(); + if theme.contains("dark") || theme.contains("breezedark") { + return Ok("dark".to_string()); + } else if theme.contains("light") || theme.contains("breeze") { + return Ok("light".to_string()); + } + } + } + + // Try color scheme as well + if let Ok(output) = Command::new("kreadconfig6") + .args([ + "--file", + "kdeglobals", + "--group", + "General", + "--key", + "ColorScheme", + ]) + .output() + { + if output.status.success() { + let scheme = String::from_utf8_lossy(&output.stdout) + .trim() + .to_lowercase(); + if scheme.contains("dark") || scheme.contains("breezedark") { + return Ok("dark".to_string()); + } else if scheme.contains("light") || scheme.contains("breeze") { + return Ok("light".to_string()); + } + } + } + } + + // Try KDE Plasma 5 as fallback + if is_command_available("kreadconfig5") { + if let Ok(output) = Command::new("kreadconfig5") + .args([ + "--file", + "kdeglobals", + "--group", + "KDE", + "--key", + "LookAndFeelPackage", + ]) + .output() + { + if output.status.success() { + let theme = String::from_utf8_lossy(&output.stdout) + .trim() + .to_lowercase(); + if theme.contains("dark") || theme.contains("breezedark") { + return Ok("dark".to_string()); + } else if theme.contains("light") || theme.contains("breeze") { + return Ok("light".to_string()); + } + } + } + } + + Err("Could not detect KDE theme".into()) + } + + fn detect_xfce_theme() -> Result> { + if !is_command_available("xfconf-query") { + return Err("xfconf-query not available".into()); + } + + // Check XFCE theme + if let Ok(output) = Command::new("xfconf-query") + .args(["-c", "xsettings", "-p", "/Net/ThemeName"]) + .output() + { + if output.status.success() { + let theme = String::from_utf8_lossy(&output.stdout) + .trim() + .to_lowercase(); + if theme.contains("dark") || theme.contains("night") { + return Ok("dark".to_string()); + } else if theme.contains("light") { + return Ok("light".to_string()); + } + } + } + + // Check XFCE window manager theme as backup + if let Ok(output) = Command::new("xfconf-query") + .args(["-c", "xfwm4", "-p", "/general/theme"]) + .output() + { + if output.status.success() { + let theme = String::from_utf8_lossy(&output.stdout) + .trim() + .to_lowercase(); + if theme.contains("dark") || theme.contains("night") { + return Ok("dark".to_string()); + } else if theme.contains("light") { + return Ok("light".to_string()); + } + } + } + + Err("Could not detect XFCE theme".into()) + } + + fn detect_gtk_theme() -> Result> { + // Try to read GTK3 settings file + if let Ok(home) = std::env::var("HOME") { + let gtk3_settings = std::path::Path::new(&home).join(".config/gtk-3.0/settings.ini"); + if gtk3_settings.exists() { + if let Ok(content) = std::fs::read_to_string(gtk3_settings) { + for line in content.lines() { + if line.starts_with("gtk-theme-name=") { + let theme_name = line.split('=').nth(1).unwrap_or("").trim().to_lowercase(); + if theme_name.contains("dark") || theme_name.contains("night") { + return Ok("dark".to_string()); + } else if theme_name.contains("light") || theme_name.contains("adwaita") { + return Ok("light".to_string()); + } + } + } + } + } + + // Try GTK4 settings + let gtk4_settings = std::path::Path::new(&home).join(".config/gtk-4.0/settings.ini"); + if gtk4_settings.exists() { + if let Ok(content) = std::fs::read_to_string(gtk4_settings) { + for line in content.lines() { + if line.starts_with("gtk-theme-name=") { + let theme_name = line.split('=').nth(1).unwrap_or("").trim().to_lowercase(); + if theme_name.contains("dark") || theme_name.contains("night") { + return Ok("dark".to_string()); + } else if theme_name.contains("light") || theme_name.contains("adwaita") { + return Ok("light".to_string()); + } + } + } + } + } + } + + Err("Could not detect GTK theme".into()) + } + + fn detect_dconf_theme() -> Result> { + if !is_command_available("dconf") { + return Err("dconf not available".into()); + } + + // Try reading color scheme directly from dconf + if let Ok(output) = Command::new("dconf") + .args(["read", "/org/gnome/desktop/interface/color-scheme"]) + .output() + { + if output.status.success() { + let scheme = String::from_utf8_lossy(&output.stdout).trim().to_string(); + match scheme.as_str() { + "'prefer-dark'" => return Ok("dark".to_string()), + "'prefer-light'" => return Ok("light".to_string()), + _ => {} + } + } + } + + // Try reading GTK theme from dconf + if let Ok(output) = Command::new("dconf") + .args(["read", "/org/gnome/desktop/interface/gtk-theme"]) + .output() + { + if output.status.success() { + let theme_name = String::from_utf8_lossy(&output.stdout) + .trim() + .trim_matches('\'') + .to_lowercase(); + + if theme_name.contains("dark") || theme_name.contains("night") { + return Ok("dark".to_string()); + } else if theme_name.contains("light") || theme_name.contains("adwaita") { + return Ok("light".to_string()); + } + } + } + + Err("Could not detect dconf theme".into()) + } + + fn detect_env_theme() -> Result> { + // Check common environment variables + if let Ok(theme) = std::env::var("GTK_THEME") { + let theme_lower = theme.to_lowercase(); + if theme_lower.contains("dark") || theme_lower.contains("night") { + return Ok("dark".to_string()); + } else if theme_lower.contains("light") { + return Ok("light".to_string()); + } + } + + if let Ok(theme) = std::env::var("QT_STYLE_OVERRIDE") { + let theme_lower = theme.to_lowercase(); + if theme_lower.contains("dark") || theme_lower.contains("night") { + return Ok("dark".to_string()); + } else if theme_lower.contains("light") { + return Ok("light".to_string()); + } + } + + Err("Could not detect theme from environment".into()) + } + + fn detect_portal_theme() -> Result> { + if !is_command_available("busctl") { + return Err("busctl not available".into()); + } + + // Try to query the color scheme via org.freedesktop.portal.Settings + if let Ok(output) = Command::new("busctl") + .args([ + "--user", + "call", + "org.freedesktop.portal.Desktop", + "/org/freedesktop/portal/desktop", + "org.freedesktop.portal.Settings", + "Read", + "ss", + "org.freedesktop.appearance", + "color-scheme", + ]) + .output() + { + if output.status.success() { + let response = String::from_utf8_lossy(&output.stdout); + // Parse DBus response - look for preference values + if response.contains(" 1 ") { + return Ok("dark".to_string()); + } else if response.contains(" 2 ") { + return Ok("light".to_string()); + } + } + } + + Err("Could not detect portal theme".into()) + } + + fn detect_system_files_theme() -> Result> { + // Check if we're in a dark terminal (heuristic) + if let Ok(term) = std::env::var("TERM") { + let term_lower = term.to_lowercase(); + if term_lower.contains("dark") || term_lower.contains("night") { + return Ok("dark".to_string()); + } + } + + // Check if we can determine from desktop session + if let Ok(desktop) = std::env::var("XDG_CURRENT_DESKTOP") { + let desktop_lower = desktop.to_lowercase(); + // Some desktops default to dark + if desktop_lower.contains("i3") || desktop_lower.contains("sway") { + // Window managers often use dark themes by default + return Ok("dark".to_string()); + } + } + + Err("Could not detect theme from system files".into()) + } + + fn is_command_available(command: &str) -> bool { + Command::new("which") + .arg(command) + .output() + .map(|output| output.status.success()) + .unwrap_or(false) + } +} + +#[cfg(target_os = "macos")] +mod macos { + use super::*; + + pub fn detect_system_theme() -> SystemTheme { + // macOS theme detection using osascript + if let Ok(output) = Command::new("osascript") + .args([ + "-e", + "tell application \"System Events\" to tell appearance preferences to get dark mode", + ]) + .output() + { + if output.status.success() { + let result = String::from_utf8_lossy(&output.stdout).to_string(); + let result = result.trim(); + match result { + "true" => { + return SystemTheme { + theme: "dark".to_string(), + } + } + "false" => { + return SystemTheme { + theme: "light".to_string(), + } + } + _ => {} + } + } + } + + // Fallback method using defaults + if let Ok(output) = Command::new("defaults") + .args(["read", "-g", "AppleInterfaceStyle"]) + .output() + { + if output.status.success() { + let style = String::from_utf8_lossy(&output.stdout).to_string(); + let style = style.trim(); + if style.to_lowercase() == "dark" { + return SystemTheme { + theme: "dark".to_string(), + }; + } + } + } + + // Default to light if we can't determine + SystemTheme { + theme: "light".to_string(), + } + } +} + +#[cfg(target_os = "windows")] +mod windows { + use super::*; + + pub fn detect_system_theme() -> SystemTheme { + // Windows theme detection via registry + // This is a simplified implementation - you might want to use winreg crate for better registry access + if let Ok(output) = Command::new("reg") + .args([ + "query", + "HKEY_CURRENT_USER\\Software\\Microsoft\\Windows\\CurrentVersion\\Themes\\Personalize", + "/v", + "AppsUseLightTheme", + ]) + .output() + { + if output.status.success() { + let result = String::from_utf8_lossy(&output.stdout); + if result.contains("0x0") { + return SystemTheme { + theme: "dark".to_string(), + }; + } else if result.contains("0x1") { + return SystemTheme { + theme: "light".to_string(), + }; + } + } + } + + // Default to light if we can't determine + SystemTheme { + theme: "light".to_string(), + } + } +} + +// Command to expose this functionality to the frontend +#[tauri::command] +pub fn get_system_theme() -> SystemTheme { + let detector = ThemeDetector::new(); + detector.detect_system_theme() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_theme_detector_creation() { + let detector = ThemeDetector::new(); + let theme = detector.detect_system_theme(); + + // Should return a valid theme string + assert!(matches!(theme.theme.as_str(), "light" | "dark" | "unknown")); + } + + #[test] + fn test_get_system_theme_command() { + let theme = get_system_theme(); + assert!(matches!(theme.theme.as_str(), "light" | "dark" | "unknown")); + } +} diff --git a/src-tauri/src/version_updater.rs b/src-tauri/src/version_updater.rs index e00faa8..bd7e9a3 100644 --- a/src-tauri/src/version_updater.rs +++ b/src-tauri/src/version_updater.rs @@ -448,22 +448,6 @@ pub async fn get_version_update_status() -> Result<(Option, u64), String> { Ok((last_update, time_until_next)) } -#[tauri::command] -pub async fn check_version_update_needed() -> Result { - Ok(VersionUpdater::should_run_background_update()) -} - -#[tauri::command] -pub async fn force_version_update_check(_app_handle: tauri::AppHandle) -> Result { - let updater = get_version_updater(); - let updater_guard = updater.lock().await; - - match updater_guard.check_and_run_startup_update().await { - Ok(_) => Ok(true), - Err(e) => Err(format!("Failed to run version update check: {e}")), - } -} - #[cfg(test)] mod tests { use super::*; diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 8dad49f..8768272 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -37,6 +37,25 @@ "files": { "Info.plist": "Info.plist" } + }, + "linux": { + "deb": { + "depends": ["xdg-utils"], + "files": { + "/usr/share/applications/donutbrowser.desktop": "donutbrowser.desktop" + } + }, + "rpm": { + "depends": ["xdg-utils"], + "files": { + "/usr/share/applications/donutbrowser.desktop": "donutbrowser.desktop" + } + }, + "appimage": { + "files": { + "usr/share/applications/donutbrowser.desktop": "donutbrowser.desktop" + } + } } }, "plugins": { diff --git a/src/components/create-profile-dialog.tsx b/src/components/create-profile-dialog.tsx index b23aeb0..8b15391 100644 --- a/src/components/create-profile-dialog.tsx +++ b/src/components/create-profile-dialog.tsx @@ -26,6 +26,8 @@ import { } from "@/components/ui/tooltip"; import { VersionSelector } from "@/components/version-selector"; import { useBrowserDownload } from "@/hooks/use-browser-download"; +import { useBrowserSupport } from "@/hooks/use-browser-support"; +import { getBrowserDisplayName } from "@/lib/browser-utils"; import type { BrowserProfile, ProxySettings } from "@/types"; import { invoke } from "@tauri-apps/api/core"; import { useEffect, useState } from "react"; @@ -60,9 +62,6 @@ export function CreateProfileDialog({ const [selectedBrowser, setSelectedBrowser] = useState("mullvad-browser"); const [selectedVersion, setSelectedVersion] = useState(null); - const [supportedBrowsers, setSupportedBrowsers] = useState< - BrowserTypeString[] - >([]); const [isCreating, setIsCreating] = useState(false); const [existingProfiles, setExistingProfiles] = useState( [], @@ -84,13 +83,29 @@ export function CreateProfileDialog({ isVersionDownloaded, } = useBrowserDownload(); + const { + supportedBrowsers, + isLoading: isLoadingSupport, + isBrowserSupported, + } = useBrowserSupport(); + useEffect(() => { if (isOpen) { - void loadSupportedBrowsers(); void loadExistingProfiles(); } }, [isOpen]); + useEffect(() => { + if (supportedBrowsers.length > 0) { + // Set default browser to first supported browser + if (supportedBrowsers.includes("mullvad-browser")) { + setSelectedBrowser("mullvad-browser"); + } else if (supportedBrowsers.length > 0) { + setSelectedBrowser(supportedBrowsers[0] as BrowserTypeString); + } + } + }, [supportedBrowsers]); + useEffect(() => { if (isOpen && selectedBrowser) { // Reset selected version when browser changes @@ -105,7 +120,7 @@ export function CreateProfileDialog({ if (availableVersions.length > 0 && selectedBrowser) { // Always reset version when browser changes or versions are loaded // Find the latest stable version (not alpha/beta) - const stableVersions = availableVersions.filter((v) => !v.is_alpha); + const stableVersions = availableVersions.filter((v) => !v.is_nightly); if (stableVersions.length > 0) { // Select the first stable version (they're already sorted newest first) @@ -117,22 +132,6 @@ export function CreateProfileDialog({ } }, [availableVersions, selectedBrowser]); - const loadSupportedBrowsers = async () => { - try { - const browsers = await invoke( - "get_supported_browsers", - ); - setSupportedBrowsers(browsers); - if (browsers.includes("mullvad-browser")) { - setSelectedBrowser("mullvad-browser"); - } else if (browsers.length > 0) { - setSelectedBrowser(browsers[0]); - } - } catch (error) { - console.error("Failed to load supported browsers:", error); - } - }; - const loadExistingProfiles = async () => { try { const profiles = await invoke("list_browser_profiles"); @@ -261,21 +260,58 @@ export function CreateProfileDialog({ onValueChange={(value) => { setSelectedBrowser(value as BrowserTypeString); }} + disabled={isLoadingSupport} > - + - {supportedBrowsers.map((browser) => ( - - {browser - .split("-") - .map( - (word) => word.charAt(0).toUpperCase() + word.slice(1), - ) - .join(" ")} - - ))} + {( + [ + "mullvad-browser", + "firefox", + "firefox-developer", + "chromium", + "brave", + "zen", + "tor-browser", + ] as BrowserTypeString[] + ).map((browser) => { + const isSupported = isBrowserSupported(browser); + const displayName = getBrowserDisplayName(browser); + + if (!isSupported) { + return ( + + + + {displayName} (Not supported on this platform) + + + +

+ {displayName} is not supported on your current + platform or architecture. +

+
+
+ ); + } + + return ( + + {displayName} + + ); + })}
diff --git a/src/components/settings-dialog.tsx b/src/components/settings-dialog.tsx index 680642e..760b5cf 100644 --- a/src/components/settings-dialog.tsx +++ b/src/components/settings-dialog.tsx @@ -28,6 +28,7 @@ interface AppSettings { show_settings_on_startup: boolean; theme: string; auto_updates_enabled: boolean; + auto_delete_unused_binaries: boolean; } interface SettingsDialogProps { @@ -41,17 +42,21 @@ export function SettingsDialog({ isOpen, onClose }: SettingsDialogProps) { show_settings_on_startup: true, theme: "system", auto_updates_enabled: true, + auto_delete_unused_binaries: true, }); const [originalSettings, setOriginalSettings] = useState({ set_as_default_browser: false, show_settings_on_startup: true, theme: "system", auto_updates_enabled: true, + auto_delete_unused_binaries: true, }); const [isDefaultBrowser, setIsDefaultBrowser] = useState(false); const [isLoading, setIsLoading] = useState(false); const [isSaving, setIsSaving] = useState(false); const [isSettingDefault, setIsSettingDefault] = useState(false); + const [isClearingCache, setIsClearingCache] = useState(false); + const [isCleaningBinaries, setIsCleaningBinaries] = useState(false); const { setTheme } = useTheme(); @@ -106,6 +111,39 @@ export function SettingsDialog({ isOpen, onClose }: SettingsDialogProps) { } }; + const handleClearCache = async () => { + setIsClearingCache(true); + try { + await invoke("clear_all_version_cache"); + // Optionally show a success message + console.log("Cache cleared successfully"); + } catch (error) { + console.error("Failed to clear cache:", error); + } finally { + setIsClearingCache(false); + } + }; + + const handleCleanupBinaries = async () => { + setIsCleaningBinaries(true); + try { + const cleanedUp = await invoke("cleanup_unused_binaries"); + if (cleanedUp.length > 0) { + console.log( + `Cleaned up ${cleanedUp.length} unused binaries:`, + cleanedUp, + ); + // You could show a toast with the results + } else { + console.log("No unused binaries to clean up"); + } + } catch (error) { + console.error("Failed to cleanup unused binaries:", error); + } finally { + setIsCleaningBinaries(false); + } + }; + const handleSave = async () => { setIsSaving(true); try { @@ -130,7 +168,9 @@ export function SettingsDialog({ isOpen, onClose }: SettingsDialogProps) { settings.show_settings_on_startup !== originalSettings.show_settings_on_startup || settings.theme !== originalSettings.theme || - settings.auto_updates_enabled !== originalSettings.auto_updates_enabled; + settings.auto_updates_enabled !== originalSettings.auto_updates_enabled || + settings.auto_delete_unused_binaries !== + originalSettings.auto_delete_unused_binaries; return ( @@ -216,9 +256,26 @@ export function SettingsDialog({ isOpen, onClose }: SettingsDialogProps) { +
+ { + updateSetting( + "auto_delete_unused_binaries", + checked as boolean, + ); + }} + /> + +
+

When enabled, Donut Browser will check for browser updates and - notify you when updates are available for your profiles. + notify you when updates are available for your profiles. Unused + binaries will be automatically deleted to save disk space.

@@ -244,6 +301,45 @@ export function SettingsDialog({ isOpen, onClose }: SettingsDialogProps) { starts.

+ + {/* Advanced Section */} +
+ + + { + void handleClearCache(); + }} + variant="outline" + className="w-full" + > + Clear All Version Cache + + +

+ Clear all cached browser version data. This will force a fresh + download of version information on the next app restart or manual + refresh. +

+ + { + void handleCleanupBinaries(); + }} + variant="outline" + className="w-full" + > + Clean Up Unused Binaries + + +

+ Manually remove browser binaries that are not used by any profile. + This can help free up disk space. Note: This will run + automatically when the setting above is enabled. +

+
diff --git a/src/components/theme-provider.tsx b/src/components/theme-provider.tsx index 9404bb2..b65b35f 100644 --- a/src/components/theme-provider.tsx +++ b/src/components/theme-provider.tsx @@ -9,6 +9,10 @@ interface AppSettings { theme: string; } +interface SystemTheme { + theme: string; +} + interface CustomThemeProviderProps { children: React.ReactNode; } @@ -24,6 +28,25 @@ function getSystemTheme(): string { return "light"; } +// Function to get native system theme (fallback to CSS media query) +async function getNativeSystemTheme(): Promise { + try { + const systemTheme = await invoke("get_system_theme"); + if (systemTheme.theme === "dark" || systemTheme.theme === "light") { + return systemTheme.theme; + } + // Fallback to CSS media query if native detection returns "unknown" + return getSystemTheme(); + } catch (error) { + console.warn( + "Failed to get native system theme, falling back to CSS media query:", + error, + ); + // Fallback to CSS media query + return getSystemTheme(); + } +} + export function CustomThemeProvider({ children }: CustomThemeProviderProps) { const [isLoading, setIsLoading] = useState(true); const [defaultTheme, setDefaultTheme] = useState("system"); @@ -41,7 +64,7 @@ export function CustomThemeProvider({ children }: CustomThemeProviderProps) { } catch (error) { console.error("Failed to load theme settings:", error); // For first-time users, detect system preference and apply it - const systemTheme = getSystemTheme(); + const systemTheme = await getNativeSystemTheme(); console.log( "First-time user detected, applying system theme:", systemTheme, @@ -69,6 +92,50 @@ export function CustomThemeProvider({ children }: CustomThemeProviderProps) { void loadTheme(); }, []); + // Monitor system theme changes when using "system" theme + useEffect(() => { + if (!mounted || defaultTheme !== "system") { + return; + } + + const checkSystemTheme = async () => { + try { + const currentSystemTheme = await getNativeSystemTheme(); + // Force re-evaluation by toggling the theme + const html = document.documentElement; + const currentClass = html.className; + + // Apply the system theme class + if (currentSystemTheme === "dark") { + if (!html.classList.contains("dark")) { + html.classList.add("dark"); + html.classList.remove("light"); + } + } else { + if ( + !html.classList.contains("light") || + html.classList.contains("dark") + ) { + html.classList.add("light"); + html.classList.remove("dark"); + } + } + } catch (error) { + console.warn("Failed to check system theme:", error); + } + }; + + // Check system theme every 2 seconds when using system theme + const intervalId = setInterval(() => void checkSystemTheme(), 2000); + + // Initial check + void checkSystemTheme(); + + return () => { + clearInterval(intervalId); + }; + }, [mounted, defaultTheme]); + if (isLoading) { // Use a consistent loading screen that doesn't depend on system theme during SSR // This prevents hydration mismatch by ensuring server and client render the same initially @@ -77,6 +144,7 @@ export function CustomThemeProvider({ children }: CustomThemeProviderProps) { // Only apply system theme detection after component is mounted (client-side only) if (mounted) { + // Use CSS media query for loading screen since async call would complicate this const systemTheme = getSystemTheme(); loadingBgColor = systemTheme === "dark" ? "bg-gray-900" : "bg-white"; spinnerColor = @@ -85,10 +153,10 @@ export function CustomThemeProvider({ children }: CustomThemeProviderProps) { return (
); diff --git a/src/components/version-selector.tsx b/src/components/version-selector.tsx index a915076..3f329a4 100644 --- a/src/components/version-selector.tsx +++ b/src/components/version-selector.tsx @@ -30,7 +30,7 @@ interface GithubRelease { hash?: string; }>; published_at: string; - is_alpha: boolean; + is_nightly: boolean; } interface VersionSelectorProps { @@ -75,7 +75,7 @@ export function VersionSelector({ className="justify-between w-full" > {selectedVersion ?? placeholder} - + @@ -114,11 +114,11 @@ export function VersionSelector({ : "opacity-0", )} /> -
+
{version.tag_name} - {version.is_alpha && ( + {version.is_nightly && ( - Alpha + Nightly )} {isDownloaded && ( @@ -147,7 +147,7 @@ export function VersionSelector({ variant="outline" className="w-full" > - + {isDownloading ? "Downloading..." : "Download Browser"} )} diff --git a/src/hooks/use-browser-download.ts b/src/hooks/use-browser-download.ts index fdc002d..34f68c3 100644 --- a/src/hooks/use-browser-download.ts +++ b/src/hooks/use-browser-download.ts @@ -19,7 +19,7 @@ interface GithubRelease { hash?: string; }>; published_at: string; - is_alpha: boolean; + is_nightly: boolean; } interface BrowserVersionInfo { @@ -231,7 +231,7 @@ export function useBrowserDownload() { tag_name: versionInfo.version, assets: [], published_at: versionInfo.date, - is_alpha: versionInfo.is_prerelease, + is_nightly: versionInfo.is_prerelease, }), ); @@ -272,7 +272,7 @@ export function useBrowserDownload() { tag_name: versionInfo.version, assets: [], published_at: versionInfo.date, - is_alpha: versionInfo.is_prerelease, + is_nightly: versionInfo.is_prerelease, }), ); @@ -325,6 +325,22 @@ export function useBrowserDownload() { setIsDownloading(true); try { + // Check browser compatibility before attempting download + const isSupported = await invoke( + "is_browser_supported_on_platform", + { browserStr }, + ); + if (!isSupported) { + const supportedBrowsers = await invoke( + "get_supported_browsers", + ); + throw new Error( + `${browserName} is not supported on your platform. Supported browsers: ${supportedBrowsers + .map(getBrowserDisplayName) + .join(", ")}`, + ); + } + await invoke("download_browser", { browserStr, version }); await loadDownloadedVersions(browserStr); } catch (error) { diff --git a/src/hooks/use-browser-support.ts b/src/hooks/use-browser-support.ts new file mode 100644 index 0000000..2f903f6 --- /dev/null +++ b/src/hooks/use-browser-support.ts @@ -0,0 +1,59 @@ +import { invoke } from "@tauri-apps/api/core"; +import { useEffect, useState } from "react"; + +export interface BrowserSupportInfo { + supportedBrowsers: string[]; + isLoading: boolean; + error: string | null; +} + +export function useBrowserSupport() { + const [supportedBrowsers, setSupportedBrowsers] = useState([]); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + const loadSupportedBrowsers = async () => { + try { + setIsLoading(true); + setError(null); + const browsers = await invoke("get_supported_browsers"); + setSupportedBrowsers(browsers); + } catch (err) { + console.error("Failed to load supported browsers:", err); + setError( + err instanceof Error + ? err.message + : "Failed to load supported browsers", + ); + } finally { + setIsLoading(false); + } + }; + + void loadSupportedBrowsers(); + }, []); + + const isBrowserSupported = (browser: string): boolean => { + return supportedBrowsers.includes(browser); + }; + + const checkBrowserSupport = async (browser: string): Promise => { + try { + return await invoke("is_browser_supported_on_platform", { + browserStr: browser, + }); + } catch (err) { + console.error(`Failed to check support for browser ${browser}:`, err); + return false; + } + }; + + return { + supportedBrowsers, + isLoading, + error, + isBrowserSupported, + checkBrowserSupport, + }; +} diff --git a/tailwind.config.js b/tailwind.config.js index 2c86fe2..9eadda5 100644 --- a/tailwind.config.js +++ b/tailwind.config.js @@ -1,4 +1,4 @@ -module.exports = { +export default { darkMode: "class", theme: { extend: {