mirror of
https://github.com/zhom/donutbrowser.git
synced 2026-07-07 21:48:03 +02:00
refactor: cleanup
This commit is contained in:
@@ -928,18 +928,35 @@ impl AppAutoUpdater {
|
||||
// Move new app to current location
|
||||
fs::rename(installer_path, ¤t_app_path)?;
|
||||
|
||||
// Remove quarantine attributes from the new app
|
||||
let _ = Command::new("xattr")
|
||||
.args([
|
||||
"-dr",
|
||||
"com.apple.quarantine",
|
||||
current_app_path.to_str().unwrap(),
|
||||
])
|
||||
.output();
|
||||
|
||||
let _ = Command::new("xattr")
|
||||
.args(["-cr", current_app_path.to_str().unwrap()])
|
||||
.output();
|
||||
// Remove the macOS quarantine attribute from the freshly-installed app
|
||||
// so Gatekeeper doesn't block its first launch — but only if it's
|
||||
// actually present. macOS Sequoia's App Management TCC fires on the
|
||||
// modify-class syscall regardless of whether anything is actually
|
||||
// modified, so we gate the call behind a read-only `getxattr` check.
|
||||
let needs_quarantine_removal = {
|
||||
use std::ffi::CString;
|
||||
use std::os::unix::ffi::OsStrExt;
|
||||
let path_c = CString::new(current_app_path.as_os_str().as_bytes()).ok();
|
||||
let attr_c = CString::new("com.apple.quarantine").ok();
|
||||
match (path_c, attr_c) {
|
||||
(Some(p), Some(a)) => {
|
||||
// SAFETY: getxattr with a null buffer is a read-only size query.
|
||||
let result =
|
||||
unsafe { libc::getxattr(p.as_ptr(), a.as_ptr(), std::ptr::null_mut(), 0, 0, 0) };
|
||||
result >= 0
|
||||
}
|
||||
_ => false,
|
||||
}
|
||||
};
|
||||
if needs_quarantine_removal {
|
||||
let _ = Command::new("xattr")
|
||||
.args([
|
||||
"-dr",
|
||||
"com.apple.quarantine",
|
||||
current_app_path.to_str().unwrap(),
|
||||
])
|
||||
.output();
|
||||
}
|
||||
|
||||
// Clean up backup after successful installation
|
||||
let _ = fs::remove_dir_all(&backup_path);
|
||||
|
||||
@@ -127,8 +127,16 @@ lazy_static! {
|
||||
impl CloudAuthManager {
|
||||
fn new() -> Self {
|
||||
let state = Self::load_auth_state_from_disk();
|
||||
// Bound every cloud API call so no single slow / hung request can stall
|
||||
// the startup chain (sync-token → proxy-config → wayfern-token), which
|
||||
// otherwise gates Wayfern launch behind whichever endpoint is slowest.
|
||||
let client = Client::builder()
|
||||
.timeout(std::time::Duration::from_secs(15))
|
||||
.connect_timeout(std::time::Duration::from_secs(5))
|
||||
.build()
|
||||
.unwrap_or_else(|_| Client::new());
|
||||
Self {
|
||||
client: Client::new(),
|
||||
client,
|
||||
state: Mutex::new(state),
|
||||
refresh_lock: tokio::sync::Mutex::new(()),
|
||||
wayfern_token: Mutex::new(None),
|
||||
@@ -990,7 +998,15 @@ impl CloudAuthManager {
|
||||
let token = self
|
||||
.api_call_with_retry(|access_token| {
|
||||
let url = format!("{CLOUD_API_URL}/api/auth/wayfern-start");
|
||||
let client = reqwest::Client::new();
|
||||
// Bound the request: without a timeout, an unreachable
|
||||
// api.donutbrowser.com hangs the background fetch indefinitely,
|
||||
// which in turn forces wayfern_manager's launch-time wait to
|
||||
// exhaust its full polling budget every time.
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(std::time::Duration::from_secs(8))
|
||||
.connect_timeout(std::time::Duration::from_secs(4))
|
||||
.build()
|
||||
.unwrap_or_else(|_| reqwest::Client::new());
|
||||
async move {
|
||||
let response = client
|
||||
.post(&url)
|
||||
|
||||
+70
-23
@@ -12,6 +12,39 @@ use tokio::process::Command;
|
||||
#[cfg(target_os = "macos")]
|
||||
use std::fs::create_dir_all;
|
||||
|
||||
/// Returns true if `path` carries a `com.apple.quarantine` extended attribute.
|
||||
///
|
||||
/// Uses `getxattr` with a null buffer to query the attribute size only —
|
||||
/// this is a read-only syscall and does NOT trigger macOS Sequoia's App
|
||||
/// Management TCC prompt. We use it to gate the `xattr -d` removal: macOS
|
||||
/// fires the prompt on the modify-class syscall (`removexattr`) even when
|
||||
/// the operation is a no-op, so skipping the call entirely when the
|
||||
/// attribute is absent is the only way to stay quiet.
|
||||
#[cfg(target_os = "macos")]
|
||||
fn has_quarantine_attr(path: &Path) -> bool {
|
||||
use std::ffi::CString;
|
||||
use std::os::unix::ffi::OsStrExt;
|
||||
let Ok(path_c) = CString::new(path.as_os_str().as_bytes()) else {
|
||||
return false;
|
||||
};
|
||||
let Ok(attr_c) = CString::new("com.apple.quarantine") else {
|
||||
return false;
|
||||
};
|
||||
// SAFETY: getxattr is a stable libc API. Passing a null buffer with size 0
|
||||
// makes it a pure read-only size query.
|
||||
let result = unsafe {
|
||||
libc::getxattr(
|
||||
path_c.as_ptr(),
|
||||
attr_c.as_ptr(),
|
||||
std::ptr::null_mut(),
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
)
|
||||
};
|
||||
result >= 0
|
||||
}
|
||||
|
||||
pub struct Extractor;
|
||||
|
||||
impl Extractor {
|
||||
@@ -207,18 +240,23 @@ impl Extractor {
|
||||
|
||||
match extraction_result {
|
||||
Ok(path) => {
|
||||
// Remove quarantine attributes on macOS to prevent
|
||||
// "app was prevented from modifying data" prompts
|
||||
// Remove quarantine attributes on macOS to prevent Gatekeeper prompts —
|
||||
// but only if there's actually something to remove. Calling the
|
||||
// modify-class `removexattr` syscall on a file without quarantine still
|
||||
// fires macOS Sequoia's App Management TCC notification, so we skip
|
||||
// the call entirely when the attribute is absent.
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
let _ = tokio::process::Command::new("xattr")
|
||||
.args([
|
||||
"-dr",
|
||||
"com.apple.quarantine",
|
||||
dest_dir.to_str().unwrap_or("."),
|
||||
])
|
||||
.output()
|
||||
.await;
|
||||
if has_quarantine_attr(dest_dir) {
|
||||
let _ = tokio::process::Command::new("xattr")
|
||||
.args([
|
||||
"-dr",
|
||||
"com.apple.quarantine",
|
||||
dest_dir.to_str().unwrap_or("."),
|
||||
])
|
||||
.output()
|
||||
.await;
|
||||
}
|
||||
}
|
||||
|
||||
log::info!(
|
||||
@@ -419,9 +457,15 @@ impl Extractor {
|
||||
|
||||
log::info!("Copying .app to: {}", app_path.display());
|
||||
|
||||
// `-X` strips extended attributes (notably com.apple.quarantine) during
|
||||
// the copy itself. Without it, `cp -R` preserves quarantine from the
|
||||
// mounted DMG, which then has to be removed with `xattr -dr` — and that
|
||||
// removexattr syscall on a signed .app bundle trips macOS Sequoia's App
|
||||
// Management TCC notification ("Donut.app was prevented from modifying
|
||||
// apps on your Mac"). Stripping at copy time is silent.
|
||||
let output = Command::new("cp")
|
||||
.args([
|
||||
"-R",
|
||||
"-RX",
|
||||
app_entry.to_str().unwrap(),
|
||||
app_path.to_str().unwrap(),
|
||||
])
|
||||
@@ -444,18 +488,21 @@ impl Extractor {
|
||||
|
||||
log::info!("Successfully copied .app bundle");
|
||||
|
||||
// Remove quarantine attributes
|
||||
let _ = Command::new("xattr")
|
||||
.args(["-dr", "com.apple.quarantine", app_path.to_str().unwrap()])
|
||||
.output()
|
||||
.await;
|
||||
|
||||
let _ = Command::new("xattr")
|
||||
.args(["-cr", app_path.to_str().unwrap()])
|
||||
.output()
|
||||
.await;
|
||||
|
||||
log::info!("Removed quarantine attributes");
|
||||
// Remove the macOS quarantine attribute so Gatekeeper doesn't block launch
|
||||
// — but only if it's actually present. A no-op `removexattr` syscall on a
|
||||
// signed .app bundle still trips macOS Sequoia's App Management privacy
|
||||
// prompt ("Donut.app was prevented from modifying apps on your Mac"),
|
||||
// even when no modification actually happens, so we gate the call behind
|
||||
// a read-only `getxattr` check.
|
||||
if has_quarantine_attr(&app_path) {
|
||||
let _ = Command::new("xattr")
|
||||
.args(["-dr", "com.apple.quarantine", app_path.to_str().unwrap()])
|
||||
.output()
|
||||
.await;
|
||||
log::info!("Removed quarantine attributes");
|
||||
} else {
|
||||
log::info!("No quarantine attribute on .app, skipping xattr removal");
|
||||
}
|
||||
|
||||
// Unmount the DMG
|
||||
let output = Command::new("hdiutil")
|
||||
|
||||
+21
-11
@@ -1888,21 +1888,31 @@ pub fn run() {
|
||||
// Start cloud auth background refresh loop
|
||||
let app_handle_cloud = app.handle().clone();
|
||||
tauri::async_runtime::spawn(async move {
|
||||
// On startup, refresh sync token and proxy if cloud auth is active.
|
||||
// On startup, refresh sync token, proxy config, and wayfern token in
|
||||
// PARALLEL. Previously they were awaited sequentially, so the wayfern
|
||||
// token request didn't even start until the earlier two API calls had
|
||||
// finished. Wayfern launch can race with this task — a few seconds of
|
||||
// serialized API calls translates directly into a slow first launch
|
||||
// because launch_wayfern blocks waiting for the token to land.
|
||||
// api_call_with_retry handles 401/refresh internally — no direct
|
||||
// refresh_access_token call needed.
|
||||
if cloud_auth::CLOUD_AUTH.is_logged_in().await {
|
||||
if let Err(e) = cloud_auth::CLOUD_AUTH.get_or_refresh_sync_token().await {
|
||||
log::warn!("Failed to refresh cloud sync token on startup: {e}");
|
||||
}
|
||||
cloud_auth::CLOUD_AUTH.sync_cloud_proxy().await;
|
||||
|
||||
// Request wayfern token on startup for paid users
|
||||
if cloud_auth::CLOUD_AUTH.has_active_paid_subscription().await {
|
||||
if let Err(e) = cloud_auth::CLOUD_AUTH.request_wayfern_token().await {
|
||||
log::warn!("Failed to request wayfern token on startup: {e}");
|
||||
let sync_token_fut = async {
|
||||
if let Err(e) = cloud_auth::CLOUD_AUTH.get_or_refresh_sync_token().await {
|
||||
log::warn!("Failed to refresh cloud sync token on startup: {e}");
|
||||
}
|
||||
}
|
||||
};
|
||||
let proxy_fut = async {
|
||||
cloud_auth::CLOUD_AUTH.sync_cloud_proxy().await;
|
||||
};
|
||||
let wayfern_fut = async {
|
||||
if cloud_auth::CLOUD_AUTH.has_active_paid_subscription().await {
|
||||
if let Err(e) = cloud_auth::CLOUD_AUTH.request_wayfern_token().await {
|
||||
log::warn!("Failed to request wayfern token on startup: {e}");
|
||||
}
|
||||
}
|
||||
};
|
||||
tokio::join!(sync_token_fut, proxy_fut, wayfern_fut);
|
||||
}
|
||||
cloud_auth::CloudAuthManager::start_sync_token_refresh_loop(app_handle_cloud).await;
|
||||
});
|
||||
|
||||
@@ -174,6 +174,10 @@ pub struct ProxyManager {
|
||||
// Track active proxy IDs by profile name for targeted cleanup
|
||||
profile_active_proxy_ids: Mutex<HashMap<String, String>>, // Maps profile name to proxy id
|
||||
stored_proxies: Mutex<HashMap<String, StoredProxy>>, // Maps proxy ID to stored proxy
|
||||
// Consecutive cleanup passes during which a browser PID looked dead.
|
||||
// We only reap a worker after it has been missed in N consecutive scans —
|
||||
// a single sysinfo blip under load shouldn't kill a still-running worker.
|
||||
dead_browser_misses: Mutex<HashMap<u32, u8>>,
|
||||
}
|
||||
|
||||
impl ProxyManager {
|
||||
@@ -183,6 +187,7 @@ impl ProxyManager {
|
||||
profile_proxies: Mutex::new(HashMap::new()),
|
||||
profile_active_proxy_ids: Mutex::new(HashMap::new()),
|
||||
stored_proxies: Mutex::new(HashMap::new()),
|
||||
dead_browser_misses: Mutex::new(HashMap::new()),
|
||||
};
|
||||
|
||||
// Load stored proxies on initialization
|
||||
@@ -2095,17 +2100,52 @@ impl ProxyManager {
|
||||
sysinfo::RefreshKind::nothing().with_processes(sysinfo::ProcessRefreshKind::everything()),
|
||||
);
|
||||
|
||||
let dead_browser_entries: Vec<(u32, String, Option<String>)> = snapshot
|
||||
.into_iter()
|
||||
.filter(|(browser_pid, _, _)| {
|
||||
// The sentinel PID=0 is used as a placeholder during launch,
|
||||
// before update_proxy_pid has recorded the real browser PID.
|
||||
*browser_pid != 0
|
||||
&& system
|
||||
.process(sysinfo::Pid::from_u32(*browser_pid))
|
||||
.is_none()
|
||||
})
|
||||
.collect();
|
||||
// Two-state classification: alive PIDs reset their miss counter,
|
||||
// dead PIDs increment it. A worker is only reaped after MISS_THRESHOLD
|
||||
// consecutive misses (~60s by default given the 30s cleanup cadence),
|
||||
// so a single sysinfo blip under heavy load doesn't kill a healthy worker.
|
||||
const MISS_THRESHOLD: u8 = 2;
|
||||
|
||||
let mut alive_pids: Vec<u32> = Vec::new();
|
||||
let mut dead_candidates: Vec<(u32, String, Option<String>)> = Vec::new();
|
||||
let mut snapshot_pids: std::collections::HashSet<u32> = std::collections::HashSet::new();
|
||||
for (browser_pid, proxy_id, profile_id) in snapshot {
|
||||
snapshot_pids.insert(browser_pid);
|
||||
// The sentinel PID=0 is used as a placeholder during launch,
|
||||
// before update_proxy_pid has recorded the real browser PID.
|
||||
if browser_pid == 0 {
|
||||
continue;
|
||||
}
|
||||
if system
|
||||
.process(sysinfo::Pid::from_u32(browser_pid))
|
||||
.is_some()
|
||||
{
|
||||
alive_pids.push(browser_pid);
|
||||
} else {
|
||||
dead_candidates.push((browser_pid, proxy_id, profile_id));
|
||||
}
|
||||
}
|
||||
|
||||
let dead_browser_entries: Vec<(u32, String, Option<String>)> = {
|
||||
let mut misses = self.dead_browser_misses.lock().unwrap();
|
||||
// Forget PIDs no longer tracked at all (worker already torn down elsewhere).
|
||||
misses.retain(|pid, _| snapshot_pids.contains(pid));
|
||||
// Reset miss count for any PID that's currently alive.
|
||||
for pid in &alive_pids {
|
||||
misses.remove(pid);
|
||||
}
|
||||
// Increment dead candidates and select those past threshold.
|
||||
let mut to_reap = Vec::new();
|
||||
for (browser_pid, proxy_id, profile_id) in dead_candidates {
|
||||
let count = misses.entry(browser_pid).or_insert(0);
|
||||
*count = count.saturating_add(1);
|
||||
if *count >= MISS_THRESHOLD {
|
||||
misses.remove(&browser_pid);
|
||||
to_reap.push((browser_pid, proxy_id, profile_id));
|
||||
}
|
||||
}
|
||||
to_reap
|
||||
};
|
||||
|
||||
for (browser_pid, proxy_id, profile_id) in dead_browser_entries {
|
||||
log::info!(
|
||||
|
||||
@@ -16,7 +16,6 @@ use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::sync::Arc;
|
||||
use std::task::{Context, Poll};
|
||||
use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt, ReadBuf};
|
||||
use tokio::net::TcpListener;
|
||||
use tokio::net::TcpStream;
|
||||
|
||||
/// Combined read+write trait for tunnel target streams, allowing
|
||||
@@ -1232,8 +1231,49 @@ pub async fn run_proxy_server(config: ProxyConfig) -> Result<(), Box<dyn std::er
|
||||
|
||||
log::error!("Attempting to bind proxy server to {}", bind_addr);
|
||||
|
||||
// Bind to the port
|
||||
let listener = TcpListener::bind(bind_addr).await?;
|
||||
// Bind to the port. Use SO_REUSEADDR so that a freshly-restarted worker
|
||||
// can bind a port that the previous worker left in TIME_WAIT, and retry
|
||||
// briefly to absorb transient races with the OS releasing the socket.
|
||||
let listener = {
|
||||
let mut attempts: u32 = 0;
|
||||
loop {
|
||||
let socket = tokio::net::TcpSocket::new_v4()?;
|
||||
let _ = socket.set_reuseaddr(true);
|
||||
match socket.bind(bind_addr) {
|
||||
Ok(()) => match socket.listen(1024) {
|
||||
Ok(l) => break l,
|
||||
Err(e) if attempts < 5 => {
|
||||
attempts += 1;
|
||||
let delay = std::time::Duration::from_millis(200 * u64::from(attempts));
|
||||
log::warn!(
|
||||
"listen() on {} failed (attempt {}/5): {}, retrying in {}ms",
|
||||
bind_addr,
|
||||
attempts,
|
||||
e,
|
||||
delay.as_millis()
|
||||
);
|
||||
tokio::time::sleep(delay).await;
|
||||
}
|
||||
Err(e) => {
|
||||
return Err(format!("Failed to listen on {bind_addr} after 5 attempts: {e}").into())
|
||||
}
|
||||
},
|
||||
Err(e) if attempts < 5 => {
|
||||
attempts += 1;
|
||||
let delay = std::time::Duration::from_millis(200 * u64::from(attempts));
|
||||
log::warn!(
|
||||
"bind() on {} failed (attempt {}/5): {}, retrying in {}ms",
|
||||
bind_addr,
|
||||
attempts,
|
||||
e,
|
||||
delay.as_millis()
|
||||
);
|
||||
tokio::time::sleep(delay).await;
|
||||
}
|
||||
Err(e) => return Err(format!("Failed to bind {bind_addr} after 5 attempts: {e}").into()),
|
||||
}
|
||||
}
|
||||
};
|
||||
let actual_port = listener.local_addr()?.port();
|
||||
|
||||
log::error!("Successfully bound to port {}", actual_port);
|
||||
@@ -1295,52 +1335,54 @@ pub async fn run_proxy_server(config: ProxyConfig) -> Result<(), Box<dyn std::er
|
||||
|
||||
loop {
|
||||
interval.tick().await;
|
||||
if let Some(tracker) = get_traffic_tracker() {
|
||||
let (sent, recv, requests) = tracker.get_snapshot();
|
||||
let current_bytes = sent + recv;
|
||||
let time_since_activity = last_activity_time.elapsed();
|
||||
let time_since_flush = last_flush_time.elapsed();
|
||||
let has_traffic = current_bytes > 0 || requests > 0;
|
||||
// Catch panics so a poisoned lock or unexpected error inside
|
||||
// flush_to_disk doesn't abort the flush task and leave stats
|
||||
// unwritten for the lifetime of the worker. The captured state
|
||||
// is all Copy or atomic-assignment, so AssertUnwindSafe is sound.
|
||||
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
|
||||
if let Some(tracker) = get_traffic_tracker() {
|
||||
let (sent, recv, requests) = tracker.get_snapshot();
|
||||
let current_bytes = sent + recv;
|
||||
let time_since_activity = last_activity_time.elapsed();
|
||||
let time_since_flush = last_flush_time.elapsed();
|
||||
let has_traffic = current_bytes > 0 || requests > 0;
|
||||
|
||||
// Determine flush frequency based on activity
|
||||
// When active: flush every 5 seconds
|
||||
// When idle: flush every 30 seconds
|
||||
let desired_interval_secs =
|
||||
if has_traffic || time_since_activity < std::time::Duration::from_secs(30) {
|
||||
5u64
|
||||
} else {
|
||||
30u64
|
||||
};
|
||||
let desired_interval_secs =
|
||||
if has_traffic || time_since_activity < std::time::Duration::from_secs(30) {
|
||||
5u64
|
||||
} else {
|
||||
30u64
|
||||
};
|
||||
|
||||
// Update interval if needed
|
||||
if desired_interval_secs != current_interval_secs {
|
||||
current_interval_secs = desired_interval_secs;
|
||||
interval = tokio::time::interval(tokio::time::Duration::from_secs(desired_interval_secs));
|
||||
}
|
||||
if desired_interval_secs != current_interval_secs {
|
||||
current_interval_secs = desired_interval_secs;
|
||||
interval =
|
||||
tokio::time::interval(tokio::time::Duration::from_secs(desired_interval_secs));
|
||||
}
|
||||
|
||||
// Only flush if enough time has passed since last flush
|
||||
let flush_interval = std::time::Duration::from_secs(desired_interval_secs);
|
||||
let should_flush = time_since_flush >= flush_interval;
|
||||
let flush_interval = std::time::Duration::from_secs(desired_interval_secs);
|
||||
let should_flush = time_since_flush >= flush_interval;
|
||||
|
||||
if should_flush {
|
||||
match tracker.flush_to_disk() {
|
||||
Ok(Some((sent, recv))) => {
|
||||
// Successful flush with data
|
||||
last_flush_time = std::time::Instant::now();
|
||||
if sent > 0 || recv > 0 {
|
||||
last_activity_time = std::time::Instant::now();
|
||||
if should_flush {
|
||||
match tracker.flush_to_disk() {
|
||||
Ok(Some((sent, recv))) => {
|
||||
last_flush_time = std::time::Instant::now();
|
||||
if sent > 0 || recv > 0 {
|
||||
last_activity_time = std::time::Instant::now();
|
||||
}
|
||||
}
|
||||
Ok(None) => {
|
||||
last_flush_time = std::time::Instant::now();
|
||||
}
|
||||
Err(e) => {
|
||||
log::error!("Failed to flush traffic stats: {}", e);
|
||||
}
|
||||
}
|
||||
Ok(None) => {
|
||||
// No data to flush - this is normal
|
||||
last_flush_time = std::time::Instant::now();
|
||||
}
|
||||
Err(e) => {
|
||||
log::error!("Failed to flush traffic stats: {}", e);
|
||||
// Don't update flush time on error - retry sooner
|
||||
}
|
||||
}
|
||||
}
|
||||
}));
|
||||
if let Err(panic) = result {
|
||||
log::error!("Panic caught in proxy traffic flush task; continuing: {panic:?}");
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -639,14 +639,25 @@ impl WayfernManager {
|
||||
.has_active_paid_subscription()
|
||||
.await
|
||||
{
|
||||
log::info!("Wayfern token not ready for paid user, waiting...");
|
||||
for _ in 0..15 {
|
||||
// Brief wait for the background token fetch — when the API is healthy
|
||||
// the token usually lands in well under a second. If api.donutbrowser.com
|
||||
// is unreachable we don't want to gate the whole launch on it; the
|
||||
// browser still works without the token (cross-OS fingerprinting just
|
||||
// won't be enabled for this session, and the next launch will pick it
|
||||
// up once the token arrives).
|
||||
log::info!("Wayfern token not ready for paid user, waiting briefly...");
|
||||
for _ in 0..3 {
|
||||
tokio::time::sleep(Duration::from_secs(1)).await;
|
||||
wayfern_token = crate::cloud_auth::CLOUD_AUTH.get_wayfern_token().await;
|
||||
if wayfern_token.is_some() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if wayfern_token.is_none() {
|
||||
log::warn!(
|
||||
"Wayfern token still unavailable after wait; launching without it (api.donutbrowser.com may be unreachable)"
|
||||
);
|
||||
}
|
||||
}
|
||||
if let Some(ref token) = wayfern_token {
|
||||
args.push(format!("--wayfern-token={token}"));
|
||||
|
||||
Reference in New Issue
Block a user