diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 9f076ab..083271f 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -10,7 +10,28 @@ env: TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }} jobs: + # Wait for linting jobs to complete first + wait-for-linting: + runs-on: ubuntu-latest + steps: + - name: Wait for Rust linting to complete + uses: lewagon/wait-on-check-action@v1.3.4 + with: + ref: ${{ github.ref }} + check-name: "Lint Rust" + repo-token: ${{ secrets.GITHUB_TOKEN }} + wait-interval: 10 + + - name: Wait for JavaScript linting to complete + uses: lewagon/wait-on-check-action@v1.3.4 + with: + ref: ${{ github.ref }} + check-name: "Lint Node.js" + repo-token: ${{ secrets.GITHUB_TOKEN }} + wait-interval: 10 + release: + needs: wait-for-linting permissions: contents: write strategy: diff --git a/.github/workflows/rolling-release.yml b/.github/workflows/rolling-release.yml index 8db8c3a..86d0171 100644 --- a/.github/workflows/rolling-release.yml +++ b/.github/workflows/rolling-release.yml @@ -10,7 +10,28 @@ env: TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }} jobs: + # Wait for linting jobs to complete first + wait-for-linting: + runs-on: ubuntu-latest + steps: + - name: Wait for Rust linting to complete + uses: lewagon/wait-on-check-action@v1.3.4 + with: + ref: ${{ github.ref }} + check-name: "Lint Rust" + repo-token: ${{ secrets.GITHUB_TOKEN }} + wait-interval: 10 + + - name: Wait for JavaScript linting to complete + uses: lewagon/wait-on-check-action@v1.3.4 + with: + ref: ${{ github.ref }} + check-name: "Lint Node.js" + repo-token: ${{ secrets.GITHUB_TOKEN }} + wait-interval: 10 + rolling-release: + needs: wait-for-linting permissions: contents: write strategy: diff --git a/.vscode/settings.json b/.vscode/settings.json index cfa1d38..6c154a4 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -9,11 +9,14 @@ "ntlm", "propertylist", "rlib", + "rustc", "serde", + "shadcn", "signon", "sspi", "staticlib", "sysinfo", - "systempreferences" + "systempreferences", + "turbopack" ] } diff --git a/biome.json b/biome.json new file mode 100644 index 0000000..078b377 --- /dev/null +++ b/biome.json @@ -0,0 +1,56 @@ +{ + "$schema": "https://biomejs.dev/schemas/1.9.4/schema.json", + "vcs": { + "enabled": false, + "clientKind": "git", + "useIgnoreFile": false + }, + "files": { + "ignoreUnknown": false, + "ignore": [] + }, + "formatter": { + "enabled": true, + "indentStyle": "space", + "indentWidth": 2 + }, + "organizeImports": { + "enabled": true + }, + "linter": { + "enabled": true, + "rules": { + "recommended": true, + "correctness": { + "useHookAtTopLevel": "error" + }, + "nursery": { + "useGoogleFontDisplay": "error", + "noDocumentImportInPage": "error", + "noHeadElement": "error", + "noHeadImportInDocument": "error", + "noImgElement": "off", + "useComponentExportOnlyModules": { + "level": "error", + "options": { + "allowExportNames": ["metadata", "badgeVariants", "buttonVariants"] + } + } + }, + "a11y": { + "useSemanticElements": "off" + } + } + }, + "css": { + "formatter": { + "quoteStyle": "double" + } + }, + "javascript": { + "formatter": { + "quoteStyle": "double" + }, + "globals": [] + } +} diff --git a/package.json b/package.json index f096b2c..3453925 100644 --- a/package.json +++ b/package.json @@ -7,13 +7,14 @@ "dev": "next dev --turbopack", "build": "next build", "start": "next start", - "lint": "prettier --check src/ && tsc --noEmit && next lint", + "lint": "biome ci src/ && tsc --noEmit && next lint", "lint:rust": "cd src-tauri && cargo clippy --all-targets --all-features -- -D warnings -D clippy::all", "tauri": "tauri", "shadcn:add": "pnpm dlx shadcn@latest add", "prepare": "husky", - "format:js": "prettier --write src/", + "format:js": "biome check src/ --fix && prettier --write src/", "format:rust": "cd src-tauri && cargo fmt --all", + "format:biome": "biome check src/ --fix", "format": "pnpm format:js && pnpm format:rust", "prettier": "prettier --write" }, @@ -44,6 +45,7 @@ "tailwind-merge": "^3.3.0" }, "devDependencies": { + "@biomejs/biome": "1.9.4", "@eslint/eslintrc": "^3.3.1", "@eslint/js": "^9.27.0", "@next/eslint-plugin-next": "^15.3.2", @@ -69,6 +71,7 @@ "packageManager": "pnpm@10.11.0+sha512.6540583f41cc5f628eb3d9773ecee802f4f9ef9923cc45b69890fb47991d4b092964694ec3a4f738a420c918a333062c8b925d312f42e4f0c263eb603551f977", "lint-staged": { "src/**/*.{js,jsx,ts,tsx,json,css,md}": [ + "biome check --fix", "prettier --write" ], "src-tauri/**/*.rs": [ diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b9b055a..bfdb843 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -81,6 +81,9 @@ importers: specifier: ^3.3.0 version: 3.3.0 devDependencies: + '@biomejs/biome': + specifier: 1.9.4 + version: 1.9.4 '@eslint/eslintrc': specifier: ^3.3.1 version: 3.3.1 @@ -234,6 +237,59 @@ packages: resolution: {integrity: sha512-+EzkxvLNfiUeKMgy/3luqfsCWFRXLb7U6wNQTk60tovuckwB15B191tJWvpp4HjiQWdJkCxO3Wbvc6jlk3Xb2Q==} engines: {node: '>=6.9.0'} + '@biomejs/biome@1.9.4': + resolution: {integrity: sha512-1rkd7G70+o9KkTn5KLmDYXihGoTaIGO9PIIN2ZB7UJxFrWw04CZHPYiMRjYsaDvVV7hP1dYNRLxSANLaBFGpog==} + engines: {node: '>=14.21.3'} + hasBin: true + + '@biomejs/cli-darwin-arm64@1.9.4': + resolution: {integrity: sha512-bFBsPWrNvkdKrNCYeAp+xo2HecOGPAy9WyNyB/jKnnedgzl4W4Hb9ZMzYNbf8dMCGmUdSavlYHiR01QaYR58cw==} + engines: {node: '>=14.21.3'} + cpu: [arm64] + os: [darwin] + + '@biomejs/cli-darwin-x64@1.9.4': + resolution: {integrity: sha512-ngYBh/+bEedqkSevPVhLP4QfVPCpb+4BBe2p7Xs32dBgs7rh9nY2AIYUL6BgLw1JVXV8GlpKmb/hNiuIxfPfZg==} + engines: {node: '>=14.21.3'} + cpu: [x64] + os: [darwin] + + '@biomejs/cli-linux-arm64-musl@1.9.4': + resolution: {integrity: sha512-v665Ct9WCRjGa8+kTr0CzApU0+XXtRgwmzIf1SeKSGAv+2scAlW6JR5PMFo6FzqqZ64Po79cKODKf3/AAmECqA==} + engines: {node: '>=14.21.3'} + cpu: [arm64] + os: [linux] + + '@biomejs/cli-linux-arm64@1.9.4': + resolution: {integrity: sha512-fJIW0+LYujdjUgJJuwesP4EjIBl/N/TcOX3IvIHJQNsAqvV2CHIogsmA94BPG6jZATS4Hi+xv4SkBBQSt1N4/g==} + engines: {node: '>=14.21.3'} + cpu: [arm64] + os: [linux] + + '@biomejs/cli-linux-x64-musl@1.9.4': + resolution: {integrity: sha512-gEhi/jSBhZ2m6wjV530Yy8+fNqG8PAinM3oV7CyO+6c3CEh16Eizm21uHVsyVBEB6RIM8JHIl6AGYCv6Q6Q9Tg==} + engines: {node: '>=14.21.3'} + cpu: [x64] + os: [linux] + + '@biomejs/cli-linux-x64@1.9.4': + resolution: {integrity: sha512-lRCJv/Vi3Vlwmbd6K+oQ0KhLHMAysN8lXoCI7XeHlxaajk06u7G+UsFSO01NAs5iYuWKmVZjmiOzJ0OJmGsMwg==} + engines: {node: '>=14.21.3'} + cpu: [x64] + os: [linux] + + '@biomejs/cli-win32-arm64@1.9.4': + resolution: {integrity: sha512-tlbhLk+WXZmgwoIKwHIHEBZUwxml7bRJgk0X2sPyNR3S93cdRq6XulAZRQJ17FYGGzWne0fgrXBKpl7l4M87Hg==} + engines: {node: '>=14.21.3'} + cpu: [arm64] + os: [win32] + + '@biomejs/cli-win32-x64@1.9.4': + resolution: {integrity: sha512-8Y5wMhVIPaWe6jw2H+KlEm4wP/f7EW3810ZLmDlrEEy5KvBsb9ECEfu/kMWD484ijfQ8+nIi0giMgu9g1UAuuA==} + engines: {node: '>=14.21.3'} + cpu: [x64] + os: [win32] + '@emnapi/core@1.4.3': resolution: {integrity: sha512-4m62DuCE07lw01soJwPiBGC0nAww0Q+RY70VZ+n49yDIO13yyinhbWCeNnaob0lakDtWQzSdtNWzJeOJt2ma+g==} @@ -3229,6 +3285,41 @@ snapshots: '@babel/helper-string-parser': 7.27.1 '@babel/helper-validator-identifier': 7.27.1 + '@biomejs/biome@1.9.4': + optionalDependencies: + '@biomejs/cli-darwin-arm64': 1.9.4 + '@biomejs/cli-darwin-x64': 1.9.4 + '@biomejs/cli-linux-arm64': 1.9.4 + '@biomejs/cli-linux-arm64-musl': 1.9.4 + '@biomejs/cli-linux-x64': 1.9.4 + '@biomejs/cli-linux-x64-musl': 1.9.4 + '@biomejs/cli-win32-arm64': 1.9.4 + '@biomejs/cli-win32-x64': 1.9.4 + + '@biomejs/cli-darwin-arm64@1.9.4': + optional: true + + '@biomejs/cli-darwin-x64@1.9.4': + optional: true + + '@biomejs/cli-linux-arm64-musl@1.9.4': + optional: true + + '@biomejs/cli-linux-arm64@1.9.4': + optional: true + + '@biomejs/cli-linux-x64-musl@1.9.4': + optional: true + + '@biomejs/cli-linux-x64@1.9.4': + optional: true + + '@biomejs/cli-win32-arm64@1.9.4': + optional: true + + '@biomejs/cli-win32-x64@1.9.4': + optional: true + '@emnapi/core@1.4.3': dependencies: '@emnapi/wasi-threads': 1.0.2 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 0261394..ce632b1 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,4 +1,5 @@ onlyBuiltDependencies: + - '@biomejs/biome' - '@tailwindcss/oxide' - esbuild - sharp diff --git a/src-tauri/src/api_client.rs b/src-tauri/src/api_client.rs index 1a7953a..0cd541f 100644 --- a/src-tauri/src/api_client.rs +++ b/src-tauri/src/api_client.rs @@ -54,7 +54,7 @@ impl VersionComponent { .filter_map(|part| part.parse().ok()) .collect(); - let major = parts.get(0).copied().unwrap_or(0); + let major = parts.first().copied().unwrap_or(0); let minor = parts.get(1).copied().unwrap_or(0); let patch = parts.get(2).copied().unwrap_or(0); @@ -103,31 +103,31 @@ impl VersionComponent { } // Extract kind and number - let (kind, number) = if pre_release.starts_with("alpha") { + let (kind, number) = if let Some(stripped) = pre_release.strip_prefix("alpha") { ( PreReleaseKind::Alpha, - Self::extract_number(&pre_release[5..]), + Self::extract_number(stripped), ) - } else if pre_release.starts_with("beta") { + } else if let Some(stripped) = pre_release.strip_prefix("beta") { ( PreReleaseKind::Beta, - Self::extract_number(&pre_release[4..]), + Self::extract_number(stripped), ) - } else if pre_release.starts_with("rc") { - (PreReleaseKind::RC, Self::extract_number(&pre_release[2..])) - } else if pre_release.starts_with("dev") { - (PreReleaseKind::Dev, Self::extract_number(&pre_release[3..])) - } else if pre_release.starts_with("pre") { - (PreReleaseKind::Pre, Self::extract_number(&pre_release[3..])) - } else if pre_release.starts_with('a') { + } else if let Some(stripped) = pre_release.strip_prefix("rc") { + (PreReleaseKind::RC, Self::extract_number(stripped)) + } else if let Some(stripped) = pre_release.strip_prefix("dev") { + (PreReleaseKind::Dev, Self::extract_number(stripped)) + } else if let Some(stripped) = pre_release.strip_prefix("pre") { + (PreReleaseKind::Pre, Self::extract_number(stripped)) + } else if let Some(stripped) = pre_release.strip_prefix('a') { ( PreReleaseKind::Alpha, - Self::extract_number(&pre_release[1..]), + Self::extract_number(stripped), ) - } else if pre_release.starts_with('b') { + } else if let Some(stripped) = pre_release.strip_prefix('b') { ( PreReleaseKind::Beta, - Self::extract_number(&pre_release[1..]), + Self::extract_number(stripped), ) } else { return None; @@ -903,7 +903,6 @@ impl ApiClient { #[cfg(test)] mod tests { use super::*; - use tokio; #[test] fn test_version_parsing() { diff --git a/src-tauri/src/auto_updater.rs b/src-tauri/src/auto_updater.rs index c676ab2..e9f396b 100644 --- a/src-tauri/src/auto_updater.rs +++ b/src-tauri/src/auto_updater.rs @@ -17,7 +17,7 @@ pub struct UpdateNotification { pub timestamp: u64, } -#[derive(Debug, Serialize, Deserialize, Clone)] +#[derive(Debug, Serialize, Deserialize, Clone, Default)] pub struct AutoUpdateState { pub pending_updates: Vec, pub disabled_browsers: HashSet, // browsers disabled during update @@ -26,17 +26,6 @@ pub struct AutoUpdateState { pub last_check_timestamp: u64, } -impl Default for AutoUpdateState { - fn default() -> Self { - Self { - pending_updates: Vec::new(), - disabled_browsers: HashSet::new(), - auto_update_downloads: HashSet::new(), - last_check_timestamp: 0, - } - } -} - pub struct AutoUpdater { version_service: BrowserVersionService, browser_runner: BrowserRunner, @@ -77,7 +66,7 @@ impl AutoUpdater { for profile in profiles { browser_profiles .entry(profile.browser.clone()) - .or_insert_with(Vec::new) + .or_default() .push(profile); } @@ -132,7 +121,7 @@ impl AutoUpdater { // Only consider versions newer than current self.is_version_newer(&v.version, current_version) && // Respect version type preference - (is_current_stable == !v.is_prerelease || !is_current_stable) + is_current_stable != v.is_prerelease }) .max_by(|a, b| self.compare_versions(&a.version, &b.version)); @@ -843,7 +832,6 @@ mod tests { std::fs::write(&state_file, json).unwrap(); // Dismiss notification (remove from pending updates) - let mut state = state; state .pending_updates .retain(|n| n.id != "test_notification"); diff --git a/src-tauri/src/browser.rs b/src-tauri/src/browser.rs index c8bc41d..319bd16 100644 --- a/src-tauri/src/browser.rs +++ b/src-tauri/src/browser.rs @@ -80,7 +80,7 @@ impl Browser for FirefoxBrowser { // Find the .app directory let app_path = std::fs::read_dir(install_dir)? .filter_map(Result::ok) - .find(|entry| entry.path().extension().map_or(false, |ext| ext == "app")) + .find(|entry| entry.path().extension().is_some_and(|ext| ext == "app")) .ok_or("Browser app not found")?; // Construct the browser executable path @@ -148,13 +148,11 @@ impl Browser for FirefoxBrowser { if browser_dir.exists() { println!("Directory exists, checking for .app files..."); if let Ok(entries) = std::fs::read_dir(&browser_dir) { - for entry in entries { - if let Ok(entry) = entry { - println!(" Found entry: {:?}", entry.path()); - if entry.path().extension().map_or(false, |ext| ext == "app") { - println!(" Found .app file: {:?}", entry.path()); - return true; - } + for entry in entries.flatten() { + println!(" Found entry: {:?}", entry.path()); + if entry.path().extension().is_some_and(|ext| ext == "app") { + println!(" Found .app file: {:?}", entry.path()); + return true; } } } @@ -186,7 +184,7 @@ impl Browser for ChromiumBrowser { // Find the .app directory let app_path = std::fs::read_dir(install_dir)? .filter_map(Result::ok) - .find(|entry| entry.path().extension().map_or(false, |ext| ext == "app")) + .find(|entry| entry.path().extension().is_some_and(|ext| ext == "app")) .ok_or("Browser app not found")?; // Construct the browser executable path @@ -226,7 +224,6 @@ impl Browser for ChromiumBrowser { // Add proxy configuration if provided if let Some(proxy) = proxy_settings { if proxy.enabled { - // Read PAC file and encode it as base64 let pac_path = Path::new(profile_path).join("proxy.pac"); if pac_path.exists() { let pac_content = fs::read(&pac_path)?; @@ -260,18 +257,16 @@ impl Browser for ChromiumBrowser { if browser_dir.exists() { println!("Directory exists, checking for .app files..."); if let Ok(entries) = std::fs::read_dir(&browser_dir) { - for entry in entries { - if let Ok(entry) = entry { - println!(" Found entry: {:?}", entry.path()); - if entry.path().extension().map_or(false, |ext| ext == "app") { - println!(" Found .app file: {:?}", entry.path()); - // Try to get the executable path as a final verification - if self.get_executable_path(&browser_dir).is_ok() { - println!(" Executable path verification successful"); - return true; - } else { - println!(" Executable path verification failed"); - } + for entry in entries.flatten() { + println!(" Found entry: {:?}", entry.path()); + if entry.path().extension().is_some_and(|ext| ext == "app") { + println!(" Found .app file: {:?}", entry.path()); + // Try to get the executable path as a final verification + if self.get_executable_path(&browser_dir).is_ok() { + println!(" Executable path verification successful"); + return true; + } else { + println!(" Executable path verification failed"); } } } @@ -535,7 +530,7 @@ mod tests { 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(); + fs::create_dir_all(chromium_app_dir.join("Contents").join("MacOS")).unwrap(); // Create a mock executable let executable_path = chromium_app_dir diff --git a/src-tauri/src/browser_runner.rs b/src-tauri/src/browser_runner.rs index 040f250..e164384 100644 --- a/src-tauri/src/browser_runner.rs +++ b/src-tauri/src/browser_runner.rs @@ -92,8 +92,8 @@ impl BrowserRunner { // Check if this is the correct browser type let is_correct_browser = match profile.browser.as_str() { - "mullvad-browser" => self.is_tor_or_mullvad_browser(&exe_name, &cmd, "mullvad-browser"), - "tor-browser" => self.is_tor_or_mullvad_browser(&exe_name, &cmd, "tor-browser"), + "mullvad-browser" => self.is_tor_or_mullvad_browser(&exe_name, cmd, "mullvad-browser"), + "tor-browser" => self.is_tor_or_mullvad_browser(&exe_name, cmd, "tor-browser"), _ => return false, }; @@ -108,12 +108,12 @@ impl BrowserRunner { // Check profile path match let profile_path_match = cmd.iter().any(|s| { let arg = s.to_str().unwrap_or(""); - arg == &profile.profile_path + arg == profile.profile_path || arg == format!("-profile={}", profile.profile_path) || (arg == "-profile" && cmd .iter() - .any(|s2| s2.to_str().unwrap_or("") == &profile.profile_path)) + .any(|s2| s2.to_str().unwrap_or("") == profile.profile_path)) }); if !profile_path_match { @@ -128,10 +128,10 @@ impl BrowserRunner { "PID {} validated successfully for {} profile {}", pid, profile.browser, profile.name ); - return true; + true } else { println!("PID {} does not exist", pid); - return false; + false } } pub fn get_binaries_dir(&self) -> PathBuf { @@ -436,7 +436,7 @@ impl BrowserRunner { let entry = entry?; let path = entry.path(); - if path.extension().map_or(false, |ext| ext == "json") { + if path.extension().is_some_and(|ext| ext == "json") { let content = fs::read_to_string(path)?; let profile: BrowserProfile = serde_json::from_str(&content)?; profiles.push(profile); @@ -505,9 +505,9 @@ impl BrowserRunner { let exe_name = process.name().to_string_lossy().to_lowercase(); let is_correct_browser = match profile.browser.as_str() { "mullvad-browser" => { - self.is_tor_or_mullvad_browser(&exe_name, &cmd, "mullvad-browser") + self.is_tor_or_mullvad_browser(&exe_name, cmd, "mullvad-browser") } - "tor-browser" => self.is_tor_or_mullvad_browser(&exe_name, &cmd, "tor-browser"), + "tor-browser" => self.is_tor_or_mullvad_browser(&exe_name, cmd, "tor-browser"), _ => false, }; @@ -518,12 +518,12 @@ impl BrowserRunner { // Check for profile path match let profile_path_match = cmd.iter().any(|s| { let arg = s.to_str().unwrap_or(""); - arg == &profile.profile_path + arg == profile.profile_path || arg == format!("-profile={}", profile.profile_path) || (arg == "-profile" && cmd .iter() - .any(|s2| s2.to_str().unwrap_or("") == &profile.profile_path)) + .any(|s2| s2.to_str().unwrap_or("") == profile.profile_path)) }); if profile_path_match { @@ -1200,10 +1200,10 @@ end try // and can't have multiple instances with the same profile match final_profile.browser.as_str() { "mullvad-browser" | "tor-browser" => { - return Err(format!( + Err(format!( "Failed to open URL in existing {} browser. Cannot launch new instance due to profile conflict: {}", final_profile.browser, e - ).into()); + ).into()) } _ => { println!( @@ -1375,16 +1375,16 @@ end try || profile.browser == "mullvad-browser" || profile.browser == "zen" { - arg == &profile.profile_path + arg == profile.profile_path || arg == format!("-profile={}", profile.profile_path) || (arg == "-profile" && cmd .iter() - .any(|s2| s2.to_str().unwrap_or("") == &profile.profile_path)) + .any(|s2| s2.to_str().unwrap_or("") == profile.profile_path)) } else { // For Chromium-based browsers, check for user-data-dir arg.contains(&format!("--user-data-dir={}", profile.profile_path)) - || arg == &profile.profile_path + || arg == profile.profile_path } }); @@ -1421,8 +1421,8 @@ end try && !exe_name.contains("mullvad") } "firefox-developer" => exe_name.contains("firefox") && exe_name.contains("developer"), - "mullvad-browser" => self.is_tor_or_mullvad_browser(&exe_name, &cmd, "mullvad-browser"), - "tor-browser" => self.is_tor_or_mullvad_browser(&exe_name, &cmd, "tor-browser"), + "mullvad-browser" => self.is_tor_or_mullvad_browser(&exe_name, cmd, "mullvad-browser"), + "tor-browser" => self.is_tor_or_mullvad_browser(&exe_name, cmd, "tor-browser"), "zen" => exe_name.contains("zen"), "chromium" => exe_name.contains("chromium"), "brave" => exe_name.contains("brave"), @@ -1443,16 +1443,16 @@ end try || profile.browser == "mullvad-browser" || profile.browser == "zen" { - arg == &profile.profile_path + arg == profile.profile_path || arg == format!("-profile={}", profile.profile_path) || (arg == "-profile" && cmd .iter() - .any(|s2| s2.to_str().unwrap_or("") == &profile.profile_path)) + .any(|s2| s2.to_str().unwrap_or("") == profile.profile_path)) } else { // For Chromium-based browsers, check for user-data-dir arg.contains(&format!("--user-data-dir={}", profile.profile_path)) - || arg == &profile.profile_path + || arg == profile.profile_path } }); @@ -1572,8 +1572,8 @@ end try && !exe_name.contains("mullvad") } "firefox-developer" => exe_name.contains("firefox") && exe_name.contains("developer"), - "mullvad-browser" => self.is_tor_or_mullvad_browser(&exe_name, &cmd, "mullvad-browser"), - "tor-browser" => self.is_tor_or_mullvad_browser(&exe_name, &cmd, "tor-browser"), + "mullvad-browser" => self.is_tor_or_mullvad_browser(&exe_name, cmd, "mullvad-browser"), + "tor-browser" => self.is_tor_or_mullvad_browser(&exe_name, cmd, "tor-browser"), "zen" => exe_name.contains("zen"), "chromium" => exe_name.contains("chromium"), "brave" => exe_name.contains("brave"), @@ -1594,11 +1594,11 @@ end try || profile.browser == "mullvad-browser" || profile.browser == "zen" { - arg == &profile.profile_path || arg == format!("-profile={}", profile.profile_path) + arg == profile.profile_path || arg == format!("-profile={}", profile.profile_path) } else { // For Chromium-based browsers, check for user-data-dir arg.contains(&format!("--user-data-dir={}", profile.profile_path)) - || arg == &profile.profile_path + || arg == profile.profile_path } }); diff --git a/src-tauri/src/browser_version_service.rs b/src-tauri/src/browser_version_service.rs index 8447b8f..2fa5acf 100644 --- a/src-tauri/src/browser_version_service.rs +++ b/src-tauri/src/browser_version_service.rs @@ -551,7 +551,6 @@ mod tests { async fn test_browser_version_service_creation() { let _service = BrowserVersionService::new(); // Test passes if we can create the service without panicking - assert!(true); } #[tokio::test] diff --git a/src-tauri/src/download.rs b/src-tauri/src/download.rs index 971b294..bc0ba58 100644 --- a/src-tauri/src/download.rs +++ b/src-tauri/src/download.rs @@ -180,8 +180,8 @@ impl Downloader { } else { 0.0 }; - let eta = if speed > 0.0 && total_size.is_some() { - Some((total_size.unwrap() - downloaded) as f64 / speed) + let eta = if speed > 0.0 { + total_size.map(|total| (total - downloaded) as f64 / speed) } else { None }; @@ -209,7 +209,6 @@ impl Downloader { #[cfg(test)] mod tests { use super::*; - use tokio; #[tokio::test] async fn test_resolve_brave_download_url() { diff --git a/src-tauri/src/downloaded_browsers.rs b/src-tauri/src/downloaded_browsers.rs index fd820ae..d683f23 100644 --- a/src-tauri/src/downloaded_browsers.rs +++ b/src-tauri/src/downloaded_browsers.rs @@ -66,7 +66,7 @@ impl DownloadedBrowsersRegistry { self .browsers .entry(info.browser.clone()) - .or_insert_with(HashMap::new) + .or_default() .insert(info.version.clone(), info); } diff --git a/src-tauri/src/extraction.rs b/src-tauri/src/extraction.rs index b7ab849..17b8502 100644 --- a/src-tauri/src/extraction.rs +++ b/src-tauri/src/extraction.rs @@ -85,7 +85,7 @@ impl Extractor { // Find the .app directory in the mount point let app_entry = fs::read_dir(&mount_point)? .filter_map(Result::ok) - .find(|entry| entry.path().extension().map_or(false, |ext| ext == "app")) + .find(|entry| entry.path().extension().is_some_and(|ext| ext == "app")) .ok_or("No .app found in DMG")?; // Copy the .app to the destination @@ -179,35 +179,31 @@ impl Extractor { // First, try to find any .app file in the destination directory if let Ok(entries) = fs::read_dir(dest_dir) { - for entry in entries { - if let Ok(entry) = entry { - let path = entry.path(); - if path.extension().map_or(false, |ext| ext == "app") { - app_path = Some(path); - break; - } - // For Chromium, check subdirectories (chrome-mac folder) - if path.is_dir() { - if let Ok(sub_entries) = fs::read_dir(&path) { - for sub_entry in sub_entries { - if let Ok(sub_entry) = sub_entry { - let sub_path = sub_entry.path(); - if sub_path.extension().map_or(false, |ext| ext == "app") { - // Move the app to the root destination directory - let target_path = dest_dir.join(sub_path.file_name().unwrap()); - fs::rename(&sub_path, &target_path)?; - app_path = Some(target_path); + for entry in entries.flatten() { + let path = entry.path(); + if path.extension().is_some_and(|ext| ext == "app") { + app_path = Some(path); + break; + } + // For Chromium, check subdirectories (chrome-mac folder) + 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.extension().is_some_and(|ext| ext == "app") { + // Move the app to the root destination directory + let target_path = dest_dir.join(sub_path.file_name().unwrap()); + fs::rename(&sub_path, &target_path)?; + app_path = Some(target_path); - // Clean up the now-empty subdirectory - let _ = fs::remove_dir_all(&path); - break; - } - } - } - if app_path.is_some() { + // Clean up the now-empty subdirectory + let _ = fs::remove_dir_all(&path); break; } } + if app_path.is_some() { + break; + } } } } @@ -238,7 +234,6 @@ mod tests { fn test_extractor_creation() { let _extractor = Extractor::new(); // Just verify we can create an extractor instance - assert!(true); } #[test] @@ -327,7 +322,7 @@ mod tests { let entries: Vec<_> = fs::read_dir(temp_dir.path()) .unwrap() .filter_map(Result::ok) - .filter(|entry| entry.path().extension().map_or(false, |ext| ext == "app")) + .filter(|entry| entry.path().extension().is_some_and(|ext| ext == "app")) .collect(); assert_eq!(entries.len(), 1); @@ -349,19 +344,15 @@ mod tests { let mut found_app = false; if let Ok(entries) = fs::read_dir(temp_dir.path()) { - for entry in entries { - if let Ok(entry) = entry { - let path = entry.path(); - if path.is_dir() { - if let Ok(sub_entries) = fs::read_dir(&path) { - for sub_entry in sub_entries { - if let Ok(sub_entry) = sub_entry { - let sub_path = sub_entry.path(); - if sub_path.extension().map_or(false, |ext| ext == "app") { - found_app = true; - break; - } - } + for entry in entries.flatten() { + let path = entry.path(); + 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.extension().is_some_and(|ext| ext == "app") { + found_app = true; + break; } } } diff --git a/src-tauri/src/version_updater.rs b/src-tauri/src/version_updater.rs index 76f784a..4fb0c25 100644 --- a/src-tauri/src/version_updater.rs +++ b/src-tauri/src/version_updater.rs @@ -259,7 +259,7 @@ impl VersionUpdater { ) -> Result, Box> { println!("Starting background version update for all browsers"); - let browsers = vec![ + let browsers = [ "firefox", "firefox-developer", "mullvad-browser", @@ -416,11 +416,7 @@ impl VersionUpdater { let elapsed = current_time.saturating_sub(state.last_update_time); let update_interval_secs = state.update_interval_hours * 60 * 60; - if elapsed >= update_interval_secs { - 0 // Update overdue - } else { - update_interval_secs - elapsed - } + update_interval_secs.saturating_sub(elapsed) } } } diff --git a/src/app/page.tsx b/src/app/page.tsx index 8f6bbe6..a0dddca 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -6,7 +6,6 @@ import { ProfilesDataTable } from "@/components/profile-data-table"; import { ProfileSelectorDialog } from "@/components/profile-selector-dialog"; import { ProxySettingsDialog } from "@/components/proxy-settings-dialog"; import { SettingsDialog } from "@/components/settings-dialog"; -import { useUpdateNotifications } from "@/components/update-notification"; import { Button } from "@/components/ui/button"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { @@ -14,12 +13,13 @@ import { TooltipContent, TooltipTrigger, } from "@/components/ui/tooltip"; +import { useUpdateNotifications } from "@/hooks/use-update-notifications"; +import { showErrorToast } from "@/lib/toast-utils"; import type { BrowserProfile, ProxySettings } from "@/types"; import { invoke } from "@tauri-apps/api/core"; import { listen } from "@tauri-apps/api/event"; import { useCallback, useEffect, useRef, useState } from "react"; import { GoGear, GoPlus } from "react-icons/go"; -import { showErrorToast } from "@/components/custom-toast"; type BrowserTypeString = | "mullvad-browser" @@ -177,26 +177,14 @@ export default function Home() { }); console.log("Smart URL opening succeeded:", result); // URL was handled successfully - } catch (error: any) { + } catch (error: unknown) { console.log( "Smart URL opening failed or requires profile selection:", error ); - // Check if it's the special error cases - if (error === "show_selector") { - // Show profile selector - setPendingUrls((prev) => [...prev, { id: Date.now().toString(), url }]); - } else if (error === "no_profiles") { - // No profiles available, show error message - setError( - "No profiles available. Please create a profile first before opening URLs." - ); - } else { - // Some other error occurred - console.error("Failed to open URL:", error); - setError(`Failed to open URL: ${error}`); - } + // Show profile selector for manual selection + setPendingUrls((prev) => [...prev, { id: Date.now().toString(), url }]); } }; @@ -260,7 +248,9 @@ export default function Home() { await loadProfiles(); } catch (error) { - setError(`Failed to create profile: ${error as any}`); + setError( + `Failed to create profile: ${error instanceof Error ? error.message : String(error)}` + ); throw error; } }, @@ -347,9 +337,9 @@ export default function Home() { if (profiles.length === 0 || !isClient) return; const interval = setInterval(() => { - profiles.forEach((profile) => { + for (const profile of profiles) { void checkBrowserStatus(profile); - }); + } }, 500); return () => { diff --git a/src/components/change-version-dialog.tsx b/src/components/change-version-dialog.tsx index d23d8a8..14e2c07 100644 --- a/src/components/change-version-dialog.tsx +++ b/src/components/change-version-dialog.tsx @@ -16,8 +16,8 @@ import { VersionSelector } from "@/components/version-selector"; import { useBrowserDownload } from "@/hooks/use-browser-download"; import type { BrowserProfile } from "@/types"; import { invoke } from "@tauri-apps/api/core"; -import { LuTriangleAlert } from "react-icons/lu"; import { useEffect, useState } from "react"; +import { LuTriangleAlert } from "react-icons/lu"; interface ChangeVersionDialogProps { isOpen: boolean; diff --git a/src/components/custom-toast.tsx b/src/components/custom-toast.tsx index 16a0869..32ad7d4 100644 --- a/src/components/custom-toast.tsx +++ b/src/components/custom-toast.tsx @@ -43,12 +43,11 @@ */ import React from "react"; -import { toast as sonnerToast } from "sonner"; import { LuCheckCheck, - LuTriangleAlert, LuDownload, LuRefreshCw, + LuTriangleAlert, } from "react-icons/lu"; interface BaseToastProps { @@ -123,7 +122,6 @@ function getToastIcon(type: ToastProps["type"], stage?: string) { return ( ); - case "loading": default: return (
@@ -214,204 +212,3 @@ export function UnifiedToast(props: ToastProps) {
); } - -// Unified toast function -export function showToast(props: ToastProps & { id?: string }) { - const toastId = props.id ?? `toast-${props.type}-${Date.now()}`; - - // Improved duration logic - make toasts disappear more quickly - let duration: number; - if (props.duration !== undefined) { - duration = props.duration; - } else { - switch (props.type) { - case "loading": - case "fetching": - duration = 10000; // 10 seconds instead of infinite - break; - case "download": - // Only keep infinite for active downloading, others get shorter durations - if ("stage" in props && props.stage === "downloading") { - duration = Number.POSITIVE_INFINITY; - } else if ("stage" in props && props.stage === "completed") { - duration = 3000; // Shorter duration for completed downloads - } else { - duration = 8000; // 8 seconds for extracting/verifying - } - break; - case "version-update": - duration = 15000; // 15 seconds instead of infinite - break; - case "success": - duration = 3000; // Shorter success duration - break; - case "error": - duration = 5000; // Reasonable error duration - break; - default: - duration = 4000; - } - } - - if (props.type === "success") { - sonnerToast.success(, { - id: toastId, - duration, - style: { - background: "transparent", - border: "none", - boxShadow: "none", - padding: 0, - }, - }); - } else if (props.type === "error") { - sonnerToast.error(, { - id: toastId, - duration, - style: { - background: "transparent", - border: "none", - boxShadow: "none", - padding: 0, - }, - }); - } else { - sonnerToast.custom((id) => , { - id: toastId, - duration, - style: { - background: "transparent", - border: "none", - boxShadow: "none", - padding: 0, - }, - }); - } - - return toastId; -} - -// Convenience functions for common use cases -export function showLoadingToast( - title: string, - options?: { - id?: string; - description?: string; - duration?: number; - } -) { - return showToast({ - type: "loading", - title, - ...options, - }); -} - -export function showDownloadToast( - browserName: string, - version: string, - stage: "downloading" | "extracting" | "verifying" | "completed", - progress?: { percentage: number; speed?: string; eta?: string }, - options?: { suppressCompletionToast?: boolean } -) { - const title = - stage === "completed" - ? `${browserName} ${version} downloaded successfully!` - : stage === "downloading" - ? `Downloading ${browserName} ${version}` - : stage === "extracting" - ? `Extracting ${browserName} ${version}` - : `Verifying ${browserName} ${version}`; - - // Don't show completion toast if suppressed (for auto-update scenarios) - if (stage === "completed" && options?.suppressCompletionToast) { - dismissToast(`download-${browserName.toLowerCase()}-${version}`); - return; - } - - return showToast({ - type: "download", - title, - stage, - progress, - id: `download-${browserName.toLowerCase()}-${version}`, - }); -} - -export function showVersionUpdateToast( - title: string, - options?: { - id?: string; - description?: string; - progress?: { - current: number; - total: number; - found: number; - }; - duration?: number; - } -) { - return showToast({ - type: "version-update", - title, - ...options, - }); -} - -export function showFetchingToast( - browserName: string, - options?: { - id?: string; - description?: string; - duration?: number; - } -) { - return showToast({ - type: "fetching", - title: `Checking for new ${browserName} versions...`, - description: - options?.description ?? "Fetching latest release information...", - browserName, - ...options, - }); -} - -export function showSuccessToast( - title: string, - options?: { - id?: string; - description?: string; - duration?: number; - } -) { - return showToast({ - type: "success", - title, - ...options, - }); -} - -export function showErrorToast( - title: string, - options?: { - id?: string; - description?: string; - duration?: number; - } -) { - return showToast({ - type: "error", - title, - ...options, - }); -} - -// Generic helper for dismissing toasts -export function dismissToast(id: string) { - sonnerToast.dismiss(id); -} - -// Dismiss all toasts -export function dismissAllToasts() { - sonnerToast.dismiss(); -} diff --git a/src/components/profile-data-table.tsx b/src/components/profile-data-table.tsx index 004bd8b..9bf7a2d 100644 --- a/src/components/profile-data-table.tsx +++ b/src/components/profile-data-table.tsx @@ -31,6 +31,8 @@ import { TooltipContent, TooltipTrigger, } from "@/components/ui/tooltip"; +import { useTableSorting } from "@/hooks/use-table-sorting"; +import { getBrowserDisplayName, getBrowserIcon } from "@/lib/browser-utils"; import type { BrowserProfile } from "@/types"; import { type ColumnDef, @@ -40,14 +42,12 @@ import { getSortedRowModel, useReactTable, } from "@tanstack/react-table"; -import { LuChevronDown, LuChevronUp } from "react-icons/lu"; -import { IoEllipsisHorizontal } from "react-icons/io5"; import * as React from "react"; import { CiCircleCheck } from "react-icons/ci"; -import { useTableSorting } from "@/hooks/use-table-sorting"; +import { IoEllipsisHorizontal } from "react-icons/io5"; +import { LuChevronDown, LuChevronUp } from "react-icons/lu"; import { Input } from "./ui/input"; import { Label } from "./ui/label"; -import { getBrowserDisplayName, getBrowserIcon } from "@/lib/browser-utils"; interface ProfilesDataTableProps { data: BrowserProfile[]; @@ -392,7 +392,17 @@ export function ProfilesDataTable({ }, }, ], - [isClient, runningProfiles, isUpdating, data] + [ + isClient, + runningProfiles, + isUpdating, + data, + onLaunchProfile, + onKillProfile, + onProxySettings, + onDeleteProfile, + onChangeVersion, + ] ); const table = useReactTable({ diff --git a/src/components/profile-selector-dialog.tsx b/src/components/profile-selector-dialog.tsx index e14782a..b1fe81e 100644 --- a/src/components/profile-selector-dialog.tsx +++ b/src/components/profile-selector-dialog.tsx @@ -23,12 +23,12 @@ import { TooltipContent, TooltipTrigger, } from "@/components/ui/tooltip"; +import { getBrowserDisplayName, getBrowserIcon } from "@/lib/browser-utils"; import type { BrowserProfile } from "@/types"; import { invoke } from "@tauri-apps/api/core"; -import { LuCopy } from "react-icons/lu"; import { useEffect, useState } from "react"; +import { LuCopy } from "react-icons/lu"; import { toast } from "sonner"; -import { getBrowserDisplayName, getBrowserIcon } from "@/lib/browser-utils"; interface ProfileSelectorDialogProps { isOpen: boolean; diff --git a/src/components/ui/checkbox.tsx b/src/components/ui/checkbox.tsx index 0a0105a..3f92202 100644 --- a/src/components/ui/checkbox.tsx +++ b/src/components/ui/checkbox.tsx @@ -1,8 +1,8 @@ "use client"; import * as CheckboxPrimitive from "@radix-ui/react-checkbox"; -import { LuCheck } from "react-icons/lu"; import type * as React from "react"; +import { LuCheck } from "react-icons/lu"; import { cn } from "@/lib/utils"; diff --git a/src/components/ui/dropdown-menu.tsx b/src/components/ui/dropdown-menu.tsx index b446c7e..fd7536b 100644 --- a/src/components/ui/dropdown-menu.tsx +++ b/src/components/ui/dropdown-menu.tsx @@ -1,8 +1,8 @@ "use client"; import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"; -import { LuCheck, LuChevronRight, LuCircle } from "react-icons/lu"; import type * as React from "react"; +import { LuCheck, LuChevronRight, LuCircle } from "react-icons/lu"; import { cn } from "@/lib/utils"; diff --git a/src/components/ui/select.tsx b/src/components/ui/select.tsx index 5601a89..5eaf520 100644 --- a/src/components/ui/select.tsx +++ b/src/components/ui/select.tsx @@ -1,8 +1,8 @@ "use client"; import * as SelectPrimitive from "@radix-ui/react-select"; -import { LuCheck, LuChevronDown, LuChevronUp } from "react-icons/lu"; import type * as React from "react"; +import { LuCheck, LuChevronDown, LuChevronUp } from "react-icons/lu"; import { cn } from "@/lib/utils"; diff --git a/src/components/update-notification.tsx b/src/components/update-notification.tsx index 1c7727f..e57b6dc 100644 --- a/src/components/update-notification.tsx +++ b/src/components/update-notification.tsx @@ -2,14 +2,12 @@ /* eslint-disable @typescript-eslint/no-misused-promises */ -import { Button } from "@/components/ui/button"; import { Badge } from "@/components/ui/badge"; -import { invoke } from "@tauri-apps/api/core"; -import { toast } from "sonner"; -import { useCallback, useEffect, useState } from "react"; -import { FaDownload, FaTimes } from "react-icons/fa"; -import { showToast } from "@/components/custom-toast"; +import { Button } from "@/components/ui/button"; import { getBrowserDisplayName } from "@/lib/browser-utils"; +import React from "react"; +import { FaDownload, FaTimes } from "react-icons/fa"; +import { LuDownload } from "react-icons/lu"; interface UpdateNotification { id: string; @@ -28,7 +26,7 @@ interface UpdateNotificationProps { isUpdating?: boolean; } -function UpdateNotificationComponent({ +export function UpdateNotificationComponent({ notification, onUpdate, onDismiss, @@ -108,198 +106,3 @@ function UpdateNotificationComponent({ ); } - -export function useUpdateNotifications() { - const [notifications, setNotifications] = useState([]); - const [updatingBrowsers, setUpdatingBrowsers] = useState>( - new Set() - ); - const [isClient, setIsClient] = useState(false); - - // Ensure we're on the client side to prevent hydration mismatches - useEffect(() => { - setIsClient(true); - }, []); - - const checkForUpdates = useCallback(async () => { - if (!isClient) return; // Only run on client side - - try { - const updates = await invoke( - "check_for_browser_updates" - ); - setNotifications(updates); - - // Show toasts for new notifications - we'll define handleUpdate and handleDismiss separately - // to avoid circular dependencies - } catch (error) { - console.error("Failed to check for updates:", error); - } - }, [isClient]); - - const handleUpdate = useCallback( - async (browser: string, newVersion: string) => { - try { - setUpdatingBrowsers((prev) => new Set(prev).add(browser)); - const browserDisplayName = getBrowserDisplayName(browser); - - // Dismiss all notifications for this browser first - const browserNotifications = notifications.filter( - (n) => n.browser === browser - ); - for (const notification of browserNotifications) { - toast.dismiss(notification.id); - await invoke("dismiss_update_notification", { - notificationId: notification.id, - }); - } - - try { - // Check if browser already exists before downloading - const isDownloaded = await invoke("check_browser_exists", { - browserStr: browser, - version: newVersion, - }); - - if (isDownloaded) { - // Browser already exists, skip download and go straight to profile update - console.log( - `${browserDisplayName} ${newVersion} already exists, skipping download` - ); - } else { - // Mark download as auto-update in the backend for toast suppression - await invoke("mark_auto_update_download", { - browser, - version: newVersion, - }); - - // Download the browser (progress will be handled by use-browser-download hook) - await invoke("download_browser", { - browserStr: browser, - version: newVersion, - }); - } - - // Complete the update with auto-update of profile versions - const updatedProfiles = await invoke( - "complete_browser_update_with_auto_update", - { - browser, - newVersion, - } - ); - - // Show success message based on whether profiles were updated - if (updatedProfiles.length > 0) { - const profileText = - updatedProfiles.length === 1 - ? `Profile "${updatedProfiles[0]}" has been updated` - : `${updatedProfiles.length} profiles have been updated`; - - showToast({ - type: "success", - title: `${browserDisplayName} update completed`, - description: `${profileText} to version ${newVersion}. Running profiles were not updated and can be updated manually.`, - duration: 5000, - }); - } else { - showToast({ - type: "success", - title: `${browserDisplayName} update ready`, - description: - "All affected profiles are currently running. Stop them and manually update their versions to use the new version.", - duration: 5000, - }); - } - } catch (downloadError) { - console.error("Failed to download browser:", downloadError); - - // Clean up auto-update tracking on error - try { - await invoke("remove_auto_update_download", { - browser, - version: newVersion, - }); - } catch (e) { - console.error("Failed to clean up auto-update tracking:", e); - } - - showToast({ - type: "error", - title: `Failed to download ${browserDisplayName} ${newVersion}`, - description: String(downloadError), - duration: 6000, - }); - throw downloadError; - } - - // Refresh notifications to clear any remaining ones - await checkForUpdates(); - } catch (error) { - console.error("Failed to start update:", error); - const browserDisplayName = getBrowserDisplayName(browser); - showToast({ - type: "error", - title: `Failed to update ${browserDisplayName}`, - description: String(error), - duration: 6000, - }); - } finally { - setUpdatingBrowsers((prev) => { - const next = new Set(prev); - next.delete(browser); - return next; - }); - } - }, - [notifications, checkForUpdates] - ); - - const handleDismiss = useCallback( - async (notificationId: string) => { - if (!isClient) return; // Only run on client side - - try { - toast.dismiss(notificationId); - await invoke("dismiss_update_notification", { notificationId }); - await checkForUpdates(); - } catch (error) { - console.error("Failed to dismiss notification:", error); - } - }, - [checkForUpdates, isClient] - ); - - // Separate effect to show toasts when notifications change - useEffect(() => { - if (!isClient) return; - - notifications.forEach((notification) => { - const isUpdating = updatingBrowsers.has(notification.browser); - - toast.custom( - () => ( - - ), - { - id: notification.id, - duration: Number.POSITIVE_INFINITY, // Persistent until user action - position: "top-right", - // Remove transparent styling to fix background issue - style: undefined, - } - ); - }); - }, [notifications, updatingBrowsers, handleUpdate, handleDismiss, isClient]); - - return { - notifications, - checkForUpdates, - isUpdating: (browser: string) => updatingBrowsers.has(browser), - }; -} diff --git a/src/components/version-selector.tsx b/src/components/version-selector.tsx index f3ac915..b926102 100644 --- a/src/components/version-selector.tsx +++ b/src/components/version-selector.tsx @@ -17,8 +17,8 @@ import { PopoverTrigger, } from "@/components/ui/popover"; import { cn } from "@/lib/utils"; -import { LuDownload } from "react-icons/lu"; import { useState } from "react"; +import { LuDownload } from "react-icons/lu"; import { LuCheck, LuChevronsUpDown } from "react-icons/lu"; import { ScrollArea } from "./ui/scroll-area"; diff --git a/src/components/version-update-settings.tsx b/src/components/version-update-settings.tsx index 284a904..7f2cea5 100644 --- a/src/components/version-update-settings.tsx +++ b/src/components/version-update-settings.tsx @@ -11,10 +11,10 @@ import { } from "@/components/ui/card"; import { useVersionUpdater } from "@/hooks/use-version-updater"; import { - LuRefreshCw, - LuClock, LuCheckCheck, LuCircleAlert, + LuClock, + LuRefreshCw, } from "react-icons/lu"; export function VersionUpdateSettings() { diff --git a/src/hooks/use-browser-download.ts b/src/hooks/use-browser-download.ts index 49e1d6e..b75a55d 100644 --- a/src/hooks/use-browser-download.ts +++ b/src/hooks/use-browser-download.ts @@ -1,15 +1,15 @@ +import { getBrowserDisplayName } from "@/lib/browser-utils"; +import { + dismissToast, + showDownloadToast, + showErrorToast, + showFetchingToast, + showSuccessToast, +} from "@/lib/toast-utils"; import { invoke } from "@tauri-apps/api/core"; import { listen } from "@tauri-apps/api/event"; import { useCallback, useEffect, useState } from "react"; import { toast } from "sonner"; -import { - showDownloadToast, - showFetchingToast, - showSuccessToast, - showErrorToast, - dismissToast, -} from "../components/custom-toast"; -import { getBrowserDisplayName } from "@/lib/browser-utils"; interface GithubRelease { tag_name: string; @@ -192,15 +192,15 @@ export function useBrowserDownload() { const formatTime = (seconds: number): string => { if (seconds < 60) { return `${Math.round(seconds)}s`; - } else if (seconds < 3600) { + } + if (seconds < 3600) { const minutes = Math.floor(seconds / 60); const remainingSeconds = Math.round(seconds % 60); return `${minutes}m ${remainingSeconds}s`; - } else { - const hours = Math.floor(seconds / 3600); - const minutes = Math.floor((seconds % 3600) / 60); - return `${hours}h ${minutes}m`; } + const hours = Math.floor(seconds / 3600); + const minutes = Math.floor((seconds % 3600) / 60); + return `${hours}h ${minutes}m`; }; const formatBytes = (bytes: number): string => { @@ -208,9 +208,7 @@ export function useBrowserDownload() { const k = 1024; const sizes = ["B", "KB", "MB", "GB"]; const i = Math.floor(Math.log(bytes) / Math.log(k)); - return `${Number.parseFloat((bytes / Math.pow(k, i)).toFixed(1))} ${ - sizes[i] - }`; + return `${Number.parseFloat((bytes / k ** i).toFixed(1))} ${sizes[i]}`; }; const loadVersions = useCallback(async (browserStr: string) => { diff --git a/src/hooks/use-table-sorting.ts b/src/hooks/use-table-sorting.ts index 7dfeeb8..357ccfe 100644 --- a/src/hooks/use-table-sorting.ts +++ b/src/hooks/use-table-sorting.ts @@ -1,7 +1,7 @@ -import { invoke } from "@tauri-apps/api/core"; -import { useCallback, useEffect, useState } from "react"; import type { TableSortingSettings } from "@/types"; import type { SortingState } from "@tanstack/react-table"; +import { invoke } from "@tauri-apps/api/core"; +import { useCallback, useEffect, useState } from "react"; export function useTableSorting() { const [sortingSettings, setSortingSettings] = useState({ diff --git a/src/hooks/use-update-notifications.tsx b/src/hooks/use-update-notifications.tsx new file mode 100644 index 0000000..f017008 --- /dev/null +++ b/src/hooks/use-update-notifications.tsx @@ -0,0 +1,211 @@ +import { UpdateNotificationComponent } from "@/components/update-notification"; +import { getBrowserDisplayName } from "@/lib/browser-utils"; +import { showToast } from "@/lib/toast-utils"; +import { invoke } from "@tauri-apps/api/core"; +import React, { useCallback, useEffect, useState } from "react"; +import { toast } from "sonner"; + +interface UpdateNotification { + id: string; + browser: string; + current_version: string; + new_version: string; + affected_profiles: string[]; + is_stable_update: boolean; + timestamp: number; +} + +export function useUpdateNotifications() { + const [notifications, setNotifications] = useState([]); + const [updatingBrowsers, setUpdatingBrowsers] = useState>( + new Set() + ); + const [isClient, setIsClient] = useState(false); + + // Ensure we're on the client side to prevent hydration mismatches + useEffect(() => { + setIsClient(true); + }, []); + + const checkForUpdates = useCallback(async () => { + if (!isClient) return; // Only run on client side + + try { + const updates = await invoke( + "check_for_browser_updates" + ); + setNotifications(updates); + + // Show toasts for new notifications - we'll define handleUpdate and handleDismiss separately + // to avoid circular dependencies + } catch (error) { + console.error("Failed to check for updates:", error); + } + }, [isClient]); + + const handleUpdate = useCallback( + async (browser: string, newVersion: string) => { + try { + setUpdatingBrowsers((prev) => new Set(prev).add(browser)); + const browserDisplayName = getBrowserDisplayName(browser); + + // Dismiss all notifications for this browser first + const browserNotifications = notifications.filter( + (n) => n.browser === browser + ); + for (const notification of browserNotifications) { + toast.dismiss(notification.id); + await invoke("dismiss_update_notification", { + notificationId: notification.id, + }); + } + + try { + // Check if browser already exists before downloading + const isDownloaded = await invoke("check_browser_exists", { + browserStr: browser, + version: newVersion, + }); + + if (isDownloaded) { + // Browser already exists, skip download and go straight to profile update + console.log( + `${browserDisplayName} ${newVersion} already exists, skipping download` + ); + } else { + // Mark download as auto-update in the backend for toast suppression + await invoke("mark_auto_update_download", { + browser, + version: newVersion, + }); + + // Download the browser (progress will be handled by use-browser-download hook) + await invoke("download_browser", { + browserStr: browser, + version: newVersion, + }); + } + + // Complete the update with auto-update of profile versions + const updatedProfiles = await invoke( + "complete_browser_update_with_auto_update", + { + browser, + newVersion, + } + ); + + // Show success message based on whether profiles were updated + if (updatedProfiles.length > 0) { + const profileText = + updatedProfiles.length === 1 + ? `Profile "${updatedProfiles[0]}" has been updated` + : `${updatedProfiles.length} profiles have been updated`; + + showToast({ + type: "success", + title: `${browserDisplayName} update completed`, + description: `${profileText} to version ${newVersion}. Running profiles were not updated and can be updated manually.`, + duration: 5000, + }); + } else { + showToast({ + type: "success", + title: `${browserDisplayName} update ready`, + description: + "All affected profiles are currently running. Stop them and manually update their versions to use the new version.", + duration: 5000, + }); + } + } catch (downloadError) { + console.error("Failed to download browser:", downloadError); + + // Clean up auto-update tracking on error + try { + await invoke("remove_auto_update_download", { + browser, + version: newVersion, + }); + } catch (e) { + console.error("Failed to clean up auto-update tracking:", e); + } + + showToast({ + type: "error", + title: `Failed to download ${browserDisplayName} ${newVersion}`, + description: String(downloadError), + duration: 6000, + }); + throw downloadError; + } + + // Refresh notifications to clear any remaining ones + await checkForUpdates(); + } catch (error) { + console.error("Failed to start update:", error); + const browserDisplayName = getBrowserDisplayName(browser); + showToast({ + type: "error", + title: `Failed to update ${browserDisplayName}`, + description: String(error), + duration: 6000, + }); + } finally { + setUpdatingBrowsers((prev) => { + const next = new Set(prev); + next.delete(browser); + return next; + }); + } + }, + [notifications, checkForUpdates] + ); + + const handleDismiss = useCallback( + async (notificationId: string) => { + if (!isClient) return; // Only run on client side + + try { + toast.dismiss(notificationId); + await invoke("dismiss_update_notification", { notificationId }); + await checkForUpdates(); + } catch (error) { + console.error("Failed to dismiss notification:", error); + } + }, + [checkForUpdates, isClient] + ); + + // Separate effect to show toasts when notifications change + useEffect(() => { + if (!isClient) return; + + for (const notification of notifications) { + const isUpdating = updatingBrowsers.has(notification.browser); + + toast.custom( + () => ( + + ), + { + id: notification.id, + duration: Number.POSITIVE_INFINITY, // Persistent until user action + position: "top-right", + // Remove transparent styling to fix background issue + style: undefined, + } + ); + } + }, [notifications, updatingBrowsers, handleUpdate, handleDismiss, isClient]); + + return { + notifications, + checkForUpdates, + isUpdating: (browser: string) => updatingBrowsers.has(browser), + }; +} diff --git a/src/hooks/use-version-updater.ts b/src/hooks/use-version-updater.ts index e1edbe7..9c65700 100644 --- a/src/hooks/use-version-updater.ts +++ b/src/hooks/use-version-updater.ts @@ -1,13 +1,13 @@ +import { getBrowserDisplayName } from "@/lib/browser-utils"; +import { + dismissToast, + showLoadingToast, + showVersionUpdateToast, +} from "@/lib/toast-utils"; import { invoke } from "@tauri-apps/api/core"; import { listen } from "@tauri-apps/api/event"; import { useCallback, useEffect, useState } from "react"; import { toast } from "sonner"; -import { - showVersionUpdateToast, - showLoadingToast, - dismissToast, -} from "../components/custom-toast"; -import { getBrowserDisplayName } from "@/lib/browser-utils"; interface VersionUpdateProgress { current_browser: string; @@ -158,7 +158,7 @@ export function useVersionUpdater() { ).length; if (failedUpdates > 0) { - toast.warning(`Update completed with some errors`, { + toast.warning("Update completed with some errors", { description: `${totalNewVersions} new versions found, ${failedUpdates} browsers failed to update`, duration: 5000, }); @@ -226,11 +226,11 @@ export function useVersionUpdater() { if (hours > 0) { return `${hours}h ${minutes}m`; - } else if (minutes > 0) { - return `${minutes}m`; - } else { - return "< 1m"; } + if (minutes > 0) { + return `${minutes}m`; + } + return "< 1m"; }, []); const formatLastUpdateTime = useCallback( @@ -245,11 +245,11 @@ export function useVersionUpdater() { if (diffHours > 0) { return `${diffHours}h ${diffMinutes}m ago`; - } else if (diffMinutes > 0) { - return `${diffMinutes}m ago`; - } else { - return "Just now"; } + if (diffMinutes > 0) { + return `${diffMinutes}m ago`; + } + return "Just now"; }, [] ); diff --git a/src/lib/browser-utils.ts b/src/lib/browser-utils.ts index ac3ca2b..bff9d49 100644 --- a/src/lib/browser-utils.ts +++ b/src/lib/browser-utils.ts @@ -3,8 +3,8 @@ * Centralized helpers for browser name mapping, icons, etc. */ -import { SiMullvad, SiBrave, SiTorbrowser } from "react-icons/si"; import { FaChrome, FaFirefox } from "react-icons/fa"; +import { SiBrave, SiMullvad, SiTorbrowser } from "react-icons/si"; /** * Map internal browser names to display names diff --git a/src/lib/toast-utils.ts b/src/lib/toast-utils.ts new file mode 100644 index 0000000..f435d97 --- /dev/null +++ b/src/lib/toast-utils.ts @@ -0,0 +1,256 @@ +import { UnifiedToast } from "@/components/custom-toast"; +import React from "react"; +import { toast as sonnerToast } from "sonner"; + +// Define toast types locally +export interface BaseToastProps { + id?: string; + title: string; + description?: string; + duration?: number; +} + +export interface LoadingToastProps extends BaseToastProps { + type: "loading"; +} + +export interface SuccessToastProps extends BaseToastProps { + type: "success"; +} + +export interface ErrorToastProps extends BaseToastProps { + type: "error"; +} + +export interface DownloadToastProps extends BaseToastProps { + type: "download"; + stage?: "downloading" | "extracting" | "verifying" | "completed"; + progress?: { + percentage: number; + speed?: string; + eta?: string; + }; +} + +export interface VersionUpdateToastProps extends BaseToastProps { + type: "version-update"; + progress?: { + current: number; + total: number; + found: number; + }; +} + +export interface FetchingToastProps extends BaseToastProps { + type: "fetching"; + browserName?: string; +} + +export type ToastProps = + | LoadingToastProps + | SuccessToastProps + | ErrorToastProps + | DownloadToastProps + | VersionUpdateToastProps + | FetchingToastProps; + +// Unified toast function +export function showToast(props: ToastProps & { id?: string }) { + const toastId = props.id ?? `toast-${props.type}-${Date.now()}`; + + // Improved duration logic - make toasts disappear more quickly + let duration: number; + if (props.duration !== undefined) { + duration = props.duration; + } else { + switch (props.type) { + case "loading": + case "fetching": + duration = 10000; // 10 seconds instead of infinite + break; + case "download": + // Only keep infinite for active downloading, others get shorter durations + if ("stage" in props && props.stage === "downloading") { + duration = Number.POSITIVE_INFINITY; + } else if ("stage" in props && props.stage === "completed") { + duration = 3000; // Shorter duration for completed downloads + } else { + duration = 8000; // 8 seconds for extracting/verifying + } + break; + case "version-update": + duration = 15000; // 15 seconds instead of infinite + break; + case "success": + duration = 3000; // Shorter success duration + break; + case "error": + duration = 5000; // Reasonable error duration + break; + default: + duration = 4000; + } + } + + if (props.type === "success") { + sonnerToast.success(React.createElement(UnifiedToast, props), { + id: toastId, + duration, + style: { + background: "transparent", + border: "none", + boxShadow: "none", + padding: 0, + }, + }); + } else if (props.type === "error") { + sonnerToast.error(React.createElement(UnifiedToast, props), { + id: toastId, + duration, + style: { + background: "transparent", + border: "none", + boxShadow: "none", + padding: 0, + }, + }); + } else { + sonnerToast.custom((id) => React.createElement(UnifiedToast, props), { + id: toastId, + duration, + style: { + background: "transparent", + border: "none", + boxShadow: "none", + padding: 0, + }, + }); + } + + return toastId; +} + +// Convenience functions for common use cases +export function showLoadingToast( + title: string, + options?: { + id?: string; + description?: string; + duration?: number; + } +) { + return showToast({ + type: "loading", + title, + ...options, + }); +} + +export function showDownloadToast( + browserName: string, + version: string, + stage: "downloading" | "extracting" | "verifying" | "completed", + progress?: { percentage: number; speed?: string; eta?: string }, + options?: { suppressCompletionToast?: boolean } +) { + const title = + stage === "completed" + ? `${browserName} ${version} downloaded successfully!` + : stage === "downloading" + ? `Downloading ${browserName} ${version}` + : stage === "extracting" + ? `Extracting ${browserName} ${version}` + : `Verifying ${browserName} ${version}`; + + // Don't show completion toast if suppressed (for auto-update scenarios) + if (stage === "completed" && options?.suppressCompletionToast) { + dismissToast(`download-${browserName.toLowerCase()}-${version}`); + return; + } + + return showToast({ + type: "download", + title, + stage, + progress, + id: `download-${browserName.toLowerCase()}-${version}`, + }); +} + +export function showVersionUpdateToast( + title: string, + options?: { + id?: string; + description?: string; + progress?: { + current: number; + total: number; + found: number; + }; + duration?: number; + } +) { + return showToast({ + type: "version-update", + title, + ...options, + }); +} + +export function showFetchingToast( + browserName: string, + options?: { + id?: string; + description?: string; + duration?: number; + } +) { + return showToast({ + type: "fetching", + title: `Checking for new ${browserName} versions...`, + description: + options?.description ?? "Fetching latest release information...", + browserName, + ...options, + }); +} + +export function showSuccessToast( + title: string, + options?: { + id?: string; + description?: string; + duration?: number; + } +) { + return showToast({ + type: "success", + title, + ...options, + }); +} + +export function showErrorToast( + title: string, + options?: { + id?: string; + description?: string; + duration?: number; + } +) { + return showToast({ + type: "error", + title, + ...options, + }); +} + +// Generic helper for dismissing toasts +export function dismissToast(id: string) { + sonnerToast.dismiss(id); +} + +// Dismiss all toasts +export function dismissAllToasts() { + sonnerToast.dismiss(); +}