refactor: cleanup

This commit is contained in:
zhom
2026-05-29 06:31:42 +04:00
parent 17e33aa53f
commit ecafb5e1c0
40 changed files with 484 additions and 2344 deletions
-4
View File
@@ -88,7 +88,6 @@ jobs:
working-directory: ./src-tauri
run: |
cargo build --bin donut-proxy --release
cargo build --bin donut-daemon --release
- name: Copy sidecar binaries to Tauri binaries
shell: bash
@@ -97,12 +96,9 @@ jobs:
HOST_TARGET="${{ steps.host_target.outputs.target }}"
if [[ "$HOST_TARGET" == *"windows"* ]]; then
cp src-tauri/target/release/donut-proxy.exe src-tauri/binaries/donut-proxy-${HOST_TARGET}.exe
cp src-tauri/target/release/donut-daemon.exe src-tauri/binaries/donut-daemon-${HOST_TARGET}.exe
else
cp src-tauri/target/release/donut-proxy src-tauri/binaries/donut-proxy-${HOST_TARGET}
cp src-tauri/target/release/donut-daemon src-tauri/binaries/donut-daemon-${HOST_TARGET}
chmod +x src-tauri/binaries/donut-proxy-${HOST_TARGET}
chmod +x src-tauri/binaries/donut-daemon-${HOST_TARGET}
fi
- name: Run rustfmt check
-4
View File
@@ -162,7 +162,6 @@ jobs:
working-directory: ./src-tauri
run: |
cargo build --bin donut-proxy --target ${{ matrix.target }} --release
cargo build --bin donut-daemon --target ${{ matrix.target }} --release
- name: Copy sidecar binaries to Tauri binaries
shell: bash
@@ -170,12 +169,9 @@ jobs:
mkdir -p src-tauri/binaries
if [[ "${{ matrix.platform }}" == "windows-latest" ]]; then
cp src-tauri/target/${{ matrix.target }}/release/donut-proxy.exe src-tauri/binaries/donut-proxy-${{ matrix.target }}.exe
cp src-tauri/target/${{ matrix.target }}/release/donut-daemon.exe src-tauri/binaries/donut-daemon-${{ matrix.target }}.exe
else
cp src-tauri/target/${{ matrix.target }}/release/donut-proxy src-tauri/binaries/donut-proxy-${{ matrix.target }}
cp src-tauri/target/${{ matrix.target }}/release/donut-daemon src-tauri/binaries/donut-daemon-${{ matrix.target }}
chmod +x src-tauri/binaries/donut-proxy-${{ matrix.target }}
chmod +x src-tauri/binaries/donut-daemon-${{ matrix.target }}
fi
- name: Import Apple certificate
-4
View File
@@ -161,7 +161,6 @@ jobs:
working-directory: ./src-tauri
run: |
cargo build --bin donut-proxy --target ${{ matrix.target }} --release
cargo build --bin donut-daemon --target ${{ matrix.target }} --release
- name: Copy sidecar binaries to Tauri binaries
shell: bash
@@ -169,12 +168,9 @@ jobs:
mkdir -p src-tauri/binaries
if [[ "${{ matrix.platform }}" == "windows-latest" ]]; then
cp src-tauri/target/${{ matrix.target }}/release/donut-proxy.exe src-tauri/binaries/donut-proxy-${{ matrix.target }}.exe
cp src-tauri/target/${{ matrix.target }}/release/donut-daemon.exe src-tauri/binaries/donut-daemon-${{ matrix.target }}.exe
else
cp src-tauri/target/${{ matrix.target }}/release/donut-proxy src-tauri/binaries/donut-proxy-${{ matrix.target }}
cp src-tauri/target/${{ matrix.target }}/release/donut-daemon src-tauri/binaries/donut-daemon-${{ matrix.target }}
chmod +x src-tauri/binaries/donut-proxy-${{ matrix.target }}
chmod +x src-tauri/binaries/donut-daemon-${{ matrix.target }}
fi
- name: Import Apple certificate
-1
View File
@@ -3,7 +3,6 @@ extend-exclude = [
"src-tauri/src/camoufox/data/*.json",
"src-tauri/src/camoufox/data/*.xml",
"src/i18n/locales/*.json",
"src-tauri/build.rs",
# Auto-generated from commit subjects by release.yml; typos here originate
# in commit messages, which are immutable, so don't spell-check it.
"CHANGELOG.md",
+1 -44
View File
@@ -1840,7 +1840,6 @@ dependencies = [
"smoltcp",
"sys-locale",
"sysinfo",
"tao",
"tar",
"tauri",
"tauri-build",
@@ -1861,7 +1860,6 @@ dependencies = [
"toml 1.1.2+spec-1.1.0",
"tower",
"tower-http",
"tray-icon 0.24.0",
"url",
"urlencoding",
"utoipa",
@@ -3667,25 +3665,6 @@ dependencies = [
"vcpkg",
]
[[package]]
name = "libxdo"
version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "00333b8756a3d28e78def82067a377de7fa61b24909000aeaa2b446a948d14db"
dependencies = [
"libxdo-sys",
]
[[package]]
name = "libxdo-sys"
version = "0.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "db23b9e7e2b7831bbd8aac0bbeeeb7b68cbebc162b227e7052e8e55829a09212"
dependencies = [
"libc",
"x11",
]
[[package]]
name = "linux-raw-sys"
version = "0.12.1"
@@ -3923,7 +3902,6 @@ dependencies = [
"dpi",
"gtk",
"keyboard-types",
"libxdo",
"objc2",
"objc2-app-kit",
"objc2-core-foundation",
@@ -6633,7 +6611,7 @@ dependencies = [
"tauri-utils",
"thiserror 2.0.18",
"tokio",
"tray-icon 0.23.1",
"tray-icon",
"url",
"webkit2gtk",
"webview2-com",
@@ -7507,27 +7485,6 @@ dependencies = [
"windows-sys 0.60.2",
]
[[package]]
name = "tray-icon"
version = "0.24.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e47e6d063cfe4ad2e416fcbb310be3a37c5fd85c745b62cb562bfa4a003df674"
dependencies = [
"crossbeam-channel",
"dirs",
"libappindicator",
"muda",
"objc2",
"objc2-app-kit",
"objc2-core-foundation",
"objc2-core-graphics",
"objc2-foundation",
"once_cell",
"png 0.18.1",
"thiserror 2.0.18",
"windows-sys 0.60.2",
]
[[package]]
name = "tree_magic_mini"
version = "3.2.2"
+1 -7
View File
@@ -24,10 +24,6 @@ path = "src/main.rs"
name = "donut-proxy"
path = "src/bin/proxy_server.rs"
[[bin]]
name = "donut-daemon"
path = "src/bin/donut_daemon.rs"
[build-dependencies]
tauri-build = { version = "2", features = [] }
resvg = "0.47"
@@ -111,9 +107,7 @@ quick-xml = { version = "0.40", features = ["serialize"] }
boringtun = "0.7"
smoltcp = { version = "0.13", default-features = false, features = ["std", "medium-ip", "proto-ipv4", "proto-ipv6", "socket-tcp", "socket-udp"] }
# Daemon dependencies (tray icon)
tray-icon = "0.24"
tao = "0.35"
# Tray icon decoding (main-process system tray)
image = "0.25"
dirs = "6"
crossbeam-channel = "0.5"
+5 -11
View File
@@ -5,7 +5,7 @@ fn main() {
// This allows running cargo test without building the frontend first
ensure_dist_folder_exists();
// Generate tray icon PNGs from SVG (macOS template icon format)
// Generate tray icon PNG files from SVG (macOS template icon format)
generate_tray_icons();
#[cfg(target_os = "macos")]
@@ -93,19 +93,13 @@ fn external_binaries_exist() -> bool {
let binaries_dir = PathBuf::from(&manifest_dir).join("binaries");
// Check for all required external binaries (must match tauri.conf.json externalBin)
let (donut_proxy_name, donut_daemon_name) = if target.contains("windows") {
(
format!("donut-proxy-{}.exe", target),
format!("donut-daemon-{}.exe", target),
)
let donut_proxy_name = if target.contains("windows") {
format!("donut-proxy-{}.exe", target)
} else {
(
format!("donut-proxy-{}", target),
format!("donut-daemon-{}", target),
)
format!("donut-proxy-{}", target)
};
binaries_dir.join(&donut_proxy_name).exists() && binaries_dir.join(&donut_daemon_name).exists()
binaries_dir.join(&donut_proxy_name).exists()
}
fn ensure_dist_folder_exists() {
-1
View File
@@ -77,4 +77,3 @@ function copyBinary(baseName) {
}
copyBinary("donut-proxy");
copyBinary("donut-daemon");
-3
View File
@@ -102,6 +102,3 @@ copy_binary() {
# Copy donut-proxy binary
copy_binary "donut-proxy"
# Copy donut-daemon binary
copy_binary "donut-daemon"
-8
View File
@@ -1,6 +1,5 @@
use crate::browser::ProxySettings;
use crate::camoufox_manager::CamoufoxConfig;
use crate::daemon_ws::{ws_handler, WsState};
use crate::events;
use crate::group_manager::GROUP_MANAGER;
use crate::profile::manager::ProfileManager;
@@ -412,16 +411,9 @@ impl ApiServer {
))
.layer(middleware::from_fn(terms_check_middleware));
// Create WebSocket route with its own state (no auth required for daemon IPC)
let ws_state = WsState::new();
let ws_routes = Router::new()
.route("/events", get(ws_handler))
.with_state(ws_state);
let api_for_v1 = api.clone();
let app = Router::new()
.merge(v1_routes)
.nest("/ws", ws_routes)
.route("/openapi.json", get(move || async move { Json(api) }))
.route(
"/v1/openapi.json",
-498
View File
@@ -1,498 +0,0 @@
// Donut Browser Daemon - Background process for tray icon and services
// This runs independently of the main Tauri GUI
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
use std::env;
use std::fs;
use std::path::PathBuf;
use std::process;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::mpsc;
use std::time::{Duration, Instant};
use serde::{Deserialize, Serialize};
use tao::event::{Event, StartCause};
use tao::event_loop::{ControlFlow, EventLoopBuilder};
use tokio::runtime::Runtime;
use tray_icon::menu::MenuEvent;
use tray_icon::TrayIcon;
#[cfg(not(target_os = "macos"))]
use tray_icon::{MouseButton, TrayIconEvent};
use donutbrowser_lib::daemon::{autostart, services, tray};
static SHOULD_QUIT: AtomicBool = AtomicBool::new(false);
#[cfg(windows)]
fn win_process_exists(pid: u32) -> bool {
const PROCESS_QUERY_LIMITED_INFORMATION: u32 = 0x1000;
extern "system" {
fn OpenProcess(dwDesiredAccess: u32, bInheritHandles: i32, dwProcessId: u32) -> *mut ();
fn CloseHandle(hObject: *mut ()) -> i32;
}
let handle = unsafe { OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, 0, pid) };
if handle.is_null() {
false
} else {
unsafe { CloseHandle(handle) };
true
}
}
enum ServiceStatus {
Ready {
api_port: Option<u16>,
mcp_running: bool,
},
Failed(String),
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
struct DaemonState {
daemon_pid: Option<u32>,
api_port: Option<u16>,
mcp_running: bool,
version: String,
}
fn get_state_path() -> PathBuf {
autostart::get_data_dir()
.unwrap_or_else(|| PathBuf::from("."))
.join("daemon-state.json")
}
fn ensure_data_dir() -> std::io::Result<()> {
if let Some(data_dir) = autostart::get_data_dir() {
fs::create_dir_all(&data_dir)?;
}
Ok(())
}
fn read_state() -> DaemonState {
let path = get_state_path();
if path.exists() {
if let Ok(content) = fs::read_to_string(&path) {
if let Ok(state) = serde_json::from_str(&content) {
return state;
}
}
}
DaemonState::default()
}
fn write_state(state: &DaemonState) -> std::io::Result<()> {
let path = get_state_path();
let content = serde_json::to_string_pretty(state)?;
fs::write(path, content)
}
fn set_high_priority() {
#[cfg(unix)]
{
// Set high priority so the daemon is killed last under resource pressure
// Negative nice value = higher priority. Try -10, fall back to -5 if it fails.
unsafe {
if libc::setpriority(libc::PRIO_PROCESS, 0, -10) != 0 {
let _ = libc::setpriority(libc::PRIO_PROCESS, 0, -5);
}
}
}
#[cfg(windows)]
{
use windows::Win32::Foundation::CloseHandle;
use windows::Win32::System::Threading::{
GetCurrentProcess, SetPriorityClass, ABOVE_NORMAL_PRIORITY_CLASS,
};
// Set high priority so the daemon is killed last under resource pressure
unsafe {
let handle = GetCurrentProcess();
let _ = SetPriorityClass(handle, ABOVE_NORMAL_PRIORITY_CLASS);
// GetCurrentProcess returns a pseudo-handle that doesn't need to be closed,
// but we do it anyway for consistency
let _ = CloseHandle(handle);
}
}
}
fn run_daemon() {
// Set high priority so the daemon is less likely to be killed under resource pressure
set_high_priority();
// Initialize logging to file for debugging (since stdout/stderr may be redirected)
let log_path = autostart::get_data_dir()
.unwrap_or_else(|| std::path::PathBuf::from("."))
.join("daemon.log");
let log_file = std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(&log_path);
env_logger::Builder::from_default_env()
.filter_level(log::LevelFilter::Info)
.format_timestamp_millis()
.target(if let Ok(file) = log_file {
env_logger::Target::Pipe(Box::new(file))
} else {
env_logger::Target::Stderr
})
.init();
if let Err(e) = ensure_data_dir() {
eprintln!("Failed to create data directory: {}", e);
process::exit(1);
}
log::info!("[daemon] Starting with PID {}", process::id());
// Create tokio runtime for async operations
let rt = Runtime::new().expect("Failed to create tokio runtime");
// Create channel for service status updates
let (tx, rx) = mpsc::channel::<ServiceStatus>();
// Spawn services in a background thread so we don't block the event loop
let rt_handle = rt.handle().clone();
std::thread::spawn(move || {
let result = rt_handle.block_on(async { services::DaemonServices::start().await });
let status = match result {
Ok(s) => ServiceStatus::Ready {
api_port: s.api_port,
mcp_running: s.mcp_running,
},
Err(e) => ServiceStatus::Failed(e),
};
let _ = tx.send(status);
});
// Write initial state (services still starting)
let state = DaemonState {
daemon_pid: Some(process::id()),
api_port: None,
mcp_running: false,
version: env!("CARGO_PKG_VERSION").to_string(),
};
if let Err(e) = write_state(&state) {
log::error!("Failed to write state: {}", e);
}
// Prepare tray menu and icon (but don't create the tray icon yet)
let tray_menu = tray::TrayMenu::new();
let icon = tray::load_icon();
let menu_channel = MenuEvent::receiver();
// Create the event loop IMMEDIATELY (critical for macOS tray icon)
let event_loop = EventLoopBuilder::new().build();
// Store tray icon in Option - created after event loop starts
let mut tray_icon: Option<TrayIcon> = None;
// Install signal handlers so SIGTERM/SIGINT trigger graceful shutdown
#[cfg(unix)]
unsafe {
extern "C" fn signal_handler(_sig: libc::c_int) {
SHOULD_QUIT.store(true, std::sync::atomic::Ordering::SeqCst);
}
libc::signal(
libc::SIGTERM,
signal_handler as *const () as libc::sighandler_t,
);
libc::signal(
libc::SIGINT,
signal_handler as *const () as libc::sighandler_t,
);
}
#[cfg(windows)]
{
extern "system" {
fn SetConsoleCtrlHandler(
handler: Option<unsafe extern "system" fn(u32) -> i32>,
add: i32,
) -> i32;
}
unsafe extern "system" fn ctrl_handler(_ctrl_type: u32) -> i32 {
SHOULD_QUIT.store(true, std::sync::atomic::Ordering::SeqCst);
1 // TRUE
}
unsafe {
SetConsoleCtrlHandler(Some(ctrl_handler), 1);
}
}
// Run the event loop
event_loop.run(move |event, _, control_flow| {
// Use WaitUntil to check for menu events periodically while staying low on CPU
*control_flow = ControlFlow::WaitUntil(Instant::now() + Duration::from_millis(100));
match event {
Event::NewEvents(StartCause::Init) => {
// Hide from dock on macOS (must be done after event loop starts)
#[cfg(target_os = "macos")]
{
use objc2::MainThreadMarker;
use objc2_app_kit::{NSApplication, NSApplicationActivationPolicy};
if let Some(mtm) = MainThreadMarker::new() {
let app = NSApplication::sharedApplication(mtm);
app.setActivationPolicy(NSApplicationActivationPolicy::Accessory);
}
}
// Create tray icon after event loop has started (required for macOS)
tray_icon = Some(tray::create_tray_icon(icon.clone(), &tray_menu.menu));
log::info!("[daemon] Tray icon created");
}
Event::MainEventsCleared => {
// Check for service status updates from background thread
if let Ok(status) = rx.try_recv() {
match status {
ServiceStatus::Ready {
api_port,
mcp_running,
} => {
log::info!("[daemon] Services started successfully");
// Update state file
let mut state = read_state();
state.api_port = api_port;
state.mcp_running = mcp_running;
if let Err(e) = write_state(&state) {
log::error!("Failed to write state: {}", e);
}
}
ServiceStatus::Failed(e) => {
log::error!("Failed to start services: {}", e);
}
}
}
// Process menu events
while let Ok(event) = menu_channel.try_recv() {
if event.id == tray_menu.quit_item.id() {
log::info!("[daemon] Quit requested");
SHOULD_QUIT.store(true, Ordering::SeqCst);
}
}
// Handle tray icon click (left-click opens the app)
// On macOS, left-click already shows the menu, so don't also launch the GUI.
#[cfg(not(target_os = "macos"))]
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) {
// Remove tray icon from status bar immediately so the UI feels responsive
tray_icon = None;
tray::quit_gui();
let mut state = read_state();
state.daemon_pid = None;
let _ = write_state(&state);
log::info!("[daemon] Exiting");
// Use process::exit for immediate termination instead of ControlFlow::Exit.
// ControlFlow::Exit can delay because tao's macOS event loop defers exit,
// and dropping the tokio runtime blocks until all spawned tasks finish.
process::exit(0);
}
}
Event::Reopen { .. } => {
tray::open_gui();
// Re-hide daemon from Dock. macOS activates the daemon (making it
// visible) when the user clicks the Dock icon, overriding the
// Accessory policy set at init.
#[cfg(target_os = "macos")]
{
use objc2::MainThreadMarker;
use objc2_app_kit::{NSApplication, NSApplicationActivationPolicy};
if let Some(mtm) = MainThreadMarker::new() {
let app = NSApplication::sharedApplication(mtm);
app.setActivationPolicy(NSApplicationActivationPolicy::Accessory);
}
}
}
_ => {}
}
// Keep tray_icon alive
let _ = &tray_icon;
// Keep runtime alive
let _ = &rt;
});
}
fn stop_daemon() {
let state = read_state();
if let Some(pid) = state.daemon_pid {
// On Windows, taskkill /F kills instantly with no handler, so kill GUI first
#[cfg(windows)]
{
use std::os::windows::process::CommandExt;
use std::process::Command;
const CREATE_NO_WINDOW: u32 = 0x08000000;
let state_path = get_state_path();
if let Ok(content) = fs::read_to_string(&state_path) {
if let Ok(val) = serde_json::from_str::<serde_json::Value>(&content) {
if let Some(gui_pid) = val.get("gui_pid").and_then(|v| v.as_u64()) {
let _ = Command::new("taskkill")
.args(["/PID", &gui_pid.to_string(), "/F"])
.creation_flags(CREATE_NO_WINDOW)
.output();
}
}
}
let _ = Command::new("taskkill")
.args(["/PID", &pid.to_string(), "/F"])
.creation_flags(CREATE_NO_WINDOW)
.output();
eprintln!("Sent stop signal to daemon (PID {})", pid);
}
#[cfg(unix)]
{
unsafe {
libc::kill(pid as i32, libc::SIGTERM);
}
eprintln!("Sent stop signal to daemon (PID {})", pid);
}
} else {
eprintln!("Daemon is not running");
}
}
fn show_status() {
let state = read_state();
if let Some(pid) = state.daemon_pid {
#[cfg(unix)]
let is_running = unsafe { libc::kill(pid as i32, 0) == 0 };
#[cfg(windows)]
let is_running = win_process_exists(pid);
#[cfg(not(any(unix, windows)))]
let is_running = false;
if is_running {
eprintln!("Daemon is running (PID {})", pid);
if let Some(port) = state.api_port {
eprintln!(" API: Running on port {}", port);
} else {
eprintln!(" API: Stopped");
}
eprintln!(
" MCP: {}",
if state.mcp_running {
"Running"
} else {
"Stopped"
}
);
} else {
eprintln!("Daemon is not running (stale PID in state file)");
}
} else {
eprintln!("Daemon is not running");
}
}
fn print_usage() {
eprintln!("Donut Browser Daemon");
eprintln!();
eprintln!("Usage: donut-daemon <command>");
eprintln!();
eprintln!("Commands:");
eprintln!(" start Start the daemon (detaches from terminal)");
eprintln!(" stop Stop the running daemon");
eprintln!(" status Show daemon status");
eprintln!(" run Run in foreground (for debugging)");
eprintln!(" autostart Manage autostart settings");
eprintln!(" enable Enable autostart on login");
eprintln!(" disable Disable autostart on login");
eprintln!(" status Show autostart status");
}
fn main() {
let args: Vec<String> = env::args().collect();
if args.len() < 2 {
print_usage();
process::exit(1);
}
match args[1].as_str() {
"start" => {
run_daemon();
}
"stop" => {
stop_daemon();
}
"status" => {
show_status();
}
"run" => {
run_daemon();
}
"autostart" => {
if args.len() < 3 {
eprintln!("Usage: donut-daemon autostart <enable|disable|status>");
process::exit(1);
}
match args[2].as_str() {
"enable" => {
if let Err(e) = autostart::enable_autostart() {
eprintln!("Failed to enable autostart: {}", e);
process::exit(1);
}
eprintln!("Autostart enabled");
}
"disable" => {
if let Err(e) = autostart::disable_autostart() {
eprintln!("Failed to disable autostart: {}", e);
process::exit(1);
}
eprintln!("Autostart disabled");
}
"status" => {
if autostart::is_autostart_enabled() {
eprintln!("Autostart is enabled");
} else {
eprintln!("Autostart is disabled");
}
}
_ => {
eprintln!("Unknown autostart command: {}", args[2]);
process::exit(1);
}
}
}
_ => {
print_usage();
process::exit(1);
}
}
}
-351
View File
@@ -1,351 +0,0 @@
use directories::ProjectDirs;
#[cfg(any(target_os = "macos", target_os = "linux"))]
use std::fs;
use std::io;
use std::path::PathBuf;
fn get_daemon_path() -> Option<PathBuf> {
// First try to find the daemon binary in the same directory as the current executable
if let Ok(current_exe) = std::env::current_exe() {
let daemon_path = current_exe.parent()?.join(daemon_binary_name());
if daemon_path.exists() {
return Some(daemon_path);
}
}
// Try common installation paths
#[cfg(target_os = "macos")]
{
let paths = [
PathBuf::from("/Applications/Donut Browser.app/Contents/MacOS/donut-daemon"),
dirs::home_dir()?.join("Applications/Donut Browser.app/Contents/MacOS/donut-daemon"),
];
for path in paths {
if path.exists() {
return Some(path);
}
}
}
#[cfg(target_os = "windows")]
{
let paths = [
dirs::data_local_dir()?.join("Donut Browser/donut-daemon.exe"),
PathBuf::from("C:\\Program Files\\Donut Browser\\donut-daemon.exe"),
];
for path in paths {
if path.exists() {
return Some(path);
}
}
}
#[cfg(target_os = "linux")]
{
let paths = [
PathBuf::from("/usr/bin/donut-daemon"),
PathBuf::from("/usr/local/bin/donut-daemon"),
dirs::home_dir()?.join(".local/bin/donut-daemon"),
];
for path in paths {
if path.exists() {
return Some(path);
}
}
}
None
}
fn daemon_binary_name() -> &'static str {
#[cfg(windows)]
{
"donut-daemon.exe"
}
#[cfg(not(windows))]
{
"donut-daemon"
}
}
#[cfg(target_os = "macos")]
pub fn enable_autostart() -> io::Result<()> {
let daemon_path = get_daemon_path()
.ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "Daemon binary not found"))?;
let plist_dir = dirs::home_dir()
.ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "Home directory not found"))?
.join("Library/LaunchAgents");
fs::create_dir_all(&plist_dir)?;
let plist_path = plist_dir.join("com.donutbrowser.daemon.plist");
// Get log directory (use data directory instead of /tmp)
let log_dir = get_data_dir()
.unwrap_or_else(|| PathBuf::from("/tmp"))
.join("logs");
fs::create_dir_all(&log_dir)?;
let plist_content = format!(
r#"<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>com.donutbrowser.daemon</string>
<key>ProgramArguments</key>
<array>
<string>{daemon_path}</string>
<string>run</string>
</array>
<key>RunAtLoad</key>
<true/>
<key>LimitLoadToSessionType</key>
<string>Aqua</string>
<key>ProcessType</key>
<string>Interactive</string>
<key>StandardOutPath</key>
<string>{log_dir}/daemon.out.log</string>
<key>StandardErrorPath</key>
<string>{log_dir}/daemon.err.log</string>
</dict>
</plist>
"#,
daemon_path = daemon_path.display(),
log_dir = log_dir.display()
);
fs::write(&plist_path, plist_content)?;
log::info!("Created launch agent at {:?}", plist_path);
Ok(())
}
#[cfg(target_os = "macos")]
pub fn get_plist_path() -> Option<PathBuf> {
dirs::home_dir().map(|h| h.join("Library/LaunchAgents/com.donutbrowser.daemon.plist"))
}
#[cfg(target_os = "macos")]
pub fn disable_autostart() -> io::Result<()> {
let plist_path = get_plist_path()
.ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "Home directory not found"))?;
if plist_path.exists() {
// First unload the launch agent if it's loaded
let _ = unload_launch_agent();
fs::remove_file(&plist_path)?;
log::info!("Removed launch agent at {:?}", plist_path);
}
Ok(())
}
#[cfg(target_os = "macos")]
pub fn is_autostart_enabled() -> bool {
get_plist_path().is_some_and(|p| p.exists())
}
#[cfg(target_os = "macos")]
pub fn load_launch_agent() -> io::Result<()> {
use std::process::Command;
let plist_path = get_plist_path()
.ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "Could not determine plist path"))?;
if !plist_path.exists() {
return Err(io::Error::new(
io::ErrorKind::NotFound,
"Launch agent plist does not exist",
));
}
// Use launchctl load to start the daemon via launchd
// The -w flag writes the "disabled" key to the override plist
let output = Command::new("launchctl")
.args(["load", "-w"])
.arg(&plist_path)
.output()?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
// "already loaded" is not an error condition for us
if !stderr.contains("already loaded") {
return Err(io::Error::other(format!(
"launchctl load failed: {}",
stderr
)));
}
}
log::info!("Loaded launch agent via launchctl");
Ok(())
}
#[cfg(target_os = "macos")]
pub fn start_launch_agent() -> io::Result<()> {
use std::process::Command;
let output = Command::new("launchctl")
.args(["start", "com.donutbrowser.daemon"])
.output()?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(io::Error::other(format!(
"launchctl start failed: {}",
stderr
)));
}
log::info!("Started launch agent via launchctl");
Ok(())
}
#[cfg(target_os = "macos")]
pub fn unload_launch_agent() -> io::Result<()> {
use std::process::Command;
let plist_path = get_plist_path()
.ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "Could not determine plist path"))?;
if !plist_path.exists() {
return Ok(());
}
let output = Command::new("launchctl")
.args(["unload"])
.arg(&plist_path)
.output()?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
// Not being loaded is not an error
if !stderr.contains("Could not find specified service") {
log::warn!("launchctl unload warning: {}", stderr);
}
}
log::info!("Unloaded launch agent via launchctl");
Ok(())
}
#[cfg(target_os = "linux")]
pub fn enable_autostart() -> io::Result<()> {
let daemon_path = get_daemon_path()
.ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "Daemon binary not found"))?;
let autostart_dir = dirs::config_dir()
.ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "Config directory not found"))?
.join("autostart");
fs::create_dir_all(&autostart_dir)?;
let desktop_path = autostart_dir.join("donut-daemon.desktop");
let escaped_daemon_path = daemon_path
.display()
.to_string()
.replace('\\', "\\\\")
.replace('"', "\\\"")
.replace('`', "\\`")
.replace('$', "\\$");
let desktop_content = format!(
r#"[Desktop Entry]
Type=Application
Name=Donut Browser Daemon
Exec="{escaped_daemon_path}" run
Hidden=false
NoDisplay=true
X-GNOME-Autostart-enabled=true
"#,
);
fs::write(&desktop_path, desktop_content)?;
log::info!("Created autostart entry at {:?}", desktop_path);
Ok(())
}
#[cfg(target_os = "linux")]
pub fn disable_autostart() -> io::Result<()> {
let desktop_path = dirs::config_dir()
.ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "Config directory not found"))?
.join("autostart/donut-daemon.desktop");
if desktop_path.exists() {
fs::remove_file(&desktop_path)?;
log::info!("Removed autostart entry at {:?}", desktop_path);
}
Ok(())
}
#[cfg(target_os = "linux")]
pub fn is_autostart_enabled() -> bool {
dirs::config_dir()
.map(|c| c.join("autostart/donut-daemon.desktop").exists())
.unwrap_or(false)
}
#[cfg(target_os = "windows")]
pub fn enable_autostart() -> io::Result<()> {
use winreg::enums::HKEY_CURRENT_USER;
use winreg::RegKey;
let daemon_path = get_daemon_path()
.ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "Daemon binary not found"))?;
let hkcu = RegKey::predef(HKEY_CURRENT_USER);
let (key, _) = hkcu.create_subkey("Software\\Microsoft\\Windows\\CurrentVersion\\Run")?;
key.set_value(
"DonutBrowserDaemon",
&format!("\"{}\" run", daemon_path.display()),
)?;
log::info!("Added registry autostart entry");
Ok(())
}
#[cfg(target_os = "windows")]
pub fn disable_autostart() -> io::Result<()> {
use winreg::enums::HKEY_CURRENT_USER;
use winreg::RegKey;
let hkcu = RegKey::predef(HKEY_CURRENT_USER);
if let Ok(key) = hkcu.open_subkey_with_flags(
"Software\\Microsoft\\Windows\\CurrentVersion\\Run",
winreg::enums::KEY_WRITE,
) {
let _ = key.delete_value("DonutBrowserDaemon");
log::info!("Removed registry autostart entry");
}
Ok(())
}
#[cfg(target_os = "windows")]
pub fn is_autostart_enabled() -> bool {
use winreg::enums::HKEY_CURRENT_USER;
use winreg::RegKey;
let hkcu = RegKey::predef(HKEY_CURRENT_USER);
if let Ok(key) = hkcu.open_subkey("Software\\Microsoft\\Windows\\CurrentVersion\\Run") {
key.get_value::<String, _>("DonutBrowserDaemon").is_ok()
} else {
false
}
}
pub fn get_data_dir() -> Option<PathBuf> {
if crate::app_dirs::is_portable() {
return Some(crate::app_dirs::data_dir());
}
if let Some(proj_dirs) = ProjectDirs::from("com", "donutbrowser", "Donut Browser") {
Some(proj_dirs.data_dir().to_path_buf())
} else {
dirs::home_dir().map(|h| h.join(".donutbrowser"))
}
}
-3
View File
@@ -1,3 +0,0 @@
pub mod autostart;
pub mod services;
pub mod tray;
-51
View File
@@ -1,51 +0,0 @@
use crate::events::{self, DaemonEmitter, DaemonEvent};
use std::sync::Arc;
use tokio::sync::broadcast;
pub struct DaemonServices {
pub api_port: Option<u16>,
pub mcp_running: bool,
event_emitter: Arc<DaemonEmitter>,
}
impl DaemonServices {
pub async fn start() -> Result<Self, String> {
log::info!("Starting daemon services...");
// Create the daemon event emitter
let (emitter, _rx) = DaemonEmitter::with_capacity(256);
let emitter_arc = Arc::new(emitter);
// Set the global event emitter
if let Err(e) = events::set_global_emitter(emitter_arc.clone()) {
log::warn!("Failed to set global event emitter: {}", e);
}
// NOTE: The API server currently requires an AppHandle which is only available
// in the Tauri GUI context. For now, the daemon starts with minimal services.
// The GUI will start the API server when it connects to the daemon.
//
// TODO: Refactor API server to work without AppHandle for daemon mode
let api_port = None;
let mcp_running = false;
log::info!("Daemon services started (minimal mode - waiting for GUI connection)");
Ok(Self {
api_port,
mcp_running,
event_emitter: emitter_arc,
})
}
pub fn subscribe_events(&self) -> broadcast::Receiver<DaemonEvent> {
self.event_emitter.subscribe()
}
pub async fn stop(&mut self) {
log::info!("Stopping daemon services...");
self.api_port = None;
self.mcp_running = false;
}
}
-204
View File
@@ -1,204 +0,0 @@
use std::process::Command;
use tray_icon::menu::{Menu, MenuItem};
use tray_icon::{Icon, TrayIcon, TrayIconBuilder};
pub fn load_icon() -> Icon {
// 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)
.expect("Failed to load icon")
.into_rgba8();
let (width, height) = image.dimensions();
let rgba = image.into_raw();
Icon::from_rgba(rgba, width, height).expect("Failed to create icon")
}
pub struct TrayMenu {
pub menu: Menu,
pub quit_item: MenuItem,
}
impl Default for TrayMenu {
fn default() -> Self {
Self::new()
}
}
impl TrayMenu {
pub fn new() -> Self {
let menu = Menu::new();
let quit_item = MenuItem::new("Quit Donut Browser", true, None);
menu.append(&quit_item).unwrap();
Self { menu, quit_item }
}
}
pub fn create_tray_icon(icon: Icon, menu: &Menu) -> TrayIcon {
let builder = TrayIconBuilder::new()
.with_icon(icon)
.with_tooltip("Donut Browser")
.with_menu(Box::new(menu.clone()));
// On macOS, template icons are automatically colored by the system for light/dark mode
#[cfg(target_os = "macos")]
let builder = builder.with_icon_as_template(true);
builder.build().expect("Failed to create tray icon")
}
/// Resolve the .app bundle path from the current daemon executable.
/// In production the daemon is at `Donut.app/Contents/MacOS/donut-daemon`.
#[cfg(target_os = "macos")]
fn get_app_bundle_path() -> Option<std::path::PathBuf> {
let exe = std::env::current_exe().ok()?;
let macos_dir = exe.parent()?;
let contents_dir = macos_dir.parent()?;
let app_dir = contents_dir.parent()?;
if app_dir.extension().and_then(|e| e.to_str()) == Some("app") {
Some(app_dir.to_path_buf())
} else {
None
}
}
pub fn open_gui() {
log::info!("Opening GUI...");
#[cfg(target_os = "macos")]
{
// Launch the GUI binary directly. The daemon lives inside the same .app
// bundle, so `open` (even with `-n`) can re-activate the daemon instead
// of launching the GUI. Directly running the binary avoids macOS's app
// activation machinery. The single-instance Tauri plugin in the GUI
// handles deduplication if a GUI instance is already running.
if let Some(app_bundle) = get_app_bundle_path() {
let gui_binary = app_bundle.join("Contents").join("MacOS").join("Donut");
if gui_binary.exists() {
let _ = Command::new(&gui_binary).spawn();
} else {
let _ = Command::new("open").args(["-n"]).arg(&app_bundle).spawn();
}
} else {
let _ = Command::new("open").args(["-n", "-a", "Donut"]).spawn();
}
}
#[cfg(target_os = "windows")]
{
use std::path::PathBuf;
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(
"C:\\Program Files\\Donut Browser\\Donut Browser.exe",
)),
];
for path in paths.iter().flatten() {
if path.exists() {
let _ = Command::new(path).spawn();
return;
}
}
}
#[cfg(target_os = "linux")]
{
let _ = Command::new("donutbrowser").spawn();
}
}
fn read_gui_pid() -> Option<u32> {
let path = super::autostart::get_data_dir()?.join("daemon-state.json");
let content = std::fs::read_to_string(path).ok()?;
let val: serde_json::Value = serde_json::from_str(&content).ok()?;
val.get("gui_pid")?.as_u64().map(|p| p as u32)
}
fn kill_gui_by_pid() -> bool {
let Some(pid) = read_gui_pid() else {
return false;
};
#[cfg(unix)]
{
let ret = unsafe { libc::kill(pid as i32, libc::SIGTERM) };
ret == 0
}
#[cfg(windows)]
{
use std::os::windows::process::CommandExt;
const CREATE_NO_WINDOW: u32 = 0x08000000;
Command::new("taskkill")
.args(["/PID", &pid.to_string(), "/F"])
.creation_flags(CREATE_NO_WINDOW)
.output()
.map(|o| o.status.success())
.unwrap_or(false)
}
#[cfg(not(any(unix, windows)))]
{
false
}
}
pub fn quit_gui() {
log::info!("[daemon] Quitting GUI...");
if kill_gui_by_pid() {
log::info!("[daemon] GUI killed by PID");
return;
}
log::info!("[daemon] PID-based kill failed, falling back to name-based kill");
#[cfg(target_os = "macos")]
{
// Use spawn() instead of output() to avoid blocking the event loop.
// AppleScript has a ~2 minute default timeout that would freeze the tray icon.
let _ = Command::new("osascript")
.args(["-e", "tell application \"Donut\" to quit"])
.spawn();
}
#[cfg(target_os = "windows")]
{
use std::os::windows::process::CommandExt;
const CREATE_NO_WINDOW: u32 = 0x08000000;
let _ = Command::new("taskkill")
.args(["/IM", "Donut.exe", "/F"])
.creation_flags(CREATE_NO_WINDOW)
.spawn();
let _ = Command::new("taskkill")
.args(["/IM", "donutbrowser.exe", "/F"])
.creation_flags(CREATE_NO_WINDOW)
.spawn();
}
#[cfg(target_os = "linux")]
{
let _ = Command::new("pkill").args(["-x", "donutbrowser"]).spawn();
}
}
-152
View File
@@ -1,152 +0,0 @@
use futures_util::{SinkExt, StreamExt};
use serde::{Deserialize, Serialize};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use tauri::Emitter;
use tokio::sync::Mutex;
use tokio_tungstenite::{connect_async, tungstenite::Message};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WsMessage {
#[serde(rename = "type")]
pub msg_type: String,
pub event: Option<String>,
pub payload: Option<serde_json::Value>,
}
pub struct DaemonClient {
app_handle: tauri::AppHandle,
connected: Arc<AtomicBool>,
shutdown: Arc<AtomicBool>,
daemon_port: Arc<Mutex<Option<u16>>>,
}
impl DaemonClient {
pub fn new(app_handle: tauri::AppHandle) -> Self {
Self {
app_handle,
connected: Arc::new(AtomicBool::new(false)),
shutdown: Arc::new(AtomicBool::new(false)),
daemon_port: Arc::new(Mutex::new(None)),
}
}
pub fn is_connected(&self) -> bool {
self.connected.load(Ordering::SeqCst)
}
pub async fn connect(&self, port: u16) -> Result<(), String> {
*self.daemon_port.lock().await = Some(port);
let url = format!("ws://127.0.0.1:{}/ws/events", port);
log::info!("[daemon-client] Connecting to daemon at {}", url);
let (ws_stream, _) = connect_async(&url)
.await
.map_err(|e| format!("Failed to connect to daemon: {}", e))?;
self.connected.store(true, Ordering::SeqCst);
log::info!("[daemon-client] Connected to daemon");
let (mut write, mut read) = ws_stream.split();
let app_handle = self.app_handle.clone();
let connected = self.connected.clone();
let shutdown = self.shutdown.clone();
// Spawn task to handle incoming messages
tokio::spawn(async move {
while !shutdown.load(Ordering::SeqCst) {
match read.next().await {
Some(Ok(Message::Text(text))) => {
if let Ok(ws_msg) = serde_json::from_str::<WsMessage>(&text) {
match ws_msg.msg_type.as_str() {
"event" => {
if let (Some(event), Some(payload)) = (ws_msg.event, ws_msg.payload) {
// Forward event to Tauri frontend
if let Err(e) = app_handle.emit(&event, payload) {
log::error!("[daemon-client] Failed to emit event: {}", e);
}
}
}
"connected" => {
log::info!("[daemon-client] Received connection confirmation");
}
"pong" => {
log::debug!("[daemon-client] Received pong");
}
_ => {
log::debug!("[daemon-client] Unknown message type: {}", ws_msg.msg_type);
}
}
}
}
Some(Ok(Message::Ping(data))) => {
log::debug!("[daemon-client] Received ping");
if let Err(e) = write.send(Message::Pong(data)).await {
log::error!("[daemon-client] Failed to send pong: {}", e);
break;
}
}
Some(Ok(Message::Close(_))) => {
log::info!("[daemon-client] Daemon closed connection");
break;
}
Some(Err(e)) => {
log::error!("[daemon-client] WebSocket error: {}", e);
break;
}
None => {
log::info!("[daemon-client] WebSocket stream ended");
break;
}
_ => {}
}
}
connected.store(false, Ordering::SeqCst);
log::info!("[daemon-client] Disconnected from daemon");
});
Ok(())
}
pub fn disconnect(&self) {
self.shutdown.store(true, Ordering::SeqCst);
self.connected.store(false, Ordering::SeqCst);
}
}
pub async fn start_daemon_connection(app_handle: tauri::AppHandle, port: u16) -> DaemonClient {
let client = DaemonClient::new(app_handle);
if let Err(e) = client.connect(port).await {
log::error!("[daemon-client] Failed to connect: {}", e);
}
client
}
pub async fn find_and_connect_to_daemon(app_handle: tauri::AppHandle) -> Option<DaemonClient> {
// Try default port first
let default_port = 10108;
log::info!(
"[daemon-client] Looking for daemon on port {}",
default_port
);
let client = DaemonClient::new(app_handle);
match client.connect(default_port).await {
Ok(()) => Some(client),
Err(e) => {
log::warn!(
"[daemon-client] Could not connect to daemon on default port: {}",
e
);
None
}
}
}
-360
View File
@@ -1,360 +0,0 @@
// Daemon Spawn - Start the daemon from the GUI
// Currently disabled; will be re-enabled in the future
use serde::Deserialize;
use std::fs;
use std::path::PathBuf;
use std::process::{Command, Stdio};
use std::thread;
use std::time::Duration;
use crate::daemon::autostart;
/// Check if a process with the given PID exists using the Windows API.
/// This avoids spawning tasklist.exe which causes a visible conhost window flash.
#[cfg(windows)]
fn win_process_exists(pid: u32) -> bool {
const PROCESS_QUERY_LIMITED_INFORMATION: u32 = 0x1000;
extern "system" {
fn OpenProcess(dwDesiredAccess: u32, bInheritHandles: i32, dwProcessId: u32) -> *mut ();
fn CloseHandle(hObject: *mut ()) -> i32;
}
let handle = unsafe { OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, 0, pid) };
if handle.is_null() {
false
} else {
unsafe { CloseHandle(handle) };
true
}
}
#[derive(Debug, Deserialize, Default)]
struct DaemonState {
daemon_pid: Option<u32>,
}
fn get_state_path() -> PathBuf {
autostart::get_data_dir()
.unwrap_or_else(|| PathBuf::from("."))
.join("daemon-state.json")
}
fn read_state() -> DaemonState {
let path = get_state_path();
if path.exists() {
if let Ok(content) = fs::read_to_string(&path) {
if let Ok(state) = serde_json::from_str(&content) {
return state;
}
}
}
DaemonState::default()
}
pub fn is_daemon_running() -> bool {
let state = read_state();
if let Some(pid) = state.daemon_pid {
#[cfg(unix)]
{
unsafe { libc::kill(pid as i32, 0) == 0 }
}
#[cfg(windows)]
{
win_process_exists(pid)
}
#[cfg(not(any(unix, windows)))]
{
false
}
} else {
false
}
}
#[cfg(target_os = "macos")]
fn is_dev_mode() -> bool {
if let Ok(current_exe) = std::env::current_exe() {
let path_str = current_exe.to_string_lossy();
path_str.contains("target/debug") || path_str.contains("target/release")
} else {
false
}
}
#[cfg(target_os = "macos")]
fn get_daemon_path() -> Option<PathBuf> {
// First try to find the daemon binary next to the current executable
if let Ok(current_exe) = std::env::current_exe() {
if let Some(exe_dir) = current_exe.parent() {
let daemon_path = exe_dir.join("donut-daemon");
if daemon_path.exists() {
return Some(daemon_path);
}
}
}
// Try common installation paths
let paths = [
PathBuf::from("/Applications/Donut Browser.app/Contents/MacOS/donut-daemon"),
dirs::home_dir()
.map(|h| h.join("Applications/Donut Browser.app/Contents/MacOS/donut-daemon"))
.unwrap_or_default(),
];
paths.into_iter().find(|path| path.exists())
}
#[cfg(any(target_os = "linux", windows))]
fn get_daemon_path() -> Option<PathBuf> {
// First, try to find it next to the current executable
if let Ok(current_exe) = std::env::current_exe() {
let exe_dir = current_exe.parent()?;
// Check for daemon binary in same directory
#[cfg(target_os = "windows")]
let daemon_name = "donut-daemon.exe";
#[cfg(target_os = "linux")]
let daemon_name = "donut-daemon";
let daemon_path = exe_dir.join(daemon_name);
if daemon_path.exists() {
return Some(daemon_path);
}
}
// Try to find it in PATH
#[cfg(target_os = "windows")]
{
use std::os::windows::process::CommandExt;
const CREATE_NO_WINDOW: u32 = 0x08000000;
if let Ok(output) = Command::new("where")
.arg("donut-daemon")
.creation_flags(CREATE_NO_WINDOW)
.output()
{
if output.status.success() {
let path = String::from_utf8_lossy(&output.stdout);
let path = path.lines().next()?.trim();
return Some(PathBuf::from(path));
}
}
}
#[cfg(target_os = "linux")]
{
if let Ok(output) = Command::new("which").arg("donut-daemon").output() {
if output.status.success() {
let path = String::from_utf8_lossy(&output.stdout);
let path = path.trim();
if !path.is_empty() {
return Some(PathBuf::from(path));
}
}
}
}
None
}
pub fn spawn_daemon() -> Result<(), String> {
// Log the daemon state for debugging
let state = read_state();
log::info!("Daemon state before spawn: pid={:?}", state.daemon_pid);
// Check if already running
if is_daemon_running() {
log::info!("Daemon is already running (verified by PID check)");
return Ok(());
}
log::info!("Daemon is not running, attempting to start...");
// Log current exe location for debugging
let current_exe = std::env::current_exe().ok();
log::info!("Current exe: {:?}", current_exe);
// On macOS, use launchctl to start the daemon via launchd
// This ensures the daemon runs in the user's Aqua session with WindowServer access
// and survives app termination since it's managed by launchd, not as a child process
#[cfg(target_os = "macos")]
{
spawn_daemon_macos()?;
}
// On Linux, use direct spawn
#[cfg(target_os = "linux")]
{
spawn_daemon_unix()?;
}
#[cfg(windows)]
{
spawn_daemon_windows()?;
}
// Wait for daemon to start (max 3 seconds)
for i in 0..30 {
thread::sleep(Duration::from_millis(100));
if is_daemon_running() {
log::info!("Daemon started successfully after {}ms", (i + 1) * 100);
return Ok(());
}
}
// Check if we got a state file at least
let state = read_state();
if let Some(pid) = state.daemon_pid {
log::info!("Daemon appears to have started (PID {} in state file)", pid);
return Ok(());
}
Err("Daemon did not start within timeout".to_string())
}
#[cfg(target_os = "macos")]
fn spawn_daemon_macos() -> Result<(), String> {
use std::os::unix::process::CommandExt;
// In dev mode, use direct spawn instead of launchctl
// This avoids issues with plist paths pointing to wrong binaries
if is_dev_mode() {
log::info!("Dev mode detected, using direct spawn instead of launchctl");
let daemon_path = get_daemon_path().ok_or_else(|| {
format!(
"Could not find daemon binary. Current exe: {:?}",
std::env::current_exe().ok()
)
})?;
log::info!("Spawning daemon from: {:?}", daemon_path);
// Create a new process group so daemon survives parent exit
let mut cmd = Command::new(&daemon_path);
cmd
.arg("run")
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())
.process_group(0);
cmd
.spawn()
.map_err(|e| format!("Failed to spawn daemon: {}", e))?;
return Ok(());
}
// Production mode: use launchctl for proper daemon management
// First, ensure the LaunchAgent plist is installed
let autostart_enabled = autostart::is_autostart_enabled();
log::info!("LaunchAgent plist exists: {}", autostart_enabled);
if !autostart_enabled {
log::info!("Installing LaunchAgent plist for daemon management");
autostart::enable_autostart().map_err(|e| format!("Failed to install LaunchAgent: {}", e))?;
log::info!("LaunchAgent plist installed successfully");
}
// Load the launch agent via launchctl
log::info!("Loading daemon via launchctl...");
autostart::load_launch_agent().map_err(|e| format!("Failed to load LaunchAgent: {}", e))?;
log::info!("launchctl load completed");
// Also explicitly start the agent in case it was already loaded but stopped
if let Err(e) = autostart::start_launch_agent() {
log::debug!("launchctl start note (non-fatal): {}", e);
}
Ok(())
}
#[cfg(target_os = "linux")]
fn spawn_daemon_unix() -> Result<(), String> {
use std::os::unix::process::CommandExt;
let daemon_path = get_daemon_path().ok_or_else(|| {
format!(
"Could not find daemon binary. Current exe: {:?}",
std::env::current_exe().ok()
)
})?;
log::info!("Spawning daemon from: {:?}", daemon_path);
// Create a new process group so daemon survives parent exit
let mut cmd = Command::new(&daemon_path);
cmd
.arg("run")
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())
.process_group(0);
cmd
.spawn()
.map_err(|e| format!("Failed to spawn daemon: {}", e))?;
Ok(())
}
#[cfg(windows)]
fn spawn_daemon_windows() -> Result<(), String> {
use std::os::windows::process::CommandExt;
const DETACHED_PROCESS: u32 = 0x00000008;
const CREATE_NEW_PROCESS_GROUP: u32 = 0x00000200;
let daemon_path = get_daemon_path().ok_or_else(|| {
format!(
"Could not find daemon binary. Current exe: {:?}",
std::env::current_exe().ok()
)
})?;
log::info!("Spawning daemon from: {:?}", daemon_path);
Command::new(&daemon_path)
.arg("run")
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())
.creation_flags(DETACHED_PROCESS | CREATE_NEW_PROCESS_GROUP)
.spawn()
.map_err(|e| format!("Failed to spawn daemon: {}", e))?;
Ok(())
}
pub fn ensure_daemon_running() -> Result<(), String> {
if !is_daemon_running() {
spawn_daemon()?;
}
Ok(())
}
pub fn register_gui_pid() {
let path = get_state_path();
let mut val: serde_json::Value = if path.exists() {
fs::read_to_string(&path)
.ok()
.and_then(|c| serde_json::from_str(&c).ok())
.unwrap_or_else(|| serde_json::json!({}))
} else {
serde_json::json!({})
};
if let Some(obj) = val.as_object_mut() {
obj.insert(
"gui_pid".to_string(),
serde_json::Value::Number(std::process::id().into()),
);
}
if let Ok(content) = serde_json::to_string_pretty(&val) {
let _ = fs::write(&path, content);
}
}
-134
View File
@@ -1,134 +0,0 @@
use axum::{
extract::{
ws::{Message, WebSocket, WebSocketUpgrade},
State,
},
response::IntoResponse,
};
use futures_util::{SinkExt, StreamExt};
use serde::{Deserialize, Serialize};
use std::sync::Arc;
use crate::events::{DaemonEmitter, DaemonEvent};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WsMessage {
#[serde(rename = "type")]
pub msg_type: String,
pub event: Option<String>,
pub payload: Option<serde_json::Value>,
}
#[derive(Clone)]
pub struct WsState {
event_emitter: Option<Arc<DaemonEmitter>>,
}
impl WsState {
pub fn new() -> Self {
Self {
event_emitter: None,
}
}
pub fn with_emitter(emitter: Arc<DaemonEmitter>) -> Self {
Self {
event_emitter: Some(emitter),
}
}
}
impl Default for WsState {
fn default() -> Self {
Self::new()
}
}
pub async fn ws_handler(ws: WebSocketUpgrade, State(state): State<WsState>) -> impl IntoResponse {
ws.on_upgrade(move |socket| handle_socket(socket, state))
}
async fn handle_socket(socket: WebSocket, state: WsState) {
let (mut sender, mut receiver) = socket.split();
// Subscribe to daemon events if emitter is available
let mut event_rx = state.event_emitter.as_ref().map(|e| e.subscribe());
log::info!("[ws] Client connected");
// Send initial ping to confirm connection
let ping_msg = WsMessage {
msg_type: "connected".to_string(),
event: None,
payload: None,
};
if let Ok(msg_str) = serde_json::to_string(&ping_msg) {
let _ = sender.send(Message::Text(msg_str.into())).await;
}
loop {
tokio::select! {
// Handle incoming messages from client
Some(msg) = receiver.next() => {
match msg {
Ok(Message::Text(text)) => {
if let Ok(ws_msg) = serde_json::from_str::<WsMessage>(&text) {
match ws_msg.msg_type.as_str() {
"ping" => {
let pong = WsMessage {
msg_type: "pong".to_string(),
event: None,
payload: None,
};
if let Ok(msg_str) = serde_json::to_string(&pong) {
let _ = sender.send(Message::Text(msg_str.into())).await;
}
}
_ => {
log::debug!("[ws] Received unknown message type: {}", ws_msg.msg_type);
}
}
}
}
Ok(Message::Ping(data)) => {
let _ = sender.send(Message::Pong(data)).await;
}
Ok(Message::Close(_)) => {
log::info!("[ws] Client disconnected");
break;
}
Err(e) => {
log::error!("[ws] Error receiving message: {}", e);
break;
}
_ => {}
}
}
// Forward daemon events to client
Some(daemon_event) = async {
if let Some(ref mut rx) = event_rx {
rx.recv().await.ok()
} else {
std::future::pending::<Option<DaemonEvent>>().await
}
} => {
let ws_msg = WsMessage {
msg_type: "event".to_string(),
event: Some(daemon_event.event_type),
payload: Some(daemon_event.payload),
};
if let Ok(msg_str) = serde_json::to_string(&ws_msg) {
if sender.send(Message::Text(msg_str.into())).await.is_err() {
log::error!("[ws] Failed to send event to client");
break;
}
}
}
else => break,
}
}
log::info!("[ws] WebSocket connection closed");
}
+2 -73
View File
@@ -1,10 +1,7 @@
use serde::Serialize;
use std::sync::Arc;
use tokio::sync::broadcast;
/// Trait for emitting events to the frontend or connected clients.
/// This abstraction allows the same code to work in both GUI (Tauri) mode
/// and daemon mode (WebSocket broadcast).
/// Trait for emitting events to the frontend.
///
/// Note: This trait uses `serde_json::Value` to be dyn-compatible.
/// Use the convenience functions `emit()` and `emit_empty()` which accept
@@ -37,49 +34,6 @@ impl EventEmitter for TauriEmitter {
}
}
/// Event message sent through the daemon's broadcast channel.
#[derive(Clone, Debug)]
pub struct DaemonEvent {
pub event_type: String,
pub payload: serde_json::Value,
}
/// Daemon-based event emitter for background daemon mode.
/// Broadcasts events to all connected WebSocket clients.
#[derive(Clone)]
pub struct DaemonEmitter {
tx: broadcast::Sender<DaemonEvent>,
}
impl DaemonEmitter {
pub fn new(tx: broadcast::Sender<DaemonEvent>) -> Self {
Self { tx }
}
/// Create a new DaemonEmitter with a default channel capacity.
pub fn with_capacity(capacity: usize) -> (Self, broadcast::Receiver<DaemonEvent>) {
let (tx, rx) = broadcast::channel(capacity);
(Self { tx }, rx)
}
/// Subscribe to events from this emitter.
pub fn subscribe(&self) -> broadcast::Receiver<DaemonEvent> {
self.tx.subscribe()
}
}
impl EventEmitter for DaemonEmitter {
fn emit_value(&self, event: &str, payload: serde_json::Value) -> Result<(), String> {
let daemon_event = DaemonEvent {
event_type: event.to_string(),
payload,
};
// Ignore send errors (no receivers connected)
let _ = self.tx.send(daemon_event);
Ok(())
}
}
/// No-op emitter for testing or when events are not needed.
#[derive(Clone, Default)]
pub struct NoopEmitter;
@@ -91,8 +45,7 @@ impl EventEmitter for NoopEmitter {
}
/// Global event emitter that can be set at runtime.
/// This allows managers to emit events without knowing whether they're
/// running in GUI or daemon mode.
/// This allows managers to emit events without holding an AppHandle directly.
static GLOBAL_EMITTER: std::sync::OnceLock<Arc<dyn EventEmitter>> = std::sync::OnceLock::new();
/// Set the global event emitter. This should be called once during app startup.
@@ -136,30 +89,6 @@ mod tests {
.is_ok());
}
#[test]
fn test_daemon_emitter() {
let (emitter, mut rx) = DaemonEmitter::with_capacity(16);
// Emit an event
let _ = emitter.emit_value("test-event", serde_json::json!("hello"));
// Check we received it
let event = rx.try_recv().unwrap();
assert_eq!(event.event_type, "test-event");
assert_eq!(event.payload, serde_json::json!("hello"));
}
#[test]
fn test_daemon_emitter_no_receivers() {
let (tx, _) = broadcast::channel::<DaemonEvent>(16);
let emitter = DaemonEmitter::new(tx);
// Should not error even with no receivers
assert!(emitter
.emit_value("test-event", serde_json::json!("hello"))
.is_ok());
}
#[test]
fn test_emit_convenience_function() {
// Test that emit() works with various types
+41 -22
View File
@@ -52,11 +52,6 @@ mod wayfern_terms;
pub mod cloud_auth;
mod commercial_license;
mod cookie_manager;
pub mod daemon;
pub mod daemon_client;
#[allow(dead_code)]
mod daemon_spawn;
pub mod daemon_ws;
pub mod events;
mod mcp_integrations;
mod mcp_server;
@@ -98,10 +93,10 @@ use downloaded_browsers_registry::{
use downloader::{cancel_download, download_browser};
use settings_manager::{
decline_launch_on_login, dismiss_window_resize_warning, enable_launch_on_login, get_app_settings,
get_sync_settings, get_system_info, get_system_language, get_table_sorting_settings,
get_window_resize_warning_dismissed, open_log_directory, read_log_files, save_app_settings,
save_sync_settings, save_table_sorting_settings, should_show_launch_on_login_prompt,
dismiss_window_resize_warning, get_app_settings, get_sync_settings, get_system_info,
get_system_language, get_table_sorting_settings, get_window_resize_warning_dismissed,
open_log_directory, read_log_files, save_app_settings, save_sync_settings,
save_table_sorting_settings,
};
use sync::{
@@ -196,7 +191,8 @@ impl<R: Runtime> WindowExt for WebviewWindow<R> {
}
}
#[tauri::command]
// Called internally for deep-link / startup URL handling — not invoked from the
// frontend, so it is intentionally not a `#[tauri::command]`.
async fn handle_url_open(app: tauri::AppHandle, url: String) -> Result<(), String> {
log::info!("handle_url_open called with URL: {url}");
@@ -1175,6 +1171,34 @@ fn show_main_window(app_handle: &tauri::AppHandle) {
}
}
/// Update the tray menu labels with localized strings pushed from the frontend
/// (which owns the active language). The item ids are unchanged so the existing
/// menu-event handler keeps matching.
#[tauri::command]
fn update_tray_menu(
app_handle: tauri::AppHandle,
show_label: String,
quit_label: String,
) -> Result<(), String> {
use tauri::menu::{MenuBuilder, MenuItemBuilder};
if let Some(tray) = app_handle.tray_by_id("main") {
let show_item = MenuItemBuilder::with_id("tray_show", show_label)
.build(&app_handle)
.map_err(|e| e.to_string())?;
let quit_item = MenuItemBuilder::with_id("tray_quit", quit_label)
.build(&app_handle)
.map_err(|e| e.to_string())?;
let menu = MenuBuilder::new(&app_handle)
.item(&show_item)
.separator()
.item(&quit_item)
.build()
.map_err(|e| e.to_string())?;
tray.set_menu(Some(menu)).map_err(|e| e.to_string())?;
}
Ok(())
}
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
let args: Vec<String> = env::args().collect();
@@ -1248,14 +1272,6 @@ pub fn run() {
mgr.ensure_icons_extracted();
}
// Daemon (tray icon) is currently disabled — clean up any existing autostart
if daemon::autostart::is_autostart_enabled() {
log::info!("Removing daemon autostart (daemon is disabled)");
if let Err(e) = daemon::autostart::disable_autostart() {
log::warn!("Failed to remove daemon autostart: {e}");
}
}
// Create the main window programmatically
#[allow(unused_variables)]
let win_builder = WebviewWindowBuilder::new(app, "main", WebviewUrl::default())
@@ -1275,6 +1291,12 @@ pub fn run() {
// System tray so the user can keep the app running after the close
// dialog's "Minimize" action hides the window.
//
// These initial labels are bootstrap defaults only — the frontend pushes
// localized labels via `update_tray_menu` on mount and on every language
// change (the active language lives in the webview). The tray menu is only
// ever opened after the user minimizes to tray, by which point the
// frontend has already localized it, so these strings are never shown.
{
use tauri::menu::{MenuBuilder, MenuItemBuilder};
use tauri::tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent};
@@ -2066,6 +2088,7 @@ pub fn run() {
.invoke_handler(tauri::generate_handler![
confirm_quit,
hide_to_tray,
update_tray_menu,
get_supported_browsers,
is_browser_supported_on_platform,
download_browser,
@@ -2096,9 +2119,6 @@ pub fn run() {
save_app_settings,
read_log_files,
open_log_directory,
should_show_launch_on_login_prompt,
enable_launch_on_login,
decline_launch_on_login,
get_table_sorting_settings,
save_table_sorting_settings,
get_system_language,
@@ -2216,7 +2236,6 @@ pub fn run() {
disconnect_vpn,
get_vpn_status,
list_active_vpn_connections,
handle_url_open,
// Cloud auth commands
cloud_auth::cloud_exchange_device_code,
cloud_auth::cloud_get_user,
-1
View File
@@ -28,7 +28,6 @@ fn unsuffixed_binary_name(base_name: &str) -> String {
{
match base_name {
"donut-proxy" => "donut-proxy.exe".to_string(),
"donut-daemon" => "donut-daemon.exe".to_string(),
_ => String::new(),
}
}
-61
View File
@@ -50,8 +50,6 @@ pub struct AppSettings {
#[serde(default)]
pub mcp_token: Option<String>, // Displayed token for user to copy (not persisted, loaded from encrypted file)
#[serde(default)]
pub launch_on_login_declined: bool, // User permanently declined the launch-on-login prompt
#[serde(default)]
pub language: Option<String>, // ISO 639-1: "en", "es", "pt", "fr", "zh", "ja", "ko", "ru", or None for system default
#[serde(default)]
pub window_resize_warning_dismissed: bool,
@@ -93,7 +91,6 @@ impl Default for AppSettings {
mcp_enabled: false,
mcp_port: None,
mcp_token: None,
launch_on_login_declined: false,
language: None,
window_resize_warning_dismissed: false,
disable_auto_updates: false,
@@ -183,17 +180,6 @@ impl SettingsManager {
Ok(())
}
pub fn should_show_launch_on_login_prompt(&self) -> Result<bool, Box<dyn std::error::Error>> {
// Daemon is currently disabled, never show this prompt
Ok(false)
}
pub fn decline_launch_on_login(&self) -> Result<(), Box<dyn std::error::Error>> {
let mut settings = self.load_settings()?;
settings.launch_on_login_declined = true;
self.save_settings(&settings)
}
fn get_vault_password() -> String {
env!("DONUT_BROWSER_VAULT_PASSWORD").to_string()
}
@@ -795,7 +781,6 @@ pub async fn save_app_settings(
if let Ok(content) = std::fs::read_to_string(manager.get_settings_file()) {
if let Ok(current) = serde_json::from_str::<AppSettings>(&content) {
settings.window_resize_warning_dismissed = current.window_resize_warning_dismissed;
settings.launch_on_login_declined = current.launch_on_login_declined;
}
}
@@ -919,28 +904,6 @@ pub async fn open_log_directory(app_handle: tauri::AppHandle) -> Result<(), Stri
Ok(())
}
#[tauri::command]
pub async fn should_show_launch_on_login_prompt() -> Result<bool, String> {
let manager = SettingsManager::instance();
manager
.should_show_launch_on_login_prompt()
.map_err(|e| format!("Failed to check launch on login prompt setting: {e}"))
}
#[tauri::command]
pub async fn enable_launch_on_login() -> Result<(), String> {
crate::daemon::autostart::enable_autostart()
.map_err(|e| format!("Failed to enable autostart: {e}"))
}
#[tauri::command]
pub async fn decline_launch_on_login() -> Result<(), String> {
let manager = SettingsManager::instance();
manager
.decline_launch_on_login()
.map_err(|e| format!("Failed to decline launch on login: {e}"))
}
#[tauri::command]
pub async fn get_table_sorting_settings() -> Result<TableSortingSettings, String> {
let manager = SettingsManager::instance();
@@ -1182,7 +1145,6 @@ mod tests {
mcp_enabled: false,
mcp_port: None,
mcp_token: None,
launch_on_login_declined: false,
language: None,
window_resize_warning_dismissed: false,
disable_auto_updates: false,
@@ -1247,29 +1209,6 @@ mod tests {
);
}
#[test]
fn test_should_show_launch_on_login_prompt() {
let (manager, _temp_dir, _guard) = create_test_settings_manager();
let result = manager.should_show_launch_on_login_prompt();
assert!(result.is_ok(), "Should not fail");
let _should_show = result.unwrap();
}
#[test]
fn test_decline_launch_on_login() {
let (manager, _temp_dir, _guard) = create_test_settings_manager();
let settings = manager.load_settings().unwrap();
assert!(!settings.launch_on_login_declined);
manager.decline_launch_on_login().unwrap();
let settings = manager.load_settings().unwrap();
assert!(settings.launch_on_login_declined);
}
#[test]
fn test_load_corrupted_settings_file() {
let (manager, _temp_dir, _guard) = create_test_settings_manager();
+1 -1
View File
@@ -19,7 +19,7 @@
"active": true,
"targets": ["app", "dmg", "nsis", "deb", "rpm", "appimage"],
"category": "Productivity",
"externalBin": ["binaries/donut-proxy", "binaries/donut-daemon"],
"externalBin": ["binaries/donut-proxy"],
"icon": [
"icons/32x32.png",
"icons/128x128.png",
+6 -37
View File
@@ -23,7 +23,6 @@ import { GroupManagementDialog } from "@/components/group-management-dialog";
import HomeHeader from "@/components/home-header";
import { ImportProfileDialog } from "@/components/import-profile-dialog";
import { IntegrationsDialog } from "@/components/integrations-dialog";
import { LaunchOnLoginDialog } from "@/components/launch-on-login-dialog";
import { PermissionDialog } from "@/components/permission-dialog";
import { ProfilesDataTable } from "@/components/profile-data-table";
import {
@@ -215,8 +214,6 @@ export default function Home() {
const [passwordDialogMode, setPasswordDialogMode] =
useState<PasswordDialogMode>("set");
const pendingLaunchAfterUnlockRef = useRef<BrowserProfile | null>(null);
const [hasCheckedStartupPrompt, setHasCheckedStartupPrompt] = useState(false);
const [launchOnLoginDialogOpen, setLaunchOnLoginDialogOpen] = useState(false);
const [windowResizeWarningOpen, setWindowResizeWarningOpen] = useState(false);
const [windowResizeWarningBrowserType, setWindowResizeWarningBrowserType] =
useState<string | undefined>(undefined);
@@ -546,24 +543,6 @@ export default function Home() {
}
}, [handleUrlOpen, hasCheckedStartupUrl]);
const checkStartupPrompt = useCallback(async () => {
// Only check once during app startup to prevent reopening after dismissing notifications
if (hasCheckedStartupPrompt) return;
try {
const shouldShow = await invoke<boolean>(
"should_show_launch_on_login_prompt",
);
if (shouldShow) {
setLaunchOnLoginDialogOpen(true);
}
} catch (error) {
console.error("Failed to check startup prompt:", error);
} finally {
setHasCheckedStartupPrompt(true);
}
}, [hasCheckedStartupPrompt]);
// Handle profile errors from useProfileEvents hook
useEffect(() => {
if (profilesError) {
@@ -1190,9 +1169,6 @@ export default function Home() {
}, [profiles, t]);
useEffect(() => {
// Check for startup default browser prompt
void checkStartupPrompt();
// Listen for URL open events and get cleanup function
const setupListeners = async () => {
const cleanup = await listenForUrlEvents();
@@ -1235,7 +1211,6 @@ export default function Home() {
};
}, [
checkForUpdates,
checkStartupPrompt,
listenForUrlEvents,
checkCurrentUrl,
checkMissingBinaries,
@@ -1337,11 +1312,13 @@ export default function Home() {
showToast({
id: "browser-support-ending-warning",
type: "error",
title: "Browser support ending soon",
description: `Support for the following profiles will be removed on March 15, 2026: ${unsupportedNames}. Please migrate to Wayfern or Camoufox profiles.`,
title: t("browserSupport.endingSoonTitle"),
description: t("browserSupport.endingSoonDescription", {
profiles: unsupportedNames,
}),
duration: 15000,
action: {
label: "Learn more",
label: t("common.buttons.learnMore"),
onClick: () => {
const event = new CustomEvent("url-open-request", {
detail: "https://github.com/zhom/donutbrowser/discussions",
@@ -1351,7 +1328,7 @@ export default function Home() {
},
});
}
}, [profiles]);
}, [profiles, t]);
// Re-check Wayfern terms when a browser download completes
useEffect(() => {
@@ -1851,14 +1828,6 @@ export default function Home() {
onClose={checkTrialStatus}
/>
{/* Launch on Login Dialog - shown on every startup until enabled or declined */}
<LaunchOnLoginDialog
isOpen={launchOnLoginDialogOpen}
onClose={() => {
setLaunchOnLoginDialogOpen(false);
}}
/>
<WindowResizeWarningDialog
isOpen={windowResizeWarningOpen}
browserType={windowResizeWarningBrowserType}
+19 -1
View File
@@ -15,7 +15,7 @@ import {
import { RippleButton } from "./ui/ripple";
export function CloseConfirmDialog() {
const { t } = useTranslation();
const { t, i18n } = useTranslation();
const [isOpen, setIsOpen] = useState(false);
useEffect(() => {
@@ -29,6 +29,24 @@ export function CloseConfirmDialog() {
};
}, []);
// The native tray menu is built in Rust and cannot read the active language,
// so push localized labels to it on mount and whenever the language changes.
useEffect(() => {
const syncTrayMenu = () => {
void invoke("update_tray_menu", {
showLabel: t("tray.show"),
quitLabel: t("tray.quit"),
}).catch(() => {
// Tray is desktop-only; ignore on platforms without one.
});
};
syncTrayMenu();
i18n.on("languageChanged", syncTrayMenu);
return () => {
i18n.off("languageChanged", syncTrayMenu);
};
}, [t, i18n]);
const handleMinimize = async () => {
setIsOpen(false);
try {
-106
View File
@@ -1,106 +0,0 @@
"use client";
import { invoke } from "@tauri-apps/api/core";
import { useCallback, useState } from "react";
import { useTranslation } from "react-i18next";
import { LoadingButton } from "@/components/loading-button";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { showErrorToast, showSuccessToast } from "@/lib/toast-utils";
interface LaunchOnLoginDialogProps {
isOpen: boolean;
onClose: () => void;
}
export function LaunchOnLoginDialog({
isOpen,
onClose,
}: LaunchOnLoginDialogProps) {
const { t } = useTranslation();
const [isEnabling, setIsEnabling] = useState(false);
const [isDeclining, setIsDeclining] = useState(false);
const handleEnable = useCallback(async () => {
setIsEnabling(true);
try {
await invoke("enable_launch_on_login");
showSuccessToast(t("launchOnLogin.enableSuccess"));
onClose();
} catch (error) {
console.error("Failed to enable launch on login:", error);
showErrorToast(t("launchOnLogin.enableFailed"), {
description:
error instanceof Error ? error.message : t("launchOnLogin.tryAgain"),
});
} finally {
setIsEnabling(false);
}
}, [onClose, t]);
const handleDecline = useCallback(async () => {
setIsDeclining(true);
try {
await invoke("decline_launch_on_login");
onClose();
} catch (error) {
console.error("Failed to decline launch on login:", error);
showErrorToast(t("launchOnLogin.declineFailed"), {
description:
error instanceof Error ? error.message : t("launchOnLogin.tryAgain"),
});
} finally {
setIsDeclining(false);
}
}, [onClose, t]);
return (
<Dialog open={isOpen}>
<DialogContent
className="sm:max-w-sm"
onEscapeKeyDown={(e) => {
e.preventDefault();
}}
onPointerDownOutside={(e) => {
e.preventDefault();
}}
onInteractOutside={(e) => {
e.preventDefault();
}}
>
<DialogHeader>
<DialogTitle>{t("launchOnLogin.title")}</DialogTitle>
</DialogHeader>
<p className="text-sm text-muted-foreground">
{t("launchOnLogin.description")}
</p>
<DialogFooter className="flex-row justify-between sm:justify-between">
<Button
variant="ghost"
onClick={handleDecline}
disabled={isEnabling || isDeclining}
>
{isDeclining
? t("launchOnLogin.declining")
: t("launchOnLogin.declineButton")}
</Button>
<LoadingButton
onClick={handleEnable}
isLoading={isEnabling}
disabled={isDeclining}
>
{t("launchOnLogin.enableButton")}
</LoadingButton>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
+81 -31
View File
@@ -422,7 +422,9 @@ export function SharedCamoufoxConfigForm({
e.target.value || undefined,
);
}}
placeholder="Mozilla/5.0..."
placeholder={t("common.placeholders.example", {
value: "Mozilla/5.0...",
})}
/>
</div>
<div className="space-y-2">
@@ -436,7 +438,9 @@ export function SharedCamoufoxConfigForm({
e.target.value || undefined,
);
}}
placeholder="e.g., MacIntel, Win32"
placeholder={t("common.placeholders.example", {
value: "MacIntel, Win32",
})}
/>
</div>
<div className="space-y-2">
@@ -452,7 +456,9 @@ export function SharedCamoufoxConfigForm({
e.target.value || undefined,
);
}}
placeholder="e.g., 5.0 (Macintosh)"
placeholder={t("common.placeholders.example", {
value: "5.0 (Macintosh)",
})}
/>
</div>
<div className="space-y-2">
@@ -487,7 +493,7 @@ export function SharedCamoufoxConfigForm({
e.target.value ? parseInt(e.target.value, 10) : undefined,
);
}}
placeholder="e.g., 8"
placeholder={t("common.placeholders.example", { value: "8" })}
/>
</div>
<div className="space-y-2">
@@ -504,7 +510,7 @@ export function SharedCamoufoxConfigForm({
e.target.value ? parseInt(e.target.value, 10) : undefined,
);
}}
placeholder="e.g., 0"
placeholder={t("common.placeholders.example", { value: "0" })}
/>
</div>
<div className="space-y-2">
@@ -549,7 +555,9 @@ export function SharedCamoufoxConfigForm({
e.target.value || undefined,
);
}}
placeholder="e.g., en-US"
placeholder={t("common.placeholders.example", {
value: "en-US",
})}
/>
</div>
</div>
@@ -573,7 +581,9 @@ export function SharedCamoufoxConfigForm({
e.target.value ? parseInt(e.target.value, 10) : undefined,
);
}}
placeholder="e.g., 1920"
placeholder={t("common.placeholders.example", {
value: "1920",
})}
/>
</div>
<div className="space-y-2">
@@ -590,7 +600,9 @@ export function SharedCamoufoxConfigForm({
e.target.value ? parseInt(e.target.value, 10) : undefined,
);
}}
placeholder="e.g., 1080"
placeholder={t("common.placeholders.example", {
value: "1080",
})}
/>
</div>
<div className="space-y-2">
@@ -607,7 +619,9 @@ export function SharedCamoufoxConfigForm({
e.target.value ? parseInt(e.target.value, 10) : undefined,
);
}}
placeholder="e.g., 1920"
placeholder={t("common.placeholders.example", {
value: "1920",
})}
/>
</div>
<div className="space-y-2">
@@ -624,7 +638,9 @@ export function SharedCamoufoxConfigForm({
e.target.value ? parseInt(e.target.value, 10) : undefined,
);
}}
placeholder="e.g., 1055"
placeholder={t("common.placeholders.example", {
value: "1055",
})}
/>
</div>
<div className="space-y-2">
@@ -641,7 +657,9 @@ export function SharedCamoufoxConfigForm({
e.target.value ? parseInt(e.target.value, 10) : undefined,
);
}}
placeholder="e.g., 30"
placeholder={t("common.placeholders.example", {
value: "30",
})}
/>
</div>
<div className="space-y-2">
@@ -658,7 +676,9 @@ export function SharedCamoufoxConfigForm({
e.target.value ? parseInt(e.target.value, 10) : undefined,
);
}}
placeholder="e.g., 30"
placeholder={t("common.placeholders.example", {
value: "30",
})}
/>
</div>
</div>
@@ -682,7 +702,9 @@ export function SharedCamoufoxConfigForm({
e.target.value ? parseInt(e.target.value, 10) : undefined,
);
}}
placeholder="e.g., 1512"
placeholder={t("common.placeholders.example", {
value: "1512",
})}
/>
</div>
<div className="space-y-2">
@@ -699,7 +721,9 @@ export function SharedCamoufoxConfigForm({
e.target.value ? parseInt(e.target.value, 10) : undefined,
);
}}
placeholder="e.g., 886"
placeholder={t("common.placeholders.example", {
value: "886",
})}
/>
</div>
<div className="space-y-2">
@@ -716,7 +740,9 @@ export function SharedCamoufoxConfigForm({
e.target.value ? parseInt(e.target.value, 10) : undefined,
);
}}
placeholder="e.g., 1512"
placeholder={t("common.placeholders.example", {
value: "1512",
})}
/>
</div>
<div className="space-y-2">
@@ -733,7 +759,9 @@ export function SharedCamoufoxConfigForm({
e.target.value ? parseInt(e.target.value, 10) : undefined,
);
}}
placeholder="e.g., 886"
placeholder={t("common.placeholders.example", {
value: "886",
})}
/>
</div>
<div className="space-y-2">
@@ -748,7 +776,7 @@ export function SharedCamoufoxConfigForm({
e.target.value ? parseInt(e.target.value, 10) : undefined,
);
}}
placeholder="e.g., 0"
placeholder={t("common.placeholders.example", { value: "0" })}
/>
</div>
<div className="space-y-2">
@@ -763,7 +791,7 @@ export function SharedCamoufoxConfigForm({
e.target.value ? parseInt(e.target.value, 10) : undefined,
);
}}
placeholder="e.g., 0"
placeholder={t("common.placeholders.example", { value: "0" })}
/>
</div>
</div>
@@ -786,7 +814,9 @@ export function SharedCamoufoxConfigForm({
e.target.value ? parseFloat(e.target.value) : undefined,
);
}}
placeholder="e.g., 41.0019"
placeholder={t("common.placeholders.example", {
value: "41.0019",
})}
/>
</div>
<div className="space-y-2">
@@ -802,7 +832,9 @@ export function SharedCamoufoxConfigForm({
e.target.value ? parseFloat(e.target.value) : undefined,
);
}}
placeholder="e.g., 28.9645"
placeholder={t("common.placeholders.example", {
value: "28.9645",
})}
/>
</div>
<div className="space-y-2">
@@ -817,7 +849,9 @@ export function SharedCamoufoxConfigForm({
e.target.value || undefined,
);
}}
placeholder="e.g., America/New_York"
placeholder={t("common.placeholders.example", {
value: "America/New_York",
})}
/>
</div>
</div>
@@ -840,7 +874,9 @@ export function SharedCamoufoxConfigForm({
e.target.value || undefined,
);
}}
placeholder="e.g., tr"
placeholder={t("common.placeholders.example", {
value: "tr",
})}
/>
</div>
<div className="space-y-2">
@@ -854,7 +890,9 @@ export function SharedCamoufoxConfigForm({
e.target.value || undefined,
);
}}
placeholder="e.g., TR"
placeholder={t("common.placeholders.example", {
value: "TR",
})}
/>
</div>
<div className="space-y-2">
@@ -868,7 +906,9 @@ export function SharedCamoufoxConfigForm({
e.target.value || undefined,
);
}}
placeholder="e.g., Latn"
placeholder={t("common.placeholders.example", {
value: "Latn",
})}
/>
</div>
</div>
@@ -891,7 +931,9 @@ export function SharedCamoufoxConfigForm({
e.target.value || undefined,
);
}}
placeholder="e.g., Mesa"
placeholder={t("common.placeholders.example", {
value: "Mesa",
})}
/>
</div>
<div className="space-y-2">
@@ -1053,7 +1095,7 @@ export function SharedCamoufoxConfigForm({
e.target.value ? parseFloat(e.target.value) : undefined,
);
}}
placeholder="e.g., 0"
placeholder={t("common.placeholders.example", { value: "0" })}
/>
</div>
<div className="space-y-2">
@@ -1071,7 +1113,7 @@ export function SharedCamoufoxConfigForm({
e.target.value ? parseFloat(e.target.value) : undefined,
);
}}
placeholder="e.g., 0"
placeholder={t("common.placeholders.example", { value: "0" })}
/>
</div>
</div>
@@ -1240,7 +1282,9 @@ export function SharedCamoufoxConfigForm({
: undefined,
);
}}
placeholder="e.g., 1920"
placeholder={t("common.placeholders.example", {
value: "1920",
})}
/>
</div>
<div className="space-y-2">
@@ -1259,7 +1303,9 @@ export function SharedCamoufoxConfigForm({
: undefined,
);
}}
placeholder="e.g., 1080"
placeholder={t("common.placeholders.example", {
value: "1080",
})}
/>
</div>
<div className="space-y-2">
@@ -1278,7 +1324,9 @@ export function SharedCamoufoxConfigForm({
: undefined,
);
}}
placeholder="e.g., 800"
placeholder={t("common.placeholders.example", {
value: "800",
})}
/>
</div>
<div className="space-y-2">
@@ -1297,7 +1345,9 @@ export function SharedCamoufoxConfigForm({
: undefined,
);
}}
placeholder="e.g., 600"
placeholder={t("common.placeholders.example", {
value: "600",
})}
/>
</div>
</div>
+90 -34
View File
@@ -302,7 +302,9 @@ export function WayfernConfigForm({
e.target.value || undefined,
);
}}
placeholder="Mozilla/5.0..."
placeholder={t("common.placeholders.example", {
value: "Mozilla/5.0...",
})}
/>
</div>
<div className="space-y-2">
@@ -334,7 +336,9 @@ export function WayfernConfigForm({
e.target.value || undefined,
);
}}
placeholder="e.g., 10.0.0"
placeholder={t("common.placeholders.example", {
value: "10.0.0",
})}
/>
</div>
<div className="space-y-2">
@@ -348,7 +352,9 @@ export function WayfernConfigForm({
e.target.value || undefined,
);
}}
placeholder="e.g., Google Chrome"
placeholder={t("common.placeholders.example", {
value: "Google Chrome",
})}
/>
</div>
<div className="space-y-2">
@@ -364,7 +370,9 @@ export function WayfernConfigForm({
e.target.value || undefined,
);
}}
placeholder="e.g., 143"
placeholder={t("common.placeholders.example", {
value: "143",
})}
/>
</div>
</div>
@@ -388,7 +396,7 @@ export function WayfernConfigForm({
e.target.value ? parseInt(e.target.value, 10) : undefined,
);
}}
placeholder="e.g., 8"
placeholder={t("common.placeholders.example", { value: "8" })}
/>
</div>
<div className="space-y-2">
@@ -405,7 +413,7 @@ export function WayfernConfigForm({
e.target.value ? parseInt(e.target.value, 10) : undefined,
);
}}
placeholder="e.g., 0"
placeholder={t("common.placeholders.example", { value: "0" })}
/>
</div>
<div className="space-y-2">
@@ -422,7 +430,7 @@ export function WayfernConfigForm({
e.target.value ? parseInt(e.target.value, 10) : undefined,
);
}}
placeholder="e.g., 8"
placeholder={t("common.placeholders.example", { value: "8" })}
/>
</div>
</div>
@@ -446,7 +454,9 @@ export function WayfernConfigForm({
e.target.value ? parseInt(e.target.value, 10) : undefined,
);
}}
placeholder="e.g., 1920"
placeholder={t("common.placeholders.example", {
value: "1920",
})}
/>
</div>
<div className="space-y-2">
@@ -463,7 +473,9 @@ export function WayfernConfigForm({
e.target.value ? parseInt(e.target.value, 10) : undefined,
);
}}
placeholder="e.g., 1080"
placeholder={t("common.placeholders.example", {
value: "1080",
})}
/>
</div>
<div className="space-y-2">
@@ -481,7 +493,9 @@ export function WayfernConfigForm({
e.target.value ? parseFloat(e.target.value) : undefined,
);
}}
placeholder="e.g., 1.0"
placeholder={t("common.placeholders.example", {
value: "1.0",
})}
/>
</div>
<div className="space-y-2">
@@ -498,7 +512,9 @@ export function WayfernConfigForm({
e.target.value ? parseInt(e.target.value, 10) : undefined,
);
}}
placeholder="e.g., 1920"
placeholder={t("common.placeholders.example", {
value: "1920",
})}
/>
</div>
<div className="space-y-2">
@@ -515,7 +531,9 @@ export function WayfernConfigForm({
e.target.value ? parseInt(e.target.value, 10) : undefined,
);
}}
placeholder="e.g., 1040"
placeholder={t("common.placeholders.example", {
value: "1040",
})}
/>
</div>
<div className="space-y-2">
@@ -532,7 +550,9 @@ export function WayfernConfigForm({
e.target.value ? parseInt(e.target.value, 10) : undefined,
);
}}
placeholder="e.g., 24"
placeholder={t("common.placeholders.example", {
value: "24",
})}
/>
</div>
</div>
@@ -556,7 +576,9 @@ export function WayfernConfigForm({
e.target.value ? parseInt(e.target.value, 10) : undefined,
);
}}
placeholder="e.g., 1920"
placeholder={t("common.placeholders.example", {
value: "1920",
})}
/>
</div>
<div className="space-y-2">
@@ -573,7 +595,9 @@ export function WayfernConfigForm({
e.target.value ? parseInt(e.target.value, 10) : undefined,
);
}}
placeholder="e.g., 1040"
placeholder={t("common.placeholders.example", {
value: "1040",
})}
/>
</div>
<div className="space-y-2">
@@ -590,7 +614,9 @@ export function WayfernConfigForm({
e.target.value ? parseInt(e.target.value, 10) : undefined,
);
}}
placeholder="e.g., 1920"
placeholder={t("common.placeholders.example", {
value: "1920",
})}
/>
</div>
<div className="space-y-2">
@@ -607,7 +633,9 @@ export function WayfernConfigForm({
e.target.value ? parseInt(e.target.value, 10) : undefined,
);
}}
placeholder="e.g., 940"
placeholder={t("common.placeholders.example", {
value: "940",
})}
/>
</div>
<div className="space-y-2">
@@ -622,7 +650,7 @@ export function WayfernConfigForm({
e.target.value ? parseInt(e.target.value, 10) : undefined,
);
}}
placeholder="e.g., 0"
placeholder={t("common.placeholders.example", { value: "0" })}
/>
</div>
<div className="space-y-2">
@@ -637,7 +665,7 @@ export function WayfernConfigForm({
e.target.value ? parseInt(e.target.value, 10) : undefined,
);
}}
placeholder="e.g., 0"
placeholder={t("common.placeholders.example", { value: "0" })}
/>
</div>
</div>
@@ -660,7 +688,9 @@ export function WayfernConfigForm({
e.target.value || undefined,
);
}}
placeholder="e.g., en-US"
placeholder={t("common.placeholders.example", {
value: "en-US",
})}
/>
</div>
<div className="space-y-2">
@@ -740,7 +770,9 @@ export function WayfernConfigForm({
e.target.value || undefined,
);
}}
placeholder="e.g., America/New_York"
placeholder={t("common.placeholders.example", {
value: "America/New_York",
})}
/>
</div>
<div className="space-y-2">
@@ -775,7 +807,9 @@ export function WayfernConfigForm({
e.target.value ? parseFloat(e.target.value) : undefined,
);
}}
placeholder="e.g., 40.7128"
placeholder={t("common.placeholders.example", {
value: "40.7128",
})}
/>
</div>
<div className="space-y-2">
@@ -791,7 +825,9 @@ export function WayfernConfigForm({
e.target.value ? parseFloat(e.target.value) : undefined,
);
}}
placeholder="e.g., -74.0060"
placeholder={t("common.placeholders.example", {
value: "-74.0060",
})}
/>
</div>
<div className="space-y-2">
@@ -806,7 +842,9 @@ export function WayfernConfigForm({
e.target.value ? parseFloat(e.target.value) : undefined,
);
}}
placeholder="e.g., 100"
placeholder={t("common.placeholders.example", {
value: "100",
})}
/>
</div>
</div>
@@ -829,7 +867,9 @@ export function WayfernConfigForm({
e.target.value || undefined,
);
}}
placeholder="e.g., Intel"
placeholder={t("common.placeholders.example", {
value: "Intel",
})}
/>
</div>
<div className="space-y-2">
@@ -926,7 +966,9 @@ export function WayfernConfigForm({
e.target.value ? parseInt(e.target.value, 10) : undefined,
);
}}
placeholder="e.g., 48000"
placeholder={t("common.placeholders.example", {
value: "48000",
})}
/>
</div>
<div className="space-y-2">
@@ -943,7 +985,7 @@ export function WayfernConfigForm({
e.target.value ? parseInt(e.target.value, 10) : undefined,
);
}}
placeholder="e.g., 2"
placeholder={t("common.placeholders.example", { value: "2" })}
/>
</div>
</div>
@@ -987,7 +1029,9 @@ export function WayfernConfigForm({
e.target.value ? parseFloat(e.target.value) : undefined,
);
}}
placeholder="e.g., 0.85"
placeholder={t("common.placeholders.example", {
value: "0.85",
})}
/>
</div>
</div>
@@ -1008,7 +1052,9 @@ export function WayfernConfigForm({
e.target.value || undefined,
);
}}
placeholder="e.g., Google Inc."
placeholder={t("common.placeholders.example", {
value: "Google Inc.",
})}
/>
</div>
<div className="space-y-2">
@@ -1038,7 +1084,9 @@ export function WayfernConfigForm({
e.target.value || undefined,
);
}}
placeholder="e.g., 20030107"
placeholder={t("common.placeholders.example", {
value: "20030107",
})}
/>
</div>
</div>
@@ -1197,7 +1245,9 @@ export function WayfernConfigForm({
: undefined,
);
}}
placeholder="e.g., 1920"
placeholder={t("common.placeholders.example", {
value: "1920",
})}
/>
</div>
<div className="space-y-2">
@@ -1216,7 +1266,9 @@ export function WayfernConfigForm({
: undefined,
);
}}
placeholder="e.g., 1080"
placeholder={t("common.placeholders.example", {
value: "1080",
})}
/>
</div>
<div className="space-y-2">
@@ -1235,7 +1287,9 @@ export function WayfernConfigForm({
: undefined,
);
}}
placeholder="e.g., 800"
placeholder={t("common.placeholders.example", {
value: "800",
})}
/>
</div>
<div className="space-y-2">
@@ -1254,7 +1308,9 @@ export function WayfernConfigForm({
: undefined,
);
}}
placeholder="e.g., 600"
placeholder={t("common.placeholders.example", {
value: "600",
})}
/>
</div>
</div>
+1 -1
View File
@@ -71,7 +71,7 @@ export function useAppUpdateNotifications() {
percentage: 0,
speed: undefined,
eta: undefined,
message: "Starting update...",
message: t("appUpdate.toast.startingUpdate"),
});
await invoke("download_and_prepare_app_update", {
+2 -2
View File
@@ -443,7 +443,7 @@ export function useBrowserDownload() {
showToast({
id: "geoip-download",
type: "download",
title: "Downloading GeoIP database",
title: i18n.t("browserDownload.toast.geoipDownloading"),
stage: "downloading",
progress: {
percentage,
@@ -455,7 +455,7 @@ export function useBrowserDownload() {
showToast({
id: "geoip-download",
type: "download",
title: "GeoIP database downloaded successfully!",
title: i18n.t("browserDownload.toast.geoipDownloaded"),
stage: "completed",
});
}
+35 -13
View File
@@ -62,8 +62,12 @@ export function useUpdateNotifications(
showToast({
id: `auto-update-started-${browser}-${newVersion}`,
type: "loading",
title: `${browserDisplayName} update started`,
description: `Version ${newVersion} download will begin shortly. Browser launch is disabled until update completes.`,
title: i18n.t("versionUpdater.toast.updateStarted", {
browser: browserDisplayName,
}),
description: i18n.t("versionUpdater.toast.updateStartedDescription", {
version: newVersion,
}),
duration: 4000,
});
@@ -83,8 +87,11 @@ export function useUpdateNotifications(
showToast({
id: `auto-update-skip-download-${browser}-${newVersion}`,
type: "success",
title: `${browserDisplayName} ${newVersion} already available`,
description: "Updating profile configurations...",
title: i18n.t("versionUpdater.toast.alreadyAvailable", {
browser: browserDisplayName,
version: newVersion,
}),
description: i18n.t("versionUpdater.toast.updatingProfiles"),
duration: 3000,
});
} else {
@@ -92,8 +99,11 @@ export function useUpdateNotifications(
showToast({
id: `auto-update-download-starting-${browser}-${newVersion}`,
type: "loading",
title: `Starting ${browserDisplayName} ${newVersion} download`,
description: "Download progress will be shown below...",
title: i18n.t("versionUpdater.toast.downloadStarting", {
browser: browserDisplayName,
version: newVersion,
}),
description: i18n.t("versionUpdater.toast.downloadProgressBelow"),
duration: 4000,
});
@@ -115,24 +125,36 @@ export function useUpdateNotifications(
// Show success message based on whether profiles were updated
if (updatedProfiles.length > 0) {
const profileText =
const description =
updatedProfiles.length === 1
? `Profile "${updatedProfiles[0]}" has been updated`
: `${updatedProfiles.length} profiles have been updated`;
? i18n.t("versionUpdater.toast.singleProfileUpdated", {
name: updatedProfiles[0],
version: newVersion,
})
: i18n.t("versionUpdater.toast.multipleProfilesUpdated", {
count: updatedProfiles.length,
version: newVersion,
});
showToast({
id: `auto-update-success-${browser}-${newVersion}`,
type: "success",
title: `${browserDisplayName} update completed`,
description: `${profileText} to version ${newVersion}. You can now launch your browsers with the latest version.`,
title: i18n.t("versionUpdater.toast.updateCompleted", {
browser: browserDisplayName,
}),
description,
duration: 6000,
});
} else {
showToast({
id: `auto-update-success-${browser}-${newVersion}`,
type: "success",
title: `${browserDisplayName} update completed`,
description: `Version ${newVersion} is now available. Running profiles will use the new version when restarted.`,
title: i18n.t("versionUpdater.toast.updateCompleted", {
browser: browserDisplayName,
}),
description: i18n.t("versionUpdater.toast.versionAvailable", {
version: newVersion,
}),
duration: 6000,
});
}
+7 -1
View File
@@ -139,7 +139,13 @@ export function useVersionUpdater() {
try {
// Show auto-update start notification
showAutoUpdateToast(browserDisplayName, new_version, {
description: `Downloading ${browserDisplayName} ${new_version} automatically. Progress will be shown below.`,
description: i18n.t(
"versionUpdater.toast.autoDownloadStarted",
{
browser: browserDisplayName,
version: new_version,
},
),
});
// Dismiss the update notification in the backend
+24 -15
View File
@@ -33,7 +33,8 @@
"minimize": "Minimize",
"saving": "Saving…",
"saved": "Saved",
"copied": "Copied"
"copied": "Copied",
"learnMore": "Learn more"
},
"status": {
"active": "Active",
@@ -99,6 +100,9 @@
"srOnly": {
"copy": "Copy",
"copied": "Copied"
},
"placeholders": {
"example": "e.g., {{value}}"
}
},
"settings": {
@@ -1524,17 +1528,6 @@
"creatingButton": "Creating...",
"createButton": "Create"
},
"launchOnLogin": {
"title": "Enable Launch on Login?",
"description": "Running in the background helps keep your proxies and browsers alive.",
"declineButton": "Don't Ask Again",
"declining": "...",
"enableButton": "Enable",
"enableSuccess": "Launch on login enabled",
"enableFailed": "Failed to enable launch on login",
"declineFailed": "Failed to save preference",
"tryAgain": "Please try again"
},
"wayfernTerms": {
"title": "Wayfern Terms and Conditions",
"description": "Before using Donut Browser, you must read and agree to Wayfern's Terms and Conditions.",
@@ -1680,7 +1673,8 @@
"viewRelease": "View Release",
"later": "Later",
"uploading": "Uploading",
"downloading": "Downloading"
"downloading": "Downloading",
"startingUpdate": "Starting update..."
}
},
"browserDownload": {
@@ -1694,7 +1688,9 @@
"extractionFailedDescription": "The corrupt file was deleted. It will be re-downloaded on next attempt.",
"extracting": "Extracting browser files... Please do not close the app.",
"verifying": "Verifying browser files...",
"downloadingRolling": "Downloading rolling release build..."
"downloadingRolling": "Downloading rolling release build...",
"geoipDownloading": "Downloading GeoIP database",
"geoipDownloaded": "GeoIP database downloaded successfully!"
}
},
"versionUpdater": {
@@ -1712,7 +1708,12 @@
"updateSuccessDescription": "Found {{newVersions}} new versions across {{successfulUpdates}} browsers. Auto-downloads will start shortly.",
"upToDate": "No new browser versions found",
"upToDateDescription": "All browser versions are up to date",
"updateAllFailed": "Failed to update browser versions"
"updateAllFailed": "Failed to update browser versions",
"updateStarted": "{{browser}} update started",
"updateStartedDescription": "Version {{version}} download will begin shortly. Browser launch is disabled until update completes.",
"downloadStarting": "Starting {{browser}} {{version}} download",
"downloadProgressBelow": "Download progress will be shown below...",
"autoDownloadStarted": "Downloading {{browser}} {{version}} automatically. Progress will be shown below."
}
},
"profilePassword": {
@@ -1918,5 +1919,13 @@
"description": "Would you like to send the app to the system tray or quit?",
"minimize": "Minimize to Tray",
"quit": "Quit"
},
"tray": {
"show": "Show Donut Browser",
"quit": "Quit"
},
"browserSupport": {
"endingSoonTitle": "Browser support ending soon",
"endingSoonDescription": "Support for the following profiles will be removed on March 15, 2026: {{profiles}}. Please migrate to Wayfern or Camoufox profiles."
}
}
+24 -15
View File
@@ -33,7 +33,8 @@
"minimize": "Minimizar",
"saving": "Guardando…",
"saved": "Guardado",
"copied": "Copiado"
"copied": "Copiado",
"learnMore": "Más información"
},
"status": {
"active": "Activo",
@@ -99,6 +100,9 @@
"srOnly": {
"copy": "Copiar",
"copied": "Copiado"
},
"placeholders": {
"example": "p. ej., {{value}}"
}
},
"settings": {
@@ -1524,17 +1528,6 @@
"creatingButton": "Creando...",
"createButton": "Crear"
},
"launchOnLogin": {
"title": "¿Activar inicio al iniciar sesión?",
"description": "Ejecutarse en segundo plano ayuda a mantener vivos los proxies y navegadores.",
"declineButton": "No volver a preguntar",
"declining": "...",
"enableButton": "Activar",
"enableSuccess": "Inicio al iniciar sesión activado",
"enableFailed": "Error al activar el inicio al iniciar sesión",
"declineFailed": "Error al guardar la preferencia",
"tryAgain": "Por favor, inténtalo de nuevo"
},
"wayfernTerms": {
"title": "Términos y condiciones de Wayfern",
"description": "Antes de usar Donut Browser, debes leer y aceptar los Términos y Condiciones de Wayfern.",
@@ -1680,7 +1673,8 @@
"viewRelease": "Ver lanzamiento",
"later": "Más tarde",
"uploading": "Subiendo",
"downloading": "Descargando"
"downloading": "Descargando",
"startingUpdate": "Iniciando actualización..."
}
},
"browserDownload": {
@@ -1694,7 +1688,9 @@
"extractionFailedDescription": "El archivo dañado fue eliminado. Se volverá a descargar en el próximo intento.",
"extracting": "Extrayendo archivos del navegador... No cierre la aplicación.",
"verifying": "Verificando archivos del navegador...",
"downloadingRolling": "Descargando compilación rolling release..."
"downloadingRolling": "Descargando compilación rolling release...",
"geoipDownloading": "Descargando base de datos GeoIP",
"geoipDownloaded": "¡Base de datos GeoIP descargada correctamente!"
}
},
"versionUpdater": {
@@ -1712,7 +1708,12 @@
"updateSuccessDescription": "Se encontraron {{newVersions}} nuevas versiones en {{successfulUpdates}} navegadores. Las descargas automáticas comenzarán pronto.",
"upToDate": "No se encontraron nuevas versiones del navegador",
"upToDateDescription": "Todas las versiones del navegador están actualizadas",
"updateAllFailed": "Error al actualizar las versiones del navegador"
"updateAllFailed": "Error al actualizar las versiones del navegador",
"updateStarted": "Actualización de {{browser}} iniciada",
"updateStartedDescription": "La descarga de la versión {{version}} comenzará en breve. El inicio del navegador está deshabilitado hasta que finalice la actualización.",
"downloadStarting": "Iniciando la descarga de {{browser}} {{version}}",
"downloadProgressBelow": "El progreso de la descarga se mostrará a continuación...",
"autoDownloadStarted": "Descargando {{browser}} {{version}} automáticamente. El progreso se mostrará a continuación."
}
},
"profilePassword": {
@@ -1918,5 +1919,13 @@
"description": "¿Quieres enviar la aplicación a la bandeja del sistema o salir?",
"minimize": "Minimizar a la bandeja",
"quit": "Salir"
},
"tray": {
"show": "Mostrar Donut Browser",
"quit": "Salir"
},
"browserSupport": {
"endingSoonTitle": "El soporte del navegador finalizará pronto",
"endingSoonDescription": "El soporte para los siguientes perfiles se eliminará el 15 de marzo de 2026: {{profiles}}. Migra a perfiles de Wayfern o Camoufox."
}
}
+24 -15
View File
@@ -33,7 +33,8 @@
"minimize": "Réduire",
"saving": "Enregistrement…",
"saved": "Enregistré",
"copied": "Copié"
"copied": "Copié",
"learnMore": "En savoir plus"
},
"status": {
"active": "Actif",
@@ -99,6 +100,9 @@
"srOnly": {
"copy": "Copier",
"copied": "Copié"
},
"placeholders": {
"example": "p. ex. {{value}}"
}
},
"settings": {
@@ -1524,17 +1528,6 @@
"creatingButton": "Création...",
"createButton": "Créer"
},
"launchOnLogin": {
"title": "Activer le démarrage à la connexion ?",
"description": "Tourner en arrière-plan permet de garder vos proxys et navigateurs actifs.",
"declineButton": "Ne plus demander",
"declining": "...",
"enableButton": "Activer",
"enableSuccess": "Démarrage à la connexion activé",
"enableFailed": "Échec de l'activation du démarrage à la connexion",
"declineFailed": "Échec de l'enregistrement de la préférence",
"tryAgain": "Veuillez réessayer"
},
"wayfernTerms": {
"title": "Conditions générales de Wayfern",
"description": "Avant d'utiliser Donut Browser, vous devez lire et accepter les Conditions Générales de Wayfern.",
@@ -1680,7 +1673,8 @@
"viewRelease": "Voir la version",
"later": "Plus tard",
"uploading": "Envoi",
"downloading": "Téléchargement"
"downloading": "Téléchargement",
"startingUpdate": "Démarrage de la mise à jour..."
}
},
"browserDownload": {
@@ -1694,7 +1688,9 @@
"extractionFailedDescription": "Le fichier corrompu a été supprimé. Il sera retéléchargé lors de la prochaine tentative.",
"extracting": "Extraction des fichiers du navigateur... Ne fermez pas l'application.",
"verifying": "Vérification des fichiers du navigateur...",
"downloadingRolling": "Téléchargement de la version rolling release..."
"downloadingRolling": "Téléchargement de la version rolling release...",
"geoipDownloading": "Téléchargement de la base de données GeoIP",
"geoipDownloaded": "Base de données GeoIP téléchargée avec succès !"
}
},
"versionUpdater": {
@@ -1712,7 +1708,12 @@
"updateSuccessDescription": "{{newVersions}} nouvelles versions trouvées sur {{successfulUpdates}} navigateurs. Les téléchargements automatiques commenceront sous peu.",
"upToDate": "Aucune nouvelle version de navigateur trouvée",
"upToDateDescription": "Toutes les versions des navigateurs sont à jour",
"updateAllFailed": "Échec de la mise à jour des versions des navigateurs"
"updateAllFailed": "Échec de la mise à jour des versions des navigateurs",
"updateStarted": "Mise à jour de {{browser}} démarrée",
"updateStartedDescription": "Le téléchargement de la version {{version}} va bientôt commencer. Le lancement du navigateur est désactivé jusqu'à la fin de la mise à jour.",
"downloadStarting": "Démarrage du téléchargement de {{browser}} {{version}}",
"downloadProgressBelow": "La progression du téléchargement sera affichée ci-dessous...",
"autoDownloadStarted": "Téléchargement automatique de {{browser}} {{version}}. La progression sera affichée ci-dessous."
}
},
"profilePassword": {
@@ -1918,5 +1919,13 @@
"description": "Voulez-vous envoyer l'application dans la zone de notification ou quitter ?",
"minimize": "Réduire dans la barre d'état",
"quit": "Quitter"
},
"tray": {
"show": "Afficher Donut Browser",
"quit": "Quitter"
},
"browserSupport": {
"endingSoonTitle": "La prise en charge du navigateur prend bientôt fin",
"endingSoonDescription": "La prise en charge des profils suivants sera supprimée le 15 mars 2026 : {{profiles}}. Veuillez migrer vers des profils Wayfern ou Camoufox."
}
}
+24 -15
View File
@@ -33,7 +33,8 @@
"minimize": "最小化",
"saving": "保存中…",
"saved": "保存しました",
"copied": "コピーしました"
"copied": "コピーしました",
"learnMore": "詳細"
},
"status": {
"active": "アクティブ",
@@ -99,6 +100,9 @@
"srOnly": {
"copy": "コピー",
"copied": "コピーしました"
},
"placeholders": {
"example": "例: {{value}}"
}
},
"settings": {
@@ -1524,17 +1528,6 @@
"creatingButton": "作成中...",
"createButton": "作成"
},
"launchOnLogin": {
"title": "ログイン時に起動しますか?",
"description": "バックグラウンドで実行することで、プロキシとブラウザを維持できます。",
"declineButton": "今後は表示しない",
"declining": "...",
"enableButton": "有効にする",
"enableSuccess": "ログイン時の起動を有効にしました",
"enableFailed": "ログイン時の起動を有効にできませんでした",
"declineFailed": "設定の保存に失敗しました",
"tryAgain": "もう一度お試しください"
},
"wayfernTerms": {
"title": "Wayfern 利用規約",
"description": "Donut Browser を使用する前に、Wayfern の利用規約を読み、同意する必要があります。",
@@ -1680,7 +1673,8 @@
"viewRelease": "リリースを見る",
"later": "後で",
"uploading": "アップロード中",
"downloading": "ダウンロード中"
"downloading": "ダウンロード中",
"startingUpdate": "更新を開始しています..."
}
},
"browserDownload": {
@@ -1694,7 +1688,9 @@
"extractionFailedDescription": "破損したファイルは削除されました。次回の試行時に再ダウンロードされます。",
"extracting": "ブラウザファイルを展開中... アプリを閉じないでください。",
"verifying": "ブラウザファイルを検証中...",
"downloadingRolling": "ローリングリリースビルドをダウンロード中..."
"downloadingRolling": "ローリングリリースビルドをダウンロード中...",
"geoipDownloading": "GeoIP データベースをダウンロード中",
"geoipDownloaded": "GeoIP データベースのダウンロードが完了しました!"
}
},
"versionUpdater": {
@@ -1712,7 +1708,12 @@
"updateSuccessDescription": "{{successfulUpdates}} 個のブラウザに {{newVersions}} 個の新しいバージョンが見つかりました。自動ダウンロードがまもなく開始します。",
"upToDate": "新しいブラウザのバージョンは見つかりませんでした",
"upToDateDescription": "すべてのブラウザバージョンは最新です",
"updateAllFailed": "ブラウザバージョンの更新に失敗しました"
"updateAllFailed": "ブラウザバージョンの更新に失敗しました",
"updateStarted": "{{browser}} の更新を開始しました",
"updateStartedDescription": "バージョン {{version}} のダウンロードがまもなく開始されます。更新が完了するまでブラウザの起動は無効になります。",
"downloadStarting": "{{browser}} {{version}} のダウンロードを開始しています",
"downloadProgressBelow": "ダウンロードの進行状況は下に表示されます...",
"autoDownloadStarted": "{{browser}} {{version}} を自動的にダウンロードしています。進行状況は下に表示されます。"
}
},
"profilePassword": {
@@ -1918,5 +1919,13 @@
"description": "アプリをシステムトレイに格納しますか、それとも終了しますか?",
"minimize": "トレイに格納",
"quit": "終了"
},
"tray": {
"show": "Donut Browser を表示",
"quit": "終了"
},
"browserSupport": {
"endingSoonTitle": "ブラウザのサポートが間もなく終了します",
"endingSoonDescription": "次のプロファイルのサポートは 2026 年 3 月 15 日に削除されます: {{profiles}}。Wayfern または Camoufox のプロファイルに移行してください。"
}
}
+24 -15
View File
@@ -33,7 +33,8 @@
"minimize": "최소화",
"saving": "저장 중…",
"saved": "저장됨",
"copied": "복사됨"
"copied": "복사됨",
"learnMore": "자세히 알아보기"
},
"status": {
"active": "활성",
@@ -99,6 +100,9 @@
"srOnly": {
"copy": "복사",
"copied": "복사됨"
},
"placeholders": {
"example": "예: {{value}}"
}
},
"settings": {
@@ -1524,17 +1528,6 @@
"creatingButton": "생성 중...",
"createButton": "생성"
},
"launchOnLogin": {
"title": "로그인 시 실행을 활성화하시겠습니까?",
"description": "백그라운드에서 실행하면 프록시와 브라우저를 계속 유지할 수 있습니다.",
"declineButton": "다시 묻지 않음",
"declining": "...",
"enableButton": "활성화",
"enableSuccess": "로그인 시 실행이 활성화되었습니다",
"enableFailed": "로그인 시 실행 활성화 실패",
"declineFailed": "환경 설정 저장 실패",
"tryAgain": "다시 시도하세요"
},
"wayfernTerms": {
"title": "Wayfern 이용 약관",
"description": "도넛 브라우저를 사용하기 전에 Wayfern의 이용 약관을 읽고 동의해야 합니다.",
@@ -1680,7 +1673,8 @@
"viewRelease": "릴리스 보기",
"later": "나중에",
"uploading": "업로드 중",
"downloading": "다운로드 중"
"downloading": "다운로드 중",
"startingUpdate": "업데이트를 시작하는 중..."
}
},
"browserDownload": {
@@ -1694,7 +1688,9 @@
"extractionFailedDescription": "손상된 파일이 삭제되었습니다. 다음 시도 시 다시 다운로드됩니다.",
"extracting": "브라우저 파일 압축 해제 중... 앱을 닫지 마세요.",
"verifying": "브라우저 파일 확인 중...",
"downloadingRolling": "롤링 릴리스 빌드 다운로드 중..."
"downloadingRolling": "롤링 릴리스 빌드 다운로드 중...",
"geoipDownloading": "GeoIP 데이터베이스 다운로드 중",
"geoipDownloaded": "GeoIP 데이터베이스를 성공적으로 다운로드했습니다!"
}
},
"versionUpdater": {
@@ -1712,7 +1708,12 @@
"updateSuccessDescription": "{{successfulUpdates}}개 브라우저에서 {{newVersions}}개의 새 버전을 찾았습니다. 자동 다운로드가 곧 시작됩니다.",
"upToDate": "새 브라우저 버전이 없습니다",
"upToDateDescription": "모든 브라우저 버전이 최신입니다",
"updateAllFailed": "브라우저 버전 업데이트 실패"
"updateAllFailed": "브라우저 버전 업데이트 실패",
"updateStarted": "{{browser}} 업데이트를 시작했습니다",
"updateStartedDescription": "버전 {{version}} 다운로드가 곧 시작됩니다. 업데이트가 완료될 때까지 브라우저 실행이 비활성화됩니다.",
"downloadStarting": "{{browser}} {{version}} 다운로드를 시작하는 중",
"downloadProgressBelow": "다운로드 진행 상황이 아래에 표시됩니다...",
"autoDownloadStarted": "{{browser}} {{version}}을(를) 자동으로 다운로드하는 중입니다. 진행 상황이 아래에 표시됩니다."
}
},
"profilePassword": {
@@ -1918,5 +1919,13 @@
"description": "앱을 시스템 트레이로 보내시겠습니까, 아니면 종료하시겠습니까?",
"minimize": "트레이로 최소화",
"quit": "종료"
},
"tray": {
"show": "Donut Browser 표시",
"quit": "종료"
},
"browserSupport": {
"endingSoonTitle": "브라우저 지원이 곧 종료됩니다",
"endingSoonDescription": "다음 프로필에 대한 지원이 2026년 3월 15일에 제거됩니다: {{profiles}}. Wayfern 또는 Camoufox 프로필로 마이그레이션하세요."
}
}
+24 -15
View File
@@ -33,7 +33,8 @@
"minimize": "Minimizar",
"saving": "Salvando…",
"saved": "Salvo",
"copied": "Copiado"
"copied": "Copiado",
"learnMore": "Saiba mais"
},
"status": {
"active": "Ativo",
@@ -99,6 +100,9 @@
"srOnly": {
"copy": "Copiar",
"copied": "Copiado"
},
"placeholders": {
"example": "ex.: {{value}}"
}
},
"settings": {
@@ -1524,17 +1528,6 @@
"creatingButton": "Criando...",
"createButton": "Criar"
},
"launchOnLogin": {
"title": "Ativar inicialização no login?",
"description": "Rodar em segundo plano ajuda a manter seus proxies e navegadores ativos.",
"declineButton": "Não perguntar novamente",
"declining": "...",
"enableButton": "Ativar",
"enableSuccess": "Inicialização no login ativada",
"enableFailed": "Falha ao ativar a inicialização no login",
"declineFailed": "Falha ao salvar a preferência",
"tryAgain": "Tente novamente"
},
"wayfernTerms": {
"title": "Termos e condições da Wayfern",
"description": "Antes de usar o Donut Browser, você deve ler e concordar com os Termos e Condições da Wayfern.",
@@ -1680,7 +1673,8 @@
"viewRelease": "Ver lançamento",
"later": "Mais tarde",
"uploading": "Enviando",
"downloading": "Baixando"
"downloading": "Baixando",
"startingUpdate": "Iniciando atualização..."
}
},
"browserDownload": {
@@ -1694,7 +1688,9 @@
"extractionFailedDescription": "O arquivo corrompido foi excluído. Será baixado novamente na próxima tentativa.",
"extracting": "Extraindo arquivos do navegador... Não feche o aplicativo.",
"verifying": "Verificando arquivos do navegador...",
"downloadingRolling": "Baixando build rolling release..."
"downloadingRolling": "Baixando build rolling release...",
"geoipDownloading": "Baixando banco de dados GeoIP",
"geoipDownloaded": "Banco de dados GeoIP baixado com sucesso!"
}
},
"versionUpdater": {
@@ -1712,7 +1708,12 @@
"updateSuccessDescription": "Foram encontradas {{newVersions}} novas versões em {{successfulUpdates}} navegadores. Os downloads automáticos começarão em breve.",
"upToDate": "Nenhuma nova versão de navegador encontrada",
"upToDateDescription": "Todas as versões dos navegadores estão atualizadas",
"updateAllFailed": "Falha ao atualizar as versões dos navegadores"
"updateAllFailed": "Falha ao atualizar as versões dos navegadores",
"updateStarted": "Atualização do {{browser}} iniciada",
"updateStartedDescription": "O download da versão {{version}} começará em breve. O início do navegador está desativado até a atualização ser concluída.",
"downloadStarting": "Iniciando o download do {{browser}} {{version}}",
"downloadProgressBelow": "O progresso do download será mostrado abaixo...",
"autoDownloadStarted": "Baixando {{browser}} {{version}} automaticamente. O progresso será mostrado abaixo."
}
},
"profilePassword": {
@@ -1918,5 +1919,13 @@
"description": "Você deseja enviar o aplicativo para a bandeja do sistema ou sair?",
"minimize": "Minimizar para a bandeja",
"quit": "Sair"
},
"tray": {
"show": "Mostrar Donut Browser",
"quit": "Sair"
},
"browserSupport": {
"endingSoonTitle": "O suporte ao navegador terminará em breve",
"endingSoonDescription": "O suporte aos seguintes perfis será removido em 15 de março de 2026: {{profiles}}. Migre para perfis Wayfern ou Camoufox."
}
}
+24 -15
View File
@@ -33,7 +33,8 @@
"minimize": "Свернуть",
"saving": "Сохраняем…",
"saved": "Сохранено",
"copied": "Скопировано"
"copied": "Скопировано",
"learnMore": "Подробнее"
},
"status": {
"active": "Активен",
@@ -99,6 +100,9 @@
"srOnly": {
"copy": "Скопировать",
"copied": "Скопировано"
},
"placeholders": {
"example": "напр., {{value}}"
}
},
"settings": {
@@ -1524,17 +1528,6 @@
"creatingButton": "Создание...",
"createButton": "Создать"
},
"launchOnLogin": {
"title": "Запускать при входе?",
"description": "Работа в фоновом режиме помогает поддерживать прокси и браузеры активными.",
"declineButton": "Больше не спрашивать",
"declining": "...",
"enableButton": "Включить",
"enableSuccess": "Запуск при входе включен",
"enableFailed": "Не удалось включить запуск при входе",
"declineFailed": "Не удалось сохранить настройку",
"tryAgain": "Пожалуйста, попробуйте снова"
},
"wayfernTerms": {
"title": "Условия использования Wayfern",
"description": "Прежде чем использовать Donut Browser, необходимо прочитать и согласиться с Условиями использования Wayfern.",
@@ -1680,7 +1673,8 @@
"viewRelease": "Посмотреть релиз",
"later": "Позже",
"uploading": "Загрузка",
"downloading": "Скачивание"
"downloading": "Скачивание",
"startingUpdate": "Запуск обновления..."
}
},
"browserDownload": {
@@ -1694,7 +1688,9 @@
"extractionFailedDescription": "Повреждённый файл удалён. Он будет повторно загружен при следующей попытке.",
"extracting": "Распаковка файлов браузера... Не закрывайте приложение.",
"verifying": "Проверка файлов браузера...",
"downloadingRolling": "Загрузка rolling release сборки..."
"downloadingRolling": "Загрузка rolling release сборки...",
"geoipDownloading": "Загрузка базы данных GeoIP",
"geoipDownloaded": "База данных GeoIP успешно загружена!"
}
},
"versionUpdater": {
@@ -1712,7 +1708,12 @@
"updateSuccessDescription": "Найдено {{newVersions}} новых версий для {{successfulUpdates}} браузеров. Автоматическая загрузка начнётся в ближайшее время.",
"upToDate": "Новых версий браузеров не найдено",
"upToDateDescription": "Все версии браузеров актуальны",
"updateAllFailed": "Не удалось обновить версии браузеров"
"updateAllFailed": "Не удалось обновить версии браузеров",
"updateStarted": "Обновление {{browser}} началось",
"updateStartedDescription": "Загрузка версии {{version}} скоро начнётся. Запуск браузера отключён до завершения обновления.",
"downloadStarting": "Запуск загрузки {{browser}} {{version}}",
"downloadProgressBelow": "Прогресс загрузки будет показан ниже...",
"autoDownloadStarted": "Автоматическая загрузка {{browser}} {{version}}. Прогресс будет показан ниже."
}
},
"profilePassword": {
@@ -1918,5 +1919,13 @@
"description": "Свернуть приложение в системный трей или выйти?",
"minimize": "Свернуть в трей",
"quit": "Выйти"
},
"tray": {
"show": "Показать Donut Browser",
"quit": "Выход"
},
"browserSupport": {
"endingSoonTitle": "Поддержка браузера скоро завершится",
"endingSoonDescription": "Поддержка следующих профилей будет прекращена 15 марта 2026 г.: {{profiles}}. Перейдите на профили Wayfern или Camoufox."
}
}
+24 -15
View File
@@ -33,7 +33,8 @@
"minimize": "最小化",
"saving": "正在保存…",
"saved": "已保存",
"copied": "已复制"
"copied": "已复制",
"learnMore": "了解更多"
},
"status": {
"active": "活跃",
@@ -99,6 +100,9 @@
"srOnly": {
"copy": "复制",
"copied": "已复制"
},
"placeholders": {
"example": "例如:{{value}}"
}
},
"settings": {
@@ -1524,17 +1528,6 @@
"creatingButton": "正在创建...",
"createButton": "创建"
},
"launchOnLogin": {
"title": "启用登录时启动?",
"description": "在后台运行有助于保持代理和浏览器存活。",
"declineButton": "不再询问",
"declining": "...",
"enableButton": "启用",
"enableSuccess": "已启用登录时启动",
"enableFailed": "启用登录时启动失败",
"declineFailed": "保存偏好失败",
"tryAgain": "请重试"
},
"wayfernTerms": {
"title": "Wayfern 条款和条件",
"description": "在使用 Donut Browser 之前,你必须阅读并同意 Wayfern 的条款和条件。",
@@ -1680,7 +1673,8 @@
"viewRelease": "查看版本",
"later": "稍后",
"uploading": "上传中",
"downloading": "下载中"
"downloading": "下载中",
"startingUpdate": "正在开始更新..."
}
},
"browserDownload": {
@@ -1694,7 +1688,9 @@
"extractionFailedDescription": "损坏的文件已删除。下次尝试时将重新下载。",
"extracting": "正在提取浏览器文件...请不要关闭应用。",
"verifying": "正在验证浏览器文件...",
"downloadingRolling": "正在下载滚动发布版本..."
"downloadingRolling": "正在下载滚动发布版本...",
"geoipDownloading": "正在下载 GeoIP 数据库",
"geoipDownloaded": "GeoIP 数据库下载成功!"
}
},
"versionUpdater": {
@@ -1712,7 +1708,12 @@
"updateSuccessDescription": "在 {{successfulUpdates}} 个浏览器中发现 {{newVersions}} 个新版本。自动下载即将开始。",
"upToDate": "未发现新的浏览器版本",
"upToDateDescription": "所有浏览器版本都是最新的",
"updateAllFailed": "更新浏览器版本失败"
"updateAllFailed": "更新浏览器版本失败",
"updateStarted": "{{browser}} 更新已开始",
"updateStartedDescription": "版本 {{version}} 即将开始下载。更新完成前浏览器启动将被禁用。",
"downloadStarting": "正在开始下载 {{browser}} {{version}}",
"downloadProgressBelow": "下载进度将显示在下方...",
"autoDownloadStarted": "正在自动下载 {{browser}} {{version}}。进度将显示在下方。"
}
},
"profilePassword": {
@@ -1918,5 +1919,13 @@
"description": "您想将应用最小化到系统托盘还是退出?",
"minimize": "最小化到托盘",
"quit": "退出"
},
"tray": {
"show": "显示 Donut Browser",
"quit": "退出"
},
"browserSupport": {
"endingSoonTitle": "浏览器支持即将结束",
"endingSoonDescription": "以下配置文件的支持将于 2026 年 3 月 15 日移除:{{profiles}}。请迁移到 Wayfern 或 Camoufox 配置文件。"
}
}