mirror of
https://github.com/zhom/donutbrowser.git
synced 2026-06-09 16:33:58 +02:00
feat: windows support
This commit is contained in:
@@ -16,7 +16,7 @@ use serde::{Deserialize, Serialize};
|
||||
use tao::event::{Event, StartCause};
|
||||
use tao::event_loop::{ControlFlow, EventLoopBuilder};
|
||||
use tokio::runtime::Runtime;
|
||||
use tray_icon::TrayIcon;
|
||||
use tray_icon::{MouseButton, TrayIcon, TrayIconEvent};
|
||||
|
||||
use donutbrowser_lib::daemon::{autostart, services, tray};
|
||||
|
||||
@@ -162,10 +162,7 @@ fn run_daemon() {
|
||||
}
|
||||
|
||||
// Prepare tray menu and icon (but don't create the tray icon yet)
|
||||
// Show "Starting..." state initially
|
||||
let tray_menu = tray::TrayMenu::new();
|
||||
tray_menu.update_api_status(None);
|
||||
tray_menu.update_mcp_status(false);
|
||||
|
||||
let icon = tray::load_icon();
|
||||
let menu_channel = MenuEvent::receiver();
|
||||
@@ -208,8 +205,6 @@ fn run_daemon() {
|
||||
mcp_running,
|
||||
} => {
|
||||
log::info!("[daemon] Services started successfully");
|
||||
tray_menu.update_api_status(api_port);
|
||||
tray_menu.update_mcp_status(mcp_running);
|
||||
|
||||
// Update state file
|
||||
let mut state = read_state();
|
||||
@@ -221,16 +216,13 @@ fn run_daemon() {
|
||||
}
|
||||
ServiceStatus::Failed(e) => {
|
||||
log::error!("Failed to start services: {}", e);
|
||||
// Keep tray icon running, show error state
|
||||
tray_menu.update_api_status(None);
|
||||
tray_menu.update_mcp_status(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Process menu events
|
||||
while let Ok(event) = menu_channel.try_recv() {
|
||||
if event.id == tray_menu.open_item.id() || event.id == tray_menu.preferences_item.id() {
|
||||
if event.id == tray_menu.open_item.id() {
|
||||
tray::open_gui();
|
||||
} else if event.id == tray_menu.quit_item.id() {
|
||||
log::info!("[daemon] Quit requested");
|
||||
@@ -238,6 +230,17 @@ fn run_daemon() {
|
||||
}
|
||||
}
|
||||
|
||||
// Handle tray icon click (left-click opens the app)
|
||||
while let Ok(event) = TrayIconEvent::receiver().try_recv() {
|
||||
if let TrayIconEvent::Click {
|
||||
button: MouseButton::Left,
|
||||
..
|
||||
} = event
|
||||
{
|
||||
tray::open_gui();
|
||||
}
|
||||
}
|
||||
|
||||
// Use swap to only run cleanup once
|
||||
if SHOULD_QUIT.swap(false, Ordering::SeqCst) {
|
||||
// Cleanup
|
||||
|
||||
@@ -291,19 +291,31 @@ async fn main() {
|
||||
log::error!("Proxy worker starting, looking for config id: {}", id);
|
||||
log::error!("Process PID: {}", std::process::id());
|
||||
|
||||
let config = match get_proxy_config(id) {
|
||||
Some(config) => {
|
||||
log::error!(
|
||||
"Found config: id={}, port={:?}, upstream={}",
|
||||
config.id,
|
||||
config.local_port,
|
||||
config.upstream_url
|
||||
);
|
||||
config
|
||||
}
|
||||
None => {
|
||||
log::error!("Proxy configuration {} not found", id);
|
||||
process::exit(1);
|
||||
// Retry config loading to handle file system race condition on Windows
|
||||
// where the config file may not be immediately visible after being written
|
||||
let config = {
|
||||
let mut attempts = 0;
|
||||
loop {
|
||||
if let Some(config) = get_proxy_config(id) {
|
||||
log::error!(
|
||||
"Found config: id={}, port={:?}, upstream={}",
|
||||
config.id,
|
||||
config.local_port,
|
||||
config.upstream_url
|
||||
);
|
||||
break config;
|
||||
}
|
||||
attempts += 1;
|
||||
if attempts >= 10 {
|
||||
log::error!(
|
||||
"Proxy configuration {} not found after {} attempts",
|
||||
id,
|
||||
attempts
|
||||
);
|
||||
process::exit(1);
|
||||
}
|
||||
log::error!("Config {} not found yet, retrying ({}/10)...", id, attempts);
|
||||
std::thread::sleep(std::time::Duration::from_millis(50));
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -546,7 +546,11 @@ mod windows {
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
if path.extension().is_some_and(|ext| ext == "exe") {
|
||||
let name = path.file_stem().unwrap_or_default().to_string_lossy();
|
||||
let name = path
|
||||
.file_stem()
|
||||
.unwrap_or_default()
|
||||
.to_string_lossy()
|
||||
.to_lowercase();
|
||||
if name.starts_with("firefox")
|
||||
|| name.starts_with("zen")
|
||||
|| name.starts_with("camoufox")
|
||||
@@ -609,7 +613,11 @@ mod windows {
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
if path.extension().is_some_and(|ext| ext == "exe") {
|
||||
let name = path.file_stem().unwrap_or_default().to_string_lossy();
|
||||
let name = path
|
||||
.file_stem()
|
||||
.unwrap_or_default()
|
||||
.to_string_lossy()
|
||||
.to_lowercase();
|
||||
if name.contains("chromium")
|
||||
|| name.contains("brave")
|
||||
|| name.contains("chrome")
|
||||
@@ -644,7 +652,11 @@ mod windows {
|
||||
let path = entry.path();
|
||||
|
||||
if path.extension().is_some_and(|ext| ext == "exe") {
|
||||
let name = path.file_stem().unwrap_or_default().to_string_lossy();
|
||||
let name = path
|
||||
.file_stem()
|
||||
.unwrap_or_default()
|
||||
.to_string_lossy()
|
||||
.to_lowercase();
|
||||
if name.starts_with("firefox")
|
||||
|| name.starts_with("zen")
|
||||
|| name.starts_with("camoufox")
|
||||
@@ -705,7 +717,11 @@ mod windows {
|
||||
let path = entry.path();
|
||||
|
||||
if path.extension().is_some_and(|ext| ext == "exe") {
|
||||
let name = path.file_stem().unwrap_or_default().to_string_lossy();
|
||||
let name = path
|
||||
.file_stem()
|
||||
.unwrap_or_default()
|
||||
.to_string_lossy()
|
||||
.to_lowercase();
|
||||
if name.contains("chromium")
|
||||
|| name.contains("brave")
|
||||
|| name.contains("chrome")
|
||||
|
||||
@@ -0,0 +1,704 @@
|
||||
use aes_gcm::{
|
||||
aead::{Aead, AeadCore, KeyInit, OsRng},
|
||||
Aes256Gcm, Key, Nonce,
|
||||
};
|
||||
use argon2::{password_hash::SaltString, Argon2, PasswordHasher};
|
||||
use chrono::Utc;
|
||||
use lazy_static::lazy_static;
|
||||
use reqwest::Client;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
use crate::settings_manager::SettingsManager;
|
||||
use crate::sync;
|
||||
|
||||
pub const CLOUD_API_URL: &str = "https://api.donutbrowser.com";
|
||||
pub const CLOUD_SYNC_URL: &str = "https://sync.donutbrowser.com";
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct CloudUser {
|
||||
pub id: String,
|
||||
pub email: String,
|
||||
pub plan: String,
|
||||
#[serde(rename = "planPeriod")]
|
||||
pub plan_period: String,
|
||||
#[serde(rename = "subscriptionStatus")]
|
||||
pub subscription_status: String,
|
||||
#[serde(rename = "profileLimit")]
|
||||
pub profile_limit: i64,
|
||||
#[serde(rename = "cloudProfilesUsed")]
|
||||
pub cloud_profiles_used: i64,
|
||||
#[serde(rename = "proxyBandwidthLimitMb")]
|
||||
pub proxy_bandwidth_limit_mb: i64,
|
||||
#[serde(rename = "proxyBandwidthUsedMb")]
|
||||
pub proxy_bandwidth_used_mb: i64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct CloudAuthState {
|
||||
pub user: CloudUser,
|
||||
pub logged_in_at: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct OtpRequestResponse {
|
||||
message: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct OtpVerifyResponse {
|
||||
#[serde(rename = "accessToken")]
|
||||
access_token: String,
|
||||
#[serde(rename = "refreshToken")]
|
||||
refresh_token: String,
|
||||
user: CloudUser,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct RefreshTokenResponse {
|
||||
#[serde(rename = "accessToken")]
|
||||
access_token: String,
|
||||
#[serde(rename = "refreshToken")]
|
||||
refresh_token: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct SyncTokenResponse {
|
||||
#[serde(rename = "syncToken")]
|
||||
sync_token: String,
|
||||
}
|
||||
|
||||
pub struct CloudAuthManager {
|
||||
client: Client,
|
||||
state: Mutex<Option<CloudAuthState>>,
|
||||
}
|
||||
|
||||
lazy_static! {
|
||||
pub static ref CLOUD_AUTH: CloudAuthManager = CloudAuthManager::new();
|
||||
}
|
||||
|
||||
impl CloudAuthManager {
|
||||
fn new() -> Self {
|
||||
let state = Self::load_auth_state_from_disk();
|
||||
Self {
|
||||
client: Client::new(),
|
||||
state: Mutex::new(state),
|
||||
}
|
||||
}
|
||||
|
||||
// --- Settings directory (reuse SettingsManager path) ---
|
||||
|
||||
fn get_settings_dir() -> PathBuf {
|
||||
SettingsManager::instance().get_settings_dir()
|
||||
}
|
||||
|
||||
fn get_vault_password() -> String {
|
||||
env!("DONUT_BROWSER_VAULT_PASSWORD").to_string()
|
||||
}
|
||||
|
||||
// --- Encrypted file storage (same pattern as settings_manager.rs) ---
|
||||
|
||||
fn encrypt_and_store(file_path: &PathBuf, header: &[u8; 5], data: &str) -> Result<(), String> {
|
||||
if let Some(parent) = file_path.parent() {
|
||||
fs::create_dir_all(parent).map_err(|e| format!("Failed to create directory: {e}"))?;
|
||||
}
|
||||
|
||||
let vault_password = Self::get_vault_password();
|
||||
let salt = SaltString::generate(&mut OsRng);
|
||||
let argon2 = Argon2::default();
|
||||
let password_hash = argon2
|
||||
.hash_password(vault_password.as_bytes(), &salt)
|
||||
.map_err(|e| format!("Argon2 key derivation failed: {e}"))?;
|
||||
let hash_value = password_hash.hash.unwrap();
|
||||
let hash_bytes = hash_value.as_bytes();
|
||||
let key_bytes: [u8; 32] = hash_bytes[..32]
|
||||
.try_into()
|
||||
.map_err(|_| "Invalid key length".to_string())?;
|
||||
let key = Key::<Aes256Gcm>::from(key_bytes);
|
||||
let cipher = Aes256Gcm::new(&key);
|
||||
let nonce = Aes256Gcm::generate_nonce(&mut OsRng);
|
||||
let ciphertext = cipher
|
||||
.encrypt(&nonce, data.as_bytes())
|
||||
.map_err(|e| format!("Encryption failed: {e}"))?;
|
||||
|
||||
let mut file_data = Vec::new();
|
||||
file_data.extend_from_slice(header);
|
||||
file_data.push(2u8);
|
||||
let salt_str = salt.as_str();
|
||||
file_data.push(salt_str.len() as u8);
|
||||
file_data.extend_from_slice(salt_str.as_bytes());
|
||||
file_data.extend_from_slice(&nonce);
|
||||
file_data.extend_from_slice(&(ciphertext.len() as u32).to_le_bytes());
|
||||
file_data.extend_from_slice(&ciphertext);
|
||||
|
||||
fs::write(file_path, file_data).map_err(|e| format!("Failed to write file: {e}"))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn decrypt_from_file(file_path: &PathBuf, header: &[u8; 5]) -> Result<Option<String>, String> {
|
||||
if !file_path.exists() {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let file_data = fs::read(file_path).map_err(|e| format!("Failed to read file: {e}"))?;
|
||||
|
||||
if file_data.len() < 6 || &file_data[0..5] != header {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let version = file_data[5];
|
||||
if version != 2 {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let mut offset = 6;
|
||||
if offset >= file_data.len() {
|
||||
return Ok(None);
|
||||
}
|
||||
let salt_len = file_data[offset] as usize;
|
||||
offset += 1;
|
||||
|
||||
if offset + salt_len > file_data.len() {
|
||||
return Ok(None);
|
||||
}
|
||||
let salt_bytes = &file_data[offset..offset + salt_len];
|
||||
let salt_str = std::str::from_utf8(salt_bytes).map_err(|_| "Invalid salt encoding")?;
|
||||
let salt = SaltString::from_b64(salt_str).map_err(|_| "Invalid salt format")?;
|
||||
offset += salt_len;
|
||||
|
||||
if offset + 12 > file_data.len() {
|
||||
return Ok(None);
|
||||
}
|
||||
let nonce_bytes: [u8; 12] = file_data[offset..offset + 12]
|
||||
.try_into()
|
||||
.map_err(|_| "Invalid nonce length".to_string())?;
|
||||
let nonce = Nonce::from(nonce_bytes);
|
||||
offset += 12;
|
||||
|
||||
if offset + 4 > file_data.len() {
|
||||
return Ok(None);
|
||||
}
|
||||
let ciphertext_len = u32::from_le_bytes([
|
||||
file_data[offset],
|
||||
file_data[offset + 1],
|
||||
file_data[offset + 2],
|
||||
file_data[offset + 3],
|
||||
]) as usize;
|
||||
offset += 4;
|
||||
|
||||
if offset + ciphertext_len > file_data.len() {
|
||||
return Ok(None);
|
||||
}
|
||||
let ciphertext = &file_data[offset..offset + ciphertext_len];
|
||||
|
||||
let vault_password = Self::get_vault_password();
|
||||
let argon2 = Argon2::default();
|
||||
let password_hash = argon2
|
||||
.hash_password(vault_password.as_bytes(), &salt)
|
||||
.map_err(|e| format!("Argon2 key derivation failed: {e}"))?;
|
||||
let hash_value = password_hash.hash.unwrap();
|
||||
let hash_bytes = hash_value.as_bytes();
|
||||
let key_bytes: [u8; 32] = hash_bytes[..32]
|
||||
.try_into()
|
||||
.map_err(|_| "Invalid key length".to_string())?;
|
||||
let key = Key::<Aes256Gcm>::from(key_bytes);
|
||||
let cipher = Aes256Gcm::new(&key);
|
||||
let plaintext = cipher
|
||||
.decrypt(&nonce, ciphertext)
|
||||
.map_err(|_| "Decryption failed".to_string())?;
|
||||
|
||||
match String::from_utf8(plaintext) {
|
||||
Ok(token) => Ok(Some(token)),
|
||||
Err(_) => Ok(None),
|
||||
}
|
||||
}
|
||||
|
||||
// --- Token storage methods ---
|
||||
|
||||
fn store_access_token(token: &str) -> Result<(), String> {
|
||||
let path = Self::get_settings_dir().join("cloud_access_token.dat");
|
||||
Self::encrypt_and_store(&path, b"DBCAT", token)
|
||||
}
|
||||
|
||||
fn load_access_token() -> Result<Option<String>, String> {
|
||||
let path = Self::get_settings_dir().join("cloud_access_token.dat");
|
||||
Self::decrypt_from_file(&path, b"DBCAT")
|
||||
}
|
||||
|
||||
fn store_refresh_token(token: &str) -> Result<(), String> {
|
||||
let path = Self::get_settings_dir().join("cloud_refresh_token.dat");
|
||||
Self::encrypt_and_store(&path, b"DBCRT", token)
|
||||
}
|
||||
|
||||
fn load_refresh_token() -> Result<Option<String>, String> {
|
||||
let path = Self::get_settings_dir().join("cloud_refresh_token.dat");
|
||||
Self::decrypt_from_file(&path, b"DBCRT")
|
||||
}
|
||||
|
||||
fn store_cloud_sync_token(token: &str) -> Result<(), String> {
|
||||
let path = Self::get_settings_dir().join("cloud_sync_token.dat");
|
||||
Self::encrypt_and_store(&path, b"DBCST", token)
|
||||
}
|
||||
|
||||
fn load_cloud_sync_token() -> Result<Option<String>, String> {
|
||||
let path = Self::get_settings_dir().join("cloud_sync_token.dat");
|
||||
Self::decrypt_from_file(&path, b"DBCST")
|
||||
}
|
||||
|
||||
fn store_auth_state(state: &CloudAuthState) -> Result<(), String> {
|
||||
let path = Self::get_settings_dir().join("cloud_auth_state.json");
|
||||
if let Some(parent) = path.parent() {
|
||||
fs::create_dir_all(parent).map_err(|e| format!("Failed to create directory: {e}"))?;
|
||||
}
|
||||
let json =
|
||||
serde_json::to_string_pretty(state).map_err(|e| format!("Failed to serialize: {e}"))?;
|
||||
fs::write(path, json).map_err(|e| format!("Failed to write auth state: {e}"))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn load_auth_state_from_disk() -> Option<CloudAuthState> {
|
||||
let path = Self::get_settings_dir().join("cloud_auth_state.json");
|
||||
if !path.exists() {
|
||||
return None;
|
||||
}
|
||||
let content = fs::read_to_string(path).ok()?;
|
||||
serde_json::from_str(&content).ok()
|
||||
}
|
||||
|
||||
fn delete_all_cloud_files() {
|
||||
let dir = Self::get_settings_dir();
|
||||
let files = [
|
||||
"cloud_access_token.dat",
|
||||
"cloud_refresh_token.dat",
|
||||
"cloud_sync_token.dat",
|
||||
"cloud_auth_state.json",
|
||||
];
|
||||
for f in &files {
|
||||
let path = dir.join(f);
|
||||
if path.exists() {
|
||||
let _ = fs::remove_file(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- JWT expiry check ---
|
||||
|
||||
fn is_jwt_expiring_soon(token: &str) -> bool {
|
||||
let parts: Vec<&str> = token.split('.').collect();
|
||||
if parts.len() != 3 {
|
||||
return true;
|
||||
}
|
||||
|
||||
use base64::{engine::general_purpose, Engine as _};
|
||||
let payload = match general_purpose::URL_SAFE_NO_PAD.decode(parts[1]) {
|
||||
Ok(bytes) => bytes,
|
||||
Err(_) => {
|
||||
// Try standard base64 with padding
|
||||
match general_purpose::STANDARD.decode(parts[1]) {
|
||||
Ok(bytes) => bytes,
|
||||
Err(_) => return true,
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let json: serde_json::Value = match serde_json::from_slice(&payload) {
|
||||
Ok(v) => v,
|
||||
Err(_) => return true,
|
||||
};
|
||||
|
||||
let exp = match json.get("exp").and_then(|v| v.as_i64()) {
|
||||
Some(exp) => exp,
|
||||
None => return true,
|
||||
};
|
||||
|
||||
let now = Utc::now().timestamp();
|
||||
exp - now < 120
|
||||
}
|
||||
|
||||
// --- API methods ---
|
||||
|
||||
pub async fn request_otp(&self, email: &str) -> Result<String, String> {
|
||||
let url = format!("{CLOUD_API_URL}/api/auth/otp/request");
|
||||
let response = self
|
||||
.client
|
||||
.post(&url)
|
||||
.json(&serde_json::json!({ "email": email }))
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to request OTP: {e}"))?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
let status = response.status();
|
||||
let body = response.text().await.unwrap_or_default();
|
||||
return Err(format!("OTP request failed ({status}): {body}"));
|
||||
}
|
||||
|
||||
let result: OtpRequestResponse = response
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to parse response: {e}"))?;
|
||||
|
||||
Ok(result.message)
|
||||
}
|
||||
|
||||
pub async fn verify_otp(&self, email: &str, code: &str) -> Result<CloudAuthState, String> {
|
||||
let url = format!("{CLOUD_API_URL}/api/auth/otp/verify");
|
||||
let response = self
|
||||
.client
|
||||
.post(&url)
|
||||
.json(&serde_json::json!({ "email": email, "code": code }))
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to verify OTP: {e}"))?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
let status = response.status();
|
||||
let body = response.text().await.unwrap_or_default();
|
||||
return Err(format!("OTP verification failed ({status}): {body}"));
|
||||
}
|
||||
|
||||
let result: OtpVerifyResponse = response
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to parse response: {e}"))?;
|
||||
|
||||
// Store tokens
|
||||
Self::store_access_token(&result.access_token)?;
|
||||
Self::store_refresh_token(&result.refresh_token)?;
|
||||
|
||||
// Build and persist auth state
|
||||
let auth_state = CloudAuthState {
|
||||
user: result.user,
|
||||
logged_in_at: Utc::now().to_rfc3339(),
|
||||
};
|
||||
Self::store_auth_state(&auth_state)?;
|
||||
|
||||
// Update in-memory state
|
||||
let mut state = self.state.lock().await;
|
||||
*state = Some(auth_state.clone());
|
||||
|
||||
Ok(auth_state)
|
||||
}
|
||||
|
||||
pub async fn refresh_access_token(&self) -> Result<(), String> {
|
||||
let refresh_token =
|
||||
Self::load_refresh_token()?.ok_or_else(|| "No refresh token stored".to_string())?;
|
||||
|
||||
let url = format!("{CLOUD_API_URL}/api/auth/token/refresh");
|
||||
let response = self
|
||||
.client
|
||||
.post(&url)
|
||||
.json(&serde_json::json!({ "refreshToken": refresh_token }))
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to refresh token: {e}"))?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
let status = response.status();
|
||||
if status == reqwest::StatusCode::UNAUTHORIZED {
|
||||
// Refresh token expired — clear everything
|
||||
self.clear_auth().await;
|
||||
let _ = crate::events::emit_empty("cloud-auth-expired");
|
||||
return Err("Session expired. Please log in again.".to_string());
|
||||
}
|
||||
let body = response.text().await.unwrap_or_default();
|
||||
return Err(format!("Token refresh failed ({status}): {body}"));
|
||||
}
|
||||
|
||||
let result: RefreshTokenResponse = response
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to parse response: {e}"))?;
|
||||
|
||||
Self::store_access_token(&result.access_token)?;
|
||||
Self::store_refresh_token(&result.refresh_token)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn fetch_profile(&self) -> Result<CloudUser, String> {
|
||||
let user = self
|
||||
.api_call_with_retry(|access_token| {
|
||||
let url = format!("{CLOUD_API_URL}/api/auth/me");
|
||||
let client = self.client.clone();
|
||||
async move {
|
||||
let response = client
|
||||
.get(&url)
|
||||
.header("Authorization", format!("Bearer {access_token}"))
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to fetch profile: {e}"))?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
let status = response.status();
|
||||
let body = response.text().await.unwrap_or_default();
|
||||
return Err(format!("Profile fetch failed ({status}): {body}"));
|
||||
}
|
||||
|
||||
response
|
||||
.json::<CloudUser>()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to parse profile: {e}"))
|
||||
}
|
||||
})
|
||||
.await?;
|
||||
|
||||
// Update cached state
|
||||
let mut state = self.state.lock().await;
|
||||
if let Some(auth_state) = state.as_mut() {
|
||||
auth_state.user = user.clone();
|
||||
let _ = Self::store_auth_state(auth_state);
|
||||
}
|
||||
|
||||
Ok(user)
|
||||
}
|
||||
|
||||
pub async fn get_or_refresh_sync_token(&self) -> Result<Option<String>, String> {
|
||||
if !self.is_logged_in().await {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
// Check cached sync token
|
||||
if let Ok(Some(token)) = Self::load_cloud_sync_token() {
|
||||
if !Self::is_jwt_expiring_soon(&token) {
|
||||
return Ok(Some(token));
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch new sync token
|
||||
let sync_token = self
|
||||
.api_call_with_retry(|access_token| {
|
||||
let url = format!("{CLOUD_API_URL}/api/auth/sync-token");
|
||||
let client = self.client.clone();
|
||||
async move {
|
||||
let response = client
|
||||
.post(&url)
|
||||
.header("Authorization", format!("Bearer {access_token}"))
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to get sync token: {e}"))?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
let status = response.status();
|
||||
let body = response.text().await.unwrap_or_default();
|
||||
return Err(format!("Sync token request failed ({status}): {body}"));
|
||||
}
|
||||
|
||||
let result: SyncTokenResponse = response
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to parse sync token response: {e}"))?;
|
||||
|
||||
Ok(result.sync_token)
|
||||
}
|
||||
})
|
||||
.await?;
|
||||
|
||||
Self::store_cloud_sync_token(&sync_token)?;
|
||||
Ok(Some(sync_token))
|
||||
}
|
||||
|
||||
pub async fn logout(&self) -> Result<(), String> {
|
||||
// Try to call the logout API (best-effort)
|
||||
if let Ok(Some(access_token)) = Self::load_access_token() {
|
||||
let refresh_token = Self::load_refresh_token().ok().flatten();
|
||||
let url = format!("{CLOUD_API_URL}/api/auth/logout");
|
||||
let mut body = serde_json::json!({});
|
||||
if let Some(rt) = &refresh_token {
|
||||
body = serde_json::json!({ "refreshToken": rt });
|
||||
}
|
||||
let _ = self
|
||||
.client
|
||||
.post(&url)
|
||||
.header("Authorization", format!("Bearer {access_token}"))
|
||||
.json(&body)
|
||||
.send()
|
||||
.await;
|
||||
}
|
||||
|
||||
self.clear_auth().await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn is_logged_in(&self) -> bool {
|
||||
let state = self.state.lock().await;
|
||||
state.is_some()
|
||||
}
|
||||
|
||||
pub async fn has_active_paid_subscription(&self) -> bool {
|
||||
let state = self.state.lock().await;
|
||||
match &*state {
|
||||
Some(auth) => auth.user.plan != "free" && auth.user.subscription_status == "active",
|
||||
None => false,
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn get_user(&self) -> Option<CloudAuthState> {
|
||||
let state = self.state.lock().await;
|
||||
state.clone()
|
||||
}
|
||||
|
||||
async fn clear_auth(&self) {
|
||||
let mut state = self.state.lock().await;
|
||||
*state = None;
|
||||
Self::delete_all_cloud_files();
|
||||
}
|
||||
|
||||
/// API call with 401 retry: if first attempt gets 401, refresh access token and retry once.
|
||||
async fn api_call_with_retry<F, Fut, T>(&self, make_request: F) -> Result<T, String>
|
||||
where
|
||||
F: Fn(String) -> Fut + Send,
|
||||
Fut: std::future::Future<Output = Result<T, String>> + Send,
|
||||
{
|
||||
let access_token = Self::load_access_token()?.ok_or_else(|| "Not logged in".to_string())?;
|
||||
|
||||
match make_request(access_token).await {
|
||||
Ok(result) => Ok(result),
|
||||
Err(e) if e.contains("(401)") => {
|
||||
// Try refreshing the access token
|
||||
self.refresh_access_token().await?;
|
||||
let new_token =
|
||||
Self::load_access_token()?.ok_or_else(|| "Not logged in after refresh".to_string())?;
|
||||
make_request(new_token).await
|
||||
}
|
||||
Err(e) => Err(e),
|
||||
}
|
||||
}
|
||||
|
||||
/// Background loop that refreshes the sync token periodically
|
||||
pub async fn start_sync_token_refresh_loop(app_handle: tauri::AppHandle) {
|
||||
loop {
|
||||
tokio::time::sleep(std::time::Duration::from_secs(600)).await; // 10 minutes
|
||||
|
||||
if !CLOUD_AUTH.is_logged_in().await {
|
||||
continue;
|
||||
}
|
||||
|
||||
match CLOUD_AUTH.get_or_refresh_sync_token().await {
|
||||
Ok(Some(_)) => {
|
||||
log::debug!("Cloud sync token refreshed successfully");
|
||||
}
|
||||
Ok(None) => {}
|
||||
Err(e) => {
|
||||
log::warn!("Failed to refresh cloud sync token: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
// Also refresh the access token if needed
|
||||
if let Ok(Some(token)) = Self::load_access_token() {
|
||||
if Self::is_jwt_expiring_soon(&token) {
|
||||
if let Err(e) = CLOUD_AUTH.refresh_access_token().await {
|
||||
log::warn!("Failed to refresh cloud access token: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Refresh profile data periodically
|
||||
if let Err(e) = CLOUD_AUTH.fetch_profile().await {
|
||||
log::debug!("Failed to refresh cloud profile: {e}");
|
||||
}
|
||||
|
||||
let _ = &app_handle; // keep app_handle alive
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Tauri commands ---
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn cloud_request_otp(email: String) -> Result<String, String> {
|
||||
CLOUD_AUTH.request_otp(&email).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn cloud_verify_otp(
|
||||
app_handle: tauri::AppHandle,
|
||||
email: String,
|
||||
code: String,
|
||||
) -> Result<CloudAuthState, String> {
|
||||
let state = CLOUD_AUTH.verify_otp(&email, &code).await?;
|
||||
|
||||
// Pre-fetch sync token so sync can start immediately
|
||||
if CLOUD_AUTH.has_active_paid_subscription().await {
|
||||
if let Err(e) = CLOUD_AUTH.get_or_refresh_sync_token().await {
|
||||
log::warn!("Failed to pre-fetch sync token after login: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
let _ = &app_handle;
|
||||
Ok(state)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn cloud_get_user() -> Result<Option<CloudAuthState>, String> {
|
||||
Ok(CLOUD_AUTH.get_user().await)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn cloud_refresh_profile() -> Result<CloudUser, String> {
|
||||
CLOUD_AUTH.fetch_profile().await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn cloud_logout(app_handle: tauri::AppHandle) -> Result<(), String> {
|
||||
CLOUD_AUTH.logout().await?;
|
||||
let _ = &app_handle;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn cloud_has_active_subscription() -> Result<bool, String> {
|
||||
Ok(CLOUD_AUTH.has_active_paid_subscription().await)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn restart_sync_service(app_handle: tauri::AppHandle) -> Result<(), String> {
|
||||
// Stop existing scheduler
|
||||
if let Some(scheduler) = sync::get_global_scheduler() {
|
||||
scheduler.stop();
|
||||
}
|
||||
|
||||
// Restart sync pipeline
|
||||
let app_handle_sync = app_handle.clone();
|
||||
tauri::async_runtime::spawn(async move {
|
||||
let mut subscription_manager = sync::SubscriptionManager::new();
|
||||
let work_rx = subscription_manager.take_work_receiver();
|
||||
|
||||
if let Err(e) = subscription_manager.start(app_handle_sync.clone()).await {
|
||||
log::warn!("Failed to start sync subscription: {e}");
|
||||
return;
|
||||
}
|
||||
|
||||
if let Some(work_rx) = work_rx {
|
||||
let scheduler = Arc::new(sync::SyncScheduler::new());
|
||||
sync::set_global_scheduler(scheduler.clone());
|
||||
|
||||
scheduler.sync_all_enabled_profiles(&app_handle_sync).await;
|
||||
|
||||
match sync::SyncEngine::create_from_settings(&app_handle_sync).await {
|
||||
Ok(engine) => {
|
||||
if let Err(e) = engine
|
||||
.check_for_missing_synced_profiles(&app_handle_sync)
|
||||
.await
|
||||
{
|
||||
log::warn!("Failed to check for missing profiles: {}", e);
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
log::debug!("Sync not configured, skipping missing profile check: {}", e);
|
||||
}
|
||||
}
|
||||
|
||||
scheduler
|
||||
.clone()
|
||||
.start(app_handle_sync.clone(), work_rx)
|
||||
.await;
|
||||
log::info!("Sync scheduler restarted");
|
||||
}
|
||||
});
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
use muda::{Menu, MenuItem, PredefinedMenuItem, Submenu};
|
||||
use muda::{Menu, MenuItem, PredefinedMenuItem};
|
||||
use std::process::Command;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use tray_icon::{Icon, TrayIcon, TrayIconBuilder};
|
||||
@@ -6,8 +6,11 @@ use tray_icon::{Icon, TrayIcon, TrayIconBuilder};
|
||||
static GUI_RUNNING: AtomicBool = AtomicBool::new(false);
|
||||
|
||||
pub fn load_icon() -> Icon {
|
||||
// Use the generated template icon (44x44 for retina, macOS standard menu bar size)
|
||||
// This is the donut logo converted to template format (black with alpha)
|
||||
// On Windows, use the full-color icon so it renders well on dark taskbars.
|
||||
// On macOS/Linux, use the template icon (black with alpha) for system light/dark handling.
|
||||
#[cfg(target_os = "windows")]
|
||||
let icon_bytes = include_bytes!("../../icons/tray-icon-win-44.png");
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
let icon_bytes = include_bytes!("../../icons/tray-icon-44.png");
|
||||
|
||||
let image = image::load_from_memory(icon_bytes)
|
||||
@@ -23,10 +26,6 @@ pub fn load_icon() -> Icon {
|
||||
pub struct TrayMenu {
|
||||
pub menu: Menu,
|
||||
pub open_item: MenuItem,
|
||||
pub running_profiles_submenu: Submenu,
|
||||
pub api_status_item: MenuItem,
|
||||
pub mcp_status_item: MenuItem,
|
||||
pub preferences_item: MenuItem,
|
||||
pub quit_item: MenuItem,
|
||||
}
|
||||
|
||||
@@ -41,53 +40,19 @@ impl TrayMenu {
|
||||
let menu = Menu::new();
|
||||
|
||||
let open_item = MenuItem::new("Open Donut Browser", true, None);
|
||||
let running_profiles_submenu = Submenu::new("Running Profiles", true);
|
||||
let no_profiles_item = MenuItem::new("No running profiles", false, None);
|
||||
running_profiles_submenu.append(&no_profiles_item).unwrap();
|
||||
|
||||
let separator1 = PredefinedMenuItem::separator();
|
||||
let api_status_item = MenuItem::new("API: Starting...", false, None);
|
||||
let mcp_status_item = MenuItem::new("MCP: Starting...", false, None);
|
||||
let separator2 = PredefinedMenuItem::separator();
|
||||
let preferences_item = MenuItem::new("Preferences...", true, None);
|
||||
let separator = PredefinedMenuItem::separator();
|
||||
let quit_item = MenuItem::new("Quit Donut Browser", true, None);
|
||||
|
||||
menu.append(&open_item).unwrap();
|
||||
menu.append(&running_profiles_submenu).unwrap();
|
||||
menu.append(&separator1).unwrap();
|
||||
menu.append(&api_status_item).unwrap();
|
||||
menu.append(&mcp_status_item).unwrap();
|
||||
menu.append(&separator2).unwrap();
|
||||
menu.append(&preferences_item).unwrap();
|
||||
menu.append(&separator).unwrap();
|
||||
menu.append(&quit_item).unwrap();
|
||||
|
||||
Self {
|
||||
menu,
|
||||
open_item,
|
||||
running_profiles_submenu,
|
||||
api_status_item,
|
||||
mcp_status_item,
|
||||
preferences_item,
|
||||
quit_item,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn update_api_status(&self, port: Option<u16>) {
|
||||
let text = match port {
|
||||
Some(p) => format!("API: Running on :{}", p),
|
||||
None => "API: Stopped".to_string(),
|
||||
};
|
||||
self.api_status_item.set_text(&text);
|
||||
}
|
||||
|
||||
pub fn update_mcp_status(&self, running: bool) {
|
||||
let text = if running {
|
||||
"MCP: Running"
|
||||
} else {
|
||||
"MCP: Stopped"
|
||||
};
|
||||
self.mcp_status_item.set_text(text);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn create_tray_icon(icon: Icon, menu: &Menu) -> TrayIcon {
|
||||
@@ -121,6 +86,17 @@ pub fn open_gui() {
|
||||
{
|
||||
use std::path::PathBuf;
|
||||
|
||||
// In dev mode, find the main exe next to the daemon binary
|
||||
if let Ok(current_exe) = std::env::current_exe() {
|
||||
if let Some(exe_dir) = current_exe.parent() {
|
||||
let app_path = exe_dir.join("donutbrowser.exe");
|
||||
if app_path.exists() {
|
||||
let _ = Command::new(app_path).spawn();
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let paths = [
|
||||
dirs::data_local_dir().map(|p| p.join("Donut Browser").join("Donut Browser.exe")),
|
||||
Some(PathBuf::from(
|
||||
|
||||
@@ -1191,6 +1191,64 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn ensure_active_browsers_downloaded(
|
||||
app_handle: tauri::AppHandle,
|
||||
) -> Result<Vec<String>, String> {
|
||||
let registry = DownloadedBrowsersRegistry::instance();
|
||||
let version_manager = crate::browser_version_manager::BrowserVersionManager::instance();
|
||||
let mut downloaded = Vec::new();
|
||||
|
||||
for browser in &["wayfern", "camoufox"] {
|
||||
// Check if any version is already downloaded
|
||||
let existing = registry.get_downloaded_versions(browser);
|
||||
if !existing.is_empty() {
|
||||
log::debug!(
|
||||
"Skipping {browser}: already have {} version(s) downloaded",
|
||||
existing.len()
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Get the latest release type for this browser
|
||||
let release_types = match version_manager.get_browser_release_types(browser).await {
|
||||
Ok(rt) => rt,
|
||||
Err(e) => {
|
||||
log::warn!("Failed to get release types for {browser}: {e}");
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
// Use stable version (the only release type for these browsers)
|
||||
let version = match release_types.stable {
|
||||
Some(v) => v,
|
||||
None => {
|
||||
log::debug!("No stable version available for {browser} on this platform, skipping");
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
log::info!("Auto-downloading {browser} {version} (no versions found locally)");
|
||||
match crate::downloader::download_browser(
|
||||
app_handle.clone(),
|
||||
browser.to_string(),
|
||||
version.clone(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(_) => {
|
||||
downloaded.push(format!("{browser} {version}"));
|
||||
log::info!("Successfully auto-downloaded {browser} {version}");
|
||||
}
|
||||
Err(e) => {
|
||||
log::warn!("Failed to auto-download {browser} {version}: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(downloaded)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn get_downloaded_browser_versions(browser_str: String) -> Result<Vec<String>, String> {
|
||||
let registry = DownloadedBrowsersRegistry::instance();
|
||||
|
||||
@@ -870,6 +870,8 @@ impl Extractor {
|
||||
"chromium.exe",
|
||||
"zen.exe",
|
||||
"brave.exe",
|
||||
"camoufox.exe",
|
||||
"wayfern.exe",
|
||||
];
|
||||
|
||||
// First try priority executable names
|
||||
@@ -938,6 +940,8 @@ impl Extractor {
|
||||
|| file_name.contains("zen")
|
||||
|| file_name.contains("brave")
|
||||
|| file_name.contains("browser")
|
||||
|| file_name.contains("camoufox")
|
||||
|| file_name.contains("wayfern")
|
||||
{
|
||||
return Ok(path);
|
||||
}
|
||||
|
||||
+32
-2
@@ -37,6 +37,7 @@ pub mod traffic_stats;
|
||||
mod wayfern_manager;
|
||||
mod wayfern_terms;
|
||||
// mod theme_detector; // removed: theme detection handled in webview via CSS prefers-color-scheme
|
||||
pub mod cloud_auth;
|
||||
mod commercial_license;
|
||||
mod cookie_manager;
|
||||
pub mod daemon;
|
||||
@@ -66,7 +67,8 @@ use browser_version_manager::{
|
||||
};
|
||||
|
||||
use downloaded_browsers_registry::{
|
||||
check_missing_binaries, ensure_all_binaries_exist, get_downloaded_browser_versions,
|
||||
check_missing_binaries, ensure_active_browsers_downloaded, ensure_all_binaries_exist,
|
||||
get_downloaded_browser_versions,
|
||||
};
|
||||
|
||||
use downloader::{cancel_download, download_browser};
|
||||
@@ -654,6 +656,9 @@ pub fn run() {
|
||||
.focused(true)
|
||||
.visible(true);
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
let win_builder = win_builder.decorations(false);
|
||||
|
||||
#[allow(unused_variables)]
|
||||
let window = win_builder.build().unwrap();
|
||||
|
||||
@@ -1084,6 +1089,21 @@ pub fn run() {
|
||||
}
|
||||
});
|
||||
|
||||
// Start cloud auth background refresh loop
|
||||
let app_handle_cloud = app.handle().clone();
|
||||
tauri::async_runtime::spawn(async move {
|
||||
// On startup, refresh access token + sync token if cloud auth is active
|
||||
if cloud_auth::CLOUD_AUTH.is_logged_in().await {
|
||||
if let Err(e) = cloud_auth::CLOUD_AUTH.refresh_access_token().await {
|
||||
log::warn!("Failed to refresh cloud access token on startup: {e}");
|
||||
}
|
||||
if let Err(e) = cloud_auth::CLOUD_AUTH.get_or_refresh_sync_token().await {
|
||||
log::warn!("Failed to refresh cloud sync token on startup: {e}");
|
||||
}
|
||||
}
|
||||
cloud_auth::CloudAuthManager::start_sync_token_refresh_loop(app_handle_cloud).await;
|
||||
});
|
||||
|
||||
Ok(())
|
||||
})
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
@@ -1135,6 +1155,7 @@ pub fn run() {
|
||||
check_missing_binaries,
|
||||
check_missing_geoip_database,
|
||||
ensure_all_binaries_exist,
|
||||
ensure_active_browsers_downloaded,
|
||||
create_stored_proxy,
|
||||
get_stored_proxies,
|
||||
update_stored_proxy,
|
||||
@@ -1190,7 +1211,14 @@ pub fn run() {
|
||||
connect_vpn,
|
||||
disconnect_vpn,
|
||||
get_vpn_status,
|
||||
list_active_vpn_connections
|
||||
list_active_vpn_connections,
|
||||
// Cloud auth commands
|
||||
cloud_auth::cloud_request_otp,
|
||||
cloud_auth::cloud_verify_otp,
|
||||
cloud_auth::cloud_get_user,
|
||||
cloud_auth::cloud_refresh_profile,
|
||||
cloud_auth::cloud_logout,
|
||||
cloud_auth::restart_sync_service
|
||||
])
|
||||
.run(tauri::generate_context!())
|
||||
.expect("error while running tauri application");
|
||||
@@ -1340,6 +1368,8 @@ mod tests {
|
||||
// Remove trailing comma and whitespace
|
||||
let command = line.trim_end_matches(',').trim();
|
||||
if !command.is_empty() {
|
||||
// Strip module prefix (e.g., "cloud_auth::cloud_request_otp" -> "cloud_request_otp")
|
||||
let command = command.rsplit("::").next().unwrap_or(command);
|
||||
commands.push(command.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1415,11 +1415,16 @@ mod tests {
|
||||
.parent()
|
||||
.unwrap()
|
||||
.to_path_buf();
|
||||
let proxy_binary_name = if cfg!(windows) {
|
||||
"donut-proxy.exe"
|
||||
} else {
|
||||
"donut-proxy"
|
||||
};
|
||||
let proxy_binary = project_root
|
||||
.join("src-tauri")
|
||||
.join("target")
|
||||
.join("debug")
|
||||
.join("donut-proxy");
|
||||
.join(proxy_binary_name);
|
||||
|
||||
// Check if binary already exists
|
||||
if proxy_binary.exists() {
|
||||
|
||||
@@ -60,7 +60,7 @@ pub async fn start_proxy_process_with_profile(
|
||||
cmd.stdout(Stdio::null());
|
||||
|
||||
// Always log to file for diagnostics (both debug and release builds)
|
||||
let log_path = std::path::PathBuf::from("/tmp").join(format!("donut-proxy-{}.log", id));
|
||||
let log_path = std::env::temp_dir().join(format!("donut-proxy-{}.log", id));
|
||||
if let Ok(file) = std::fs::File::create(&log_path) {
|
||||
log::info!("Proxy worker stderr will be logged to: {:?}", log_path);
|
||||
cmd.stderr(Stdio::from(file));
|
||||
@@ -105,13 +105,28 @@ pub async fn start_proxy_process_with_profile(
|
||||
|
||||
#[cfg(windows)]
|
||||
{
|
||||
use std::os::windows::io::AsRawHandle;
|
||||
use std::os::windows::process::CommandExt;
|
||||
use std::process::Command as StdCommand;
|
||||
use windows::Win32::Foundation::CloseHandle;
|
||||
use windows::Win32::Foundation::{CloseHandle, SetHandleInformation, HANDLE, HANDLE_FLAGS};
|
||||
use windows::Win32::System::Threading::{
|
||||
OpenProcess, SetPriorityClass, ABOVE_NORMAL_PRIORITY_CLASS, PROCESS_SET_INFORMATION,
|
||||
};
|
||||
|
||||
// Mark current stdout/stderr as non-inheritable so the spawned worker process
|
||||
// does not inherit pipe handles from our parent (prevents blocking when parent exits).
|
||||
let stdout_handle = std::io::stdout().as_raw_handle();
|
||||
let stderr_handle = std::io::stderr().as_raw_handle();
|
||||
const HANDLE_FLAG_INHERIT: u32 = 0x00000001;
|
||||
unsafe {
|
||||
if !stdout_handle.is_null() {
|
||||
let _ = SetHandleInformation(HANDLE(stdout_handle), HANDLE_FLAG_INHERIT, HANDLE_FLAGS(0));
|
||||
}
|
||||
if !stderr_handle.is_null() {
|
||||
let _ = SetHandleInformation(HANDLE(stderr_handle), HANDLE_FLAG_INHERIT, HANDLE_FLAGS(0));
|
||||
}
|
||||
}
|
||||
|
||||
let mut cmd = StdCommand::new(&exe);
|
||||
cmd.arg("proxy-worker");
|
||||
cmd.arg("start");
|
||||
@@ -120,11 +135,20 @@ pub async fn start_proxy_process_with_profile(
|
||||
|
||||
cmd.stdin(Stdio::null());
|
||||
cmd.stdout(Stdio::null());
|
||||
cmd.stderr(Stdio::null());
|
||||
|
||||
// On Windows, use CREATE_NEW_PROCESS_GROUP flag for proper detachment
|
||||
// Log to file for diagnostics (matching Unix behavior)
|
||||
let log_path = std::env::temp_dir().join(format!("donut-proxy-{}.log", id));
|
||||
if let Ok(file) = std::fs::File::create(&log_path) {
|
||||
log::info!("Proxy worker stderr will be logged to: {:?}", log_path);
|
||||
cmd.stderr(Stdio::from(file));
|
||||
} else {
|
||||
cmd.stderr(Stdio::null());
|
||||
}
|
||||
|
||||
// On Windows, use DETACHED_PROCESS | CREATE_NEW_PROCESS_GROUP for proper detachment.
|
||||
const DETACHED_PROCESS: u32 = 0x00000008;
|
||||
const CREATE_NEW_PROCESS_GROUP: u32 = 0x00000200;
|
||||
cmd.creation_flags(CREATE_NEW_PROCESS_GROUP);
|
||||
cmd.creation_flags(DETACHED_PROCESS | CREATE_NEW_PROCESS_GROUP);
|
||||
|
||||
let child = cmd.spawn()?;
|
||||
let pid = child.id();
|
||||
|
||||
@@ -870,6 +870,19 @@ pub async fn save_table_sorting_settings(sorting: TableSortingSettings) -> Resul
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_sync_settings(app_handle: tauri::AppHandle) -> Result<SyncSettings, String> {
|
||||
// Cloud auth takes priority over self-hosted settings
|
||||
if crate::cloud_auth::CLOUD_AUTH.is_logged_in().await {
|
||||
let sync_token = crate::cloud_auth::CLOUD_AUTH
|
||||
.get_or_refresh_sync_token()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to get cloud sync token: {e}"))?;
|
||||
return Ok(SyncSettings {
|
||||
sync_server_url: Some(crate::cloud_auth::CLOUD_SYNC_URL.to_string()),
|
||||
sync_token,
|
||||
});
|
||||
}
|
||||
|
||||
// Fall back to self-hosted settings
|
||||
let manager = SettingsManager::instance();
|
||||
let mut sync_settings = manager
|
||||
.get_sync_settings()
|
||||
|
||||
@@ -24,6 +24,18 @@ impl SyncEngine {
|
||||
}
|
||||
|
||||
pub async fn create_from_settings(app_handle: &tauri::AppHandle) -> Result<Self, String> {
|
||||
// Cloud auth takes priority
|
||||
if crate::cloud_auth::CLOUD_AUTH.is_logged_in().await {
|
||||
let url = crate::cloud_auth::CLOUD_SYNC_URL.to_string();
|
||||
let token = crate::cloud_auth::CLOUD_AUTH
|
||||
.get_or_refresh_sync_token()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to get cloud sync token: {e}"))?
|
||||
.ok_or_else(|| "Cloud sync token not available".to_string())?;
|
||||
return Ok(Self::new(url, token));
|
||||
}
|
||||
|
||||
// Fall back to self-hosted settings
|
||||
let manager = SettingsManager::instance();
|
||||
let settings = manager
|
||||
.load_settings()
|
||||
|
||||
@@ -2,7 +2,6 @@ use super::engine::SyncEngine;
|
||||
use super::subscription::SyncWorkItem;
|
||||
use crate::events;
|
||||
use crate::profile::ProfileManager;
|
||||
use once_cell::sync::OnceCell;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::Arc;
|
||||
@@ -11,14 +10,16 @@ use tokio::sync::mpsc;
|
||||
use tokio::sync::Mutex;
|
||||
use tokio::time::sleep;
|
||||
|
||||
static GLOBAL_SCHEDULER: OnceCell<Arc<SyncScheduler>> = OnceCell::new();
|
||||
static GLOBAL_SCHEDULER: std::sync::Mutex<Option<Arc<SyncScheduler>>> = std::sync::Mutex::new(None);
|
||||
|
||||
pub fn get_global_scheduler() -> Option<Arc<SyncScheduler>> {
|
||||
GLOBAL_SCHEDULER.get().cloned()
|
||||
GLOBAL_SCHEDULER.lock().ok().and_then(|g| g.clone())
|
||||
}
|
||||
|
||||
pub fn set_global_scheduler(scheduler: Arc<SyncScheduler>) {
|
||||
let _ = GLOBAL_SCHEDULER.set(scheduler);
|
||||
if let Ok(mut g) = GLOBAL_SCHEDULER.lock() {
|
||||
*g = Some(scheduler);
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
|
||||
@@ -53,6 +53,20 @@ impl SyncSubscription {
|
||||
app_handle: &tauri::AppHandle,
|
||||
work_tx: mpsc::UnboundedSender<SyncWorkItem>,
|
||||
) -> Result<Option<Self>, String> {
|
||||
// Cloud auth takes priority
|
||||
if crate::cloud_auth::CLOUD_AUTH.is_logged_in().await {
|
||||
let url = crate::cloud_auth::CLOUD_SYNC_URL.to_string();
|
||||
let token = crate::cloud_auth::CLOUD_AUTH
|
||||
.get_or_refresh_sync_token()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to get cloud sync token: {e}"))?;
|
||||
let Some(token) = token else {
|
||||
return Ok(None);
|
||||
};
|
||||
return Ok(Some(Self::new(url, token, work_tx)));
|
||||
}
|
||||
|
||||
// Fall back to self-hosted settings
|
||||
let manager = SettingsManager::instance();
|
||||
let settings = manager
|
||||
.load_settings()
|
||||
|
||||
@@ -265,6 +265,23 @@ impl VersionUpdater {
|
||||
app_handle: &tauri::AppHandle,
|
||||
) -> Result<Vec<BackgroundUpdateResult>, Box<dyn std::error::Error + Send + Sync>> {
|
||||
let supported_browsers = self.browser_version_manager.get_supported_browsers();
|
||||
|
||||
// Only fetch versions for active browsers (wayfern, camoufox) plus any
|
||||
// deprecated browsers that still have existing profiles
|
||||
let active_browsers = ["wayfern", "camoufox"];
|
||||
let browsers_with_profiles: std::collections::HashSet<String> =
|
||||
crate::profile::ProfileManager::instance()
|
||||
.list_profiles()
|
||||
.unwrap_or_default()
|
||||
.iter()
|
||||
.map(|p| p.browser.clone())
|
||||
.collect();
|
||||
|
||||
let supported_browsers: Vec<String> = supported_browsers
|
||||
.into_iter()
|
||||
.filter(|b| active_browsers.contains(&b.as_str()) || browsers_with_profiles.contains(b))
|
||||
.collect();
|
||||
|
||||
let total_browsers = supported_browsers.len();
|
||||
let mut results = Vec::new();
|
||||
let mut total_new_versions = 0;
|
||||
|
||||
Reference in New Issue
Block a user