refactor: cleanup

This commit is contained in:
zhom
2026-08-27 09:16:25 +04:00
parent 63f673e7d4
commit 15b51f8d2d
31 changed files with 1471 additions and 339 deletions
+43
View File
@@ -31,7 +31,50 @@ env:
IMAGE_NAME: donutbrowser/donut-sync
jobs:
# donut-sync's own end-to-end suite covers which host it signs into presigned
# URLs. That is the whole of the self-hosted sync failure in issue 534: sign
# against an address only the server can reach and every client transfer dies
# at connect while /health and /readyz stay green. The suite existed and was
# never run by anything, so the guard was decorative. Run it here, before the
# image ships, because an image with broken presigning is the thing that
# reaches users.
#
# Ubuntu only, and separate from the Rust and Node matrices, because it needs
# Docker for MinIO and a POSIX env-var prefix in the package script.
test:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 #v7.0.1
- name: Set up pnpm package manager
uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 #v6.0.10
with:
run_install: false
- name: Set up Node.js
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 #v7.0.0
with:
node-version-file: .node-version
cache: "pnpm"
- name: Install dependencies
run: pnpm install --frozen-lockfile
# Publishes MinIO on 8987, which is the port test/test-env.ts pins.
- name: Start test storage
run: docker compose -f donut-sync/docker-compose.yml up -d --wait
- name: Run donut-sync end-to-end tests
working-directory: ./donut-sync
run: pnpm test:e2e
- name: Stop test storage
if: always()
run: docker compose -f donut-sync/docker-compose.yml down -v
build-and-push:
needs: test
runs-on: ubuntu-latest
steps:
- name: Checkout repository
+1 -1
View File
@@ -1,2 +1,2 @@
23
24
+1 -1
View File
@@ -1 +1 @@
23
24
+11
View File
@@ -1,3 +1,14 @@
# Storage for developing and testing donut-sync itself. It runs MinIO only, and
# the sync server is expected to run on the host beside it (`pnpm start:dev`),
# which is why MinIO is published and why the port matches the one pinned in
# test/test-env.ts.
#
# This is NOT the self-hosting compose file. That one runs donut-sync in a
# container too, and it must set S3_PUBLIC_ENDPOINT, because a server that signs
# presigned URLs against a compose-internal host such as `http://minio:9000`
# hands every device a URL it cannot open, while /health and /readyz stay green.
# Take the self-hosting compose from https://donutbrowser.com/docs/self-hosting
# rather than from here.
services:
minio:
image: minio/minio:latest
+35 -9
View File
@@ -86,6 +86,11 @@ export class SyncService implements OnModuleInit {
// `S3_PUBLIC_ENDPOINT` names a different, client-reachable address.
private presignClient: S3Client;
private publicEndpoint: string;
/**
* Whether an operator chose the public endpoint, or it fell back to the
* server's own storage address. The fallback is the shape that fails.
*/
private publicEndpointWasConfigured: boolean;
private bucket: string;
// Upper bound on presign batch array length (DoS guard).
private static readonly MAX_BATCH_ITEMS = 1000;
@@ -131,9 +136,11 @@ export class SyncService implements OnModuleInit {
// network and nowhere else. Signing is bound to the host, so the presign
// client is a second client pinned to the public address rather than a
// string rewrite of the signed URL.
const publicEndpoint =
this.configService.get<string>("S3_PUBLIC_ENDPOINT") || endpoint;
const configuredPublicEndpoint =
this.configService.get<string>("S3_PUBLIC_ENDPOINT");
const publicEndpoint = configuredPublicEndpoint || endpoint;
this.publicEndpoint = publicEndpoint;
this.publicEndpointWasConfigured = Boolean(configuredPublicEndpoint);
this.presignClient =
publicEndpoint === endpoint
? this.s3Client
@@ -191,14 +198,33 @@ export class SyncService implements OnModuleInit {
const isSingleLabel =
!host.includes(".") && !host.includes(":") && host !== "localhost";
if (!isSingleLabel) return;
this.logger.warn(
`Storage endpoint '${this.publicEndpoint}' uses the container-only host '${host}'. ` +
"Presigned URLs built from it cannot be reached by Donut Browser, so every " +
"transfer will fail while /health and /readyz stay green. Set S3_PUBLIC_ENDPOINT " +
"to an address your devices can reach (and publish that port).",
);
if (isSingleLabel) {
this.logger.warn(
`Storage endpoint '${this.publicEndpoint}' uses the container-only host '${host}'. ` +
"Presigned URLs built from it cannot be reached by Donut Browser, so every " +
"transfer will fail while /health and /readyz stay green. Set S3_PUBLIC_ENDPOINT " +
"to an address your devices can reach (and publish that port).",
);
return;
}
// A dotted host proves nothing. With `S3_PUBLIC_ENDPOINT` unset, clients are
// handed whatever address this server uses for storage itself, and a
// reachable-looking name such as `storage.internal`, or a private address on
// a network the devices are not on, fails in exactly the same way while
// saying nothing at all. This server cannot test the endpoint for them,
// because it does not know where its clients are, so state what it does
// know and leave the judgement to the operator.
if (!this.publicEndpointWasConfigured) {
this.logger.log(
`S3_PUBLIC_ENDPOINT is not set, so presigned URLs will name '${this.publicEndpoint}', ` +
"the address this server uses for storage itself. Transfers go straight from each " +
"device to that address, and this server cannot verify a device can reach it. If " +
"transfers fail while /health and /readyz stay green, set S3_PUBLIC_ENDPOINT to an " +
"address your devices can reach and publish that port.",
);
}
}
private async ensureBucketExists(): Promise<void> {
+70 -1
View File
@@ -1,4 +1,4 @@
import { INestApplication } from "@nestjs/common";
import { INestApplication, Logger } from "@nestjs/common";
import { ConfigModule } from "@nestjs/config";
import { Test, TestingModule } from "@nestjs/testing";
import request from "supertest";
@@ -199,3 +199,72 @@ describe("presigned URL host", () => {
});
});
});
// The server cannot test whether a device can reach the endpoint it signs, so
// the only honest thing it can do is say what it is handing out. Without this,
// the one configuration that breaks every transfer boots completely silently.
describe("boot message about the presign endpoint", () => {
let logs: string[];
let warnings: string[];
let logSpy: jest.SpyInstance;
let warnSpy: jest.SpyInstance;
beforeEach(() => {
logs = [];
warnings = [];
logSpy = jest
.spyOn(Logger.prototype, "log")
.mockImplementation((message: unknown) => {
logs.push(String(message));
});
warnSpy = jest
.spyOn(Logger.prototype, "warn")
.mockImplementation((message: unknown) => {
warnings.push(String(message));
});
});
afterEach(() => {
logSpy.mockRestore();
warnSpy.mockRestore();
});
it("says which host clients will be handed when S3_PUBLIC_ENDPOINT is unset", async () => {
const app = await bootstrap(undefined);
try {
const spoken = [...logs, ...warnings].join("\n");
expect(spoken).toContain("S3_PUBLIC_ENDPOINT");
expect(spoken).toContain(TEST_S3_ENDPOINT);
} finally {
await app.close();
}
});
// A single-label host is the documented compose default and cannot work for
// any client, so it earns a warning rather than a note.
it("warns loudly about a container-only host", async () => {
const app = await bootstrap("http://minio:9000");
try {
const spoken = warnings.join("\n");
expect(spoken).toContain("minio");
expect(spoken).toContain("S3_PUBLIC_ENDPOINT");
} finally {
delete process.env.S3_PUBLIC_ENDPOINT;
await app.close();
}
});
// An operator who set the variable made a choice. Repeating the note at them
// would train them to ignore it, and the warning above is for the value that
// provably cannot work, not for every value the server cannot verify.
it("stays quiet when an operator has chosen a routable endpoint", async () => {
const app = await bootstrap(PUBLIC_ENDPOINT);
try {
const spoken = [...logs, ...warnings].join("\n");
expect(spoken).not.toContain("S3_PUBLIC_ENDPOINT is not set");
} finally {
delete process.env.S3_PUBLIC_ENDPOINT;
await app.close();
}
});
});
+1 -1
View File
@@ -2,7 +2,7 @@
"name": "donutbrowser",
"private": true,
"license": "AGPL-3.0",
"version": "0.29.6",
"version": "0.30.0",
"type": "module",
"scripts": {
"predev": "pnpm licenses:generate",
+1 -1
View File
@@ -1735,7 +1735,7 @@ dependencies = [
[[package]]
name = "donutbrowser"
version = "0.29.6"
version = "0.30.0"
dependencies = [
"aes 0.9.2",
"aes-gcm 0.11.1",
+5 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "donutbrowser"
version = "0.29.6"
version = "0.30.0"
description = "Simple Yet Powerful Anti-Detect Browser"
authors = ["zhom@github"]
edition = "2021"
@@ -147,6 +147,10 @@ windows = { version = "0.62", features = [
"Win32_Security",
"Win32_Storage_FileSystem",
"Win32_System_Registry",
# CoInitializeEx, so the `ms-settings:` hand-off in default_browser.rs has a
# COM apartment. ShellExecuteW activates the URI through a shell extension,
# and it runs on a `spawn_blocking` thread that has no apartment of its own.
"Win32_System_Com",
"Win32_UI_Shell",
# SendMessageTimeoutW, for the association-change broadcast in
# default_browser.rs. Going through the crate rather than a hand-written
+21 -23
View File
@@ -1872,36 +1872,34 @@ rm "{}"
parameters
);
// windows-sys is not a direct dep, so use the raw FFI via the
// windows crate that Tauri pulls in. ShellExecuteW returns an
// HINSTANCE > 32 on success.
#[link(name = "shell32")]
extern "system" {
fn ShellExecuteW(
hwnd: *mut std::ffi::c_void,
operation: *const u16,
file: *const u16,
parameters: *const u16,
directory: *const u16,
show_cmd: i32,
) -> isize;
}
const SW_SHOWNORMAL: i32 = 1;
let open: Vec<u16> = "open\0".encode_utf16().collect();
// Take the binding from the `windows` crate rather than writing the
// declaration here. A hand-written one is what put the wrong width on
// `SendMessageTimeoutA`'s out-parameter in `default_browser.rs`, and
// that killed the process on every click of "Set as default browser".
// No compiler and no lint can see such a mistake. The generated binding
// cannot drift from the real ABI, so there is nothing to get wrong.
use windows::core::{w, PCWSTR};
use windows::Win32::UI::Shell::ShellExecuteW;
use windows::Win32::UI::WindowsAndMessaging::SW_SHOWNORMAL;
let result = unsafe {
ShellExecuteW(
std::ptr::null_mut(),
open.as_ptr(),
file_w.as_ptr(),
params_w.as_ptr(),
std::ptr::null(),
None,
w!("open"),
PCWSTR(file_w.as_ptr()),
PCWSTR(params_w.as_ptr()),
PCWSTR::null(),
SW_SHOWNORMAL,
)
};
if result as usize <= 32 {
return Err(format!("ShellExecuteW failed with code {result}").into());
// ShellExecuteW reports success as a value above 32. Anything at or
// below that is an error code wearing a handle's type. Read it as a
// signed value: the old `as usize` turned every negative code into a
// very large number, which read as success.
let code = result.0 as isize;
if code <= 32 {
return Err(format!("ShellExecuteW failed with code {code}").into());
}
} else {
// No pending installer — just restart the app. Use a minimal
+4 -48
View File
@@ -11,7 +11,6 @@ use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use std::fs;
use std::path::PathBuf;
use std::sync::Arc;
use tokio::sync::Mutex;
use crate::browser::ProxySettings;
@@ -1618,54 +1617,11 @@ pub async fn cloud_get_proxy_usage() -> Result<Option<CloudProxyUsage>, String>
#[tauri::command]
pub async fn restart_sync_service(app_handle: tauri::AppHandle) -> Result<(), String> {
// Stop existing scheduler
if let Some(scheduler) = sync::get_global_scheduler() {
scheduler.stop();
}
// Restart sync pipeline
let app_handle_sync = app_handle.clone();
// Rebuilding the pipeline reaches the network, so do it off the command and
// let the caller's dialog close. `start_pipeline` retires the previous
// scheduler and the previous subscription itself.
tauri::async_runtime::spawn(async move {
let mut subscription_manager = sync::SubscriptionManager::new();
let work_rx = subscription_manager.take_work_receiver();
if let Err(e) = subscription_manager.start(app_handle_sync.clone()).await {
log::warn!("Failed to start sync subscription: {e}");
return;
}
if let Some(work_rx) = work_rx {
let scheduler = Arc::new(sync::SyncScheduler::new());
sync::set_global_scheduler(scheduler.clone());
scheduler.sync_all_enabled_profiles(&app_handle_sync).await;
match sync::SyncEngine::create_from_settings(&app_handle_sync).await {
Ok(engine) => {
if let Err(e) = engine
.check_for_missing_synced_profiles(&app_handle_sync)
.await
{
log::warn!("Failed to check for missing profiles: {}", e);
}
if let Err(e) = engine
.check_for_missing_synced_entities(&app_handle_sync)
.await
{
log::warn!("Failed to check for missing entities: {}", e);
}
}
Err(e) => {
log::warn!("Sync not configured, skipping missing profile check: {}", e);
}
}
scheduler
.clone()
.start(app_handle_sync.clone(), work_rx)
.await;
log::info!("Sync scheduler restarted");
}
sync::start_pipeline(app_handle).await;
});
Ok(())
+616 -162
View File
@@ -1,7 +1,31 @@
use serde::Serialize;
use tauri::command;
pub struct DefaultBrowser {}
/// What happened when the user asked Donut to become the default browser.
///
/// macOS and Linux let a program make the change itself. Windows does not. The
/// registry value that decides the handler carries a signature only the shell
/// can produce, so the most a program may do is register itself and open the
/// page where the user makes the choice. Without this distinction the caller
/// reports a change that has not happened yet, which is what the Windows path
/// used to do.
///
/// Each platform builds exactly one of these, so on any single target the other
/// one reads as never constructed. That is what the allow is for: the variant is
/// live, just not on the host being compiled.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "camelCase", tag = "status")]
#[allow(dead_code)]
pub enum SetDefaultOutcome {
/// Donut is the default browser now. Nothing is left for the user to do.
Set,
/// Registration is complete and the system settings page is open. The user
/// makes the final choice there.
AwaitingSystemSettings,
}
impl DefaultBrowser {
fn new() -> Self {
Self {}
@@ -21,7 +45,7 @@ impl DefaultBrowser {
// Linux answers this by running `xdg-mime`, a shell script that forks
// further. That is blocking work with no upper bound, and this command
// runs on the same async runtime as every other command, the REST API and
// the sync scheduler so doing it inline occupies a worker for as long as
// the sync scheduler, so doing it inline occupies a worker for as long as
// the desktop takes to answer. The Settings page polls this on a timer.
#[cfg(target_os = "linux")]
return blocking(linux::is_default_browser).await;
@@ -30,16 +54,22 @@ impl DefaultBrowser {
Err("Unsupported platform".to_string())
}
pub async fn set_as_default_browser(&self) -> Result<(), String> {
pub async fn set_as_default_browser(&self) -> Result<SetDefaultOutcome, String> {
#[cfg(target_os = "macos")]
return macos::set_as_default_browser();
return macos::set_as_default_browser().map(|()| SetDefaultOutcome::Set);
// Windows writes several registry trees, broadcasts `WM_SETTINGCHANGE` to
// every top-level window on the desktop and then hands off to the shell.
// The broadcast alone costs about 130 ms on an idle desktop and seconds on
// a busy one, so this does not belong on a runtime worker either.
#[cfg(target_os = "windows")]
return windows::set_as_default_browser();
return blocking(windows::set_as_default_browser).await;
// Same reasoning, and this one additionally sleeps 500ms before verifying.
#[cfg(target_os = "linux")]
return blocking(linux::set_as_default_browser).await;
return blocking(linux::set_as_default_browser)
.await
.map(|()| SetDefaultOutcome::Set);
#[cfg(not(any(target_os = "macos", target_os = "windows", target_os = "linux")))]
Err("Unsupported platform".to_string())
@@ -47,7 +77,7 @@ impl DefaultBrowser {
}
/// Run blocking work off the async runtime's worker threads.
#[cfg(target_os = "linux")]
#[cfg(any(target_os = "linux", target_os = "windows"))]
async fn blocking<T, F>(work: F) -> Result<T, String>
where
F: FnOnce() -> Result<T, String> + Send + 'static,
@@ -124,18 +154,44 @@ mod macos {
#[cfg(target_os = "windows")]
#[allow(clippy::needless_borrows_for_generic_args)]
mod windows {
use super::SetDefaultOutcome;
use std::path::Path;
use winreg::enums::*;
use winreg::RegKey;
/// The key Windows knows us by. Never shown to a person.
const APP_NAME: &str = "DonutBrowser";
/// The name Windows shows in "Default apps" and in "Open with".
const DISPLAY_NAME: &str = "Donut Browser";
const DESCRIPTION: &str = "Donut Browser - Simple Yet Powerful Anti-Detect Browser";
const PROG_ID: &str = "DonutBrowser.HTML";
pub fn is_default_browser() -> Result<bool, String> {
let schemes = ["http", "https"];
/// A web browser registers under `StartMenuInternet`, and
/// `RegisteredApplications` points at the `Capabilities` subkey of that
/// entry. Edge, Chrome and Firefox all do exactly this, and the shell reads
/// the capability data from there.
///
/// The previous layout invented its own key at `Software\DonutBrowser` and
/// pointed `RegisteredApplications` at the parent instead of at
/// `Capabilities`. Every other entry on a normal machine ends in
/// `Capabilities`. The shell found no capability data, so Donut was never
/// offered as a browser and the button appeared to do nothing.
const CLIENT_KEY: &str = r"Software\Clients\StartMenuInternet\DonutBrowser";
/// The value written into `RegisteredApplications`.
const CAPABILITIES_KEY: &str = r"Software\Clients\StartMenuInternet\DonutBrowser\Capabilities";
/// The layout earlier builds wrote. Removed on every run, so a machine that
/// ran one of those does not keep stale capability data claiming http.
const LEGACY_APP_KEY: &str = r"Software\DonutBrowser";
for scheme in schemes {
// Check if our browser is set as the default handler for this scheme
const URL_SCHEMES: [&str; 2] = ["http", "https"];
/// The file types a browser is asked to open from Explorer. The ProgId
/// command passes the path through as `%1`, and `urls_from_args` in `lib.rs`
/// turns a path into a `file://` URL, so every extension listed here can
/// actually be serviced. Do not add one that cannot.
const FILE_EXTENSIONS: [&str; 4] = [".htm", ".html", ".shtml", ".xhtml"];
pub fn is_default_browser() -> Result<bool, String> {
for scheme in URL_SCHEMES {
if !is_default_for_scheme(scheme)? {
return Ok(false);
}
@@ -144,44 +200,42 @@ mod windows {
Ok(true)
}
pub fn set_as_default_browser() -> Result<(), String> {
// Get the current executable path
let exe_path = std::env::current_exe()
.map_err(|e| format!("Failed to get current executable path: {}", e))?;
pub fn set_as_default_browser() -> Result<SetDefaultOutcome, String> {
let exe_path =
std::env::current_exe().map_err(|e| format!("Failed to get current executable path: {e}"))?;
let exe_path_str = exe_path
let exe_path = exe_path
.to_str()
.ok_or("Failed to convert executable path to string")?;
// Verify the executable exists
if !Path::new(exe_path_str).exists() {
return Err(format!("Executable not found at: {}", exe_path_str));
if !Path::new(exe_path).exists() {
return Err(format!("Executable not found at: {exe_path}"));
}
// Register the application
register_application(exe_path_str)?;
let hkcu = RegKey::predef(HKEY_CURRENT_USER);
remove_legacy_registration(&hkcu);
register_prog_id(&hkcu, exe_path)?;
register_client(&hkcu, exe_path)?;
register_file_extensions(&hkcu)?;
register_application(&hkcu)?;
// Set as default for HTTP and HTTPS
set_default_for_scheme("http")?;
set_default_for_scheme("https")?;
// Register file associations for HTML files
register_html_file_association(exe_path_str)?;
// Notify the system of changes
notify_system_of_changes();
Ok(())
open_default_apps_settings()?;
Ok(SetDefaultOutcome::AwaitingSystemSettings)
}
/// Wrap a path in the quotes the shell expects around a command or an icon.
fn quoted(value: &str) -> String {
format!(r#""{value}""#)
}
fn is_default_for_scheme(scheme: &str) -> Result<bool, String> {
let hkcu = RegKey::predef(HKEY_CURRENT_USER);
// Check Software\Microsoft\Windows\Shell\Associations\UrlAssociations\{scheme}\UserChoice
let path = format!(
"Software\\Microsoft\\Windows\\Shell\\Associations\\UrlAssociations\\{}\\UserChoice",
scheme
);
let path =
format!(r"Software\Microsoft\Windows\Shell\Associations\UrlAssociations\{scheme}\UserChoice");
match hkcu.open_subkey(&path) {
Ok(key) => match key.get_value::<String, _>("ProgId") {
@@ -192,167 +246,276 @@ mod windows {
}
}
fn register_application(exe_path: &str) -> Result<(), String> {
let hkcu = RegKey::predef(HKEY_CURRENT_USER);
/// Delete the layout earlier builds wrote.
///
/// Nothing else in this application has ever written under that key, so
/// removing it cannot lose anything a user cares about. Leaving it would
/// leave a second `Capabilities` block claiming http and https from a key the
/// shell no longer reads.
///
/// The old code also wrote the ProgId into the default value of
/// `Software\Classes\.html` and `.htm`. That value is the association itself,
/// and it was never ours to take. Give it back, but only where it still holds
/// the ProgId we wrote. Any other value is the user's own choice and is left
/// alone.
fn remove_legacy_registration(root: &RegKey) {
match root.delete_subkey_all(LEGACY_APP_KEY) {
Ok(()) => log::debug!("Removed the superseded default-browser registration key"),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
Err(e) => log::debug!("Could not remove the superseded registration key: {e}"),
}
// Register in Software\RegisteredApplications
let (registered_apps, _) = hkcu
.create_subkey("Software\\RegisteredApplications")
.map_err(|e| format!("Failed to create RegisteredApplications key: {}", e))?;
for extension in [".htm", ".html"] {
let path = format!(r"Software\Classes\{extension}");
let Ok(key) = root.open_subkey_with_flags(&path, KEY_READ | KEY_SET_VALUE) else {
continue;
};
registered_apps
.set_value(APP_NAME, &format!("Software\\{}", APP_NAME))
.map_err(|e| format!("Failed to set registered application: {}", e))?;
let ours = key
.get_value::<String, _>("")
.map(|value| value == PROG_ID)
.unwrap_or(false);
// Create application key
let (app_key, _) = hkcu
.create_subkey(&format!("Software\\{}", APP_NAME))
.map_err(|e| format!("Failed to create application key: {}", e))?;
// Set application properties
app_key
.set_value("ApplicationName", &APP_NAME)
.map_err(|e| format!("Failed to set ApplicationName: {}", e))?;
app_key
.set_value(
"ApplicationDescription",
&"Donut Browser - Simple Yet Powerful Anti-Detect Browser",
)
.map_err(|e| format!("Failed to set ApplicationDescription: {}", e))?;
app_key
.set_value("ApplicationIcon", &format!("\"{}\",0", exe_path))
.map_err(|e| format!("Failed to set ApplicationIcon: {}", e))?;
// Create Capabilities key
let (capabilities, _) = app_key
.create_subkey("Capabilities")
.map_err(|e| format!("Failed to create Capabilities key: {}", e))?;
capabilities
.set_value(
"ApplicationDescription",
&"Donut Browser - Simple Yet Powerful Anti-Detect Browser",
)
.map_err(|e| format!("Failed to set Capabilities description: {}", e))?;
// Set URL associations
let (url_assoc, _) = capabilities
.create_subkey("URLAssociations")
.map_err(|e| format!("Failed to create URLAssociations key: {}", e))?;
url_assoc
.set_value("http", &PROG_ID)
.map_err(|e| format!("Failed to set http association: {}", e))?;
url_assoc
.set_value("https", &PROG_ID)
.map_err(|e| format!("Failed to set https association: {}", e))?;
// Set file associations
let (file_assoc, _) = capabilities
.create_subkey("FileAssociations")
.map_err(|e| format!("Failed to create FileAssociations key: {}", e))?;
file_assoc
.set_value(".html", &PROG_ID)
.map_err(|e| format!("Failed to set .html association: {}", e))?;
file_assoc
.set_value(".htm", &PROG_ID)
.map_err(|e| format!("Failed to set .htm association: {}", e))?;
// Register the ProgID
register_prog_id(exe_path)?;
Ok(())
if ours {
match key.delete_value("") {
Ok(()) => log::debug!("Released the {extension} association taken by an older build"),
Err(e) => log::debug!("Could not release the {extension} association: {e}"),
}
}
}
}
fn register_prog_id(exe_path: &str) -> Result<(), String> {
let hkcu = RegKey::predef(HKEY_CURRENT_USER);
// Create ProgID key
let (prog_id_key, _) = hkcu
.create_subkey(&format!("Software\\Classes\\{}", PROG_ID))
.map_err(|e| format!("Failed to create ProgID key: {}", e))?;
/// Describe the document type Donut opens, and how to open one.
fn register_prog_id(root: &RegKey, exe_path: &str) -> Result<(), String> {
let (prog_id_key, _) = root
.create_subkey(format!(r"Software\Classes\{PROG_ID}"))
.map_err(|e| format!("Failed to create ProgID key: {e}"))?;
prog_id_key
.set_value("", &"Donut Browser Document")
.map_err(|e| format!("Failed to set ProgID default value: {}", e))?;
.map_err(|e| format!("Failed to set ProgID default value: {e}"))?;
prog_id_key
.set_value("FriendlyTypeName", &"Donut Browser Document")
.map_err(|e| format!("Failed to set FriendlyTypeName: {}", e))?;
.map_err(|e| format!("Failed to set FriendlyTypeName: {e}"))?;
// The shell reads this block to put a name and an icon beside the ProgId in
// the "Open with" list. Without it the entry shows as the raw ProgId.
let (application, _) = prog_id_key
.create_subkey("Application")
.map_err(|e| format!("Failed to create ProgID Application key: {e}"))?;
application
.set_value("ApplicationName", &DISPLAY_NAME)
.map_err(|e| format!("Failed to set ProgID ApplicationName: {e}"))?;
application
.set_value("ApplicationIcon", &format!("{},0", quoted(exe_path)))
.map_err(|e| format!("Failed to set ProgID ApplicationIcon: {e}"))?;
// Create DefaultIcon key
let (icon_key, _) = prog_id_key
.create_subkey("DefaultIcon")
.map_err(|e| format!("Failed to create DefaultIcon key: {}", e))?;
.map_err(|e| format!("Failed to create DefaultIcon key: {e}"))?;
icon_key
.set_value("", &format!("\"{}\",0", exe_path))
.map_err(|e| format!("Failed to set default icon: {}", e))?;
.set_value("", &format!("{},0", quoted(exe_path)))
.map_err(|e| format!("Failed to set default icon: {e}"))?;
// Create shell\open\command key
let (command_key, _) = prog_id_key
.create_subkey("shell\\open\\command")
.map_err(|e| format!("Failed to create command key: {}", e))?;
.create_subkey(r"shell\open\command")
.map_err(|e| format!("Failed to create command key: {e}"))?;
command_key
.set_value("", &format!("\"{}\" \"%1\"", exe_path))
.map_err(|e| format!("Failed to set command: {}", e))?;
.set_value("", &format!(r#"{} "%1""#, quoted(exe_path)))
.map_err(|e| format!("Failed to set command: {e}"))?;
Ok(())
}
fn set_default_for_scheme(scheme: &str) -> Result<(), String> {
let hkcu = RegKey::predef(HKEY_CURRENT_USER);
/// The `StartMenuInternet` entry: the shape the shell reads for a web
/// browser. A display name, an icon, the command that starts it, the
/// `InstallInfo` block the default-programs page expects, and the capability
/// lists that say which schemes and file types it handles.
fn register_client(root: &RegKey, exe_path: &str) -> Result<(), String> {
let (client, _) = root
.create_subkey(CLIENT_KEY)
.map_err(|e| format!("Failed to create browser client key: {e}"))?;
// Set in Software\Microsoft\Windows\CurrentVersion\Explorer\FileExts\.html\UserChoice
// Note: On Windows 10+, this might require elevated permissions or user interaction
// through the Settings app due to security restrictions
client
.set_value("", &DISPLAY_NAME)
.map_err(|e| format!("Failed to set client display name: {e}"))?;
// Try to set the association in the user's choice
let user_choice_path = format!(
"Software\\Microsoft\\Windows\\Shell\\Associations\\UrlAssociations\\{}\\UserChoice",
scheme
);
let (icon, _) = client
.create_subkey("DefaultIcon")
.map_err(|e| format!("Failed to create client DefaultIcon key: {e}"))?;
// Note: Setting UserChoice directly may not work on Windows 10+ due to hash verification
// The user may need to manually set the default browser through Windows Settings
match hkcu.create_subkey(&user_choice_path) {
Ok((user_choice, _)) => {
// Attempt to set the ProgId
if user_choice.set_value("ProgId", &PROG_ID).is_err() {
// If we can't set UserChoice, that's expected on newer Windows versions
// The registration is still valuable for the "Open with" menu
}
}
Err(_) => {
// Expected on newer Windows versions - user must set manually
}
icon
.set_value("", &format!("{},0", quoted(exe_path)))
.map_err(|e| format!("Failed to set client icon: {e}"))?;
let (command, _) = client
.create_subkey(r"shell\open\command")
.map_err(|e| format!("Failed to create client command key: {e}"))?;
// No `%1` here. This entry is how the shell starts the browser with no
// document, for example from the Start menu.
command
.set_value("", &quoted(exe_path))
.map_err(|e| format!("Failed to set client command: {e}"))?;
// The shell reads the icons-visible state from here, so the block has to
// exist. It also understands `ReinstallCommand`, `HideIconsCommand` and
// `ShowIconsCommand`, and Edge and Chrome advertise all three. Donut does
// not, because it does not act on `--make-default-browser`, `--hide-icons`
// or `--show-icons`. Advertising a command the program ignores is the same
// empty claim as registering a file type nothing can open. Add them here on
// the day the flags do something.
let (install_info, _) = client
.create_subkey("InstallInfo")
.map_err(|e| format!("Failed to create InstallInfo key: {e}"))?;
install_info
.set_value("IconsVisible", &1u32)
.map_err(|e| format!("Failed to set IconsVisible: {e}"))?;
let (capabilities, _) = client
.create_subkey("Capabilities")
.map_err(|e| format!("Failed to create Capabilities key: {e}"))?;
// `ApplicationName` belongs inside `Capabilities`. The old code wrote it one
// level up, where the shell does not look, so the entry had no name.
capabilities
.set_value("ApplicationName", &DISPLAY_NAME)
.map_err(|e| format!("Failed to set ApplicationName: {e}"))?;
capabilities
.set_value("ApplicationDescription", &DESCRIPTION)
.map_err(|e| format!("Failed to set ApplicationDescription: {e}"))?;
capabilities
.set_value("ApplicationIcon", &format!("{},0", quoted(exe_path)))
.map_err(|e| format!("Failed to set ApplicationIcon: {e}"))?;
let (url_assoc, _) = capabilities
.create_subkey("URLAssociations")
.map_err(|e| format!("Failed to create URLAssociations key: {e}"))?;
for scheme in URL_SCHEMES {
url_assoc
.set_value(scheme, &PROG_ID)
.map_err(|e| format!("Failed to set {scheme} association: {e}"))?;
}
let (file_assoc, _) = capabilities
.create_subkey("FileAssociations")
.map_err(|e| format!("Failed to create FileAssociations key: {e}"))?;
for extension in FILE_EXTENSIONS {
file_assoc
.set_value(extension, &PROG_ID)
.map_err(|e| format!("Failed to set {extension} association: {e}"))?;
}
Ok(())
}
fn register_html_file_association(_exe_path: &str) -> Result<(), String> {
let hkcu = RegKey::predef(HKEY_CURRENT_USER);
/// Offer Donut in the "Open with" list for the HTML file types, without
/// taking the association away from whatever the user already chose.
///
/// The old code wrote the ProgId into the default value of
/// `Software\Classes\.html`, which is the association itself. That replaced
/// the user's choice without asking, was never undone on uninstall, and did
/// not even take effect, because the per-user `FileExts` choice outranks it.
/// `OpenWithProgids` is the additive form: it adds Donut to the list and
/// displaces nothing.
fn register_file_extensions(root: &RegKey) -> Result<(), String> {
for extension in FILE_EXTENSIONS {
let (open_with, _) = root
.create_subkey(format!(r"Software\Classes\{extension}\OpenWithProgids"))
.map_err(|e| format!("Failed to create OpenWithProgids key for {extension}: {e}"))?;
// Register .html and .htm file associations
for ext in &[".html", ".htm"] {
let ext_path = format!("Software\\Classes\\{}", ext);
// Only the value name matters here. The payload is a marker.
open_with
.set_value(PROG_ID, &"")
.map_err(|e| format!("Failed to register the {extension} handler: {e}"))?;
}
match hkcu.create_subkey(&ext_path) {
Ok((ext_key, _)) => {
// Set the default value to our ProgID
let _ = ext_key.set_value("", &PROG_ID);
}
Err(_) => {
// Continue if we can't set the file association
}
}
Ok(())
}
/// Point `RegisteredApplications` at the capability data. This is what puts
/// Donut in the list Windows offers under "Default apps".
fn register_application(root: &RegKey) -> Result<(), String> {
let (registered_apps, _) = root
.create_subkey(r"Software\RegisteredApplications")
.map_err(|e| format!("Failed to create RegisteredApplications key: {e}"))?;
registered_apps
.set_value(APP_NAME, &CAPABILITIES_KEY)
.map_err(|e| format!("Failed to set registered application: {e}"))
}
/// Open the page where the user chooses the default browser.
///
/// Windows does not let a program make itself the default. The value that
/// decides the handler, the `UserChoice` key under `UrlAssociations`, carries
/// a hash over the user's SID, the ProgId and a timestamp, and only the shell
/// can produce it. Windows 11 also ships UCPD.sys, which blocks writes to
/// those keys outright.
///
/// The old code wrote `ProgId` there with no hash and discarded every error,
/// then reported success. The registry never changed, the Settings page went
/// on saying "Inactive", and the user was told nothing. Registration is the
/// part a program is allowed to do. The choice belongs to the user, so open
/// the page where they can make it and let the caller say so.
fn open_default_apps_settings() -> Result<(), String> {
use windows::core::{HSTRING, PCWSTR};
use windows::Win32::System::Com::{
CoInitializeEx, CoUninitialize, COINIT_APARTMENTTHREADED, COINIT_DISABLE_OLE1DDE,
};
use windows::Win32::UI::Shell::ShellExecuteW;
use windows::Win32::UI::WindowsAndMessaging::SW_SHOWNORMAL;
// `registeredAppUser` makes the page open on our entry rather than at the
// top of the list. It is the name just written into
// `RegisteredApplications`, so it only resolves because registration ran
// first.
let target = HSTRING::from(format!(
"ms-settings:defaultapps?registeredAppUser={APP_NAME}"
));
let operation = HSTRING::from("open");
// ShellExecuteW hands the URI to a shell extension, and shell extensions
// are COM objects. This runs on a `spawn_blocking` thread, which has no
// apartment of its own, so give it one. An error means the thread already
// had an apartment in another mode, and in that case it is not ours to
// tear down.
let com_status =
unsafe { CoInitializeEx(None, COINIT_APARTMENTTHREADED | COINIT_DISABLE_OLE1DDE) };
let owns_com = com_status.is_ok();
let result = unsafe {
ShellExecuteW(
None,
PCWSTR(operation.as_ptr()),
PCWSTR(target.as_ptr()),
PCWSTR::null(),
PCWSTR::null(),
SW_SHOWNORMAL,
)
};
if owns_com {
unsafe { CoUninitialize() };
}
// ShellExecuteW reports success as a value above 32. Anything at or below
// that is an error code wearing a handle's type.
let code = result.0 as isize;
if code <= 32 {
return Err(format!(
"Donut Browser is registered, but Windows Settings did not open (code {code}). Open Settings, then Apps, then Default apps, find Donut Browser and set it for HTTP and HTTPS."
));
}
Ok(())
@@ -399,6 +562,199 @@ mod windows {
);
}
}
#[cfg(test)]
mod registration_tests {
use super::*;
/// A scratch key that stands in for HKCU, so the test writes a real tree
/// through the real code without touching the tree Windows actually reads.
/// Deleted on the way out, including when an assertion fails.
struct ScratchRoot {
key: RegKey,
path: String,
}
const SCRATCH_PARENT: &str = r"Software\DonutBrowserTests";
impl ScratchRoot {
fn new(name: &str) -> Self {
let hkcu = RegKey::predef(HKEY_CURRENT_USER);
let path = format!(r"{SCRATCH_PARENT}\{name}");
let _ = hkcu.delete_subkey_all(&path);
let (key, _) = hkcu.create_subkey(&path).expect("create the scratch root");
Self { key, path }
}
fn value(&self, subkey: &str, name: &str) -> Option<String> {
self
.key
.open_subkey(subkey)
.ok()?
.get_value::<String, _>(name)
.ok()
}
}
impl Drop for ScratchRoot {
fn drop(&mut self) {
let hkcu = RegKey::predef(HKEY_CURRENT_USER);
let _ = hkcu.delete_subkey_all(&self.path);
// Take the shared parent too, so a test run leaves nothing at all in
// the user's registry. `delete_subkey` refuses a key that still has
// children, which is exactly the guard needed while tests run in
// parallel: whoever finishes last removes it.
let _ = hkcu.delete_subkey(SCRATCH_PARENT);
}
}
const EXE: &str = r"C:\Program Files\Donut Browser\donutbrowser.exe";
#[test]
fn registration_writes_the_shape_the_shell_reads() {
let root = ScratchRoot::new("registration");
register_prog_id(&root.key, EXE).expect("register the ProgId");
register_client(&root.key, EXE).expect("register the client");
register_file_extensions(&root.key).expect("register the file types");
register_application(&root.key).expect("register the application");
// The bug that made the button do nothing: this pointed at the
// application key instead of at its `Capabilities` subkey, so the shell
// read no capabilities and never offered Donut as a browser. Every other
// entry on a working machine ends in `Capabilities`.
let registered = root
.value(r"Software\RegisteredApplications", APP_NAME)
.expect("RegisteredApplications entry");
assert_eq!(registered, CAPABILITIES_KEY);
assert!(
registered.ends_with(r"\Capabilities"),
"RegisteredApplications must name the Capabilities subkey, got {registered}"
);
assert!(
root.key.open_subkey(&registered).is_ok(),
"RegisteredApplications names {registered}, which does not exist"
);
// The second bug: `ApplicationName` sat one level above `Capabilities`,
// where the shell does not look, so the entry had no name to show.
assert_eq!(
root.value(CAPABILITIES_KEY, "ApplicationName").as_deref(),
Some(DISPLAY_NAME)
);
assert_eq!(
root
.value(CAPABILITIES_KEY, "ApplicationDescription")
.as_deref(),
Some(DESCRIPTION)
);
// Every scheme and file type the capability lists claim.
for scheme in URL_SCHEMES {
assert_eq!(
root
.value(&format!(r"{CAPABILITIES_KEY}\URLAssociations"), scheme)
.as_deref(),
Some(PROG_ID),
"{scheme} is not claimed"
);
}
for extension in FILE_EXTENSIONS {
assert_eq!(
root
.value(&format!(r"{CAPABILITIES_KEY}\FileAssociations"), extension)
.as_deref(),
Some(PROG_ID),
"{extension} is not claimed"
);
}
// The rest of the StartMenuInternet entry.
assert_eq!(root.value(CLIENT_KEY, "").as_deref(), Some(DISPLAY_NAME));
assert_eq!(
root.value(&format!(r"{CLIENT_KEY}\shell\open\command"), ""),
Some(quoted(EXE))
);
assert!(root
.key
.open_subkey(format!(r"{CLIENT_KEY}\InstallInfo"))
.is_ok());
// The ProgId command has to carry `%1`. Without it the shell starts the
// browser and never says which page to open.
let prog_id_command = root
.value(
&format!(r"Software\Classes\{PROG_ID}\shell\open\command"),
"",
)
.expect("ProgId command");
assert_eq!(prog_id_command, format!(r#"{} "%1""#, quoted(EXE)));
// The file types are offered, not seized. Taking the default value of
// `Software\Classes\.html` is what the old code did, and that value
// belongs to whatever the user chose.
for extension in FILE_EXTENSIONS {
assert_eq!(
root
.value(
&format!(r"Software\Classes\{extension}\OpenWithProgids"),
PROG_ID
)
.as_deref(),
Some(""),
"{extension} should offer the handler"
);
assert!(
root
.value(&format!(r"Software\Classes\{extension}"), "")
.is_none(),
"{extension} default value must be left alone"
);
}
}
#[test]
fn the_association_an_older_build_took_is_given_back() {
let root = ScratchRoot::new("legacy");
// Recreate what the old code left behind: its own application key, and
// the ProgId written straight into the association for one file type.
let (legacy, _) = root
.key
.create_subkey(format!(r"{LEGACY_APP_KEY}\Capabilities\URLAssociations"))
.expect("legacy key");
legacy.set_value("http", &PROG_ID).expect("legacy claim");
let (html, _) = root
.key
.create_subkey(r"Software\Classes\.html")
.expect("html class");
html.set_value("", &PROG_ID).expect("legacy association");
// A file type the user pointed somewhere else. This one is not ours and
// must survive untouched.
let (htm, _) = root
.key
.create_subkey(r"Software\Classes\.htm")
.expect("htm class");
htm.set_value("", &"ChromeHTML").expect("user association");
remove_legacy_registration(&root.key);
assert!(
root.key.open_subkey(LEGACY_APP_KEY).is_err(),
"the superseded application key should be gone"
);
assert!(
root.value(r"Software\Classes\.html", "").is_none(),
"the association we took should have been released"
);
assert_eq!(
root.value(r"Software\Classes\.htm", "").as_deref(),
Some("ChromeHTML"),
"a choice that is not ours must not be touched"
);
}
}
}
#[cfg(target_os = "linux")]
@@ -551,7 +907,105 @@ pub async fn is_default_browser() -> Result<bool, String> {
}
#[command]
pub async fn set_as_default_browser() -> Result<(), String> {
pub async fn set_as_default_browser() -> Result<SetDefaultOutcome, String> {
let default_browser = DefaultBrowser::instance();
default_browser.set_as_default_browser().await
}
#[cfg(test)]
mod tests {
/// The type system now prevents the mistake behind the crash on Windows.
/// `SendMessageTimeoutW` comes from the `windows` crate, and its
/// out-parameter is typed `Option<*mut usize>`, so a four byte slot no longer
/// compiles. That guarantee holds only while the call goes through the crate.
/// A hand-written declaration would bring back the whole class of bug in a
/// form no compiler and no lint can see, so refuse one here.
///
/// This looks at the Windows module on every platform, because the module is
/// compiled out everywhere else and would otherwise go unchecked on the
/// runners that do most of the work.
#[test]
fn the_windows_module_declares_no_foreign_functions_by_hand() {
const SOURCE: &str = include_str!("default_browser.rs");
let start = SOURCE
.find("mod windows {")
.expect("the Windows module was renamed; update this guard");
let end = SOURCE
.find("mod linux {")
.expect("the Linux module was renamed; update this guard");
assert!(
start < end,
"the module order changed; update this guard so it still reads the Windows module"
);
assert!(
!SOURCE[start..end].contains(r#"extern ""#),
"The Windows module declares a foreign function by hand. Do not. A \
hand-written declaration of SendMessageTimeoutA, with its out-parameter \
typed *mut u32 instead of the real PDWORD_PTR, is what made Windows \
write four bytes past a stack slot and kill the process every time a \
user set Donut as their default browser. Take the binding from the \
`windows` crate, which cannot drift from the real ABI, and add the \
feature it needs to Cargo.toml."
);
}
/// Show why the out-parameter has to be pointer sized.
///
/// This does not try to reproduce the crash. Whether the four byte overrun is
/// fatal depends on the frame the optimiser happens to build, so a crash test
/// passes under one profile and fails under another. It measures the thing
/// that is always true instead: the call writes eight bytes.
#[cfg(target_os = "windows")]
#[test]
fn send_message_timeout_writes_a_pointer_sized_result() {
use windows::Win32::Foundation::{HWND, LPARAM, WPARAM};
use windows::Win32::UI::WindowsAndMessaging::{SendMessageTimeoutW, SMTO_ABORTIFHUNG, WM_NULL};
/// A four byte slot with a marker behind it, laid out the way the old code
/// laid out its `u32`. Eight bytes in total and eight byte aligned, so a
/// pointer sized write lands entirely inside the struct. Nothing outside it
/// is touched and the test is not itself undefined behaviour.
#[repr(C, align(8))]
struct Probe {
result: u32,
canary: u32,
}
const SENTINEL: u32 = 0xDEAD_BEEF;
let mut probe = Probe {
result: SENTINEL,
canary: SENTINEL,
};
// The window handle is deliberately not a window. USER32 clears the
// out-parameter before it looks at the target, so this measures the write
// width without creating a window, without a message loop and without
// sending anything to another process. The test is hermetic.
unsafe {
SendMessageTimeoutW(
HWND(0xDEAD_0000_usize as *mut core::ffi::c_void),
WM_NULL,
WPARAM(0),
LPARAM(0),
SMTO_ABORTIFHUNG,
50,
Some(&mut probe as *mut Probe as *mut usize),
);
}
assert_eq!(
probe.result, 0,
"SendMessageTimeoutW did not write the out-parameter at all, so this test \
no longer measures anything. Check the call before trusting it."
);
assert_ne!(
probe.canary, SENTINEL,
"SendMessageTimeoutW wrote only four bytes. If Windows has really narrowed \
lpdwResult to a DWORD then notify_system_of_changes may use a u32. Until \
then the out-parameter stays pointer sized."
);
}
}
+105 -46
View File
@@ -1745,6 +1745,44 @@ fn setup_system_tray(app: &tauri::AppHandle) -> Result<(), Box<dyn std::error::E
Ok(())
}
/// Pick the things to open out of a command line.
///
/// This is how the desktop hands a browser its work. Windows and Linux both
/// start the executable with the target as an argument: a URL for a link, and a
/// plain path for a file, because the ProgId command in the registry passes
/// `%1` through unchanged. A path becomes a `file://` URL here, so callers only
/// ever deal with URLs.
///
/// A path that does not exist is ignored. Guessing at one would turn a stray
/// flag into a navigation. The first argument is the executable's own path and
/// is never a target.
fn urls_from_args<'a>(args: impl IntoIterator<Item = &'a String>) -> Vec<String> {
args
.into_iter()
.skip(1)
.filter_map(|arg| {
if arg.starts_with("http://") || arg.starts_with("https://") {
return Some(arg.clone());
}
let path = std::path::Path::new(arg);
if !path.is_file() {
return None;
}
let absolute = if path.is_absolute() {
path.to_path_buf()
} else {
env::current_dir().ok()?.join(path)
};
url::Url::from_file_path(absolute)
.ok()
.map(|url| url.to_string())
})
.collect()
}
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
run_with_builder(|builder| builder);
@@ -1755,7 +1793,7 @@ pub fn run_with_builder(
configure_builder: impl FnOnce(tauri::Builder<tauri::Wry>) -> tauri::Builder<tauri::Wry>,
) {
let args: Vec<String> = env::args().collect();
let startup_url = args.iter().find(|arg| arg.starts_with("http")).cloned();
let startup_url = urls_from_args(args.iter()).into_iter().next();
if let Some(url) = startup_url.clone() {
log::info!("Found startup URL in command line");
@@ -1819,6 +1857,20 @@ pub fn run_with_builder(
let _ = window.set_focus();
let _ = window.unminimize();
}
// A second launch is how the desktop hands a running browser its next
// link. The shell starts the executable with the target in argv, this
// callback receives that argv, and the second process exits. The callback
// used to log the arguments and drop them, so clicking a link did nothing
// whenever Donut was already open, which is every time after the first.
for url in urls_from_args(args.iter()) {
let handle = app_handle.clone();
tauri::async_runtime::spawn(async move {
if let Err(e) = handle_url_open(handle, url).await {
log::error!("Failed to handle a forwarded URL: {e}");
}
});
}
},
));
@@ -2620,51 +2672,7 @@ pub fn run_with_builder(
// Start sync subscription and scheduler if configured
let app_handle_sync = app.handle().clone();
tauri::async_runtime::spawn(async move {
use std::sync::Arc;
let mut subscription_manager = sync::SubscriptionManager::new();
let work_rx = subscription_manager.take_work_receiver();
if let Err(e) = subscription_manager.start(app_handle_sync.clone()).await {
log::warn!("Failed to start sync subscription: {e}");
}
if let Some(work_rx) = work_rx {
let scheduler = Arc::new(sync::SyncScheduler::new());
// Set the global scheduler so commands can access it
sync::set_global_scheduler(scheduler.clone());
// Start initial sync for all enabled profiles
scheduler.sync_all_enabled_profiles(&app_handle_sync).await;
// Check for missing synced profiles (deleted locally but exist remotely)
match sync::SyncEngine::create_from_settings(&app_handle_sync).await {
Ok(engine) => {
if let Err(e) = engine
.check_for_missing_synced_profiles(&app_handle_sync)
.await
{
log::warn!("Failed to check for missing profiles: {}", e);
}
if let Err(e) = engine
.check_for_missing_synced_entities(&app_handle_sync)
.await
{
log::warn!("Failed to check for missing entities: {}", e);
}
}
Err(e) => {
log::warn!("Sync not configured, skipping missing profile check: {}", e);
}
}
scheduler
.clone()
.start(app_handle_sync.clone(), work_rx)
.await;
log::info!("Sync scheduler started");
}
sync::start_pipeline(app_handle_sync).await;
});
// Start cloud auth background refresh loop
@@ -2965,6 +2973,57 @@ pub fn run_with_builder(
mod tests {
use std::fs;
#[test]
fn a_command_line_yields_the_links_and_files_it_carries() {
let exe = "C:/Program Files/Donut Browser/donutbrowser.exe".to_string();
// The executable's own path leads every command line and is not a target,
// even on a machine where that path happens to exist.
assert!(super::urls_from_args([&exe]).is_empty());
let link = "https://example.com/a?b=c".to_string();
let insecure = "http://example.com".to_string();
assert_eq!(
super::urls_from_args([&exe, &link, &insecure]),
vec![link.clone(), insecure]
);
// Flags and stray words are not links. The old filter took anything
// starting with "http", which is looser than it looks.
let flag = "--headless".to_string();
let near_miss = "httpsomething".to_string();
assert_eq!(
super::urls_from_args([&exe, &flag, &near_miss]),
Vec::<String>::new()
);
// A path that is not there is ignored rather than guessed at.
let missing = "C:/no/such/page.html".to_string();
assert!(super::urls_from_args([&exe, &missing]).is_empty());
// Explorer hands a browser a bare path, not a URL, because the registered
// command passes `%1` straight through. Turning it into a `file://` URL
// here is what makes the .html association in `default_browser.rs` a real
// claim rather than an empty one.
let directory = tempfile::tempdir().expect("temp dir");
let page = directory.path().join("page.html");
fs::write(&page, "<html></html>").expect("write the page");
let page_arg = page.to_string_lossy().to_string();
let found = super::urls_from_args([&exe, &page_arg]);
assert_eq!(found.len(), 1, "the file should have produced one URL");
assert!(
found[0].starts_with("file:///"),
"expected a file URL, got {}",
found[0]
);
assert!(
found[0].ends_with("page.html"),
"expected the page's own name, got {}",
found[0]
);
}
#[test]
fn backend_error_helpers_preserve_codes_and_structure_diagnostics() {
let coded = super::backend_error("PROFILE_NOT_FOUND");
+39 -13
View File
@@ -1,5 +1,27 @@
use super::types::*;
use reqwest::Client;
use std::time::Duration;
/// How long to wait for a storage host to accept a connection.
///
/// This client had no timeouts at all. A host that neither accepts nor refuses,
/// which is what a dropping firewall or a black-holed address looks like, held
/// every attempt for the operating system's own connect backoff: measured at
/// 21 s on Windows and 134 s on Linux. With `MAX_FILE_RETRIES` and its backoff
/// that is minutes for one file, and a profile of two hundred files reports
/// nothing for most of an hour.
///
/// Matches the pre-flight probe, so a host that fails the check fails a
/// transfer the same way and in the same time.
const CONNECT_TIMEOUT: Duration = Duration::from_secs(8);
/// How long a transfer may make no progress at all.
///
/// Deliberately an inactivity timeout and not a deadline on the whole request.
/// Profile files run to tens of megabytes and a slow link is not a broken one,
/// so a total timeout would start failing syncs that were working. This fires
/// only when nothing arrives for a full minute.
const READ_TIMEOUT: Duration = Duration::from_secs(60);
#[derive(Clone)]
pub struct SyncClient {
@@ -11,7 +33,15 @@ pub struct SyncClient {
impl SyncClient {
pub fn new(base_url: String, token: String) -> Self {
Self {
client: Client::new(),
client: Client::builder()
.connect_timeout(CONNECT_TIMEOUT)
.read_timeout(READ_TIMEOUT)
.build()
// A builder failure here means the TLS backend did not start. The
// default client cannot transfer either, so fall back and let the first
// real request report it, rather than making this constructor fallible
// for a condition no caller can act on.
.unwrap_or_default(),
base_url: base_url.trim_end_matches('/').to_string(),
token,
}
@@ -235,14 +265,13 @@ impl SyncClient {
}
// The storage host here comes from the presigned URL, so on a self-hosted
// server it is whatever the server signed against frequently an address
// server it is whatever the server signed against, frequently an address
// only the server can resolve. `reqwest`'s own Display collapses that to
// "error sending request", which is why this failure used to be
// undiagnosable; report the innermost cause instead.
let response = req
.send()
.await
.map_err(|e| SyncError::NetworkError(super::preflight::transport_reason(&e)))?;
// undiagnosable; report the innermost cause and the host it names.
let response = req.send().await.map_err(|e| {
SyncError::NetworkError(super::preflight::transport_reason_for(presigned_url, &e))
})?;
if !response.status().is_success() {
let status = response.status();
@@ -256,12 +285,9 @@ impl SyncClient {
}
pub async fn download_bytes(&self, presigned_url: &str) -> SyncResult<Vec<u8>> {
let response = self
.client
.get(presigned_url)
.send()
.await
.map_err(|e| SyncError::NetworkError(super::preflight::transport_reason(&e)))?;
let response = self.client.get(presigned_url).send().await.map_err(|e| {
SyncError::NetworkError(super::preflight::transport_reason_for(presigned_url, &e))
})?;
if !response.status().is_success() {
return Err(SyncError::NetworkError(format!(
+108 -3
View File
@@ -146,9 +146,14 @@ fn critical_failure_message(action: &str, failures: &[(String, String)]) -> Stri
/// Transfers go straight to the storage host named in the presigned URL, not
/// through the sync server, so a self-hosted server that signs URLs against an
/// address only it can resolve fails every file here while its own `/health`
/// and `/readyz` stay green. The cause string already carries the host; without
/// this line it still reads as an unexplained network fault, and the setting
/// that fixes it lives on the server, where the user is not looking.
/// and `/readyz` stay green. The cause string names the host, which says which
/// address is wrong; this line says where to change it, because the setting
/// lives on the server, where the user is not looking.
///
/// The host only started appearing in that string when the transfer path moved
/// to `transport_reason_for`. Before that this comment claimed a host that was
/// never there, and every report of this bug arrived with a list of file names
/// and nothing to act on.
fn storage_endpoint_hint(cause: &str) -> String {
let lowered = cause.to_ascii_lowercase();
let is_transport_failure = [
@@ -4321,6 +4326,106 @@ pub async fn rollover_encryption_for_all_entities(
mod tests {
use super::*;
/// The whole of issue 534, at the only place the user ever sees it.
///
/// A self-hosted server signs every presigned URL against the address it uses
/// for storage itself. In the documented compose file that is a Docker
/// service name, so the server is healthy, `/health` and `/readyz` are green,
/// and the client cannot open a single one of the URLs it is handed. The
/// reporters got a list of file names, no host and no setting, and there was
/// nothing in it to act on.
///
/// The message has to carry three things: which files, which host refused
/// them, and which setting fixes it.
#[test]
fn a_transfer_failure_names_the_host_and_the_setting_that_fixes_it() {
// Exactly the text the transfer path now produces. The trailing host comes
// from `preflight::transport_reason_for`, which the two transfer call sites
// in `client.rs` use.
let cause = "connection failed: No such host is known. (os error 11001) \
(storage host minio:9000)";
let failures = vec![
("profile/Default/Cookies".to_string(), cause.to_string()),
("profile/Default/Login Data".to_string(), cause.to_string()),
("profile/Local State".to_string(), cause.to_string()),
];
let message = critical_failure_message("upload", &failures);
assert!(
message.contains("minio:9000"),
"the reader has to learn which host refused the transfer: {message}"
);
assert!(
message.contains("S3_PUBLIC_ENDPOINT"),
"the setting that fixes it lives on the server, so the message has to \
name it: {message}"
);
assert!(
message.contains("profile/Default/Cookies"),
"the affected files still belong in the message: {message}"
);
}
/// The same guarantee, but driven through the real transfer path instead of a
/// hand-written cause string.
///
/// The test above pins the message builder. This one pins the join: that an
/// upload which cannot reach its host actually produces a cause carrying that
/// host. Dropping back to a reason that omits the host, which is how this
/// shipped for months, breaks this test and not the one above.
///
/// No server is involved. `.invalid` never resolves (RFC 2606), so the
/// failure is the real one, offline and deterministic.
#[tokio::test]
async fn an_unreachable_storage_host_survives_the_whole_way_to_the_message() {
let client = SyncClient::new("http://127.0.0.1:1".to_string(), "unused".to_string());
let presigned = "http://donut-storage.invalid:9000/bucket/profiles/p1/Cookies\
?X-Amz-Signature=deadbeef";
let error = client
.upload_bytes(presigned, b"payload", None)
.await
.expect_err("a host that cannot resolve must not report a successful upload");
let message = critical_failure_message(
"upload",
&[("profile/Default/Cookies".to_string(), error.to_string())],
);
assert!(
message.contains("donut-storage.invalid:9000"),
"the host has to survive from the transfer to the message: {message}"
);
assert!(
message.contains("S3_PUBLIC_ENDPOINT"),
"an unreachable storage host has one fix, and it is on the server: {message}"
);
assert!(
!message.contains("X-Amz-Signature"),
"the signature must never reach the message: {message}"
);
}
/// The hint is for a transfer that never connected. A server that answered
/// and refused is a different problem with a different fix, and pointing that
/// user at their storage endpoint would send them the wrong way.
#[test]
fn a_rejected_transfer_is_not_blamed_on_the_storage_endpoint() {
let failures = vec![(
"profile/Default/Cookies".to_string(),
"Upload failed with status 403 Forbidden: SignatureDoesNotMatch".to_string(),
)];
let message = critical_failure_message("upload", &failures);
assert!(message.contains("SignatureDoesNotMatch"), "{message}");
assert!(
!message.contains("S3_PUBLIC_ENDPOINT"),
"a 403 is not an unreachable host: {message}"
);
}
#[test]
fn test_critical_failure_message_carries_the_cause() {
// A self-hosted server that hands out unreachable presigned URLs fails
+93
View File
@@ -31,6 +31,99 @@ pub use scheduler::{get_global_scheduler, set_global_scheduler, SyncScheduler};
pub use subscription::{SubscriptionManager, SyncWorkItem};
pub use types::{SyncError, SyncResult};
/// The live subscription, held so it can be stopped.
///
/// It used to be a local inside whichever task built the pipeline. Dropping a
/// `SubscriptionManager` does not end its work: `SyncSubscription::start`
/// spawns a task holding clones of the running flag and the work sender, so the
/// task outlived the handle and nothing could reach it. Every restart added one
/// more live SSE connection, each with its own poll loop on the server, and
/// disconnecting left an authenticated stream open to a server the user had
/// just removed.
static GLOBAL_SUBSCRIPTION: std::sync::Mutex<Option<SubscriptionManager>> =
std::sync::Mutex::new(None);
/// Held for the whole of `start_pipeline`, so only one pipeline is ever being
/// assembled at a time.
static PIPELINE_LOCK: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(());
/// Retire the running pipeline, both halves of it.
pub fn stop_pipeline() {
if let Some(scheduler) = get_global_scheduler() {
scheduler.stop();
}
if let Ok(mut guard) = GLOBAL_SUBSCRIPTION.lock() {
if let Some(subscription) = guard.as_mut() {
subscription.stop();
}
*guard = None;
}
}
/// Build and start the sync pipeline. Safe to call again to restart it.
///
/// Startup and `restart_sync_service` each held their own copy of this, and the
/// copies had drifted. The restart copy stopped the old scheduler first and then
/// returned early if the subscription failed to start, so it left the
/// application holding a scheduler whose task had already exited. Everything
/// queued afterwards went into `pending_profiles` and was never drained, and
/// sync was silently dead until the app was restarted. One function cannot
/// drift from itself.
pub async fn start_pipeline(app_handle: tauri::AppHandle) {
// Two restarts arriving together would otherwise interleave: the second
// retires what the first has not published yet, then both start, and one
// scheduler is left ticking with nothing able to reach it. Building the
// pipeline is rare and already awaits the network, so serialising it costs
// nothing worth measuring.
let _building = PIPELINE_LOCK.lock().await;
stop_pipeline();
let mut subscription_manager = SubscriptionManager::new();
let Some(work_rx) = subscription_manager.take_work_receiver() else {
log::error!("Sync pipeline has no work receiver; not starting");
return;
};
// A subscription failure costs live updates from other devices. It does not
// stop this device syncing its own changes on the timer, so carry on. The
// restart path used to give up here, which turned a token hiccup into sync
// being dead until the next launch.
if let Err(e) = subscription_manager.start(app_handle.clone()).await {
log::warn!("Failed to start sync subscription, continuing without live updates: {e}");
}
if let Ok(mut guard) = GLOBAL_SUBSCRIPTION.lock() {
*guard = Some(subscription_manager);
}
let scheduler = std::sync::Arc::new(SyncScheduler::new());
// Published before the loop starts, because the checks below await the
// network and anything queued in the meantime has to land in this scheduler.
// `stop()` marks it cancelled, so a restart arriving during that window still
// retires it and `start` below becomes a no-op.
set_global_scheduler(scheduler.clone());
scheduler.sync_all_enabled_profiles(&app_handle).await;
match SyncEngine::create_from_settings(&app_handle).await {
Ok(engine) => {
if let Err(e) = engine.check_for_missing_synced_profiles(&app_handle).await {
log::warn!("Failed to check for missing profiles: {e}");
}
if let Err(e) = engine.check_for_missing_synced_entities(&app_handle).await {
log::warn!("Failed to check for missing entities: {e}");
}
}
Err(e) => {
log::warn!("Sync not configured, skipping missing profile check: {e}");
}
}
if scheduler.clone().start(app_handle, work_rx).await {
log::info!("Sync scheduler started");
}
}
/// Queue a profile sync if the profile has sync enabled. No-op otherwise.
///
/// Called from profile metadata update paths so a rename / tag edit / proxy
+71
View File
@@ -173,6 +173,38 @@ pub(crate) fn transport_reason(error: &reqwest::Error) -> String {
}
}
/// The same reason, naming the host that would not answer.
///
/// A transfer goes straight to the host inside the presigned URL, and that host
/// is chosen by the server, not by this device. It is therefore the one fact the
/// user has never seen and the only one that points at the fix. Leaving it out
/// is what produced reports of "connection failed" with nothing to act on.
///
/// `reqwest::Error::url()` is empty for connect-stage failures, which are
/// exactly the ones that matter here, so take the host from the URL the caller
/// already holds.
pub(crate) fn transport_reason_for(url: &str, error: &reqwest::Error) -> String {
let reason = transport_reason(error);
match storage_host(url) {
Some(host) => format!("{reason} (storage host {host})"),
None => reason,
}
}
/// Host and port, and nothing else.
///
/// A presigned URL carries the signature and the object key in its query, and
/// this string reaches log files and toasts. Only the authority is safe to
/// repeat, and it is the whole of what the reader needs.
fn storage_host(url: &str) -> Option<String> {
let parsed = url::Url::parse(url).ok()?;
let host = parsed.host_str()?;
match parsed.port() {
Some(port) => Some(format!("{host}:{port}")),
None => Some(host.to_string()),
}
}
/// Pre-flight a sync server before saving it, and before trusting it to sync.
#[tauri::command]
pub async fn check_sync_server_connection(server_url: String) -> Result<SyncServerCheck, String> {
@@ -260,4 +292,43 @@ mod tests {
// The bare reqwest Display is what this exists to avoid.
assert_ne!(error, "error sending request");
}
#[test]
fn a_presigned_url_yields_only_its_authority() {
// The query carries the signature and the key. Neither may reach a log.
let signed = "http://minio:9000/donut/profiles/p1/profile/Default/Cookies\
?X-Amz-Signature=deadbeef&X-Amz-Credential=minioadmin";
assert_eq!(storage_host(signed).as_deref(), Some("minio:9000"));
assert_eq!(
storage_host("https://storage.example.com/bucket/key").as_deref(),
Some("storage.example.com")
);
assert_eq!(storage_host("not a url").as_deref(), None);
}
#[tokio::test]
async fn a_failed_transfer_names_the_host_that_refused_it() {
// The whole point of the message. Issue 534 reporters saw a list of file
// names and a bare "connection failed", and could not tell that the host
// their server had signed into every URL was one only the server could
// resolve.
let url = "http://minio.invalid:9000/donut/profiles/p1/Cookies?X-Amz-Signature=abc";
let error = probe_client()
.put(url)
.body(b"payload".to_vec())
.send()
.await
.expect_err("an unresolvable host must not succeed");
let message = transport_reason_for(url, &error);
assert!(
message.contains("minio.invalid:9000"),
"the failure has to name the storage host, got: {message}"
);
assert!(
!message.contains("X-Amz-Signature"),
"the signature must never reach the message, got: {message}"
);
}
}
+153 -15
View File
@@ -8,7 +8,6 @@ use std::sync::Arc;
use std::time::{Duration, Instant};
use tokio::sync::mpsc;
use tokio::sync::Mutex;
use tokio::time::sleep;
static GLOBAL_SCHEDULER: std::sync::Mutex<Option<Arc<SyncScheduler>>> = std::sync::Mutex::new(None);
@@ -22,6 +21,17 @@ pub fn set_global_scheduler(scheduler: Arc<SyncScheduler>) {
}
}
/// What `start` should do, given the flags.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum StartDecision {
/// Nothing is running and nothing retired it. Spawn the loop.
Start,
/// A loop is already ticking on this scheduler.
AlreadyRunning,
/// `stop` was called on it, possibly before it ever ran.
Retired,
}
#[derive(Debug, Clone)]
struct ProfileStopTime {
#[allow(dead_code)]
@@ -31,6 +41,15 @@ struct ProfileStopTime {
pub struct SyncScheduler {
running: Arc<AtomicBool>,
/// Set by `stop()` and never cleared. A scheduler is one-shot.
///
/// The pipeline publishes a scheduler before it starts its loop, because work
/// queued during the network checks in between has to land somewhere. That
/// left a window where `stop()` cleared a `running` flag that was still
/// false, so it did nothing, and the scheduler then started anyway and ticked
/// forever with no way to reach it. `running` cannot express "retired before
/// it ever ran", so this does.
cancelled: Arc<AtomicBool>,
pending_profiles: Arc<Mutex<HashMap<String, ProfileStopTime>>>,
pending_proxies: Arc<Mutex<HashSet<String>>>,
pending_groups: Arc<Mutex<HashSet<String>>>,
@@ -52,6 +71,7 @@ impl SyncScheduler {
pub fn new() -> Self {
Self {
running: Arc::new(AtomicBool::new(false)),
cancelled: Arc::new(AtomicBool::new(false)),
pending_profiles: Arc::new(Mutex::new(HashMap::new())),
pending_proxies: Arc::new(Mutex::new(HashSet::new())),
pending_groups: Arc::new(Mutex::new(HashSet::new())),
@@ -68,7 +88,12 @@ impl SyncScheduler {
self.running.load(Ordering::SeqCst)
}
/// Retire this scheduler for good.
///
/// Order matters: mark it cancelled before clearing `running`, so a `start()`
/// racing this call cannot slip between the two and begin ticking.
pub fn stop(&self) {
self.cancelled.store(true, Ordering::SeqCst);
self.running.store(false, Ordering::SeqCst);
}
@@ -334,35 +359,93 @@ impl SyncScheduler {
}
}
/// The decision `start` makes before it spawns anything.
///
/// Split out so it can be tested. `start` needs a `tauri::AppHandle`, which a
/// unit test cannot build, and the retirement rule is the part worth pinning
/// down. The `running` check stays a `swap` so two concurrent starts cannot
/// both win.
fn claim_start_slot(&self) -> StartDecision {
if self.cancelled.load(Ordering::SeqCst) {
return StartDecision::Retired;
}
if self.running.swap(true, Ordering::SeqCst) {
return StartDecision::AlreadyRunning;
}
StartDecision::Start
}
/// Begin ticking. Returns whether a loop was actually started, so the caller
/// can log the truth instead of assuming.
pub async fn start(
self: Arc<Self>,
app_handle: tauri::AppHandle,
mut work_rx: mpsc::UnboundedReceiver<SyncWorkItem>,
) {
if self.running.swap(true, Ordering::SeqCst) {
return;
) -> bool {
match self.claim_start_slot() {
StartDecision::Retired => {
// Retired while the pipeline was still assembling it. Starting now
// would leave a task nothing can stop, because the handle in the global
// has already been replaced.
log::info!("Sync scheduler was retired before it started; not starting it");
return false;
}
StartDecision::AlreadyRunning => {
log::warn!("Sync scheduler is already running; ignoring the second start");
return false;
}
StartDecision::Start => {}
}
let scheduler = self.clone();
let app_handle_clone = app_handle.clone();
tokio::spawn(async move {
// A fresh `sleep` inside the `select!` restarts from zero on every
// iteration, so a steady stream of work items kept resetting it and
// `process_pending` never ran: queued profiles sat there for as long as
// the stream lasted. An interval keeps its own schedule regardless of how
// often the other arm fires. `Delay` rather than `Burst` so a slow
// `process_pending` does not come back to a pile of missed ticks and run
// itself back to back.
let mut ticker = tokio::time::interval(Duration::from_millis(2000));
ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
// The first tick of an interval resolves immediately. The old shape
// always waited 2000 ms before its first pass, so consume it here and
// keep that behaviour.
ticker.tick().await;
// Once the senders are gone `recv()` resolves instantly and forever, so
// the arm has to be disabled or the loop spins hot on a dead channel.
let mut work_channel_open = true;
while scheduler.running.load(Ordering::SeqCst) {
tokio::select! {
Some(work_item) = work_rx.recv() => {
match work_item {
SyncWorkItem::Profile(id) => scheduler.queue_profile_sync(id).await,
SyncWorkItem::Proxy(id) => scheduler.queue_proxy_sync(id).await,
SyncWorkItem::Group(id) => scheduler.queue_group_sync(id).await,
SyncWorkItem::Vpn(id) => scheduler.queue_vpn_sync(id).await,
SyncWorkItem::Extension(id) => scheduler.queue_extension_sync(id).await,
SyncWorkItem::ExtensionGroup(id) => scheduler.queue_extension_group_sync(id).await,
SyncWorkItem::Tombstone(entity_type, entity_id) => {
scheduler.queue_tombstone(entity_type, entity_id).await
received = work_rx.recv(), if work_channel_open => {
match received {
Some(work_item) => match work_item {
SyncWorkItem::Profile(id) => scheduler.queue_profile_sync(id).await,
SyncWorkItem::Proxy(id) => scheduler.queue_proxy_sync(id).await,
SyncWorkItem::Group(id) => scheduler.queue_group_sync(id).await,
SyncWorkItem::Vpn(id) => scheduler.queue_vpn_sync(id).await,
SyncWorkItem::Extension(id) => scheduler.queue_extension_sync(id).await,
SyncWorkItem::ExtensionGroup(id) => scheduler.queue_extension_group_sync(id).await,
SyncWorkItem::Tombstone(entity_type, entity_id) => {
scheduler.queue_tombstone(entity_type, entity_id).await
}
},
None => {
// The subscription is gone, so no more live updates from other
// devices. Local changes and the timer still work, so keep
// ticking rather than ending the scheduler.
log::warn!(
"Sync work channel closed; continuing on the timer without live updates"
);
work_channel_open = false;
}
}
}
_ = sleep(Duration::from_millis(2000)) => {
_ = ticker.tick() => {
scheduler.process_pending(&app_handle_clone).await;
}
}
@@ -370,6 +453,8 @@ impl SyncScheduler {
log::info!("Sync scheduler stopped");
});
true
}
async fn process_pending(&self, app_handle: &tauri::AppHandle) {
@@ -853,3 +938,56 @@ impl SyncScheduler {
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_fresh_scheduler_starts_once() {
let scheduler = SyncScheduler::new();
assert_eq!(scheduler.claim_start_slot(), StartDecision::Start);
assert!(scheduler.is_running());
assert_eq!(
scheduler.claim_start_slot(),
StartDecision::AlreadyRunning,
"a second start must not spawn a second loop on the same scheduler"
);
}
#[test]
fn a_scheduler_retired_before_it_ran_never_starts() {
// The pipeline publishes a scheduler, then awaits two network checks, then
// starts the loop. A restart landing in that window calls `stop()` on a
// scheduler that has not started yet. `running` was already false, so the
// old `stop()` did nothing at all, the loop started afterwards, and it
// ticked forever with the global already pointing elsewhere.
let scheduler = SyncScheduler::new();
assert!(!scheduler.is_running());
scheduler.stop();
assert_eq!(
scheduler.claim_start_slot(),
StartDecision::Retired,
"a scheduler stopped before starting must stay stopped"
);
assert!(
!scheduler.is_running(),
"refusing to start must not leave the running flag set"
);
}
#[test]
fn stopping_a_running_scheduler_retires_it_for_good() {
let scheduler = SyncScheduler::new();
assert_eq!(scheduler.claim_start_slot(), StartDecision::Start);
scheduler.stop();
assert!(!scheduler.is_running());
// A scheduler is one-shot. Restarting the pipeline builds a new one, so a
// retired instance coming back to life could only ever be a duplicate.
assert_eq!(scheduler.claim_start_slot(), StartDecision::Retired);
}
}
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "Donut",
"version": "0.29.6",
"version": "0.30.0",
"identifier": "com.donutbrowser",
"build": {
"beforeDevCommand": "pnpm copy-proxy-binary && pnpm dev",
+29 -3
View File
@@ -62,6 +62,7 @@ import {
} from "@/lib/themes";
import { showErrorToast, showSuccessToast } from "@/lib/toast-utils";
import { cn } from "@/lib/utils";
import type { SetDefaultBrowserOutcome } from "@/types";
import { RippleButton } from "./ui/ripple";
interface AppSettings {
@@ -401,14 +402,39 @@ export function SettingsDialog({
const handleSetDefaultBrowser = useCallback(async () => {
setIsSettingDefault(true);
try {
await invoke("set_as_default_browser");
// Windows keeps the final choice for its own settings page, so a call
// that succeeded does not always mean Donut is the default yet. Say which
// of the two happened. Saying nothing at all is what left the user
// watching the badge stay "Inactive" with no explanation.
const outcome = await invoke<SetDefaultBrowserOutcome>(
"set_as_default_browser",
);
await checkDefaultBrowserStatus();
if (outcome.status === "awaitingSystemSettings") {
showSuccessToast(t("settings.defaultBrowser.finishInSystemSettings"), {
description: t(
"settings.defaultBrowser.finishInSystemSettingsDescription",
),
duration: 8000,
});
} else {
showSuccessToast(t("settings.defaultBrowser.setSuccess"));
}
} catch (error) {
console.error("Failed to set as default browser:", error);
showErrorToast(t("settings.defaultBrowser.setFailed"), {
description:
error instanceof Error
? error.message
: typeof error === "string"
? error
: t("common.errors.unknown"),
duration: 8000,
});
} finally {
setIsSettingDefault(false);
}
}, [checkDefaultBrowserStatus]);
}, [checkDefaultBrowserStatus, t]);
const handleClearTraffic = useCallback(async () => {
setIsClearingTraffic(true);
+5 -1
View File
@@ -134,7 +134,11 @@
"title": "Default Browser",
"setAsDefault": "Set as Default Browser",
"alreadyDefault": "Already Default Browser",
"description": "When set as default, Donut Browser will handle web links and allow you to choose which profile to use."
"description": "When set as default, Donut Browser will handle web links and allow you to choose which profile to use.",
"setSuccess": "Donut Browser is now your default browser",
"setFailed": "Could not set the default browser",
"finishInSystemSettings": "Finish in Windows Settings",
"finishInSystemSettingsDescription": "Donut Browser is registered. Windows Settings is open: choose Donut Browser under Web browser to finish."
},
"permissions": {
"title": "System Permissions",
+5 -1
View File
@@ -134,7 +134,11 @@
"title": "Navegador Predeterminado",
"setAsDefault": "Establecer como Navegador Predeterminado",
"alreadyDefault": "Ya es el Navegador Predeterminado",
"description": "Cuando se establece como predeterminado, Donut Browser manejará los enlaces web y te permitirá elegir qué perfil usar."
"description": "Cuando se establece como predeterminado, Donut Browser manejará los enlaces web y te permitirá elegir qué perfil usar.",
"setSuccess": "Donut Browser ya es tu navegador predeterminado",
"setFailed": "No se pudo establecer el navegador predeterminado",
"finishInSystemSettings": "Termina en la Configuración de Windows",
"finishInSystemSettingsDescription": "Donut Browser está registrado. Se abrió la Configuración de Windows: elige Donut Browser en Navegador web para terminar."
},
"permissions": {
"title": "Permisos del Sistema",
+5 -1
View File
@@ -134,7 +134,11 @@
"title": "Navigateur par défaut",
"setAsDefault": "Définir comme navigateur par défaut",
"alreadyDefault": "Déjà le navigateur par défaut",
"description": "Lorsqu'il est défini par défaut, Donut Browser gérera les liens web et vous permettra de choisir quel profil utiliser."
"description": "Lorsqu'il est défini par défaut, Donut Browser gérera les liens web et vous permettra de choisir quel profil utiliser.",
"setSuccess": "Donut Browser est maintenant votre navigateur par défaut",
"setFailed": "Impossible de définir le navigateur par défaut",
"finishInSystemSettings": "Terminez dans les Paramètres Windows",
"finishInSystemSettingsDescription": "Donut Browser est enregistré. Les Paramètres Windows sont ouverts : choisissez Donut Browser sous Navigateur web pour terminer."
},
"permissions": {
"title": "Permissions système",
+5 -1
View File
@@ -134,7 +134,11 @@
"title": "デフォルトブラウザ",
"setAsDefault": "デフォルトブラウザに設定",
"alreadyDefault": "既にデフォルトブラウザです",
"description": "デフォルトに設定すると、Donut Browser がウェブリンクを処理し、使用するプロファイルを選択できます。"
"description": "デフォルトに設定すると、Donut Browser がウェブリンクを処理し、使用するプロファイルを選択できます。",
"setSuccess": "Donut Browser が既定のブラウザーになりました",
"setFailed": "既定のブラウザーを設定できませんでした",
"finishInSystemSettings": "Windows の設定で完了してください",
"finishInSystemSettingsDescription": "Donut Browser を登録しました。Windows の設定が開いています。「Web ブラウザー」で Donut Browser を選ぶと完了します。"
},
"permissions": {
"title": "システム権限",
+5 -1
View File
@@ -134,7 +134,11 @@
"title": "기본 브라우저",
"setAsDefault": "기본 브라우저로 설정",
"alreadyDefault": "이미 기본 브라우저입니다",
"description": "기본 브라우저로 설정하면 Donut Browser가 웹 링크를 처리하고 사용할 프로필을 선택할 수 있습니다."
"description": "기본 브라우저로 설정하면 Donut Browser가 웹 링크를 처리하고 사용할 프로필을 선택할 수 있습니다.",
"setSuccess": "이제 Donut Browser가 기본 브라우저입니다",
"setFailed": "기본 브라우저를 설정하지 못했습니다",
"finishInSystemSettings": "Windows 설정에서 완료하세요",
"finishInSystemSettingsDescription": "Donut Browser가 등록되었습니다. Windows 설정이 열려 있습니다. '웹 브라우저'에서 Donut Browser를 선택하면 완료됩니다."
},
"permissions": {
"title": "시스템 권한",
+5 -1
View File
@@ -134,7 +134,11 @@
"title": "Navegador Padrão",
"setAsDefault": "Definir como Navegador Padrão",
"alreadyDefault": "Já é o Navegador Padrão",
"description": "Quando definido como padrão, o Donut Browser lidará com links da web e permitirá que você escolha qual perfil usar."
"description": "Quando definido como padrão, o Donut Browser lidará com links da web e permitirá que você escolha qual perfil usar.",
"setSuccess": "O Donut Browser agora é o seu navegador padrão",
"setFailed": "Não foi possível definir o navegador padrão",
"finishInSystemSettings": "Conclua nas Configurações do Windows",
"finishInSystemSettingsDescription": "O Donut Browser está registrado. As Configurações do Windows foram abertas: escolha o Donut Browser em Navegador da web para concluir."
},
"permissions": {
"title": "Permissões do Sistema",
+5 -1
View File
@@ -134,7 +134,11 @@
"title": "Браузер по умолчанию",
"setAsDefault": "Установить браузером по умолчанию",
"alreadyDefault": "Уже браузер по умолчанию",
"description": "При установке по умолчанию Donut Browser будет обрабатывать веб-ссылки и позволит выбрать профиль для использования."
"description": "При установке по умолчанию Donut Browser будет обрабатывать веб-ссылки и позволит выбрать профиль для использования.",
"setSuccess": "Donut Browser теперь браузер по умолчанию",
"setFailed": "Не удалось назначить браузер по умолчанию",
"finishInSystemSettings": "Завершите в параметрах Windows",
"finishInSystemSettingsDescription": "Donut Browser зарегистрирован. Параметры Windows открыты: выберите Donut Browser в разделе «Веб-браузер», чтобы завершить."
},
"permissions": {
"title": "Системные разрешения",
+5 -1
View File
@@ -134,7 +134,11 @@
"title": "Varsayılan Tarayıcı",
"setAsDefault": "Varsayılan Tarayıcı Olarak Ayarla",
"alreadyDefault": "Zaten Varsayılan Tarayıcı",
"description": "Varsayılan olarak ayarlandığında, Donut Browser web bağlantılarını yönetir ve hangi profilin kullanılacağını seçmenize olanak tanır."
"description": "Varsayılan olarak ayarlandığında, Donut Browser web bağlantılarını yönetir ve hangi profilin kullanılacağını seçmenize olanak tanır.",
"setSuccess": "Donut Browser artık varsayılan tarayıcınız",
"setFailed": "Varsayılan tarayıcı ayarlanamadı",
"finishInSystemSettings": "Windows Ayarları'nda tamamlayın",
"finishInSystemSettingsDescription": "Donut Browser kaydedildi. Windows Ayarları açıldı: tamamlamak için Web tarayıcısı bölümünden Donut Browser'ı seçin."
},
"permissions": {
"title": "Sistem İzinleri",
+5 -1
View File
@@ -134,7 +134,11 @@
"title": "Trình duyệt mặc định",
"setAsDefault": "Đặt làm trình duyệt mặc định",
"alreadyDefault": "Đã là trình duyệt mặc định",
"description": "Khi được đặt làm mặc định, Donut Browser sẽ xử lý các liên kết web và cho phép bạn chọn hồ sơ để sử dụng."
"description": "Khi được đặt làm mặc định, Donut Browser sẽ xử lý các liên kết web và cho phép bạn chọn hồ sơ để sử dụng.",
"setSuccess": "Donut Browser hiện là trình duyệt mặc định của bạn",
"setFailed": "Không thể đặt trình duyệt mặc định",
"finishInSystemSettings": "Hoàn tất trong Cài đặt Windows",
"finishInSystemSettingsDescription": "Donut Browser đã được đăng ký. Cài đặt Windows đang mở: chọn Donut Browser trong mục Trình duyệt web để hoàn tất."
},
"permissions": {
"title": "Quyền hệ thống",
+5 -1
View File
@@ -134,7 +134,11 @@
"title": "默认浏览器",
"setAsDefault": "设为默认浏览器",
"alreadyDefault": "已是默认浏览器",
"description": "设为默认后,Donut Browser 将处理网页链接并允许您选择使用哪个配置文件。"
"description": "设为默认后,Donut Browser 将处理网页链接并允许您选择使用哪个配置文件。",
"setSuccess": "Donut Browser 现在是您的默认浏览器",
"setFailed": "无法设置默认浏览器",
"finishInSystemSettings": "请在 Windows 设置中完成",
"finishInSystemSettingsDescription": "Donut Browser 已注册。Windows 设置已打开:在“Web 浏览器”中选择 Donut Browser 即可完成。"
},
"permissions": {
"title": "系统权限",
+13
View File
@@ -818,3 +818,16 @@ export interface PreLaunchChecks {
exit_measurement_unreliable: boolean;
consent_token: string | null;
}
/**
* What happened when the user asked Donut to become the default browser.
*
* macOS and Linux let a program make the change itself, so the answer there is
* always "set". Windows reserves the final choice for its own settings page:
* the app registers itself, Windows Settings opens, and the user finishes the
* job. Treating that case as plain success is how the button used to report a
* change that had not happened.
*/
export type SetDefaultBrowserOutcome =
| { status: "set" }
| { status: "awaitingSystemSettings" };