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