feat: linux support preview

This commit is contained in:
zhom
2025-06-05 21:15:05 +04:00
parent 6836d73ffa
commit 0da34f04cb
39 changed files with 3877 additions and 942 deletions
+8 -8
View File
@@ -1,14 +1,14 @@
# ✨ 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> -->
### ️ 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. -->
### 🔄 Type of Change
## 🔄 Type of Change
<!-- Mark the relevant option with an "x". -->
@@ -19,11 +19,11 @@
- [ ] 🧹 Code cleanup/refactoring
- [ ] ⚡ 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. -->
### ✅ Checklist
## ✅ Checklist
<!-- Mark completed items with an "x". -->
@@ -36,11 +36,11 @@
- [ ] New and existing unit tests pass locally with my changes
- [ ] Any dependent changes have been merged and published
### 🧪 How Has This Been Tested?
## 🧪 How Has This Been Tested?
<!-- Please describe the tests that you ran to verify your changes. -->
### 📱 Platform Testing
## 📱 Platform Testing
<!-- Which platforms have you tested on? -->
@@ -49,6 +49,6 @@
- [ ] Windows (if applicable)
- [ ] Linux (if applicable)
### 📋 Additional Notes
## 📋 Additional Notes
<!-- Any additional information that reviewers should know about this PR. -->
+2 -2
View File
@@ -76,7 +76,7 @@ jobs:
if [[ "${{ matrix.os }}" == "ubuntu-latest" ]]; then
pnpm run build:linux-x64
elif [[ "${{ matrix.os }}" == "macos-latest" ]]; then
pnpm run build:aarch64
pnpm run build:universal
elif [[ "${{ matrix.os }}" == "windows-latest" ]]; then
pnpm run build:win-x64
fi
@@ -88,7 +88,7 @@ jobs:
if [[ "${{ matrix.os }}" == "ubuntu-latest" ]]; then
cp nodecar/dist/nodecar src-tauri/binaries/nodecar-x86_64-unknown-linux-gnu
elif [[ "${{ matrix.os }}" == "macos-latest" ]]; then
cp nodecar/dist/nodecar src-tauri/binaries/nodecar-aarch64-apple-darwin
cp nodecar/dist/nodecar src-tauri/binaries/nodecar-universal-apple-darwin
elif [[ "${{ matrix.os }}" == "windows-latest" ]]; then
cp nodecar/dist/nodecar.exe src-tauri/binaries/nodecar-x86_64-pc-windows-msvc.exe
fi
+30 -12
View File
@@ -57,19 +57,25 @@ jobs:
target: "x86_64-apple-darwin"
pkg_target: "latest-macos-x64"
nodecar_script: "build:x86_64"
- platform: "macos-latest"
args: "--target universal-apple-darwin"
arch: "aarch64"
target: "aarch64-apple-darwin,x86_64-apple-darwin"
pkg_target: "universal"
nodecar_script: "build:universal"
- platform: "ubuntu-20.04"
args: "--target x86_64-unknown-linux-gnu"
arch: "x86_64"
target: "x86_64-unknown-linux-gnu"
pkg_target: "latest-linux-x64"
nodecar_script: "build:linux-x64"
- platform: "ubuntu-20.04"
args: "--target aarch64-unknown-linux-gnu"
arch: "aarch64"
target: "aarch64-unknown-linux-gnu"
pkg_target: "latest-linux-arm64"
nodecar_script: "build:linux-arm64"
# Future platforms can be added here:
# - platform: "ubuntu-20.04"
# args: "--target x86_64-unknown-linux-gnu"
# arch: "x86_64"
# target: "x86_64-unknown-linux-gnu"
# pkg_target: "latest-linux-x64"
# nodecar_script: "build:linux-x64"
# - platform: "ubuntu-20.04"
# args: "--target aarch64-unknown-linux-gnu"
# arch: "aarch64"
# target: "aarch64-unknown-linux-gnu"
# pkg_target: "latest-linux-arm64"
# nodecar_script: "build:linux-arm64"
# - platform: "windows-latest"
# args: "--target x86_64-pc-windows-msvc"
# arch: "x86_64"
@@ -105,6 +111,18 @@ jobs:
run: |
sudo apt-get update
sudo apt-get install -y libwebkit2gtk-4.1-dev libgtk-3-dev libayatana-appindicator3-dev librsvg2-dev
# Install cross-compilation tools for ARM64
if [[ "${{ matrix.arch }}" == "aarch64" ]]; then
sudo apt-get install -y gcc-aarch64-linux-gnu
fi
- name: Setup cross-compilation environment (Ubuntu ARM64 only)
if: matrix.platform == 'ubuntu-20.04' && matrix.arch == 'aarch64'
run: |
echo "CC_aarch64_unknown_linux_gnu=aarch64-linux-gnu-gcc" >> $GITHUB_ENV
echo "CXX_aarch64_unknown_linux_gnu=aarch64-linux-gnu-g++" >> $GITHUB_ENV
echo "AR_aarch64_unknown_linux_gnu=aarch64-linux-gnu-ar" >> $GITHUB_ENV
echo "CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_LINKER=aarch64-linux-gnu-gcc" >> $GITHUB_ENV
- name: Rust cache
uses: swatinem/rust-cache@v2
+41 -1
View File
@@ -56,6 +56,24 @@ jobs:
target: "x86_64-apple-darwin"
pkg_target: "latest-macos-x64"
nodecar_script: "build:x86_64"
- platform: "macos-latest"
args: "--target universal-apple-darwin"
arch: "aarch64"
target: "aarch64-apple-darwin,x86_64-apple-darwin"
pkg_target: "universal"
nodecar_script: "build:universal"
- platform: "ubuntu-20.04"
args: "--target x86_64-unknown-linux-gnu"
arch: "x86_64"
target: "x86_64-unknown-linux-gnu"
pkg_target: "latest-linux-x64"
nodecar_script: "build:linux-x64"
- platform: "ubuntu-20.04"
args: "--target aarch64-unknown-linux-gnu"
arch: "aarch64"
target: "aarch64-unknown-linux-gnu"
pkg_target: "latest-linux-arm64"
nodecar_script: "build:linux-arm64"
runs-on: ${{ matrix.platform }}
steps:
@@ -74,6 +92,24 @@ jobs:
with:
targets: ${{ matrix.target }}
- name: Install dependencies (Ubuntu only)
if: matrix.platform == 'ubuntu-20.04'
run: |
sudo apt-get update
sudo apt-get install -y libwebkit2gtk-4.1-dev libgtk-3-dev libayatana-appindicator3-dev librsvg2-dev
# Install cross-compilation tools for ARM64
if [[ "${{ matrix.arch }}" == "aarch64" ]]; then
sudo apt-get install -y gcc-aarch64-linux-gnu
fi
- name: Setup cross-compilation environment (Ubuntu ARM64 only)
if: matrix.platform == 'ubuntu-20.04' && matrix.arch == 'aarch64'
run: |
echo "CC_aarch64_unknown_linux_gnu=aarch64-linux-gnu-gcc" >> $GITHUB_ENV
echo "CXX_aarch64_unknown_linux_gnu=aarch64-linux-gnu-g++" >> $GITHUB_ENV
echo "AR_aarch64_unknown_linux_gnu=aarch64-linux-gnu-ar" >> $GITHUB_ENV
echo "CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_LINKER=aarch64-linux-gnu-gcc" >> $GITHUB_ENV
- name: Rust cache
uses: swatinem/rust-cache@v2
with:
@@ -97,7 +133,11 @@ jobs:
shell: bash
run: |
mkdir -p src-tauri/binaries
cp nodecar/dist/nodecar src-tauri/binaries/nodecar-${{ matrix.target }}
if [[ "${{ matrix.platform }}" == "windows-latest" ]]; then
cp nodecar/dist/nodecar.exe src-tauri/binaries/nodecar-${{ matrix.target }}.exe
else
cp nodecar/dist/nodecar src-tauri/binaries/nodecar-${{ matrix.target }}
fi
- name: Build frontend
run: pnpm build
+3
View File
@@ -46,4 +46,7 @@ yarn-error.log*
# typescript
*.tsbuildinfo
# eslint
.eslintcache
!**/.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"
]
}
+9
View File
@@ -9,13 +9,20 @@
"checkin",
"clippy",
"codegen",
"devedition",
"donutbrowser",
"dtolnay",
"elif",
"esbuild",
"eslintcache",
"frontmost",
"gifs",
"gsettings",
"idletime",
"KHTML",
"launchservices",
"mountpoint",
"msys",
"Mullvad",
"nodecar",
"ntlm",
@@ -33,6 +40,7 @@
"sonner",
"sspi",
"staticlib",
"subdirs",
"swatinem",
"sysinfo",
"systempreferences",
@@ -42,6 +50,7 @@
"turbopack",
"unlisten",
"unrs",
"vercel",
"wiremock",
"xattr",
"zhom"
+1 -1
View File
@@ -10,6 +10,7 @@
"build": "tsc && pkg ./dist/index.js --targets latest-macos-arm64 --output dist/nodecar",
"build:aarch64": "tsc && pkg ./dist/index.js --targets latest-macos-arm64 --output dist/nodecar",
"build:x86_64": "tsc && pkg ./dist/index.js --targets latest-macos-x64 --output dist/nodecar",
"build:universal": "tsc && pkg ./dist/index.js --targets latest-macos-arm64 --output dist/nodecar-arm64 && pkg ./dist/index.js --targets latest-macos-x64 --output dist/nodecar-x64 && lipo -create -output dist/nodecar dist/nodecar-arm64 dist/nodecar-x64 && rm dist/nodecar-arm64 dist/nodecar-x64",
"build:linux-x64": "tsc && pkg ./dist/index.js --targets latest-linux-x64 --output dist/nodecar",
"build:linux-arm64": "tsc && pkg ./dist/index.js --targets latest-linux-arm64 --output dist/nodecar",
"build:win-x64": "tsc && pkg ./dist/index.js --targets latest-win-x64 --output dist/nodecar",
@@ -18,7 +19,6 @@
"keywords": [],
"author": "",
"license": "AGPL-3.0",
"packageManager": "pnpm@10.11.1+sha512.e519b9f7639869dc8d5c3c5dfef73b3f091094b0a006d7317353c72b124e80e1afd429732e28705ad6bfa1ee879c1fce46c128ccebd3192101f43dd67c667912",
"dependencies": {
"@types/node": "^22.15.29",
"@yao-pkg/pkg": "^6.5.1",
-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",
"build": "next build",
"start": "next start",
"test": "pnpm test:rust",
"test:rust": "cd src-tauri && cargo test",
"lint": "pnpm lint:js && pnpm lint:rust",
"lint:js": "biome check src/ && tsc --noEmit && next lint",
"lint:rust": "cd src-tauri && cargo clippy --all-targets --all-features -- -D warnings -D clippy::all && cargo fmt --all",
"tauri": "tauri",
"shadcn:add": "pnpm dlx shadcn@latest add",
"prepare": "husky",
"prepare": "husky && husky install",
"format:rust": "cd src-tauri && cargo clippy --fix --allow-dirty --all-targets --all-features -- -D warnings -D clippy::all && cargo fmt --all",
"format:js": "biome check src/ --fix",
"format": "pnpm format:js && pnpm format:rust",
"cargo": "cd src-tauri && cargo"
"cargo": "cd src-tauri && cargo",
"check-unused-commands": "cd src-tauri && cargo run --bin check_unused_commands"
},
"dependencies": {
"@radix-ui/react-checkbox": "^1.3.2",
@@ -54,7 +57,7 @@
"@tauri-apps/cli": "^2.5.0",
"@types/node": "^22.15.29",
"@types/react": "^19.1.6",
"@types/react-dom": "^19.1.5",
"@types/react-dom": "^19.1.6",
"@typescript-eslint/eslint-plugin": "^8.33.1",
"@typescript-eslint/parser": "^8.33.1",
"@vitejs/plugin-react": "^4.5.1",
@@ -64,14 +67,15 @@
"husky": "^9.1.7",
"lint-staged": "^16.1.0",
"tailwindcss": "^4.1.8",
"tw-animate-css": "^1.3.3",
"tw-animate-css": "^1.3.4",
"typescript": "~5.8.3",
"typescript-eslint": "^8.33.1"
},
"packageManager": "pnpm@10.11.0+sha512.6540583f41cc5f628eb3d9773ecee802f4f9ef9923cc45b69890fb47991d4b092964694ec3a4f738a420c918a333062c8b925d312f42e4f0c263eb603551f977",
"packageManager": "pnpm@10.11.1",
"lint-staged": {
"src/**/*.{js,jsx,ts,tsx,json,css,md}": [
"biome check --fix"
"biome check --fix",
"eslint --cache --fix"
],
"src-tauri/**/*.rs": [
"cd src-tauri && cargo fmt --all",
+122 -122
View File
@@ -10,34 +10,34 @@ importers:
dependencies:
'@radix-ui/react-checkbox':
specifier: ^1.3.2
version: 1.3.2(@types/react-dom@19.1.5(@types/react@19.1.6))(@types/react@19.1.6)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
version: 1.3.2(@types/react-dom@19.1.6(@types/react@19.1.6))(@types/react@19.1.6)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
'@radix-ui/react-dialog':
specifier: ^1.1.14
version: 1.1.14(@types/react-dom@19.1.5(@types/react@19.1.6))(@types/react@19.1.6)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
version: 1.1.14(@types/react-dom@19.1.6(@types/react@19.1.6))(@types/react@19.1.6)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
'@radix-ui/react-dropdown-menu':
specifier: ^2.1.15
version: 2.1.15(@types/react-dom@19.1.5(@types/react@19.1.6))(@types/react@19.1.6)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
version: 2.1.15(@types/react-dom@19.1.6(@types/react@19.1.6))(@types/react@19.1.6)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
'@radix-ui/react-label':
specifier: ^2.1.7
version: 2.1.7(@types/react-dom@19.1.5(@types/react@19.1.6))(@types/react@19.1.6)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
version: 2.1.7(@types/react-dom@19.1.6(@types/react@19.1.6))(@types/react@19.1.6)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
'@radix-ui/react-popover':
specifier: ^1.1.14
version: 1.1.14(@types/react-dom@19.1.5(@types/react@19.1.6))(@types/react@19.1.6)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
version: 1.1.14(@types/react-dom@19.1.6(@types/react@19.1.6))(@types/react@19.1.6)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
'@radix-ui/react-progress':
specifier: ^1.1.7
version: 1.1.7(@types/react-dom@19.1.5(@types/react@19.1.6))(@types/react@19.1.6)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
version: 1.1.7(@types/react-dom@19.1.6(@types/react@19.1.6))(@types/react@19.1.6)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
'@radix-ui/react-scroll-area':
specifier: ^1.2.9
version: 1.2.9(@types/react-dom@19.1.5(@types/react@19.1.6))(@types/react@19.1.6)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
version: 1.2.9(@types/react-dom@19.1.6(@types/react@19.1.6))(@types/react@19.1.6)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
'@radix-ui/react-select':
specifier: ^2.2.5
version: 2.2.5(@types/react-dom@19.1.5(@types/react@19.1.6))(@types/react@19.1.6)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
version: 2.2.5(@types/react-dom@19.1.6(@types/react@19.1.6))(@types/react@19.1.6)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
'@radix-ui/react-slot':
specifier: ^1.2.3
version: 1.2.3(@types/react@19.1.6)(react@19.1.0)
'@radix-ui/react-tooltip':
specifier: ^1.2.7
version: 1.2.7(@types/react-dom@19.1.5(@types/react@19.1.6))(@types/react@19.1.6)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
version: 1.2.7(@types/react-dom@19.1.6(@types/react@19.1.6))(@types/react@19.1.6)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
'@tanstack/react-table':
specifier: ^8.21.3
version: 8.21.3(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
@@ -58,7 +58,7 @@ importers:
version: 2.1.1
cmdk:
specifier: ^1.1.1
version: 1.1.1(@types/react-dom@19.1.5(@types/react@19.1.6))(@types/react@19.1.6)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
version: 1.1.1(@types/react-dom@19.1.6(@types/react@19.1.6))(@types/react@19.1.6)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
next:
specifier: ^15.3.3
version: 15.3.3(@babel/core@7.27.4)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
@@ -106,8 +106,8 @@ importers:
specifier: ^19.1.6
version: 19.1.6
'@types/react-dom':
specifier: ^19.1.5
version: 19.1.5(@types/react@19.1.6)
specifier: ^19.1.6
version: 19.1.6(@types/react@19.1.6)
'@typescript-eslint/eslint-plugin':
specifier: ^8.33.1
version: 8.33.1(@typescript-eslint/parser@8.33.1(eslint@9.28.0(jiti@2.4.2))(typescript@5.8.3))(eslint@9.28.0(jiti@2.4.2))(typescript@5.8.3)
@@ -136,8 +136,8 @@ importers:
specifier: ^4.1.8
version: 4.1.8
tw-animate-css:
specifier: ^1.3.3
version: 1.3.3
specifier: ^1.3.4
version: 1.3.4
typescript:
specifier: ~5.8.3
version: 5.8.3
@@ -1495,8 +1495,8 @@ packages:
'@types/node@22.15.29':
resolution: {integrity: sha512-LNdjOkUDlU1RZb8e1kOIUpN1qQUlzGkEtbVNo53vbrwDg5om6oduhm4SiUaPW5ASTXhAiP0jInWG8Qx9fVlOeQ==}
'@types/react-dom@19.1.5':
resolution: {integrity: sha512-CMCjrWucUBZvohgZxkjd6S9h0nZxXjzus6yDfUb+xLxYM7VvjKNH1tQrE9GWLql1XoOP4/Ds3bwFqShHUYraGg==}
'@types/react-dom@19.1.6':
resolution: {integrity: sha512-4hOiT/dwO8Ko0gV1m/TJZYk3y0KBnY9vzDh7W+DH17b2HFSOGgdj33dhihPeuy3l0q23+4e+hoXHV6hCC4dCXw==}
peerDependencies:
'@types/react': ^19.0.0
@@ -1830,8 +1830,8 @@ packages:
resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==}
engines: {node: '>=6'}
caniuse-lite@1.0.30001720:
resolution: {integrity: sha512-Ec/2yV2nNPwb4DnTANEV99ZWwm3ZWfdlfkQbWSDDt+PsXEVYwlhPH8tdMaPunYTKKmz7AnHi2oNEi1GcmKCD8g==}
caniuse-lite@1.0.30001721:
resolution: {integrity: sha512-cOuvmUVtKrtEaoKiO0rSc29jcjwMwX5tOHDy4MgVFEWiUXj4uBMJkwI8MDySkgXidpMiHUcviogAvFi4pA2hDQ==}
chalk@4.1.2:
resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==}
@@ -1996,8 +1996,8 @@ packages:
duplexer2@0.1.4:
resolution: {integrity: sha512-asLFVfWWtJ90ZyOUHMqk7/S2w2guQKxUI2itj3d92ADHhxUSbCMGi1f1cBcJ7xM1To+pE/Khbwo1yuNbMEPKeA==}
electron-to-chromium@1.5.162:
resolution: {integrity: sha512-hQA+Zb5QQwoSaXJWEAGEw1zhk//O7qDzib05Z4qTqZfNju/FAkrm5ZInp0JbTp4Z18A6bilopdZWEYrFSsfllA==}
electron-to-chromium@1.5.165:
resolution: {integrity: sha512-naiMx1Z6Nb2TxPU6fiFrUrDTjyPMLdTtaOd2oLmG8zVSg2hCWGkhPyxwk+qRmZ1ytwVqUv0u7ZcDA5+ALhaUtw==}
emoji-regex@10.4.0:
resolution: {integrity: sha512-EC+0oUMY1Rqm4O6LLrgjtYDvcVYTy7chDnM4Q7030tP4Kwj3u/pR6gP9ygnp2CJMK5Gq+9Q2oqmrFJAz01DXjw==}
@@ -3368,8 +3368,8 @@ packages:
tunnel-agent@0.6.0:
resolution: {integrity: sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==}
tw-animate-css@1.3.3:
resolution: {integrity: sha512-tXE2TRWrskc4TU3RDd7T8n8Np/wCfoeH9gz22c7PzYqNPQ9FBGFbWWzwL0JyHcFp+jHozmF76tbHfPAx22ua2Q==}
tw-animate-css@1.3.4:
resolution: {integrity: sha512-dd1Ht6/YQHcNbq0znIT6dG8uhO7Ce+VIIhZUhjsryXsMPJQz3bZg7Q2eNzLwipb25bRZslGb2myio5mScd1TFg==}
type-check@0.4.0:
resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==}
@@ -4057,22 +4057,22 @@ snapshots:
'@radix-ui/primitive@1.1.2': {}
'@radix-ui/react-arrow@1.1.7(@types/react-dom@19.1.5(@types/react@19.1.6))(@types/react@19.1.6)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)':
'@radix-ui/react-arrow@1.1.7(@types/react-dom@19.1.6(@types/react@19.1.6))(@types/react@19.1.6)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)':
dependencies:
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.1.5(@types/react@19.1.6))(@types/react@19.1.6)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.1.6(@types/react@19.1.6))(@types/react@19.1.6)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
react: 19.1.0
react-dom: 19.1.0(react@19.1.0)
optionalDependencies:
'@types/react': 19.1.6
'@types/react-dom': 19.1.5(@types/react@19.1.6)
'@types/react-dom': 19.1.6(@types/react@19.1.6)
'@radix-ui/react-checkbox@1.3.2(@types/react-dom@19.1.5(@types/react@19.1.6))(@types/react@19.1.6)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)':
'@radix-ui/react-checkbox@1.3.2(@types/react-dom@19.1.6(@types/react@19.1.6))(@types/react@19.1.6)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)':
dependencies:
'@radix-ui/primitive': 1.1.2
'@radix-ui/react-compose-refs': 1.1.2(@types/react@19.1.6)(react@19.1.0)
'@radix-ui/react-context': 1.1.2(@types/react@19.1.6)(react@19.1.0)
'@radix-ui/react-presence': 1.1.4(@types/react-dom@19.1.5(@types/react@19.1.6))(@types/react@19.1.6)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.1.5(@types/react@19.1.6))(@types/react@19.1.6)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
'@radix-ui/react-presence': 1.1.4(@types/react-dom@19.1.6(@types/react@19.1.6))(@types/react@19.1.6)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.1.6(@types/react@19.1.6))(@types/react@19.1.6)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
'@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.1.6)(react@19.1.0)
'@radix-ui/react-use-previous': 1.1.1(@types/react@19.1.6)(react@19.1.0)
'@radix-ui/react-use-size': 1.1.1(@types/react@19.1.6)(react@19.1.0)
@@ -4080,19 +4080,19 @@ snapshots:
react-dom: 19.1.0(react@19.1.0)
optionalDependencies:
'@types/react': 19.1.6
'@types/react-dom': 19.1.5(@types/react@19.1.6)
'@types/react-dom': 19.1.6(@types/react@19.1.6)
'@radix-ui/react-collection@1.1.7(@types/react-dom@19.1.5(@types/react@19.1.6))(@types/react@19.1.6)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)':
'@radix-ui/react-collection@1.1.7(@types/react-dom@19.1.6(@types/react@19.1.6))(@types/react@19.1.6)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)':
dependencies:
'@radix-ui/react-compose-refs': 1.1.2(@types/react@19.1.6)(react@19.1.0)
'@radix-ui/react-context': 1.1.2(@types/react@19.1.6)(react@19.1.0)
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.1.5(@types/react@19.1.6))(@types/react@19.1.6)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.1.6(@types/react@19.1.6))(@types/react@19.1.6)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
'@radix-ui/react-slot': 1.2.3(@types/react@19.1.6)(react@19.1.0)
react: 19.1.0
react-dom: 19.1.0(react@19.1.0)
optionalDependencies:
'@types/react': 19.1.6
'@types/react-dom': 19.1.5(@types/react@19.1.6)
'@types/react-dom': 19.1.6(@types/react@19.1.6)
'@radix-ui/react-compose-refs@1.1.2(@types/react@19.1.6)(react@19.1.0)':
dependencies:
@@ -4106,18 +4106,18 @@ snapshots:
optionalDependencies:
'@types/react': 19.1.6
'@radix-ui/react-dialog@1.1.14(@types/react-dom@19.1.5(@types/react@19.1.6))(@types/react@19.1.6)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)':
'@radix-ui/react-dialog@1.1.14(@types/react-dom@19.1.6(@types/react@19.1.6))(@types/react@19.1.6)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)':
dependencies:
'@radix-ui/primitive': 1.1.2
'@radix-ui/react-compose-refs': 1.1.2(@types/react@19.1.6)(react@19.1.0)
'@radix-ui/react-context': 1.1.2(@types/react@19.1.6)(react@19.1.0)
'@radix-ui/react-dismissable-layer': 1.1.10(@types/react-dom@19.1.5(@types/react@19.1.6))(@types/react@19.1.6)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
'@radix-ui/react-dismissable-layer': 1.1.10(@types/react-dom@19.1.6(@types/react@19.1.6))(@types/react@19.1.6)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
'@radix-ui/react-focus-guards': 1.1.2(@types/react@19.1.6)(react@19.1.0)
'@radix-ui/react-focus-scope': 1.1.7(@types/react-dom@19.1.5(@types/react@19.1.6))(@types/react@19.1.6)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
'@radix-ui/react-focus-scope': 1.1.7(@types/react-dom@19.1.6(@types/react@19.1.6))(@types/react@19.1.6)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
'@radix-ui/react-id': 1.1.1(@types/react@19.1.6)(react@19.1.0)
'@radix-ui/react-portal': 1.1.9(@types/react-dom@19.1.5(@types/react@19.1.6))(@types/react@19.1.6)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
'@radix-ui/react-presence': 1.1.4(@types/react-dom@19.1.5(@types/react@19.1.6))(@types/react@19.1.6)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.1.5(@types/react@19.1.6))(@types/react@19.1.6)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
'@radix-ui/react-portal': 1.1.9(@types/react-dom@19.1.6(@types/react@19.1.6))(@types/react@19.1.6)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
'@radix-ui/react-presence': 1.1.4(@types/react-dom@19.1.6(@types/react@19.1.6))(@types/react@19.1.6)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.1.6(@types/react@19.1.6))(@types/react@19.1.6)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
'@radix-ui/react-slot': 1.2.3(@types/react@19.1.6)(react@19.1.0)
'@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.1.6)(react@19.1.0)
aria-hidden: 1.2.6
@@ -4126,7 +4126,7 @@ snapshots:
react-remove-scroll: 2.7.1(@types/react@19.1.6)(react@19.1.0)
optionalDependencies:
'@types/react': 19.1.6
'@types/react-dom': 19.1.5(@types/react@19.1.6)
'@types/react-dom': 19.1.6(@types/react@19.1.6)
'@radix-ui/react-direction@1.1.1(@types/react@19.1.6)(react@19.1.0)':
dependencies:
@@ -4134,33 +4134,33 @@ snapshots:
optionalDependencies:
'@types/react': 19.1.6
'@radix-ui/react-dismissable-layer@1.1.10(@types/react-dom@19.1.5(@types/react@19.1.6))(@types/react@19.1.6)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)':
'@radix-ui/react-dismissable-layer@1.1.10(@types/react-dom@19.1.6(@types/react@19.1.6))(@types/react@19.1.6)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)':
dependencies:
'@radix-ui/primitive': 1.1.2
'@radix-ui/react-compose-refs': 1.1.2(@types/react@19.1.6)(react@19.1.0)
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.1.5(@types/react@19.1.6))(@types/react@19.1.6)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.1.6(@types/react@19.1.6))(@types/react@19.1.6)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
'@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.1.6)(react@19.1.0)
'@radix-ui/react-use-escape-keydown': 1.1.1(@types/react@19.1.6)(react@19.1.0)
react: 19.1.0
react-dom: 19.1.0(react@19.1.0)
optionalDependencies:
'@types/react': 19.1.6
'@types/react-dom': 19.1.5(@types/react@19.1.6)
'@types/react-dom': 19.1.6(@types/react@19.1.6)
'@radix-ui/react-dropdown-menu@2.1.15(@types/react-dom@19.1.5(@types/react@19.1.6))(@types/react@19.1.6)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)':
'@radix-ui/react-dropdown-menu@2.1.15(@types/react-dom@19.1.6(@types/react@19.1.6))(@types/react@19.1.6)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)':
dependencies:
'@radix-ui/primitive': 1.1.2
'@radix-ui/react-compose-refs': 1.1.2(@types/react@19.1.6)(react@19.1.0)
'@radix-ui/react-context': 1.1.2(@types/react@19.1.6)(react@19.1.0)
'@radix-ui/react-id': 1.1.1(@types/react@19.1.6)(react@19.1.0)
'@radix-ui/react-menu': 2.1.15(@types/react-dom@19.1.5(@types/react@19.1.6))(@types/react@19.1.6)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.1.5(@types/react@19.1.6))(@types/react@19.1.6)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
'@radix-ui/react-menu': 2.1.15(@types/react-dom@19.1.6(@types/react@19.1.6))(@types/react@19.1.6)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.1.6(@types/react@19.1.6))(@types/react@19.1.6)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
'@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.1.6)(react@19.1.0)
react: 19.1.0
react-dom: 19.1.0(react@19.1.0)
optionalDependencies:
'@types/react': 19.1.6
'@types/react-dom': 19.1.5(@types/react@19.1.6)
'@types/react-dom': 19.1.6(@types/react@19.1.6)
'@radix-ui/react-focus-guards@1.1.2(@types/react@19.1.6)(react@19.1.0)':
dependencies:
@@ -4168,16 +4168,16 @@ snapshots:
optionalDependencies:
'@types/react': 19.1.6
'@radix-ui/react-focus-scope@1.1.7(@types/react-dom@19.1.5(@types/react@19.1.6))(@types/react@19.1.6)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)':
'@radix-ui/react-focus-scope@1.1.7(@types/react-dom@19.1.6(@types/react@19.1.6))(@types/react@19.1.6)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)':
dependencies:
'@radix-ui/react-compose-refs': 1.1.2(@types/react@19.1.6)(react@19.1.0)
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.1.5(@types/react@19.1.6))(@types/react@19.1.6)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.1.6(@types/react@19.1.6))(@types/react@19.1.6)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
'@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.1.6)(react@19.1.0)
react: 19.1.0
react-dom: 19.1.0(react@19.1.0)
optionalDependencies:
'@types/react': 19.1.6
'@types/react-dom': 19.1.5(@types/react@19.1.6)
'@types/react-dom': 19.1.6(@types/react@19.1.6)
'@radix-ui/react-id@1.1.1(@types/react@19.1.6)(react@19.1.0)':
dependencies:
@@ -4186,31 +4186,31 @@ snapshots:
optionalDependencies:
'@types/react': 19.1.6
'@radix-ui/react-label@2.1.7(@types/react-dom@19.1.5(@types/react@19.1.6))(@types/react@19.1.6)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)':
'@radix-ui/react-label@2.1.7(@types/react-dom@19.1.6(@types/react@19.1.6))(@types/react@19.1.6)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)':
dependencies:
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.1.5(@types/react@19.1.6))(@types/react@19.1.6)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.1.6(@types/react@19.1.6))(@types/react@19.1.6)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
react: 19.1.0
react-dom: 19.1.0(react@19.1.0)
optionalDependencies:
'@types/react': 19.1.6
'@types/react-dom': 19.1.5(@types/react@19.1.6)
'@types/react-dom': 19.1.6(@types/react@19.1.6)
'@radix-ui/react-menu@2.1.15(@types/react-dom@19.1.5(@types/react@19.1.6))(@types/react@19.1.6)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)':
'@radix-ui/react-menu@2.1.15(@types/react-dom@19.1.6(@types/react@19.1.6))(@types/react@19.1.6)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)':
dependencies:
'@radix-ui/primitive': 1.1.2
'@radix-ui/react-collection': 1.1.7(@types/react-dom@19.1.5(@types/react@19.1.6))(@types/react@19.1.6)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
'@radix-ui/react-collection': 1.1.7(@types/react-dom@19.1.6(@types/react@19.1.6))(@types/react@19.1.6)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
'@radix-ui/react-compose-refs': 1.1.2(@types/react@19.1.6)(react@19.1.0)
'@radix-ui/react-context': 1.1.2(@types/react@19.1.6)(react@19.1.0)
'@radix-ui/react-direction': 1.1.1(@types/react@19.1.6)(react@19.1.0)
'@radix-ui/react-dismissable-layer': 1.1.10(@types/react-dom@19.1.5(@types/react@19.1.6))(@types/react@19.1.6)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
'@radix-ui/react-dismissable-layer': 1.1.10(@types/react-dom@19.1.6(@types/react@19.1.6))(@types/react@19.1.6)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
'@radix-ui/react-focus-guards': 1.1.2(@types/react@19.1.6)(react@19.1.0)
'@radix-ui/react-focus-scope': 1.1.7(@types/react-dom@19.1.5(@types/react@19.1.6))(@types/react@19.1.6)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
'@radix-ui/react-focus-scope': 1.1.7(@types/react-dom@19.1.6(@types/react@19.1.6))(@types/react@19.1.6)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
'@radix-ui/react-id': 1.1.1(@types/react@19.1.6)(react@19.1.0)
'@radix-ui/react-popper': 1.2.7(@types/react-dom@19.1.5(@types/react@19.1.6))(@types/react@19.1.6)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
'@radix-ui/react-portal': 1.1.9(@types/react-dom@19.1.5(@types/react@19.1.6))(@types/react@19.1.6)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
'@radix-ui/react-presence': 1.1.4(@types/react-dom@19.1.5(@types/react@19.1.6))(@types/react@19.1.6)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.1.5(@types/react@19.1.6))(@types/react@19.1.6)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
'@radix-ui/react-roving-focus': 1.1.10(@types/react-dom@19.1.5(@types/react@19.1.6))(@types/react@19.1.6)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
'@radix-ui/react-popper': 1.2.7(@types/react-dom@19.1.6(@types/react@19.1.6))(@types/react@19.1.6)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
'@radix-ui/react-portal': 1.1.9(@types/react-dom@19.1.6(@types/react@19.1.6))(@types/react@19.1.6)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
'@radix-ui/react-presence': 1.1.4(@types/react-dom@19.1.6(@types/react@19.1.6))(@types/react@19.1.6)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.1.6(@types/react@19.1.6))(@types/react@19.1.6)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
'@radix-ui/react-roving-focus': 1.1.10(@types/react-dom@19.1.6(@types/react@19.1.6))(@types/react@19.1.6)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
'@radix-ui/react-slot': 1.2.3(@types/react@19.1.6)(react@19.1.0)
'@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.1.6)(react@19.1.0)
aria-hidden: 1.2.6
@@ -4219,21 +4219,21 @@ snapshots:
react-remove-scroll: 2.7.1(@types/react@19.1.6)(react@19.1.0)
optionalDependencies:
'@types/react': 19.1.6
'@types/react-dom': 19.1.5(@types/react@19.1.6)
'@types/react-dom': 19.1.6(@types/react@19.1.6)
'@radix-ui/react-popover@1.1.14(@types/react-dom@19.1.5(@types/react@19.1.6))(@types/react@19.1.6)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)':
'@radix-ui/react-popover@1.1.14(@types/react-dom@19.1.6(@types/react@19.1.6))(@types/react@19.1.6)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)':
dependencies:
'@radix-ui/primitive': 1.1.2
'@radix-ui/react-compose-refs': 1.1.2(@types/react@19.1.6)(react@19.1.0)
'@radix-ui/react-context': 1.1.2(@types/react@19.1.6)(react@19.1.0)
'@radix-ui/react-dismissable-layer': 1.1.10(@types/react-dom@19.1.5(@types/react@19.1.6))(@types/react@19.1.6)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
'@radix-ui/react-dismissable-layer': 1.1.10(@types/react-dom@19.1.6(@types/react@19.1.6))(@types/react@19.1.6)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
'@radix-ui/react-focus-guards': 1.1.2(@types/react@19.1.6)(react@19.1.0)
'@radix-ui/react-focus-scope': 1.1.7(@types/react-dom@19.1.5(@types/react@19.1.6))(@types/react@19.1.6)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
'@radix-ui/react-focus-scope': 1.1.7(@types/react-dom@19.1.6(@types/react@19.1.6))(@types/react@19.1.6)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
'@radix-ui/react-id': 1.1.1(@types/react@19.1.6)(react@19.1.0)
'@radix-ui/react-popper': 1.2.7(@types/react-dom@19.1.5(@types/react@19.1.6))(@types/react@19.1.6)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
'@radix-ui/react-portal': 1.1.9(@types/react-dom@19.1.5(@types/react@19.1.6))(@types/react@19.1.6)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
'@radix-ui/react-presence': 1.1.4(@types/react-dom@19.1.5(@types/react@19.1.6))(@types/react@19.1.6)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.1.5(@types/react@19.1.6))(@types/react@19.1.6)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
'@radix-ui/react-popper': 1.2.7(@types/react-dom@19.1.6(@types/react@19.1.6))(@types/react@19.1.6)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
'@radix-ui/react-portal': 1.1.9(@types/react-dom@19.1.6(@types/react@19.1.6))(@types/react@19.1.6)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
'@radix-ui/react-presence': 1.1.4(@types/react-dom@19.1.6(@types/react@19.1.6))(@types/react@19.1.6)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.1.6(@types/react@19.1.6))(@types/react@19.1.6)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
'@radix-ui/react-slot': 1.2.3(@types/react@19.1.6)(react@19.1.0)
'@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.1.6)(react@19.1.0)
aria-hidden: 1.2.6
@@ -4242,15 +4242,15 @@ snapshots:
react-remove-scroll: 2.7.1(@types/react@19.1.6)(react@19.1.0)
optionalDependencies:
'@types/react': 19.1.6
'@types/react-dom': 19.1.5(@types/react@19.1.6)
'@types/react-dom': 19.1.6(@types/react@19.1.6)
'@radix-ui/react-popper@1.2.7(@types/react-dom@19.1.5(@types/react@19.1.6))(@types/react@19.1.6)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)':
'@radix-ui/react-popper@1.2.7(@types/react-dom@19.1.6(@types/react@19.1.6))(@types/react@19.1.6)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)':
dependencies:
'@floating-ui/react-dom': 2.1.3(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
'@radix-ui/react-arrow': 1.1.7(@types/react-dom@19.1.5(@types/react@19.1.6))(@types/react@19.1.6)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
'@radix-ui/react-arrow': 1.1.7(@types/react-dom@19.1.6(@types/react@19.1.6))(@types/react@19.1.6)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
'@radix-ui/react-compose-refs': 1.1.2(@types/react@19.1.6)(react@19.1.0)
'@radix-ui/react-context': 1.1.2(@types/react@19.1.6)(react@19.1.0)
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.1.5(@types/react@19.1.6))(@types/react@19.1.6)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.1.6(@types/react@19.1.6))(@types/react@19.1.6)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
'@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.1.6)(react@19.1.0)
'@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.1.6)(react@19.1.0)
'@radix-ui/react-use-rect': 1.1.1(@types/react@19.1.6)(react@19.1.0)
@@ -4260,19 +4260,19 @@ snapshots:
react-dom: 19.1.0(react@19.1.0)
optionalDependencies:
'@types/react': 19.1.6
'@types/react-dom': 19.1.5(@types/react@19.1.6)
'@types/react-dom': 19.1.6(@types/react@19.1.6)
'@radix-ui/react-portal@1.1.9(@types/react-dom@19.1.5(@types/react@19.1.6))(@types/react@19.1.6)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)':
'@radix-ui/react-portal@1.1.9(@types/react-dom@19.1.6(@types/react@19.1.6))(@types/react@19.1.6)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)':
dependencies:
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.1.5(@types/react@19.1.6))(@types/react@19.1.6)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.1.6(@types/react@19.1.6))(@types/react@19.1.6)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
'@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.1.6)(react@19.1.0)
react: 19.1.0
react-dom: 19.1.0(react@19.1.0)
optionalDependencies:
'@types/react': 19.1.6
'@types/react-dom': 19.1.5(@types/react@19.1.6)
'@types/react-dom': 19.1.6(@types/react@19.1.6)
'@radix-ui/react-presence@1.1.4(@types/react-dom@19.1.5(@types/react@19.1.6))(@types/react@19.1.6)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)':
'@radix-ui/react-presence@1.1.4(@types/react-dom@19.1.6(@types/react@19.1.6))(@types/react@19.1.6)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)':
dependencies:
'@radix-ui/react-compose-refs': 1.1.2(@types/react@19.1.6)(react@19.1.0)
'@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.1.6)(react@19.1.0)
@@ -4280,89 +4280,89 @@ snapshots:
react-dom: 19.1.0(react@19.1.0)
optionalDependencies:
'@types/react': 19.1.6
'@types/react-dom': 19.1.5(@types/react@19.1.6)
'@types/react-dom': 19.1.6(@types/react@19.1.6)
'@radix-ui/react-primitive@2.1.3(@types/react-dom@19.1.5(@types/react@19.1.6))(@types/react@19.1.6)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)':
'@radix-ui/react-primitive@2.1.3(@types/react-dom@19.1.6(@types/react@19.1.6))(@types/react@19.1.6)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)':
dependencies:
'@radix-ui/react-slot': 1.2.3(@types/react@19.1.6)(react@19.1.0)
react: 19.1.0
react-dom: 19.1.0(react@19.1.0)
optionalDependencies:
'@types/react': 19.1.6
'@types/react-dom': 19.1.5(@types/react@19.1.6)
'@types/react-dom': 19.1.6(@types/react@19.1.6)
'@radix-ui/react-progress@1.1.7(@types/react-dom@19.1.5(@types/react@19.1.6))(@types/react@19.1.6)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)':
'@radix-ui/react-progress@1.1.7(@types/react-dom@19.1.6(@types/react@19.1.6))(@types/react@19.1.6)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)':
dependencies:
'@radix-ui/react-context': 1.1.2(@types/react@19.1.6)(react@19.1.0)
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.1.5(@types/react@19.1.6))(@types/react@19.1.6)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.1.6(@types/react@19.1.6))(@types/react@19.1.6)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
react: 19.1.0
react-dom: 19.1.0(react@19.1.0)
optionalDependencies:
'@types/react': 19.1.6
'@types/react-dom': 19.1.5(@types/react@19.1.6)
'@types/react-dom': 19.1.6(@types/react@19.1.6)
'@radix-ui/react-roving-focus@1.1.10(@types/react-dom@19.1.5(@types/react@19.1.6))(@types/react@19.1.6)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)':
'@radix-ui/react-roving-focus@1.1.10(@types/react-dom@19.1.6(@types/react@19.1.6))(@types/react@19.1.6)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)':
dependencies:
'@radix-ui/primitive': 1.1.2
'@radix-ui/react-collection': 1.1.7(@types/react-dom@19.1.5(@types/react@19.1.6))(@types/react@19.1.6)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
'@radix-ui/react-collection': 1.1.7(@types/react-dom@19.1.6(@types/react@19.1.6))(@types/react@19.1.6)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
'@radix-ui/react-compose-refs': 1.1.2(@types/react@19.1.6)(react@19.1.0)
'@radix-ui/react-context': 1.1.2(@types/react@19.1.6)(react@19.1.0)
'@radix-ui/react-direction': 1.1.1(@types/react@19.1.6)(react@19.1.0)
'@radix-ui/react-id': 1.1.1(@types/react@19.1.6)(react@19.1.0)
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.1.5(@types/react@19.1.6))(@types/react@19.1.6)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.1.6(@types/react@19.1.6))(@types/react@19.1.6)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
'@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.1.6)(react@19.1.0)
'@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.1.6)(react@19.1.0)
react: 19.1.0
react-dom: 19.1.0(react@19.1.0)
optionalDependencies:
'@types/react': 19.1.6
'@types/react-dom': 19.1.5(@types/react@19.1.6)
'@types/react-dom': 19.1.6(@types/react@19.1.6)
'@radix-ui/react-scroll-area@1.2.9(@types/react-dom@19.1.5(@types/react@19.1.6))(@types/react@19.1.6)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)':
'@radix-ui/react-scroll-area@1.2.9(@types/react-dom@19.1.6(@types/react@19.1.6))(@types/react@19.1.6)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)':
dependencies:
'@radix-ui/number': 1.1.1
'@radix-ui/primitive': 1.1.2
'@radix-ui/react-compose-refs': 1.1.2(@types/react@19.1.6)(react@19.1.0)
'@radix-ui/react-context': 1.1.2(@types/react@19.1.6)(react@19.1.0)
'@radix-ui/react-direction': 1.1.1(@types/react@19.1.6)(react@19.1.0)
'@radix-ui/react-presence': 1.1.4(@types/react-dom@19.1.5(@types/react@19.1.6))(@types/react@19.1.6)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.1.5(@types/react@19.1.6))(@types/react@19.1.6)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
'@radix-ui/react-presence': 1.1.4(@types/react-dom@19.1.6(@types/react@19.1.6))(@types/react@19.1.6)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.1.6(@types/react@19.1.6))(@types/react@19.1.6)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
'@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.1.6)(react@19.1.0)
'@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.1.6)(react@19.1.0)
react: 19.1.0
react-dom: 19.1.0(react@19.1.0)
optionalDependencies:
'@types/react': 19.1.6
'@types/react-dom': 19.1.5(@types/react@19.1.6)
'@types/react-dom': 19.1.6(@types/react@19.1.6)
'@radix-ui/react-select@2.2.5(@types/react-dom@19.1.5(@types/react@19.1.6))(@types/react@19.1.6)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)':
'@radix-ui/react-select@2.2.5(@types/react-dom@19.1.6(@types/react@19.1.6))(@types/react@19.1.6)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)':
dependencies:
'@radix-ui/number': 1.1.1
'@radix-ui/primitive': 1.1.2
'@radix-ui/react-collection': 1.1.7(@types/react-dom@19.1.5(@types/react@19.1.6))(@types/react@19.1.6)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
'@radix-ui/react-collection': 1.1.7(@types/react-dom@19.1.6(@types/react@19.1.6))(@types/react@19.1.6)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
'@radix-ui/react-compose-refs': 1.1.2(@types/react@19.1.6)(react@19.1.0)
'@radix-ui/react-context': 1.1.2(@types/react@19.1.6)(react@19.1.0)
'@radix-ui/react-direction': 1.1.1(@types/react@19.1.6)(react@19.1.0)
'@radix-ui/react-dismissable-layer': 1.1.10(@types/react-dom@19.1.5(@types/react@19.1.6))(@types/react@19.1.6)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
'@radix-ui/react-dismissable-layer': 1.1.10(@types/react-dom@19.1.6(@types/react@19.1.6))(@types/react@19.1.6)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
'@radix-ui/react-focus-guards': 1.1.2(@types/react@19.1.6)(react@19.1.0)
'@radix-ui/react-focus-scope': 1.1.7(@types/react-dom@19.1.5(@types/react@19.1.6))(@types/react@19.1.6)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
'@radix-ui/react-focus-scope': 1.1.7(@types/react-dom@19.1.6(@types/react@19.1.6))(@types/react@19.1.6)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
'@radix-ui/react-id': 1.1.1(@types/react@19.1.6)(react@19.1.0)
'@radix-ui/react-popper': 1.2.7(@types/react-dom@19.1.5(@types/react@19.1.6))(@types/react@19.1.6)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
'@radix-ui/react-portal': 1.1.9(@types/react-dom@19.1.5(@types/react@19.1.6))(@types/react@19.1.6)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.1.5(@types/react@19.1.6))(@types/react@19.1.6)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
'@radix-ui/react-popper': 1.2.7(@types/react-dom@19.1.6(@types/react@19.1.6))(@types/react@19.1.6)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
'@radix-ui/react-portal': 1.1.9(@types/react-dom@19.1.6(@types/react@19.1.6))(@types/react@19.1.6)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.1.6(@types/react@19.1.6))(@types/react@19.1.6)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
'@radix-ui/react-slot': 1.2.3(@types/react@19.1.6)(react@19.1.0)
'@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.1.6)(react@19.1.0)
'@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.1.6)(react@19.1.0)
'@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.1.6)(react@19.1.0)
'@radix-ui/react-use-previous': 1.1.1(@types/react@19.1.6)(react@19.1.0)
'@radix-ui/react-visually-hidden': 1.2.3(@types/react-dom@19.1.5(@types/react@19.1.6))(@types/react@19.1.6)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
'@radix-ui/react-visually-hidden': 1.2.3(@types/react-dom@19.1.6(@types/react@19.1.6))(@types/react@19.1.6)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
aria-hidden: 1.2.6
react: 19.1.0
react-dom: 19.1.0(react@19.1.0)
react-remove-scroll: 2.7.1(@types/react@19.1.6)(react@19.1.0)
optionalDependencies:
'@types/react': 19.1.6
'@types/react-dom': 19.1.5(@types/react@19.1.6)
'@types/react-dom': 19.1.6(@types/react@19.1.6)
'@radix-ui/react-slot@1.2.3(@types/react@19.1.6)(react@19.1.0)':
dependencies:
@@ -4371,25 +4371,25 @@ snapshots:
optionalDependencies:
'@types/react': 19.1.6
'@radix-ui/react-tooltip@1.2.7(@types/react-dom@19.1.5(@types/react@19.1.6))(@types/react@19.1.6)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)':
'@radix-ui/react-tooltip@1.2.7(@types/react-dom@19.1.6(@types/react@19.1.6))(@types/react@19.1.6)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)':
dependencies:
'@radix-ui/primitive': 1.1.2
'@radix-ui/react-compose-refs': 1.1.2(@types/react@19.1.6)(react@19.1.0)
'@radix-ui/react-context': 1.1.2(@types/react@19.1.6)(react@19.1.0)
'@radix-ui/react-dismissable-layer': 1.1.10(@types/react-dom@19.1.5(@types/react@19.1.6))(@types/react@19.1.6)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
'@radix-ui/react-dismissable-layer': 1.1.10(@types/react-dom@19.1.6(@types/react@19.1.6))(@types/react@19.1.6)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
'@radix-ui/react-id': 1.1.1(@types/react@19.1.6)(react@19.1.0)
'@radix-ui/react-popper': 1.2.7(@types/react-dom@19.1.5(@types/react@19.1.6))(@types/react@19.1.6)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
'@radix-ui/react-portal': 1.1.9(@types/react-dom@19.1.5(@types/react@19.1.6))(@types/react@19.1.6)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
'@radix-ui/react-presence': 1.1.4(@types/react-dom@19.1.5(@types/react@19.1.6))(@types/react@19.1.6)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.1.5(@types/react@19.1.6))(@types/react@19.1.6)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
'@radix-ui/react-popper': 1.2.7(@types/react-dom@19.1.6(@types/react@19.1.6))(@types/react@19.1.6)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
'@radix-ui/react-portal': 1.1.9(@types/react-dom@19.1.6(@types/react@19.1.6))(@types/react@19.1.6)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
'@radix-ui/react-presence': 1.1.4(@types/react-dom@19.1.6(@types/react@19.1.6))(@types/react@19.1.6)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.1.6(@types/react@19.1.6))(@types/react@19.1.6)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
'@radix-ui/react-slot': 1.2.3(@types/react@19.1.6)(react@19.1.0)
'@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.1.6)(react@19.1.0)
'@radix-ui/react-visually-hidden': 1.2.3(@types/react-dom@19.1.5(@types/react@19.1.6))(@types/react@19.1.6)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
'@radix-ui/react-visually-hidden': 1.2.3(@types/react-dom@19.1.6(@types/react@19.1.6))(@types/react@19.1.6)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
react: 19.1.0
react-dom: 19.1.0(react@19.1.0)
optionalDependencies:
'@types/react': 19.1.6
'@types/react-dom': 19.1.5(@types/react@19.1.6)
'@types/react-dom': 19.1.6(@types/react@19.1.6)
'@radix-ui/react-use-callback-ref@1.1.1(@types/react@19.1.6)(react@19.1.0)':
dependencies:
@@ -4445,14 +4445,14 @@ snapshots:
optionalDependencies:
'@types/react': 19.1.6
'@radix-ui/react-visually-hidden@1.2.3(@types/react-dom@19.1.5(@types/react@19.1.6))(@types/react@19.1.6)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)':
'@radix-ui/react-visually-hidden@1.2.3(@types/react-dom@19.1.6(@types/react@19.1.6))(@types/react@19.1.6)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)':
dependencies:
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.1.5(@types/react@19.1.6))(@types/react@19.1.6)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.1.6(@types/react@19.1.6))(@types/react@19.1.6)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
react: 19.1.0
react-dom: 19.1.0(react@19.1.0)
optionalDependencies:
'@types/react': 19.1.6
'@types/react-dom': 19.1.5(@types/react@19.1.6)
'@types/react-dom': 19.1.6(@types/react@19.1.6)
'@radix-ui/rect@1.1.1': {}
@@ -4709,7 +4709,7 @@ snapshots:
dependencies:
undici-types: 6.21.0
'@types/react-dom@19.1.5(@types/react@19.1.6)':
'@types/react-dom@19.1.6(@types/react@19.1.6)':
dependencies:
'@types/react': 19.1.6
@@ -5070,8 +5070,8 @@ snapshots:
browserslist@4.25.0:
dependencies:
caniuse-lite: 1.0.30001720
electron-to-chromium: 1.5.162
caniuse-lite: 1.0.30001721
electron-to-chromium: 1.5.165
node-releases: 2.0.19
update-browserslist-db: 1.1.3(browserslist@4.25.0)
@@ -5103,7 +5103,7 @@ snapshots:
callsites@3.1.0: {}
caniuse-lite@1.0.30001720: {}
caniuse-lite@1.0.30001721: {}
chalk@4.1.2:
dependencies:
@@ -5151,12 +5151,12 @@ snapshots:
clsx@2.1.1: {}
cmdk@1.1.1(@types/react-dom@19.1.5(@types/react@19.1.6))(@types/react@19.1.6)(react-dom@19.1.0(react@19.1.0))(react@19.1.0):
cmdk@1.1.1(@types/react-dom@19.1.6(@types/react@19.1.6))(@types/react@19.1.6)(react-dom@19.1.0(react@19.1.0))(react@19.1.0):
dependencies:
'@radix-ui/react-compose-refs': 1.1.2(@types/react@19.1.6)(react@19.1.0)
'@radix-ui/react-dialog': 1.1.14(@types/react-dom@19.1.5(@types/react@19.1.6))(@types/react@19.1.6)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
'@radix-ui/react-dialog': 1.1.14(@types/react-dom@19.1.6(@types/react@19.1.6))(@types/react@19.1.6)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
'@radix-ui/react-id': 1.1.1(@types/react@19.1.6)(react@19.1.0)
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.1.5(@types/react@19.1.6))(@types/react@19.1.6)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.1.6(@types/react@19.1.6))(@types/react@19.1.6)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
react: 19.1.0
react-dom: 19.1.0(react@19.1.0)
transitivePeerDependencies:
@@ -5273,7 +5273,7 @@ snapshots:
dependencies:
readable-stream: 2.3.8
electron-to-chromium@1.5.162: {}
electron-to-chromium@1.5.165: {}
emoji-regex@10.4.0: {}
@@ -6193,7 +6193,7 @@ snapshots:
'@swc/counter': 0.1.3
'@swc/helpers': 0.5.15
busboy: 1.6.0
caniuse-lite: 1.0.30001720
caniuse-lite: 1.0.30001721
postcss: 8.4.31
react: 19.1.0
react-dom: 19.1.0(react@19.1.0)
@@ -6899,7 +6899,7 @@ snapshots:
dependencies:
safe-buffer: 5.2.1
tw-animate-css@1.3.3: {}
tw-animate-css@1.3.4: {}
type-check@0.4.0:
dependencies:
+21
View File
@@ -0,0 +1,21 @@
#!/bin/bash
# Determine file extension based on platform
if [[ "$OSTYPE" == "msys" || "$OSTYPE" == "win32" || "$OSTYPE" == "cygwin" ]]; then
EXT=".exe"
else
EXT=""
fi
# Get Rust target triple
RUST_INFO=$(rustc -vV)
TARGET_TRIPLE=$(echo "$RUST_INFO" | grep -o 'host: [^ ]*' | cut -d' ' -f2)
# Check if target triple was found
if [ -z "$TARGET_TRIPLE" ]; then
echo "Failed to determine platform target triple" >&2
exit 1
fi
# Rename the file
mv "nodecar/dist/nodecar${EXT}" "src-tauri/binaries/nodecar-${TARGET_TRIPLE}${EXT}"
+2 -1
View File
@@ -1,9 +1,10 @@
[package]
name = "donutbrowser"
version = "0.2.5"
description = "Browser Orchestrator"
description = "Simple Yet Powerful Browser Orchestrator"
authors = ["zhom@github"]
edition = "2021"
default-run = "donutbrowser"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
+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;
+154 -60
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);
version_comp.pre_release.is_some()
}
// Browser-specific alpha version detection for Zen Browser
pub fn is_zen_alpha_version(version: &str) -> bool {
pub fn is_zen_nightly_version(version: &str) -> bool {
// For Zen Browser, only "twilight" is considered alpha/pre-release
version.to_lowercase() == "twilight"
}
@@ -449,7 +449,7 @@ impl ApiClient {
BrowserRelease {
version: version.clone(),
date: "".to_string(), // Cache doesn't store dates
is_prerelease: is_alpha_version(&version),
is_prerelease: is_nightly_version(&version),
download_url: Some(format!(
"{}/?product=firefox-{}&os=osx&lang=en-US",
self.mozilla_download_base, version
@@ -467,7 +467,7 @@ impl ApiClient {
let response = self
.client
.get(url)
.header("User-Agent", "donutbrowser")
.header("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36")
.send()
.await?;
@@ -534,7 +534,7 @@ impl ApiClient {
BrowserRelease {
version: version.clone(),
date: "".to_string(), // Cache doesn't store dates
is_prerelease: is_alpha_version(&version),
is_prerelease: is_nightly_version(&version),
download_url: Some(format!(
"{}/?product=devedition-{}&os=osx&lang=en-US",
self.mozilla_download_base, version
@@ -552,7 +552,7 @@ impl ApiClient {
let response = self
.client
.get(url)
.header("User-Agent", "donutbrowser")
.header("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36")
.send()
.await?;
@@ -624,13 +624,13 @@ impl ApiClient {
println!("Fetching Mullvad releases from GitHub API...");
let url = format!(
"{}/repos/mullvad/mullvad-browser/releases",
"{}/repos/mullvad/mullvad-browser/releases?per_page=100",
self.github_api_base
);
let releases = self
.client
.get(url)
.header("User-Agent", "donutbrowser")
.header("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36")
.send()
.await?
.json::<Vec<GithubRelease>>()
@@ -639,7 +639,7 @@ impl ApiClient {
let mut releases: Vec<GithubRelease> = releases
.into_iter()
.map(|mut release| {
release.is_alpha = release.prerelease;
release.is_nightly = release.prerelease;
release
})
.collect();
@@ -670,13 +670,13 @@ impl ApiClient {
println!("Fetching Zen releases from GitHub API...");
let url = format!(
"{}/repos/zen-browser/desktop/releases",
"{}/repos/zen-browser/desktop/releases?per_page=100",
self.github_api_base
);
let mut releases = self
.client
.get(url)
.header("User-Agent", "donutbrowser")
.header("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36")
.send()
.await?
.json::<Vec<GithubRelease>>()
@@ -684,8 +684,8 @@ impl ApiClient {
// Check for twilight updates and mark alpha releases
for release in &mut releases {
// Use browser-specific alpha detection for Zen Browser
release.is_alpha = is_zen_alpha_version(&release.tag_name) || release.prerelease;
// Use browser-specific alpha detection for Zen Browser - only "twilight" is nightly
release.is_nightly = is_zen_nightly_version(&release.tag_name);
// Check for twilight update if this is a twilight release
if release.tag_name.to_lowercase() == "twilight" {
@@ -726,32 +726,32 @@ impl ApiClient {
println!("Fetching Brave releases from GitHub API...");
let url = format!(
"{}/repos/brave/brave-browser/releases",
"{}/repos/brave/brave-browser/releases?per_page=100",
self.github_api_base
);
let releases = self
.client
.get(url)
.header("User-Agent", "donutbrowser")
.header("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36")
.send()
.await?
.json::<Vec<GithubRelease>>()
.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
.into_iter()
.filter_map(|mut release| {
// Check if this release has a universal DMG asset
let has_universal_dmg = release
.assets
.iter()
.any(|asset| asset.name.contains(".dmg") && asset.name.contains("universal"));
// Check if this release has compatible assets for the current platform
let has_compatible_asset = Self::has_compatible_brave_asset(&release.assets, &os, &arch);
if has_universal_dmg {
// Set is_alpha based on the release name
// Nightly releases contain "Nightly", stable contain "Release"
release.is_alpha = release.name.to_lowercase().contains("nightly");
if has_compatible_asset {
// Set is_nightly based on the release name
// Stable releases start with "Release", everything else is nightly
release.is_nightly = !release.name.starts_with("Release");
Some(release)
} else {
None
@@ -772,6 +772,83 @@ impl ApiClient {
Ok(filtered_releases)
}
/// Check if a Brave release has compatible assets for the given platform and architecture
fn has_compatible_brave_asset(
assets: &[crate::browser::GithubAsset],
os: &str,
arch: &str,
) -> bool {
match os {
"windows" => {
// For Windows, look for standalone setup EXE (not the auto-updater one)
assets
.iter()
.any(|asset| {
let name = asset.name.to_lowercase();
name.contains("standalone") && name.ends_with(".exe") && !name.contains("silent")
})
|| assets.iter().any(|asset| asset.name.ends_with(".exe"))
}
"macos" => {
// For macOS, prefer universal DMG
assets
.iter()
.any(|asset| {
let name = asset.name.to_lowercase();
name.contains("universal") && name.ends_with(".dmg")
})
|| assets.iter().any(|asset| asset.name.ends_with(".dmg"))
}
"linux" => {
// For Linux, check for architecture-specific packages (prefer ZIP for stable releases)
let arch_pattern = if arch == "arm64" { "arm64" } else { "amd64" };
assets
.iter()
.any(|asset| {
let name = asset.name.to_lowercase();
name.contains("linux") && name.contains(arch_pattern) && name.ends_with(".zip")
})
|| assets.iter().any(|asset| {
let name = asset.name.to_lowercase();
name.contains(arch_pattern) && (name.ends_with(".deb") || name.ends_with(".rpm"))
})
|| assets.iter().any(|asset| {
let name = asset.name.to_lowercase();
name.contains("linux") && name.ends_with(".zip")
})
|| assets.iter().any(|asset| {
let name = asset.name.to_lowercase();
name.ends_with(".deb") || name.ends_with(".rpm")
})
}
_ => false,
}
}
/// Get platform and architecture information
fn get_platform_info() -> (String, String) {
let os = if cfg!(target_os = "windows") {
"windows"
} else if cfg!(target_os = "linux") {
"linux"
} else if cfg!(target_os = "macos") {
"macos"
} else {
"unknown"
};
let arch = if cfg!(target_arch = "x86_64") {
"x64"
} else if cfg!(target_arch = "aarch64") {
"arm64"
} else {
"unknown"
};
(os.to_string(), arch.to_string())
}
pub async fn fetch_chromium_latest_version(
&self,
) -> Result<String, Box<dyn std::error::Error + Send + Sync>> {
@@ -785,7 +862,7 @@ impl ApiClient {
let version = self
.client
.get(&url)
.header("User-Agent", "donutbrowser")
.header("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36")
.send()
.await?
.text()
@@ -885,7 +962,7 @@ impl ApiClient {
let html = self
.client
.get(url)
.header("User-Agent", "donutbrowser")
.header("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36")
.send()
.await?
.text()
@@ -965,7 +1042,7 @@ impl ApiClient {
let html = self
.client
.get(&url)
.header("User-Agent", "donutbrowser")
.header("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36")
.send()
.await?
.text()
@@ -1032,12 +1109,31 @@ impl ApiClient {
Ok(false) // No update detected
}
pub fn clear_all_cache(&self) -> Result<(), Box<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)]
mod tests {
use super::*;
use wiremock::matchers::{header, method, path};
use wiremock::matchers::{method, path, query_param};
use wiremock::{Mock, MockServer, ResponseTemplate};
async fn setup_mock_server() -> MockServer {
@@ -1215,7 +1311,6 @@ mod tests {
Mock::given(method("GET"))
.and(path("/firefox.json"))
.and(header("user-agent", "donutbrowser"))
.respond_with(
ResponseTemplate::new(200)
.set_body_string(mock_response)
@@ -1226,6 +1321,9 @@ mod tests {
let result = client.fetch_firefox_releases_with_caching(true).await;
if let Err(e) = &result {
println!("Firefox API test error: {e}");
}
assert!(result.is_ok());
let releases = result.unwrap();
assert!(!releases.is_empty());
@@ -1259,7 +1357,6 @@ mod tests {
Mock::given(method("GET"))
.and(path("/devedition.json"))
.and(header("user-agent", "donutbrowser"))
.respond_with(
ResponseTemplate::new(200)
.set_body_string(mock_response)
@@ -1272,6 +1369,9 @@ mod tests {
.fetch_firefox_developer_releases_with_caching(true)
.await;
if let Err(e) = &result {
println!("Firefox Developer API test error: {e}");
}
assert!(result.is_ok());
let releases = result.unwrap();
assert!(!releases.is_empty());
@@ -1307,7 +1407,7 @@ mod tests {
Mock::given(method("GET"))
.and(path("/repos/mullvad/mullvad-browser/releases"))
.and(header("user-agent", "donutbrowser"))
.and(query_param("per_page", "100"))
.respond_with(
ResponseTemplate::new(200)
.set_body_string(mock_response)
@@ -1322,7 +1422,7 @@ mod tests {
let releases = result.unwrap();
assert!(!releases.is_empty());
assert_eq!(releases[0].tag_name, "14.5a6");
assert!(releases[0].is_alpha);
assert!(releases[0].is_nightly);
}
#[tokio::test]
@@ -1348,7 +1448,7 @@ mod tests {
Mock::given(method("GET"))
.and(path("/repos/zen-browser/desktop/releases"))
.and(header("user-agent", "donutbrowser"))
.and(query_param("per_page", "100"))
.respond_with(
ResponseTemplate::new(200)
.set_body_string(mock_response)
@@ -1388,7 +1488,7 @@ mod tests {
Mock::given(method("GET"))
.and(path("/repos/brave/brave-browser/releases"))
.and(header("user-agent", "donutbrowser"))
.and(query_param("per_page", "100"))
.respond_with(
ResponseTemplate::new(200)
.set_body_string(mock_response)
@@ -1399,11 +1499,14 @@ mod tests {
let result = client.fetch_brave_releases_with_caching(true).await;
if let Err(e) = &result {
println!("Brave API test error: {e}");
}
assert!(result.is_ok());
let releases = result.unwrap();
assert!(!releases.is_empty());
assert_eq!(releases[0].tag_name, "v1.81.9");
assert!(!releases[0].is_alpha);
assert!(!releases[0].is_nightly);
}
#[tokio::test]
@@ -1419,7 +1522,6 @@ mod tests {
Mock::given(method("GET"))
.and(path(format!("/{arch}/LAST_CHANGE")))
.and(header("user-agent", "donutbrowser"))
.respond_with(
ResponseTemplate::new(200)
.set_body_string("1465660")
@@ -1448,7 +1550,6 @@ mod tests {
Mock::given(method("GET"))
.and(path(format!("/{arch}/LAST_CHANGE")))
.and(header("user-agent", "donutbrowser"))
.respond_with(
ResponseTemplate::new(200)
.set_body_string("1465660")
@@ -1491,7 +1592,6 @@ mod tests {
Mock::given(method("GET"))
.and(path("/"))
.and(header("user-agent", "donutbrowser"))
.respond_with(
ResponseTemplate::new(200)
.set_body_string(mock_html)
@@ -1502,7 +1602,6 @@ mod tests {
Mock::given(method("GET"))
.and(path("/14.0.4/"))
.and(header("user-agent", "donutbrowser"))
.respond_with(
ResponseTemplate::new(200)
.set_body_string(version_html)
@@ -1513,7 +1612,6 @@ mod tests {
Mock::given(method("GET"))
.and(path("/14.0.3/"))
.and(header("user-agent", "donutbrowser"))
.respond_with(
ResponseTemplate::new(200)
.set_body_string(version_html.replace("14.0.4", "14.0.3"))
@@ -1551,7 +1649,6 @@ mod tests {
Mock::given(method("GET"))
.and(path("/14.0.4/"))
.and(header("user-agent", "donutbrowser"))
.respond_with(
ResponseTemplate::new(200)
.set_body_string(version_html)
@@ -1581,7 +1678,6 @@ mod tests {
Mock::given(method("GET"))
.and(path("/14.0.5/"))
.and(header("user-agent", "donutbrowser"))
.respond_with(
ResponseTemplate::new(200)
.set_body_string(version_html)
@@ -1597,24 +1693,24 @@ mod tests {
}
#[test]
fn test_is_alpha_version() {
assert!(is_alpha_version("1.2.3a1"));
assert!(is_alpha_version("137.0b5"));
assert!(is_alpha_version("140.0rc1"));
assert!(!is_alpha_version("139.0"));
assert!(!is_alpha_version("1.2.3"));
fn test_is_nightly_version() {
assert!(is_nightly_version("1.2.3a1"));
assert!(is_nightly_version("137.0b5"));
assert!(is_nightly_version("140.0rc1"));
assert!(!is_nightly_version("139.0"));
assert!(!is_nightly_version("1.2.3"));
}
#[test]
fn test_is_zen_alpha_version() {
// Only "twilight" should be considered alpha for Zen Browser
assert!(is_zen_alpha_version("twilight"));
assert!(is_zen_alpha_version("TWILIGHT")); // Case insensitive
fn test_is_zen_nightly_version() {
// Only "twilight" should be considered nightly for Zen Browser
assert!(is_zen_nightly_version("twilight"));
assert!(is_zen_nightly_version("TWILIGHT")); // Case insensitive
// Versions with "b" should NOT be considered alpha for Zen Browser
assert!(!is_zen_alpha_version("1.12.8b"));
assert!(!is_zen_alpha_version("1.0.0b1"));
assert!(!is_zen_alpha_version("2.0.0"));
// Versions with "b" should NOT be considered nightly for Zen Browser
assert!(!is_zen_nightly_version("1.12.8b"));
assert!(!is_zen_nightly_version("1.0.0b1"));
assert!(!is_zen_nightly_version("2.0.0"));
}
#[tokio::test]
@@ -1624,7 +1720,6 @@ mod tests {
Mock::given(method("GET"))
.and(path("/firefox.json"))
.and(header("user-agent", "donutbrowser"))
.respond_with(ResponseTemplate::new(404))
.mount(&server)
.await;
@@ -1640,7 +1735,6 @@ mod tests {
Mock::given(method("GET"))
.and(path("/firefox.json"))
.and(header("user-agent", "donutbrowser"))
.respond_with(
ResponseTemplate::new(200)
.set_body_string("invalid json")
@@ -1660,7 +1754,7 @@ mod tests {
Mock::given(method("GET"))
.and(path("/repos/zen-browser/desktop/releases"))
.and(header("user-agent", "donutbrowser"))
.and(query_param("per_page", "100"))
.respond_with(ResponseTemplate::new(429).insert_header("retry-after", "60"))
.mount(&server)
.await;
+94 -21
View File
@@ -156,7 +156,7 @@ impl AppAutoUpdater {
let response = self
.client
.get(url)
.header("User-Agent", "donutbrowser")
.header("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36")
.send()
.await?;
@@ -227,6 +227,45 @@ impl AppAutoUpdater {
/// Get the appropriate download URL for the current platform
fn get_download_url_for_platform(&self, assets: &[AppReleaseAsset]) -> Option<String> {
println!("Looking for macOS universal binary assets");
for asset in assets {
println!("Found asset: {}", asset.name);
}
// Priority 1: Look for universal macOS DMG (preferred)
for asset in assets {
if asset.name.contains(".dmg")
&& (asset.name.contains("universal")
|| asset.name.contains("Universal")
|| asset.name.contains("_universal.dmg")
|| asset.name.contains("-universal.dmg")
|| asset.name.contains("_universal_")
|| asset.name.contains("-universal-"))
{
println!("Found universal binary: {}", asset.name);
return Some(asset.browser_download_url.clone());
}
}
// Priority 2: Look for generic macOS DMG without architecture specification
// This would be the case for universal binaries that don't explicitly mention "universal"
for asset in assets {
if asset.name.contains(".dmg")
&& (asset.name.to_lowercase().contains("macos")
|| asset.name.to_lowercase().contains("darwin"))
&& !asset.name.contains("x64")
&& !asset.name.contains("x86_64")
&& !asset.name.contains("x86-64")
&& !asset.name.contains("aarch64")
&& !asset.name.contains("arm64")
&& !asset.name.contains(".app.tar.gz")
{
println!("Found generic macOS DMG (likely universal): {}", asset.name);
return Some(asset.browser_download_url.clone());
}
}
// Priority 3: Fallback to current architecture-specific binary for backward compatibility
let arch = if cfg!(target_arch = "aarch64") {
"aarch64"
} else if cfg!(target_arch = "x86_64") {
@@ -235,12 +274,9 @@ impl AppAutoUpdater {
"unknown"
};
println!("Looking for assets with architecture: {arch}");
for asset in assets {
println!("Found asset: {}", asset.name);
}
println!("Falling back to architecture-specific search for: {arch}");
// Priority 1: Look for exact architecture match in DMG
// Look for exact architecture match in DMG
for asset in assets {
if asset.name.contains(".dmg")
&& (asset.name.contains(&format!("_{arch}.dmg"))
@@ -253,7 +289,7 @@ impl AppAutoUpdater {
}
}
// Priority 2: Look for x86_64 variations if we're looking for x64
// Look for x86_64 variations if we're looking for x64
if arch == "x64" {
for asset in assets {
if asset.name.contains(".dmg")
@@ -265,7 +301,7 @@ impl AppAutoUpdater {
}
}
// Priority 3: Look for arm64 variations if we're looking for aarch64
// Look for arm64 variations if we're looking for aarch64
if arch == "aarch64" {
for asset in assets {
if asset.name.contains(".dmg")
@@ -277,7 +313,7 @@ impl AppAutoUpdater {
}
}
// Priority 4: Fallback to any macOS DMG
// Priority 4: Final fallback to any macOS DMG
for asset in assets {
if asset.name.contains(".dmg")
&& (asset.name.to_lowercase().contains("macos")
@@ -356,7 +392,7 @@ impl AppAutoUpdater {
let response = self
.client
.get(download_url)
.header("User-Agent", "donutbrowser")
.header("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36")
.send()
.await?;
@@ -390,7 +426,16 @@ impl AppAutoUpdater {
.unwrap_or("");
match extension {
"dmg" => extractor.extract_dmg(archive_path, dest_dir).await,
"dmg" => {
#[cfg(target_os = "macos")]
{
extractor.extract_dmg(archive_path, dest_dir).await
}
#[cfg(not(target_os = "macos"))]
{
Err("DMG extraction is only supported on macOS".into())
}
}
"zip" => extractor.extract_zip(archive_path, dest_dir).await,
_ => Err(format!("Unsupported archive format: {extension}").into()),
}
@@ -535,14 +580,6 @@ pub async fn download_and_install_app_update(
.map_err(|e| format!("Failed to install app update: {e}"))
}
#[tauri::command]
pub fn get_app_version_info() -> Result<(String, bool), String> {
Ok((
AppAutoUpdater::get_current_version(),
AppAutoUpdater::is_nightly_build(),
))
}
#[tauri::command]
pub async fn check_for_app_updates_manual() -> Result<Option<AppUpdateInfo>, String> {
println!("Manual app update check triggered");
@@ -651,14 +688,50 @@ mod tests {
browser_download_url: "https://example.com/aarch64.dmg".to_string(),
size: 12345,
},
AppReleaseAsset {
name: "Donut.Browser_0.1.0_universal.dmg".to_string(),
browser_download_url: "https://example.com/universal.dmg".to_string(),
size: 12345,
},
];
let url = updater.get_download_url_for_platform(&assets);
assert!(url.is_some());
// The exact URL depends on the target architecture
// Should prefer universal binary over architecture-specific ones
let url = url.unwrap();
assert!(url.contains(".dmg"));
assert_eq!(url, "https://example.com/universal.dmg");
// Test with generic macOS DMG (no architecture specified)
let generic_assets = vec![AppReleaseAsset {
name: "Donut.Browser_0.1.0_macos.dmg".to_string(),
browser_download_url: "https://example.com/macos.dmg".to_string(),
size: 12345,
}];
let generic_url = updater.get_download_url_for_platform(&generic_assets);
assert!(generic_url.is_some());
assert_eq!(generic_url.unwrap(), "https://example.com/macos.dmg");
// Test fallback to architecture-specific when no universal is available
let arch_specific_assets = vec![
AppReleaseAsset {
name: "Donut.Browser_0.1.0_x64.dmg".to_string(),
browser_download_url: "https://example.com/x64.dmg".to_string(),
size: 12345,
},
AppReleaseAsset {
name: "Donut.Browser_0.1.0_aarch64.dmg".to_string(),
browser_download_url: "https://example.com/aarch64.dmg".to_string(),
size: 12345,
},
];
let arch_url = updater.get_download_url_for_platform(&arch_specific_assets);
assert!(arch_url.is_some());
// The exact URL depends on the target architecture, but should be one of the available ones
let arch_url = arch_url.unwrap();
assert!(arch_url.contains(".dmg"));
}
#[test]
+53 -63
View File
@@ -112,7 +112,7 @@ impl AutoUpdater {
available_versions: &[BrowserVersionInfo],
) -> Result<Option<UpdateNotification>, Box<dyn std::error::Error + Send + Sync>> {
let current_version = &profile.version;
let is_current_stable = !self.is_alpha_version(current_version);
let is_current_stable = !self.is_nightly_version(current_version);
// Find the best available update
let best_update = available_versions
@@ -218,40 +218,6 @@ impl AutoUpdater {
Ok(state.auto_update_downloads.contains(&download_key))
}
/// Start browser update process
pub async fn start_browser_update(
&self,
browser: &str,
new_version: &str,
) -> Result<(), Box<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
pub async fn auto_update_profile_versions(
&self,
@@ -312,9 +278,51 @@ impl AutoUpdater {
state.auto_update_downloads.remove(&download_key);
self.save_auto_update_state(&state)?;
// Check if auto-delete of unused binaries is enabled and perform cleanup
let settings = self
.settings_manager
.load_settings()
.map_err(|e| format!("Failed to load settings: {e}"))?;
if settings.auto_delete_unused_binaries {
// Perform cleanup in the background - don't fail the update if cleanup fails
if let Err(e) = self.cleanup_unused_binaries_internal() {
eprintln!("Warning: Failed to cleanup unused binaries after auto-update: {e}");
}
}
Ok(updated_profiles)
}
/// Internal method to cleanup unused binaries (used by auto-cleanup)
fn cleanup_unused_binaries_internal(
&self,
) -> Result<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
pub fn is_browser_disabled(
&self,
@@ -337,7 +345,7 @@ impl AutoUpdater {
// Helper methods
fn is_alpha_version(&self, version: &str) -> bool {
fn is_nightly_version(&self, version: &str) -> bool {
version.contains("alpha")
|| version.contains("beta")
|| version.contains("rc")
@@ -414,24 +422,6 @@ pub async fn check_for_browser_updates() -> Result<Vec<UpdateNotification>, Stri
Ok(grouped)
}
#[tauri::command]
pub async fn start_browser_update(browser: String, new_version: String) -> Result<(), String> {
let updater = AutoUpdater::new();
updater
.start_browser_update(&browser, &new_version)
.await
.map_err(|e| format!("Failed to start browser update: {e}"))
}
#[tauri::command]
pub async fn complete_browser_update(browser: String) -> Result<(), String> {
let updater = AutoUpdater::new();
updater
.complete_browser_update(&browser)
.await
.map_err(|e| format!("Failed to complete browser update: {e}"))
}
#[tauri::command]
pub async fn is_browser_disabled_for_update(browser: String) -> Result<bool, String> {
let updater = AutoUpdater::new();
@@ -509,18 +499,18 @@ mod tests {
}
#[test]
fn test_is_alpha_version() {
fn test_is_nightly_version() {
let updater = AutoUpdater::new();
assert!(updater.is_alpha_version("1.0.0-alpha"));
assert!(updater.is_alpha_version("1.0.0-beta"));
assert!(updater.is_alpha_version("1.0.0-rc"));
assert!(updater.is_alpha_version("1.0.0a1"));
assert!(updater.is_alpha_version("1.0.0b1"));
assert!(updater.is_alpha_version("1.0.0-dev"));
assert!(updater.is_nightly_version("1.0.0-alpha"));
assert!(updater.is_nightly_version("1.0.0-beta"));
assert!(updater.is_nightly_version("1.0.0-rc"));
assert!(updater.is_nightly_version("1.0.0a1"));
assert!(updater.is_nightly_version("1.0.0b1"));
assert!(updater.is_nightly_version("1.0.0-dev"));
assert!(!updater.is_alpha_version("1.0.0"));
assert!(!updater.is_alpha_version("1.2.3"));
assert!(!updater.is_nightly_version("1.0.0"));
assert!(!updater.is_nightly_version("1.2.3"));
}
#[test]
+524 -136
View File
@@ -50,7 +50,6 @@ impl BrowserType {
}
pub trait Browser: Send + Sync {
fn browser_type(&self) -> BrowserType;
fn get_executable_path(&self, install_dir: &Path) -> Result<PathBuf, Box<dyn std::error::Error>>;
fn create_launch_args(
&self,
@@ -59,24 +58,17 @@ pub trait Browser: Send + Sync {
url: Option<String>,
) -> Result<Vec<String>, Box<dyn std::error::Error>>;
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 {
browser_type: BrowserType,
}
// Platform-specific modules
#[cfg(target_os = "macos")]
mod macos {
use super::*;
impl FirefoxBrowser {
pub fn new(browser_type: BrowserType) -> Self {
Self { browser_type }
}
}
impl Browser for FirefoxBrowser {
fn browser_type(&self) -> BrowserType {
self.browser_type.clone()
}
fn get_executable_path(&self, install_dir: &Path) -> Result<PathBuf, Box<dyn std::error::Error>> {
pub fn get_firefox_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)
@@ -106,6 +98,439 @@ impl Browser for FirefoxBrowser {
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(
&self,
profile_path: &str,
@@ -135,34 +560,52 @@ impl Browser for FirefoxBrowser {
}
fn is_version_downloaded(&self, version: &str, binaries_dir: &Path) -> bool {
let browser_dir = binaries_dir
.join(self.browser_type().as_str())
.join(version);
// Expected structure: binaries/<browser>/<version>
let browser_dir = binaries_dir.join(self.browser_type.as_str()).join(version);
println!("Firefox browser checking version {version} in directory: {browser_dir:?}");
// Only check if directory exists and contains a .app file
if browser_dir.exists() {
println!("Directory exists, checking for .app files...");
if let Ok(entries) = std::fs::read_dir(&browser_dir) {
for entry in entries.flatten() {
println!(" Found entry: {:?}", entry.path());
if entry.path().extension().is_some_and(|ext| ext == "app") {
println!(" Found .app file: {:?}", entry.path());
return true;
}
}
}
println!("No .app files found in directory");
} else {
if !browser_dir.exists() {
println!("Directory does not exist: {browser_dir:?}");
return false;
}
false
println!("Directory exists, checking for browser files...");
#[cfg(target_os = "macos")]
return macos::is_firefox_version_downloaded(&browser_dir);
#[cfg(target_os = "linux")]
return linux::is_firefox_version_downloaded(&browser_dir, &self.browser_type);
#[cfg(target_os = "windows")]
return windows::is_firefox_version_downloaded(&browser_dir);
#[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
{
println!("Unsupported platform for browser verification");
false
}
}
fn prepare_executable(&self, executable_path: &Path) -> Result<(), Box<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)
pub struct ChromiumBrowser {
#[allow(dead_code)]
browser_type: BrowserType,
}
@@ -173,34 +616,18 @@ impl ChromiumBrowser {
}
impl Browser for ChromiumBrowser {
fn browser_type(&self) -> BrowserType {
self.browser_type.clone()
}
fn get_executable_path(&self, install_dir: &Path) -> Result<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")?;
#[cfg(target_os = "macos")]
return macos::get_chromium_executable_path(install_dir);
// Construct the browser executable path
let mut executable_dir = app_path.path();
executable_dir.push("Contents");
executable_dir.push("MacOS");
#[cfg(target_os = "linux")]
return linux::get_chromium_executable_path(install_dir, &self.browser_type);
// Find the first executable in the MacOS directory
let executable_path = std::fs::read_dir(&executable_dir)?
.filter_map(Result::ok)
.find(|entry| {
let binding = entry.file_name();
let name = binding.to_string_lossy();
name.contains("Chromium") || name.contains("Brave") || name.contains("Google Chrome")
})
.map(|entry| entry.path())
.ok_or("No executable found in MacOS directory")?;
#[cfg(target_os = "windows")]
return windows::get_chromium_executable_path(install_dir, &self.browser_type);
Ok(executable_path)
#[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
Err("Unsupported platform".into())
}
fn create_launch_args(
@@ -240,35 +667,46 @@ impl Browser for ChromiumBrowser {
}
fn is_version_downloaded(&self, version: &str, binaries_dir: &Path) -> bool {
let browser_dir = binaries_dir
.join(self.browser_type().as_str())
.join(version);
// Expected structure: binaries/<browser>/<version>
let browser_dir = binaries_dir.join(self.browser_type.as_str()).join(version);
println!("Chromium browser checking version {version} in directory: {browser_dir:?}");
// Check if directory exists and contains at least one .app file
if browser_dir.exists() {
println!("Directory exists, checking for .app files...");
if let Ok(entries) = std::fs::read_dir(&browser_dir) {
for entry in entries.flatten() {
println!(" Found entry: {:?}", entry.path());
if entry.path().extension().is_some_and(|ext| ext == "app") {
println!(" Found .app file: {:?}", entry.path());
// Try to get the executable path as a final verification
if self.get_executable_path(&browser_dir).is_ok() {
println!(" Executable path verification successful");
return true;
} else {
println!(" Executable path verification failed");
}
}
}
}
println!("No valid .app files found in directory");
} else {
if !browser_dir.exists() {
println!("Directory does not exist: {browser_dir:?}");
return false;
}
false
println!("Directory exists, checking for browser files...");
#[cfg(target_os = "macos")]
return macos::is_chromium_version_downloaded(&browser_dir);
#[cfg(target_os = "linux")]
return linux::is_chromium_version_downloaded(&browser_dir, &self.browser_type);
#[cfg(target_os = "windows")]
return windows::is_chromium_version_downloaded(&browser_dir, &self.browser_type);
#[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
{
println!("Unsupported platform for browser verification");
false
}
}
fn prepare_executable(&self, executable_path: &Path) -> Result<(), Box<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)]
pub published_at: String,
#[serde(default)]
pub is_alpha: bool,
pub is_nightly: bool,
#[serde(default)]
pub prerelease: bool,
}
@@ -354,56 +792,6 @@ mod tests {
assert!(BrowserType::from_str("Firefox").is_err()); // Case sensitive
}
#[test]
fn test_firefox_browser_creation() {
let browser = FirefoxBrowser::new(BrowserType::Firefox);
assert_eq!(browser.browser_type(), BrowserType::Firefox);
let browser = FirefoxBrowser::new(BrowserType::MullvadBrowser);
assert_eq!(browser.browser_type(), BrowserType::MullvadBrowser);
let browser = FirefoxBrowser::new(BrowserType::TorBrowser);
assert_eq!(browser.browser_type(), BrowserType::TorBrowser);
let browser = FirefoxBrowser::new(BrowserType::Zen);
assert_eq!(browser.browser_type(), BrowserType::Zen);
}
#[test]
fn test_chromium_browser_creation() {
let browser = ChromiumBrowser::new(BrowserType::Chromium);
assert_eq!(browser.browser_type(), BrowserType::Chromium);
let browser = ChromiumBrowser::new(BrowserType::Brave);
assert_eq!(browser.browser_type(), BrowserType::Brave);
}
#[test]
fn test_browser_factory() {
// Test Firefox-based browsers
let browser = create_browser(BrowserType::Firefox);
assert_eq!(browser.browser_type(), BrowserType::Firefox);
let browser = create_browser(BrowserType::MullvadBrowser);
assert_eq!(browser.browser_type(), BrowserType::MullvadBrowser);
let browser = create_browser(BrowserType::Zen);
assert_eq!(browser.browser_type(), BrowserType::Zen);
let browser = create_browser(BrowserType::TorBrowser);
assert_eq!(browser.browser_type(), BrowserType::TorBrowser);
let browser = create_browser(BrowserType::FirefoxDeveloper);
assert_eq!(browser.browser_type(), BrowserType::FirefoxDeveloper);
// Test Chromium-based browsers
let browser = create_browser(BrowserType::Chromium);
assert_eq!(browser.browser_type(), BrowserType::Chromium);
let browser = create_browser(BrowserType::Brave);
assert_eq!(browser.browser_type(), BrowserType::Brave);
}
#[test]
fn test_firefox_launch_args() {
// Test regular Firefox (should not use -no-remote)
@@ -509,7 +897,7 @@ mod tests {
let temp_dir = TempDir::new().unwrap();
let binaries_dir = temp_dir.path();
// Create a mock Firefox browser installation
// Create a mock Firefox browser installation with new path structure: binaries/<browser>/<version>/
let browser_dir = binaries_dir.join("firefox").join("139.0");
fs::create_dir_all(&browser_dir).unwrap();
@@ -521,7 +909,7 @@ mod tests {
assert!(browser.is_version_downloaded("139.0", binaries_dir));
assert!(!browser.is_version_downloaded("140.0", binaries_dir));
// Test with Chromium browser
// Test with Chromium browser with new path structure
let chromium_dir = binaries_dir.join("chromium").join("1465660");
fs::create_dir_all(&chromium_dir).unwrap();
let chromium_app_dir = chromium_dir.join("Chromium.app");
@@ -544,7 +932,7 @@ mod tests {
let temp_dir = TempDir::new().unwrap();
let binaries_dir = temp_dir.path();
// Create browser directory but no .app directory
// Create browser directory but no .app directory with new path structure
let browser_dir = binaries_dir.join("firefox").join("139.0");
fs::create_dir_all(&browser_dir).unwrap();
+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(
profile: &BrowserProfile,
url: &str,
@@ -484,6 +492,17 @@ mod windows {
false
}
pub async fn launch_browser_process(
executable_path: &std::path::Path,
args: &[String],
) -> Result<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(
profile: &BrowserProfile,
url: &str,
@@ -580,6 +599,126 @@ mod linux {
false
}
pub async fn launch_browser_process(
executable_path: &std::path::Path,
args: &[String],
) -> Result<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(
profile: &BrowserProfile,
url: &str,
@@ -854,9 +993,38 @@ impl BrowserRunner {
// Save the updated profile
self.save_profile(&profile)?;
// Check if auto-delete of unused binaries is enabled
let settings_manager = crate::settings_manager::SettingsManager::new();
if let Ok(settings) = settings_manager.load_settings() {
if settings.auto_delete_unused_binaries {
// Perform cleanup in the background
let _ = self.cleanup_unused_binaries_internal();
}
}
Ok(profile)
}
/// Internal method to cleanup unused binaries (used by auto-cleanup)
fn cleanup_unused_binaries_internal(&self) -> Result<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> {
vec![
// Disable default browser check
@@ -1018,7 +1186,7 @@ impl BrowserRunner {
.map_err(|_| format!("Invalid browser type: {}", profile.browser))?;
let browser = create_browser(browser_type.clone());
// Get executable path
// Get executable path - path structure: binaries/<browser>/<version>/
let mut browser_dir = self.get_binaries_dir();
browser_dir.push(&profile.browser);
browser_dir.push(&profile.version);
@@ -1027,13 +1195,40 @@ impl BrowserRunner {
.get_executable_path(&browser_dir)
.expect("Failed to get executable path");
// Prepare the executable (set permissions, etc.)
if let Err(e) = browser.prepare_executable(&executable_path) {
println!("Warning: Failed to prepare executable: {e}");
// Continue anyway, the error might not be critical
}
// Get launch arguments
let browser_args = browser
.create_launch_args(&profile.profile_path, profile.proxy.as_ref(), url)
.expect("Failed to create launch arguments");
// Launch browser
let child = Command::new(executable_path).args(&browser_args).spawn()?;
// Launch browser using platform-specific method
let child = {
#[cfg(target_os = "macos")]
{
macos::launch_browser_process(&executable_path, &browser_args).await?
}
#[cfg(target_os = "windows")]
{
windows::launch_browser_process(&executable_path, &browser_args).await?
}
#[cfg(target_os = "linux")]
{
linux::launch_browser_process(&executable_path, &browser_args).await?
}
#[cfg(not(any(target_os = "macos", target_os = "windows", target_os = "linux")))]
{
return Err("Unsupported platform for browser launching".into());
}
};
let launcher_pid = child.id();
println!(
@@ -1159,7 +1354,7 @@ impl BrowserRunner {
let browser_type = BrowserType::from_str(&updated_profile.browser)
.map_err(|_| format!("Invalid browser type: {}", updated_profile.browser))?;
// Get browser directory for all platforms
// Get browser directory for all platforms - path structure: binaries/<browser>/<version>/
let mut browser_dir = self.get_binaries_dir();
browser_dir.push(&updated_profile.browser);
browser_dir.push(&updated_profile.version);
@@ -1385,7 +1580,7 @@ impl BrowserRunner {
profile.name = new_name.to_string();
// Create new paths
let _new_profile_file = profiles_dir.join(format!(
let _ = profiles_dir.join(format!(
"{}.json",
new_name.to_lowercase().replace(" ", "_")
));
@@ -1410,44 +1605,6 @@ impl BrowserRunner {
Ok(profile)
}
pub fn get_saved_mullvad_releases(&self) -> Result<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>> {
let profiles_dir = self.get_profiles_dir();
let profile_file = profiles_dir.join(format!(
@@ -1477,6 +1634,15 @@ impl BrowserRunner {
fs::remove_file(profile_file)?
}
// Check if auto-delete of unused binaries is enabled
let settings_manager = crate::settings_manager::SettingsManager::new();
if let Ok(settings) = settings_manager.load_settings() {
if settings.auto_delete_unused_binaries {
// Perform cleanup in the background after profile deletion
let _ = self.cleanup_unused_binaries_internal();
}
}
Ok(())
}
@@ -1802,7 +1968,16 @@ pub async fn launch_browser_profile(
let updated_profile = browser_runner
.launch_or_open_url(app_handle.clone(), &profile, url)
.await
.expect("Failed to launch browser or open URL");
.map_err(|e| {
// Check if this is an architecture compatibility issue
if let Some(io_error) = e.downcast_ref::<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 let Some(proxy) = &profile.proxy {
@@ -1839,15 +2014,6 @@ pub async fn launch_browser_profile(
Ok(updated_profile)
}
// Add Tauri command to get saved releases
#[tauri::command]
pub fn get_saved_mullvad_releases() -> Result<Vec<String>, String> {
let browser_runner = BrowserRunner::new();
browser_runner
.get_saved_mullvad_releases()
.map_err(|e| e.to_string())
}
#[tauri::command]
pub fn update_profile_proxy(
profile_name: String,
@@ -1903,27 +2069,17 @@ pub fn delete_profile(_app_handle: tauri::AppHandle, profile_name: String) -> Re
}
#[tauri::command]
pub fn get_supported_browsers() -> Result<Vec<&'static str>, String> {
Ok(vec![
BrowserType::MullvadBrowser.as_str(),
BrowserType::Firefox.as_str(),
BrowserType::FirefoxDeveloper.as_str(),
BrowserType::Chromium.as_str(),
BrowserType::Brave.as_str(),
BrowserType::Zen.as_str(),
BrowserType::TorBrowser.as_str(),
])
pub fn get_supported_browsers() -> Result<Vec<String>, String> {
let service = BrowserVersionService::new();
Ok(service.get_supported_browsers())
}
#[tauri::command]
pub async fn fetch_browser_versions_detailed(
browser_str: String,
) -> Result<Vec<BrowserVersionInfo>, String> {
pub fn is_browser_supported_on_platform(browser_str: String) -> Result<bool, String> {
let service = BrowserVersionService::new();
service
.fetch_browser_versions_detailed(&browser_str, false)
.await
.map_err(|e| format!("Failed to fetch detailed browser versions: {e}"))
.is_browser_supported(&browser_str)
.map_err(|e| format!("Failed to check browser support: {e}"))
}
#[tauri::command]
@@ -1996,20 +2152,6 @@ pub async fn fetch_browser_versions_with_count_cached_first(
}
}
#[tauri::command]
pub fn get_cached_browser_versions_detailed(
browser_str: String,
) -> Result<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]
pub async fn download_browser(
app_handle: tauri::AppHandle,
@@ -2029,8 +2171,22 @@ pub async fn download_browser(
return Ok(version);
}
// Use the centralized browser version service for download info
// Check if browser is supported on current platform before attempting download
let version_service = BrowserVersionService::new();
if !version_service
.is_browser_supported(&browser_str)
.unwrap_or(false)
{
return Err(format!(
"Browser '{}' is not supported on your platform ({} {}). Supported browsers: {}",
browser_str,
std::env::consts::OS,
std::env::consts::ARCH,
version_service.get_supported_browsers().join(", ")
));
}
let download_info = version_service
.get_download_info(&browser_str, &version)
.map_err(|e| format!("Failed to get download info: {e}"))?;
@@ -2203,15 +2359,6 @@ pub fn create_browser_profile_new(
create_browser_profile(name, browser_type.as_str().to_string(), version, proxy)
}
#[tauri::command]
pub async fn fetch_browser_versions(browser_str: String) -> Result<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]
pub async fn fetch_browser_versions_with_count(
browser_str: String,
@@ -2230,6 +2377,14 @@ pub fn get_downloaded_browser_versions(browser_str: String) -> Result<Vec<String
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)]
mod tests {
use super::*;
@@ -2367,7 +2522,7 @@ mod tests {
let (runner, _temp_dir) = create_test_browser_runner();
// Create profile
let _profile = runner
let _ = runner
.create_profile("Original Name", "firefox", "139.0", None)
.unwrap();
@@ -2387,7 +2542,7 @@ mod tests {
let (runner, _temp_dir) = create_test_browser_runner();
// Create profile
let _profile = runner
let _ = runner
.create_profile("To Delete", "firefox", "139.0", None)
.unwrap();
@@ -2421,13 +2576,13 @@ mod tests {
let (runner, _temp_dir) = create_test_browser_runner();
// Create multiple profiles
let _profile1 = runner
let _ = runner
.create_profile("Profile 1", "firefox", "139.0", None)
.unwrap();
let _profile2 = runner
let _ = runner
.create_profile("Profile 2", "chromium", "1465660", None)
.unwrap();
let _profile3 = runner
let _ = runner
.create_profile("Profile 3", "brave", "v1.81.9", None)
.unwrap();
@@ -2446,10 +2601,10 @@ mod tests {
let (runner, _temp_dir) = create_test_browser_runner();
// Test that we can't rename to an existing profile name
let _profile1 = runner
let _ = runner
.create_profile("Profile 1", "firefox", "139.0", None)
.unwrap();
let _profile2 = runner
let _ = runner
.create_profile("Profile 2", "firefox", "139.0", None)
.unwrap();
+334 -154
View File
@@ -40,6 +40,70 @@ impl BrowserVersionService {
Self { api_client }
}
/// Check if a browser is supported on the current platform and architecture
pub fn is_browser_supported(
&self,
browser: &str,
) -> Result<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)
pub fn get_cached_browser_versions(&self, browser: &str) -> Option<Vec<String>> {
self.api_client.load_cached_versions(browser)
@@ -58,7 +122,7 @@ impl BrowserVersionService {
.map(|version| {
BrowserVersionInfo {
version: version.clone(),
is_prerelease: crate::api_client::is_alpha_version(&version),
is_prerelease: crate::api_client::is_nightly_version(&version),
date: "".to_string(), // Cache doesn't store dates
}
})
@@ -176,7 +240,7 @@ impl BrowserVersionService {
} else {
BrowserVersionInfo {
version: version.clone(),
is_prerelease: crate::api_client::is_alpha_version(&version),
is_prerelease: crate::api_client::is_nightly_version(&version),
date: "".to_string(),
}
}
@@ -197,7 +261,7 @@ impl BrowserVersionService {
} else {
BrowserVersionInfo {
version: version.clone(),
is_prerelease: crate::api_client::is_alpha_version(&version),
is_prerelease: crate::api_client::is_nightly_version(&version),
date: "".to_string(),
}
}
@@ -212,7 +276,7 @@ impl BrowserVersionService {
if let Some(release) = releases.iter().find(|r| r.tag_name == version) {
BrowserVersionInfo {
version: release.tag_name.clone(),
is_prerelease: release.is_alpha,
is_prerelease: release.is_nightly,
date: release.published_at.clone(),
}
} else {
@@ -233,7 +297,7 @@ impl BrowserVersionService {
if let Some(release) = releases.iter().find(|r| r.tag_name == version) {
BrowserVersionInfo {
version: release.tag_name.clone(),
is_prerelease: release.prerelease,
is_prerelease: release.is_nightly,
date: release.published_at.clone(),
}
} else {
@@ -254,7 +318,7 @@ impl BrowserVersionService {
if let Some(release) = releases.iter().find(|r| r.tag_name == version) {
BrowserVersionInfo {
version: release.tag_name.clone(),
is_prerelease: release.prerelease,
is_prerelease: release.is_nightly,
date: release.published_at.clone(),
}
} else {
@@ -281,7 +345,7 @@ impl BrowserVersionService {
} else {
BrowserVersionInfo {
version: version.clone(),
is_prerelease: false, // Chromium versions are usually stable
is_prerelease: false, // Chromium usually stable releases
date: "".to_string(),
}
}
@@ -302,7 +366,7 @@ impl BrowserVersionService {
} else {
BrowserVersionInfo {
version: version.clone(),
is_prerelease: version.contains("alpha") || version.contains("rc"),
is_prerelease: false, // TOR Browser usually stable releases
date: "".to_string(),
}
}
@@ -355,151 +419,264 @@ impl BrowserVersionService {
browser: &str,
version: &str,
) -> Result<DownloadInfo, Box<dyn std::error::Error + Send + Sync>> {
let (os, arch) = Self::get_platform_info();
match browser {
"firefox" => {
#[cfg(target_os = "macos")]
return Ok(DownloadInfo {
url: format!("https://download.mozilla.org/?product=firefox-{version}&os=osx&lang=en-US"),
filename: format!("firefox-{version}.dmg"),
is_archive: true,
});
let os_param = match (&os[..], &arch[..]) {
("windows", _) => "win64",
("linux", "x64") => "linux64",
("linux", "arm64") => "linux64-aarch64",
("macos", _) => "osx",
_ => {
return Err(
format!("Unsupported platform/architecture for Firefox: {os}/{arch}").into(),
)
}
};
#[cfg(target_os = "windows")]
return Ok(DownloadInfo {
url: format!("https://download.mozilla.org/?product=firefox-{version}&os=win64&lang=en-US"),
filename: format!("firefox-{version}.exe"),
is_archive: false,
});
let (filename, is_archive) = match os.as_str() {
"windows" => (format!("firefox-{version}.exe"), false),
"linux" => (format!("firefox-{version}.tar.xz"), true),
"macos" => (format!("firefox-{version}.dmg"), true),
_ => return Err(format!("Unsupported platform for Firefox: {os}").into()),
};
#[cfg(target_os = "linux")]
return Ok(DownloadInfo {
url: format!("https://download.mozilla.org/?product=firefox-{version}&os=linux64&lang=en-US"),
filename: format!("firefox-{version}.tar.bz2"),
is_archive: true,
});
#[cfg(not(any(target_os = "macos", target_os = "windows", target_os = "linux")))]
return Err("Unsupported platform for Firefox".into());
}
"firefox-developer" => {
#[cfg(target_os = "macos")]
return Ok(DownloadInfo {
url: format!("https://download.mozilla.org/?product=devedition-{version}&os=osx&lang=en-US"),
filename: format!("firefox-developer-{version}.dmg"),
is_archive: true,
});
#[cfg(target_os = "windows")]
return Ok(DownloadInfo {
url: format!("https://download.mozilla.org/?product=devedition-{version}&os=win64&lang=en-US"),
filename: format!("firefox-developer-{version}.exe"),
is_archive: false,
});
#[cfg(target_os = "linux")]
return Ok(DownloadInfo {
url: format!("https://download.mozilla.org/?product=devedition-{version}&os=linux64&lang=en-US"),
filename: format!("firefox-developer-{version}.tar.bz2"),
is_archive: true,
});
#[cfg(not(any(target_os = "macos", target_os = "windows", target_os = "linux")))]
return Err("Unsupported platform for Firefox Developer".into());
}
"mullvad-browser" => {
#[cfg(target_os = "macos")]
return Ok(DownloadInfo {
url: format!(
"https://github.com/mullvad/mullvad-browser/releases/download/{version}/mullvad-browser-macos-{version}.dmg"
),
filename: format!("mullvad-browser-{version}.dmg"),
is_archive: true,
});
#[cfg(target_os = "windows")]
return Ok(DownloadInfo {
url: format!(
"https://github.com/mullvad/mullvad-browser/releases/download/{version}/mullvad-browser-windows-{version}.exe"
),
filename: format!("mullvad-browser-{version}.exe"),
is_archive: false,
});
#[cfg(target_os = "linux")]
return Ok(DownloadInfo {
url: format!(
"https://github.com/mullvad/mullvad-browser/releases/download/{version}/mullvad-browser-linux-{version}.tar.xz"
),
filename: format!("mullvad-browser-{version}.tar.xz"),
is_archive: true,
});
#[cfg(not(any(target_os = "macos", target_os = "windows", target_os = "linux")))]
return Err("Unsupported platform for Mullvad Browser".into());
}
"zen" => {
#[cfg(target_os = "macos")]
return Ok(DownloadInfo {
url: format!(
"https://github.com/zen-browser/desktop/releases/download/{version}/zen.macos-universal.dmg"
),
filename: format!("zen-{version}.dmg"),
is_archive: true,
});
#[cfg(target_os = "windows")]
return Ok(DownloadInfo {
url: format!(
"https://github.com/zen-browser/desktop/releases/download/{version}/zen.win.x64.zip"
),
filename: format!("zen-{version}.zip"),
is_archive: true,
});
#[cfg(target_os = "linux")]
return Ok(DownloadInfo {
url: format!(
"https://github.com/zen-browser/desktop/releases/download/{version}/zen.linux-x86_64.AppImage"
),
filename: format!("zen-{version}.AppImage"),
is_archive: false,
});
#[cfg(not(any(target_os = "macos", target_os = "windows", target_os = "linux")))]
return Err("Unsupported platform for Zen Browser".into());
}
"brave" => {
// For Brave, we use a placeholder URL since we need to resolve the actual asset URL dynamically
// The actual URL will be resolved in the download service using the GitHub API
Ok(DownloadInfo {
url: format!(
"https://github.com/brave/brave-browser/releases/download/{version}/Brave-Browser-universal.dmg"
"https://download.mozilla.org/?product=firefox-{version}&os={os_param}&lang=en-US"
),
filename: format!("brave-{version}.dmg"),
is_archive: true,
filename,
is_archive,
})
}
"firefox-developer" => {
let os_param = match (&os[..], &arch[..]) {
("windows", _) => "win64",
("linux", "x64") => "linux64",
("linux", "arm64") => "linux64-aarch64",
("macos", _) => "osx",
_ => {
return Err(
format!("Unsupported platform/architecture for Firefox Developer: {os}/{arch}")
.into(),
)
}
};
let (filename, is_archive) = match os.as_str() {
"windows" => (format!("firefox-developer-{version}.exe"), false),
"linux" => (format!("firefox-developer-{version}.tar.xz"), true),
"macos" => (format!("firefox-developer-{version}.dmg"), true),
_ => return Err(format!("Unsupported platform for Firefox Developer: {os}").into()),
};
Ok(DownloadInfo {
url: format!(
"https://download.mozilla.org/?product=firefox-devedition-{version}&os={os_param}&lang=en-US"
),
filename,
is_archive,
})
}
"mullvad-browser" => {
// Mullvad Browser doesn't support ARM64 on Windows and Linux
if arch == "arm64" && (os == "windows" || os == "linux") {
return Err(format!("Mullvad Browser doesn't support ARM64 on {os}").into());
}
let (platform_str, filename, is_archive) = match os.as_str() {
"windows" => {
if arch == "arm64" {
return Err("Mullvad Browser doesn't support ARM64 on Windows".into());
}
(
"windows-x86_64",
format!("mullvad-browser-windows-x86_64-{version}.exe"),
false,
)
}
"linux" => {
if arch == "arm64" {
return Err("Mullvad Browser doesn't support ARM64 on Linux".into());
}
(
"x86_64",
format!("mullvad-browser-x86_64-{version}.tar.xz"),
true,
)
}
"macos" => (
"macos",
format!("mullvad-browser-macos-{version}.dmg"),
true,
),
_ => return Err(format!("Unsupported platform for Mullvad Browser: {os}").into()),
};
Ok(DownloadInfo {
url: format!(
"https://github.com/mullvad/mullvad-browser/releases/download/{version}/mullvad-browser-{platform_str}-{version}{}",
if os == "windows" { ".exe" } else if os == "linux" { ".tar.xz" } else { ".dmg" }
),
filename,
is_archive,
})
}
"zen" => {
let (asset_name, filename, is_archive) = match (&os[..], &arch[..]) {
("windows", "x64") => ("zen.installer.exe", format!("zen-{version}.exe"), false),
("windows", "arm64") => (
"zen.installer-arm64.exe",
format!("zen-{version}-arm64.exe"),
false,
),
("linux", "x64") => (
"zen.linux-x86_64.tar.xz",
format!("zen-{version}-x86_64.tar.xz"),
true,
),
("linux", "arm64") => (
"zen.linux-aarch64.tar.xz",
format!("zen-{version}-aarch64.tar.xz"),
true,
),
("macos", _) => (
"zen.macos-universal.dmg",
format!("zen-{version}.dmg"),
true,
),
_ => {
return Err(format!("Unsupported platform/architecture for Zen: {os}/{arch}").into())
}
};
Ok(DownloadInfo {
url: format!(
"https://github.com/zen-browser/desktop/releases/download/{version}/{asset_name}"
),
filename,
is_archive,
})
}
"brave" => {
// Brave uses different asset naming conventions
// The actual URL will be resolved dynamically in the download service
let (filename, is_archive) = match (&os[..], &arch[..]) {
("windows", _) => (format!("brave-{version}.exe"), false),
("linux", "x64") => (format!("brave-browser-{version}-linux-amd64.zip"), true),
("linux", "arm64") => (format!("brave-browser-{version}-linux-arm64.zip"), true),
("macos", _) => (format!("Brave-Browser-universal.dmg"), true),
_ => {
return Err(format!("Unsupported platform/architecture for Brave: {os}/{arch}").into())
}
};
Ok(DownloadInfo {
url: format!(
"https://github.com/brave/brave-browser/releases/download/{version}/brave-placeholder"
),
filename,
is_archive,
})
}
"chromium" => {
let arch = if cfg!(target_arch = "aarch64") { "Mac_Arm" } else { "Mac" };
let platform_str = match (&os[..], &arch[..]) {
("windows", "x64") => "Win_x64",
("windows", "arm64") => "Win_Arm64",
("linux", "x64") => "Linux_x64",
("linux", "arm64") => return Err("Chromium doesn't support ARM64 on Linux".into()),
("macos", "x64") => "Mac",
("macos", "arm64") => "Mac_Arm",
_ => {
return Err(
format!("Unsupported platform/architecture for Chromium: {os}/{arch}").into(),
)
}
};
let (archive_name, filename) = match os.as_str() {
"windows" => ("chrome-win.zip", format!("chromium-{version}-win.zip")),
"linux" => ("chrome-linux.zip", format!("chromium-{version}-linux.zip")),
"macos" => ("chrome-mac.zip", format!("chromium-{version}-mac.zip")),
_ => return Err(format!("Unsupported platform for Chromium: {os}").into()),
};
Ok(DownloadInfo {
url: format!(
"https://commondatastorage.googleapis.com/chromium-browser-snapshots/{arch}/{version}/chrome-mac.zip"
"https://commondatastorage.googleapis.com/chromium-browser-snapshots/{platform_str}/{version}/{archive_name}"
),
filename: format!("chromium-{version}.zip"),
filename,
is_archive: true,
})
}
"tor-browser" => Ok(DownloadInfo {
url: format!(
"https://archive.torproject.org/tor-package-archive/torbrowser/{version}/tor-browser-macos-{version}.dmg"
),
filename: format!("tor-browser-{version}.dmg"),
is_archive: true,
}),
"tor-browser" => {
// TOR Browser doesn't support ARM64 on Windows and Linux
if arch == "arm64" && (os == "windows" || os == "linux") {
return Err(format!("TOR Browser doesn't support ARM64 on {os}").into());
}
let (platform_str, filename, is_archive) = match os.as_str() {
"windows" => {
if arch == "arm64" {
return Err("TOR Browser doesn't support ARM64 on Windows".into());
}
(
"windows-x86_64-portable",
format!("tor-browser-windows-x86_64-portable-{version}.exe"),
false,
)
}
"linux" => {
if arch == "arm64" {
return Err("TOR Browser doesn't support ARM64 on Linux".into());
}
(
"linux-x86_64",
format!("tor-browser-linux-x86_64-{version}.tar.xz"),
true,
)
}
"macos" => ("macos", format!("tor-browser-macos-{version}.dmg"), true),
_ => return Err(format!("Unsupported platform for TOR Browser: {os}").into()),
};
Ok(DownloadInfo {
url: format!(
"https://archive.torproject.org/tor-package-archive/torbrowser/{version}/tor-browser-{platform_str}-{version}{}",
if os == "windows" { ".exe" } else if os == "linux" { ".tar.xz" } else { ".dmg" }
),
filename,
is_archive,
})
}
_ => Err(format!("Unsupported browser: {browser}").into()),
}
}
/// Get platform and architecture information
fn get_platform_info() -> (String, String) {
let os = if cfg!(target_os = "windows") {
"windows"
} else if cfg!(target_os = "linux") {
"linux"
} else if cfg!(target_os = "macos") {
"macos"
} else {
"unknown"
};
let arch = if cfg!(target_arch = "x86_64") {
"x64"
} else if cfg!(target_arch = "aarch64") {
"arm64"
} else {
"unknown"
};
(os.to_string(), arch.to_string())
}
// Private helper methods for each browser type
async fn fetch_firefox_versions(
@@ -634,7 +811,7 @@ impl BrowserVersionService {
#[cfg(test)]
mod tests {
use super::*;
use wiremock::matchers::{header, method, path};
use wiremock::matchers::{method, path, query_param};
use wiremock::{Mock, MockServer, ResponseTemplate};
async fn setup_mock_server() -> MockServer {
@@ -692,7 +869,6 @@ mod tests {
Mock::given(method("GET"))
.and(path("/firefox.json"))
.and(header("user-agent", "donutbrowser"))
.respond_with(
ResponseTemplate::new(200)
.set_body_string(mock_response)
@@ -728,7 +904,6 @@ mod tests {
Mock::given(method("GET"))
.and(path("/devedition.json"))
.and(header("user-agent", "donutbrowser"))
.respond_with(
ResponseTemplate::new(200)
.set_body_string(mock_response)
@@ -770,7 +945,7 @@ mod tests {
Mock::given(method("GET"))
.and(path("/repos/mullvad/mullvad-browser/releases"))
.and(header("user-agent", "donutbrowser"))
.and(query_param("per_page", "100"))
.respond_with(
ResponseTemplate::new(200)
.set_body_string(mock_response)
@@ -812,7 +987,7 @@ mod tests {
Mock::given(method("GET"))
.and(path("/repos/zen-browser/desktop/releases"))
.and(header("user-agent", "donutbrowser"))
.and(query_param("per_page", "100"))
.respond_with(
ResponseTemplate::new(200)
.set_body_string(mock_response)
@@ -825,21 +1000,31 @@ mod tests {
async fn setup_brave_mocks(server: &MockServer) {
let mock_response = r#"[
{
"tag_name": "v1.81.9",
"name": "Brave Release 1.81.9",
"tag_name": "v1.79.119",
"name": "Release v1.79.119 (Chromium 137.0.7151.68)",
"prerelease": false,
"published_at": "2024-01-15T10:00:00Z",
"assets": [
{
"name": "brave-v1.81.9-universal.dmg",
"browser_download_url": "https://example.com/brave-1.81.9-universal.dmg",
"name": "brave-v1.79.119-universal.dmg",
"browser_download_url": "https://example.com/brave-1.79.119-universal.dmg",
"size": 200000000
},
{
"name": "brave-browser-1.79.119-linux-amd64.zip",
"browser_download_url": "https://example.com/brave-browser-1.79.119-linux-amd64.zip",
"size": 150000000
},
{
"name": "brave-browser-1.79.119-linux-arm64.zip",
"browser_download_url": "https://example.com/brave-browser-1.79.119-linux-arm64.zip",
"size": 145000000
}
]
},
{
"tag_name": "v1.81.8",
"name": "Brave Release 1.81.8",
"name": "Nightly v1.81.8",
"prerelease": false,
"published_at": "2024-01-10T10:00:00Z",
"assets": [
@@ -854,7 +1039,7 @@ mod tests {
Mock::given(method("GET"))
.and(path("/repos/brave/brave-browser/releases"))
.and(header("user-agent", "donutbrowser"))
.and(query_param("per_page", "100"))
.respond_with(
ResponseTemplate::new(200)
.set_body_string(mock_response)
@@ -873,7 +1058,6 @@ mod tests {
Mock::given(method("GET"))
.and(path(format!("/{arch}/LAST_CHANGE")))
.and(header("user-agent", "donutbrowser"))
.respond_with(
ResponseTemplate::new(200)
.set_body_string("1465660")
@@ -921,7 +1105,6 @@ mod tests {
Mock::given(method("GET"))
.and(path("/"))
.and(header("user-agent", "donutbrowser"))
.respond_with(
ResponseTemplate::new(200)
.set_body_string(mock_html)
@@ -932,7 +1115,6 @@ mod tests {
Mock::given(method("GET"))
.and(path("/14.0.4/"))
.and(header("user-agent", "donutbrowser"))
.respond_with(
ResponseTemplate::new(200)
.set_body_string(version_html_144)
@@ -943,7 +1125,6 @@ mod tests {
Mock::given(method("GET"))
.and(path("/14.0.3/"))
.and(header("user-agent", "donutbrowser"))
.respond_with(
ResponseTemplate::new(200)
.set_body_string(version_html_143)
@@ -954,7 +1135,6 @@ mod tests {
Mock::given(method("GET"))
.and(path("/14.0.2/"))
.and(header("user-agent", "donutbrowser"))
.respond_with(
ResponseTemplate::new(200)
.set_body_string(version_html_142)
@@ -966,7 +1146,7 @@ mod tests {
#[tokio::test]
async fn test_browser_version_service_creation() {
let _service = BrowserVersionService::new();
let _ = BrowserVersionService::new();
// Test passes if we can create the service without panicking
}
@@ -1304,7 +1484,7 @@ mod tests {
let mullvad_info = service
.get_download_info("mullvad-browser", "14.5a6")
.unwrap();
assert_eq!(mullvad_info.filename, "mullvad-browser-14.5a6.dmg");
assert_eq!(mullvad_info.filename, "mullvad-browser-macos-14.5a6.dmg");
assert!(mullvad_info.url.contains("mullvad-browser-macos-14.5a6"));
assert!(mullvad_info.is_archive);
@@ -1316,20 +1496,20 @@ mod tests {
// Test Tor Browser
let tor_info = service.get_download_info("tor-browser", "14.0.4").unwrap();
assert_eq!(tor_info.filename, "tor-browser-14.0.4.dmg");
assert_eq!(tor_info.filename, "tor-browser-macos-14.0.4.dmg");
assert!(tor_info.url.contains("tor-browser-macos-14.0.4"));
assert!(tor_info.is_archive);
// Test Chromium
let chromium_info = service.get_download_info("chromium", "1465660").unwrap();
assert_eq!(chromium_info.filename, "chromium-1465660.zip");
assert_eq!(chromium_info.filename, "chromium-1465660-mac.zip");
assert!(chromium_info.url.contains("chrome-mac.zip"));
assert!(chromium_info.is_archive);
// Test Brave
let brave_info = service.get_download_info("brave", "v1.81.9").unwrap();
assert_eq!(brave_info.filename, "brave-v1.81.9.dmg");
assert!(brave_info.url.contains("Brave-Browser"));
assert!(brave_info.url.contains("brave-placeholder"));
assert!(brave_info.is_archive);
// Test unsupported browser
+129 -3
View File
@@ -77,13 +77,139 @@ mod windows {
#[cfg(target_os = "linux")]
mod linux {
use std::process::Command;
const APP_DESKTOP_NAME: &str = "donutbrowser.desktop";
pub fn is_default_browser() -> Result<bool, String> {
// Linux implementation would go here
Err("Linux support not implemented yet".to_string())
// Check if xdg-mime is available
if !is_xdg_mime_available() {
return Err("xdg-mime utility not found. Please install xdg-utils package.".to_string());
}
let schemes = ["http", "https"];
for scheme in schemes {
let mime_type = format!("x-scheme-handler/{}", scheme);
// Query the current default handler for this scheme
let output = Command::new("xdg-mime")
.args(["query", "default", &mime_type])
.output()
.map_err(|e| format!("Failed to query default handler for {}: {}", scheme, e))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(format!("xdg-mime query failed for {}: {}", scheme, stderr));
}
let current_handler = String::from_utf8_lossy(&output.stdout).trim().to_string();
// Check if our app is the default handler
if current_handler != APP_DESKTOP_NAME {
return Ok(false);
}
}
Ok(true)
}
pub fn set_as_default_browser() -> Result<(), String> {
Err("Linux support not implemented yet".to_string())
// Check if xdg-mime is available
if !is_xdg_mime_available() {
return Err("xdg-mime utility not found. Please install xdg-utils package.".to_string());
}
// Check if the desktop file exists in common locations
if !check_desktop_file_exists() {
return Err(format!(
"Desktop file '{}' not found in standard locations. Please ensure the application is properly installed. You can manually set Donut Browser as the default browser in your system settings.",
APP_DESKTOP_NAME
));
}
let schemes = ["http", "https"];
let mut all_succeeded = true;
let mut error_messages = Vec::new();
for scheme in schemes {
let mime_type = format!("x-scheme-handler/{}", scheme);
// Set our app as the default handler for this scheme
let output = Command::new("xdg-mime")
.args(["default", APP_DESKTOP_NAME, &mime_type])
.output()
.map_err(|e| format!("Failed to set default handler for {}: {}", scheme, e))?;
if !output.status.success() {
all_succeeded = false;
let stderr = String::from_utf8_lossy(&output.stderr);
error_messages.push(format!("Failed to set default for {}: {}", scheme, stderr));
}
}
if !all_succeeded {
return Err(format!(
"Some xdg-mime commands failed:\n{}\n\nYou may need to:\n1. Run with appropriate permissions\n2. Manually set the default browser in your desktop environment settings\n3. Restart your desktop session",
error_messages.join("\n")
));
}
// Give the system a moment to process the changes
std::thread::sleep(std::time::Duration::from_millis(500));
// Verify the changes took effect
match is_default_browser() {
Ok(true) => Ok(()),
Ok(false) => {
// This is the common case where commands succeed but verification fails
Err(format!(
"The xdg-mime commands completed successfully, but Donut Browser is not yet set as the default. This is common on some Linux distributions. Please try one of these options:\n\n1. Restart your desktop session and try again\n2. Log out and log back in\n3. Manually set Donut Browser as the default in your system settings:\n - GNOME: Settings > Default Applications > Web\n - KDE: System Settings > Applications > Default Applications > Web Browser\n - XFCE: Settings > Preferred Applications > Web Browser\n - Or run: xdg-settings set default-web-browser {}\n\nThe changes may take effect automatically after a desktop restart.",
APP_DESKTOP_NAME
))
}
Err(e) => Err(format!(
"Set as default completed, but verification failed: {}. The changes may still be in effect after restarting your desktop session.",
e
))
}
}
fn is_xdg_mime_available() -> bool {
Command::new("which")
.arg("xdg-mime")
.output()
.map(|output| output.status.success())
.unwrap_or(false)
}
fn check_desktop_file_exists() -> bool {
let desktop_locations = [
"~/.local/share/applications/",
"/usr/share/applications/",
"/usr/local/share/applications/",
"/var/lib/flatpak/exports/share/applications/",
"~/.local/share/flatpak/exports/share/applications/",
];
for location in &desktop_locations {
let path = if location.starts_with('~') {
if let Ok(home) = std::env::var("HOME") {
location.replace('~', &home)
} else {
continue;
}
} else {
location.to_string()
};
let full_path = format!("{}{}", path, APP_DESKTOP_NAME);
if std::path::Path::new(&full_path).exists() {
return true;
}
}
false
}
}
+233 -37
View File
@@ -51,7 +51,7 @@ impl Downloader {
) -> Result<String, Box<dyn std::error::Error + Send + Sync>> {
match browser_type {
BrowserType::Brave => {
// For Brave, we need to find the actual macOS asset
// For Brave, we need to find the actual platform-specific asset
let releases = self
.api_client
.fetch_brave_releases_with_caching(true)
@@ -65,19 +65,20 @@ impl Downloader {
})
.ok_or(format!("Brave version {version} not found"))?;
// Find the universal macOS DMG asset
let asset = release
.assets
.iter()
.find(|asset| asset.name.contains(".dmg") && asset.name.contains("universal"))
// Get platform and architecture info
let (os, arch) = Self::get_platform_info();
// Find the appropriate asset based on platform and architecture
let asset_url = self
.find_brave_asset(&release.assets, &os, &arch)
.ok_or(format!(
"No universal macOS DMG asset found for Brave version {version}"
"No compatible asset found for Brave version {version} on {os}/{arch}"
))?;
Ok(asset.browser_download_url.clone())
Ok(asset_url)
}
BrowserType::Zen => {
// For Zen, verify the asset exists
// For Zen, verify the asset exists and handle different naming patterns
let releases = self
.api_client
.fetch_zen_releases_with_caching(true)
@@ -88,16 +89,17 @@ impl Downloader {
.find(|r| r.tag_name == version)
.ok_or(format!("Zen version {version} not found"))?;
// Find the macOS universal DMG asset
let asset = release
.assets
.iter()
.find(|asset| asset.name == "zen.macos-universal.dmg")
// Get platform and architecture info
let (os, arch) = Self::get_platform_info();
// Find the appropriate asset
let asset_url = self
.find_zen_asset(&release.assets, &os, &arch)
.ok_or(format!(
"No macOS universal asset found for Zen version {version}"
"No compatible asset found for Zen version {version} on {os}/{arch}"
))?;
Ok(asset.browser_download_url.clone())
Ok(asset_url)
}
BrowserType::MullvadBrowser => {
// For Mullvad, verify the asset exists
@@ -111,16 +113,17 @@ impl Downloader {
.find(|r| r.tag_name == version)
.ok_or(format!("Mullvad version {version} not found"))?;
// Find the macOS DMG asset
let asset = release
.assets
.iter()
.find(|asset| asset.name.contains(".dmg") && asset.name.contains("mac"))
// Get platform and architecture info
let (os, arch) = Self::get_platform_info();
// Find the appropriate asset
let asset_url = self
.find_mullvad_asset(&release.assets, &os, &arch)
.ok_or(format!(
"No macOS asset found for Mullvad version {version}"
"No compatible asset found for Mullvad version {version} on {os}/{arch}"
))?;
Ok(asset.browser_download_url.clone())
Ok(asset_url)
}
_ => {
// For other browsers, use the provided URL
@@ -129,6 +132,202 @@ impl Downloader {
}
}
/// Get platform and architecture information
fn get_platform_info() -> (String, String) {
let os = if cfg!(target_os = "windows") {
"windows"
} else if cfg!(target_os = "linux") {
"linux"
} else if cfg!(target_os = "macos") {
"macos"
} else {
"unknown"
};
let arch = if cfg!(target_arch = "x86_64") {
"x64"
} else if cfg!(target_arch = "aarch64") {
"arm64"
} else {
"unknown"
};
(os.to_string(), arch.to_string())
}
/// Find the appropriate Brave asset for the current platform and architecture
fn find_brave_asset(
&self,
assets: &[crate::browser::GithubAsset],
os: &str,
arch: &str,
) -> Option<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>(
&self,
app_handle: &tauri::AppHandle<R>,
@@ -170,7 +369,7 @@ impl Downloader {
let response = self
.client
.get(&download_url)
.header("User-Agent", "donutbrowser")
.header("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36")
.send()
.await?;
@@ -247,7 +446,7 @@ mod tests {
use crate::browser_version_service::DownloadInfo;
use tempfile::TempDir;
use wiremock::matchers::{header, method, path};
use wiremock::matchers::{method, path, query_param};
use wiremock::{Mock, MockServer, ResponseTemplate};
async fn setup_mock_server() -> MockServer {
@@ -290,7 +489,7 @@ mod tests {
Mock::given(method("GET"))
.and(path("/repos/brave/brave-browser/releases"))
.and(header("user-agent", "donutbrowser"))
.and(query_param("per_page", "100"))
.respond_with(
ResponseTemplate::new(200)
.set_body_string(mock_response)
@@ -338,7 +537,7 @@ mod tests {
Mock::given(method("GET"))
.and(path("/repos/zen-browser/desktop/releases"))
.and(header("user-agent", "donutbrowser"))
.and(query_param("per_page", "100"))
.respond_with(
ResponseTemplate::new(200)
.set_body_string(mock_response)
@@ -386,7 +585,7 @@ mod tests {
Mock::given(method("GET"))
.and(path("/repos/mullvad/mullvad-browser/releases"))
.and(header("user-agent", "donutbrowser"))
.and(query_param("per_page", "100"))
.respond_with(
ResponseTemplate::new(200)
.set_body_string(mock_response)
@@ -497,7 +696,7 @@ mod tests {
Mock::given(method("GET"))
.and(path("/repos/brave/brave-browser/releases"))
.and(header("user-agent", "donutbrowser"))
.and(query_param("per_page", "100"))
.respond_with(
ResponseTemplate::new(200)
.set_body_string(mock_response)
@@ -547,7 +746,7 @@ mod tests {
Mock::given(method("GET"))
.and(path("/repos/zen-browser/desktop/releases"))
.and(header("user-agent", "donutbrowser"))
.and(query_param("per_page", "100"))
.respond_with(
ResponseTemplate::new(200)
.set_body_string(mock_response)
@@ -570,7 +769,7 @@ mod tests {
assert!(result
.unwrap_err()
.to_string()
.contains("No macOS universal asset found"));
.contains("No compatible asset found"));
}
#[tokio::test]
@@ -589,7 +788,6 @@ mod tests {
// Mock the download endpoint
Mock::given(method("GET"))
.and(path("/test-download"))
.and(header("user-agent", "donutbrowser"))
.respond_with(
ResponseTemplate::new(200)
.set_body_bytes(test_content)
@@ -640,7 +838,6 @@ mod tests {
// Mock a 404 response
Mock::given(method("GET"))
.and(path("/missing-file"))
.and(header("user-agent", "donutbrowser"))
.respond_with(ResponseTemplate::new(404))
.mount(&server)
.await;
@@ -691,7 +888,7 @@ mod tests {
Mock::given(method("GET"))
.and(path("/repos/mullvad/mullvad-browser/releases"))
.and(header("user-agent", "donutbrowser"))
.and(query_param("per_page", "100"))
.respond_with(
ResponseTemplate::new(200)
.set_body_string(mock_response)
@@ -714,7 +911,7 @@ mod tests {
assert!(result
.unwrap_err()
.to_string()
.contains("No macOS asset found"));
.contains("No compatible asset found"));
}
#[tokio::test]
@@ -741,7 +938,7 @@ mod tests {
Mock::given(method("GET"))
.and(path("/repos/brave/brave-browser/releases"))
.and(header("user-agent", "donutbrowser"))
.and(query_param("per_page", "100"))
.respond_with(
ResponseTemplate::new(200)
.set_body_string(mock_response)
@@ -780,7 +977,6 @@ mod tests {
Mock::given(method("GET"))
.and(path("/chunked-download"))
.and(header("user-agent", "donutbrowser"))
.respond_with(
ResponseTemplate::new(200)
.set_body_bytes(test_content.clone())
+42
View File
@@ -175,6 +175,48 @@ impl DownloadedBrowsersRegistry {
}
Ok(())
}
/// Find and remove unused browser binaries that are not referenced by any active profiles
pub fn cleanup_unused_binaries(
&mut self,
active_profiles: &[(String, String)], // (browser, version) pairs
) -> Result<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)]
+705 -28
View File
@@ -34,18 +34,146 @@ impl Extractor {
};
let _ = app_handle.emit("download-progress", &progress);
let extension = archive_path
.extension()
.and_then(|ext| ext.to_str())
.unwrap_or("");
// Try to detect the actual file type by reading the file header
let actual_format = self.detect_file_format(archive_path)?;
match extension {
"dmg" => self.extract_dmg(archive_path, dest_dir).await,
match actual_format.as_str() {
"dmg" => {
#[cfg(target_os = "macos")]
return self.extract_dmg(archive_path, dest_dir).await;
#[cfg(not(target_os = "macos"))]
return Err("DMG extraction is only supported on macOS".into());
}
"zip" => self.extract_zip(archive_path, dest_dir).await,
_ => Err(format!("Unsupported archive format: {extension}").into()),
"tar.xz" => self.extract_tar_xz(archive_path, dest_dir).await,
"tar.bz2" => self.extract_tar_bz2(archive_path, dest_dir).await,
"tar.gz" => self.extract_tar_gz(archive_path, dest_dir).await,
"exe" => {
// For Windows EXE files, some may be self-extracting archives, others are installers
// For browsers like Firefox, TOR, they're typically installers that don't need extraction
self
.handle_exe_file(archive_path, dest_dir, browser_type)
.await
}
"deb" => {
#[cfg(target_os = "linux")]
return self.extract_deb(archive_path, dest_dir).await;
#[cfg(not(target_os = "linux"))]
return Err("DEB extraction is only supported on Linux".into());
}
"appimage" => {
#[cfg(target_os = "linux")]
return self.handle_appimage(archive_path, dest_dir).await;
#[cfg(not(target_os = "linux"))]
return Err("AppImage is only supported on Linux".into());
}
_ => {
Err(format!(
"Unsupported archive format: {} (detected: {}). The downloaded file might be corrupted or in an unexpected format.",
archive_path.extension().and_then(|ext| ext.to_str()).unwrap_or("unknown"),
actual_format
).into())
}
}
}
/// Detect the actual file format by reading file headers
fn detect_file_format(
&self,
file_path: &Path,
) -> Result<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(
&self,
dmg_path: &Path,
@@ -154,7 +282,56 @@ impl Extractor {
zip_path: &Path,
dest_dir: &Path,
) -> 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")
.args([
"-q", // quiet
@@ -174,16 +351,269 @@ impl Extractor {
);
}
// Find the extracted .app directory or Chromium.app specifically
let mut app_path: Option<PathBuf> = None;
self.find_extracted_executable(dest_dir).await
}
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
if let Ok(entries) = fs::read_dir(dest_dir) {
for entry in entries.flatten() {
let path = entry.path();
if path.extension().is_some_and(|ext| ext == "app") {
app_path = Some(path);
break;
return Ok(path);
}
// For Chromium, check subdirectories (chrome-mac folder)
if path.is_dir() {
@@ -194,33 +624,280 @@ impl Extractor {
// Move the app to the root destination directory
let target_path = dest_dir.join(sub_path.file_name().unwrap());
fs::rename(&sub_path, &target_path)?;
app_path = Some(target_path);
// Clean up the now-empty subdirectory
let _ = fs::remove_dir_all(&path);
break;
return Ok(target_path);
}
}
if app_path.is_some() {
break;
}
}
}
}
}
let app_path = app_path.ok_or("No .app found after extraction")?;
Err("No .app found after extraction".into())
}
// Remove quarantine attributes
let _ = Command::new("xattr")
.args(["-dr", "com.apple.quarantine", app_path.to_str().unwrap()])
.output();
#[cfg(target_os = "windows")]
async fn find_windows_executable(
&self,
dest_dir: &Path,
) -> Result<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")
.args(["-cr", app_path.to_str().unwrap()])
.output();
for exe_name in &exe_names {
let exe_path = dest_dir.join(exe_name);
if exe_path.exists() {
return Ok(exe_path);
}
}
Ok(app_path)
// If no specific executable found, look for any .exe file
if let Ok(entries) = fs::read_dir(dest_dir) {
for entry in entries.flatten() {
let path = entry.path();
if path.extension().is_some_and(|ext| ext == "exe") {
return Ok(path);
}
// Check subdirectories
if path.is_dir() {
if let Ok(sub_result) = self.find_windows_executable(&path).await {
return Ok(sub_result);
}
}
}
}
Err("No executable found after extraction".into())
}
#[cfg(target_os = "linux")]
async fn find_linux_executable(
&self,
dest_dir: &Path,
) -> Result<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]
fn test_extractor_creation() {
let _extractor = Extractor::new();
let _ = Extractor::new();
// Just verify we can create an extractor instance
}
#[test]
fn test_unsupported_archive_format() {
let _extractor = Extractor::new();
let _ = Extractor::new();
let temp_dir = TempDir::new().unwrap();
let fake_archive = temp_dir.path().join("test.rar");
File::create(&fake_archive).unwrap();
+181 -93
View File
@@ -1,6 +1,5 @@
// Learn more about Tauri commands at https://tauri.app/develop/calling-rust/
use std::sync::Mutex;
use std::time::{SystemTime, UNIX_EPOCH};
use tauri::{Emitter, Manager, Runtime, WebviewUrl, WebviewWindow, WebviewWindowBuilder};
use tauri_plugin_deep_link::DeepLinkExt;
@@ -19,22 +18,22 @@ mod downloaded_browsers;
mod extraction;
mod proxy_manager;
mod settings_manager;
mod theme_detector;
mod version_updater;
extern crate lazy_static;
use browser_runner::{
check_browser_exists, check_browser_status, create_browser_profile, create_browser_profile_new,
delete_profile, download_browser, fetch_browser_versions, fetch_browser_versions_cached_first,
fetch_browser_versions_detailed, fetch_browser_versions_with_count,
fetch_browser_versions_with_count_cached_first, get_cached_browser_versions_detailed,
get_downloaded_browser_versions, get_saved_mullvad_releases, get_supported_browsers,
is_browser_downloaded, kill_browser_profile, launch_browser_profile, list_browser_profiles,
rename_profile, should_update_browser_cache, update_profile_proxy, update_profile_version,
check_browser_exists, check_browser_status, cleanup_unused_binaries, create_browser_profile_new,
delete_profile, download_browser, fetch_browser_versions_cached_first,
fetch_browser_versions_with_count, fetch_browser_versions_with_count_cached_first,
get_downloaded_browser_versions, get_supported_browsers, is_browser_supported_on_platform,
kill_browser_profile, launch_browser_profile, list_browser_profiles, rename_profile,
update_profile_proxy, update_profile_version,
};
use settings_manager::{
disable_default_browser_prompt, get_app_settings, get_table_sorting_settings, save_app_settings,
clear_all_version_cache, get_app_settings, get_table_sorting_settings, save_app_settings,
save_table_sorting_settings, should_show_settings_on_startup,
};
@@ -43,21 +42,21 @@ use default_browser::{
};
use version_updater::{
check_version_update_needed, force_version_update_check, get_version_update_status,
get_version_updater, trigger_manual_version_update,
get_version_update_status, get_version_updater, trigger_manual_version_update,
};
use auto_updater::{
check_for_browser_updates, complete_browser_update, complete_browser_update_with_auto_update,
dismiss_update_notification, is_auto_update_download, is_browser_disabled_for_update,
mark_auto_update_download, remove_auto_update_download, start_browser_update,
check_for_browser_updates, complete_browser_update_with_auto_update, dismiss_update_notification,
is_auto_update_download, is_browser_disabled_for_update, mark_auto_update_download,
remove_auto_update_download,
};
use app_auto_updater::{
check_for_app_updates, check_for_app_updates_manual, download_and_install_app_update,
get_app_version_info,
};
use theme_detector::get_system_theme;
// Trait to extend WebviewWindow with transparent titlebar functionality
pub trait WindowExt {
#[cfg(target_os = "macos")]
@@ -103,13 +102,6 @@ impl<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]
async fn handle_url_open(app: tauri::AppHandle, url: String) -> Result<(), String> {
println!("handle_url_open called with URL: {url}");
@@ -169,61 +161,6 @@ async fn check_and_handle_startup_url(app_handle: tauri::AppHandle) -> Result<bo
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)]
pub fn run() {
tauri::Builder::default()
@@ -233,12 +170,14 @@ pub fn run() {
.plugin(tauri_plugin_deep_link::init())
.setup(|app| {
// Create the main window programmatically
#[allow(unused_variables)]
let win_builder = WebviewWindowBuilder::new(app, "main", WebviewUrl::default())
.title("Donut Browser")
.inner_size(900.0, 600.0)
.resizable(false)
.fullscreen(false);
#[allow(unused_variables)]
let window = win_builder.build().unwrap();
// Set transparent titlebar for macOS
@@ -328,25 +267,19 @@ pub fn run() {
Ok(())
})
.invoke_handler(tauri::generate_handler![
greet,
get_supported_browsers,
is_browser_supported_on_platform,
download_browser,
delete_profile,
is_browser_downloaded,
check_browser_exists,
cleanup_unused_binaries,
create_browser_profile_new,
create_browser_profile,
list_browser_profiles,
launch_browser_profile,
fetch_browser_versions,
fetch_browser_versions_detailed,
fetch_browser_versions_with_count,
fetch_browser_versions_cached_first,
fetch_browser_versions_with_count_cached_first,
get_cached_browser_versions_detailed,
should_update_browser_cache,
get_downloaded_browser_versions,
get_saved_mullvad_releases,
update_profile_proxy,
update_profile_version,
check_browser_status,
@@ -355,22 +288,17 @@ pub fn run() {
get_app_settings,
save_app_settings,
should_show_settings_on_startup,
disable_default_browser_prompt,
get_table_sorting_settings,
save_table_sorting_settings,
clear_all_version_cache,
is_default_browser,
open_url_with_profile,
set_as_default_browser,
smart_open_url,
handle_url_open,
check_and_handle_startup_url,
trigger_manual_version_update,
get_version_update_status,
check_version_update_needed,
force_version_update_check,
check_for_browser_updates,
start_browser_update,
complete_browser_update,
is_browser_disabled_for_update,
dismiss_update_notification,
complete_browser_update_with_auto_update,
@@ -380,9 +308,169 @@ pub fn run() {
check_for_app_updates,
check_for_app_updates_manual,
download_and_install_app_update,
get_app_version_info,
set_window_background_color,
get_system_theme,
])
.run(tauri::generate_context!())
.expect("error while running tauri application");
}
#[cfg(test)]
mod tests {
use std::fs;
#[test]
fn test_no_unused_tauri_commands() {
check_unused_commands(false); // Run in strict mode for CI
}
#[test]
fn test_unused_tauri_commands_detailed() {
check_unused_commands(true); // Run in verbose mode for development
}
fn check_unused_commands(verbose: bool) {
// Extract command names from the generate_handler! macro in this file
let lib_rs_content = fs::read_to_string("src/lib.rs").expect("Failed to read lib.rs");
let commands = extract_tauri_commands(&lib_rs_content);
// Get all frontend files
let frontend_files = get_frontend_files("../src");
// Check which commands are actually used
let mut unused_commands = Vec::new();
let mut used_commands = Vec::new();
for command in &commands {
let mut is_used = false;
for file_content in &frontend_files {
// More comprehensive search for command usage
if is_command_used(file_content, command) {
is_used = true;
break;
}
}
if is_used {
used_commands.push(command.clone());
if verbose {
println!("{command}");
}
} else {
unused_commands.push(command.clone());
if verbose {
println!("{command} (UNUSED)");
}
}
}
if verbose {
println!("\n📊 Summary:");
println!(" ✅ Used commands: {}", used_commands.len());
println!(" ❌ Unused commands: {}", unused_commands.len());
}
if !unused_commands.is_empty() {
let message = format!(
"Found {} unused Tauri commands: {}\n\nThese commands are exported in generate_handler! but not used in the frontend.\nConsider removing them or add them to the allowlist if they're used elsewhere.\n\nRun `pnpm check-unused-commands` for detailed analysis.",
unused_commands.len(),
unused_commands.join(", ")
);
if verbose {
println!("\n🚨 {message}");
} else {
panic!("{}", message);
}
} else if verbose {
println!("\n🎉 All exported commands are being used!");
} else {
println!(
"✅ All {} exported Tauri commands are being used in the frontend",
commands.len()
);
}
}
fn is_command_used(content: &str, command: &str) -> bool {
// Check various patterns for invoke usage
let patterns = vec![
format!("invoke<{}>(\"{}\"", "", command), // invoke<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::path::PathBuf;
use crate::api_client::ApiClient;
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct TableSortingSettings {
pub column: String, // Column to sort by: "name", "browser", "status"
@@ -28,6 +30,8 @@ pub struct AppSettings {
pub theme: String, // "light", "dark", or "system"
#[serde(default = "default_auto_updates_enabled")]
pub auto_updates_enabled: bool,
#[serde(default = "default_auto_delete_unused_binaries")]
pub auto_delete_unused_binaries: bool,
}
fn default_show_settings_on_startup() -> bool {
@@ -42,6 +46,10 @@ fn default_auto_updates_enabled() -> bool {
true
}
fn default_auto_delete_unused_binaries() -> bool {
true
}
impl Default for AppSettings {
fn default() -> Self {
Self {
@@ -49,6 +57,7 @@ impl Default for AppSettings {
show_settings_on_startup: default_show_settings_on_startup(),
theme: default_theme(),
auto_updates_enabled: default_auto_updates_enabled(),
auto_delete_unused_binaries: default_auto_delete_unused_binaries(),
}
}
}
@@ -163,13 +172,6 @@ impl SettingsManager {
// 3. User hasn't explicitly disabled the default browser setting
Ok(settings.show_settings_on_startup && !settings.set_as_default_browser)
}
pub fn disable_default_browser_prompt(&self) -> Result<(), Box<dyn std::error::Error>> {
let mut settings = self.load_settings()?;
settings.show_settings_on_startup = false;
self.save_settings(&settings)?;
Ok(())
}
}
#[tauri::command]
@@ -196,14 +198,6 @@ pub async fn should_show_settings_on_startup() -> Result<bool, String> {
.map_err(|e| format!("Failed to check prompt setting: {e}"))
}
#[tauri::command]
pub async fn disable_default_browser_prompt() -> Result<(), String> {
let manager = SettingsManager::new();
manager
.disable_default_browser_prompt()
.map_err(|e| format!("Failed to disable prompt: {e}"))
}
#[tauri::command]
pub async fn get_table_sorting_settings() -> Result<TableSortingSettings, String> {
let manager = SettingsManager::new();
@@ -219,3 +213,11 @@ pub async fn save_table_sorting_settings(sorting: TableSortingSettings) -> Resul
.save_table_sorting(&sorting)
.map_err(|e| format!("Failed to save table sorting settings: {e}"))
}
#[tauri::command]
pub async fn clear_all_version_cache() -> Result<(), String> {
let api_client = ApiClient::new();
api_client
.clear_all_cache()
.map_err(|e| format!("Failed to clear version cache: {e}"))
}
+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))
}
#[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)]
mod tests {
use super::*;
+19
View File
@@ -37,6 +37,25 @@
"files": {
"Info.plist": "Info.plist"
}
},
"linux": {
"deb": {
"depends": ["xdg-utils"],
"files": {
"/usr/share/applications/donutbrowser.desktop": "donutbrowser.desktop"
}
},
"rpm": {
"depends": ["xdg-utils"],
"files": {
"/usr/share/applications/donutbrowser.desktop": "donutbrowser.desktop"
}
},
"appimage": {
"files": {
"usr/share/applications/donutbrowser.desktop": "donutbrowser.desktop"
}
}
}
},
"plugins": {
+68 -32
View File
@@ -26,6 +26,8 @@ import {
} from "@/components/ui/tooltip";
import { VersionSelector } from "@/components/version-selector";
import { useBrowserDownload } from "@/hooks/use-browser-download";
import { useBrowserSupport } from "@/hooks/use-browser-support";
import { getBrowserDisplayName } from "@/lib/browser-utils";
import type { BrowserProfile, ProxySettings } from "@/types";
import { invoke } from "@tauri-apps/api/core";
import { useEffect, useState } from "react";
@@ -60,9 +62,6 @@ export function CreateProfileDialog({
const [selectedBrowser, setSelectedBrowser] =
useState<BrowserTypeString | null>("mullvad-browser");
const [selectedVersion, setSelectedVersion] = useState<string | null>(null);
const [supportedBrowsers, setSupportedBrowsers] = useState<
BrowserTypeString[]
>([]);
const [isCreating, setIsCreating] = useState(false);
const [existingProfiles, setExistingProfiles] = useState<BrowserProfile[]>(
[],
@@ -84,13 +83,29 @@ export function CreateProfileDialog({
isVersionDownloaded,
} = useBrowserDownload();
const {
supportedBrowsers,
isLoading: isLoadingSupport,
isBrowserSupported,
} = useBrowserSupport();
useEffect(() => {
if (isOpen) {
void loadSupportedBrowsers();
void loadExistingProfiles();
}
}, [isOpen]);
useEffect(() => {
if (supportedBrowsers.length > 0) {
// Set default browser to first supported browser
if (supportedBrowsers.includes("mullvad-browser")) {
setSelectedBrowser("mullvad-browser");
} else if (supportedBrowsers.length > 0) {
setSelectedBrowser(supportedBrowsers[0] as BrowserTypeString);
}
}
}, [supportedBrowsers]);
useEffect(() => {
if (isOpen && selectedBrowser) {
// Reset selected version when browser changes
@@ -105,7 +120,7 @@ export function CreateProfileDialog({
if (availableVersions.length > 0 && selectedBrowser) {
// Always reset version when browser changes or versions are loaded
// Find the latest stable version (not alpha/beta)
const stableVersions = availableVersions.filter((v) => !v.is_alpha);
const stableVersions = availableVersions.filter((v) => !v.is_nightly);
if (stableVersions.length > 0) {
// Select the first stable version (they're already sorted newest first)
@@ -117,22 +132,6 @@ export function CreateProfileDialog({
}
}, [availableVersions, selectedBrowser]);
const loadSupportedBrowsers = async () => {
try {
const browsers = await invoke<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 () => {
try {
const profiles = await invoke<BrowserProfile[]>("list_browser_profiles");
@@ -261,21 +260,58 @@ export function CreateProfileDialog({
onValueChange={(value) => {
setSelectedBrowser(value as BrowserTypeString);
}}
disabled={isLoadingSupport}
>
<SelectTrigger>
<SelectValue placeholder="Select browser" />
<SelectValue
placeholder={
isLoadingSupport ? "Loading browsers..." : "Select browser"
}
/>
</SelectTrigger>
<SelectContent>
{supportedBrowsers.map((browser) => (
<SelectItem key={browser} value={browser}>
{browser
.split("-")
.map(
(word) => word.charAt(0).toUpperCase() + word.slice(1),
)
.join(" ")}
</SelectItem>
))}
{(
[
"mullvad-browser",
"firefox",
"firefox-developer",
"chromium",
"brave",
"zen",
"tor-browser",
] as BrowserTypeString[]
).map((browser) => {
const isSupported = isBrowserSupported(browser);
const displayName = getBrowserDisplayName(browser);
if (!isSupported) {
return (
<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>
</Select>
</div>
+98 -2
View File
@@ -28,6 +28,7 @@ interface AppSettings {
show_settings_on_startup: boolean;
theme: string;
auto_updates_enabled: boolean;
auto_delete_unused_binaries: boolean;
}
interface SettingsDialogProps {
@@ -41,17 +42,21 @@ export function SettingsDialog({ isOpen, onClose }: SettingsDialogProps) {
show_settings_on_startup: true,
theme: "system",
auto_updates_enabled: true,
auto_delete_unused_binaries: true,
});
const [originalSettings, setOriginalSettings] = useState<AppSettings>({
set_as_default_browser: false,
show_settings_on_startup: true,
theme: "system",
auto_updates_enabled: true,
auto_delete_unused_binaries: true,
});
const [isDefaultBrowser, setIsDefaultBrowser] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const [isSaving, setIsSaving] = useState(false);
const [isSettingDefault, setIsSettingDefault] = useState(false);
const [isClearingCache, setIsClearingCache] = useState(false);
const [isCleaningBinaries, setIsCleaningBinaries] = useState(false);
const { setTheme } = useTheme();
@@ -106,6 +111,39 @@ export function SettingsDialog({ isOpen, onClose }: SettingsDialogProps) {
}
};
const handleClearCache = async () => {
setIsClearingCache(true);
try {
await invoke("clear_all_version_cache");
// Optionally show a success message
console.log("Cache cleared successfully");
} catch (error) {
console.error("Failed to clear cache:", error);
} finally {
setIsClearingCache(false);
}
};
const handleCleanupBinaries = async () => {
setIsCleaningBinaries(true);
try {
const cleanedUp = await invoke<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 () => {
setIsSaving(true);
try {
@@ -130,7 +168,9 @@ export function SettingsDialog({ isOpen, onClose }: SettingsDialogProps) {
settings.show_settings_on_startup !==
originalSettings.show_settings_on_startup ||
settings.theme !== originalSettings.theme ||
settings.auto_updates_enabled !== originalSettings.auto_updates_enabled;
settings.auto_updates_enabled !== originalSettings.auto_updates_enabled ||
settings.auto_delete_unused_binaries !==
originalSettings.auto_delete_unused_binaries;
return (
<Dialog open={isOpen} onOpenChange={onClose}>
@@ -216,9 +256,26 @@ export function SettingsDialog({ isOpen, onClose }: SettingsDialogProps) {
</Label>
</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">
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>
</div>
@@ -244,6 +301,45 @@ export function SettingsDialog({ isOpen, onClose }: SettingsDialogProps) {
starts.
</p>
</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>
<DialogFooter className="flex-shrink-0">
+71 -3
View File
@@ -9,6 +9,10 @@ interface AppSettings {
theme: string;
}
interface SystemTheme {
theme: string;
}
interface CustomThemeProviderProps {
children: React.ReactNode;
}
@@ -24,6 +28,25 @@ function getSystemTheme(): string {
return "light";
}
// Function to get native system theme (fallback to CSS media query)
async function getNativeSystemTheme(): Promise<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) {
const [isLoading, setIsLoading] = useState(true);
const [defaultTheme, setDefaultTheme] = useState<string>("system");
@@ -41,7 +64,7 @@ export function CustomThemeProvider({ children }: CustomThemeProviderProps) {
} catch (error) {
console.error("Failed to load theme settings:", error);
// For first-time users, detect system preference and apply it
const systemTheme = getSystemTheme();
const systemTheme = await getNativeSystemTheme();
console.log(
"First-time user detected, applying system theme:",
systemTheme,
@@ -69,6 +92,50 @@ export function CustomThemeProvider({ children }: CustomThemeProviderProps) {
void loadTheme();
}, []);
// Monitor system theme changes when using "system" theme
useEffect(() => {
if (!mounted || defaultTheme !== "system") {
return;
}
const checkSystemTheme = async () => {
try {
const currentSystemTheme = await getNativeSystemTheme();
// Force re-evaluation by toggling the theme
const html = document.documentElement;
const currentClass = html.className;
// Apply the system theme class
if (currentSystemTheme === "dark") {
if (!html.classList.contains("dark")) {
html.classList.add("dark");
html.classList.remove("light");
}
} else {
if (
!html.classList.contains("light") ||
html.classList.contains("dark")
) {
html.classList.add("light");
html.classList.remove("dark");
}
}
} catch (error) {
console.warn("Failed to check system theme:", error);
}
};
// Check system theme every 2 seconds when using system theme
const intervalId = setInterval(() => void checkSystemTheme(), 2000);
// Initial check
void checkSystemTheme();
return () => {
clearInterval(intervalId);
};
}, [mounted, defaultTheme]);
if (isLoading) {
// Use a consistent loading screen that doesn't depend on system theme during SSR
// This prevents hydration mismatch by ensuring server and client render the same initially
@@ -77,6 +144,7 @@ export function CustomThemeProvider({ children }: CustomThemeProviderProps) {
// Only apply system theme detection after component is mounted (client-side only)
if (mounted) {
// Use CSS media query for loading screen since async call would complicate this
const systemTheme = getSystemTheme();
loadingBgColor = systemTheme === "dark" ? "bg-gray-900" : "bg-white";
spinnerColor =
@@ -85,10 +153,10 @@ export function CustomThemeProvider({ children }: CustomThemeProviderProps) {
return (
<div
className={`fixed inset-0 ${loadingBgColor} flex items-center justify-center`}
className={`flex fixed inset-0 justify-center items-center ${loadingBgColor}`}
>
<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>
);
+6 -6
View File
@@ -30,7 +30,7 @@ interface GithubRelease {
hash?: string;
}>;
published_at: string;
is_alpha: boolean;
is_nightly: boolean;
}
interface VersionSelectorProps {
@@ -75,7 +75,7 @@ export function VersionSelector({
className="justify-between w-full"
>
{selectedVersion ?? placeholder}
<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>
</PopoverTrigger>
<PopoverContent className="w-[300px] p-0">
@@ -114,11 +114,11 @@ export function VersionSelector({
: "opacity-0",
)}
/>
<div className="flex items-center gap-2">
<div className="flex gap-2 items-center">
<span>{version.tag_name}</span>
{version.is_alpha && (
{version.is_nightly && (
<Badge variant="secondary" className="text-xs">
Alpha
Nightly
</Badge>
)}
{isDownloaded && (
@@ -147,7 +147,7 @@ export function VersionSelector({
variant="outline"
className="w-full"
>
<LuDownload className="mr-2 h-4 w-4" />
<LuDownload className="mr-2 w-4 h-4" />
{isDownloading ? "Downloading..." : "Download Browser"}
</LoadingButton>
)}
+19 -3
View File
@@ -19,7 +19,7 @@ interface GithubRelease {
hash?: string;
}>;
published_at: string;
is_alpha: boolean;
is_nightly: boolean;
}
interface BrowserVersionInfo {
@@ -231,7 +231,7 @@ export function useBrowserDownload() {
tag_name: versionInfo.version,
assets: [],
published_at: versionInfo.date,
is_alpha: versionInfo.is_prerelease,
is_nightly: versionInfo.is_prerelease,
}),
);
@@ -272,7 +272,7 @@ export function useBrowserDownload() {
tag_name: versionInfo.version,
assets: [],
published_at: versionInfo.date,
is_alpha: versionInfo.is_prerelease,
is_nightly: versionInfo.is_prerelease,
}),
);
@@ -325,6 +325,22 @@ export function useBrowserDownload() {
setIsDownloading(true);
try {
// Check browser compatibility before attempting download
const isSupported = await invoke<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 loadDownloadedVersions(browserStr);
} 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",
theme: {
extend: {