chore: linting for both js and rs

This commit is contained in:
zhom
2025-05-29 23:57:54 +04:00
parent cf57b2a043
commit 5c02b59af4
33 changed files with 836 additions and 608 deletions
+21
View File
@@ -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:
+21
View File
@@ -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:
+4 -1
View File
@@ -9,11 +9,14 @@
"ntlm",
"propertylist",
"rlib",
"rustc",
"serde",
"shadcn",
"signon",
"sspi",
"staticlib",
"sysinfo",
"systempreferences"
"systempreferences",
"turbopack"
]
}
+56
View File
@@ -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": []
}
}
+5 -2
View File
@@ -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": [
+91
View File
@@ -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
+1
View File
@@ -1,4 +1,5 @@
onlyBuiltDependencies:
- '@biomejs/biome'
- '@tailwindcss/oxide'
- esbuild
- sharp
+15 -16
View File
@@ -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() {
+3 -15
View File
@@ -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<UpdateNotification>,
pub disabled_browsers: HashSet<String>, // 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");
+18 -23
View File
@@ -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
+25 -25
View File
@@ -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
}
});
-1
View File
@@ -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]
+2 -3
View File
@@ -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() {
+1 -1
View File
@@ -66,7 +66,7 @@ impl DownloadedBrowsersRegistry {
self
.browsers
.entry(info.browser.clone())
.or_insert_with(HashMap::new)
.or_default()
.insert(info.version.clone(), info);
}
+32 -41
View File
@@ -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;
}
}
}
+2 -6
View File
@@ -259,7 +259,7 @@ impl VersionUpdater {
) -> Result<Vec<BackgroundUpdateResult>, Box<dyn std::error::Error + Send + Sync>> {
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)
}
}
}
+10 -20
View File
@@ -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 () => {
+1 -1
View File
@@ -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;
+1 -204
View File
@@ -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 (
<LuRefreshCw className="h-4 w-4 text-blue-500 animate-spin flex-shrink-0" />
);
case "loading":
default:
return (
<div className="animate-spin rounded-full h-4 w-4 border-2 border-blue-500 border-t-transparent flex-shrink-0" />
@@ -214,204 +212,3 @@ export function UnifiedToast(props: ToastProps) {
</div>
);
}
// 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(<UnifiedToast {...props} />, {
id: toastId,
duration,
style: {
background: "transparent",
border: "none",
boxShadow: "none",
padding: 0,
},
});
} else if (props.type === "error") {
sonnerToast.error(<UnifiedToast {...props} />, {
id: toastId,
duration,
style: {
background: "transparent",
border: "none",
boxShadow: "none",
padding: 0,
},
});
} else {
sonnerToast.custom((id) => <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();
}
+15 -5
View File
@@ -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({
+2 -2
View File
@@ -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;
+1 -1
View File
@@ -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";
+1 -1
View File
@@ -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";
+1 -1
View File
@@ -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";
+5 -202
View File
@@ -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({
</div>
);
}
export function useUpdateNotifications() {
const [notifications, setNotifications] = useState<UpdateNotification[]>([]);
const [updatingBrowsers, setUpdatingBrowsers] = useState<Set<string>>(
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<UpdateNotification[]>(
"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<boolean>("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<string[]>(
"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(
() => (
<UpdateNotificationComponent
notification={notification}
onUpdate={handleUpdate}
onDismiss={handleDismiss}
isUpdating={isUpdating}
/>
),
{
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),
};
}
+1 -1
View File
@@ -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";
+2 -2
View File
@@ -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() {
+14 -16
View File
@@ -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) => {
+2 -2
View File
@@ -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<TableSortingSettings>({
+211
View File
@@ -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<UpdateNotification[]>([]);
const [updatingBrowsers, setUpdatingBrowsers] = useState<Set<string>>(
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<UpdateNotification[]>(
"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<boolean>("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<string[]>(
"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(
() => (
<UpdateNotificationComponent
notification={notification}
onUpdate={handleUpdate}
onDismiss={handleDismiss}
isUpdating={isUpdating}
/>
),
{
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),
};
}
+15 -15
View File
@@ -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";
},
[]
);
+1 -1
View File
@@ -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
+256
View File
@@ -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();
}