mirror of
https://github.com/zhom/donutbrowser.git
synced 2026-08-28 21:50:46 +02:00
refactor: cleanup
This commit is contained in:
@@ -31,7 +31,50 @@ env:
|
|||||||
IMAGE_NAME: donutbrowser/donut-sync
|
IMAGE_NAME: donutbrowser/donut-sync
|
||||||
|
|
||||||
jobs:
|
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:
|
build-and-push:
|
||||||
|
needs: test
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
|
|||||||
+1
-1
@@ -1,2 +1,2 @@
|
|||||||
23
|
24
|
||||||
|
|
||||||
|
|||||||
@@ -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:
|
services:
|
||||||
minio:
|
minio:
|
||||||
image: minio/minio:latest
|
image: minio/minio:latest
|
||||||
|
|||||||
@@ -86,6 +86,11 @@ export class SyncService implements OnModuleInit {
|
|||||||
// `S3_PUBLIC_ENDPOINT` names a different, client-reachable address.
|
// `S3_PUBLIC_ENDPOINT` names a different, client-reachable address.
|
||||||
private presignClient: S3Client;
|
private presignClient: S3Client;
|
||||||
private publicEndpoint: string;
|
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;
|
private bucket: string;
|
||||||
// Upper bound on presign batch array length (DoS guard).
|
// Upper bound on presign batch array length (DoS guard).
|
||||||
private static readonly MAX_BATCH_ITEMS = 1000;
|
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
|
// 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
|
// client is a second client pinned to the public address rather than a
|
||||||
// string rewrite of the signed URL.
|
// string rewrite of the signed URL.
|
||||||
const publicEndpoint =
|
const configuredPublicEndpoint =
|
||||||
this.configService.get<string>("S3_PUBLIC_ENDPOINT") || endpoint;
|
this.configService.get<string>("S3_PUBLIC_ENDPOINT");
|
||||||
|
const publicEndpoint = configuredPublicEndpoint || endpoint;
|
||||||
this.publicEndpoint = publicEndpoint;
|
this.publicEndpoint = publicEndpoint;
|
||||||
|
this.publicEndpointWasConfigured = Boolean(configuredPublicEndpoint);
|
||||||
this.presignClient =
|
this.presignClient =
|
||||||
publicEndpoint === endpoint
|
publicEndpoint === endpoint
|
||||||
? this.s3Client
|
? this.s3Client
|
||||||
@@ -191,14 +198,33 @@ export class SyncService implements OnModuleInit {
|
|||||||
|
|
||||||
const isSingleLabel =
|
const isSingleLabel =
|
||||||
!host.includes(".") && !host.includes(":") && host !== "localhost";
|
!host.includes(".") && !host.includes(":") && host !== "localhost";
|
||||||
if (!isSingleLabel) return;
|
|
||||||
|
|
||||||
this.logger.warn(
|
if (isSingleLabel) {
|
||||||
`Storage endpoint '${this.publicEndpoint}' uses the container-only host '${host}'. ` +
|
this.logger.warn(
|
||||||
"Presigned URLs built from it cannot be reached by Donut Browser, so every " +
|
`Storage endpoint '${this.publicEndpoint}' uses the container-only host '${host}'. ` +
|
||||||
"transfer will fail while /health and /readyz stay green. Set S3_PUBLIC_ENDPOINT " +
|
"Presigned URLs built from it cannot be reached by Donut Browser, so every " +
|
||||||
"to an address your devices can reach (and publish that port).",
|
"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> {
|
private async ensureBucketExists(): Promise<void> {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { INestApplication } from "@nestjs/common";
|
import { INestApplication, Logger } from "@nestjs/common";
|
||||||
import { ConfigModule } from "@nestjs/config";
|
import { ConfigModule } from "@nestjs/config";
|
||||||
import { Test, TestingModule } from "@nestjs/testing";
|
import { Test, TestingModule } from "@nestjs/testing";
|
||||||
import request from "supertest";
|
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
@@ -2,7 +2,7 @@
|
|||||||
"name": "donutbrowser",
|
"name": "donutbrowser",
|
||||||
"private": true,
|
"private": true,
|
||||||
"license": "AGPL-3.0",
|
"license": "AGPL-3.0",
|
||||||
"version": "0.29.6",
|
"version": "0.30.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"predev": "pnpm licenses:generate",
|
"predev": "pnpm licenses:generate",
|
||||||
|
|||||||
Generated
+1
-1
@@ -1735,7 +1735,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "donutbrowser"
|
name = "donutbrowser"
|
||||||
version = "0.29.6"
|
version = "0.30.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"aes 0.9.2",
|
"aes 0.9.2",
|
||||||
"aes-gcm 0.11.1",
|
"aes-gcm 0.11.1",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "donutbrowser"
|
name = "donutbrowser"
|
||||||
version = "0.29.6"
|
version = "0.30.0"
|
||||||
description = "Simple Yet Powerful Anti-Detect Browser"
|
description = "Simple Yet Powerful Anti-Detect Browser"
|
||||||
authors = ["zhom@github"]
|
authors = ["zhom@github"]
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
@@ -147,6 +147,10 @@ windows = { version = "0.62", features = [
|
|||||||
"Win32_Security",
|
"Win32_Security",
|
||||||
"Win32_Storage_FileSystem",
|
"Win32_Storage_FileSystem",
|
||||||
"Win32_System_Registry",
|
"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",
|
"Win32_UI_Shell",
|
||||||
# SendMessageTimeoutW, for the association-change broadcast in
|
# SendMessageTimeoutW, for the association-change broadcast in
|
||||||
# default_browser.rs. Going through the crate rather than a hand-written
|
# default_browser.rs. Going through the crate rather than a hand-written
|
||||||
|
|||||||
@@ -1872,36 +1872,34 @@ rm "{}"
|
|||||||
parameters
|
parameters
|
||||||
);
|
);
|
||||||
|
|
||||||
// windows-sys is not a direct dep, so use the raw FFI via the
|
// Take the binding from the `windows` crate rather than writing the
|
||||||
// windows crate that Tauri pulls in. ShellExecuteW returns an
|
// declaration here. A hand-written one is what put the wrong width on
|
||||||
// HINSTANCE > 32 on success.
|
// `SendMessageTimeoutA`'s out-parameter in `default_browser.rs`, and
|
||||||
#[link(name = "shell32")]
|
// that killed the process on every click of "Set as default browser".
|
||||||
extern "system" {
|
// No compiler and no lint can see such a mistake. The generated binding
|
||||||
fn ShellExecuteW(
|
// cannot drift from the real ABI, so there is nothing to get wrong.
|
||||||
hwnd: *mut std::ffi::c_void,
|
use windows::core::{w, PCWSTR};
|
||||||
operation: *const u16,
|
use windows::Win32::UI::Shell::ShellExecuteW;
|
||||||
file: *const u16,
|
use windows::Win32::UI::WindowsAndMessaging::SW_SHOWNORMAL;
|
||||||
parameters: *const u16,
|
|
||||||
directory: *const u16,
|
|
||||||
show_cmd: i32,
|
|
||||||
) -> isize;
|
|
||||||
}
|
|
||||||
const SW_SHOWNORMAL: i32 = 1;
|
|
||||||
let open: Vec<u16> = "open\0".encode_utf16().collect();
|
|
||||||
|
|
||||||
let result = unsafe {
|
let result = unsafe {
|
||||||
ShellExecuteW(
|
ShellExecuteW(
|
||||||
std::ptr::null_mut(),
|
None,
|
||||||
open.as_ptr(),
|
w!("open"),
|
||||||
file_w.as_ptr(),
|
PCWSTR(file_w.as_ptr()),
|
||||||
params_w.as_ptr(),
|
PCWSTR(params_w.as_ptr()),
|
||||||
std::ptr::null(),
|
PCWSTR::null(),
|
||||||
SW_SHOWNORMAL,
|
SW_SHOWNORMAL,
|
||||||
)
|
)
|
||||||
};
|
};
|
||||||
|
|
||||||
if result as usize <= 32 {
|
// ShellExecuteW reports success as a value above 32. Anything at or
|
||||||
return Err(format!("ShellExecuteW failed with code {result}").into());
|
// 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 {
|
} else {
|
||||||
// No pending installer — just restart the app. Use a minimal
|
// No pending installer — just restart the app. Use a minimal
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ use serde::{Deserialize, Serialize};
|
|||||||
use sha2::{Digest, Sha256};
|
use sha2::{Digest, Sha256};
|
||||||
use std::fs;
|
use std::fs;
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
use std::sync::Arc;
|
|
||||||
use tokio::sync::Mutex;
|
use tokio::sync::Mutex;
|
||||||
|
|
||||||
use crate::browser::ProxySettings;
|
use crate::browser::ProxySettings;
|
||||||
@@ -1618,54 +1617,11 @@ pub async fn cloud_get_proxy_usage() -> Result<Option<CloudProxyUsage>, String>
|
|||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn restart_sync_service(app_handle: tauri::AppHandle) -> Result<(), String> {
|
pub async fn restart_sync_service(app_handle: tauri::AppHandle) -> Result<(), String> {
|
||||||
// Stop existing scheduler
|
// Rebuilding the pipeline reaches the network, so do it off the command and
|
||||||
if let Some(scheduler) = sync::get_global_scheduler() {
|
// let the caller's dialog close. `start_pipeline` retires the previous
|
||||||
scheduler.stop();
|
// scheduler and the previous subscription itself.
|
||||||
}
|
|
||||||
|
|
||||||
// Restart sync pipeline
|
|
||||||
let app_handle_sync = app_handle.clone();
|
|
||||||
tauri::async_runtime::spawn(async move {
|
tauri::async_runtime::spawn(async move {
|
||||||
let mut subscription_manager = sync::SubscriptionManager::new();
|
sync::start_pipeline(app_handle).await;
|
||||||
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");
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
|
|||||||
+616
-162
@@ -1,7 +1,31 @@
|
|||||||
|
use serde::Serialize;
|
||||||
use tauri::command;
|
use tauri::command;
|
||||||
|
|
||||||
pub struct DefaultBrowser {}
|
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 {
|
impl DefaultBrowser {
|
||||||
fn new() -> Self {
|
fn new() -> Self {
|
||||||
Self {}
|
Self {}
|
||||||
@@ -21,7 +45,7 @@ impl DefaultBrowser {
|
|||||||
// Linux answers this by running `xdg-mime`, a shell script that forks
|
// Linux answers this by running `xdg-mime`, a shell script that forks
|
||||||
// further. That is blocking work with no upper bound, and this command
|
// 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
|
// 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.
|
// the desktop takes to answer. The Settings page polls this on a timer.
|
||||||
#[cfg(target_os = "linux")]
|
#[cfg(target_os = "linux")]
|
||||||
return blocking(linux::is_default_browser).await;
|
return blocking(linux::is_default_browser).await;
|
||||||
@@ -30,16 +54,22 @@ impl DefaultBrowser {
|
|||||||
Err("Unsupported platform".to_string())
|
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")]
|
#[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")]
|
#[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.
|
// Same reasoning, and this one additionally sleeps 500ms before verifying.
|
||||||
#[cfg(target_os = "linux")]
|
#[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")))]
|
#[cfg(not(any(target_os = "macos", target_os = "windows", target_os = "linux")))]
|
||||||
Err("Unsupported platform".to_string())
|
Err("Unsupported platform".to_string())
|
||||||
@@ -47,7 +77,7 @@ impl DefaultBrowser {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Run blocking work off the async runtime's worker threads.
|
/// 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>
|
async fn blocking<T, F>(work: F) -> Result<T, String>
|
||||||
where
|
where
|
||||||
F: FnOnce() -> Result<T, String> + Send + 'static,
|
F: FnOnce() -> Result<T, String> + Send + 'static,
|
||||||
@@ -124,18 +154,44 @@ mod macos {
|
|||||||
#[cfg(target_os = "windows")]
|
#[cfg(target_os = "windows")]
|
||||||
#[allow(clippy::needless_borrows_for_generic_args)]
|
#[allow(clippy::needless_borrows_for_generic_args)]
|
||||||
mod windows {
|
mod windows {
|
||||||
|
use super::SetDefaultOutcome;
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
use winreg::enums::*;
|
use winreg::enums::*;
|
||||||
use winreg::RegKey;
|
use winreg::RegKey;
|
||||||
|
|
||||||
|
/// The key Windows knows us by. Never shown to a person.
|
||||||
const APP_NAME: &str = "DonutBrowser";
|
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";
|
const PROG_ID: &str = "DonutBrowser.HTML";
|
||||||
|
|
||||||
pub fn is_default_browser() -> Result<bool, String> {
|
/// A web browser registers under `StartMenuInternet`, and
|
||||||
let schemes = ["http", "https"];
|
/// `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 {
|
const URL_SCHEMES: [&str; 2] = ["http", "https"];
|
||||||
// Check if our browser is set as the default handler for this scheme
|
/// 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)? {
|
if !is_default_for_scheme(scheme)? {
|
||||||
return Ok(false);
|
return Ok(false);
|
||||||
}
|
}
|
||||||
@@ -144,44 +200,42 @@ mod windows {
|
|||||||
Ok(true)
|
Ok(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn set_as_default_browser() -> Result<(), String> {
|
pub fn set_as_default_browser() -> Result<SetDefaultOutcome, String> {
|
||||||
// Get the current executable path
|
let exe_path =
|
||||||
let exe_path = std::env::current_exe()
|
std::env::current_exe().map_err(|e| format!("Failed to get current executable path: {e}"))?;
|
||||||
.map_err(|e| format!("Failed to get current executable path: {}", e))?;
|
|
||||||
|
|
||||||
let exe_path_str = exe_path
|
let exe_path = exe_path
|
||||||
.to_str()
|
.to_str()
|
||||||
.ok_or("Failed to convert executable path to string")?;
|
.ok_or("Failed to convert executable path to string")?;
|
||||||
|
|
||||||
// Verify the executable exists
|
if !Path::new(exe_path).exists() {
|
||||||
if !Path::new(exe_path_str).exists() {
|
return Err(format!("Executable not found at: {exe_path}"));
|
||||||
return Err(format!("Executable not found at: {}", exe_path_str));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Register the application
|
let hkcu = RegKey::predef(HKEY_CURRENT_USER);
|
||||||
register_application(exe_path_str)?;
|
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();
|
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> {
|
fn is_default_for_scheme(scheme: &str) -> Result<bool, String> {
|
||||||
let hkcu = RegKey::predef(HKEY_CURRENT_USER);
|
let hkcu = RegKey::predef(HKEY_CURRENT_USER);
|
||||||
|
|
||||||
// Check Software\Microsoft\Windows\Shell\Associations\UrlAssociations\{scheme}\UserChoice
|
let path =
|
||||||
let path = format!(
|
format!(r"Software\Microsoft\Windows\Shell\Associations\UrlAssociations\{scheme}\UserChoice");
|
||||||
"Software\\Microsoft\\Windows\\Shell\\Associations\\UrlAssociations\\{}\\UserChoice",
|
|
||||||
scheme
|
|
||||||
);
|
|
||||||
|
|
||||||
match hkcu.open_subkey(&path) {
|
match hkcu.open_subkey(&path) {
|
||||||
Ok(key) => match key.get_value::<String, _>("ProgId") {
|
Ok(key) => match key.get_value::<String, _>("ProgId") {
|
||||||
@@ -192,167 +246,276 @@ mod windows {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn register_application(exe_path: &str) -> Result<(), String> {
|
/// Delete the layout earlier builds wrote.
|
||||||
let hkcu = RegKey::predef(HKEY_CURRENT_USER);
|
///
|
||||||
|
/// 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
|
for extension in [".htm", ".html"] {
|
||||||
let (registered_apps, _) = hkcu
|
let path = format!(r"Software\Classes\{extension}");
|
||||||
.create_subkey("Software\\RegisteredApplications")
|
let Ok(key) = root.open_subkey_with_flags(&path, KEY_READ | KEY_SET_VALUE) else {
|
||||||
.map_err(|e| format!("Failed to create RegisteredApplications key: {}", e))?;
|
continue;
|
||||||
|
};
|
||||||
|
|
||||||
registered_apps
|
let ours = key
|
||||||
.set_value(APP_NAME, &format!("Software\\{}", APP_NAME))
|
.get_value::<String, _>("")
|
||||||
.map_err(|e| format!("Failed to set registered application: {}", e))?;
|
.map(|value| value == PROG_ID)
|
||||||
|
.unwrap_or(false);
|
||||||
|
|
||||||
// Create application key
|
if ours {
|
||||||
let (app_key, _) = hkcu
|
match key.delete_value("") {
|
||||||
.create_subkey(&format!("Software\\{}", APP_NAME))
|
Ok(()) => log::debug!("Released the {extension} association taken by an older build"),
|
||||||
.map_err(|e| format!("Failed to create application key: {}", e))?;
|
Err(e) => log::debug!("Could not release the {extension} association: {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(())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn register_prog_id(exe_path: &str) -> Result<(), String> {
|
/// Describe the document type Donut opens, and how to open one.
|
||||||
let hkcu = RegKey::predef(HKEY_CURRENT_USER);
|
fn register_prog_id(root: &RegKey, exe_path: &str) -> Result<(), String> {
|
||||||
|
let (prog_id_key, _) = root
|
||||||
// Create ProgID key
|
.create_subkey(format!(r"Software\Classes\{PROG_ID}"))
|
||||||
let (prog_id_key, _) = hkcu
|
.map_err(|e| format!("Failed to create ProgID key: {e}"))?;
|
||||||
.create_subkey(&format!("Software\\Classes\\{}", PROG_ID))
|
|
||||||
.map_err(|e| format!("Failed to create ProgID key: {}", e))?;
|
|
||||||
|
|
||||||
prog_id_key
|
prog_id_key
|
||||||
.set_value("", &"Donut Browser Document")
|
.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
|
prog_id_key
|
||||||
.set_value("FriendlyTypeName", &"Donut Browser Document")
|
.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
|
let (icon_key, _) = prog_id_key
|
||||||
.create_subkey("DefaultIcon")
|
.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
|
icon_key
|
||||||
.set_value("", &format!("\"{}\",0", exe_path))
|
.set_value("", &format!("{},0", quoted(exe_path)))
|
||||||
.map_err(|e| format!("Failed to set default icon: {}", e))?;
|
.map_err(|e| format!("Failed to set default icon: {e}"))?;
|
||||||
|
|
||||||
// Create shell\open\command key
|
|
||||||
let (command_key, _) = prog_id_key
|
let (command_key, _) = prog_id_key
|
||||||
.create_subkey("shell\\open\\command")
|
.create_subkey(r"shell\open\command")
|
||||||
.map_err(|e| format!("Failed to create command key: {}", e))?;
|
.map_err(|e| format!("Failed to create command key: {e}"))?;
|
||||||
|
|
||||||
command_key
|
command_key
|
||||||
.set_value("", &format!("\"{}\" \"%1\"", exe_path))
|
.set_value("", &format!(r#"{} "%1""#, quoted(exe_path)))
|
||||||
.map_err(|e| format!("Failed to set command: {}", e))?;
|
.map_err(|e| format!("Failed to set command: {e}"))?;
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn set_default_for_scheme(scheme: &str) -> Result<(), String> {
|
/// The `StartMenuInternet` entry: the shape the shell reads for a web
|
||||||
let hkcu = RegKey::predef(HKEY_CURRENT_USER);
|
/// 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
|
client
|
||||||
// Note: On Windows 10+, this might require elevated permissions or user interaction
|
.set_value("", &DISPLAY_NAME)
|
||||||
// through the Settings app due to security restrictions
|
.map_err(|e| format!("Failed to set client display name: {e}"))?;
|
||||||
|
|
||||||
// Try to set the association in the user's choice
|
let (icon, _) = client
|
||||||
let user_choice_path = format!(
|
.create_subkey("DefaultIcon")
|
||||||
"Software\\Microsoft\\Windows\\Shell\\Associations\\UrlAssociations\\{}\\UserChoice",
|
.map_err(|e| format!("Failed to create client DefaultIcon key: {e}"))?;
|
||||||
scheme
|
|
||||||
);
|
|
||||||
|
|
||||||
// Note: Setting UserChoice directly may not work on Windows 10+ due to hash verification
|
icon
|
||||||
// The user may need to manually set the default browser through Windows Settings
|
.set_value("", &format!("{},0", quoted(exe_path)))
|
||||||
match hkcu.create_subkey(&user_choice_path) {
|
.map_err(|e| format!("Failed to set client icon: {e}"))?;
|
||||||
Ok((user_choice, _)) => {
|
|
||||||
// Attempt to set the ProgId
|
let (command, _) = client
|
||||||
if user_choice.set_value("ProgId", &PROG_ID).is_err() {
|
.create_subkey(r"shell\open\command")
|
||||||
// If we can't set UserChoice, that's expected on newer Windows versions
|
.map_err(|e| format!("Failed to create client command key: {e}"))?;
|
||||||
// The registration is still valuable for the "Open with" menu
|
|
||||||
}
|
// No `%1` here. This entry is how the shell starts the browser with no
|
||||||
}
|
// document, for example from the Start menu.
|
||||||
Err(_) => {
|
command
|
||||||
// Expected on newer Windows versions - user must set manually
|
.set_value("", "ed(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(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn register_html_file_association(_exe_path: &str) -> Result<(), String> {
|
/// Offer Donut in the "Open with" list for the HTML file types, without
|
||||||
let hkcu = RegKey::predef(HKEY_CURRENT_USER);
|
/// 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
|
// Only the value name matters here. The payload is a marker.
|
||||||
for ext in &[".html", ".htm"] {
|
open_with
|
||||||
let ext_path = format!("Software\\Classes\\{}", ext);
|
.set_value(PROG_ID, &"")
|
||||||
|
.map_err(|e| format!("Failed to register the {extension} handler: {e}"))?;
|
||||||
|
}
|
||||||
|
|
||||||
match hkcu.create_subkey(&ext_path) {
|
Ok(())
|
||||||
Ok((ext_key, _)) => {
|
}
|
||||||
// Set the default value to our ProgID
|
|
||||||
let _ = ext_key.set_value("", &PROG_ID);
|
/// Point `RegisteredApplications` at the capability data. This is what puts
|
||||||
}
|
/// Donut in the list Windows offers under "Default apps".
|
||||||
Err(_) => {
|
fn register_application(root: &RegKey) -> Result<(), String> {
|
||||||
// Continue if we can't set the file association
|
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(())
|
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(®istered).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")]
|
#[cfg(target_os = "linux")]
|
||||||
@@ -551,7 +907,105 @@ pub async fn is_default_browser() -> Result<bool, String> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[command]
|
#[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();
|
let default_browser = DefaultBrowser::instance();
|
||||||
default_browser.set_as_default_browser().await
|
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
@@ -1745,6 +1745,44 @@ fn setup_system_tray(app: &tauri::AppHandle) -> Result<(), Box<dyn std::error::E
|
|||||||
Ok(())
|
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)]
|
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||||
pub fn run() {
|
pub fn run() {
|
||||||
run_with_builder(|builder| builder);
|
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>,
|
configure_builder: impl FnOnce(tauri::Builder<tauri::Wry>) -> tauri::Builder<tauri::Wry>,
|
||||||
) {
|
) {
|
||||||
let args: Vec<String> = env::args().collect();
|
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() {
|
if let Some(url) = startup_url.clone() {
|
||||||
log::info!("Found startup URL in command line");
|
log::info!("Found startup URL in command line");
|
||||||
@@ -1819,6 +1857,20 @@ pub fn run_with_builder(
|
|||||||
let _ = window.set_focus();
|
let _ = window.set_focus();
|
||||||
let _ = window.unminimize();
|
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
|
// Start sync subscription and scheduler if configured
|
||||||
let app_handle_sync = app.handle().clone();
|
let app_handle_sync = app.handle().clone();
|
||||||
tauri::async_runtime::spawn(async move {
|
tauri::async_runtime::spawn(async move {
|
||||||
use std::sync::Arc;
|
sync::start_pipeline(app_handle_sync).await;
|
||||||
|
|
||||||
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");
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Start cloud auth background refresh loop
|
// Start cloud auth background refresh loop
|
||||||
@@ -2965,6 +2973,57 @@ pub fn run_with_builder(
|
|||||||
mod tests {
|
mod tests {
|
||||||
use std::fs;
|
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]
|
#[test]
|
||||||
fn backend_error_helpers_preserve_codes_and_structure_diagnostics() {
|
fn backend_error_helpers_preserve_codes_and_structure_diagnostics() {
|
||||||
let coded = super::backend_error("PROFILE_NOT_FOUND");
|
let coded = super::backend_error("PROFILE_NOT_FOUND");
|
||||||
|
|||||||
@@ -1,5 +1,27 @@
|
|||||||
use super::types::*;
|
use super::types::*;
|
||||||
use reqwest::Client;
|
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)]
|
#[derive(Clone)]
|
||||||
pub struct SyncClient {
|
pub struct SyncClient {
|
||||||
@@ -11,7 +33,15 @@ pub struct SyncClient {
|
|||||||
impl SyncClient {
|
impl SyncClient {
|
||||||
pub fn new(base_url: String, token: String) -> Self {
|
pub fn new(base_url: String, token: String) -> Self {
|
||||||
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(),
|
base_url: base_url.trim_end_matches('/').to_string(),
|
||||||
token,
|
token,
|
||||||
}
|
}
|
||||||
@@ -235,14 +265,13 @@ impl SyncClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// The storage host here comes from the presigned URL, so on a self-hosted
|
// 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
|
// only the server can resolve. `reqwest`'s own Display collapses that to
|
||||||
// "error sending request", which is why this failure used to be
|
// "error sending request", which is why this failure used to be
|
||||||
// undiagnosable; report the innermost cause instead.
|
// undiagnosable; report the innermost cause and the host it names.
|
||||||
let response = req
|
let response = req.send().await.map_err(|e| {
|
||||||
.send()
|
SyncError::NetworkError(super::preflight::transport_reason_for(presigned_url, &e))
|
||||||
.await
|
})?;
|
||||||
.map_err(|e| SyncError::NetworkError(super::preflight::transport_reason(&e)))?;
|
|
||||||
|
|
||||||
if !response.status().is_success() {
|
if !response.status().is_success() {
|
||||||
let status = response.status();
|
let status = response.status();
|
||||||
@@ -256,12 +285,9 @@ impl SyncClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub async fn download_bytes(&self, presigned_url: &str) -> SyncResult<Vec<u8>> {
|
pub async fn download_bytes(&self, presigned_url: &str) -> SyncResult<Vec<u8>> {
|
||||||
let response = self
|
let response = self.client.get(presigned_url).send().await.map_err(|e| {
|
||||||
.client
|
SyncError::NetworkError(super::preflight::transport_reason_for(presigned_url, &e))
|
||||||
.get(presigned_url)
|
})?;
|
||||||
.send()
|
|
||||||
.await
|
|
||||||
.map_err(|e| SyncError::NetworkError(super::preflight::transport_reason(&e)))?;
|
|
||||||
|
|
||||||
if !response.status().is_success() {
|
if !response.status().is_success() {
|
||||||
return Err(SyncError::NetworkError(format!(
|
return Err(SyncError::NetworkError(format!(
|
||||||
|
|||||||
@@ -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
|
/// 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
|
/// 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`
|
/// 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
|
/// and `/readyz` stay green. The cause string names the host, which says which
|
||||||
/// this line it still reads as an unexplained network fault, and the setting
|
/// address is wrong; this line says where to change it, because the setting
|
||||||
/// that fixes it lives on the server, where the user is not looking.
|
/// 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 {
|
fn storage_endpoint_hint(cause: &str) -> String {
|
||||||
let lowered = cause.to_ascii_lowercase();
|
let lowered = cause.to_ascii_lowercase();
|
||||||
let is_transport_failure = [
|
let is_transport_failure = [
|
||||||
@@ -4321,6 +4326,106 @@ pub async fn rollover_encryption_for_all_entities(
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
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]
|
#[test]
|
||||||
fn test_critical_failure_message_carries_the_cause() {
|
fn test_critical_failure_message_carries_the_cause() {
|
||||||
// A self-hosted server that hands out unreachable presigned URLs fails
|
// A self-hosted server that hands out unreachable presigned URLs fails
|
||||||
|
|||||||
@@ -31,6 +31,99 @@ pub use scheduler::{get_global_scheduler, set_global_scheduler, SyncScheduler};
|
|||||||
pub use subscription::{SubscriptionManager, SyncWorkItem};
|
pub use subscription::{SubscriptionManager, SyncWorkItem};
|
||||||
pub use types::{SyncError, SyncResult};
|
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.
|
/// 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
|
/// Called from profile metadata update paths so a rename / tag edit / proxy
|
||||||
|
|||||||
@@ -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.
|
/// Pre-flight a sync server before saving it, and before trusting it to sync.
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn check_sync_server_connection(server_url: String) -> Result<SyncServerCheck, String> {
|
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.
|
// The bare reqwest Display is what this exists to avoid.
|
||||||
assert_ne!(error, "error sending request");
|
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
@@ -8,7 +8,6 @@ use std::sync::Arc;
|
|||||||
use std::time::{Duration, Instant};
|
use std::time::{Duration, Instant};
|
||||||
use tokio::sync::mpsc;
|
use tokio::sync::mpsc;
|
||||||
use tokio::sync::Mutex;
|
use tokio::sync::Mutex;
|
||||||
use tokio::time::sleep;
|
|
||||||
|
|
||||||
static GLOBAL_SCHEDULER: std::sync::Mutex<Option<Arc<SyncScheduler>>> = std::sync::Mutex::new(None);
|
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)]
|
#[derive(Debug, Clone)]
|
||||||
struct ProfileStopTime {
|
struct ProfileStopTime {
|
||||||
#[allow(dead_code)]
|
#[allow(dead_code)]
|
||||||
@@ -31,6 +41,15 @@ struct ProfileStopTime {
|
|||||||
|
|
||||||
pub struct SyncScheduler {
|
pub struct SyncScheduler {
|
||||||
running: Arc<AtomicBool>,
|
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_profiles: Arc<Mutex<HashMap<String, ProfileStopTime>>>,
|
||||||
pending_proxies: Arc<Mutex<HashSet<String>>>,
|
pending_proxies: Arc<Mutex<HashSet<String>>>,
|
||||||
pending_groups: Arc<Mutex<HashSet<String>>>,
|
pending_groups: Arc<Mutex<HashSet<String>>>,
|
||||||
@@ -52,6 +71,7 @@ impl SyncScheduler {
|
|||||||
pub fn new() -> Self {
|
pub fn new() -> Self {
|
||||||
Self {
|
Self {
|
||||||
running: Arc::new(AtomicBool::new(false)),
|
running: Arc::new(AtomicBool::new(false)),
|
||||||
|
cancelled: Arc::new(AtomicBool::new(false)),
|
||||||
pending_profiles: Arc::new(Mutex::new(HashMap::new())),
|
pending_profiles: Arc::new(Mutex::new(HashMap::new())),
|
||||||
pending_proxies: Arc::new(Mutex::new(HashSet::new())),
|
pending_proxies: Arc::new(Mutex::new(HashSet::new())),
|
||||||
pending_groups: 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)
|
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) {
|
pub fn stop(&self) {
|
||||||
|
self.cancelled.store(true, Ordering::SeqCst);
|
||||||
self.running.store(false, 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(
|
pub async fn start(
|
||||||
self: Arc<Self>,
|
self: Arc<Self>,
|
||||||
app_handle: tauri::AppHandle,
|
app_handle: tauri::AppHandle,
|
||||||
mut work_rx: mpsc::UnboundedReceiver<SyncWorkItem>,
|
mut work_rx: mpsc::UnboundedReceiver<SyncWorkItem>,
|
||||||
) {
|
) -> bool {
|
||||||
if self.running.swap(true, Ordering::SeqCst) {
|
match self.claim_start_slot() {
|
||||||
return;
|
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 scheduler = self.clone();
|
||||||
let app_handle_clone = app_handle.clone();
|
let app_handle_clone = app_handle.clone();
|
||||||
|
|
||||||
tokio::spawn(async move {
|
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) {
|
while scheduler.running.load(Ordering::SeqCst) {
|
||||||
tokio::select! {
|
tokio::select! {
|
||||||
Some(work_item) = work_rx.recv() => {
|
received = work_rx.recv(), if work_channel_open => {
|
||||||
match work_item {
|
match received {
|
||||||
SyncWorkItem::Profile(id) => scheduler.queue_profile_sync(id).await,
|
Some(work_item) => match work_item {
|
||||||
SyncWorkItem::Proxy(id) => scheduler.queue_proxy_sync(id).await,
|
SyncWorkItem::Profile(id) => scheduler.queue_profile_sync(id).await,
|
||||||
SyncWorkItem::Group(id) => scheduler.queue_group_sync(id).await,
|
SyncWorkItem::Proxy(id) => scheduler.queue_proxy_sync(id).await,
|
||||||
SyncWorkItem::Vpn(id) => scheduler.queue_vpn_sync(id).await,
|
SyncWorkItem::Group(id) => scheduler.queue_group_sync(id).await,
|
||||||
SyncWorkItem::Extension(id) => scheduler.queue_extension_sync(id).await,
|
SyncWorkItem::Vpn(id) => scheduler.queue_vpn_sync(id).await,
|
||||||
SyncWorkItem::ExtensionGroup(id) => scheduler.queue_extension_group_sync(id).await,
|
SyncWorkItem::Extension(id) => scheduler.queue_extension_sync(id).await,
|
||||||
SyncWorkItem::Tombstone(entity_type, entity_id) => {
|
SyncWorkItem::ExtensionGroup(id) => scheduler.queue_extension_group_sync(id).await,
|
||||||
scheduler.queue_tombstone(entity_type, entity_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;
|
scheduler.process_pending(&app_handle_clone).await;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -370,6 +453,8 @@ impl SyncScheduler {
|
|||||||
|
|
||||||
log::info!("Sync scheduler stopped");
|
log::info!("Sync scheduler stopped");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
true
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn process_pending(&self, app_handle: &tauri::AppHandle) {
|
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,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://schema.tauri.app/config/2",
|
"$schema": "https://schema.tauri.app/config/2",
|
||||||
"productName": "Donut",
|
"productName": "Donut",
|
||||||
"version": "0.29.6",
|
"version": "0.30.0",
|
||||||
"identifier": "com.donutbrowser",
|
"identifier": "com.donutbrowser",
|
||||||
"build": {
|
"build": {
|
||||||
"beforeDevCommand": "pnpm copy-proxy-binary && pnpm dev",
|
"beforeDevCommand": "pnpm copy-proxy-binary && pnpm dev",
|
||||||
|
|||||||
@@ -62,6 +62,7 @@ import {
|
|||||||
} from "@/lib/themes";
|
} from "@/lib/themes";
|
||||||
import { showErrorToast, showSuccessToast } from "@/lib/toast-utils";
|
import { showErrorToast, showSuccessToast } from "@/lib/toast-utils";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
import type { SetDefaultBrowserOutcome } from "@/types";
|
||||||
import { RippleButton } from "./ui/ripple";
|
import { RippleButton } from "./ui/ripple";
|
||||||
|
|
||||||
interface AppSettings {
|
interface AppSettings {
|
||||||
@@ -401,14 +402,39 @@ export function SettingsDialog({
|
|||||||
const handleSetDefaultBrowser = useCallback(async () => {
|
const handleSetDefaultBrowser = useCallback(async () => {
|
||||||
setIsSettingDefault(true);
|
setIsSettingDefault(true);
|
||||||
try {
|
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();
|
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) {
|
} 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 {
|
} finally {
|
||||||
setIsSettingDefault(false);
|
setIsSettingDefault(false);
|
||||||
}
|
}
|
||||||
}, [checkDefaultBrowserStatus]);
|
}, [checkDefaultBrowserStatus, t]);
|
||||||
|
|
||||||
const handleClearTraffic = useCallback(async () => {
|
const handleClearTraffic = useCallback(async () => {
|
||||||
setIsClearingTraffic(true);
|
setIsClearingTraffic(true);
|
||||||
|
|||||||
@@ -134,7 +134,11 @@
|
|||||||
"title": "Default Browser",
|
"title": "Default Browser",
|
||||||
"setAsDefault": "Set as Default Browser",
|
"setAsDefault": "Set as Default Browser",
|
||||||
"alreadyDefault": "Already 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": {
|
"permissions": {
|
||||||
"title": "System Permissions",
|
"title": "System Permissions",
|
||||||
|
|||||||
@@ -134,7 +134,11 @@
|
|||||||
"title": "Navegador Predeterminado",
|
"title": "Navegador Predeterminado",
|
||||||
"setAsDefault": "Establecer como Navegador Predeterminado",
|
"setAsDefault": "Establecer como Navegador Predeterminado",
|
||||||
"alreadyDefault": "Ya es el 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": {
|
"permissions": {
|
||||||
"title": "Permisos del Sistema",
|
"title": "Permisos del Sistema",
|
||||||
|
|||||||
@@ -134,7 +134,11 @@
|
|||||||
"title": "Navigateur par défaut",
|
"title": "Navigateur par défaut",
|
||||||
"setAsDefault": "Définir comme navigateur par défaut",
|
"setAsDefault": "Définir comme navigateur par défaut",
|
||||||
"alreadyDefault": "Déjà le 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": {
|
"permissions": {
|
||||||
"title": "Permissions système",
|
"title": "Permissions système",
|
||||||
|
|||||||
@@ -134,7 +134,11 @@
|
|||||||
"title": "デフォルトブラウザ",
|
"title": "デフォルトブラウザ",
|
||||||
"setAsDefault": "デフォルトブラウザに設定",
|
"setAsDefault": "デフォルトブラウザに設定",
|
||||||
"alreadyDefault": "既にデフォルトブラウザです",
|
"alreadyDefault": "既にデフォルトブラウザです",
|
||||||
"description": "デフォルトに設定すると、Donut Browser がウェブリンクを処理し、使用するプロファイルを選択できます。"
|
"description": "デフォルトに設定すると、Donut Browser がウェブリンクを処理し、使用するプロファイルを選択できます。",
|
||||||
|
"setSuccess": "Donut Browser が既定のブラウザーになりました",
|
||||||
|
"setFailed": "既定のブラウザーを設定できませんでした",
|
||||||
|
"finishInSystemSettings": "Windows の設定で完了してください",
|
||||||
|
"finishInSystemSettingsDescription": "Donut Browser を登録しました。Windows の設定が開いています。「Web ブラウザー」で Donut Browser を選ぶと完了します。"
|
||||||
},
|
},
|
||||||
"permissions": {
|
"permissions": {
|
||||||
"title": "システム権限",
|
"title": "システム権限",
|
||||||
|
|||||||
@@ -134,7 +134,11 @@
|
|||||||
"title": "기본 브라우저",
|
"title": "기본 브라우저",
|
||||||
"setAsDefault": "기본 브라우저로 설정",
|
"setAsDefault": "기본 브라우저로 설정",
|
||||||
"alreadyDefault": "이미 기본 브라우저입니다",
|
"alreadyDefault": "이미 기본 브라우저입니다",
|
||||||
"description": "기본 브라우저로 설정하면 Donut Browser가 웹 링크를 처리하고 사용할 프로필을 선택할 수 있습니다."
|
"description": "기본 브라우저로 설정하면 Donut Browser가 웹 링크를 처리하고 사용할 프로필을 선택할 수 있습니다.",
|
||||||
|
"setSuccess": "이제 Donut Browser가 기본 브라우저입니다",
|
||||||
|
"setFailed": "기본 브라우저를 설정하지 못했습니다",
|
||||||
|
"finishInSystemSettings": "Windows 설정에서 완료하세요",
|
||||||
|
"finishInSystemSettingsDescription": "Donut Browser가 등록되었습니다. Windows 설정이 열려 있습니다. '웹 브라우저'에서 Donut Browser를 선택하면 완료됩니다."
|
||||||
},
|
},
|
||||||
"permissions": {
|
"permissions": {
|
||||||
"title": "시스템 권한",
|
"title": "시스템 권한",
|
||||||
|
|||||||
@@ -134,7 +134,11 @@
|
|||||||
"title": "Navegador Padrão",
|
"title": "Navegador Padrão",
|
||||||
"setAsDefault": "Definir como Navegador Padrão",
|
"setAsDefault": "Definir como Navegador Padrão",
|
||||||
"alreadyDefault": "Já é o 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": {
|
"permissions": {
|
||||||
"title": "Permissões do Sistema",
|
"title": "Permissões do Sistema",
|
||||||
|
|||||||
@@ -134,7 +134,11 @@
|
|||||||
"title": "Браузер по умолчанию",
|
"title": "Браузер по умолчанию",
|
||||||
"setAsDefault": "Установить браузером по умолчанию",
|
"setAsDefault": "Установить браузером по умолчанию",
|
||||||
"alreadyDefault": "Уже браузер по умолчанию",
|
"alreadyDefault": "Уже браузер по умолчанию",
|
||||||
"description": "При установке по умолчанию Donut Browser будет обрабатывать веб-ссылки и позволит выбрать профиль для использования."
|
"description": "При установке по умолчанию Donut Browser будет обрабатывать веб-ссылки и позволит выбрать профиль для использования.",
|
||||||
|
"setSuccess": "Donut Browser теперь браузер по умолчанию",
|
||||||
|
"setFailed": "Не удалось назначить браузер по умолчанию",
|
||||||
|
"finishInSystemSettings": "Завершите в параметрах Windows",
|
||||||
|
"finishInSystemSettingsDescription": "Donut Browser зарегистрирован. Параметры Windows открыты: выберите Donut Browser в разделе «Веб-браузер», чтобы завершить."
|
||||||
},
|
},
|
||||||
"permissions": {
|
"permissions": {
|
||||||
"title": "Системные разрешения",
|
"title": "Системные разрешения",
|
||||||
|
|||||||
@@ -134,7 +134,11 @@
|
|||||||
"title": "Varsayılan Tarayıcı",
|
"title": "Varsayılan Tarayıcı",
|
||||||
"setAsDefault": "Varsayılan Tarayıcı Olarak Ayarla",
|
"setAsDefault": "Varsayılan Tarayıcı Olarak Ayarla",
|
||||||
"alreadyDefault": "Zaten Varsayılan Tarayıcı",
|
"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": {
|
"permissions": {
|
||||||
"title": "Sistem İzinleri",
|
"title": "Sistem İzinleri",
|
||||||
|
|||||||
@@ -134,7 +134,11 @@
|
|||||||
"title": "Trình duyệt mặc định",
|
"title": "Trình duyệt mặc định",
|
||||||
"setAsDefault": "Đặt làm 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",
|
"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": {
|
"permissions": {
|
||||||
"title": "Quyền hệ thống",
|
"title": "Quyền hệ thống",
|
||||||
|
|||||||
@@ -134,7 +134,11 @@
|
|||||||
"title": "默认浏览器",
|
"title": "默认浏览器",
|
||||||
"setAsDefault": "设为默认浏览器",
|
"setAsDefault": "设为默认浏览器",
|
||||||
"alreadyDefault": "已是默认浏览器",
|
"alreadyDefault": "已是默认浏览器",
|
||||||
"description": "设为默认后,Donut Browser 将处理网页链接并允许您选择使用哪个配置文件。"
|
"description": "设为默认后,Donut Browser 将处理网页链接并允许您选择使用哪个配置文件。",
|
||||||
|
"setSuccess": "Donut Browser 现在是您的默认浏览器",
|
||||||
|
"setFailed": "无法设置默认浏览器",
|
||||||
|
"finishInSystemSettings": "请在 Windows 设置中完成",
|
||||||
|
"finishInSystemSettingsDescription": "Donut Browser 已注册。Windows 设置已打开:在“Web 浏览器”中选择 Donut Browser 即可完成。"
|
||||||
},
|
},
|
||||||
"permissions": {
|
"permissions": {
|
||||||
"title": "系统权限",
|
"title": "系统权限",
|
||||||
|
|||||||
@@ -818,3 +818,16 @@ export interface PreLaunchChecks {
|
|||||||
exit_measurement_unreliable: boolean;
|
exit_measurement_unreliable: boolean;
|
||||||
consent_token: string | null;
|
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" };
|
||||||
|
|||||||
Reference in New Issue
Block a user