Compare commits

...
Author SHA1 Message Date
zhom 18aa3cb87b test: fix 2025-06-06 00:22:01 +04:00
zhom f066105c0f build: remove universal macos build until @yao-pkg/pkg has support for universal macos binaries 2025-06-06 00:10:20 +04:00
zhom 9d8b3629f6 docs: readme 2025-06-06 00:04:38 +04:00
zhom 353e149886 build: switch to ubuntu 22 for linux build 2025-06-05 23:59:44 +04:00
zhom 2258edbb18 test: fix 2025-06-05 23:45:03 +04:00
zhom 69fff99bbe docs: readme 2025-06-05 23:38:16 +04:00
zhom a277b7d8a2 build: fix script path 2025-06-05 23:35:38 +04:00
zhom 7af3f7e86a docs: update readme 2025-06-05 23:34:48 +04:00
zhom de9c47241a build: cargo audit && copy binary instead of moving 2025-06-05 23:25:43 +04:00
zhom 5747729e30 build: use cargo audit for rust linting 2025-06-05 23:12:08 +04:00
zhom f71bda0fbf build: don't set extra build variables on linux arm systems 2025-06-05 22:59:51 +04:00
zhom 67bfb17e5a fix: correctly mark tor browser alpha versions: 2025-06-05 22:11:57 +04:00
zhom bb2eb65c1e chore: linter 2025-06-05 21:26:47 +04:00
zhom f397568785 docs: update readme to include linux 2025-06-05 21:17:10 +04:00
zhom 0da34f04cb feat: linux support preview 2025-06-05 21:15:05 +04:00
40 changed files with 3837 additions and 979 deletions
+8 -8
View File
@@ -1,14 +1,14 @@
# ✨ Pull Request # ✨ Pull Request
### 📓 Referenced Issue ## 📓 Referenced Issue
<!-- Please link the related issue. Use # before the issue number and use the verbs 'fixes', 'resolves' to auto-link it, for eg, Fixes: #<issue-number> --> <!-- Please link the related issue. Use # before the issue number and use the verbs 'fixes', 'resolves' to auto-link it, for eg, Fixes: #<issue-number> -->
### ️ About the PR ## ️ About the PR
<!-- Please provide a description of your solution if it is not clear in the related issue or if the PR has a breaking change. If there is an interesting topic to discuss or you have questions or there is an issue with Tauri, Rust, or another library that you have used. --> <!-- Please provide a description of your solution if it is not clear in the related issue or if the PR has a breaking change. If there is an interesting topic to discuss or you have questions or there is an issue with Tauri, Rust, or another library that you have used. -->
### 🔄 Type of Change ## 🔄 Type of Change
<!-- Mark the relevant option with an "x". --> <!-- Mark the relevant option with an "x". -->
@@ -19,11 +19,11 @@
- [ ] 🧹 Code cleanup/refactoring - [ ] 🧹 Code cleanup/refactoring
- [ ] ⚡ Performance improvement - [ ] ⚡ Performance improvement
### 🖼️ Testing Scenarios / Screenshots ## 🖼️ Testing Scenarios / Screenshots
<!-- Please include screenshots or gif to showcase the final output. Also, try to explain the testing you did to validate your change. --> <!-- Please include screenshots or gif to showcase the final output. Also, try to explain the testing you did to validate your change. -->
### ✅ Checklist ## ✅ Checklist
<!-- Mark completed items with an "x". --> <!-- Mark completed items with an "x". -->
@@ -36,11 +36,11 @@
- [ ] New and existing unit tests pass locally with my changes - [ ] New and existing unit tests pass locally with my changes
- [ ] Any dependent changes have been merged and published - [ ] Any dependent changes have been merged and published
### 🧪 How Has This Been Tested? ## 🧪 How Has This Been Tested?
<!-- Please describe the tests that you ran to verify your changes. --> <!-- Please describe the tests that you ran to verify your changes. -->
### 📱 Platform Testing ## 📱 Platform Testing
<!-- Which platforms have you tested on? --> <!-- Which platforms have you tested on? -->
@@ -49,6 +49,6 @@
- [ ] Windows (if applicable) - [ ] Windows (if applicable)
- [ ] Linux (if applicable) - [ ] Linux (if applicable)
### 📋 Additional Notes ## 📋 Additional Notes
<!-- Any additional information that reviewers should know about this PR. --> <!-- Any additional information that reviewers should know about this PR. -->
+8 -1
View File
@@ -55,6 +55,9 @@ jobs:
with: with:
components: rustfmt, clippy components: rustfmt, clippy
- name: Install cargo-audit
run: cargo install cargo-audit
- name: Install dependencies (Ubuntu only) - name: Install dependencies (Ubuntu only)
if: matrix.os == 'ubuntu-latest' if: matrix.os == 'ubuntu-latest'
run: | run: |
@@ -76,7 +79,7 @@ jobs:
if [[ "${{ matrix.os }}" == "ubuntu-latest" ]]; then if [[ "${{ matrix.os }}" == "ubuntu-latest" ]]; then
pnpm run build:linux-x64 pnpm run build:linux-x64
elif [[ "${{ matrix.os }}" == "macos-latest" ]]; then elif [[ "${{ matrix.os }}" == "macos-latest" ]]; then
pnpm run build:aarch64 pnpm run build:mac-aarch64
elif [[ "${{ matrix.os }}" == "windows-latest" ]]; then elif [[ "${{ matrix.os }}" == "windows-latest" ]]; then
pnpm run build:win-x64 pnpm run build:win-x64
fi fi
@@ -107,3 +110,7 @@ jobs:
- name: Run Rust unit tests - name: Run Rust unit tests
run: cargo test run: cargo test
working-directory: src-tauri working-directory: src-tauri
- name: Run cargo audit security check
run: cargo audit
working-directory: src-tauri
+19 -15
View File
@@ -50,26 +50,26 @@ jobs:
arch: "aarch64" arch: "aarch64"
target: "aarch64-apple-darwin" target: "aarch64-apple-darwin"
pkg_target: "latest-macos-arm64" pkg_target: "latest-macos-arm64"
nodecar_script: "build:aarch64" nodecar_script: "build:mac-aarch64"
- platform: "macos-latest" - platform: "macos-latest"
args: "--target x86_64-apple-darwin" args: "--target x86_64-apple-darwin"
arch: "x86_64" arch: "x86_64"
target: "x86_64-apple-darwin" target: "x86_64-apple-darwin"
pkg_target: "latest-macos-x64" pkg_target: "latest-macos-x64"
nodecar_script: "build:x86_64" nodecar_script: "build:mac-x86_64"
- platform: "ubuntu-22.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-22.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: # 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" # - platform: "windows-latest"
# args: "--target x86_64-pc-windows-msvc" # args: "--target x86_64-pc-windows-msvc"
# arch: "x86_64" # arch: "x86_64"
@@ -101,10 +101,14 @@ jobs:
targets: ${{ matrix.target }} targets: ${{ matrix.target }}
- name: Install dependencies (Ubuntu only) - name: Install dependencies (Ubuntu only)
if: matrix.platform == 'ubuntu-20.04' if: matrix.platform == 'ubuntu-22.04'
run: | run: |
sudo apt-get update sudo apt-get update
sudo apt-get install -y libwebkit2gtk-4.1-dev libgtk-3-dev libayatana-appindicator3-dev librsvg2-dev 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: Rust cache - name: Rust cache
uses: swatinem/rust-cache@v2 uses: swatinem/rust-cache@v2
+29 -3
View File
@@ -49,13 +49,25 @@ jobs:
arch: "aarch64" arch: "aarch64"
target: "aarch64-apple-darwin" target: "aarch64-apple-darwin"
pkg_target: "latest-macos-arm64" pkg_target: "latest-macos-arm64"
nodecar_script: "build:aarch64" nodecar_script: "build:mac-aarch64"
- platform: "macos-latest" - platform: "macos-latest"
args: "--target x86_64-apple-darwin" args: "--target x86_64-apple-darwin"
arch: "x86_64" arch: "x86_64"
target: "x86_64-apple-darwin" target: "x86_64-apple-darwin"
pkg_target: "latest-macos-x64" pkg_target: "latest-macos-x64"
nodecar_script: "build:x86_64" nodecar_script: "build:mac-x86_64"
- platform: "ubuntu-22.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-22.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 }} runs-on: ${{ matrix.platform }}
steps: steps:
@@ -74,6 +86,16 @@ jobs:
with: with:
targets: ${{ matrix.target }} targets: ${{ matrix.target }}
- name: Install dependencies (Ubuntu only)
if: matrix.platform == 'ubuntu-22.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: Rust cache - name: Rust cache
uses: swatinem/rust-cache@v2 uses: swatinem/rust-cache@v2
with: with:
@@ -97,7 +119,11 @@ jobs:
shell: bash shell: bash
run: | run: |
mkdir -p src-tauri/binaries 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 - name: Build frontend
run: pnpm build run: pnpm build
+3
View File
@@ -46,4 +46,7 @@ yarn-error.log*
# typescript # typescript
*.tsbuildinfo *.tsbuildinfo
# eslint
.eslintcache
!**/.gitkeep !**/.gitkeep
+1 -1
View File
@@ -1 +1 @@
pnpm lint-staged pnpm exec lint-staged
+1 -1
View File
@@ -1,2 +1,2 @@
22 23
+1
View File
@@ -0,0 +1 @@
23
+11
View File
@@ -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"
]
}
+15
View File
@@ -1,5 +1,6 @@
{ {
"cSpell.words": [ "cSpell.words": [
"appindicator",
"applescript", "applescript",
"autoconfig", "autoconfig",
"autologin", "autologin",
@@ -9,13 +10,25 @@
"checkin", "checkin",
"clippy", "clippy",
"codegen", "codegen",
"devedition",
"donutbrowser", "donutbrowser",
"dtolnay", "dtolnay",
"elif", "elif",
"esbuild", "esbuild",
"eslintcache",
"frontmost",
"gifs", "gifs",
"gsettings",
"icns",
"idletime",
"KHTML",
"launchservices", "launchservices",
"libayatana",
"librsvg",
"libwebkit",
"mountpoint", "mountpoint",
"msvc",
"msys",
"Mullvad", "Mullvad",
"nodecar", "nodecar",
"ntlm", "ntlm",
@@ -33,6 +46,7 @@
"sonner", "sonner",
"sspi", "sspi",
"staticlib", "staticlib",
"subdirs",
"swatinem", "swatinem",
"sysinfo", "sysinfo",
"systempreferences", "systempreferences",
@@ -42,6 +56,7 @@
"turbopack", "turbopack",
"unlisten", "unlisten",
"unrs", "unrs",
"vercel",
"wiremock", "wiremock",
"xattr", "xattr",
"zhom" "zhom"
+15 -22
View File
@@ -1,46 +1,35 @@
<div align="center"> # Donut Browser
<img src="assets/logo.png" alt="Donut Browser Logo" width="150">
<h1>Donut Browser</h1>
<strong>A powerful browser orchestrator that puts you in control of your browsing experience. 🍩</strong>
</div>
<br>
<p align="center"> ![Donut Browser Logo](assets/logo.png)
<a href="https://github.com/zhom/donutbrowser/releases/latest" target="_blank"><img alt="GitHub release" src="https://img.shields.io/github/v/release/zhom/donutbrowser">
</a>
<a href="https://github.com/zhom/donutbrowser/issues" target="_blank">
<img src="https://img.shields.io/badge/PRs-welcome-brightgreen.svg?style=flat" alt="PRs Welcome">
</a>
<a href="https://github.com/zhom/donutbrowser/blob/main/LICENSE" target="_blank">
<img src="https://img.shields.io/badge/license-AGPL--3.0-blue.svg" alt="License">
</a>
<a href="https://github.com/zhom/donutbrowser/stargazers" target="_blank">
<img src="https://img.shields.io/github/stars/zhom/donutbrowser?style=social" alt="GitHub stars">
</a>
</p>
## Donut Browser > **A powerful browser orchestrator that puts you in control of your browsing experience. 🍩**
> A free and open source browser orchestrator built with [Tauri](https://v2.tauri.app/). [![GitHub Release](https://img.shields.io/github/v/release/zhom/donutbrowser)](https://github.com/zhom/donutbrowser/releases/latest)
[![RPs Welcome!](https://img.shields.io/badge/PRs-welcome-brightgreen.svg?style=flat)](https://github.com/zhom/donutbrowser/issues)
[![License](https://img.shields.io/badge/license-AGPL--3.0-blue.svg)](https://github.com/zhom/donutbrowser/blob/main/LICENSE)
[![GitHub Stars](https://img.shields.io/github/stars/zhom/donutbrowser?style=social)](https://github.com/zhom/donutbrowser/stargazers)
![Donut Browser Preview](assets/preview.png) ![Donut Browser Preview](assets/preview.png)
## Download ## Download
> As of right now, the app is not signed by Apple. You need to have Gatekeeper disabled to run it. The app automatically checks for updates on each launch. > As of right now, the app is not signed by Apple. You need to have Gatekeeper disabled to run it. The app automatically checks for updates on each launch.
> For Linux, .deb and .rpm packages are available as well as standalone .AppImage files.
The app can be downloaded from the [releases page](https://github.com/zhom/donutbrowser/releases/latest). The app can be downloaded from the [releases page](https://github.com/zhom/donutbrowser/releases/latest).
## Supported Platforms ## Supported Platforms
-**macOS** (Intel & Apple Silicon) -**macOS** (Intel & Apple Silicon)
-**Linux** (x64 & arm64)
- 🔄 **Windows** (Planned) - 🔄 **Windows** (Planned)
- 🔄 **Linux** (Planned)
## Development ## Development
### Contributing ### Contributing
> Donut Browser is built with [Tauri](https://v2.tauri.app/).
See [CONTRIBUTING.md](CONTRIBUTING.md). See [CONTRIBUTING.md](CONTRIBUTING.md).
## Issues ## Issues
@@ -54,6 +43,10 @@ Have questions or want to contribute? We'd love to hear from you!
- **Issues**: [GitHub Issues](https://github.com/zhom/donutbrowser/issues) - **Issues**: [GitHub Issues](https://github.com/zhom/donutbrowser/issues)
- **Discussions**: [GitHub Discussions](https://github.com/zhom/donutbrowser/discussions) - **Discussions**: [GitHub Discussions](https://github.com/zhom/donutbrowser/discussions)
## Star History
[![Star History Chart](https://api.star-history.com/svg?repos=zhom/donutbrowser&type=Date)](https://www.star-history.com/#zhom/donutbrowser&Date)
## Contact ## Contact
Have an urgent question or want to report a security vulnerability? Send an email to contact at donutbrowser dot com and we'll get back to you as fast as possible. Have an urgent question or want to report a security vulnerability? Send an email to contact at donutbrowser dot com and we'll get back to you as fast as possible.
+25
View File
@@ -0,0 +1,25 @@
#!/bin/bash
# Determine file extension based on platform
if [[ "$OSTYPE" == "msys" || "$OSTYPE" == "win32" || "$OSTYPE" == "cygwin" ]]; then
EXT=".exe"
else
EXT=""
fi
# If architecture provided in the command line, use it to rename the binary in TARGET_TRIPLE
if [ -n "$1" ]; then
TARGET_TRIPLE="$1"
else
RUST_INFO=$(rustc -vV)
TARGET_TRIPLE=$(echo "$RUST_INFO" | grep -o 'host: [^ ]*' | cut -d' ' -f2)
fi
# Check if target triple was found
if [ -z "$TARGET_TRIPLE" ]; then
echo "Failed to determine platform target triple" >&2
exit 1
fi
# Copy the file
cp "dist/nodecar${EXT}" "../src-tauri/binaries/nodecar-${TARGET_TRIPLE}${EXT}"
+8 -8
View File
@@ -7,18 +7,18 @@
"watch": "nodemon --exec ts-node --esm ./src/index.ts --watch src", "watch": "nodemon --exec ts-node --esm ./src/index.ts --watch src",
"dev": "node --loader ts-node/esm ./src/index.ts", "dev": "node --loader ts-node/esm ./src/index.ts",
"start": "node --loader ts-node/esm ./src/index.ts", "start": "node --loader ts-node/esm ./src/index.ts",
"build": "tsc && pkg ./dist/index.js --targets latest-macos-arm64 --output dist/nodecar", "rename-binary": "sh ./copy-binary.sh",
"build:aarch64": "tsc && pkg ./dist/index.js --targets latest-macos-arm64 --output dist/nodecar", "build": "tsc && pkg ./dist/index.js --targets latest-macos-arm64 --output dist/nodecar && pnpm rename-binary",
"build:x86_64": "tsc && pkg ./dist/index.js --targets latest-macos-x64 --output dist/nodecar", "build:mac-aarch64": "tsc && pkg ./dist/index.js --targets latest-macos-arm64 --output dist/nodecar && pnpm rename-binary",
"build:linux-x64": "tsc && pkg ./dist/index.js --targets latest-linux-x64 --output dist/nodecar", "build:mac-x86_64": "tsc && pkg ./dist/index.js --targets latest-macos-x64 --output dist/nodecar && pnpm rename-binary",
"build:linux-arm64": "tsc && pkg ./dist/index.js --targets latest-linux-arm64 --output dist/nodecar", "build:linux-x64": "tsc && pkg ./dist/index.js --targets latest-linux-x64 --output dist/nodecar && pnpm rename-binary",
"build:win-x64": "tsc && pkg ./dist/index.js --targets latest-win-x64 --output dist/nodecar", "build:linux-arm64": "tsc && pkg ./dist/index.js --targets latest-linux-arm64 --output dist/nodecar && pnpm rename-binary",
"build:win-arm64": "tsc && pkg ./dist/index.js --targets latest-win-arm64 --output dist/nodecar" "build:win-x64": "tsc && pkg ./dist/index.js --targets latest-win-x64 --output dist/nodecar && pnpm rename-binary",
"build:win-arm64": "tsc && pkg ./dist/index.js --targets latest-win-arm64 --output dist/nodecar && pnpm rename-binary"
}, },
"keywords": [], "keywords": [],
"author": "", "author": "",
"license": "AGPL-3.0", "license": "AGPL-3.0",
"packageManager": "pnpm@10.11.1+sha512.e519b9f7639869dc8d5c3c5dfef73b3f091094b0a006d7317353c72b124e80e1afd429732e28705ad6bfa1ee879c1fce46c128ccebd3192101f43dd67c667912",
"dependencies": { "dependencies": {
"@types/node": "^22.15.29", "@types/node": "^22.15.29",
"@yao-pkg/pkg": "^6.5.1", "@yao-pkg/pkg": "^6.5.1",
-14
View File
@@ -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}`
);
+10 -6
View File
@@ -8,16 +8,19 @@
"dev": "next dev --turbopack", "dev": "next dev --turbopack",
"build": "next build", "build": "next build",
"start": "next start", "start": "next start",
"test": "pnpm test:rust",
"test:rust": "cd src-tauri && cargo test",
"lint": "pnpm lint:js && pnpm lint:rust", "lint": "pnpm lint:js && pnpm lint:rust",
"lint:js": "biome check src/ && tsc --noEmit && next lint", "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", "lint:rust": "cd src-tauri && cargo clippy --all-targets --all-features -- -D warnings -D clippy::all && cargo fmt --all",
"tauri": "tauri", "tauri": "tauri",
"shadcn:add": "pnpm dlx shadcn@latest add", "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: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:js": "biome check src/ --fix",
"format": "pnpm format:js && pnpm format:rust", "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": { "dependencies": {
"@radix-ui/react-checkbox": "^1.3.2", "@radix-ui/react-checkbox": "^1.3.2",
@@ -54,7 +57,7 @@
"@tauri-apps/cli": "^2.5.0", "@tauri-apps/cli": "^2.5.0",
"@types/node": "^22.15.29", "@types/node": "^22.15.29",
"@types/react": "^19.1.6", "@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/eslint-plugin": "^8.33.1",
"@typescript-eslint/parser": "^8.33.1", "@typescript-eslint/parser": "^8.33.1",
"@vitejs/plugin-react": "^4.5.1", "@vitejs/plugin-react": "^4.5.1",
@@ -64,14 +67,15 @@
"husky": "^9.1.7", "husky": "^9.1.7",
"lint-staged": "^16.1.0", "lint-staged": "^16.1.0",
"tailwindcss": "^4.1.8", "tailwindcss": "^4.1.8",
"tw-animate-css": "^1.3.3", "tw-animate-css": "^1.3.4",
"typescript": "~5.8.3", "typescript": "~5.8.3",
"typescript-eslint": "^8.33.1" "typescript-eslint": "^8.33.1"
}, },
"packageManager": "pnpm@10.11.0+sha512.6540583f41cc5f628eb3d9773ecee802f4f9ef9923cc45b69890fb47991d4b092964694ec3a4f738a420c918a333062c8b925d312f42e4f0c263eb603551f977", "packageManager": "pnpm@10.11.1",
"lint-staged": { "lint-staged": {
"src/**/*.{js,jsx,ts,tsx,json,css,md}": [ "src/**/*.{js,jsx,ts,tsx,json,css,md}": [
"biome check --fix" "biome check --fix",
"eslint --cache --fix"
], ],
"src-tauri/**/*.rs": [ "src-tauri/**/*.rs": [
"cd src-tauri && cargo fmt --all", "cd src-tauri && cargo fmt --all",
+122 -122
View File
@@ -10,34 +10,34 @@ importers:
dependencies: dependencies:
'@radix-ui/react-checkbox': '@radix-ui/react-checkbox':
specifier: ^1.3.2 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': '@radix-ui/react-dialog':
specifier: ^1.1.14 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': '@radix-ui/react-dropdown-menu':
specifier: ^2.1.15 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': '@radix-ui/react-label':
specifier: ^2.1.7 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': '@radix-ui/react-popover':
specifier: ^1.1.14 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': '@radix-ui/react-progress':
specifier: ^1.1.7 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': '@radix-ui/react-scroll-area':
specifier: ^1.2.9 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': '@radix-ui/react-select':
specifier: ^2.2.5 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': '@radix-ui/react-slot':
specifier: ^1.2.3 specifier: ^1.2.3
version: 1.2.3(@types/react@19.1.6)(react@19.1.0) version: 1.2.3(@types/react@19.1.6)(react@19.1.0)
'@radix-ui/react-tooltip': '@radix-ui/react-tooltip':
specifier: ^1.2.7 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': '@tanstack/react-table':
specifier: ^8.21.3 specifier: ^8.21.3
version: 8.21.3(react-dom@19.1.0(react@19.1.0))(react@19.1.0) 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 version: 2.1.1
cmdk: cmdk:
specifier: ^1.1.1 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: next:
specifier: ^15.3.3 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) 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 specifier: ^19.1.6
version: 19.1.6 version: 19.1.6
'@types/react-dom': '@types/react-dom':
specifier: ^19.1.5 specifier: ^19.1.6
version: 19.1.5(@types/react@19.1.6) version: 19.1.6(@types/react@19.1.6)
'@typescript-eslint/eslint-plugin': '@typescript-eslint/eslint-plugin':
specifier: ^8.33.1 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) 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 specifier: ^4.1.8
version: 4.1.8 version: 4.1.8
tw-animate-css: tw-animate-css:
specifier: ^1.3.3 specifier: ^1.3.4
version: 1.3.3 version: 1.3.4
typescript: typescript:
specifier: ~5.8.3 specifier: ~5.8.3
version: 5.8.3 version: 5.8.3
@@ -1495,8 +1495,8 @@ packages:
'@types/node@22.15.29': '@types/node@22.15.29':
resolution: {integrity: sha512-LNdjOkUDlU1RZb8e1kOIUpN1qQUlzGkEtbVNo53vbrwDg5om6oduhm4SiUaPW5ASTXhAiP0jInWG8Qx9fVlOeQ==} resolution: {integrity: sha512-LNdjOkUDlU1RZb8e1kOIUpN1qQUlzGkEtbVNo53vbrwDg5om6oduhm4SiUaPW5ASTXhAiP0jInWG8Qx9fVlOeQ==}
'@types/react-dom@19.1.5': '@types/react-dom@19.1.6':
resolution: {integrity: sha512-CMCjrWucUBZvohgZxkjd6S9h0nZxXjzus6yDfUb+xLxYM7VvjKNH1tQrE9GWLql1XoOP4/Ds3bwFqShHUYraGg==} resolution: {integrity: sha512-4hOiT/dwO8Ko0gV1m/TJZYk3y0KBnY9vzDh7W+DH17b2HFSOGgdj33dhihPeuy3l0q23+4e+hoXHV6hCC4dCXw==}
peerDependencies: peerDependencies:
'@types/react': ^19.0.0 '@types/react': ^19.0.0
@@ -1830,8 +1830,8 @@ packages:
resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==} resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==}
engines: {node: '>=6'} engines: {node: '>=6'}
caniuse-lite@1.0.30001720: caniuse-lite@1.0.30001721:
resolution: {integrity: sha512-Ec/2yV2nNPwb4DnTANEV99ZWwm3ZWfdlfkQbWSDDt+PsXEVYwlhPH8tdMaPunYTKKmz7AnHi2oNEi1GcmKCD8g==} resolution: {integrity: sha512-cOuvmUVtKrtEaoKiO0rSc29jcjwMwX5tOHDy4MgVFEWiUXj4uBMJkwI8MDySkgXidpMiHUcviogAvFi4pA2hDQ==}
chalk@4.1.2: chalk@4.1.2:
resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==}
@@ -1996,8 +1996,8 @@ packages:
duplexer2@0.1.4: duplexer2@0.1.4:
resolution: {integrity: sha512-asLFVfWWtJ90ZyOUHMqk7/S2w2guQKxUI2itj3d92ADHhxUSbCMGi1f1cBcJ7xM1To+pE/Khbwo1yuNbMEPKeA==} resolution: {integrity: sha512-asLFVfWWtJ90ZyOUHMqk7/S2w2guQKxUI2itj3d92ADHhxUSbCMGi1f1cBcJ7xM1To+pE/Khbwo1yuNbMEPKeA==}
electron-to-chromium@1.5.162: electron-to-chromium@1.5.165:
resolution: {integrity: sha512-hQA+Zb5QQwoSaXJWEAGEw1zhk//O7qDzib05Z4qTqZfNju/FAkrm5ZInp0JbTp4Z18A6bilopdZWEYrFSsfllA==} resolution: {integrity: sha512-naiMx1Z6Nb2TxPU6fiFrUrDTjyPMLdTtaOd2oLmG8zVSg2hCWGkhPyxwk+qRmZ1ytwVqUv0u7ZcDA5+ALhaUtw==}
emoji-regex@10.4.0: emoji-regex@10.4.0:
resolution: {integrity: sha512-EC+0oUMY1Rqm4O6LLrgjtYDvcVYTy7chDnM4Q7030tP4Kwj3u/pR6gP9ygnp2CJMK5Gq+9Q2oqmrFJAz01DXjw==} resolution: {integrity: sha512-EC+0oUMY1Rqm4O6LLrgjtYDvcVYTy7chDnM4Q7030tP4Kwj3u/pR6gP9ygnp2CJMK5Gq+9Q2oqmrFJAz01DXjw==}
@@ -3368,8 +3368,8 @@ packages:
tunnel-agent@0.6.0: tunnel-agent@0.6.0:
resolution: {integrity: sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==} resolution: {integrity: sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==}
tw-animate-css@1.3.3: tw-animate-css@1.3.4:
resolution: {integrity: sha512-tXE2TRWrskc4TU3RDd7T8n8Np/wCfoeH9gz22c7PzYqNPQ9FBGFbWWzwL0JyHcFp+jHozmF76tbHfPAx22ua2Q==} resolution: {integrity: sha512-dd1Ht6/YQHcNbq0znIT6dG8uhO7Ce+VIIhZUhjsryXsMPJQz3bZg7Q2eNzLwipb25bRZslGb2myio5mScd1TFg==}
type-check@0.4.0: type-check@0.4.0:
resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==}
@@ -4057,22 +4057,22 @@ snapshots:
'@radix-ui/primitive@1.1.2': {} '@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: 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: 19.1.0
react-dom: 19.1.0(react@19.1.0) react-dom: 19.1.0(react@19.1.0)
optionalDependencies: optionalDependencies:
'@types/react': 19.1.6 '@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: dependencies:
'@radix-ui/primitive': 1.1.2 '@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-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-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-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.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-controllable-state': 1.2.2(@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-previous': 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-use-size': 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) react-dom: 19.1.0(react@19.1.0)
optionalDependencies: optionalDependencies:
'@types/react': 19.1.6 '@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: dependencies:
'@radix-ui/react-compose-refs': 1.1.2(@types/react@19.1.6)(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-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) '@radix-ui/react-slot': 1.2.3(@types/react@19.1.6)(react@19.1.0)
react: 19.1.0 react: 19.1.0
react-dom: 19.1.0(react@19.1.0) react-dom: 19.1.0(react@19.1.0)
optionalDependencies: optionalDependencies:
'@types/react': 19.1.6 '@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)': '@radix-ui/react-compose-refs@1.1.2(@types/react@19.1.6)(react@19.1.0)':
dependencies: dependencies:
@@ -4106,18 +4106,18 @@ snapshots:
optionalDependencies: optionalDependencies:
'@types/react': 19.1.6 '@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: dependencies:
'@radix-ui/primitive': 1.1.2 '@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-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-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-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-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-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.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.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) '@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-use-controllable-state': 1.2.2(@types/react@19.1.6)(react@19.1.0)
aria-hidden: 1.2.6 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) react-remove-scroll: 2.7.1(@types/react@19.1.6)(react@19.1.0)
optionalDependencies: optionalDependencies:
'@types/react': 19.1.6 '@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)': '@radix-ui/react-direction@1.1.1(@types/react@19.1.6)(react@19.1.0)':
dependencies: dependencies:
@@ -4134,33 +4134,33 @@ snapshots:
optionalDependencies: optionalDependencies:
'@types/react': 19.1.6 '@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: dependencies:
'@radix-ui/primitive': 1.1.2 '@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-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-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) '@radix-ui/react-use-escape-keydown': 1.1.1(@types/react@19.1.6)(react@19.1.0)
react: 19.1.0 react: 19.1.0
react-dom: 19.1.0(react@19.1.0) react-dom: 19.1.0(react@19.1.0)
optionalDependencies: optionalDependencies:
'@types/react': 19.1.6 '@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: dependencies:
'@radix-ui/primitive': 1.1.2 '@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-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-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-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-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.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-controllable-state': 1.2.2(@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: 19.1.0
react-dom: 19.1.0(react@19.1.0) react-dom: 19.1.0(react@19.1.0)
optionalDependencies: optionalDependencies:
'@types/react': 19.1.6 '@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)': '@radix-ui/react-focus-guards@1.1.2(@types/react@19.1.6)(react@19.1.0)':
dependencies: dependencies:
@@ -4168,16 +4168,16 @@ snapshots:
optionalDependencies: optionalDependencies:
'@types/react': 19.1.6 '@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: dependencies:
'@radix-ui/react-compose-refs': 1.1.2(@types/react@19.1.6)(react@19.1.0) '@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-callback-ref': 1.1.1(@types/react@19.1.6)(react@19.1.0)
react: 19.1.0 react: 19.1.0
react-dom: 19.1.0(react@19.1.0) react-dom: 19.1.0(react@19.1.0)
optionalDependencies: optionalDependencies:
'@types/react': 19.1.6 '@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)': '@radix-ui/react-id@1.1.1(@types/react@19.1.6)(react@19.1.0)':
dependencies: dependencies:
@@ -4186,31 +4186,31 @@ snapshots:
optionalDependencies: optionalDependencies:
'@types/react': 19.1.6 '@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: 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: 19.1.0
react-dom: 19.1.0(react@19.1.0) react-dom: 19.1.0(react@19.1.0)
optionalDependencies: optionalDependencies:
'@types/react': 19.1.6 '@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: dependencies:
'@radix-ui/primitive': 1.1.2 '@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-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-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-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-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-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-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.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.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.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-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)
'@radix-ui/react-slot': 1.2.3(@types/react@19.1.6)(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-callback-ref': 1.1.1(@types/react@19.1.6)(react@19.1.0)
aria-hidden: 1.2.6 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) react-remove-scroll: 2.7.1(@types/react@19.1.6)(react@19.1.0)
optionalDependencies: optionalDependencies:
'@types/react': 19.1.6 '@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: dependencies:
'@radix-ui/primitive': 1.1.2 '@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-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-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-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-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-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.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.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.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) '@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-use-controllable-state': 1.2.2(@types/react@19.1.6)(react@19.1.0)
aria-hidden: 1.2.6 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) react-remove-scroll: 2.7.1(@types/react@19.1.6)(react@19.1.0)
optionalDependencies: optionalDependencies:
'@types/react': 19.1.6 '@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: dependencies:
'@floating-ui/react-dom': 2.1.3(react-dom@19.1.0(react@19.1.0))(react@19.1.0) '@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-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-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-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-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) '@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) react-dom: 19.1.0(react@19.1.0)
optionalDependencies: optionalDependencies:
'@types/react': 19.1.6 '@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: 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) '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.1.6)(react@19.1.0)
react: 19.1.0 react: 19.1.0
react-dom: 19.1.0(react@19.1.0) react-dom: 19.1.0(react@19.1.0)
optionalDependencies: optionalDependencies:
'@types/react': 19.1.6 '@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: dependencies:
'@radix-ui/react-compose-refs': 1.1.2(@types/react@19.1.6)(react@19.1.0) '@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) '@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) react-dom: 19.1.0(react@19.1.0)
optionalDependencies: optionalDependencies:
'@types/react': 19.1.6 '@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: dependencies:
'@radix-ui/react-slot': 1.2.3(@types/react@19.1.6)(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: 19.1.0
react-dom: 19.1.0(react@19.1.0) react-dom: 19.1.0(react@19.1.0)
optionalDependencies: optionalDependencies:
'@types/react': 19.1.6 '@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: dependencies:
'@radix-ui/react-context': 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)
react: 19.1.0 react: 19.1.0
react-dom: 19.1.0(react@19.1.0) react-dom: 19.1.0(react@19.1.0)
optionalDependencies: optionalDependencies:
'@types/react': 19.1.6 '@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: dependencies:
'@radix-ui/primitive': 1.1.2 '@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-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-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-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-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-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-controllable-state': 1.2.2(@types/react@19.1.6)(react@19.1.0)
react: 19.1.0 react: 19.1.0
react-dom: 19.1.0(react@19.1.0) react-dom: 19.1.0(react@19.1.0)
optionalDependencies: optionalDependencies:
'@types/react': 19.1.6 '@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: dependencies:
'@radix-ui/number': 1.1.1 '@radix-ui/number': 1.1.1
'@radix-ui/primitive': 1.1.2 '@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-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-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-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-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.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-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-layout-effect': 1.1.1(@types/react@19.1.6)(react@19.1.0)
react: 19.1.0 react: 19.1.0
react-dom: 19.1.0(react@19.1.0) react-dom: 19.1.0(react@19.1.0)
optionalDependencies: optionalDependencies:
'@types/react': 19.1.6 '@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: dependencies:
'@radix-ui/number': 1.1.1 '@radix-ui/number': 1.1.1
'@radix-ui/primitive': 1.1.2 '@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-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-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-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-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-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-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.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-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) '@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-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-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-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-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 aria-hidden: 1.2.6
react: 19.1.0 react: 19.1.0
react-dom: 19.1.0(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) react-remove-scroll: 2.7.1(@types/react@19.1.6)(react@19.1.0)
optionalDependencies: optionalDependencies:
'@types/react': 19.1.6 '@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)': '@radix-ui/react-slot@1.2.3(@types/react@19.1.6)(react@19.1.0)':
dependencies: dependencies:
@@ -4371,25 +4371,25 @@ snapshots:
optionalDependencies: optionalDependencies:
'@types/react': 19.1.6 '@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: dependencies:
'@radix-ui/primitive': 1.1.2 '@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-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-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-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-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.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.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.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) '@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-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: 19.1.0
react-dom: 19.1.0(react@19.1.0) react-dom: 19.1.0(react@19.1.0)
optionalDependencies: optionalDependencies:
'@types/react': 19.1.6 '@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)': '@radix-ui/react-use-callback-ref@1.1.1(@types/react@19.1.6)(react@19.1.0)':
dependencies: dependencies:
@@ -4445,14 +4445,14 @@ snapshots:
optionalDependencies: optionalDependencies:
'@types/react': 19.1.6 '@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: 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: 19.1.0
react-dom: 19.1.0(react@19.1.0) react-dom: 19.1.0(react@19.1.0)
optionalDependencies: optionalDependencies:
'@types/react': 19.1.6 '@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': {} '@radix-ui/rect@1.1.1': {}
@@ -4709,7 +4709,7 @@ snapshots:
dependencies: dependencies:
undici-types: 6.21.0 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: dependencies:
'@types/react': 19.1.6 '@types/react': 19.1.6
@@ -5070,8 +5070,8 @@ snapshots:
browserslist@4.25.0: browserslist@4.25.0:
dependencies: dependencies:
caniuse-lite: 1.0.30001720 caniuse-lite: 1.0.30001721
electron-to-chromium: 1.5.162 electron-to-chromium: 1.5.165
node-releases: 2.0.19 node-releases: 2.0.19
update-browserslist-db: 1.1.3(browserslist@4.25.0) update-browserslist-db: 1.1.3(browserslist@4.25.0)
@@ -5103,7 +5103,7 @@ snapshots:
callsites@3.1.0: {} callsites@3.1.0: {}
caniuse-lite@1.0.30001720: {} caniuse-lite@1.0.30001721: {}
chalk@4.1.2: chalk@4.1.2:
dependencies: dependencies:
@@ -5151,12 +5151,12 @@ snapshots:
clsx@2.1.1: {} 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: dependencies:
'@radix-ui/react-compose-refs': 1.1.2(@types/react@19.1.6)(react@19.1.0) '@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-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: 19.1.0
react-dom: 19.1.0(react@19.1.0) react-dom: 19.1.0(react@19.1.0)
transitivePeerDependencies: transitivePeerDependencies:
@@ -5273,7 +5273,7 @@ snapshots:
dependencies: dependencies:
readable-stream: 2.3.8 readable-stream: 2.3.8
electron-to-chromium@1.5.162: {} electron-to-chromium@1.5.165: {}
emoji-regex@10.4.0: {} emoji-regex@10.4.0: {}
@@ -6193,7 +6193,7 @@ snapshots:
'@swc/counter': 0.1.3 '@swc/counter': 0.1.3
'@swc/helpers': 0.5.15 '@swc/helpers': 0.5.15
busboy: 1.6.0 busboy: 1.6.0
caniuse-lite: 1.0.30001720 caniuse-lite: 1.0.30001721
postcss: 8.4.31 postcss: 8.4.31
react: 19.1.0 react: 19.1.0
react-dom: 19.1.0(react@19.1.0) react-dom: 19.1.0(react@19.1.0)
@@ -6899,7 +6899,7 @@ snapshots:
dependencies: dependencies:
safe-buffer: 5.2.1 safe-buffer: 5.2.1
tw-animate-css@1.3.3: {} tw-animate-css@1.3.4: {}
type-check@0.4.0: type-check@0.4.0:
dependencies: dependencies:
+2 -1
View File
@@ -1,9 +1,10 @@
[package] [package]
name = "donutbrowser" name = "donutbrowser"
version = "0.2.5" version = "0.2.5"
description = "Browser Orchestrator" description = "Simple Yet Powerful Browser Orchestrator"
authors = ["zhom@github"] authors = ["zhom@github"]
edition = "2021" edition = "2021"
default-run = "donutbrowser"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
+13
View File
@@ -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;
+144 -61
View File
@@ -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); let version_comp = VersionComponent::parse(version);
version_comp.pre_release.is_some() version_comp.pre_release.is_some()
} }
// Browser-specific alpha version detection for Zen Browser // 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 // For Zen Browser, only "twilight" is considered alpha/pre-release
version.to_lowercase() == "twilight" version.to_lowercase() == "twilight"
} }
@@ -449,7 +449,7 @@ impl ApiClient {
BrowserRelease { BrowserRelease {
version: version.clone(), version: version.clone(),
date: "".to_string(), // Cache doesn't store dates date: "".to_string(), // Cache doesn't store dates
is_prerelease: is_alpha_version(&version), is_prerelease: is_nightly_version(&version),
download_url: Some(format!( download_url: Some(format!(
"{}/?product=firefox-{}&os=osx&lang=en-US", "{}/?product=firefox-{}&os=osx&lang=en-US",
self.mozilla_download_base, version self.mozilla_download_base, version
@@ -467,7 +467,7 @@ impl ApiClient {
let response = self let response = self
.client .client
.get(url) .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() .send()
.await?; .await?;
@@ -534,7 +534,7 @@ impl ApiClient {
BrowserRelease { BrowserRelease {
version: version.clone(), version: version.clone(),
date: "".to_string(), // Cache doesn't store dates date: "".to_string(), // Cache doesn't store dates
is_prerelease: is_alpha_version(&version), is_prerelease: is_nightly_version(&version),
download_url: Some(format!( download_url: Some(format!(
"{}/?product=devedition-{}&os=osx&lang=en-US", "{}/?product=devedition-{}&os=osx&lang=en-US",
self.mozilla_download_base, version self.mozilla_download_base, version
@@ -552,7 +552,7 @@ impl ApiClient {
let response = self let response = self
.client .client
.get(url) .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() .send()
.await?; .await?;
@@ -624,13 +624,13 @@ impl ApiClient {
println!("Fetching Mullvad releases from GitHub API..."); println!("Fetching Mullvad releases from GitHub API...");
let url = format!( let url = format!(
"{}/repos/mullvad/mullvad-browser/releases", "{}/repos/mullvad/mullvad-browser/releases?per_page=100",
self.github_api_base self.github_api_base
); );
let releases = self let releases = self
.client .client
.get(url) .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() .send()
.await? .await?
.json::<Vec<GithubRelease>>() .json::<Vec<GithubRelease>>()
@@ -639,7 +639,7 @@ impl ApiClient {
let mut releases: Vec<GithubRelease> = releases let mut releases: Vec<GithubRelease> = releases
.into_iter() .into_iter()
.map(|mut release| { .map(|mut release| {
release.is_alpha = release.prerelease; release.is_nightly = release.prerelease;
release release
}) })
.collect(); .collect();
@@ -670,13 +670,13 @@ impl ApiClient {
println!("Fetching Zen releases from GitHub API..."); println!("Fetching Zen releases from GitHub API...");
let url = format!( let url = format!(
"{}/repos/zen-browser/desktop/releases", "{}/repos/zen-browser/desktop/releases?per_page=100",
self.github_api_base self.github_api_base
); );
let mut releases = self let mut releases = self
.client .client
.get(url) .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() .send()
.await? .await?
.json::<Vec<GithubRelease>>() .json::<Vec<GithubRelease>>()
@@ -684,8 +684,8 @@ impl ApiClient {
// Check for twilight updates and mark alpha releases // Check for twilight updates and mark alpha releases
for release in &mut releases { for release in &mut releases {
// Use browser-specific alpha detection for Zen Browser // Use browser-specific alpha detection for Zen Browser - only "twilight" is nightly
release.is_alpha = is_zen_alpha_version(&release.tag_name) || release.prerelease; release.is_nightly = is_zen_nightly_version(&release.tag_name);
// Check for twilight update if this is a twilight release // Check for twilight update if this is a twilight release
if release.tag_name.to_lowercase() == "twilight" { if release.tag_name.to_lowercase() == "twilight" {
@@ -726,32 +726,32 @@ impl ApiClient {
println!("Fetching Brave releases from GitHub API..."); println!("Fetching Brave releases from GitHub API...");
let url = format!( let url = format!(
"{}/repos/brave/brave-browser/releases", "{}/repos/brave/brave-browser/releases?per_page=100",
self.github_api_base self.github_api_base
); );
let releases = self let releases = self
.client .client
.get(url) .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() .send()
.await? .await?
.json::<Vec<GithubRelease>>() .json::<Vec<GithubRelease>>()
.await?; .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<GithubRelease> = releases let mut filtered_releases: Vec<GithubRelease> = releases
.into_iter() .into_iter()
.filter_map(|mut release| { .filter_map(|mut release| {
// Check if this release has a universal DMG asset // Check if this release has compatible assets for the current platform
let has_universal_dmg = release let has_compatible_asset = Self::has_compatible_brave_asset(&release.assets, &os, &arch);
.assets
.iter()
.any(|asset| asset.name.contains(".dmg") && asset.name.contains("universal"));
if has_universal_dmg { if has_compatible_asset {
// Set is_alpha based on the release name // Set is_nightly based on the release name
// Nightly releases contain "Nightly", stable contain "Release" // Stable releases start with "Release", everything else is nightly
release.is_alpha = release.name.to_lowercase().contains("nightly"); release.is_nightly = !release.name.starts_with("Release");
Some(release) Some(release)
} else { } else {
None None
@@ -772,6 +772,72 @@ impl ApiClient {
Ok(filtered_releases) 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( pub async fn fetch_chromium_latest_version(
&self, &self,
) -> Result<String, Box<dyn std::error::Error + Send + Sync>> { ) -> Result<String, Box<dyn std::error::Error + Send + Sync>> {
@@ -785,7 +851,7 @@ impl ApiClient {
let version = self let version = self
.client .client
.get(&url) .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() .send()
.await? .await?
.text() .text()
@@ -868,7 +934,7 @@ impl ApiClient {
BrowserRelease { BrowserRelease {
version: version.clone(), version: version.clone(),
date: "".to_string(), // Cache doesn't store dates date: "".to_string(), // Cache doesn't store dates
is_prerelease: false, // Assume all archived versions are stable is_prerelease: is_nightly_version(&version),
download_url: Some(format!( download_url: Some(format!(
"{}/{version}/tor-browser-macos-{version}.dmg", "{}/{version}/tor-browser-macos-{version}.dmg",
self.tor_archive_base self.tor_archive_base
@@ -885,7 +951,7 @@ impl ApiClient {
let html = self let html = self
.client .client
.get(url) .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() .send()
.await? .await?
.text() .text()
@@ -965,7 +1031,7 @@ impl ApiClient {
let html = self let html = self
.client .client
.get(&url) .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() .send()
.await? .await?
.text() .text()
@@ -1032,12 +1098,31 @@ impl ApiClient {
Ok(false) // No update detected Ok(false) // No update detected
} }
pub fn clear_all_cache(&self) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
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)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use wiremock::matchers::{header, method, path}; use wiremock::matchers::{method, path, query_param};
use wiremock::{Mock, MockServer, ResponseTemplate}; use wiremock::{Mock, MockServer, ResponseTemplate};
async fn setup_mock_server() -> MockServer { async fn setup_mock_server() -> MockServer {
@@ -1215,7 +1300,6 @@ mod tests {
Mock::given(method("GET")) Mock::given(method("GET"))
.and(path("/firefox.json")) .and(path("/firefox.json"))
.and(header("user-agent", "donutbrowser"))
.respond_with( .respond_with(
ResponseTemplate::new(200) ResponseTemplate::new(200)
.set_body_string(mock_response) .set_body_string(mock_response)
@@ -1226,6 +1310,9 @@ mod tests {
let result = client.fetch_firefox_releases_with_caching(true).await; 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()); assert!(result.is_ok());
let releases = result.unwrap(); let releases = result.unwrap();
assert!(!releases.is_empty()); assert!(!releases.is_empty());
@@ -1259,7 +1346,6 @@ mod tests {
Mock::given(method("GET")) Mock::given(method("GET"))
.and(path("/devedition.json")) .and(path("/devedition.json"))
.and(header("user-agent", "donutbrowser"))
.respond_with( .respond_with(
ResponseTemplate::new(200) ResponseTemplate::new(200)
.set_body_string(mock_response) .set_body_string(mock_response)
@@ -1272,6 +1358,9 @@ mod tests {
.fetch_firefox_developer_releases_with_caching(true) .fetch_firefox_developer_releases_with_caching(true)
.await; .await;
if let Err(e) = &result {
println!("Firefox Developer API test error: {e}");
}
assert!(result.is_ok()); assert!(result.is_ok());
let releases = result.unwrap(); let releases = result.unwrap();
assert!(!releases.is_empty()); assert!(!releases.is_empty());
@@ -1307,7 +1396,7 @@ mod tests {
Mock::given(method("GET")) Mock::given(method("GET"))
.and(path("/repos/mullvad/mullvad-browser/releases")) .and(path("/repos/mullvad/mullvad-browser/releases"))
.and(header("user-agent", "donutbrowser")) .and(query_param("per_page", "100"))
.respond_with( .respond_with(
ResponseTemplate::new(200) ResponseTemplate::new(200)
.set_body_string(mock_response) .set_body_string(mock_response)
@@ -1322,7 +1411,7 @@ mod tests {
let releases = result.unwrap(); let releases = result.unwrap();
assert!(!releases.is_empty()); assert!(!releases.is_empty());
assert_eq!(releases[0].tag_name, "14.5a6"); assert_eq!(releases[0].tag_name, "14.5a6");
assert!(releases[0].is_alpha); assert!(releases[0].is_nightly);
} }
#[tokio::test] #[tokio::test]
@@ -1348,7 +1437,7 @@ mod tests {
Mock::given(method("GET")) Mock::given(method("GET"))
.and(path("/repos/zen-browser/desktop/releases")) .and(path("/repos/zen-browser/desktop/releases"))
.and(header("user-agent", "donutbrowser")) .and(query_param("per_page", "100"))
.respond_with( .respond_with(
ResponseTemplate::new(200) ResponseTemplate::new(200)
.set_body_string(mock_response) .set_body_string(mock_response)
@@ -1388,7 +1477,7 @@ mod tests {
Mock::given(method("GET")) Mock::given(method("GET"))
.and(path("/repos/brave/brave-browser/releases")) .and(path("/repos/brave/brave-browser/releases"))
.and(header("user-agent", "donutbrowser")) .and(query_param("per_page", "100"))
.respond_with( .respond_with(
ResponseTemplate::new(200) ResponseTemplate::new(200)
.set_body_string(mock_response) .set_body_string(mock_response)
@@ -1399,11 +1488,14 @@ mod tests {
let result = client.fetch_brave_releases_with_caching(true).await; 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()); assert!(result.is_ok());
let releases = result.unwrap(); let releases = result.unwrap();
assert!(!releases.is_empty()); assert!(!releases.is_empty());
assert_eq!(releases[0].tag_name, "v1.81.9"); assert_eq!(releases[0].tag_name, "v1.81.9");
assert!(!releases[0].is_alpha); assert!(releases[0].is_nightly);
} }
#[tokio::test] #[tokio::test]
@@ -1419,7 +1511,6 @@ mod tests {
Mock::given(method("GET")) Mock::given(method("GET"))
.and(path(format!("/{arch}/LAST_CHANGE"))) .and(path(format!("/{arch}/LAST_CHANGE")))
.and(header("user-agent", "donutbrowser"))
.respond_with( .respond_with(
ResponseTemplate::new(200) ResponseTemplate::new(200)
.set_body_string("1465660") .set_body_string("1465660")
@@ -1448,7 +1539,6 @@ mod tests {
Mock::given(method("GET")) Mock::given(method("GET"))
.and(path(format!("/{arch}/LAST_CHANGE"))) .and(path(format!("/{arch}/LAST_CHANGE")))
.and(header("user-agent", "donutbrowser"))
.respond_with( .respond_with(
ResponseTemplate::new(200) ResponseTemplate::new(200)
.set_body_string("1465660") .set_body_string("1465660")
@@ -1491,7 +1581,6 @@ mod tests {
Mock::given(method("GET")) Mock::given(method("GET"))
.and(path("/")) .and(path("/"))
.and(header("user-agent", "donutbrowser"))
.respond_with( .respond_with(
ResponseTemplate::new(200) ResponseTemplate::new(200)
.set_body_string(mock_html) .set_body_string(mock_html)
@@ -1502,7 +1591,6 @@ mod tests {
Mock::given(method("GET")) Mock::given(method("GET"))
.and(path("/14.0.4/")) .and(path("/14.0.4/"))
.and(header("user-agent", "donutbrowser"))
.respond_with( .respond_with(
ResponseTemplate::new(200) ResponseTemplate::new(200)
.set_body_string(version_html) .set_body_string(version_html)
@@ -1513,7 +1601,6 @@ mod tests {
Mock::given(method("GET")) Mock::given(method("GET"))
.and(path("/14.0.3/")) .and(path("/14.0.3/"))
.and(header("user-agent", "donutbrowser"))
.respond_with( .respond_with(
ResponseTemplate::new(200) ResponseTemplate::new(200)
.set_body_string(version_html.replace("14.0.4", "14.0.3")) .set_body_string(version_html.replace("14.0.4", "14.0.3"))
@@ -1551,7 +1638,6 @@ mod tests {
Mock::given(method("GET")) Mock::given(method("GET"))
.and(path("/14.0.4/")) .and(path("/14.0.4/"))
.and(header("user-agent", "donutbrowser"))
.respond_with( .respond_with(
ResponseTemplate::new(200) ResponseTemplate::new(200)
.set_body_string(version_html) .set_body_string(version_html)
@@ -1581,7 +1667,6 @@ mod tests {
Mock::given(method("GET")) Mock::given(method("GET"))
.and(path("/14.0.5/")) .and(path("/14.0.5/"))
.and(header("user-agent", "donutbrowser"))
.respond_with( .respond_with(
ResponseTemplate::new(200) ResponseTemplate::new(200)
.set_body_string(version_html) .set_body_string(version_html)
@@ -1597,24 +1682,24 @@ mod tests {
} }
#[test] #[test]
fn test_is_alpha_version() { fn test_is_nightly_version() {
assert!(is_alpha_version("1.2.3a1")); assert!(is_nightly_version("1.2.3a1"));
assert!(is_alpha_version("137.0b5")); assert!(is_nightly_version("137.0b5"));
assert!(is_alpha_version("140.0rc1")); assert!(is_nightly_version("140.0rc1"));
assert!(!is_alpha_version("139.0")); assert!(!is_nightly_version("139.0"));
assert!(!is_alpha_version("1.2.3")); assert!(!is_nightly_version("1.2.3"));
} }
#[test] #[test]
fn test_is_zen_alpha_version() { fn test_is_zen_nightly_version() {
// Only "twilight" should be considered alpha for Zen Browser // Only "twilight" should be considered nightly for Zen Browser
assert!(is_zen_alpha_version("twilight")); assert!(is_zen_nightly_version("twilight"));
assert!(is_zen_alpha_version("TWILIGHT")); // Case insensitive assert!(is_zen_nightly_version("TWILIGHT")); // Case insensitive
// Versions with "b" should NOT be considered alpha for Zen Browser // Versions with "b" should NOT be considered nightly for Zen Browser
assert!(!is_zen_alpha_version("1.12.8b")); assert!(!is_zen_nightly_version("1.12.8b"));
assert!(!is_zen_alpha_version("1.0.0b1")); assert!(!is_zen_nightly_version("1.0.0b1"));
assert!(!is_zen_alpha_version("2.0.0")); assert!(!is_zen_nightly_version("2.0.0"));
} }
#[tokio::test] #[tokio::test]
@@ -1624,7 +1709,6 @@ mod tests {
Mock::given(method("GET")) Mock::given(method("GET"))
.and(path("/firefox.json")) .and(path("/firefox.json"))
.and(header("user-agent", "donutbrowser"))
.respond_with(ResponseTemplate::new(404)) .respond_with(ResponseTemplate::new(404))
.mount(&server) .mount(&server)
.await; .await;
@@ -1640,7 +1724,6 @@ mod tests {
Mock::given(method("GET")) Mock::given(method("GET"))
.and(path("/firefox.json")) .and(path("/firefox.json"))
.and(header("user-agent", "donutbrowser"))
.respond_with( .respond_with(
ResponseTemplate::new(200) ResponseTemplate::new(200)
.set_body_string("invalid json") .set_body_string("invalid json")
@@ -1660,7 +1743,7 @@ mod tests {
Mock::given(method("GET")) Mock::given(method("GET"))
.and(path("/repos/zen-browser/desktop/releases")) .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")) .respond_with(ResponseTemplate::new(429).insert_header("retry-after", "60"))
.mount(&server) .mount(&server)
.await; .await;
+48 -22
View File
@@ -156,7 +156,7 @@ impl AppAutoUpdater {
let response = self let response = self
.client .client
.get(url) .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() .send()
.await?; .await?;
@@ -227,6 +227,7 @@ impl AppAutoUpdater {
/// Get the appropriate download URL for the current platform /// Get the appropriate download URL for the current platform
fn get_download_url_for_platform(&self, assets: &[AppReleaseAsset]) -> Option<String> { fn get_download_url_for_platform(&self, assets: &[AppReleaseAsset]) -> Option<String> {
// Priority 1: Get architecture-specific binary for backward compatibility
let arch = if cfg!(target_arch = "aarch64") { let arch = if cfg!(target_arch = "aarch64") {
"aarch64" "aarch64"
} else if cfg!(target_arch = "x86_64") { } else if cfg!(target_arch = "x86_64") {
@@ -235,12 +236,9 @@ impl AppAutoUpdater {
"unknown" "unknown"
}; };
println!("Looking for assets with architecture: {arch}"); println!("Falling back to architecture-specific search for: {arch}");
for asset in assets {
println!("Found asset: {}", asset.name);
}
// Priority 1: Look for exact architecture match in DMG // Look for exact architecture match in DMG
for asset in assets { for asset in assets {
if asset.name.contains(".dmg") if asset.name.contains(".dmg")
&& (asset.name.contains(&format!("_{arch}.dmg")) && (asset.name.contains(&format!("_{arch}.dmg"))
@@ -253,7 +251,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" { if arch == "x64" {
for asset in assets { for asset in assets {
if asset.name.contains(".dmg") if asset.name.contains(".dmg")
@@ -265,7 +263,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" { if arch == "aarch64" {
for asset in assets { for asset in assets {
if asset.name.contains(".dmg") if asset.name.contains(".dmg")
@@ -277,7 +275,7 @@ impl AppAutoUpdater {
} }
} }
// Priority 4: Fallback to any macOS DMG // Priority 2: Fallback to any macOS DMG
for asset in assets { for asset in assets {
if asset.name.contains(".dmg") if asset.name.contains(".dmg")
&& (asset.name.to_lowercase().contains("macos") && (asset.name.to_lowercase().contains("macos")
@@ -356,7 +354,7 @@ impl AppAutoUpdater {
let response = self let response = self
.client .client
.get(download_url) .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() .send()
.await?; .await?;
@@ -390,7 +388,16 @@ impl AppAutoUpdater {
.unwrap_or(""); .unwrap_or("");
match extension { 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, "zip" => extractor.extract_zip(archive_path, dest_dir).await,
_ => Err(format!("Unsupported archive format: {extension}").into()), _ => Err(format!("Unsupported archive format: {extension}").into()),
} }
@@ -535,14 +542,6 @@ pub async fn download_and_install_app_update(
.map_err(|e| format!("Failed to install app update: {e}")) .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] #[tauri::command]
pub async fn check_for_app_updates_manual() -> Result<Option<AppUpdateInfo>, String> { pub async fn check_for_app_updates_manual() -> Result<Option<AppUpdateInfo>, String> {
println!("Manual app update check triggered"); println!("Manual app update check triggered");
@@ -656,9 +655,36 @@ mod tests {
let url = updater.get_download_url_for_platform(&assets); let url = updater.get_download_url_for_platform(&assets);
assert!(url.is_some()); assert!(url.is_some());
// The exact URL depends on the target architecture // Test with generic macOS DMG (no architecture specified)
let url = url.unwrap(); let generic_assets = vec![AppReleaseAsset {
assert!(url.contains(".dmg")); 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 architecture-specific DMG
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] #[test]
+53 -63
View File
@@ -112,7 +112,7 @@ impl AutoUpdater {
available_versions: &[BrowserVersionInfo], available_versions: &[BrowserVersionInfo],
) -> Result<Option<UpdateNotification>, Box<dyn std::error::Error + Send + Sync>> { ) -> Result<Option<UpdateNotification>, Box<dyn std::error::Error + Send + Sync>> {
let current_version = &profile.version; 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 // Find the best available update
let best_update = available_versions let best_update = available_versions
@@ -218,40 +218,6 @@ impl AutoUpdater {
Ok(state.auto_update_downloads.contains(&download_key)) 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<dyn std::error::Error + Send + Sync>> {
// 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<dyn std::error::Error + Send + Sync>> {
// 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 /// Automatically update all affected profile versions after browser download
pub async fn auto_update_profile_versions( pub async fn auto_update_profile_versions(
&self, &self,
@@ -312,9 +278,51 @@ impl AutoUpdater {
state.auto_update_downloads.remove(&download_key); state.auto_update_downloads.remove(&download_key);
self.save_auto_update_state(&state)?; 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) Ok(updated_profiles)
} }
/// Internal method to cleanup unused binaries (used by auto-cleanup)
fn cleanup_unused_binaries_internal(
&self,
) -> Result<Vec<String>, Box<dyn std::error::Error + Send + Sync>> {
// 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 /// Check if browser is disabled due to ongoing update
pub fn is_browser_disabled( pub fn is_browser_disabled(
&self, &self,
@@ -337,7 +345,7 @@ impl AutoUpdater {
// Helper methods // Helper methods
fn is_alpha_version(&self, version: &str) -> bool { fn is_nightly_version(&self, version: &str) -> bool {
version.contains("alpha") version.contains("alpha")
|| version.contains("beta") || version.contains("beta")
|| version.contains("rc") || version.contains("rc")
@@ -414,24 +422,6 @@ pub async fn check_for_browser_updates() -> Result<Vec<UpdateNotification>, Stri
Ok(grouped) 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] #[tauri::command]
pub async fn is_browser_disabled_for_update(browser: String) -> Result<bool, String> { pub async fn is_browser_disabled_for_update(browser: String) -> Result<bool, String> {
let updater = AutoUpdater::new(); let updater = AutoUpdater::new();
@@ -509,18 +499,18 @@ mod tests {
} }
#[test] #[test]
fn test_is_alpha_version() { fn test_is_nightly_version() {
let updater = AutoUpdater::new(); let updater = AutoUpdater::new();
assert!(updater.is_alpha_version("1.0.0-alpha")); assert!(updater.is_nightly_version("1.0.0-alpha"));
assert!(updater.is_alpha_version("1.0.0-beta")); assert!(updater.is_nightly_version("1.0.0-beta"));
assert!(updater.is_alpha_version("1.0.0-rc")); assert!(updater.is_nightly_version("1.0.0-rc"));
assert!(updater.is_alpha_version("1.0.0a1")); assert!(updater.is_nightly_version("1.0.0a1"));
assert!(updater.is_alpha_version("1.0.0b1")); assert!(updater.is_nightly_version("1.0.0b1"));
assert!(updater.is_alpha_version("1.0.0-dev")); assert!(updater.is_nightly_version("1.0.0-dev"));
assert!(!updater.is_alpha_version("1.0.0")); assert!(!updater.is_nightly_version("1.0.0"));
assert!(!updater.is_alpha_version("1.2.3")); assert!(!updater.is_nightly_version("1.2.3"));
} }
#[test] #[test]
+524 -136
View File
@@ -50,7 +50,6 @@ impl BrowserType {
} }
pub trait Browser: Send + Sync { pub trait Browser: Send + Sync {
fn browser_type(&self) -> BrowserType;
fn get_executable_path(&self, install_dir: &Path) -> Result<PathBuf, Box<dyn std::error::Error>>; fn get_executable_path(&self, install_dir: &Path) -> Result<PathBuf, Box<dyn std::error::Error>>;
fn create_launch_args( fn create_launch_args(
&self, &self,
@@ -59,24 +58,17 @@ pub trait Browser: Send + Sync {
url: Option<String>, url: Option<String>,
) -> Result<Vec<String>, Box<dyn std::error::Error>>; ) -> Result<Vec<String>, Box<dyn std::error::Error>>;
fn is_version_downloaded(&self, version: &str, binaries_dir: &Path) -> bool; fn is_version_downloaded(&self, version: &str, binaries_dir: &Path) -> bool;
fn prepare_executable(&self, executable_path: &Path) -> Result<(), Box<dyn std::error::Error>>;
} }
pub struct FirefoxBrowser { // Platform-specific modules
browser_type: BrowserType, #[cfg(target_os = "macos")]
} mod macos {
use super::*;
impl FirefoxBrowser { pub fn get_firefox_executable_path(
pub fn new(browser_type: BrowserType) -> Self { install_dir: &Path,
Self { browser_type } ) -> Result<PathBuf, Box<dyn std::error::Error>> {
}
}
impl Browser for FirefoxBrowser {
fn browser_type(&self) -> BrowserType {
self.browser_type.clone()
}
fn get_executable_path(&self, install_dir: &Path) -> Result<PathBuf, Box<dyn std::error::Error>> {
// Find the .app directory // Find the .app directory
let app_path = std::fs::read_dir(install_dir)? let app_path = std::fs::read_dir(install_dir)?
.filter_map(Result::ok) .filter_map(Result::ok)
@@ -106,6 +98,439 @@ impl Browser for FirefoxBrowser {
Ok(executable_path) Ok(executable_path)
} }
pub fn get_chromium_executable_path(
install_dir: &Path,
) -> Result<PathBuf, Box<dyn std::error::Error>> {
// 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<dyn std::error::Error>> {
// 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<PathBuf, Box<dyn std::error::Error>> {
// Expected structure: install_dir/<browser>/<binary>
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<PathBuf, Box<dyn std::error::Error>> {
// Expected structure: install_dir/<browser>/<binary>
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/<browser>/<binary>
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/<browser>/<binary>
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<dyn std::error::Error>> {
// 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<PathBuf, Box<dyn std::error::Error>> {
// 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<PathBuf, Box<dyn std::error::Error>> {
// 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<dyn std::error::Error>> {
// 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<PathBuf, Box<dyn std::error::Error>> {
#[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( fn create_launch_args(
&self, &self,
profile_path: &str, profile_path: &str,
@@ -135,34 +560,52 @@ impl Browser for FirefoxBrowser {
} }
fn is_version_downloaded(&self, version: &str, binaries_dir: &Path) -> bool { fn is_version_downloaded(&self, version: &str, binaries_dir: &Path) -> bool {
let browser_dir = binaries_dir // Expected structure: binaries/<browser>/<version>
.join(self.browser_type().as_str()) let browser_dir = binaries_dir.join(self.browser_type.as_str()).join(version);
.join(version);
println!("Firefox browser checking version {version} in directory: {browser_dir:?}"); println!("Firefox browser checking version {version} in directory: {browser_dir:?}");
// Only check if directory exists and contains a .app file if !browser_dir.exists() {
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 {
println!("Directory does not exist: {browser_dir:?}"); 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<dyn std::error::Error>> {
#[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) // Chromium-based browsers (Chromium, Brave)
pub struct ChromiumBrowser { pub struct ChromiumBrowser {
#[allow(dead_code)]
browser_type: BrowserType, browser_type: BrowserType,
} }
@@ -173,34 +616,18 @@ impl ChromiumBrowser {
} }
impl Browser for ChromiumBrowser { impl Browser for ChromiumBrowser {
fn browser_type(&self) -> BrowserType {
self.browser_type.clone()
}
fn get_executable_path(&self, install_dir: &Path) -> Result<PathBuf, Box<dyn std::error::Error>> { fn get_executable_path(&self, install_dir: &Path) -> Result<PathBuf, Box<dyn std::error::Error>> {
// Find the .app directory #[cfg(target_os = "macos")]
let app_path = std::fs::read_dir(install_dir)? return macos::get_chromium_executable_path(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 #[cfg(target_os = "linux")]
let mut executable_dir = app_path.path(); return linux::get_chromium_executable_path(install_dir, &self.browser_type);
executable_dir.push("Contents");
executable_dir.push("MacOS");
// Find the first executable in the MacOS directory #[cfg(target_os = "windows")]
let executable_path = std::fs::read_dir(&executable_dir)? return windows::get_chromium_executable_path(install_dir, &self.browser_type);
.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) #[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
Err("Unsupported platform".into())
} }
fn create_launch_args( fn create_launch_args(
@@ -240,35 +667,46 @@ impl Browser for ChromiumBrowser {
} }
fn is_version_downloaded(&self, version: &str, binaries_dir: &Path) -> bool { fn is_version_downloaded(&self, version: &str, binaries_dir: &Path) -> bool {
let browser_dir = binaries_dir // Expected structure: binaries/<browser>/<version>
.join(self.browser_type().as_str()) let browser_dir = binaries_dir.join(self.browser_type.as_str()).join(version);
.join(version);
println!("Chromium browser checking version {version} in directory: {browser_dir:?}"); 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() {
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 {
println!("Directory does not exist: {browser_dir:?}"); 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<dyn std::error::Error>> {
#[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)] #[serde(default)]
pub published_at: String, pub published_at: String,
#[serde(default)] #[serde(default)]
pub is_alpha: bool, pub is_nightly: bool,
#[serde(default)] #[serde(default)]
pub prerelease: bool, pub prerelease: bool,
} }
@@ -354,56 +792,6 @@ mod tests {
assert!(BrowserType::from_str("Firefox").is_err()); // Case sensitive 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] #[test]
fn test_firefox_launch_args() { fn test_firefox_launch_args() {
// Test regular Firefox (should not use -no-remote) // Test regular Firefox (should not use -no-remote)
@@ -509,7 +897,7 @@ mod tests {
let temp_dir = TempDir::new().unwrap(); let temp_dir = TempDir::new().unwrap();
let binaries_dir = temp_dir.path(); let binaries_dir = temp_dir.path();
// Create a mock Firefox browser installation // Create a mock Firefox browser installation with new path structure: binaries/<browser>/<version>/
let browser_dir = binaries_dir.join("firefox").join("139.0"); let browser_dir = binaries_dir.join("firefox").join("139.0");
fs::create_dir_all(&browser_dir).unwrap(); 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("139.0", binaries_dir));
assert!(!browser.is_version_downloaded("140.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"); let chromium_dir = binaries_dir.join("chromium").join("1465660");
fs::create_dir_all(&chromium_dir).unwrap(); fs::create_dir_all(&chromium_dir).unwrap();
let chromium_app_dir = chromium_dir.join("Chromium.app"); let chromium_app_dir = chromium_dir.join("Chromium.app");
@@ -544,7 +932,7 @@ mod tests {
let temp_dir = TempDir::new().unwrap(); let temp_dir = TempDir::new().unwrap();
let binaries_dir = temp_dir.path(); 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"); let browser_dir = binaries_dir.join("firefox").join("139.0");
fs::create_dir_all(&browser_dir).unwrap(); fs::create_dir_all(&browser_dir).unwrap();
+255 -100
View File
@@ -70,6 +70,14 @@ mod macos {
} }
} }
pub async fn launch_browser_process(
executable_path: &std::path::Path,
args: &[String],
) -> Result<std::process::Child, Box<dyn std::error::Error + Send + Sync>> {
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( pub async fn open_url_in_existing_browser_firefox_like(
profile: &BrowserProfile, profile: &BrowserProfile,
url: &str, url: &str,
@@ -484,6 +492,17 @@ mod windows {
false false
} }
pub async fn launch_browser_process(
executable_path: &std::path::Path,
args: &[String],
) -> Result<std::process::Child, Box<dyn std::error::Error + Send + Sync>> {
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( pub async fn open_url_in_existing_browser_firefox_like(
profile: &BrowserProfile, profile: &BrowserProfile,
url: &str, url: &str,
@@ -580,6 +599,126 @@ mod linux {
false false
} }
pub async fn launch_browser_process(
executable_path: &std::path::Path,
args: &[String],
) -> Result<std::process::Child, Box<dyn std::error::Error + Send + Sync>> {
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( pub async fn open_url_in_existing_browser_firefox_like(
profile: &BrowserProfile, profile: &BrowserProfile,
url: &str, url: &str,
@@ -854,9 +993,38 @@ impl BrowserRunner {
// Save the updated profile // Save the updated profile
self.save_profile(&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) Ok(profile)
} }
/// Internal method to cleanup unused binaries (used by auto-cleanup)
fn cleanup_unused_binaries_internal(&self) -> Result<Vec<String>, Box<dyn std::error::Error>> {
// 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<String> { fn get_common_firefox_preferences(&self) -> Vec<String> {
vec![ vec![
// Disable default browser check // Disable default browser check
@@ -1018,7 +1186,7 @@ impl BrowserRunner {
.map_err(|_| format!("Invalid browser type: {}", profile.browser))?; .map_err(|_| format!("Invalid browser type: {}", profile.browser))?;
let browser = create_browser(browser_type.clone()); let browser = create_browser(browser_type.clone());
// Get executable path // Get executable path - path structure: binaries/<browser>/<version>/
let mut browser_dir = self.get_binaries_dir(); let mut browser_dir = self.get_binaries_dir();
browser_dir.push(&profile.browser); browser_dir.push(&profile.browser);
browser_dir.push(&profile.version); browser_dir.push(&profile.version);
@@ -1027,13 +1195,40 @@ impl BrowserRunner {
.get_executable_path(&browser_dir) .get_executable_path(&browser_dir)
.expect("Failed to get executable path"); .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 // Get launch arguments
let browser_args = browser let browser_args = browser
.create_launch_args(&profile.profile_path, profile.proxy.as_ref(), url) .create_launch_args(&profile.profile_path, profile.proxy.as_ref(), url)
.expect("Failed to create launch arguments"); .expect("Failed to create launch arguments");
// Launch browser // Launch browser using platform-specific method
let child = Command::new(executable_path).args(&browser_args).spawn()?; 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(); let launcher_pid = child.id();
println!( println!(
@@ -1159,7 +1354,7 @@ impl BrowserRunner {
let browser_type = BrowserType::from_str(&updated_profile.browser) let browser_type = BrowserType::from_str(&updated_profile.browser)
.map_err(|_| format!("Invalid browser type: {}", 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/<browser>/<version>/
let mut browser_dir = self.get_binaries_dir(); let mut browser_dir = self.get_binaries_dir();
browser_dir.push(&updated_profile.browser); browser_dir.push(&updated_profile.browser);
browser_dir.push(&updated_profile.version); browser_dir.push(&updated_profile.version);
@@ -1385,7 +1580,7 @@ impl BrowserRunner {
profile.name = new_name.to_string(); profile.name = new_name.to_string();
// Create new paths // Create new paths
let _new_profile_file = profiles_dir.join(format!( let _ = profiles_dir.join(format!(
"{}.json", "{}.json",
new_name.to_lowercase().replace(" ", "_") new_name.to_lowercase().replace(" ", "_")
)); ));
@@ -1410,44 +1605,6 @@ impl BrowserRunner {
Ok(profile) Ok(profile)
} }
pub fn get_saved_mullvad_releases(&self) -> Result<Vec<String>, Box<dyn std::error::Error>> {
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<dyn std::error::Error>> { fn save_process_info(&self, profile: &BrowserProfile) -> Result<(), Box<dyn std::error::Error>> {
let profiles_dir = self.get_profiles_dir(); let profiles_dir = self.get_profiles_dir();
let profile_file = profiles_dir.join(format!( let profile_file = profiles_dir.join(format!(
@@ -1477,6 +1634,15 @@ impl BrowserRunner {
fs::remove_file(profile_file)? 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(()) Ok(())
} }
@@ -1802,7 +1968,16 @@ pub async fn launch_browser_profile(
let updated_profile = browser_runner let updated_profile = browser_runner
.launch_or_open_url(app_handle.clone(), &profile, url) .launch_or_open_url(app_handle.clone(), &profile, url)
.await .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::<std::io::Error>() {
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 the profile has proxy settings, start a proxy for it
if let Some(proxy) = &profile.proxy { if let Some(proxy) = &profile.proxy {
@@ -1839,15 +2014,6 @@ pub async fn launch_browser_profile(
Ok(updated_profile) Ok(updated_profile)
} }
// Add Tauri command to get saved releases
#[tauri::command]
pub fn get_saved_mullvad_releases() -> Result<Vec<String>, String> {
let browser_runner = BrowserRunner::new();
browser_runner
.get_saved_mullvad_releases()
.map_err(|e| e.to_string())
}
#[tauri::command] #[tauri::command]
pub fn update_profile_proxy( pub fn update_profile_proxy(
profile_name: String, profile_name: String,
@@ -1903,27 +2069,17 @@ pub fn delete_profile(_app_handle: tauri::AppHandle, profile_name: String) -> Re
} }
#[tauri::command] #[tauri::command]
pub fn get_supported_browsers() -> Result<Vec<&'static str>, String> { pub fn get_supported_browsers() -> Result<Vec<String>, String> {
Ok(vec![ let service = BrowserVersionService::new();
BrowserType::MullvadBrowser.as_str(), Ok(service.get_supported_browsers())
BrowserType::Firefox.as_str(),
BrowserType::FirefoxDeveloper.as_str(),
BrowserType::Chromium.as_str(),
BrowserType::Brave.as_str(),
BrowserType::Zen.as_str(),
BrowserType::TorBrowser.as_str(),
])
} }
#[tauri::command] #[tauri::command]
pub async fn fetch_browser_versions_detailed( pub fn is_browser_supported_on_platform(browser_str: String) -> Result<bool, String> {
browser_str: String,
) -> Result<Vec<BrowserVersionInfo>, String> {
let service = BrowserVersionService::new(); let service = BrowserVersionService::new();
service service
.fetch_browser_versions_detailed(&browser_str, false) .is_browser_supported(&browser_str)
.await .map_err(|e| format!("Failed to check browser support: {e}"))
.map_err(|e| format!("Failed to fetch detailed browser versions: {e}"))
} }
#[tauri::command] #[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<Option<Vec<BrowserVersionInfo>>, 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<bool, String> {
let service = BrowserVersionService::new();
Ok(service.should_update_cache(&browser_str))
}
#[tauri::command] #[tauri::command]
pub async fn download_browser( pub async fn download_browser(
app_handle: tauri::AppHandle, app_handle: tauri::AppHandle,
@@ -2029,8 +2171,22 @@ pub async fn download_browser(
return Ok(version); 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(); 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 let download_info = version_service
.get_download_info(&browser_str, &version) .get_download_info(&browser_str, &version)
.map_err(|e| format!("Failed to get download info: {e}"))?; .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) create_browser_profile(name, browser_type.as_str().to_string(), version, proxy)
} }
#[tauri::command]
pub async fn fetch_browser_versions(browser_str: String) -> Result<Vec<String>, 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] #[tauri::command]
pub async fn fetch_browser_versions_with_count( pub async fn fetch_browser_versions_with_count(
browser_str: String, browser_str: String,
@@ -2230,6 +2377,14 @@ pub fn get_downloaded_browser_versions(browser_str: String) -> Result<Vec<String
Ok(registry.get_downloaded_versions(&browser_str)) Ok(registry.get_downloaded_versions(&browser_str))
} }
#[tauri::command]
pub fn cleanup_unused_binaries() -> Result<Vec<String>, String> {
let browser_runner = BrowserRunner::new();
browser_runner
.cleanup_unused_binaries_internal()
.map_err(|e| format!("Failed to cleanup unused binaries: {e}"))
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
@@ -2367,7 +2522,7 @@ mod tests {
let (runner, _temp_dir) = create_test_browser_runner(); let (runner, _temp_dir) = create_test_browser_runner();
// Create profile // Create profile
let _profile = runner let _ = runner
.create_profile("Original Name", "firefox", "139.0", None) .create_profile("Original Name", "firefox", "139.0", None)
.unwrap(); .unwrap();
@@ -2387,7 +2542,7 @@ mod tests {
let (runner, _temp_dir) = create_test_browser_runner(); let (runner, _temp_dir) = create_test_browser_runner();
// Create profile // Create profile
let _profile = runner let _ = runner
.create_profile("To Delete", "firefox", "139.0", None) .create_profile("To Delete", "firefox", "139.0", None)
.unwrap(); .unwrap();
@@ -2421,13 +2576,13 @@ mod tests {
let (runner, _temp_dir) = create_test_browser_runner(); let (runner, _temp_dir) = create_test_browser_runner();
// Create multiple profiles // Create multiple profiles
let _profile1 = runner let _ = runner
.create_profile("Profile 1", "firefox", "139.0", None) .create_profile("Profile 1", "firefox", "139.0", None)
.unwrap(); .unwrap();
let _profile2 = runner let _ = runner
.create_profile("Profile 2", "chromium", "1465660", None) .create_profile("Profile 2", "chromium", "1465660", None)
.unwrap(); .unwrap();
let _profile3 = runner let _ = runner
.create_profile("Profile 3", "brave", "v1.81.9", None) .create_profile("Profile 3", "brave", "v1.81.9", None)
.unwrap(); .unwrap();
@@ -2446,10 +2601,10 @@ mod tests {
let (runner, _temp_dir) = create_test_browser_runner(); let (runner, _temp_dir) = create_test_browser_runner();
// Test that we can't rename to an existing profile name // Test that we can't rename to an existing profile name
let _profile1 = runner let _ = runner
.create_profile("Profile 1", "firefox", "139.0", None) .create_profile("Profile 1", "firefox", "139.0", None)
.unwrap(); .unwrap();
let _profile2 = runner let _ = runner
.create_profile("Profile 2", "firefox", "139.0", None) .create_profile("Profile 2", "firefox", "139.0", None)
.unwrap(); .unwrap();
+336 -156
View File
@@ -40,6 +40,70 @@ impl BrowserVersionService {
Self { api_client } Self { api_client }
} }
/// Check if a browser is supported on the current platform and architecture
pub fn is_browser_supported(
&self,
browser: &str,
) -> Result<bool, Box<dyn std::error::Error + Send + Sync>> {
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<String> {
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) /// Get cached browser versions immediately (returns None if no cache exists)
pub fn get_cached_browser_versions(&self, browser: &str) -> Option<Vec<String>> { pub fn get_cached_browser_versions(&self, browser: &str) -> Option<Vec<String>> {
self.api_client.load_cached_versions(browser) self.api_client.load_cached_versions(browser)
@@ -58,7 +122,7 @@ impl BrowserVersionService {
.map(|version| { .map(|version| {
BrowserVersionInfo { BrowserVersionInfo {
version: version.clone(), 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 date: "".to_string(), // Cache doesn't store dates
} }
}) })
@@ -176,7 +240,7 @@ impl BrowserVersionService {
} else { } else {
BrowserVersionInfo { BrowserVersionInfo {
version: version.clone(), version: version.clone(),
is_prerelease: crate::api_client::is_alpha_version(&version), is_prerelease: crate::api_client::is_nightly_version(&version),
date: "".to_string(), date: "".to_string(),
} }
} }
@@ -197,7 +261,7 @@ impl BrowserVersionService {
} else { } else {
BrowserVersionInfo { BrowserVersionInfo {
version: version.clone(), version: version.clone(),
is_prerelease: crate::api_client::is_alpha_version(&version), is_prerelease: crate::api_client::is_nightly_version(&version),
date: "".to_string(), date: "".to_string(),
} }
} }
@@ -212,7 +276,7 @@ impl BrowserVersionService {
if let Some(release) = releases.iter().find(|r| r.tag_name == version) { if let Some(release) = releases.iter().find(|r| r.tag_name == version) {
BrowserVersionInfo { BrowserVersionInfo {
version: release.tag_name.clone(), version: release.tag_name.clone(),
is_prerelease: release.is_alpha, is_prerelease: release.is_nightly,
date: release.published_at.clone(), date: release.published_at.clone(),
} }
} else { } else {
@@ -233,7 +297,7 @@ impl BrowserVersionService {
if let Some(release) = releases.iter().find(|r| r.tag_name == version) { if let Some(release) = releases.iter().find(|r| r.tag_name == version) {
BrowserVersionInfo { BrowserVersionInfo {
version: release.tag_name.clone(), version: release.tag_name.clone(),
is_prerelease: release.prerelease, is_prerelease: release.is_nightly,
date: release.published_at.clone(), date: release.published_at.clone(),
} }
} else { } else {
@@ -254,7 +318,7 @@ impl BrowserVersionService {
if let Some(release) = releases.iter().find(|r| r.tag_name == version) { if let Some(release) = releases.iter().find(|r| r.tag_name == version) {
BrowserVersionInfo { BrowserVersionInfo {
version: release.tag_name.clone(), version: release.tag_name.clone(),
is_prerelease: release.prerelease, is_prerelease: release.is_nightly,
date: release.published_at.clone(), date: release.published_at.clone(),
} }
} else { } else {
@@ -281,7 +345,7 @@ impl BrowserVersionService {
} else { } else {
BrowserVersionInfo { BrowserVersionInfo {
version: version.clone(), version: version.clone(),
is_prerelease: false, // Chromium versions are usually stable is_prerelease: false, // Chromium usually stable releases
date: "".to_string(), date: "".to_string(),
} }
} }
@@ -296,13 +360,13 @@ impl BrowserVersionService {
if let Some(release) = releases.iter().find(|r| r.version == version) { if let Some(release) = releases.iter().find(|r| r.version == version) {
BrowserVersionInfo { BrowserVersionInfo {
version: release.version.clone(), version: release.version.clone(),
is_prerelease: release.is_prerelease, is_prerelease: crate::api_client::is_nightly_version(&version),
date: release.date.clone(), date: release.date.clone(),
} }
} else { } else {
BrowserVersionInfo { BrowserVersionInfo {
version: version.clone(), version: version.clone(),
is_prerelease: version.contains("alpha") || version.contains("rc"), is_prerelease: false, // TOR Browser usually stable releases
date: "".to_string(), date: "".to_string(),
} }
} }
@@ -355,151 +419,264 @@ impl BrowserVersionService {
browser: &str, browser: &str,
version: &str, version: &str,
) -> Result<DownloadInfo, Box<dyn std::error::Error + Send + Sync>> { ) -> Result<DownloadInfo, Box<dyn std::error::Error + Send + Sync>> {
let (os, arch) = Self::get_platform_info();
match browser { match browser {
"firefox" => { "firefox" => {
#[cfg(target_os = "macos")] let os_param = match (&os[..], &arch[..]) {
return Ok(DownloadInfo { ("windows", _) => "win64",
url: format!("https://download.mozilla.org/?product=firefox-{version}&os=osx&lang=en-US"), ("linux", "x64") => "linux64",
filename: format!("firefox-{version}.dmg"), ("linux", "arm64") => "linux64-aarch64",
is_archive: true, ("macos", _) => "osx",
}); _ => {
return Err(
format!("Unsupported platform/architecture for Firefox: {os}/{arch}").into(),
)
}
};
#[cfg(target_os = "windows")] let (filename, is_archive) = match os.as_str() {
return Ok(DownloadInfo { "windows" => (format!("firefox-{version}.exe"), false),
url: format!("https://download.mozilla.org/?product=firefox-{version}&os=win64&lang=en-US"), "linux" => (format!("firefox-{version}.tar.xz"), true),
filename: format!("firefox-{version}.exe"), "macos" => (format!("firefox-{version}.dmg"), true),
is_archive: false, _ => 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 { Ok(DownloadInfo {
url: format!( 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"), filename,
is_archive: true, 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", _) => ("Brave-Browser-universal.dmg".to_string(), 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" => { "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 { Ok(DownloadInfo {
url: format!( 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, is_archive: true,
}) })
} }
"tor-browser" => Ok(DownloadInfo { "tor-browser" => {
url: format!( // TOR Browser doesn't support ARM64 on Windows and Linux
"https://archive.torproject.org/tor-package-archive/torbrowser/{version}/tor-browser-macos-{version}.dmg" if arch == "arm64" && (os == "windows" || os == "linux") {
), return Err(format!("TOR Browser doesn't support ARM64 on {os}").into());
filename: format!("tor-browser-{version}.dmg"), }
is_archive: true,
}), 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()), _ => 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 // Private helper methods for each browser type
async fn fetch_firefox_versions( async fn fetch_firefox_versions(
@@ -634,7 +811,7 @@ impl BrowserVersionService {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use wiremock::matchers::{header, method, path}; use wiremock::matchers::{method, path, query_param};
use wiremock::{Mock, MockServer, ResponseTemplate}; use wiremock::{Mock, MockServer, ResponseTemplate};
async fn setup_mock_server() -> MockServer { async fn setup_mock_server() -> MockServer {
@@ -692,7 +869,6 @@ mod tests {
Mock::given(method("GET")) Mock::given(method("GET"))
.and(path("/firefox.json")) .and(path("/firefox.json"))
.and(header("user-agent", "donutbrowser"))
.respond_with( .respond_with(
ResponseTemplate::new(200) ResponseTemplate::new(200)
.set_body_string(mock_response) .set_body_string(mock_response)
@@ -728,7 +904,6 @@ mod tests {
Mock::given(method("GET")) Mock::given(method("GET"))
.and(path("/devedition.json")) .and(path("/devedition.json"))
.and(header("user-agent", "donutbrowser"))
.respond_with( .respond_with(
ResponseTemplate::new(200) ResponseTemplate::new(200)
.set_body_string(mock_response) .set_body_string(mock_response)
@@ -770,7 +945,7 @@ mod tests {
Mock::given(method("GET")) Mock::given(method("GET"))
.and(path("/repos/mullvad/mullvad-browser/releases")) .and(path("/repos/mullvad/mullvad-browser/releases"))
.and(header("user-agent", "donutbrowser")) .and(query_param("per_page", "100"))
.respond_with( .respond_with(
ResponseTemplate::new(200) ResponseTemplate::new(200)
.set_body_string(mock_response) .set_body_string(mock_response)
@@ -812,7 +987,7 @@ mod tests {
Mock::given(method("GET")) Mock::given(method("GET"))
.and(path("/repos/zen-browser/desktop/releases")) .and(path("/repos/zen-browser/desktop/releases"))
.and(header("user-agent", "donutbrowser")) .and(query_param("per_page", "100"))
.respond_with( .respond_with(
ResponseTemplate::new(200) ResponseTemplate::new(200)
.set_body_string(mock_response) .set_body_string(mock_response)
@@ -825,21 +1000,31 @@ mod tests {
async fn setup_brave_mocks(server: &MockServer) { async fn setup_brave_mocks(server: &MockServer) {
let mock_response = r#"[ let mock_response = r#"[
{ {
"tag_name": "v1.81.9", "tag_name": "v1.79.119",
"name": "Brave Release 1.81.9", "name": "Release v1.79.119 (Chromium 137.0.7151.68)",
"prerelease": false, "prerelease": false,
"published_at": "2024-01-15T10:00:00Z", "published_at": "2024-01-15T10:00:00Z",
"assets": [ "assets": [
{ {
"name": "brave-v1.81.9-universal.dmg", "name": "brave-v1.79.119-universal.dmg",
"browser_download_url": "https://example.com/brave-1.81.9-universal.dmg", "browser_download_url": "https://example.com/brave-1.79.119-universal.dmg",
"size": 200000000 "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", "tag_name": "v1.81.8",
"name": "Brave Release 1.81.8", "name": "Nightly v1.81.8",
"prerelease": false, "prerelease": false,
"published_at": "2024-01-10T10:00:00Z", "published_at": "2024-01-10T10:00:00Z",
"assets": [ "assets": [
@@ -854,7 +1039,7 @@ mod tests {
Mock::given(method("GET")) Mock::given(method("GET"))
.and(path("/repos/brave/brave-browser/releases")) .and(path("/repos/brave/brave-browser/releases"))
.and(header("user-agent", "donutbrowser")) .and(query_param("per_page", "100"))
.respond_with( .respond_with(
ResponseTemplate::new(200) ResponseTemplate::new(200)
.set_body_string(mock_response) .set_body_string(mock_response)
@@ -873,7 +1058,6 @@ mod tests {
Mock::given(method("GET")) Mock::given(method("GET"))
.and(path(format!("/{arch}/LAST_CHANGE"))) .and(path(format!("/{arch}/LAST_CHANGE")))
.and(header("user-agent", "donutbrowser"))
.respond_with( .respond_with(
ResponseTemplate::new(200) ResponseTemplate::new(200)
.set_body_string("1465660") .set_body_string("1465660")
@@ -921,7 +1105,6 @@ mod tests {
Mock::given(method("GET")) Mock::given(method("GET"))
.and(path("/")) .and(path("/"))
.and(header("user-agent", "donutbrowser"))
.respond_with( .respond_with(
ResponseTemplate::new(200) ResponseTemplate::new(200)
.set_body_string(mock_html) .set_body_string(mock_html)
@@ -932,7 +1115,6 @@ mod tests {
Mock::given(method("GET")) Mock::given(method("GET"))
.and(path("/14.0.4/")) .and(path("/14.0.4/"))
.and(header("user-agent", "donutbrowser"))
.respond_with( .respond_with(
ResponseTemplate::new(200) ResponseTemplate::new(200)
.set_body_string(version_html_144) .set_body_string(version_html_144)
@@ -943,7 +1125,6 @@ mod tests {
Mock::given(method("GET")) Mock::given(method("GET"))
.and(path("/14.0.3/")) .and(path("/14.0.3/"))
.and(header("user-agent", "donutbrowser"))
.respond_with( .respond_with(
ResponseTemplate::new(200) ResponseTemplate::new(200)
.set_body_string(version_html_143) .set_body_string(version_html_143)
@@ -954,7 +1135,6 @@ mod tests {
Mock::given(method("GET")) Mock::given(method("GET"))
.and(path("/14.0.2/")) .and(path("/14.0.2/"))
.and(header("user-agent", "donutbrowser"))
.respond_with( .respond_with(
ResponseTemplate::new(200) ResponseTemplate::new(200)
.set_body_string(version_html_142) .set_body_string(version_html_142)
@@ -966,7 +1146,7 @@ mod tests {
#[tokio::test] #[tokio::test]
async fn test_browser_version_service_creation() { async fn test_browser_version_service_creation() {
let _service = BrowserVersionService::new(); let _ = BrowserVersionService::new();
// Test passes if we can create the service without panicking // Test passes if we can create the service without panicking
} }
@@ -1304,7 +1484,7 @@ mod tests {
let mullvad_info = service let mullvad_info = service
.get_download_info("mullvad-browser", "14.5a6") .get_download_info("mullvad-browser", "14.5a6")
.unwrap(); .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.url.contains("mullvad-browser-macos-14.5a6"));
assert!(mullvad_info.is_archive); assert!(mullvad_info.is_archive);
@@ -1316,20 +1496,20 @@ mod tests {
// Test Tor Browser // Test Tor Browser
let tor_info = service.get_download_info("tor-browser", "14.0.4").unwrap(); 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.url.contains("tor-browser-macos-14.0.4"));
assert!(tor_info.is_archive); assert!(tor_info.is_archive);
// Test Chromium // Test Chromium
let chromium_info = service.get_download_info("chromium", "1465660").unwrap(); 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.url.contains("chrome-mac.zip"));
assert!(chromium_info.is_archive); assert!(chromium_info.is_archive);
// Test Brave // Test Brave
let brave_info = service.get_download_info("brave", "v1.81.9").unwrap(); let brave_info = service.get_download_info("brave", "v1.81.9").unwrap();
assert_eq!(brave_info.filename, "brave-v1.81.9.dmg"); assert_eq!(brave_info.filename, "Brave-Browser-universal.dmg");
assert!(brave_info.url.contains("Brave-Browser")); assert!(brave_info.url.contains("brave-placeholder"));
assert!(brave_info.is_archive); assert!(brave_info.is_archive);
// Test unsupported browser // Test unsupported browser
+129 -3
View File
@@ -77,13 +77,139 @@ mod windows {
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]
mod linux { mod linux {
use std::process::Command;
const APP_DESKTOP_NAME: &str = "donutbrowser.desktop";
pub fn is_default_browser() -> Result<bool, String> { pub fn is_default_browser() -> Result<bool, String> {
// Linux implementation would go here // Check if xdg-mime is available
Err("Linux support not implemented yet".to_string()) 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> { 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
} }
} }
+231 -37
View File
@@ -51,7 +51,7 @@ impl Downloader {
) -> Result<String, Box<dyn std::error::Error + Send + Sync>> { ) -> Result<String, Box<dyn std::error::Error + Send + Sync>> {
match browser_type { match browser_type {
BrowserType::Brave => { 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 let releases = self
.api_client .api_client
.fetch_brave_releases_with_caching(true) .fetch_brave_releases_with_caching(true)
@@ -65,19 +65,20 @@ impl Downloader {
}) })
.ok_or(format!("Brave version {version} not found"))?; .ok_or(format!("Brave version {version} not found"))?;
// Find the universal macOS DMG asset // Get platform and architecture info
let asset = release let (os, arch) = Self::get_platform_info();
.assets
.iter() // Find the appropriate asset based on platform and architecture
.find(|asset| asset.name.contains(".dmg") && asset.name.contains("universal")) let asset_url = self
.find_brave_asset(&release.assets, &os, &arch)
.ok_or(format!( .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 => { BrowserType::Zen => {
// For Zen, verify the asset exists // For Zen, verify the asset exists and handle different naming patterns
let releases = self let releases = self
.api_client .api_client
.fetch_zen_releases_with_caching(true) .fetch_zen_releases_with_caching(true)
@@ -88,16 +89,17 @@ impl Downloader {
.find(|r| r.tag_name == version) .find(|r| r.tag_name == version)
.ok_or(format!("Zen version {version} not found"))?; .ok_or(format!("Zen version {version} not found"))?;
// Find the macOS universal DMG asset // Get platform and architecture info
let asset = release let (os, arch) = Self::get_platform_info();
.assets
.iter() // Find the appropriate asset
.find(|asset| asset.name == "zen.macos-universal.dmg") let asset_url = self
.find_zen_asset(&release.assets, &os, &arch)
.ok_or(format!( .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 => { BrowserType::MullvadBrowser => {
// For Mullvad, verify the asset exists // For Mullvad, verify the asset exists
@@ -111,16 +113,17 @@ impl Downloader {
.find(|r| r.tag_name == version) .find(|r| r.tag_name == version)
.ok_or(format!("Mullvad version {version} not found"))?; .ok_or(format!("Mullvad version {version} not found"))?;
// Find the macOS DMG asset // Get platform and architecture info
let asset = release let (os, arch) = Self::get_platform_info();
.assets
.iter() // Find the appropriate asset
.find(|asset| asset.name.contains(".dmg") && asset.name.contains("mac")) let asset_url = self
.find_mullvad_asset(&release.assets, &os, &arch)
.ok_or(format!( .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 // For other browsers, use the provided URL
@@ -129,6 +132,200 @@ 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<String> {
// 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<String> {
// 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<String> {
// 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<R: tauri::Runtime>( pub async fn download_browser<R: tauri::Runtime>(
&self, &self,
app_handle: &tauri::AppHandle<R>, app_handle: &tauri::AppHandle<R>,
@@ -170,7 +367,7 @@ impl Downloader {
let response = self let response = self
.client .client
.get(&download_url) .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() .send()
.await?; .await?;
@@ -247,7 +444,7 @@ mod tests {
use crate::browser_version_service::DownloadInfo; use crate::browser_version_service::DownloadInfo;
use tempfile::TempDir; use tempfile::TempDir;
use wiremock::matchers::{header, method, path}; use wiremock::matchers::{method, path, query_param};
use wiremock::{Mock, MockServer, ResponseTemplate}; use wiremock::{Mock, MockServer, ResponseTemplate};
async fn setup_mock_server() -> MockServer { async fn setup_mock_server() -> MockServer {
@@ -290,7 +487,7 @@ mod tests {
Mock::given(method("GET")) Mock::given(method("GET"))
.and(path("/repos/brave/brave-browser/releases")) .and(path("/repos/brave/brave-browser/releases"))
.and(header("user-agent", "donutbrowser")) .and(query_param("per_page", "100"))
.respond_with( .respond_with(
ResponseTemplate::new(200) ResponseTemplate::new(200)
.set_body_string(mock_response) .set_body_string(mock_response)
@@ -338,7 +535,7 @@ mod tests {
Mock::given(method("GET")) Mock::given(method("GET"))
.and(path("/repos/zen-browser/desktop/releases")) .and(path("/repos/zen-browser/desktop/releases"))
.and(header("user-agent", "donutbrowser")) .and(query_param("per_page", "100"))
.respond_with( .respond_with(
ResponseTemplate::new(200) ResponseTemplate::new(200)
.set_body_string(mock_response) .set_body_string(mock_response)
@@ -386,7 +583,7 @@ mod tests {
Mock::given(method("GET")) Mock::given(method("GET"))
.and(path("/repos/mullvad/mullvad-browser/releases")) .and(path("/repos/mullvad/mullvad-browser/releases"))
.and(header("user-agent", "donutbrowser")) .and(query_param("per_page", "100"))
.respond_with( .respond_with(
ResponseTemplate::new(200) ResponseTemplate::new(200)
.set_body_string(mock_response) .set_body_string(mock_response)
@@ -497,7 +694,7 @@ mod tests {
Mock::given(method("GET")) Mock::given(method("GET"))
.and(path("/repos/brave/brave-browser/releases")) .and(path("/repos/brave/brave-browser/releases"))
.and(header("user-agent", "donutbrowser")) .and(query_param("per_page", "100"))
.respond_with( .respond_with(
ResponseTemplate::new(200) ResponseTemplate::new(200)
.set_body_string(mock_response) .set_body_string(mock_response)
@@ -547,7 +744,7 @@ mod tests {
Mock::given(method("GET")) Mock::given(method("GET"))
.and(path("/repos/zen-browser/desktop/releases")) .and(path("/repos/zen-browser/desktop/releases"))
.and(header("user-agent", "donutbrowser")) .and(query_param("per_page", "100"))
.respond_with( .respond_with(
ResponseTemplate::new(200) ResponseTemplate::new(200)
.set_body_string(mock_response) .set_body_string(mock_response)
@@ -570,7 +767,7 @@ mod tests {
assert!(result assert!(result
.unwrap_err() .unwrap_err()
.to_string() .to_string()
.contains("No macOS universal asset found")); .contains("No compatible asset found"));
} }
#[tokio::test] #[tokio::test]
@@ -589,7 +786,6 @@ mod tests {
// Mock the download endpoint // Mock the download endpoint
Mock::given(method("GET")) Mock::given(method("GET"))
.and(path("/test-download")) .and(path("/test-download"))
.and(header("user-agent", "donutbrowser"))
.respond_with( .respond_with(
ResponseTemplate::new(200) ResponseTemplate::new(200)
.set_body_bytes(test_content) .set_body_bytes(test_content)
@@ -640,7 +836,6 @@ mod tests {
// Mock a 404 response // Mock a 404 response
Mock::given(method("GET")) Mock::given(method("GET"))
.and(path("/missing-file")) .and(path("/missing-file"))
.and(header("user-agent", "donutbrowser"))
.respond_with(ResponseTemplate::new(404)) .respond_with(ResponseTemplate::new(404))
.mount(&server) .mount(&server)
.await; .await;
@@ -691,7 +886,7 @@ mod tests {
Mock::given(method("GET")) Mock::given(method("GET"))
.and(path("/repos/mullvad/mullvad-browser/releases")) .and(path("/repos/mullvad/mullvad-browser/releases"))
.and(header("user-agent", "donutbrowser")) .and(query_param("per_page", "100"))
.respond_with( .respond_with(
ResponseTemplate::new(200) ResponseTemplate::new(200)
.set_body_string(mock_response) .set_body_string(mock_response)
@@ -714,7 +909,7 @@ mod tests {
assert!(result assert!(result
.unwrap_err() .unwrap_err()
.to_string() .to_string()
.contains("No macOS asset found")); .contains("No compatible asset found"));
} }
#[tokio::test] #[tokio::test]
@@ -741,7 +936,7 @@ mod tests {
Mock::given(method("GET")) Mock::given(method("GET"))
.and(path("/repos/brave/brave-browser/releases")) .and(path("/repos/brave/brave-browser/releases"))
.and(header("user-agent", "donutbrowser")) .and(query_param("per_page", "100"))
.respond_with( .respond_with(
ResponseTemplate::new(200) ResponseTemplate::new(200)
.set_body_string(mock_response) .set_body_string(mock_response)
@@ -780,7 +975,6 @@ mod tests {
Mock::given(method("GET")) Mock::given(method("GET"))
.and(path("/chunked-download")) .and(path("/chunked-download"))
.and(header("user-agent", "donutbrowser"))
.respond_with( .respond_with(
ResponseTemplate::new(200) ResponseTemplate::new(200)
.set_body_bytes(test_content.clone()) .set_body_bytes(test_content.clone())
+42
View File
@@ -175,6 +175,48 @@ impl DownloadedBrowsersRegistry {
} }
Ok(()) 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<Vec<String>, Box<dyn std::error::Error>> {
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)] #[cfg(test)]
+705 -28
View File
@@ -34,18 +34,146 @@ impl Extractor {
}; };
let _ = app_handle.emit("download-progress", &progress); let _ = app_handle.emit("download-progress", &progress);
let extension = archive_path // Try to detect the actual file type by reading the file header
.extension() let actual_format = self.detect_file_format(archive_path)?;
.and_then(|ext| ext.to_str())
.unwrap_or("");
match extension { match actual_format.as_str() {
"dmg" => self.extract_dmg(archive_path, dest_dir).await, "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, "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<String, Box<dyn std::error::Error + Send + Sync>> {
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( pub async fn extract_dmg(
&self, &self,
dmg_path: &Path, dmg_path: &Path,
@@ -154,7 +282,56 @@ impl Extractor {
zip_path: &Path, zip_path: &Path,
dest_dir: &Path, dest_dir: &Path,
) -> Result<PathBuf, Box<dyn std::error::Error + Send + Sync>> { ) -> Result<PathBuf, Box<dyn std::error::Error + Send + Sync>> {
// 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<PathBuf, Box<dyn std::error::Error + Send + Sync>> {
// 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<PathBuf, Box<dyn std::error::Error + Send + Sync>> {
// Use unzip command on Unix-like systems
let output = Command::new("unzip") let output = Command::new("unzip")
.args([ .args([
"-q", // quiet "-q", // quiet
@@ -174,16 +351,269 @@ impl Extractor {
); );
} }
// Find the extracted .app directory or Chromium.app specifically self.find_extracted_executable(dest_dir).await
let mut app_path: Option<PathBuf> = None; }
pub async fn extract_tar_xz(
&self,
tar_path: &Path,
dest_dir: &Path,
) -> Result<PathBuf, Box<dyn std::error::Error + Send + Sync>> {
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<PathBuf, Box<dyn std::error::Error + Send + Sync>> {
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<PathBuf, Box<dyn std::error::Error + Send + Sync>> {
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<PathBuf, Box<dyn std::error::Error + Send + Sync>> {
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<PathBuf, Box<dyn std::error::Error + Send + Sync>> {
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<PathBuf, Box<dyn std::error::Error + Send + Sync>> {
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<PathBuf, Box<dyn std::error::Error + Send + Sync>> {
// 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<PathBuf, Box<dyn std::error::Error + Send + Sync>> {
// 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<PathBuf, Box<dyn std::error::Error + Send + Sync>> {
// First, try to find any .app file in the destination directory // First, try to find any .app file in the destination directory
if let Ok(entries) = fs::read_dir(dest_dir) { if let Ok(entries) = fs::read_dir(dest_dir) {
for entry in entries.flatten() { for entry in entries.flatten() {
let path = entry.path(); let path = entry.path();
if path.extension().is_some_and(|ext| ext == "app") { if path.extension().is_some_and(|ext| ext == "app") {
app_path = Some(path); return Ok(path);
break;
} }
// For Chromium, check subdirectories (chrome-mac folder) // For Chromium, check subdirectories (chrome-mac folder)
if path.is_dir() { if path.is_dir() {
@@ -194,33 +624,280 @@ impl Extractor {
// Move the app to the root destination directory // Move the app to the root destination directory
let target_path = dest_dir.join(sub_path.file_name().unwrap()); let target_path = dest_dir.join(sub_path.file_name().unwrap());
fs::rename(&sub_path, &target_path)?; fs::rename(&sub_path, &target_path)?;
app_path = Some(target_path);
// Clean up the now-empty subdirectory // Clean up the now-empty subdirectory
let _ = fs::remove_dir_all(&path); 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 #[cfg(target_os = "windows")]
let _ = Command::new("xattr") async fn find_windows_executable(
.args(["-dr", "com.apple.quarantine", app_path.to_str().unwrap()]) &self,
.output(); dest_dir: &Path,
) -> Result<PathBuf, Box<dyn std::error::Error + Send + Sync>> {
// 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") for exe_name in &exe_names {
.args(["-cr", app_path.to_str().unwrap()]) let exe_path = dest_dir.join(exe_name);
.output(); 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<PathBuf, Box<dyn std::error::Error + Send + Sync>> {
// 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<dyn std::error::Error + Send + Sync>> {
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<dyn std::error::Error + Send + Sync>> {
Ok(())
}
#[cfg(target_os = "linux")]
async fn find_any_executable_recursive(
&self,
dir: &Path,
depth: usize,
) -> Result<PathBuf, Box<dyn std::error::Error + Send + Sync>> {
// 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] #[test]
fn test_extractor_creation() { fn test_extractor_creation() {
let _extractor = Extractor::new(); let _ = Extractor::new();
// Just verify we can create an extractor instance // Just verify we can create an extractor instance
} }
#[test] #[test]
fn test_unsupported_archive_format() { fn test_unsupported_archive_format() {
let _extractor = Extractor::new(); let _ = Extractor::new();
let temp_dir = TempDir::new().unwrap(); let temp_dir = TempDir::new().unwrap();
let fake_archive = temp_dir.path().join("test.rar"); let fake_archive = temp_dir.path().join("test.rar");
File::create(&fake_archive).unwrap(); File::create(&fake_archive).unwrap();
+181 -93
View File
@@ -1,6 +1,5 @@
// Learn more about Tauri commands at https://tauri.app/develop/calling-rust/ // Learn more about Tauri commands at https://tauri.app/develop/calling-rust/
use std::sync::Mutex; use std::sync::Mutex;
use std::time::{SystemTime, UNIX_EPOCH};
use tauri::{Emitter, Manager, Runtime, WebviewUrl, WebviewWindow, WebviewWindowBuilder}; use tauri::{Emitter, Manager, Runtime, WebviewUrl, WebviewWindow, WebviewWindowBuilder};
use tauri_plugin_deep_link::DeepLinkExt; use tauri_plugin_deep_link::DeepLinkExt;
@@ -19,22 +18,22 @@ mod downloaded_browsers;
mod extraction; mod extraction;
mod proxy_manager; mod proxy_manager;
mod settings_manager; mod settings_manager;
mod theme_detector;
mod version_updater; mod version_updater;
extern crate lazy_static; extern crate lazy_static;
use browser_runner::{ use browser_runner::{
check_browser_exists, check_browser_status, create_browser_profile, create_browser_profile_new, check_browser_exists, check_browser_status, cleanup_unused_binaries, create_browser_profile_new,
delete_profile, download_browser, fetch_browser_versions, fetch_browser_versions_cached_first, delete_profile, download_browser, fetch_browser_versions_cached_first,
fetch_browser_versions_detailed, fetch_browser_versions_with_count, fetch_browser_versions_with_count, fetch_browser_versions_with_count_cached_first,
fetch_browser_versions_with_count_cached_first, get_cached_browser_versions_detailed, get_downloaded_browser_versions, get_supported_browsers, is_browser_supported_on_platform,
get_downloaded_browser_versions, get_saved_mullvad_releases, get_supported_browsers, kill_browser_profile, launch_browser_profile, list_browser_profiles, rename_profile,
is_browser_downloaded, kill_browser_profile, launch_browser_profile, list_browser_profiles, update_profile_proxy, update_profile_version,
rename_profile, should_update_browser_cache, update_profile_proxy, update_profile_version,
}; };
use settings_manager::{ 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, save_table_sorting_settings, should_show_settings_on_startup,
}; };
@@ -43,21 +42,21 @@ use default_browser::{
}; };
use version_updater::{ use version_updater::{
check_version_update_needed, force_version_update_check, get_version_update_status, get_version_update_status, get_version_updater, trigger_manual_version_update,
get_version_updater, trigger_manual_version_update,
}; };
use auto_updater::{ use auto_updater::{
check_for_browser_updates, complete_browser_update, complete_browser_update_with_auto_update, check_for_browser_updates, complete_browser_update_with_auto_update, dismiss_update_notification,
dismiss_update_notification, is_auto_update_download, is_browser_disabled_for_update, is_auto_update_download, is_browser_disabled_for_update, mark_auto_update_download,
mark_auto_update_download, remove_auto_update_download, start_browser_update, remove_auto_update_download,
}; };
use app_auto_updater::{ use app_auto_updater::{
check_for_app_updates, check_for_app_updates_manual, download_and_install_app_update, 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 // Trait to extend WebviewWindow with transparent titlebar functionality
pub trait WindowExt { pub trait WindowExt {
#[cfg(target_os = "macos")] #[cfg(target_os = "macos")]
@@ -103,13 +102,6 @@ impl<R: Runtime> WindowExt for WebviewWindow<R> {
} }
} }
#[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] #[tauri::command]
async fn handle_url_open(app: tauri::AppHandle, url: String) -> Result<(), String> { async fn handle_url_open(app: tauri::AppHandle, url: String) -> Result<(), String> {
println!("handle_url_open called with URL: {url}"); println!("handle_url_open called with URL: {url}");
@@ -169,61 +161,6 @@ async fn check_and_handle_startup_url(app_handle: tauri::AppHandle) -> Result<bo
Ok(false) Ok(false)
} }
#[tauri::command]
async fn set_window_background_color(
app_handle: tauri::AppHandle,
is_dark_mode: bool,
) -> 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<NSWindow> =
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)] #[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() { pub fn run() {
tauri::Builder::default() tauri::Builder::default()
@@ -233,12 +170,14 @@ pub fn run() {
.plugin(tauri_plugin_deep_link::init()) .plugin(tauri_plugin_deep_link::init())
.setup(|app| { .setup(|app| {
// Create the main window programmatically // Create the main window programmatically
#[allow(unused_variables)]
let win_builder = WebviewWindowBuilder::new(app, "main", WebviewUrl::default()) let win_builder = WebviewWindowBuilder::new(app, "main", WebviewUrl::default())
.title("Donut Browser") .title("Donut Browser")
.inner_size(900.0, 600.0) .inner_size(900.0, 600.0)
.resizable(false) .resizable(false)
.fullscreen(false); .fullscreen(false);
#[allow(unused_variables)]
let window = win_builder.build().unwrap(); let window = win_builder.build().unwrap();
// Set transparent titlebar for macOS // Set transparent titlebar for macOS
@@ -328,25 +267,19 @@ pub fn run() {
Ok(()) Ok(())
}) })
.invoke_handler(tauri::generate_handler![ .invoke_handler(tauri::generate_handler![
greet,
get_supported_browsers, get_supported_browsers,
is_browser_supported_on_platform,
download_browser, download_browser,
delete_profile, delete_profile,
is_browser_downloaded,
check_browser_exists, check_browser_exists,
cleanup_unused_binaries,
create_browser_profile_new, create_browser_profile_new,
create_browser_profile,
list_browser_profiles, list_browser_profiles,
launch_browser_profile, launch_browser_profile,
fetch_browser_versions,
fetch_browser_versions_detailed,
fetch_browser_versions_with_count, fetch_browser_versions_with_count,
fetch_browser_versions_cached_first, fetch_browser_versions_cached_first,
fetch_browser_versions_with_count_cached_first, fetch_browser_versions_with_count_cached_first,
get_cached_browser_versions_detailed,
should_update_browser_cache,
get_downloaded_browser_versions, get_downloaded_browser_versions,
get_saved_mullvad_releases,
update_profile_proxy, update_profile_proxy,
update_profile_version, update_profile_version,
check_browser_status, check_browser_status,
@@ -355,22 +288,17 @@ pub fn run() {
get_app_settings, get_app_settings,
save_app_settings, save_app_settings,
should_show_settings_on_startup, should_show_settings_on_startup,
disable_default_browser_prompt,
get_table_sorting_settings, get_table_sorting_settings,
save_table_sorting_settings, save_table_sorting_settings,
clear_all_version_cache,
is_default_browser, is_default_browser,
open_url_with_profile, open_url_with_profile,
set_as_default_browser, set_as_default_browser,
smart_open_url, smart_open_url,
handle_url_open,
check_and_handle_startup_url, check_and_handle_startup_url,
trigger_manual_version_update, trigger_manual_version_update,
get_version_update_status, get_version_update_status,
check_version_update_needed,
force_version_update_check,
check_for_browser_updates, check_for_browser_updates,
start_browser_update,
complete_browser_update,
is_browser_disabled_for_update, is_browser_disabled_for_update,
dismiss_update_notification, dismiss_update_notification,
complete_browser_update_with_auto_update, complete_browser_update_with_auto_update,
@@ -380,9 +308,169 @@ pub fn run() {
check_for_app_updates, check_for_app_updates,
check_for_app_updates_manual, check_for_app_updates_manual,
download_and_install_app_update, download_and_install_app_update,
get_app_version_info, get_system_theme,
set_window_background_color,
]) ])
.run(tauri::generate_context!()) .run(tauri::generate_context!())
.expect("error while running tauri application"); .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<Type>("command"
format!("invoke(\"{}\"", command), // invoke("command"
format!("invoke<{}>(\"{}\",", "", command), // invoke<Type>("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<String> {
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<String> {
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
}
}
+17 -15
View File
@@ -3,6 +3,8 @@ use serde::{Deserialize, Serialize};
use std::fs::{self, create_dir_all}; use std::fs::{self, create_dir_all};
use std::path::PathBuf; use std::path::PathBuf;
use crate::api_client::ApiClient;
#[derive(Debug, Serialize, Deserialize, Clone)] #[derive(Debug, Serialize, Deserialize, Clone)]
pub struct TableSortingSettings { pub struct TableSortingSettings {
pub column: String, // Column to sort by: "name", "browser", "status" pub column: String, // Column to sort by: "name", "browser", "status"
@@ -28,6 +30,8 @@ pub struct AppSettings {
pub theme: String, // "light", "dark", or "system" pub theme: String, // "light", "dark", or "system"
#[serde(default = "default_auto_updates_enabled")] #[serde(default = "default_auto_updates_enabled")]
pub auto_updates_enabled: bool, 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 { fn default_show_settings_on_startup() -> bool {
@@ -42,6 +46,10 @@ fn default_auto_updates_enabled() -> bool {
true true
} }
fn default_auto_delete_unused_binaries() -> bool {
true
}
impl Default for AppSettings { impl Default for AppSettings {
fn default() -> Self { fn default() -> Self {
Self { Self {
@@ -49,6 +57,7 @@ impl Default for AppSettings {
show_settings_on_startup: default_show_settings_on_startup(), show_settings_on_startup: default_show_settings_on_startup(),
theme: default_theme(), theme: default_theme(),
auto_updates_enabled: default_auto_updates_enabled(), 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 // 3. User hasn't explicitly disabled the default browser setting
Ok(settings.show_settings_on_startup && !settings.set_as_default_browser) Ok(settings.show_settings_on_startup && !settings.set_as_default_browser)
} }
pub fn disable_default_browser_prompt(&self) -> Result<(), Box<dyn std::error::Error>> {
let mut settings = self.load_settings()?;
settings.show_settings_on_startup = false;
self.save_settings(&settings)?;
Ok(())
}
} }
#[tauri::command] #[tauri::command]
@@ -196,14 +198,6 @@ pub async fn should_show_settings_on_startup() -> Result<bool, String> {
.map_err(|e| format!("Failed to check prompt setting: {e}")) .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] #[tauri::command]
pub async fn get_table_sorting_settings() -> Result<TableSortingSettings, String> { pub async fn get_table_sorting_settings() -> Result<TableSortingSettings, String> {
let manager = SettingsManager::new(); let manager = SettingsManager::new();
@@ -219,3 +213,11 @@ pub async fn save_table_sorting_settings(sorting: TableSortingSettings) -> Resul
.save_table_sorting(&sorting) .save_table_sorting(&sorting)
.map_err(|e| format!("Failed to save table sorting settings: {e}")) .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}"))
}
+539
View File
@@ -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<String, Box<dyn std::error::Error>> {
// 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<String, Box<dyn std::error::Error>> {
// 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<String, Box<dyn std::error::Error>> {
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<String, Box<dyn std::error::Error>> {
// 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<String, Box<dyn std::error::Error>> {
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<String, Box<dyn std::error::Error>> {
// 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<String, Box<dyn std::error::Error>> {
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<String, Box<dyn std::error::Error>> {
// 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"));
}
}
-16
View File
@@ -448,22 +448,6 @@ pub async fn get_version_update_status() -> Result<(Option<u64>, u64), String> {
Ok((last_update, time_until_next)) Ok((last_update, time_until_next))
} }
#[tauri::command]
pub async fn check_version_update_needed() -> Result<bool, String> {
Ok(VersionUpdater::should_run_background_update())
}
#[tauri::command]
pub async fn force_version_update_check(_app_handle: tauri::AppHandle) -> Result<bool, String> {
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)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
+20
View File
@@ -18,6 +18,7 @@
"bundle": { "bundle": {
"active": true, "active": true,
"targets": "all", "targets": "all",
"category": "Productivity",
"externalBin": ["binaries/nodecar"], "externalBin": ["binaries/nodecar"],
"icon": [ "icon": [
"icons/32x32.png", "icons/32x32.png",
@@ -37,6 +38,25 @@
"files": { "files": {
"Info.plist": "Info.plist" "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": { "plugins": {
+68 -32
View File
@@ -26,6 +26,8 @@ import {
} from "@/components/ui/tooltip"; } from "@/components/ui/tooltip";
import { VersionSelector } from "@/components/version-selector"; import { VersionSelector } from "@/components/version-selector";
import { useBrowserDownload } from "@/hooks/use-browser-download"; 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 type { BrowserProfile, ProxySettings } from "@/types";
import { invoke } from "@tauri-apps/api/core"; import { invoke } from "@tauri-apps/api/core";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
@@ -60,9 +62,6 @@ export function CreateProfileDialog({
const [selectedBrowser, setSelectedBrowser] = const [selectedBrowser, setSelectedBrowser] =
useState<BrowserTypeString | null>("mullvad-browser"); useState<BrowserTypeString | null>("mullvad-browser");
const [selectedVersion, setSelectedVersion] = useState<string | null>(null); const [selectedVersion, setSelectedVersion] = useState<string | null>(null);
const [supportedBrowsers, setSupportedBrowsers] = useState<
BrowserTypeString[]
>([]);
const [isCreating, setIsCreating] = useState(false); const [isCreating, setIsCreating] = useState(false);
const [existingProfiles, setExistingProfiles] = useState<BrowserProfile[]>( const [existingProfiles, setExistingProfiles] = useState<BrowserProfile[]>(
[], [],
@@ -84,13 +83,29 @@ export function CreateProfileDialog({
isVersionDownloaded, isVersionDownloaded,
} = useBrowserDownload(); } = useBrowserDownload();
const {
supportedBrowsers,
isLoading: isLoadingSupport,
isBrowserSupported,
} = useBrowserSupport();
useEffect(() => { useEffect(() => {
if (isOpen) { if (isOpen) {
void loadSupportedBrowsers();
void loadExistingProfiles(); void loadExistingProfiles();
} }
}, [isOpen]); }, [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(() => { useEffect(() => {
if (isOpen && selectedBrowser) { if (isOpen && selectedBrowser) {
// Reset selected version when browser changes // Reset selected version when browser changes
@@ -105,7 +120,7 @@ export function CreateProfileDialog({
if (availableVersions.length > 0 && selectedBrowser) { if (availableVersions.length > 0 && selectedBrowser) {
// Always reset version when browser changes or versions are loaded // Always reset version when browser changes or versions are loaded
// Find the latest stable version (not alpha/beta) // 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) { if (stableVersions.length > 0) {
// Select the first stable version (they're already sorted newest first) // Select the first stable version (they're already sorted newest first)
@@ -117,22 +132,6 @@ export function CreateProfileDialog({
} }
}, [availableVersions, selectedBrowser]); }, [availableVersions, selectedBrowser]);
const loadSupportedBrowsers = async () => {
try {
const browsers = await invoke<BrowserTypeString[]>(
"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 () => { const loadExistingProfiles = async () => {
try { try {
const profiles = await invoke<BrowserProfile[]>("list_browser_profiles"); const profiles = await invoke<BrowserProfile[]>("list_browser_profiles");
@@ -261,21 +260,58 @@ export function CreateProfileDialog({
onValueChange={(value) => { onValueChange={(value) => {
setSelectedBrowser(value as BrowserTypeString); setSelectedBrowser(value as BrowserTypeString);
}} }}
disabled={isLoadingSupport}
> >
<SelectTrigger> <SelectTrigger>
<SelectValue placeholder="Select browser" /> <SelectValue
placeholder={
isLoadingSupport ? "Loading browsers..." : "Select browser"
}
/>
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
{supportedBrowsers.map((browser) => ( {(
<SelectItem key={browser} value={browser}> [
{browser "mullvad-browser",
.split("-") "firefox",
.map( "firefox-developer",
(word) => word.charAt(0).toUpperCase() + word.slice(1), "chromium",
) "brave",
.join(" ")} "zen",
</SelectItem> "tor-browser",
))} ] as BrowserTypeString[]
).map((browser) => {
const isSupported = isBrowserSupported(browser);
const displayName = getBrowserDisplayName(browser);
if (!isSupported) {
return (
<Tooltip key={browser}>
<TooltipTrigger asChild>
<SelectItem
value={browser}
disabled={true}
className="opacity-50"
>
{displayName} (Not supported on this platform)
</SelectItem>
</TooltipTrigger>
<TooltipContent>
<p>
{displayName} is not supported on your current
platform or architecture.
</p>
</TooltipContent>
</Tooltip>
);
}
return (
<SelectItem key={browser} value={browser}>
{displayName}
</SelectItem>
);
})}
</SelectContent> </SelectContent>
</Select> </Select>
</div> </div>
+98 -2
View File
@@ -28,6 +28,7 @@ interface AppSettings {
show_settings_on_startup: boolean; show_settings_on_startup: boolean;
theme: string; theme: string;
auto_updates_enabled: boolean; auto_updates_enabled: boolean;
auto_delete_unused_binaries: boolean;
} }
interface SettingsDialogProps { interface SettingsDialogProps {
@@ -41,17 +42,21 @@ export function SettingsDialog({ isOpen, onClose }: SettingsDialogProps) {
show_settings_on_startup: true, show_settings_on_startup: true,
theme: "system", theme: "system",
auto_updates_enabled: true, auto_updates_enabled: true,
auto_delete_unused_binaries: true,
}); });
const [originalSettings, setOriginalSettings] = useState<AppSettings>({ const [originalSettings, setOriginalSettings] = useState<AppSettings>({
set_as_default_browser: false, set_as_default_browser: false,
show_settings_on_startup: true, show_settings_on_startup: true,
theme: "system", theme: "system",
auto_updates_enabled: true, auto_updates_enabled: true,
auto_delete_unused_binaries: true,
}); });
const [isDefaultBrowser, setIsDefaultBrowser] = useState(false); const [isDefaultBrowser, setIsDefaultBrowser] = useState(false);
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [isSaving, setIsSaving] = useState(false); const [isSaving, setIsSaving] = useState(false);
const [isSettingDefault, setIsSettingDefault] = useState(false); const [isSettingDefault, setIsSettingDefault] = useState(false);
const [isClearingCache, setIsClearingCache] = useState(false);
const [isCleaningBinaries, setIsCleaningBinaries] = useState(false);
const { setTheme } = useTheme(); 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<string[]>("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 () => { const handleSave = async () => {
setIsSaving(true); setIsSaving(true);
try { try {
@@ -130,7 +168,9 @@ export function SettingsDialog({ isOpen, onClose }: SettingsDialogProps) {
settings.show_settings_on_startup !== settings.show_settings_on_startup !==
originalSettings.show_settings_on_startup || originalSettings.show_settings_on_startup ||
settings.theme !== originalSettings.theme || 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 ( return (
<Dialog open={isOpen} onOpenChange={onClose}> <Dialog open={isOpen} onOpenChange={onClose}>
@@ -216,9 +256,26 @@ export function SettingsDialog({ isOpen, onClose }: SettingsDialogProps) {
</Label> </Label>
</div> </div>
<div className="flex items-center space-x-2">
<Checkbox
id="auto-delete-binaries"
checked={settings.auto_delete_unused_binaries}
onCheckedChange={(checked) => {
updateSetting(
"auto_delete_unused_binaries",
checked as boolean,
);
}}
/>
<Label htmlFor="auto-delete-binaries" className="text-sm">
Automatically delete unused browser binaries
</Label>
</div>
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
When enabled, Donut Browser will check for browser updates and 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.
</p> </p>
</div> </div>
@@ -244,6 +301,45 @@ export function SettingsDialog({ isOpen, onClose }: SettingsDialogProps) {
starts. starts.
</p> </p>
</div> </div>
{/* Advanced Section */}
<div className="space-y-4">
<Label className="text-base font-medium">Advanced</Label>
<LoadingButton
isLoading={isClearingCache}
onClick={() => {
void handleClearCache();
}}
variant="outline"
className="w-full"
>
Clear All Version Cache
</LoadingButton>
<p className="text-xs text-muted-foreground">
Clear all cached browser version data. This will force a fresh
download of version information on the next app restart or manual
refresh.
</p>
<LoadingButton
isLoading={isCleaningBinaries}
onClick={() => {
void handleCleanupBinaries();
}}
variant="outline"
className="w-full"
>
Clean Up Unused Binaries
</LoadingButton>
<p className="text-xs text-muted-foreground">
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.
</p>
</div>
</div> </div>
<DialogFooter className="flex-shrink-0"> <DialogFooter className="flex-shrink-0">
+71 -3
View File
@@ -9,6 +9,10 @@ interface AppSettings {
theme: string; theme: string;
} }
interface SystemTheme {
theme: string;
}
interface CustomThemeProviderProps { interface CustomThemeProviderProps {
children: React.ReactNode; children: React.ReactNode;
} }
@@ -24,6 +28,25 @@ function getSystemTheme(): string {
return "light"; return "light";
} }
// Function to get native system theme (fallback to CSS media query)
async function getNativeSystemTheme(): Promise<string> {
try {
const systemTheme = await invoke<SystemTheme>("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) { export function CustomThemeProvider({ children }: CustomThemeProviderProps) {
const [isLoading, setIsLoading] = useState(true); const [isLoading, setIsLoading] = useState(true);
const [defaultTheme, setDefaultTheme] = useState<string>("system"); const [defaultTheme, setDefaultTheme] = useState<string>("system");
@@ -41,7 +64,7 @@ export function CustomThemeProvider({ children }: CustomThemeProviderProps) {
} catch (error) { } catch (error) {
console.error("Failed to load theme settings:", error); console.error("Failed to load theme settings:", error);
// For first-time users, detect system preference and apply it // For first-time users, detect system preference and apply it
const systemTheme = getSystemTheme(); const systemTheme = await getNativeSystemTheme();
console.log( console.log(
"First-time user detected, applying system theme:", "First-time user detected, applying system theme:",
systemTheme, systemTheme,
@@ -69,6 +92,50 @@ export function CustomThemeProvider({ children }: CustomThemeProviderProps) {
void loadTheme(); 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) { if (isLoading) {
// Use a consistent loading screen that doesn't depend on system theme during SSR // 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 // 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) // Only apply system theme detection after component is mounted (client-side only)
if (mounted) { if (mounted) {
// Use CSS media query for loading screen since async call would complicate this
const systemTheme = getSystemTheme(); const systemTheme = getSystemTheme();
loadingBgColor = systemTheme === "dark" ? "bg-gray-900" : "bg-white"; loadingBgColor = systemTheme === "dark" ? "bg-gray-900" : "bg-white";
spinnerColor = spinnerColor =
@@ -85,10 +153,10 @@ export function CustomThemeProvider({ children }: CustomThemeProviderProps) {
return ( return (
<div <div
className={`fixed inset-0 ${loadingBgColor} flex items-center justify-center`} className={`flex fixed inset-0 justify-center items-center ${loadingBgColor}`}
> >
<div <div
className={`animate-spin rounded-full h-8 w-8 border-2 ${spinnerColor} border-t-transparent`} className={`w-8 h-8 rounded-full border-2 animate-spin ${spinnerColor} border-t-transparent`}
/> />
</div> </div>
); );
+6 -6
View File
@@ -30,7 +30,7 @@ interface GithubRelease {
hash?: string; hash?: string;
}>; }>;
published_at: string; published_at: string;
is_alpha: boolean; is_nightly: boolean;
} }
interface VersionSelectorProps { interface VersionSelectorProps {
@@ -75,7 +75,7 @@ export function VersionSelector({
className="justify-between w-full" className="justify-between w-full"
> >
{selectedVersion ?? placeholder} {selectedVersion ?? placeholder}
<LuChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" /> <LuChevronsUpDown className="ml-2 w-4 h-4 opacity-50 shrink-0" />
</Button> </Button>
</PopoverTrigger> </PopoverTrigger>
<PopoverContent className="w-[300px] p-0"> <PopoverContent className="w-[300px] p-0">
@@ -114,11 +114,11 @@ export function VersionSelector({
: "opacity-0", : "opacity-0",
)} )}
/> />
<div className="flex items-center gap-2"> <div className="flex gap-2 items-center">
<span>{version.tag_name}</span> <span>{version.tag_name}</span>
{version.is_alpha && ( {version.is_nightly && (
<Badge variant="secondary" className="text-xs"> <Badge variant="secondary" className="text-xs">
Alpha Nightly
</Badge> </Badge>
)} )}
{isDownloaded && ( {isDownloaded && (
@@ -147,7 +147,7 @@ export function VersionSelector({
variant="outline" variant="outline"
className="w-full" className="w-full"
> >
<LuDownload className="mr-2 h-4 w-4" /> <LuDownload className="mr-2 w-4 h-4" />
{isDownloading ? "Downloading..." : "Download Browser"} {isDownloading ? "Downloading..." : "Download Browser"}
</LoadingButton> </LoadingButton>
)} )}
+19 -3
View File
@@ -19,7 +19,7 @@ interface GithubRelease {
hash?: string; hash?: string;
}>; }>;
published_at: string; published_at: string;
is_alpha: boolean; is_nightly: boolean;
} }
interface BrowserVersionInfo { interface BrowserVersionInfo {
@@ -231,7 +231,7 @@ export function useBrowserDownload() {
tag_name: versionInfo.version, tag_name: versionInfo.version,
assets: [], assets: [],
published_at: versionInfo.date, 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, tag_name: versionInfo.version,
assets: [], assets: [],
published_at: versionInfo.date, published_at: versionInfo.date,
is_alpha: versionInfo.is_prerelease, is_nightly: versionInfo.is_prerelease,
}), }),
); );
@@ -325,6 +325,22 @@ export function useBrowserDownload() {
setIsDownloading(true); setIsDownloading(true);
try { try {
// Check browser compatibility before attempting download
const isSupported = await invoke<boolean>(
"is_browser_supported_on_platform",
{ browserStr },
);
if (!isSupported) {
const supportedBrowsers = await invoke<string[]>(
"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 invoke("download_browser", { browserStr, version });
await loadDownloadedVersions(browserStr); await loadDownloadedVersions(browserStr);
} catch (error) { } catch (error) {
+59
View File
@@ -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<string[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
const loadSupportedBrowsers = async () => {
try {
setIsLoading(true);
setError(null);
const browsers = await invoke<string[]>("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<boolean> => {
try {
return await invoke<boolean>("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,
};
}
+1 -1
View File
@@ -1,4 +1,4 @@
module.exports = { export default {
darkMode: "class", darkMode: "class",
theme: { theme: {
extend: { extend: {