Compare commits

...

33 Commits

Author SHA1 Message Date
zhom b7c8d5672a chore: remove unused test 2025-08-08 15:10:53 +04:00
zhom 3fb81e2ca0 style: make main window more flat 2025-08-08 15:09:13 +04:00
zhom 510de96393 style: consistent flow for no proxies and no groups 2025-08-08 15:08:56 +04:00
zhom 3688e88d67 style: darker notifications 2025-08-08 15:07:30 +04:00
zhom 9646a41788 chore: linting 2025-08-08 14:57:14 +04:00
zhom f7679d25ca chore: linting 2025-08-08 14:38:49 +04:00
zhom 2d42772718 chore: linting 2025-08-08 14:23:51 +04:00
zhom 3980f835d6 chore: remove unused command 2025-08-08 14:09:20 +04:00
zhom d0185dd5ae fix: pass proper type to starts_with 2025-08-08 13:24:03 +04:00
zhom de39fa4555 chore: formatting 2025-08-08 11:06:48 +04:00
zhom f773eb6f1c refactor: handle auto-update on linux 2025-08-08 11:05:11 +04:00
zhom 458c30433d chore: linting 2025-08-08 10:50:44 +04:00
zhom 1cb9ffa249 refactor: switch to ripple button 2025-08-08 10:46:00 +04:00
zhom 5c58b5c644 chore: linting 2025-08-08 10:25:44 +04:00
zhom f41311a7bb refactor: disable profile actions when it is launching or stopping 2025-08-08 10:15:38 +04:00
zhom c8c09c296e style: show anti detect profile creation form by default 2025-08-08 10:12:41 +04:00
zhom ca0c2614f4 style: remove Actions dropdown title 2025-08-08 10:11:21 +04:00
zhom dca5a2970e style: copy 2025-08-08 10:10:24 +04:00
zhom e0a1dd5a8a refactor: more robust zip extraction and handle invalid characters 2025-08-08 10:09:43 +04:00
zhom e48b681215 refactor: better error handling for browser download 2025-08-08 09:50:26 +04:00
zhom 6796912606 test: properly mock api requests 2025-08-08 07:54:11 +04:00
zhom 7105f6544f test: cross-platform binary check 2025-08-08 07:10:27 +04:00
zhom 3003f868e7 chore: formatting 2025-08-08 06:42:36 +04:00
zhom b733d26f10 chore: linting 2025-08-08 06:33:49 +04:00
zhom 675c2417d7 chore: linting 2025-08-08 06:23:49 +04:00
zhom e10a7bf089 refactor: migrate from external commands to rust crates for extraction 2025-08-08 05:48:42 +04:00
zhom c8e3cd39ff build: don't fail fast 2025-08-08 05:16:40 +04:00
zhom 0103150dc7 build: run rust linting on ubuntu 2025-08-08 05:14:08 +04:00
zhom be57ac3219 chore: don't format md files 2025-08-08 02:51:22 +04:00
zhom b4067b5e34 build: remove file_pattern property from changelog creator 2025-08-07 23:17:15 +04:00
zhom 3fa8822139 build: disable camoufox installation 2025-08-07 23:10:06 +04:00
zhom 1e0ef0b497 Merge pull request #61 from zhom/contributors-readme-action-eImXtNP1X9
docs(contributor): contributors readme action update
2025-08-07 09:36:05 +04:00
github-actions[bot] 2da832f100 docs(contributor): contrib-readme-action has updated readme 2025-08-07 04:10:57 +00:00
47 changed files with 2419 additions and 1164 deletions
+4 -5
View File
@@ -30,9 +30,8 @@ permissions:
jobs:
build:
strategy:
fail-fast: true
matrix:
os: [macos-latest]
os: [macos-latest, ubuntu-latest]
runs-on: ${{ matrix.os }}
@@ -87,9 +86,9 @@ jobs:
fi
# TODO: replace with an integration test that fetches everything from rust
- name: Download Camoufox for testing
run: npx camoufox-js fetch
continue-on-error: true
# - name: Download Camoufox for testing
# run: npx camoufox-js fetch
# continue-on-error: true
- name: Copy nodecar binary to Tauri binaries
shell: bash
+3 -4
View File
@@ -154,9 +154,9 @@ jobs:
cp nodecar/nodecar-bin src-tauri/binaries/nodecar-${{ matrix.target }}
fi
- name: Download Camoufox for testing
run: npx camoufox-js fetch
continue-on-error: true
# - name: Download Camoufox for testing
# run: npx camoufox-js fetch
# continue-on-error: true
- name: Build frontend
run: pnpm build
@@ -179,4 +179,3 @@ jobs:
with:
branch: main
commit_message: "docs: update CHANGELOG.md for ${{ github.ref_name }} [skip ci]"
file_pattern: CHANGELOG.md
+3 -3
View File
@@ -153,9 +153,9 @@ jobs:
cp nodecar/nodecar-bin src-tauri/binaries/nodecar-${{ matrix.target }}
fi
- name: Download Camoufox for testing
run: npx camoufox-js fetch
continue-on-error: true
# - name: Download Camoufox for testing
# run: npx camoufox-js fetch
# continue-on-error: true
- name: Build frontend
run: pnpm build
+3
View File
@@ -26,6 +26,7 @@
"codegen",
"codesign",
"CTYPE",
"daijro",
"dataclasses",
"datareporting",
"datas",
@@ -44,6 +45,7 @@
"esac",
"esbuild",
"etree",
"flate",
"frontmost",
"geoip",
"getcwd",
@@ -78,6 +80,7 @@
"libxdo",
"localtime",
"lxml",
"lzma",
"mmdb",
"mountpoint",
"msiexec",
+4 -4
View File
@@ -84,8 +84,8 @@ Have questions or want to contribute? We'd love to hear from you!
<!-- readme: collaborators,contributors -start -->
<table>
<tbody>
<tr>
<tbody>
<tr>
<td align="center">
<a href="https://github.com/zhom">
<img src="https://avatars.githubusercontent.com/u/2717306?v=4" width="100;" alt="zhom"/>
@@ -93,8 +93,8 @@ Have questions or want to contribute? We'd love to hear from you!
<sub><b>zhom</b></sub>
</a>
</td>
</tr>
<tbody>
</tr>
<tbody>
</table>
<!-- readme: collaborators,contributors -end -->
+2 -1
View File
@@ -46,6 +46,7 @@
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"motion": "^12.23.12",
"next": "^15.4.6",
"next-themes": "^0.4.6",
"react": "^19.1.1",
@@ -72,7 +73,7 @@
},
"packageManager": "pnpm@10.13.1",
"lint-staged": {
"**/*.{js,jsx,ts,tsx,json,css,md}": [
"**/*.{js,jsx,ts,tsx,json,css}": [
"biome check --fix"
],
"src-tauri/**/*.rs": [
+60
View File
@@ -74,6 +74,9 @@ importers:
cmdk:
specifier: ^1.1.1
version: 1.1.1(@types/react-dom@19.1.7(@types/react@19.1.9))(@types/react@19.1.9)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)
motion:
specifier: ^12.23.12
version: 12.23.12(react-dom@19.1.1(react@19.1.1))(react@19.1.1)
next:
specifier: ^15.4.6
version: 15.4.6(@babel/core@7.28.0)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)
@@ -1860,6 +1863,20 @@ packages:
resolution: {integrity: sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==}
engines: {node: '>= 6'}
framer-motion@12.23.12:
resolution: {integrity: sha512-6e78rdVtnBvlEVgu6eFEAgG9v3wLnYEboM8I5O5EXvfKC8gxGQB8wXJdhkMy10iVcn05jl6CNw7/HTsTCfwcWg==}
peerDependencies:
'@emotion/is-prop-valid': '*'
react: ^18.0.0 || ^19.0.0
react-dom: ^18.0.0 || ^19.0.0
peerDependenciesMeta:
'@emotion/is-prop-valid':
optional: true
react:
optional: true
react-dom:
optional: true
fs-constants@1.0.0:
resolution: {integrity: sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==}
@@ -2343,6 +2360,26 @@ packages:
resolution: {integrity: sha512-DXO4L9W+08T+A7h5+xdT32l7IMot8z7WOH+7C1Maol571PnktQ8un7Ni4CyPFp4H+vht/FDA5/tpjRvWMFQDMw==}
engines: {node: '>=10', npm: '>=6'}
motion-dom@12.23.12:
resolution: {integrity: sha512-RcR4fvMCTESQBD/uKQe49D5RUeDOokkGRmz4ceaJKDBgHYtZtntC/s2vLvY38gqGaytinij/yi3hMcWVcEF5Kw==}
motion-utils@12.23.6:
resolution: {integrity: sha512-eAWoPgr4eFEOFfg2WjIsMoqJTW6Z8MTUCgn/GZ3VRpClWBdnbjryiA3ZSNLyxCTmCQx4RmYX6jX1iWHbenUPNQ==}
motion@12.23.12:
resolution: {integrity: sha512-8jCD8uW5GD1csOoqh1WhH1A6j5APHVE15nuBkFeRiMzYBdRwyAHmSP/oXSuW0WJPZRXTFdBoG4hY9TFWNhhwng==}
peerDependencies:
'@emotion/is-prop-valid': '*'
react: ^18.0.0 || ^19.0.0
react-dom: ^18.0.0 || ^19.0.0
peerDependenciesMeta:
'@emotion/is-prop-valid':
optional: true
react:
optional: true
react-dom:
optional: true
ms@2.1.3:
resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
@@ -4520,6 +4557,15 @@ snapshots:
hasown: 2.0.2
mime-types: 2.1.35
framer-motion@12.23.12(react-dom@19.1.1(react@19.1.1))(react@19.1.1):
dependencies:
motion-dom: 12.23.12
motion-utils: 12.23.6
tslib: 2.8.1
optionalDependencies:
react: 19.1.1
react-dom: 19.1.1(react@19.1.1)
fs-constants@1.0.0: {}
fs-minipass@2.1.0:
@@ -4981,6 +5027,20 @@ snapshots:
mmdb-lib@2.2.1: {}
motion-dom@12.23.12:
dependencies:
motion-utils: 12.23.6
motion-utils@12.23.6: {}
motion@12.23.12(react-dom@19.1.1(react@19.1.1))(react@19.1.1):
dependencies:
framer-motion: 12.23.12(react-dom@19.1.1(react@19.1.1))(react@19.1.1)
tslib: 2.8.1
optionalDependencies:
react: 19.1.1
react-dom: 19.1.1(react@19.1.1)
ms@2.1.3: {}
nano-spawn@1.0.2: {}
+118 -1
View File
@@ -429,6 +429,18 @@ dependencies = [
"libbz2-rs-sys",
]
[[package]]
name = "cab"
version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "171228650e6721d5acc0868a462cd864f49ac5f64e4a42cde270406e64e404d2"
dependencies = [
"byteorder",
"flate2",
"lzxd",
"time",
]
[[package]]
name = "cairo-rs"
version = "0.18.5"
@@ -524,6 +536,17 @@ dependencies = [
"uuid",
]
[[package]]
name = "cfb"
version = "0.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d8a4f8e55be323b378facfcf1f06aa97f6ec17cf4ac84fb17325093aaf62da41"
dependencies = [
"byteorder",
"fnv",
"uuid",
]
[[package]]
name = "cfg-expr"
version = "0.15.8"
@@ -691,6 +714,21 @@ dependencies = [
"libc",
]
[[package]]
name = "crc"
version = "3.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9710d3b3739c2e349eb44fe848ad0b7c8cb1e42bd87ee49371df2f7acaf3e675"
dependencies = [
"crc-catalog",
]
[[package]]
name = "crc-catalog"
version = "2.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "19d374276b40fb8bbdee95aef7c7fa6b5316ec764510eb64b8dd0e2ed0d7e7f5"
[[package]]
name = "crc32fast"
version = "1.5.0"
@@ -987,20 +1025,25 @@ version = "0.8.2"
dependencies = [
"async-trait",
"base64 0.22.1",
"bzip2",
"chrono",
"core-foundation 0.10.1",
"directories",
"flate2",
"futures-util",
"http-body-util",
"hyper",
"hyper-util",
"lazy_static",
"lzma-rs",
"msi-extract",
"objc2 0.6.1",
"objc2-app-kit 0.3.1",
"reqwest",
"serde",
"serde_json",
"sysinfo",
"tar",
"tauri",
"tauri-build",
"tauri-plugin-deep-link",
@@ -1192,6 +1235,18 @@ dependencies = [
"rustc_version",
]
[[package]]
name = "filetime"
version = "0.2.25"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "35c0522e981e68cbfa8c3f978441a5f34b30b96e146b33cd3359176b50fe8586"
dependencies = [
"cfg-if",
"libc",
"libredox",
"windows-sys 0.59.0",
]
[[package]]
name = "flate2"
version = "1.1.2"
@@ -2066,7 +2121,7 @@ version = "0.19.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a588916bfdfd92e71cacef98a63d9b1f0d74d6599980d11894290e7ddefffcf7"
dependencies = [
"cfb",
"cfb 0.7.3",
]
[[package]]
@@ -2330,6 +2385,7 @@ checksum = "391290121bad3d37fbddad76d8f5d1c1c314cfc646d143d7e07a3086ddff0ce3"
dependencies = [
"bitflags 2.9.1",
"libc",
"redox_syscall",
]
[[package]]
@@ -2369,6 +2425,22 @@ version = "0.4.27"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "13dc2df351e3202783a1fe0d44375f7295ffb4049267b0f3018346dc122a1d94"
[[package]]
name = "lzma-rs"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "297e814c836ae64db86b36cf2a557ba54368d03f6afcd7d947c266692f71115e"
dependencies = [
"byteorder",
"crc",
]
[[package]]
name = "lzxd"
version = "0.2.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7b29dffab797218e12e4df08ef5d15ab9efca2504038b1b32b9b32fc844b39c9"
[[package]]
name = "mac"
version = "0.1.1"
@@ -2468,6 +2540,30 @@ dependencies = [
"windows-sys 0.59.0",
]
[[package]]
name = "msi"
version = "0.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4a2332f87a064dea9cce571408c879e0da8dc193b3af06a2b3b2604ee4182a32"
dependencies = [
"byteorder",
"cfb 0.10.0",
"encoding_rs",
"uuid",
]
[[package]]
name = "msi-extract"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32047fe35aac08636833dcae558307dafc7679726b97f56449c3c126ed4be108"
dependencies = [
"cab",
"cfb 0.10.0",
"msi",
"thiserror 2.0.12",
]
[[package]]
name = "muda"
version = "0.17.1"
@@ -4361,6 +4457,17 @@ dependencies = [
"syn 2.0.104",
]
[[package]]
name = "tar"
version = "0.4.44"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1d863878d212c87a19c1a610eb53bb01fe12951c0501cf5a0d65f724914a667a"
dependencies = [
"filetime",
"libc",
"xattr",
]
[[package]]
name = "target-lexicon"
version = "0.12.16"
@@ -6212,6 +6319,16 @@ dependencies = [
"pkg-config",
]
[[package]]
name = "xattr"
version = "1.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "af3a19837351dc82ba89f8a125e22a3c475f05aba604acc023d62b2739ae2909"
dependencies = [
"libc",
"rustix",
]
[[package]]
name = "xdg-home"
version = "1.3.0"
+6 -3
View File
@@ -36,6 +36,12 @@ lazy_static = "1.4"
base64 = "0.22"
async-trait = "0.1"
futures-util = "0.3"
zip = "4"
tar = "0"
bzip2 = "0"
flate2 = "1"
lzma-rs = "0"
msi-extract = "0"
uuid = { version = "1.0", features = ["v4", "serde"] }
url = "2.5"
@@ -44,9 +50,6 @@ chrono = { version = "0.4", features = ["serde"] }
[target."cfg(any(target_os = \"macos\", windows, target_os = \"linux\"))".dependencies]
tauri-plugin-single-instance = { version = "2", features = ["deep-link"] }
[target.'cfg(windows)'.dependencies]
zip = "4"
[target.'cfg(target_os = "macos")'.dependencies]
core-foundation = "0.10"
objc2 = "0.6.1"
+11
View File
@@ -1678,11 +1678,22 @@ mod tests {
"name": "Release v1.81.9 (Chromium 137.0.7151.104)",
"prerelease": false,
"published_at": "2024-01-15T10:00:00Z",
"draft": false,
"assets": [
{
"name": "brave-v1.81.9-universal.dmg",
"browser_download_url": "https://example.com/brave-1.81.9-universal.dmg",
"size": 200000000
},
{
"name": "brave-browser-1.81.9-linux-amd64.zip",
"browser_download_url": "https://example.com/brave-1.81.9-linux-amd64.zip",
"size": 180000000
},
{
"name": "BraveBrowserStandaloneSetup.exe",
"browser_download_url": "https://example.com/brave-1.81.9-setup.exe",
"size": 150000000
}
]
}
+641 -61
View File
@@ -1,3 +1,69 @@
/*!
# App Auto Updater
This module provides comprehensive self-update functionality for the Donut Browser application
across multiple operating systems and installation methods.
## Supported Platforms
### macOS
- **Format**: DMG files
- **Installation**: Replaces the .app bundle in place
- **Architecture**: Supports both x64 and aarch64 (Apple Silicon)
### Windows
- **Formats**: MSI (preferred), EXE, ZIP
- **Installation**:
- MSI: Silent installation using msiexec
- EXE: Silent installation with multiple fallback flags (NSIS, Inno Setup)
- ZIP: Binary replacement
- **Architecture**: Supports both x64 and x86_64
### Linux
- **Formats**: DEB, RPM, AppImage, TAR.GZ
- **Installation Methods**:
- **DEB**: Uses dpkg or apt with pkexec for privilege escalation
- **RPM**: Uses rpm, dnf, yum, or zypper with pkexec
- **AppImage**: Direct replacement or installation to ~/.local/bin
- **TAR.GZ**: Binary extraction and replacement
- **Architecture**: Supports x64, x86_64, amd64, aarch64, arm64
## Linux Installation Detection
The updater automatically detects how the application was installed:
- **AppImage**: Detected via APPIMAGE environment variable
- **Package Manager**: Detected by executable location and package queries
- **Manual**: Detected by location in user directories
- **System**: Detected by location in system directories
## Update Process
1. **Check**: Fetches releases from GitHub API
2. **Filter**: Filters releases based on build type (stable vs nightly)
3. **Compare**: Compares versions using semantic versioning or commit hashes
4. **Download**: Downloads appropriate asset with progress tracking
5. **Extract**: Extracts or prepares installer based on format
6. **Install**: Installs using platform-appropriate method
7. **Restart**: Restarts application after successful installation
## Error Handling
- Comprehensive error messages for each platform
- Fallback mechanisms for different package managers
- Backup creation before installation
- Cleanup of temporary files
- Graceful handling of permission issues
## Testing
Includes comprehensive unit tests for:
- Version comparison logic
- Platform detection
- Asset selection
- Installation method detection (Linux)
- File format support
*/
use reqwest::Client;
use serde::{Deserialize, Serialize};
use std::fs;
@@ -236,7 +302,6 @@ impl AppAutoUpdater {
/// Get the appropriate download URL for the current platform
fn get_download_url_for_platform(&self, assets: &[AppReleaseAsset]) -> Option<String> {
// Priority 1: Get architecture-specific binary for backward compatibility
let arch = if cfg!(target_arch = "aarch64") {
"aarch64"
} else if cfg!(target_arch = "x86_64") {
@@ -245,8 +310,32 @@ impl AppAutoUpdater {
"unknown"
};
println!("Falling back to architecture-specific search for: {arch}");
println!("Looking for platform-specific asset for arch: {arch}");
#[cfg(target_os = "macos")]
{
self.get_macos_download_url(assets, arch)
}
#[cfg(target_os = "windows")]
{
self.get_windows_download_url(assets, arch)
}
#[cfg(target_os = "linux")]
{
self.get_linux_download_url(assets, arch)
}
#[cfg(not(any(target_os = "macos", target_os = "windows", target_os = "linux")))]
{
println!("Unsupported platform for auto-update");
None
}
}
#[cfg(target_os = "macos")]
fn get_macos_download_url(&self, assets: &[AppReleaseAsset], arch: &str) -> Option<String> {
// Look for exact architecture match in DMG
for asset in assets {
if asset.name.contains(".dmg")
@@ -284,20 +373,130 @@ impl AppAutoUpdater {
}
}
// Priority 2: Fallback to any macOS DMG
// Fallback to any macOS DMG
for asset in assets {
if asset.name.contains(".dmg")
&& (asset.name.to_lowercase().contains("macos")
|| asset.name.to_lowercase().contains("darwin")
|| !asset.name.contains(".app.tar.gz"))
{
// Exclude app.tar.gz files
println!("Found fallback DMG: {}", asset.name);
return Some(asset.browser_download_url.clone());
}
}
println!("No suitable asset found for platform");
None
}
#[cfg(target_os = "windows")]
fn get_windows_download_url(&self, assets: &[AppReleaseAsset], arch: &str) -> Option<String> {
// Priority order: MSI > EXE > ZIP
let extensions = ["msi", "exe", "zip"];
for ext in &extensions {
// Look for exact architecture match
for asset in assets {
if asset.name.to_lowercase().ends_with(&format!(".{ext}"))
&& (asset.name.contains(&format!("_{arch}.{ext}"))
|| asset.name.contains(&format!("-{arch}.{ext}"))
|| asset.name.contains(&format!("_{arch}_"))
|| asset.name.contains(&format!("-{arch}-")))
{
println!("Found Windows {ext} with exact arch match: {}", asset.name);
return Some(asset.browser_download_url.clone());
}
}
// Look for x86_64 variations if we're looking for x64
if arch == "x64" {
for asset in assets {
if asset.name.to_lowercase().ends_with(&format!(".{ext}"))
&& (asset.name.contains("x86_64") || asset.name.contains("x86-64"))
{
println!("Found Windows {ext} with x86_64 variant: {}", asset.name);
return Some(asset.browser_download_url.clone());
}
}
}
// Fallback to any Windows file of this type
for asset in assets {
if asset.name.to_lowercase().ends_with(&format!(".{ext}"))
&& (asset.name.to_lowercase().contains("windows")
|| asset.name.to_lowercase().contains("win32")
|| asset.name.to_lowercase().contains("win64"))
{
println!("Found Windows {ext} fallback: {}", asset.name);
return Some(asset.browser_download_url.clone());
}
}
}
None
}
#[cfg(target_os = "linux")]
fn get_linux_download_url(&self, assets: &[AppReleaseAsset], arch: &str) -> Option<String> {
// Priority order: DEB > RPM > AppImage > TAR.GZ
let extensions = ["deb", "rpm", "appimage", "tar.gz"];
for ext in &extensions {
// Look for exact architecture match
for asset in assets {
let asset_name_lower = asset.name.to_lowercase();
if asset_name_lower.ends_with(&format!(".{ext}"))
&& (asset.name.contains(&format!("_{arch}.{ext}"))
|| asset.name.contains(&format!("-{arch}.{ext}"))
|| asset.name.contains(&format!("_{arch}_"))
|| asset.name.contains(&format!("-{arch}-")))
{
println!("Found Linux {ext} with exact arch match: {}", asset.name);
return Some(asset.browser_download_url.clone());
}
}
// Look for x86_64 variations if we're looking for x64
if arch == "x64" {
for asset in assets {
let asset_name_lower = asset.name.to_lowercase();
if asset_name_lower.ends_with(&format!(".{ext}"))
&& (asset.name.contains("x86_64")
|| asset.name.contains("x86-64")
|| asset.name.contains("amd64"))
{
println!("Found Linux {ext} with x86_64 variant: {}", asset.name);
return Some(asset.browser_download_url.clone());
}
}
}
// Look for arm64 variations if we're looking for aarch64
if arch == "aarch64" {
for asset in assets {
let asset_name_lower = asset.name.to_lowercase();
if asset_name_lower.ends_with(&format!(".{ext}"))
&& (asset.name.contains("arm64") || asset.name.contains("aarch64"))
{
println!("Found Linux {ext} with arm64 variant: {}", asset.name);
return Some(asset.browser_download_url.clone());
}
}
}
// Fallback to any Linux file of this type
for asset in assets {
let asset_name_lower = asset.name.to_lowercase();
if asset_name_lower.ends_with(&format!(".{ext}"))
&& (asset_name_lower.contains("linux")
|| asset_name_lower.contains("ubuntu")
|| asset_name_lower.contains("debian"))
{
println!("Found Linux {ext} fallback: {}", asset.name);
return Some(asset.browser_download_url.clone());
}
}
}
None
}
@@ -488,6 +687,16 @@ impl AppAutoUpdater {
) -> Result<PathBuf, Box<dyn std::error::Error + Send + Sync>> {
let extractor = crate::extraction::Extractor::instance();
let file_name = archive_path
.file_name()
.and_then(|name| name.to_str())
.unwrap_or("");
// Handle compound extensions like .tar.gz
if file_name.ends_with(".tar.gz") {
return extractor.extract_tar_gz(archive_path, dest_dir).await;
}
let extension = archive_path
.extension()
.and_then(|ext| ext.to_str())
@@ -528,6 +737,39 @@ impl AppAutoUpdater {
Err("EXE installation is only supported on Windows".into())
}
}
"deb" => {
#[cfg(target_os = "linux")]
{
// For DEB files on Linux, return the path for installation
Ok(archive_path.to_path_buf())
}
#[cfg(not(target_os = "linux"))]
{
Err("DEB installation is only supported on Linux".into())
}
}
"rpm" => {
#[cfg(target_os = "linux")]
{
// For RPM files on Linux, return the path for installation
Ok(archive_path.to_path_buf())
}
#[cfg(not(target_os = "linux"))]
{
Err("RPM installation is only supported on Linux".into())
}
}
"appimage" => {
#[cfg(target_os = "linux")]
{
// For AppImage files, return the path for installation
Ok(archive_path.to_path_buf())
}
#[cfg(not(target_os = "linux"))]
{
Err("AppImage installation is only supported on Linux".into())
}
}
"zip" => extractor.extract_zip(archive_path, dest_dir).await,
_ => Err(format!("Unsupported archive format: {extension}").into()),
}
@@ -536,7 +778,7 @@ impl AppAutoUpdater {
/// Install the update by replacing the current app
async fn install_update(
&self,
installer_path: &Path,
#[allow(unused_variables)] installer_path: &Path,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
#[cfg(target_os = "macos")]
{
@@ -750,9 +992,29 @@ impl AppAutoUpdater {
#[cfg(target_os = "linux")]
{
// For Linux, we would handle different package formats here
// This implementation would depend on the specific package type
Err("Linux auto-update installation not yet implemented".into())
let file_name = installer_path
.file_name()
.and_then(|name| name.to_str())
.unwrap_or("");
println!("Installing Linux update: {}", installer_path.display());
// Handle compound extensions like .tar.gz
if file_name.ends_with(".tar.gz") {
return self.install_linux_tarball(installer_path).await;
}
let extension = installer_path
.extension()
.and_then(|ext| ext.to_str())
.unwrap_or("");
match extension {
"deb" => self.install_linux_deb(installer_path).await,
"rpm" => self.install_linux_rpm(installer_path).await,
"appimage" => self.install_linux_appimage(installer_path).await,
_ => Err(format!("Unsupported Linux installer format: {extension}").into()),
}
}
#[cfg(not(any(target_os = "macos", target_os = "windows", target_os = "linux")))]
@@ -761,6 +1023,249 @@ impl AppAutoUpdater {
}
}
/// Install Linux DEB package
#[cfg(target_os = "linux")]
async fn install_linux_deb(
&self,
deb_path: &Path,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
println!("Installing DEB package: {}", deb_path.display());
// Try different package managers in order of preference
let package_managers = [
("dpkg", vec!["-i", deb_path.to_str().unwrap()]),
("apt", vec!["install", "-y", deb_path.to_str().unwrap()]),
];
let mut last_error = String::new();
for (manager, args) in &package_managers {
// Check if package manager exists
if Command::new("which").arg(manager).output().is_ok() {
println!("Trying to install with {manager}");
let output = Command::new("pkexec").arg(manager).args(args).output();
match output {
Ok(output) if output.status.success() => {
println!("DEB installation completed successfully with {manager}");
return Ok(());
}
Ok(output) => {
let error_msg = String::from_utf8_lossy(&output.stderr);
last_error = format!("{manager} failed: {error_msg}");
println!("Installation failed with {manager}: {error_msg}");
}
Err(e) => {
last_error = format!("Failed to execute {manager}: {e}");
println!("Failed to execute {manager}: {e}");
}
}
}
}
Err(format!("DEB installation failed. Last error: {last_error}").into())
}
/// Install Linux RPM package
#[cfg(target_os = "linux")]
async fn install_linux_rpm(
&self,
rpm_path: &Path,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
println!("Installing RPM package: {}", rpm_path.display());
// Try different package managers in order of preference
let package_managers = [
("rpm", vec!["-Uvh", rpm_path.to_str().unwrap()]),
("dnf", vec!["install", "-y", rpm_path.to_str().unwrap()]),
("yum", vec!["install", "-y", rpm_path.to_str().unwrap()]),
("zypper", vec!["install", "-y", rpm_path.to_str().unwrap()]),
];
let mut last_error = String::new();
for (manager, args) in &package_managers {
// Check if package manager exists
if Command::new("which").arg(manager).output().is_ok() {
println!("Trying to install with {manager}");
let output = Command::new("pkexec").arg(manager).args(args).output();
match output {
Ok(output) if output.status.success() => {
println!("RPM installation completed successfully with {manager}");
return Ok(());
}
Ok(output) => {
let error_msg = String::from_utf8_lossy(&output.stderr);
last_error = format!("{manager} failed: {error_msg}");
println!("Installation failed with {manager}: {error_msg}");
}
Err(e) => {
last_error = format!("Failed to execute {manager}: {e}");
println!("Failed to execute {manager}: {e}");
}
}
}
}
Err(format!("RPM installation failed. Last error: {last_error}").into())
}
/// Install Linux AppImage
#[cfg(target_os = "linux")]
async fn install_linux_appimage(
&self,
appimage_path: &Path,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
println!("Installing AppImage: {}", appimage_path.display());
let current_exe = self.get_current_app_path()?;
// Detect if we're running from an AppImage
if let Ok(appimage_env) = std::env::var("APPIMAGE") {
// We're running from an AppImage, replace it
let current_appimage = PathBuf::from(appimage_env);
// Create backup
let backup_path = current_appimage.with_extension("appimage.backup");
if backup_path.exists() {
fs::remove_file(&backup_path)?;
}
fs::copy(&current_appimage, &backup_path)?;
// Make new AppImage executable
let _ = Command::new("chmod")
.args(["+x", appimage_path.to_str().unwrap()])
.output();
// Replace the AppImage
fs::copy(appimage_path, &current_appimage)?;
println!("AppImage replacement completed successfully");
Ok(())
} else {
// We're not running from AppImage, try to install to standard location
let install_dir = directories::UserDirs::new()
.ok_or("Could not determine user directories")?
.home_dir()
.join(".local/bin");
fs::create_dir_all(&install_dir)?;
let app_name = current_exe
.file_stem()
.and_then(|name| name.to_str())
.unwrap_or("donutbrowser");
let install_path = install_dir.join(format!("{app_name}.AppImage"));
// Make AppImage executable
let _ = Command::new("chmod")
.args(["+x", appimage_path.to_str().unwrap()])
.output();
// Copy to install location
fs::copy(appimage_path, &install_path)?;
// Try to create desktop entry
if let Some(user_dirs) = directories::UserDirs::new() {
let desktop_dir = user_dirs.home_dir().join(".local/share/applications");
let _ = fs::create_dir_all(&desktop_dir);
let desktop_file = desktop_dir.join(format!("{app_name}.desktop"));
let desktop_content = format!(
r#"[Desktop Entry]
Name=Donut Browser
Exec={}
Icon=donutbrowser
Type=Application
Categories=Network;WebBrowser;
"#,
install_path.to_str().unwrap()
);
let _ = fs::write(desktop_file, desktop_content);
}
println!("AppImage installation completed successfully");
Ok(())
}
}
/// Install Linux tarball
#[cfg(target_os = "linux")]
async fn install_linux_tarball(
&self,
tarball_path: &Path,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
println!("Installing tarball: {}", tarball_path.display());
let current_exe = self.get_current_app_path()?;
let temp_extract_dir = tarball_path.parent().unwrap().join("extracted");
fs::create_dir_all(&temp_extract_dir)?;
// Extract tarball
let extractor = crate::extraction::Extractor::instance();
let extracted_path = extractor
.extract_tar_gz(tarball_path, &temp_extract_dir)
.await?;
// Find the executable in the extracted files
let current_exe_name = current_exe.file_name().unwrap();
let new_exe_path =
if extracted_path.is_file() && extracted_path.file_name() == Some(current_exe_name) {
extracted_path
} else {
// Search in extracted directory
let mut found_exe = None;
if let Ok(entries) = fs::read_dir(&extracted_path) {
for entry in entries.flatten() {
let path = entry.path();
if path.file_name() == Some(current_exe_name) {
found_exe = Some(path);
break;
}
// Also check subdirectories
if path.is_dir() {
if let Ok(sub_entries) = fs::read_dir(&path) {
for sub_entry in sub_entries.flatten() {
let sub_path = sub_entry.path();
if sub_path.file_name() == Some(current_exe_name) {
found_exe = Some(sub_path);
break;
}
}
}
}
}
}
found_exe.ok_or("Could not find executable in tarball")?
};
// Create backup of current executable
let backup_path = current_exe.with_extension("backup");
if backup_path.exists() {
fs::remove_file(&backup_path)?;
}
fs::copy(&current_exe, &backup_path)?;
// Replace current executable
fs::copy(&new_exe_path, &current_exe)?;
// Make sure it's executable
let _ = Command::new("chmod")
.args(["+x", current_exe.to_str().unwrap()])
.output();
// Clean up
let _ = fs::remove_dir_all(&temp_extract_dir);
println!("Tarball installation completed successfully");
Ok(())
}
/// Get the current application bundle path
fn get_current_app_path(&self) -> Result<PathBuf, Box<dyn std::error::Error + Send + Sync>> {
#[cfg(target_os = "macos")]
@@ -1002,6 +1507,14 @@ pub async fn check_for_app_updates_manual() -> Result<Option<AppUpdateInfo>, Str
.map_err(|e| format!("Failed to check for app updates: {e}"))
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct PlatformInfo {
pub os: String,
pub arch: String,
pub installation_method: String,
pub supported_formats: Vec<String>,
}
#[cfg(test)]
mod tests {
use super::*;
@@ -1085,58 +1598,6 @@ mod tests {
assert!(updater.should_update("v0.9.9", "1.0.0", false));
}
#[test]
fn test_get_download_url_for_platform() {
let updater = AppAutoUpdater::instance();
let 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 url = updater.get_download_url_for_platform(&assets);
assert!(url.is_some());
// 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 architecture-specific DMG
let arch_specific_assets = vec![
AppReleaseAsset {
name: "Donut.Browser_0.1.0_x64.dmg".to_string(),
browser_download_url: "https://example.com/x64.dmg".to_string(),
size: 12345,
},
AppReleaseAsset {
name: "Donut.Browser_0.1.0_aarch64.dmg".to_string(),
browser_download_url: "https://example.com/aarch64.dmg".to_string(),
size: 12345,
},
];
let arch_url = updater.get_download_url_for_platform(&arch_specific_assets);
assert!(arch_url.is_some());
// The exact URL depends on the target architecture, but should be one of the available ones
let arch_url = arch_url.unwrap();
assert!(arch_url.contains(".dmg"));
}
#[test]
fn test_extract_update_uses_extractor() {
// This test verifies that the extract_update method properly uses the Extractor
@@ -1160,6 +1621,125 @@ mod tests {
let error_msg = result.unwrap_err().to_string();
assert!(error_msg.contains("Unsupported archive format: rar"));
}
#[test]
fn test_platform_specific_download_urls() {
let updater = AppAutoUpdater::instance();
// Create comprehensive assets for all platforms
let all_assets = vec![
// macOS assets
AppReleaseAsset {
name: "Donut.Browser_0.1.0_aarch64.dmg".to_string(),
browser_download_url: "https://example.com/aarch64.dmg".to_string(),
size: 12345,
},
AppReleaseAsset {
name: "Donut.Browser_0.1.0_x64.dmg".to_string(),
browser_download_url: "https://example.com/x64.dmg".to_string(),
size: 12345,
},
// Windows assets
AppReleaseAsset {
name: "Donut.Browser_0.1.0_x64.msi".to_string(),
browser_download_url: "https://example.com/x64.msi".to_string(),
size: 12345,
},
AppReleaseAsset {
name: "Donut.Browser_0.1.0_x64.exe".to_string(),
browser_download_url: "https://example.com/x64.exe".to_string(),
size: 12345,
},
// Linux assets
AppReleaseAsset {
name: "donutbrowser_0.1.0_amd64.deb".to_string(),
browser_download_url: "https://example.com/amd64.deb".to_string(),
size: 12345,
},
AppReleaseAsset {
name: "donutbrowser-0.1.0-1.x86_64.rpm".to_string(),
browser_download_url: "https://example.com/x86_64.rpm".to_string(),
size: 12345,
},
AppReleaseAsset {
name: "Donut.Browser-0.1.0-x86_64.AppImage".to_string(),
browser_download_url: "https://example.com/x86_64.AppImage".to_string(),
size: 12345,
},
];
// Test that the method returns a URL for the current platform
let url = updater.get_download_url_for_platform(&all_assets);
assert!(
url.is_some(),
"Should find a suitable download URL for current platform"
);
// Test platform-specific behavior
#[cfg(target_os = "macos")]
{
let url = url.unwrap();
assert!(url.contains(".dmg"), "macOS should prefer DMG files");
}
#[cfg(target_os = "windows")]
{
let url = url.unwrap();
assert!(
url.contains(".msi") || url.contains(".exe") || url.contains(".zip"),
"Windows should prefer MSI, EXE, or ZIP files"
);
}
#[cfg(target_os = "linux")]
{
let url = url.unwrap();
assert!(
url.contains(".deb")
|| url.contains(".rpm")
|| url.contains(".appimage")
|| url.contains(".tar.gz"),
"Linux should prefer DEB, RPM, AppImage, or TAR.GZ files"
);
}
}
#[test]
fn test_supported_file_extensions() {
let updater = AppAutoUpdater::instance();
let temp_dir = std::env::temp_dir();
let rt = tokio::runtime::Runtime::new().unwrap();
// Test that all supported extensions are handled
let supported_extensions = ["dmg", "msi", "exe", "deb", "rpm", "appimage", "zip"];
for ext in &supported_extensions {
let test_file = temp_dir.join(format!("test.{ext}"));
let result = rt.block_on(async { updater.extract_update(&test_file, &temp_dir).await });
// The result should either succeed or fail with a platform-specific error,
// but not with "Unsupported archive format"
if let Err(e) = result {
let error_msg = e.to_string();
assert!(
!error_msg.contains("Unsupported archive format"),
"Extension {ext} should be supported but got: {error_msg}"
);
}
}
// Test tar.gz compound extension
let tar_gz_file = temp_dir.join("test.tar.gz");
let result = rt.block_on(async { updater.extract_update(&tar_gz_file, &temp_dir).await });
if let Err(e) = result {
let error_msg = e.to_string();
assert!(
!error_msg.contains("Unsupported archive format"),
"tar.gz should be supported but got: {error_msg}"
);
}
}
}
// Global singleton instance
+62 -13
View File
@@ -1037,9 +1037,34 @@ mod tests {
let browser_dir = binaries_dir.join("firefox").join("139.0");
fs::create_dir_all(&browser_dir).unwrap();
// Create a mock .app directory
let app_dir = browser_dir.join("Firefox.app");
fs::create_dir_all(&app_dir).unwrap();
#[cfg(target_os = "macos")]
{
// Create a mock .app directory for macOS
let app_dir = browser_dir.join("Firefox.app");
fs::create_dir_all(&app_dir).unwrap();
}
#[cfg(target_os = "linux")]
{
// Create a mock firefox subdirectory and executable for Linux
let firefox_subdir = browser_dir.join("firefox");
fs::create_dir_all(&firefox_subdir).unwrap();
let executable_path = firefox_subdir.join("firefox");
fs::write(&executable_path, "mock executable").unwrap();
// Set executable permissions on Linux
use std::os::unix::fs::PermissionsExt;
let mut permissions = executable_path.metadata().unwrap().permissions();
permissions.set_mode(0o755);
fs::set_permissions(&executable_path, permissions).unwrap();
}
#[cfg(target_os = "windows")]
{
// Create a mock firefox.exe for Windows
let executable_path = browser_dir.join("firefox.exe");
fs::write(&executable_path, "mock executable").unwrap();
}
let browser = FirefoxBrowser::new(BrowserType::Firefox);
assert!(browser.is_version_downloaded("139.0", binaries_dir));
@@ -1048,15 +1073,39 @@ mod tests {
// 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");
fs::create_dir_all(chromium_app_dir.join("Contents").join("MacOS")).unwrap();
// Create a mock executable
let executable_path = chromium_app_dir
.join("Contents")
.join("MacOS")
.join("Chromium");
fs::write(&executable_path, "mock executable").unwrap();
#[cfg(target_os = "macos")]
{
let chromium_app_dir = chromium_dir.join("Chromium.app");
fs::create_dir_all(chromium_app_dir.join("Contents").join("MacOS")).unwrap();
// Create a mock executable
let executable_path = chromium_app_dir
.join("Contents")
.join("MacOS")
.join("Chromium");
fs::write(&executable_path, "mock executable").unwrap();
}
#[cfg(target_os = "linux")]
{
// Create a mock chromium executable for Linux
let executable_path = chromium_dir.join("chromium");
fs::write(&executable_path, "mock executable").unwrap();
// Set executable permissions on Linux
use std::os::unix::fs::PermissionsExt;
let mut permissions = executable_path.metadata().unwrap().permissions();
permissions.set_mode(0o755);
fs::set_permissions(&executable_path, permissions).unwrap();
}
#[cfg(target_os = "windows")]
{
// Create a mock chromium.exe for Windows
let executable_path = chromium_dir.join("chromium.exe");
fs::write(&executable_path, "mock executable").unwrap();
}
let chromium_browser = ChromiumBrowser::new(BrowserType::Chromium);
assert!(chromium_browser.is_version_downloaded("1465660", binaries_dir));
@@ -1068,11 +1117,11 @@ mod tests {
let temp_dir = TempDir::new().unwrap();
let binaries_dir = temp_dir.path();
// Create browser directory but no .app directory with new path structure
// Create browser directory but no proper executable structure
let browser_dir = binaries_dir.join("firefox").join("139.0");
fs::create_dir_all(&browser_dir).unwrap();
// Create some other files but no .app
// Create some other files but no proper executable structure
fs::write(browser_dir.join("readme.txt"), "Some content").unwrap();
let browser = FirefoxBrowser::new(BrowserType::Firefox);
+54 -1
View File
@@ -1250,6 +1250,59 @@ impl BrowserRunner {
// Use the browser's own verification method
let binaries_dir = self.get_binaries_dir();
if !browser.is_version_downloaded(&version, &binaries_dir) {
// Provide detailed error information for debugging
let browser_dir = binaries_dir.join(&browser_str).join(&version);
let mut error_details = format!(
"Browser download completed but verification failed for {} {}. Expected directory: {}",
browser_str,
version,
browser_dir.display()
);
// List what files actually exist
if browser_dir.exists() {
error_details.push_str("\nFiles found in directory:");
if let Ok(entries) = std::fs::read_dir(&browser_dir) {
for entry in entries.flatten() {
let path = entry.path();
let file_type = if path.is_dir() { "DIR" } else { "FILE" };
error_details.push_str(&format!("\n {} {}", file_type, path.display()));
}
} else {
error_details.push_str("\n (Could not read directory contents)");
}
} else {
error_details.push_str("\nDirectory does not exist!");
}
// For Camoufox on Linux, provide specific expected files
if browser_str == "camoufox" && cfg!(target_os = "linux") {
let camoufox_subdir = browser_dir.join("camoufox");
error_details.push_str("\nExpected Camoufox executable locations:");
error_details.push_str(&format!("\n {}/camoufox-bin", camoufox_subdir.display()));
error_details.push_str(&format!("\n {}/camoufox", camoufox_subdir.display()));
if camoufox_subdir.exists() {
error_details.push_str(&format!(
"\nCamoufox subdirectory exists: {}",
camoufox_subdir.display()
));
if let Ok(entries) = std::fs::read_dir(&camoufox_subdir) {
error_details.push_str("\nFiles in camoufox subdirectory:");
for entry in entries.flatten() {
let path = entry.path();
let file_type = if path.is_dir() { "DIR" } else { "FILE" };
error_details.push_str(&format!("\n {} {}", file_type, path.display()));
}
}
} else {
error_details.push_str(&format!(
"\nCamoufox subdirectory does not exist: {}",
camoufox_subdir.display()
));
}
}
let _ = registry.cleanup_failed_download(&browser_str, &version);
let _ = registry.save();
// Remove browser-version pair from downloading set on verification failure
@@ -1257,7 +1310,7 @@ impl BrowserRunner {
let mut downloading = DOWNLOADING_BROWSERS.lock().unwrap();
downloading.remove(&download_key);
}
return Err("Browser download completed but verification failed".into());
return Err(error_details.into());
}
registry
+158 -19
View File
@@ -1016,59 +1016,198 @@ mod tests {
fn test_get_download_info() {
let service = BrowserVersionService::instance();
// Test Firefox
// Test Firefox - platform-specific expectations
let firefox_info = service.get_download_info("firefox", "139.0").unwrap();
assert_eq!(firefox_info.filename, "Firefox 139.0.dmg");
#[cfg(target_os = "macos")]
{
assert_eq!(firefox_info.filename, "Firefox 139.0.dmg");
assert!(firefox_info.is_archive);
}
#[cfg(target_os = "linux")]
{
assert_eq!(firefox_info.filename, "firefox-139.0.tar.xz");
assert!(firefox_info.is_archive);
}
#[cfg(target_os = "windows")]
{
assert_eq!(firefox_info.filename, "Firefox Setup 139.0.exe");
assert!(!firefox_info.is_archive);
}
assert!(firefox_info
.url
.contains("download-installer.cdn.mozilla.net"));
assert!(firefox_info.url.contains("/pub/firefox/releases/139.0/"));
assert!(firefox_info.is_archive);
// Test Firefox Developer
let firefox_dev_info = service
.get_download_info("firefox-developer", "139.0b1")
.unwrap();
assert_eq!(firefox_dev_info.filename, "Firefox 139.0b1.dmg");
#[cfg(target_os = "macos")]
{
assert_eq!(firefox_dev_info.filename, "Firefox 139.0b1.dmg");
assert!(firefox_dev_info.is_archive);
}
#[cfg(target_os = "linux")]
{
assert_eq!(firefox_dev_info.filename, "firefox-139.0b1.tar.xz");
assert!(firefox_dev_info.is_archive);
}
#[cfg(target_os = "windows")]
{
assert_eq!(firefox_dev_info.filename, "Firefox Setup 139.0b1.exe");
assert!(!firefox_dev_info.is_archive);
}
assert!(firefox_dev_info
.url
.contains("download-installer.cdn.mozilla.net"));
assert!(firefox_dev_info
.url
.contains("/pub/devedition/releases/139.0b1/"));
assert!(firefox_dev_info.is_archive);
// Test Mullvad Browser
let mullvad_info = service
.get_download_info("mullvad-browser", "14.5a6")
.unwrap();
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);
#[cfg(target_os = "macos")]
{
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);
}
#[cfg(target_os = "linux")]
{
assert_eq!(
mullvad_info.filename,
"mullvad-browser-x86_64-14.5a6.tar.xz"
);
assert!(mullvad_info.url.contains("mullvad-browser-x86_64-14.5a6"));
assert!(mullvad_info.is_archive);
}
#[cfg(target_os = "windows")]
{
assert_eq!(
mullvad_info.filename,
"mullvad-browser-windows-x86_64-14.5a6.exe"
);
assert!(mullvad_info
.url
.contains("mullvad-browser-windows-x86_64-14.5a6"));
assert!(!mullvad_info.is_archive);
}
// Test Zen Browser
let zen_info = service.get_download_info("zen", "1.11b").unwrap();
assert_eq!(zen_info.filename, "zen-1.11b.dmg");
assert!(zen_info.url.contains("zen.macos-universal.dmg"));
assert!(zen_info.is_archive);
#[cfg(target_os = "macos")]
{
assert_eq!(zen_info.filename, "zen-1.11b.dmg");
assert!(zen_info.url.contains("zen.macos-universal.dmg"));
assert!(zen_info.is_archive);
}
#[cfg(target_os = "linux")]
{
assert_eq!(zen_info.filename, "zen-1.11b-x86_64.tar.xz");
assert!(zen_info.url.contains("zen.linux-x86_64.tar.xz"));
assert!(zen_info.is_archive);
}
#[cfg(target_os = "windows")]
{
assert_eq!(zen_info.filename, "zen-1.11b.exe");
assert!(zen_info.url.contains("zen.installer.exe"));
assert!(!zen_info.is_archive);
}
// Test Tor Browser
let tor_info = service.get_download_info("tor-browser", "14.0.4").unwrap();
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);
#[cfg(target_os = "macos")]
{
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);
}
#[cfg(target_os = "linux")]
{
assert_eq!(tor_info.filename, "tor-browser-linux-x86_64-14.0.4.tar.xz");
assert!(tor_info.url.contains("tor-browser-linux-x86_64-14.0.4"));
assert!(tor_info.is_archive);
}
#[cfg(target_os = "windows")]
{
assert_eq!(
tor_info.filename,
"tor-browser-windows-x86_64-portable-14.0.4.exe"
);
assert!(tor_info
.url
.contains("tor-browser-windows-x86_64-portable-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-mac.zip");
assert!(chromium_info.url.contains("chrome-mac.zip"));
#[cfg(target_os = "macos")]
{
assert_eq!(chromium_info.filename, "chromium-1465660-mac.zip");
assert!(chromium_info.url.contains("chrome-mac.zip"));
}
#[cfg(target_os = "linux")]
{
assert_eq!(chromium_info.filename, "chromium-1465660-linux.zip");
assert!(chromium_info.url.contains("chrome-linux.zip"));
}
#[cfg(target_os = "windows")]
{
assert_eq!(chromium_info.filename, "chromium-1465660-win.zip");
assert!(chromium_info.url.contains("chrome-win.zip"));
}
assert!(chromium_info.is_archive);
// Test Brave - Note: Brave uses dynamic URL resolution, so get_download_info provides a template URL
let brave_info = service.get_download_info("brave", "v1.81.9").unwrap();
assert_eq!(brave_info.filename, "Brave-Browser-universal.dmg");
assert_eq!(brave_info.url, "https://github.com/brave/brave-browser/releases/download/v1.81.9/Brave-Browser-universal.dmg");
assert!(brave_info.is_archive);
#[cfg(target_os = "macos")]
{
assert_eq!(brave_info.filename, "Brave-Browser-universal.dmg");
assert_eq!(brave_info.url, "https://github.com/brave/brave-browser/releases/download/v1.81.9/Brave-Browser-universal.dmg");
assert!(brave_info.is_archive);
}
#[cfg(target_os = "linux")]
{
assert_eq!(brave_info.filename, "brave-browser-v1.81.9-linux-amd64.zip");
assert_eq!(brave_info.url, "https://github.com/brave/brave-browser/releases/download/v1.81.9/brave-browser-v1.81.9-linux-amd64.zip");
assert!(brave_info.is_archive);
}
#[cfg(target_os = "windows")]
{
assert_eq!(brave_info.filename, "brave-v1.81.9.exe");
assert_eq!(
brave_info.url,
"https://github.com/brave/brave-browser/releases/download/v1.81.9/brave-v1.81.9.exe"
);
assert!(!brave_info.is_archive);
}
// Test unsupported browser
let unsupported_result = service.get_download_info("unsupported", "1.0.0");
File diff suppressed because it is too large Load Diff
Binary file not shown.
Binary file not shown.
Binary file not shown.
+1
View File
@@ -0,0 +1 @@
Hello, World!
Binary file not shown.
+38 -41
View File
@@ -19,7 +19,6 @@ import { ProfileSelectorDialog } from "@/components/profile-selector-dialog";
import { ProxyManagementDialog } from "@/components/proxy-management-dialog";
import { ProxySettingsDialog } from "@/components/proxy-settings-dialog";
import { SettingsDialog } from "@/components/settings-dialog";
import { Card, CardContent, CardHeader } from "@/components/ui/card";
import { useAppUpdateNotifications } from "@/hooks/use-app-update-notifications";
import type { PermissionType } from "@/hooks/use-permissions";
import { usePermissions } from "@/hooks/use-permissions";
@@ -727,46 +726,44 @@ export default function Home() {
return (
<div className="grid grid-rows-[20px_1fr_20px] items-center justify-items-center min-h-screen gap-8 font-[family-name:var(--font-geist-sans)] bg-white dark:bg-black">
<main className="flex flex-col row-start-2 gap-8 items-center w-full max-w-3xl">
<Card className="gap-2 w-full">
<CardHeader>
<HomeHeader
selectedProfiles={selectedProfiles}
onBulkDelete={handleBulkDelete}
onBulkGroupAssignment={handleBulkGroupAssignment}
onCreateProfileDialogOpen={setCreateProfileDialogOpen}
onGroupManagementDialogOpen={setGroupManagementDialogOpen}
onImportProfileDialogOpen={setImportProfileDialogOpen}
onProxyManagementDialogOpen={setProxyManagementDialogOpen}
onSettingsDialogOpen={setSettingsDialogOpen}
/>
</CardHeader>
<CardContent>
<GroupBadges
selectedGroupId={selectedGroupId}
onGroupSelect={handleSelectGroup}
groups={groups}
isLoading={areGroupsLoading}
/>
<ProfilesDataTable
data={profiles}
onLaunchProfile={launchProfile}
onKillProfile={handleKillProfile}
onProxySettings={openProxyDialog}
onDeleteProfile={handleDeleteProfile}
onRenameProfile={handleRenameProfile}
onChangeVersion={openChangeVersionDialog}
onConfigureCamoufox={handleConfigureCamoufox}
runningProfiles={runningProfiles}
isUpdating={isUpdating}
onDeleteSelectedProfiles={handleDeleteSelectedProfiles}
onAssignProfilesToGroup={handleAssignProfilesToGroup}
selectedGroupId={selectedGroupId}
selectedProfiles={selectedProfiles}
onSelectedProfilesChange={setSelectedProfiles}
/>
</CardContent>
</Card>
<main className="flex flex-col row-start-2 gap-6 items-center w-full max-w-3xl">
<div className="w-full">
<HomeHeader
selectedProfiles={selectedProfiles}
onBulkDelete={handleBulkDelete}
onBulkGroupAssignment={handleBulkGroupAssignment}
onCreateProfileDialogOpen={setCreateProfileDialogOpen}
onGroupManagementDialogOpen={setGroupManagementDialogOpen}
onImportProfileDialogOpen={setImportProfileDialogOpen}
onProxyManagementDialogOpen={setProxyManagementDialogOpen}
onSettingsDialogOpen={setSettingsDialogOpen}
/>
</div>
<div className="space-y-4 w-full">
<GroupBadges
selectedGroupId={selectedGroupId}
onGroupSelect={handleSelectGroup}
groups={groups}
isLoading={areGroupsLoading}
/>
<ProfilesDataTable
data={profiles}
onLaunchProfile={launchProfile}
onKillProfile={handleKillProfile}
onProxySettings={openProxyDialog}
onDeleteProfile={handleDeleteProfile}
onRenameProfile={handleRenameProfile}
onChangeVersion={openChangeVersionDialog}
onConfigureCamoufox={handleConfigureCamoufox}
runningProfiles={runningProfiles}
isUpdating={isUpdating}
onDeleteSelectedProfiles={handleDeleteSelectedProfiles}
onAssignProfilesToGroup={handleAssignProfilesToGroup}
selectedGroupId={selectedGroupId}
selectedProfiles={selectedProfiles}
onSelectedProfilesChange={setSelectedProfiles}
/>
</div>
</main>
<ProxySettingsDialog
+10 -9
View File
@@ -5,6 +5,7 @@ import { LuCheckCheck, LuCog, LuRefreshCw } from "react-icons/lu";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import type { AppUpdateInfo, AppUpdateProgress } from "@/types";
import { RippleButton } from "./ui/ripple";
interface AppUpdateToastProps {
updateInfo: AppUpdateInfo;
@@ -78,7 +79,7 @@ export function AppUpdateToast({
updateProgress.stage === "completed");
return (
<div className="flex items-start p-4 w-full max-w-md bg-white rounded-lg border border-gray-200 shadow-lg dark:bg-gray-800 dark:border-gray-700">
<div className="flex items-start p-4 w-full max-w-md bg-card rounded-lg border border-border shadow-lg text-card-foreground">
<div className="mr-3 mt-0.5">
{getStageIcon(updateProgress?.stage, isUpdating)}
</div>
@@ -133,9 +134,9 @@ export function AppUpdateToast({
{updateProgress.eta && `${updateProgress.eta} remaining`}
</p>
</div>
<div className="w-full bg-gray-200 dark:bg-gray-700 rounded-full h-1.5">
<div className="w-full bg-muted rounded-full h-1.5">
<div
className="bg-blue-500 h-1.5 rounded-full transition-all duration-300"
className="bg-primary h-1.5 rounded-full transition-all duration-300"
style={{ width: `${updateProgress.percentage}%` }}
/>
</div>
@@ -146,12 +147,12 @@ export function AppUpdateToast({
{showOtherStageProgress && (
<div className="mt-2 space-y-1">
{/* Progress indicator for non-downloading stages */}
<div className="w-full bg-gray-200 dark:bg-gray-700 rounded-full h-1.5">
<div className="w-full bg-muted rounded-full h-1.5">
<div
className={`h-1.5 rounded-full transition-all duration-500 ${
updateProgress.stage === "completed"
? "bg-green-500 w-full"
: "bg-blue-500 w-full animate-pulse"
: "bg-primary w-full animate-pulse"
}`}
/>
</div>
@@ -160,22 +161,22 @@ export function AppUpdateToast({
{!isUpdating && (
<div className="flex gap-2 items-center mt-3">
<Button
<RippleButton
onClick={() => void handleUpdateClick()}
size="sm"
className="flex gap-2 items-center text-xs"
>
<FaDownload className="w-3 h-3" />
Update Now
</Button>
<Button
</RippleButton>
<RippleButton
variant="outline"
onClick={onDismiss}
size="sm"
className="text-xs"
>
Later
</Button>
</RippleButton>
</div>
)}
</div>
+12 -7
View File
@@ -2,7 +2,6 @@
import { useEffect, useState } from "react";
import { SharedCamoufoxConfigForm } from "@/components/shared-camoufox-config-form";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
@@ -12,6 +11,8 @@ import {
} from "@/components/ui/dialog";
import { ScrollArea } from "@/components/ui/scroll-area";
import type { BrowserProfile, CamoufoxConfig } from "@/types";
import { LoadingButton } from "./loading-button";
import { RippleButton } from "./ui/ripple";
interface CamoufoxConfigDialogProps {
isOpen: boolean;
@@ -106,7 +107,7 @@ export function CamoufoxConfigDialog({
</DialogTitle>
</DialogHeader>
<ScrollArea className="flex-1 pr-6 h-[400px]">
<ScrollArea className="flex-1 h-[400px]">
<div className="py-4">
<SharedCamoufoxConfigForm
config={config}
@@ -117,12 +118,16 @@ export function CamoufoxConfigDialog({
</ScrollArea>
<DialogFooter className="flex-shrink-0 pt-4 border-t">
<Button variant="outline" onClick={handleClose}>
<RippleButton variant="outline" onClick={handleClose}>
Cancel
</Button>
<Button onClick={handleSave} disabled={isSaving}>
{isSaving ? "Saving..." : "Save Configuration"}
</Button>
</RippleButton>
<LoadingButton
isLoading={isSaving}
onClick={handleSave}
disabled={isSaving}
>
Save
</LoadingButton>
</DialogFooter>
</DialogContent>
</Dialog>
+3 -3
View File
@@ -6,7 +6,6 @@ import { LuTriangleAlert } from "react-icons/lu";
import { LoadingButton } from "@/components/loading-button";
import { ReleaseTypeSelector } from "@/components/release-type-selector";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import {
Dialog,
@@ -19,6 +18,7 @@ import { Label } from "@/components/ui/label";
import { useBrowserDownload } from "@/hooks/use-browser-download";
import { getBrowserDisplayName } from "@/lib/browser-utils";
import type { BrowserProfile, BrowserReleaseTypes } from "@/types";
import { RippleButton } from "./ui/ripple";
interface ChangeVersionDialogProps {
isOpen: boolean;
@@ -288,9 +288,9 @@ export function ChangeVersionDialog({
</div>
<DialogFooter>
<Button variant="outline" onClick={onClose}>
<RippleButton variant="outline" onClick={onClose}>
Cancel
</Button>
</RippleButton>
<LoadingButton
isLoading={isUpdating}
onClick={() => {
+8 -4
View File
@@ -4,7 +4,6 @@ import { invoke } from "@tauri-apps/api/core";
import { useCallback, useState } from "react";
import { toast } from "sonner";
import { LoadingButton } from "@/components/loading-button";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
@@ -16,6 +15,7 @@ import {
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import type { ProfileGroup } from "@/types";
import { RippleButton } from "./ui/ripple";
interface CreateGroupDialogProps {
isOpen: boolean;
@@ -98,15 +98,19 @@ export function CreateGroupDialog({
</div>
<DialogFooter>
<Button variant="outline" onClick={handleClose} disabled={isCreating}>
<RippleButton
variant="outline"
onClick={handleClose}
disabled={isCreating}
>
Cancel
</Button>
</RippleButton>
<LoadingButton
isLoading={isCreating}
onClick={() => void handleCreate()}
disabled={!groupName.trim()}
>
Create Group
Create
</LoadingButton>
</DialogFooter>
</DialogContent>
+191 -158
View File
@@ -2,9 +2,10 @@
import { invoke } from "@tauri-apps/api/core";
import { useCallback, useEffect, useRef, useState } from "react";
import { GoPlus } from "react-icons/go";
import { LoadingButton } from "@/components/loading-button";
import { ProxyFormDialog } from "@/components/proxy-form-dialog";
import { SharedCamoufoxConfigForm } from "@/components/shared-camoufox-config-form";
import { Button } from "@/components/ui/button";
import { Combobox } from "@/components/ui/combobox";
import {
Dialog,
@@ -27,6 +28,7 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { useBrowserDownload } from "@/hooks/use-browser-download";
import { getBrowserIcon } from "@/lib/browser-utils";
import type { BrowserReleaseTypes, CamoufoxConfig, StoredProxy } from "@/types";
import { RippleButton } from "./ui/ripple";
type BrowserTypeString =
| "mullvad-browser"
@@ -104,7 +106,7 @@ export function CreateProfileDialog({
selectedGroupId,
}: CreateProfileDialogProps) {
const [profileName, setProfileName] = useState("");
const [activeTab, setActiveTab] = useState("regular");
const [activeTab, setActiveTab] = useState("anti-detect");
// Regular browser states
const [selectedBrowser, setSelectedBrowser] = useState<BrowserTypeString>();
@@ -132,6 +134,7 @@ export function CreateProfileDialog({
useState<BrowserReleaseTypes>({});
const [supportedBrowsers, setSupportedBrowsers] = useState<string[]>([]);
const [storedProxies, setStoredProxies] = useState<StoredProxy[]>([]);
const [showProxyForm, setShowProxyForm] = useState(false);
const [isCreating, setIsCreating] = useState(false);
const loadingBrowserRef = useRef<string | null>(null);
@@ -334,7 +337,7 @@ export function CreateProfileDialog({
setCamoufoxConfig({
geoip: true, // Reset to automatic geoip
});
setActiveTab("regular");
setActiveTab("anti-detect");
onClose();
};
@@ -375,7 +378,7 @@ export function CreateProfileDialog({
return (
<Dialog open={isOpen} onOpenChange={handleClose}>
<DialogContent className="max-w-4xl max-h-[90vh] flex flex-col">
<DialogContent className="w-full max-h-[90vh] flex flex-col">
<DialogHeader className="flex-shrink-0">
<DialogTitle>Create New Profile</DialogTitle>
</DialogHeader>
@@ -385,214 +388,244 @@ export function CreateProfileDialog({
onValueChange={handleTabChange}
className="flex flex-col flex-1 w-full min-h-0"
>
<TabsList className="grid flex-shrink-0 grid-cols-2 w-full">
<TabsTrigger value="regular">Regular Browsers</TabsTrigger>
<TabsList
className="grid flex-shrink-0 grid-cols-2 w-full"
defaultValue="anti-detect"
>
<TabsTrigger value="anti-detect">Anti-Detect</TabsTrigger>
<TabsTrigger value="regular">Regular</TabsTrigger>
</TabsList>
<ScrollArea className="flex-1 pr-6 h-[320px]">
<div className="py-4 space-y-6">
{/* Profile Name - Common to both tabs */}
<div className="space-y-2">
<Label htmlFor="profile-name">Profile Name</Label>
<Input
id="profile-name"
value={profileName}
onChange={(e) => setProfileName(e.target.value)}
placeholder="Enter profile name"
/>
</div>
<ScrollArea className="flex-1 h-[330px] overflow-y-hidden">
<div className="flex flex-col justify-center items-center w-full">
<div className="py-4 space-y-6 w-full max-w-md">
{/* Profile Name - Common to both tabs */}
<div className="space-y-2">
<Label htmlFor="profile-name">Profile Name</Label>
<Input
id="profile-name"
value={profileName}
onChange={(e) => setProfileName(e.target.value)}
placeholder="Enter profile name"
/>
</div>
<TabsContent value="regular" className="mt-0 space-y-6">
<div className="space-y-4">
<div className="space-y-2">
<Label>Browser</Label>
<Combobox
options={browserOptions
.filter((browser) =>
supportedBrowsers.includes(browser.value),
)
.map((browser) => {
const IconComponent = getBrowserIcon(browser.value);
return {
value: browser.value,
label: browser.label,
icon: IconComponent,
};
})}
value={selectedBrowser || ""}
onValueChange={(value) =>
setSelectedBrowser(value as BrowserTypeString)
}
placeholder="Select a browser..."
searchPlaceholder="Search browsers..."
/>
</div>
<TabsContent value="regular" className="mt-0 space-y-6">
<div className="space-y-4">
<div className="space-y-2">
<Label>Browser</Label>
<Combobox
options={browserOptions
.filter((browser) =>
supportedBrowsers.includes(browser.value),
)
.map((browser) => {
const IconComponent = getBrowserIcon(browser.value);
return {
value: browser.value,
label: browser.label,
icon: IconComponent,
};
})}
value={selectedBrowser || ""}
onValueChange={(value) =>
setSelectedBrowser(value as BrowserTypeString)
}
placeholder="Select a browser..."
searchPlaceholder="Search browsers..."
/>
</div>
{selectedBrowser && (
<div className="space-y-3">
{!isBrowserCurrentlyDownloading(selectedBrowser) &&
!isBrowserVersionAvailable(selectedBrowser) &&
getBestAvailableVersion(
availableReleaseTypes,
selectedBrowser,
) && (
<div className="flex gap-3 items-center">
<p className="text-sm text-muted-foreground">
{selectedBrowser && (
<div className="space-y-3">
{!isBrowserCurrentlyDownloading(selectedBrowser) &&
!isBrowserVersionAvailable(selectedBrowser) &&
getBestAvailableVersion(
availableReleaseTypes,
selectedBrowser,
) && (
<div className="flex gap-3 items-center">
<p className="text-sm text-muted-foreground">
{(() => {
const bestVersion = getBestAvailableVersion(
availableReleaseTypes,
selectedBrowser,
);
return `${bestVersion?.releaseType === "stable" ? "Latest stable" : "Latest nightly"} version (${bestVersion?.version}) needs to be downloaded`;
})()}
</p>
<LoadingButton
onClick={() => handleDownload(selectedBrowser)}
isLoading={isBrowserCurrentlyDownloading(
selectedBrowser,
)}
size="sm"
disabled={isBrowserCurrentlyDownloading(
selectedBrowser,
)}
>
Download
</LoadingButton>
</div>
)}
{!isBrowserCurrentlyDownloading(selectedBrowser) &&
isBrowserVersionAvailable(selectedBrowser) && (
<div className="text-sm text-muted-foreground">
{(() => {
const bestVersion = getBestAvailableVersion(
availableReleaseTypes,
selectedBrowser,
);
return `${bestVersion?.releaseType === "stable" ? "Latest stable" : "Latest nightly"} version (${bestVersion?.version}) needs to be downloaded`;
return `${bestVersion?.releaseType === "stable" ? "Latest stable" : "Latest nightly"} version (${bestVersion?.version}) is available`;
})()}
</p>
<LoadingButton
onClick={() => handleDownload(selectedBrowser)}
isLoading={isBrowserCurrentlyDownloading(
selectedBrowser,
)}
size="sm"
disabled={isBrowserCurrentlyDownloading(
selectedBrowser,
)}
>
Download
</LoadingButton>
</div>
)}
{!isBrowserCurrentlyDownloading(selectedBrowser) &&
isBrowserVersionAvailable(selectedBrowser) && (
</div>
)}
{isBrowserCurrentlyDownloading(selectedBrowser) && (
<div className="text-sm text-muted-foreground">
{(() => {
const bestVersion = getBestAvailableVersion(
availableReleaseTypes,
selectedBrowser,
);
return ` ${bestVersion?.releaseType === "stable" ? "Latest stable" : "Latest nightly"} version (${bestVersion?.version}) is available`;
return `Downloading ${bestVersion?.releaseType === "stable" ? "stable" : "nightly"} version (${bestVersion?.version})...`;
})()}
</div>
)}
{isBrowserCurrentlyDownloading(selectedBrowser) && (
<div className="text-sm text-muted-foreground">
{(() => {
const bestVersion = getBestAvailableVersion(
availableReleaseTypes,
selectedBrowser,
);
return `Downloading ${bestVersion?.releaseType === "stable" ? "stable" : "nightly"} version (${bestVersion?.version})...`;
})()}
</div>
)}
</div>
</TabsContent>
<TabsContent value="anti-detect" className="mt-0 space-y-6">
<div className="space-y-6">
{/* Camoufox Download Status */}
{!isBrowserCurrentlyDownloading("camoufox") &&
!isBrowserVersionAvailable("camoufox") &&
getBestAvailableVersion(
camoufoxReleaseTypes,
"camoufox",
) && (
<div className="flex gap-3 items-center p-3 rounded-md border">
<p className="text-sm text-muted-foreground">
{(() => {
const bestVersion = getBestAvailableVersion(
camoufoxReleaseTypes,
"camoufox",
);
return `Camoufox ${bestVersion?.releaseType} version (${bestVersion?.version}) needs to be downloaded`;
})()}
</p>
<LoadingButton
onClick={() => handleDownload("camoufox")}
isLoading={isBrowserCurrentlyDownloading(
"camoufox",
)}
size="sm"
disabled={isBrowserCurrentlyDownloading("camoufox")}
>
{isBrowserCurrentlyDownloading("camoufox")
? "Downloading..."
: "Download"}
</LoadingButton>
</div>
)}
</div>
)}
</div>
</TabsContent>
<TabsContent value="anti-detect" className="mt-0 space-y-6">
<div className="space-y-6">
{/* Camoufox Download Status */}
{!isBrowserCurrentlyDownloading("camoufox") &&
!isBrowserVersionAvailable("camoufox") &&
getBestAvailableVersion(
camoufoxReleaseTypes,
"camoufox",
) && (
<div className="flex gap-3 items-center p-3 rounded-md border">
<p className="text-sm text-muted-foreground">
{!isBrowserCurrentlyDownloading("camoufox") &&
isBrowserVersionAvailable("camoufox") && (
<div className="p-3 text-sm rounded-md border text-muted-foreground">
{(() => {
const bestVersion = getBestAvailableVersion(
camoufoxReleaseTypes,
"camoufox",
);
return `Camoufox ${bestVersion?.releaseType} version (${bestVersion?.version}) needs to be downloaded`;
return `Camoufox ${bestVersion?.releaseType} version (${bestVersion?.version}) is available`;
})()}
</p>
<LoadingButton
onClick={() => handleDownload("camoufox")}
isLoading={isBrowserCurrentlyDownloading("camoufox")}
size="sm"
disabled={isBrowserCurrentlyDownloading("camoufox")}
>
{isBrowserCurrentlyDownloading("camoufox")
? "Downloading..."
: "Download"}
</LoadingButton>
</div>
)}
{!isBrowserCurrentlyDownloading("camoufox") &&
isBrowserVersionAvailable("camoufox") && (
</div>
)}
{isBrowserCurrentlyDownloading("camoufox") && (
<div className="p-3 text-sm rounded-md border text-muted-foreground">
{(() => {
const bestVersion = getBestAvailableVersion(
camoufoxReleaseTypes,
"camoufox",
);
return ` Camoufox ${bestVersion?.releaseType} version (${bestVersion?.version}) is available`;
return `Downloading Camoufox ${bestVersion?.releaseType} version (${bestVersion?.version})...`;
})()}
</div>
)}
{isBrowserCurrentlyDownloading("camoufox") && (
<div className="p-3 text-sm text-muted-foreground rounded-md border">
{(() => {
const bestVersion = getBestAvailableVersion(
camoufoxReleaseTypes,
"camoufox",
);
return `Downloading Camoufox ${bestVersion?.releaseType} version (${bestVersion?.version})...`;
})()}
<SharedCamoufoxConfigForm
config={camoufoxConfig}
onConfigChange={updateCamoufoxConfig}
isCreating
/>
</div>
</TabsContent>
{/* Proxy Selection - Common to both tabs - Always visible */}
<div className="space-y-3">
<div className="flex justify-between items-center">
<Label>Proxy</Label>
<RippleButton
size="sm"
variant="outline"
onClick={() => setShowProxyForm(true)}
className="px-2 h-7 text-xs"
>
<GoPlus className="mr-1 w-3 h-3" /> Add Proxy
</RippleButton>
</div>
{storedProxies.length > 0 ? (
<Select
value={selectedProxyId || "none"}
onValueChange={(value) =>
setSelectedProxyId(value === "none" ? undefined : value)
}
>
<SelectTrigger>
<SelectValue placeholder="No proxy" />
</SelectTrigger>
<SelectContent>
<SelectItem value="none">No proxy</SelectItem>
{storedProxies.map((proxy) => (
<SelectItem key={proxy.id} value={proxy.id}>
{proxy.name}
</SelectItem>
))}
</SelectContent>
</Select>
) : (
<div className="flex gap-3 items-center p-3 text-sm rounded-md border text-muted-foreground">
No proxies available. Add one to route this profile's
traffic.
</div>
)}
<SharedCamoufoxConfigForm
config={camoufoxConfig}
onConfigChange={updateCamoufoxConfig}
isCreating
/>
</div>
</TabsContent>
{/* Proxy Selection - Common to both tabs - Compact without card */}
{storedProxies.length > 0 && (
<div className="space-y-3">
<Label>Proxy</Label>
<Select
value={selectedProxyId || "none"}
onValueChange={(value) =>
setSelectedProxyId(value === "none" ? undefined : value)
}
>
<SelectTrigger>
<SelectValue placeholder="No proxy" />
</SelectTrigger>
<SelectContent>
<SelectItem value="none">No proxy</SelectItem>
{storedProxies.map((proxy) => (
<SelectItem key={proxy.id} value={proxy.id}>
{proxy.name}{" "}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
</div>
</div>
</ScrollArea>
<DialogFooter className="flex-shrink-0 pt-4 border-t">
<Button variant="outline" onClick={handleClose}>
<RippleButton variant="outline" onClick={handleClose}>
Cancel
</Button>
</RippleButton>
<LoadingButton
onClick={handleCreate}
isLoading={isCreating}
disabled={isCreateDisabled()}
>
Create Profile
Create
</LoadingButton>
</DialogFooter>
</Tabs>
</DialogContent>
<ProxyFormDialog
isOpen={showProxyForm}
onClose={() => setShowProxyForm(false)}
onSave={(proxy) => {
setStoredProxies((prev) => [...prev, proxy]);
setSelectedProxyId(proxy.id);
}}
/>
</Dialog>
);
}
+14 -9
View File
@@ -1,6 +1,5 @@
"use client";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
@@ -9,6 +8,8 @@ import {
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { LoadingButton } from "./loading-button";
import { RippleButton } from "./ui/ripple";
interface DeleteConfirmationDialogProps {
isOpen: boolean;
@@ -59,16 +60,20 @@ export function DeleteConfirmationDialog({
)}
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={onClose} disabled={isLoading}>
Cancel
</Button>
<Button
variant="destructive"
onClick={() => void handleConfirm()}
<RippleButton
variant="outline"
onClick={onClose}
disabled={isLoading}
>
{isLoading ? "Deleting..." : confirmButtonText}
</Button>
Cancel
</RippleButton>
<LoadingButton
variant="destructive"
onClick={() => void handleConfirm()}
isLoading={isLoading}
>
{confirmButtonText}
</LoadingButton>
</DialogFooter>
</DialogContent>
</Dialog>
+7 -3
View File
@@ -4,7 +4,6 @@ import { invoke } from "@tauri-apps/api/core";
import { useCallback, useEffect, useState } from "react";
import { toast } from "sonner";
import { LoadingButton } from "@/components/loading-button";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
@@ -17,6 +16,7 @@ import { Label } from "@/components/ui/label";
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
import { ScrollArea } from "@/components/ui/scroll-area";
import type { BrowserProfile, ProfileGroup } from "@/types";
import { RippleButton } from "./ui/ripple";
interface DeleteGroupDialogProps {
isOpen: boolean;
@@ -188,9 +188,13 @@ export function DeleteGroupDialog({
</div>
<DialogFooter>
<Button variant="outline" onClick={handleClose} disabled={isDeleting}>
<RippleButton
variant="outline"
onClick={handleClose}
disabled={isDeleting}
>
Cancel
</Button>
</RippleButton>
<LoadingButton
variant="destructive"
isLoading={isDeleting}
+7 -3
View File
@@ -4,7 +4,6 @@ import { invoke } from "@tauri-apps/api/core";
import { useCallback, useEffect, useState } from "react";
import { toast } from "sonner";
import { LoadingButton } from "@/components/loading-button";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
@@ -16,6 +15,7 @@ import {
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import type { ProfileGroup } from "@/types";
import { RippleButton } from "./ui/ripple";
interface EditGroupDialogProps {
isOpen: boolean;
@@ -108,9 +108,13 @@ export function EditGroupDialog({
</div>
<DialogFooter>
<Button variant="outline" onClick={handleClose} disabled={isUpdating}>
<RippleButton
variant="outline"
onClick={handleClose}
disabled={isUpdating}
>
Cancel
</Button>
</RippleButton>
<LoadingButton
isLoading={isUpdating}
onClick={() => void handleUpdate()}
+30 -4
View File
@@ -2,9 +2,10 @@
import { invoke } from "@tauri-apps/api/core";
import { useCallback, useEffect, useState } from "react";
import { GoPlus } from "react-icons/go";
import { toast } from "sonner";
import { CreateGroupDialog } from "@/components/create-group-dialog";
import { LoadingButton } from "@/components/loading-button";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
@@ -22,6 +23,7 @@ import {
SelectValue,
} from "@/components/ui/select";
import type { ProfileGroup } from "@/types";
import { RippleButton } from "./ui/ripple";
interface GroupAssignmentDialogProps {
isOpen: boolean;
@@ -41,6 +43,7 @@ export function GroupAssignmentDialog({
const [isLoading, setIsLoading] = useState(false);
const [isAssigning, setIsAssigning] = useState(false);
const [error, setError] = useState<string | null>(null);
const [createDialogOpen, setCreateDialogOpen] = useState(false);
const loadGroups = useCallback(async () => {
setIsLoading(true);
@@ -126,7 +129,17 @@ export function GroupAssignmentDialog({
</div>
<div className="space-y-2">
<Label htmlFor="group-select">Assign to Group:</Label>
<div className="flex justify-between items-center">
<Label htmlFor="group-select">Assign to Group:</Label>
<RippleButton
size="sm"
variant="outline"
className="h-7 px-2 text-xs"
onClick={() => setCreateDialogOpen(true)}
>
<GoPlus className="mr-1 w-3 h-3" /> Create Group
</RippleButton>
</div>
{isLoading ? (
<div className="text-sm text-muted-foreground">
Loading groups...
@@ -161,9 +174,13 @@ export function GroupAssignmentDialog({
</div>
<DialogFooter>
<Button variant="outline" onClick={onClose} disabled={isAssigning}>
<RippleButton
variant="outline"
onClick={onClose}
disabled={isAssigning}
>
Cancel
</Button>
</RippleButton>
<LoadingButton
isLoading={isAssigning}
onClick={() => void handleAssign()}
@@ -173,6 +190,15 @@ export function GroupAssignmentDialog({
</LoadingButton>
</DialogFooter>
</DialogContent>
<CreateGroupDialog
isOpen={createDialogOpen}
onClose={() => setCreateDialogOpen(false)}
onGroupCreated={(group) => {
setGroups((prev) => [...prev, group]);
setSelectedGroupId(group.id);
setCreateDialogOpen(false);
}}
/>
</Dialog>
);
}
+6 -5
View File
@@ -26,6 +26,7 @@ import {
TableRow,
} from "@/components/ui/table";
import type { ProfileGroup } from "@/types";
import { RippleButton } from "./ui/ripple";
interface GroupManagementDialogProps {
isOpen: boolean;
@@ -119,14 +120,14 @@ export function GroupManagementDialog({
{/* Create new group button */}
<div className="flex justify-between items-center">
<Label>Groups</Label>
<Button
<RippleButton
size="sm"
onClick={() => setCreateDialogOpen(true)}
className="flex gap-2 items-center"
>
<GoPlus className="w-4 h-4" />
Create Group
</Button>
Create
</RippleButton>
</div>
{error && (
@@ -187,9 +188,9 @@ export function GroupManagementDialog({
</div>
<DialogFooter>
<Button variant="outline" onClick={onClose}>
<RippleButton variant="outline" onClick={onClose}>
Close
</Button>
</RippleButton>
</DialogFooter>
</DialogContent>
</Dialog>
+8 -7
View File
@@ -11,6 +11,7 @@ import {
DropdownMenuItem,
DropdownMenuTrigger,
} from "./ui/dropdown-menu";
import { RippleButton } from "./ui/ripple";
import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip";
type Props = {
@@ -34,7 +35,7 @@ const HomeHeader = ({
onImportProfileDialogOpen,
onCreateProfileDialogOpen,
}: Props) => {
const handleLogoClick = () => {
const _handleLogoClick = () => {
// Trigger the same URL handling logic as if the URL came from the system
const event = new CustomEvent("url-open-request", {
detail: "https://donutbrowser.com",
@@ -46,11 +47,11 @@ const HomeHeader = ({
<div className="flex items-center gap-3">
<button
type="button"
onClick={handleLogoClick}
className="p-1 cursor-pointer"
title="Open donutbrowser.com"
onClick={_handleLogoClick}
>
<Logo className="w-10 h-10" />
<Logo className="w-10 h-10 transition-transform duration-300 ease-out will-change-transform hover:scale-110" />
</button>
{selectedProfiles.length > 0 ? (
<div className="flex items-center gap-3">
@@ -59,7 +60,7 @@ const HomeHeader = ({
{selectedProfiles.length !== 1 ? "s" : ""} selected
</span>
<div className="flex gap-2">
<Button
<RippleButton
variant="outline"
size="sm"
onClick={onBulkGroupAssignment}
@@ -67,8 +68,8 @@ const HomeHeader = ({
>
<LuUsers className="w-4 h-4" />
Assign to Group
</Button>
<Button
</RippleButton>
<RippleButton
variant="destructive"
size="sm"
onClick={onBulkDelete}
@@ -76,7 +77,7 @@ const HomeHeader = ({
>
<LuTrash2 className="w-4 h-4" />
Delete Selected
</Button>
</RippleButton>
</div>
</div>
) : (
+10 -9
View File
@@ -26,6 +26,7 @@ import {
import { useBrowserSupport } from "@/hooks/use-browser-support";
import { getBrowserDisplayName, getBrowserIcon } from "@/lib/browser-utils";
import type { DetectedProfile } from "@/types";
import { RippleButton } from "./ui/ripple";
interface ImportProfileDialogProps {
isOpen: boolean;
@@ -242,7 +243,7 @@ export function ImportProfileDialog({
);
if (profile) {
const browserName = getBrowserDisplayName(profile.browser);
const defaultName = `Imported ${browserName} Profile`;
const defaultName = `Old ${browserName}`;
setAutoDetectProfileName(defaultName);
}
}
@@ -268,7 +269,7 @@ export function ImportProfileDialog({
<div className="overflow-y-auto flex-1 space-y-6 min-h-0">
{/* Mode Selection */}
<div className="flex gap-2">
<Button
<RippleButton
variant={importMode === "auto-detect" ? "default" : "outline"}
onClick={() => {
setImportMode("auto-detect");
@@ -277,8 +278,8 @@ export function ImportProfileDialog({
disabled={isLoading}
>
Auto-Detect
</Button>
<Button
</RippleButton>
<RippleButton
variant={importMode === "manual" ? "default" : "outline"}
onClick={() => {
setImportMode("manual");
@@ -287,7 +288,7 @@ export function ImportProfileDialog({
disabled={isLoading}
>
Manual Import
</Button>
</RippleButton>
</div>
{/* Auto-Detect Mode */}
@@ -479,9 +480,9 @@ export function ImportProfileDialog({
</div>
<DialogFooter className="flex-shrink-0">
<Button variant="outline" onClick={handleClose}>
<RippleButton variant="outline" onClick={handleClose}>
Cancel
</Button>
</RippleButton>
{importMode === "auto-detect" ? (
<LoadingButton
isLoading={isImporting}
@@ -494,7 +495,7 @@ export function ImportProfileDialog({
isLoading
}
>
Import Profile
Import
</LoadingButton>
) : (
<LoadingButton
@@ -508,7 +509,7 @@ export function ImportProfileDialog({
!manualProfileName.trim()
}
>
Import Profile
Import
</LoadingButton>
)}
</DialogFooter>
+9 -2
View File
@@ -1,5 +1,8 @@
import { LuLoaderCircle } from "react-icons/lu";
import { type ButtonProps, Button as UIButton } from "./ui/button";
import {
type RippleButtonProps as ButtonProps,
RippleButton as UIButton,
} from "./ui/ripple";
type Props = ButtonProps & {
isLoading: boolean;
@@ -7,7 +10,11 @@ type Props = ButtonProps & {
};
export const LoadingButton = ({ isLoading, ...props }: Props) => {
return (
<UIButton className="grid place-items-center" {...props}>
<UIButton
className="grid place-items-center"
{...props}
disabled={props.disabled || isLoading}
>
{isLoading ? (
<LuLoaderCircle className="h-4 w-4 animate-spin" />
) : (
+3 -3
View File
@@ -3,7 +3,6 @@
import { useEffect, useState } from "react";
import { BsCamera, BsMic } from "react-icons/bs";
import { LoadingButton } from "@/components/loading-button";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
@@ -15,6 +14,7 @@ import {
import type { PermissionType } from "@/hooks/use-permissions";
import { usePermissions } from "@/hooks/use-permissions";
import { showErrorToast, showSuccessToast } from "@/lib/toast-utils";
import { RippleButton } from "./ui/ripple";
interface PermissionDialogProps {
isOpen: boolean;
@@ -148,9 +148,9 @@ export function PermissionDialog({
</div>
<DialogFooter className="gap-2">
<Button variant="outline" onClick={onClose}>
<RippleButton variant="outline" onClick={onClose}>
{isCurrentPermissionGranted ? "Done" : "Cancel"}
</Button>
</RippleButton>
{!isCurrentPermissionGranted && (
<LoadingButton
+18 -27
View File
@@ -27,8 +27,6 @@ import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { ScrollArea } from "@/components/ui/scroll-area";
@@ -57,6 +55,7 @@ import { cn } from "@/lib/utils";
import type { BrowserProfile, StoredProxy } from "@/types";
import { Input } from "./ui/input";
import { Label } from "./ui/label";
import { RippleButton } from "./ui/ripple";
interface ProfilesDataTableProps {
data: BrowserProfile[];
@@ -548,7 +547,7 @@ export function ProfilesDataTable({
<Tooltip>
<TooltipTrigger asChild>
<span className="inline-flex">
<Button
<RippleButton
variant={isRunning ? "destructive" : "default"}
size="sm"
disabled={!canLaunch || isLaunching || isStopping}
@@ -567,7 +566,7 @@ export function ProfilesDataTable({
) : (
"Launch"
)}
</Button>
</RippleButton>
</span>
</TooltipTrigger>
{tooltipContent && (
@@ -748,6 +747,10 @@ export function ProfilesDataTable({
browserState.isClient && runningProfiles.has(profile.name);
const isBrowserUpdating =
browserState.isClient && isUpdating(profile.browser);
const isLaunching = launchingProfiles.has(profile.name);
const isStopping = stoppingProfiles.has(profile.name);
const isDisabled =
isRunning || isLaunching || isStopping || isBrowserUpdating;
return (
<div className="flex justify-end items-center">
@@ -763,15 +766,11 @@ export function ProfilesDataTable({
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuLabel>Actions</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={() => {
onProxySettings(profile);
}}
disabled={
!browserState.isClient || isBrowserUpdating || isRunning
}
disabled={isDisabled}
>
Configure Proxy
</DropdownMenuItem>
@@ -781,9 +780,7 @@ export function ProfilesDataTable({
onAssignProfilesToGroup([profile.name]);
}
}}
disabled={
!browserState.isClient || isBrowserUpdating || isRunning
}
disabled={isDisabled}
>
Assign to Group
</DropdownMenuItem>
@@ -792,9 +789,7 @@ export function ProfilesDataTable({
onClick={() => {
onConfigureCamoufox(profile);
}}
disabled={
!browserState.isClient || isRunning || isBrowserUpdating
}
disabled={isDisabled}
>
Configure Camoufox
</DropdownMenuItem>
@@ -806,9 +801,7 @@ export function ProfilesDataTable({
onClick={() => {
onChangeVersion(profile);
}}
disabled={
!browserState.isClient || isRunning || isBrowserUpdating
}
disabled={isDisabled}
>
Switch Release
</DropdownMenuItem>
@@ -818,9 +811,7 @@ export function ProfilesDataTable({
setProfileToRename(profile);
setNewProfileName(profile.name);
}}
disabled={
!browserState.isClient || isRunning || isBrowserUpdating
}
disabled={isDisabled}
>
Rename
</DropdownMenuItem>
@@ -828,9 +819,7 @@ export function ProfilesDataTable({
onClick={() => {
setProfileToDelete(profile);
}}
disabled={
!browserState.isClient || isRunning || isBrowserUpdating
}
disabled={isDisabled}
>
Delete
</DropdownMenuItem>
@@ -971,15 +960,17 @@ export function ProfilesDataTable({
)}
</div>
<DialogFooter>
<Button
<RippleButton
variant="outline"
onClick={() => {
setProfileToRename(null);
}}
>
Cancel
</Button>
<Button onClick={() => void handleRename()}>Save</Button>
</RippleButton>
<RippleButton onClick={() => void handleRename()}>
Save
</RippleButton>
</DialogFooter>
</DialogContent>
</Dialog>
+5 -5
View File
@@ -6,7 +6,6 @@ import { LuCopy } from "react-icons/lu";
import { toast } from "sonner";
import { LoadingButton } from "@/components/loading-button";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
@@ -30,6 +29,7 @@ import {
import { useBrowserState } from "@/hooks/use-browser-state";
import { getBrowserDisplayName, getBrowserIcon } from "@/lib/browser-utils";
import type { BrowserProfile, StoredProxy } from "@/types";
import { RippleButton } from "./ui/ripple";
interface ProfileSelectorDialogProps {
isOpen: boolean;
@@ -196,7 +196,7 @@ export function ProfileSelectorDialog({
<div className="space-y-2">
<div className="flex justify-between items-center">
<Label className="text-sm font-medium">Opening URL:</Label>
<Button
<RippleButton
variant="outline"
size="sm"
onClick={() => void handleCopyUrl()}
@@ -204,7 +204,7 @@ export function ProfileSelectorDialog({
>
<LuCopy className="w-3 h-3" />
Copy
</Button>
</RippleButton>
</div>
<div className="p-2 text-sm break-all rounded bg-muted">
{url}
@@ -312,9 +312,9 @@ export function ProfileSelectorDialog({
</div>
<DialogFooter>
<Button variant="outline" onClick={handleCancel}>
<RippleButton variant="outline" onClick={handleCancel}>
Cancel
</Button>
</RippleButton>
<Tooltip>
<TooltipTrigger asChild>
<span className="inline-flex">
+3 -3
View File
@@ -4,7 +4,6 @@ import { invoke } from "@tauri-apps/api/core";
import { useCallback, useEffect, useState } from "react";
import { toast } from "sonner";
import { LoadingButton } from "@/components/loading-button";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
@@ -22,6 +21,7 @@ import {
SelectValue,
} from "@/components/ui/select";
import type { StoredProxy } from "@/types";
import { RippleButton } from "./ui/ripple";
interface ProxyFormData {
name: string;
@@ -264,13 +264,13 @@ export function ProxyFormDialog({
</div>
<DialogFooter>
<Button
<RippleButton
variant="outline"
onClick={handleClose}
disabled={isSubmitting}
>
Cancel
</Button>
</RippleButton>
<LoadingButton
isLoading={isSubmitting}
onClick={handleSubmit}
+6 -5
View File
@@ -20,6 +20,7 @@ import {
} from "@/components/ui/tooltip";
import { trimName } from "@/lib/name-utils";
import type { StoredProxy } from "@/types";
import { RippleButton } from "./ui/ripple";
interface ProxyManagementDialogProps {
isOpen: boolean;
@@ -124,13 +125,13 @@ export function ProxyManagementDialog({
profiles
</p>
</div>
<Button
<RippleButton
onClick={handleCreateProxy}
className="flex gap-2 items-center"
>
<FiPlus className="w-4 h-4" />
Create Proxy
</Button>
</RippleButton>
</div>
{/* Proxy List - Scrollable */}
@@ -150,10 +151,10 @@ export function ProxyManagementDialog({
<p className="mb-4 text-sm text-muted-foreground">
Create your first proxy configuration to get started
</p>
<Button variant="outline" onClick={handleCreateProxy}>
<RippleButton variant="outline" onClick={handleCreateProxy}>
<FiPlus className="mr-2 w-4 h-4" />
Create First Proxy
</Button>
</RippleButton>
</div>
) : (
<div className="overflow-y-auto pr-2 space-y-2 h-full">
@@ -221,7 +222,7 @@ export function ProxyManagementDialog({
</div>
<DialogFooter className="flex-shrink-0">
<Button onClick={onClose}>Close</Button>
<RippleButton onClick={onClose}>Close</RippleButton>
</DialogFooter>
</DialogContent>
</Dialog>
+8 -7
View File
@@ -23,6 +23,7 @@ import {
} from "@/components/ui/tooltip";
import { cn } from "@/lib/utils";
import type { StoredProxy } from "@/types";
import { RippleButton } from "./ui/ripple";
interface ProxySettingsDialogProps {
isOpen: boolean;
@@ -142,15 +143,15 @@ export function ProxySettingsDialog({
</Label>
<Tooltip>
<TooltipTrigger asChild>
<Button
<RippleButton
variant="outline"
size="sm"
onClick={handleCreateProxy}
className="flex gap-2 items-center"
>
<FiPlus className="w-4 h-4" />
Create New
</Button>
Create
</RippleButton>
</TooltipTrigger>
<TooltipContent>
<p>Create a new proxy configuration</p>
@@ -263,12 +264,12 @@ export function ProxySettingsDialog({
</div>
<DialogFooter>
<Button variant="outline" onClick={onClose}>
<RippleButton variant="outline" onClick={onClose}>
Cancel
</Button>
<Button onClick={handleSave} disabled={!hasChanged()}>
</RippleButton>
<RippleButton onClick={handleSave} disabled={!hasChanged()}>
Save
</Button>
</RippleButton>
</DialogFooter>
</DialogContent>
</Dialog>
+3 -3
View File
@@ -4,7 +4,6 @@ import { useState } from "react";
import { LuCheck, LuChevronsUpDown, LuDownload } from "react-icons/lu";
import { LoadingButton } from "@/components/loading-button";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Command,
CommandEmpty,
@@ -19,6 +18,7 @@ import {
} from "@/components/ui/popover";
import { cn } from "@/lib/utils";
import type { BrowserReleaseTypes } from "@/types";
import { RippleButton } from "./ui/ripple";
interface ReleaseTypeSelectorProps {
selectedReleaseType: "stable" | "nightly" | null;
@@ -85,7 +85,7 @@ export function ReleaseTypeSelector({
{showDropdown ? (
<Popover open={popoverOpen} onOpenChange={setPopoverOpen} modal={true}>
<PopoverTrigger asChild>
<Button
<RippleButton
variant="outline"
role="combobox"
aria-expanded={popoverOpen}
@@ -93,7 +93,7 @@ export function ReleaseTypeSelector({
>
{selectedDisplayText}
<LuChevronsUpDown className="ml-2 w-4 h-4 opacity-50 shrink-0" />
</Button>
</RippleButton>
</PopoverTrigger>
<PopoverContent className="p-0">
<Command>
+3 -3
View File
@@ -7,7 +7,6 @@ import { useCallback, useEffect, useState } from "react";
import { BsCamera, BsMic } from "react-icons/bs";
import { LoadingButton } from "@/components/loading-button";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Dialog,
@@ -33,6 +32,7 @@ import {
showSuccessToast,
showUnifiedVersionUpdateToast,
} from "@/lib/toast-utils";
import { RippleButton } from "./ui/ripple";
interface AppSettings {
set_as_default_browser: boolean;
@@ -529,9 +529,9 @@ export function SettingsDialog({ isOpen, onClose }: SettingsDialogProps) {
</div>
<DialogFooter className="flex-shrink-0">
<Button variant="outline" onClick={onClose}>
<RippleButton variant="outline" onClick={onClose}>
Cancel
</Button>
</RippleButton>
<LoadingButton
isLoading={isSaving}
onClick={() => {
+146
View File
@@ -0,0 +1,146 @@
"use client";
import { cva, type VariantProps } from "class-variance-authority";
import { type HTMLMotionProps, motion, type Transition } from "motion/react";
import * as React from "react";
import { cn } from "@/lib/utils";
const buttonVariants = cva(
"relative overflow-hidden cursor-pointer inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-lg text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
destructive:
"bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
outline:
"border bg-background hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost:
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
},
size: {
default: "h-10 px-4 py-2 has-[>svg]:px-3",
sm: "h-9 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
lg: "h-11 px-8 has-[>svg]:px-6",
icon: "size-10",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
},
);
const rippleVariants = cva("absolute rounded-full size-5 pointer-events-none", {
variants: {
variant: {
default: "bg-primary-foreground",
destructive: "bg-destructive",
outline: "bg-input",
secondary: "bg-secondary",
ghost: "bg-accent",
},
},
defaultVariants: {
variant: "default",
},
});
type Ripple = {
id: number;
x: number;
y: number;
};
type RippleButtonProps = HTMLMotionProps<"button"> & {
children: React.ReactNode;
rippleClassName?: string;
scale?: number;
transition?: Transition;
} & VariantProps<typeof buttonVariants>;
function RippleButton({
ref,
children,
onClick,
className,
rippleClassName,
variant,
size,
scale = 10,
transition = { duration: 0.6, ease: "easeOut" },
...props
}: RippleButtonProps) {
const [ripples, setRipples] = React.useState<Ripple[]>([]);
const buttonRef = React.useRef<HTMLButtonElement>(null);
React.useImperativeHandle(ref, () => buttonRef.current as HTMLButtonElement);
const createRipple = React.useCallback(
(event: React.MouseEvent<HTMLButtonElement>) => {
const button = buttonRef.current;
if (!button) return;
const rect = button.getBoundingClientRect();
const x = event.clientX - rect.left;
const y = event.clientY - rect.top;
const newRipple: Ripple = {
id: Date.now(),
x,
y,
};
setRipples((prev) => [...prev, newRipple]);
setTimeout(() => {
setRipples((prev) => prev.filter((r) => r.id !== newRipple.id));
}, 600);
},
[],
);
const handleClick = React.useCallback(
(event: React.MouseEvent<HTMLButtonElement>) => {
createRipple(event);
if (onClick) {
onClick(event);
}
},
[createRipple, onClick],
);
return (
<motion.button
ref={buttonRef}
data-slot="ripple-button"
onClick={handleClick}
whileTap={{ scale: 0.95 }}
whileHover={{ scale: 1.05 }}
className={cn(buttonVariants({ variant, size, className }))}
{...props}
>
{children}
{ripples.map((ripple) => (
<motion.span
key={ripple.id}
initial={{ scale: 0, opacity: 0.5 }}
animate={{ scale, opacity: 0 }}
transition={transition}
className={cn(
rippleVariants({ variant, className: rippleClassName }),
)}
style={{
top: ripple.y - 10,
left: ripple.x - 10,
}}
/>
))}
</motion.button>
);
}
export { RippleButton, type RippleButtonProps };
+3 -2
View File
@@ -12,8 +12,8 @@ const Toaster = ({ ...props }: ToasterProps) => {
className="toaster group"
style={
{
"--normal-bg": "var(--popover)",
"--normal-text": "var(--popover-foreground)",
"--normal-bg": "var(--card)",
"--normal-text": "var(--card-foreground)",
"--normal-border": "var(--border)",
zIndex: 99999,
} as React.CSSProperties
@@ -22,6 +22,7 @@ const Toaster = ({ ...props }: ToasterProps) => {
style: {
zIndex: 99999,
pointerEvents: "auto",
backdropFilter: "saturate(1.2)",
},
}}
{...props}
@@ -156,6 +156,7 @@ export function useAppUpdateNotifications() {
style: {
zIndex: 99999, // Ensure app updates appear above dialogs
pointerEvents: "auto", // Ensure app updates remain interactive
marginTop: "16px", // slightly lower on macOS-like top controls
},
},
);
+11 -2
View File
@@ -210,9 +210,18 @@ export function useBrowserDownload() {
if (!suppressNotifications) {
// Dismiss any existing download toast and show error
dismissToast(`download-${browserStr}-${version}`);
let errorMessage = "Unknown error occurred";
if (error instanceof Error) {
errorMessage = error.message;
} else if (typeof error === "string") {
errorMessage = error;
} else if (error && typeof error === "object" && "message" in error) {
errorMessage = String(error.message);
}
showErrorToast(`Failed to download ${browserName} ${version}`, {
description:
error instanceof Error ? error.message : "Unknown error occurred",
description: errorMessage,
});
}
throw error;
+25 -6
View File
@@ -245,11 +245,22 @@ export function useVersionUpdater() {
}
} catch (error) {
console.error("Failed to handle browser auto-update:", error);
let errorMessage = "Unknown error occurred";
if (error instanceof Error) {
errorMessage = error.message;
} else if (typeof error === "string") {
errorMessage = error;
} else if (
error &&
typeof error === "object" &&
"message" in error
) {
errorMessage = String(error.message);
}
showErrorToast(`Failed to auto-update ${browserDisplayName}`, {
description:
error instanceof Error
? error.message
: "Unknown error occurred",
description: errorMessage,
duration: 8000,
});
} finally {
@@ -336,9 +347,17 @@ export function useVersionUpdater() {
return results;
} catch (error) {
console.error("Failed to trigger manual update:", error);
let errorMessage = "Unknown error occurred";
if (error instanceof Error) {
errorMessage = error.message;
} else if (typeof error === "string") {
errorMessage = error;
} else if (error && typeof error === "object" && "message" in error) {
errorMessage = String(error.message);
}
showErrorToast("Failed to update browser versions", {
description:
error instanceof Error ? error.message : "Unknown error occurred",
description: errorMessage,
duration: 4000,
});
throw error;