mirror of
https://github.com/zhom/donutbrowser.git
synced 2026-07-17 18:07:23 +02:00
Compare commits
14 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7b7849a54a | |||
| cb0ec75d37 | |||
| ae8afbb158 | |||
| 53db00a85a | |||
| eeb5c816bf | |||
| 86d58717b4 | |||
| 06e34527b6 | |||
| 97f1f52a6d | |||
| f95e6332fa | |||
| 86671ceed6 | |||
| 0e5a4608d7 | |||
| 745a4da17c | |||
| 435092de30 | |||
| 9d5983cf55 |
@@ -603,7 +603,7 @@ jobs:
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 #v7.0.0
|
||||
|
||||
- name: Run opencode
|
||||
uses: anomalyco/opencode/github@10c894bdeef3618f5666fb506ef7f9491bb964d8 #v1.17.13
|
||||
uses: anomalyco/opencode/github@b1fc8113948b518835c2a39ece49553cffe9b30c #v1.17.18
|
||||
env:
|
||||
ZHIPU_API_KEY: ${{ secrets.ZHIPU_API_KEY }}
|
||||
TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
@@ -280,6 +280,29 @@ jobs:
|
||||
security delete-keychain $RUNNER_TEMP/app-signing.keychain-db || true
|
||||
rm -f $RUNNER_TEMP/build_certificate.p12 || true
|
||||
|
||||
# Runs after every matrix leg (including the portable ZIP upload) so the
|
||||
# sums cover the complete, final asset set. The app self-updater refuses to
|
||||
# install a release it cannot verify against this file.
|
||||
checksums:
|
||||
if: github.repository == 'zhom/donutbrowser'
|
||||
needs: [release]
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- name: Generate and upload SHA256SUMS.txt
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
TAG: ${{ github.ref_name }}
|
||||
run: |
|
||||
ASSETS_DIR="/tmp/release-assets"
|
||||
mkdir -p "$ASSETS_DIR"
|
||||
gh release download "$TAG" --repo "$GITHUB_REPOSITORY" --dir "$ASSETS_DIR"
|
||||
cd "$ASSETS_DIR"
|
||||
sha256sum Donut* > SHA256SUMS.txt
|
||||
cat SHA256SUMS.txt
|
||||
gh release upload "$TAG" SHA256SUMS.txt --clobber --repo "$GITHUB_REPOSITORY"
|
||||
|
||||
changelog:
|
||||
if: github.repository == 'zhom/donutbrowser'
|
||||
needs: [release]
|
||||
|
||||
@@ -5,6 +5,14 @@ on:
|
||||
branches:
|
||||
- main
|
||||
|
||||
# Serialize runs: the rolling `nightly` release is deleted and recreated at the
|
||||
# end of each run, and overlapping runs could interleave those steps (or leave
|
||||
# a checksums file describing another run's assets). Queue instead of cancel so
|
||||
# an in-flight delete/create is never aborted halfway.
|
||||
concurrency:
|
||||
group: rolling-release
|
||||
cancel-in-progress: false
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
security-events: write
|
||||
@@ -210,7 +218,11 @@ jobs:
|
||||
id: timestamp
|
||||
shell: bash
|
||||
run: |
|
||||
TIMESTAMP=$(date -u +"%Y-%m-%d")
|
||||
# Committer date, not wall clock: every job in this run (including
|
||||
# update-nightly-release, which runs much later) must derive the
|
||||
# exact same tag, or a run straddling midnight UTC splits the
|
||||
# release from its checksums.
|
||||
TIMESTAMP=$(git show -s --format=%cs HEAD)
|
||||
COMMIT_HASH=$(echo "${GITHUB_SHA}" | cut -c1-7)
|
||||
echo "timestamp=${TIMESTAMP}-${COMMIT_HASH}" >> $GITHUB_OUTPUT
|
||||
echo "Generated timestamp: ${TIMESTAMP}-${COMMIT_HASH}"
|
||||
@@ -289,7 +301,9 @@ jobs:
|
||||
- name: Generate nightly tag
|
||||
id: tag
|
||||
run: |
|
||||
TIMESTAMP=$(date -u +"%Y-%m-%d")
|
||||
# Committer date — must match the tag the build matrix computed (see
|
||||
# the timestamp step there), even when this job runs past midnight.
|
||||
TIMESTAMP=$(git show -s --format=%cs HEAD)
|
||||
COMMIT_HASH=$(echo "${GITHUB_SHA}" | cut -c1-7)
|
||||
echo "nightly_tag=nightly-${TIMESTAMP}-${COMMIT_HASH}" >> $GITHUB_OUTPUT
|
||||
|
||||
@@ -355,8 +369,16 @@ jobs:
|
||||
mkdir -p "$ASSETS_DIR"
|
||||
gh release download "$NIGHTLY_TAG" --dir "$ASSETS_DIR" --clobber
|
||||
|
||||
# Rename versioned filenames to stable nightly names
|
||||
# Checksums for the per-commit release (original filenames). The app
|
||||
# self-updater downloads from per-commit nightly releases and refuses
|
||||
# to install anything it cannot verify against this file.
|
||||
# --repo is required: ASSETS_DIR is outside the git checkout, so gh
|
||||
# cannot infer the repository from the working directory.
|
||||
cd "$ASSETS_DIR"
|
||||
sha256sum Donut* > SHA256SUMS.txt
|
||||
gh release upload "$NIGHTLY_TAG" SHA256SUMS.txt --clobber --repo "$GITHUB_REPOSITORY"
|
||||
|
||||
# Rename versioned filenames to stable nightly names
|
||||
for f in Donut_*_aarch64.dmg; do [ -f "$f" ] && mv "$f" Donut_nightly_aarch64.dmg; done
|
||||
for f in Donut_*_x64.dmg; do [ -f "$f" ] && mv "$f" Donut_nightly_x64.dmg; done
|
||||
for f in Donut_*_x64-setup.exe; do [ -f "$f" ] && mv "$f" Donut_nightly_x64-setup.exe; done
|
||||
@@ -368,6 +390,10 @@ jobs:
|
||||
for f in Donut-*.aarch64.rpm; do [ -f "$f" ] && mv "$f" Donut_nightly_aarch64.rpm; done
|
||||
for f in Donut_*_aarch64.app.tar.gz; do [ -f "$f" ] && mv "$f" Donut_aarch64.app.tar.gz; done
|
||||
for f in Donut_*_x64.app.tar.gz; do [ -f "$f" ] && mv "$f" Donut_x64.app.tar.gz; done
|
||||
|
||||
# Checksums for the rolling release (renamed filenames), restricted
|
||||
# to exactly the assets uploaded below.
|
||||
sha256sum Donut_nightly_* Donut_aarch64.app.tar.gz Donut_x64.app.tar.gz > SHA256SUMS.txt
|
||||
cd "$GITHUB_WORKSPACE"
|
||||
|
||||
# Delete existing rolling nightly release and tag
|
||||
@@ -379,6 +405,7 @@ jobs:
|
||||
"$ASSETS_DIR"/Donut_nightly_* \
|
||||
"$ASSETS_DIR"/Donut_aarch64.app.tar.gz \
|
||||
"$ASSETS_DIR"/Donut_x64.app.tar.gz \
|
||||
"$ASSETS_DIR"/SHA256SUMS.txt \
|
||||
--title "Donut Browser Nightly" \
|
||||
--notes-file /tmp/nightly-notes.md \
|
||||
--prerelease
|
||||
|
||||
@@ -13,7 +13,7 @@ jobs:
|
||||
pull-requests: write
|
||||
|
||||
steps:
|
||||
- uses: actions/stale@eb5cf3af3ac0a1aa4c9c45633dd1ae542a27a899 # v10.3.0
|
||||
- uses: actions/stale@1e223db275d687790206a7acac4d1a11bd6fe629 # v10.4.0
|
||||
with:
|
||||
repo-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
stale-issue-message: "This issue has been inactive for 30 days. Please respond to keep it open."
|
||||
|
||||
@@ -102,6 +102,30 @@ User-facing errors returned from a Tauri command MUST be JSON `{ "code": "FOO_BA
|
||||
|
||||
Raw error strings reach the user untranslated; that's the bug pattern this rule blocks.
|
||||
|
||||
## REST API (`src-tauri/src/api_server.rs`) — endpoints must stay in the OpenAPI spec
|
||||
|
||||
The served `/openapi.json` comes from the hand-maintained `ApiDoc` derive (`#[derive(OpenApi)]` with `paths(...)`, `components(schemas(...))`, `tags(...)`) — NOT from the router. The `OpenApiRouter`-generated spec is discarded (`let (v1_routes, _) = ...`), so a handler registered on the router but missing from `ApiDoc` silently disappears from the spec (this happened to the extension and VPN-export endpoints once).
|
||||
|
||||
**Any endpoint modification — adding, removing, or changing a route, request/response schema, or status code — must be reflected in the OpenAPI spec in the same change:**
|
||||
|
||||
1. Keep the handler's `#[utoipa::path]` annotation accurate (path, request body, every reachable response status).
|
||||
2. Add/remove the handler in `ApiDoc`'s `paths(...)` list and any new schema types in `components(schemas(...))`.
|
||||
3. Extend the `openapi_*` regression tests in `api_server.rs::tests` (they assert spec coverage and that optional fields stay optional).
|
||||
4. `#[schema(value_type = Object)]` on an `Option<T>` field erases the optionality and wrongly marks it required — use `value_type = Option<Object>` (or drop the attribute for natively supported types).
|
||||
|
||||
### Error status conventions (known errors)
|
||||
|
||||
Handlers route manager errors through `manager_error_response`, which maps message content onto a consistent status and passes the text through as the response body:
|
||||
|
||||
- `401` — missing/invalid bearer token (auth middleware; empty body).
|
||||
- `402` — the five automation endpoints (`run`, `open-url`, `kill`, `batch/run`, `batch/stop`) without a paid plan, and expired-proxy (`PROXY_PAYMENT_REQUIRED`) checks.
|
||||
- `404` — entity not found (`… not found` / `*_NOT_FOUND`).
|
||||
- `400` — validation, duplicates, empty names, invalid/unsupported/unavailable input.
|
||||
- `409` — conflicts: browser version already being downloaded, profile locked by another team member (run), browser running during cookie import.
|
||||
- `500` — internal failures (IO, network, poisoned locks).
|
||||
|
||||
Error bodies are plain-text diagnostics; some are the JSON `{"code": ...}` strings shared with the Tauri commands (e.g. `NAME_CANNOT_BE_EMPTY`, `GROUP_ALREADY_EXISTS`). The translated-error rule above applies to Tauri commands, not to REST bodies.
|
||||
|
||||
## Sub-page Dialog mode
|
||||
|
||||
A `<Dialog>` becomes a first-class app sub-page (no modal overlay, no center positioning) when `subPage` is passed. Pages like Account, Settings, Proxy Management, and Extension Management use this. The pattern for a sub-page with tabs:
|
||||
|
||||
@@ -1,6 +1,67 @@
|
||||
# Changelog
|
||||
|
||||
|
||||
## v0.28.1 (2026-07-09)
|
||||
|
||||
### Refactoring
|
||||
|
||||
- do not use system proxy on windows
|
||||
|
||||
### Maintenance
|
||||
|
||||
- chore: version bump
|
||||
- chore: update flake.nix for v0.28.0 [skip ci] (#490)
|
||||
|
||||
|
||||
## v0.28.0 (2026-07-08)
|
||||
|
||||
### Features
|
||||
|
||||
- ipv6 support for wireguard
|
||||
- per-profile window color with id-derived default
|
||||
- emit extension sync-status events
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- background status/update loop and window-color command
|
||||
- sync engine correctness and manifest traversal guard
|
||||
- replace create-profile Back button with Close
|
||||
- don't start window drag on interactive controls
|
||||
- self-reap proxy worker off-runtime and redact upstream creds in logs
|
||||
- resolve VPN SOCKS5 domain CONNECT requests through the tunnel
|
||||
- persist imported session cookies so logins survive relaunch
|
||||
|
||||
### Refactoring
|
||||
|
||||
- handle newer wayfern versions
|
||||
- fully deprecate camoufox
|
||||
- cleanup
|
||||
- better handling of unstable connection during asset downloads
|
||||
- backend-authoritative team scope and config/input hardening
|
||||
|
||||
### Documentation
|
||||
|
||||
- readme
|
||||
- agents
|
||||
|
||||
### Maintenance
|
||||
|
||||
- chore: version bump
|
||||
- chore: rename macos artifacts in ci
|
||||
- chore: lint
|
||||
- chore: copy
|
||||
- chore: linux ci
|
||||
- chore: update dependencies
|
||||
- chore: migrate biome config and exclude build dirs
|
||||
- ci(deps): bump the github-actions group with 6 updates
|
||||
- ci(deps): bump anomalyco/opencode/github in the github-actions group (#480)
|
||||
- chore: update flake.nix for v0.27.1 [skip ci] (#464)
|
||||
|
||||
### Other
|
||||
|
||||
- security: restrict secret files to owner-only (0600)
|
||||
|
||||
|
||||
## v0.27.1 (2026-06-24)
|
||||
|
||||
### Features
|
||||
|
||||
@@ -46,7 +46,7 @@
|
||||
|
||||
| | Apple Silicon | Intel |
|
||||
|---|---|---|
|
||||
| **DMG** | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.27.1/Donut_0.27.1_aarch64.dmg) | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.27.1/Donut_0.27.1_x64.dmg) |
|
||||
| **DMG** | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.28.1/Donut_0.28.1_aarch64.dmg) | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.28.1/Donut_0.28.1_x64.dmg) |
|
||||
|
||||
Or install via Homebrew:
|
||||
|
||||
@@ -56,15 +56,15 @@ brew install --cask donut
|
||||
|
||||
### Windows
|
||||
|
||||
[Download Windows Installer (x64)](https://github.com/zhom/donutbrowser/releases/download/v0.27.1/Donut_0.27.1_x64-setup.exe) · [Portable (x64)](https://github.com/zhom/donutbrowser/releases/download/v0.27.1/Donut_0.27.1_x64-portable.zip)
|
||||
[Download Windows Installer (x64)](https://github.com/zhom/donutbrowser/releases/download/v0.28.1/Donut_0.28.1_x64-setup.exe) · [Portable (x64)](https://github.com/zhom/donutbrowser/releases/download/v0.28.1/Donut_0.28.1_x64-portable.zip)
|
||||
|
||||
### Linux
|
||||
|
||||
| Format | x86_64 | ARM64 |
|
||||
|---|---|---|
|
||||
| **deb** | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.27.1/Donut_0.27.1_amd64.deb) | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.27.1/Donut_0.27.1_arm64.deb) |
|
||||
| **rpm** | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.27.1/Donut-0.27.1-1.x86_64.rpm) | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.27.1/Donut-0.27.1-1.aarch64.rpm) |
|
||||
| **AppImage** | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.27.1/Donut_0.27.1_amd64.AppImage) | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.27.1/Donut_0.27.1_aarch64.AppImage) |
|
||||
| **deb** | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.28.1/Donut_0.28.1_amd64.deb) | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.28.1/Donut_0.28.1_arm64.deb) |
|
||||
| **rpm** | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.28.1/Donut-0.28.1-1.x86_64.rpm) | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.28.1/Donut-0.28.1-1.aarch64.rpm) |
|
||||
| **AppImage** | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.28.1/Donut_0.28.1_amd64.AppImage) | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.28.1/Donut_0.28.1_aarch64.AppImage) |
|
||||
<!-- install-links-end -->
|
||||
|
||||
Or install via package manager:
|
||||
|
||||
@@ -96,17 +96,17 @@
|
||||
pkgConfigPath = lib.makeSearchPath "lib/pkgconfig" (
|
||||
pkgConfigLibs ++ map lib.getDev pkgConfigLibs
|
||||
);
|
||||
releaseVersion = "0.27.1";
|
||||
releaseVersion = "0.28.1";
|
||||
releaseAppImage =
|
||||
if system == "x86_64-linux" then
|
||||
pkgs.fetchurl {
|
||||
url = "https://github.com/zhom/donutbrowser/releases/download/v0.27.1/Donut_0.27.1_amd64.AppImage";
|
||||
hash = "sha256-TrqCu+P3Gy39hmg77U/jCn6uV06bZyj143Q5TFSDc/w=";
|
||||
url = "https://github.com/zhom/donutbrowser/releases/download/v0.28.1/Donut_0.28.1_amd64.AppImage";
|
||||
hash = "sha256-ItjyK206mpwMXzTNnSHHM8L2R8EjvEP3mrKnz6ze4oI=";
|
||||
}
|
||||
else if system == "aarch64-linux" then
|
||||
pkgs.fetchurl {
|
||||
url = "https://github.com/zhom/donutbrowser/releases/download/v0.27.1/Donut_0.27.1_aarch64.AppImage";
|
||||
hash = "sha256-iubnx2VnF/3yywdhJICD8g7bQMP8yh4yCs+HLmrUYAI=";
|
||||
url = "https://github.com/zhom/donutbrowser/releases/download/v0.28.1/Donut_0.28.1_aarch64.AppImage";
|
||||
hash = "sha256-ZdQKOJRwVB9Wb/ncC8j9ezoaOqPuj0zjuDB3N3r2JYY=";
|
||||
}
|
||||
else
|
||||
null;
|
||||
|
||||
+1
-1
@@ -2,7 +2,7 @@
|
||||
"name": "donutbrowser",
|
||||
"private": true,
|
||||
"license": "AGPL-3.0",
|
||||
"version": "0.28.0",
|
||||
"version": "0.28.2",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "next dev --turbopack -p 12341",
|
||||
|
||||
Generated
+1
-1
@@ -1797,7 +1797,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "donutbrowser"
|
||||
version = "0.28.0"
|
||||
version = "0.28.2"
|
||||
dependencies = [
|
||||
"aes 0.9.1",
|
||||
"aes-gcm 0.11.0",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "donutbrowser"
|
||||
version = "0.28.0"
|
||||
version = "0.28.2"
|
||||
description = "Simple Yet Powerful Anti-Detect Browser"
|
||||
authors = ["zhom@github"]
|
||||
edition = "2021"
|
||||
|
||||
@@ -278,7 +278,7 @@ impl ApiClient {
|
||||
{
|
||||
Ok(response) => {
|
||||
if !response.status().is_success() {
|
||||
last_err = Some(format!("HTTP {}", response.status()));
|
||||
last_err = Some(format!("HTTP {}", response.status().as_u16()));
|
||||
} else {
|
||||
match response.json::<WayfernVersionInfo>().await {
|
||||
Ok(info) => {
|
||||
|
||||
+247
-162
@@ -55,9 +55,8 @@ pub struct ApiProfileResponse {
|
||||
#[derive(Debug, Serialize, Deserialize, ToSchema)]
|
||||
pub struct CreateProfileRequest {
|
||||
pub name: String,
|
||||
/// Browser engine. Must be `"wayfern"` (anti-detect Chromium)
|
||||
/// (anti-detect Firefox). Any other value (e.g. `"chromium"`) is rejected with
|
||||
/// 400.
|
||||
/// Browser engine. Must be `"wayfern"` (anti-detect Chromium). Any other
|
||||
/// value (e.g. `"chromium"`) is rejected with 400.
|
||||
pub browser: String,
|
||||
/// Optional. Omit (or pass `"latest"`) to use the newest already-downloaded
|
||||
/// version of the chosen browser. A concrete version must already be
|
||||
@@ -72,12 +71,7 @@ pub struct CreateProfileRequest {
|
||||
/// Omit it, or pass an empty object `{}`, to have a fresh fingerprint
|
||||
/// generated automatically at creation. Provide a `fingerprint` field to
|
||||
/// pin a specific one.
|
||||
#[schema(value_type = Object)]
|
||||
/// Wayfern fingerprint/config. Send only when `browser` is `"wayfern"`.
|
||||
/// Omit it, or pass an empty object `{}`, to have a fresh fingerprint
|
||||
/// generated automatically at creation. Provide a `fingerprint` field to
|
||||
/// pin a specific one.
|
||||
#[schema(value_type = Object)]
|
||||
#[schema(value_type = Option<Object>)]
|
||||
pub wayfern_config: Option<serde_json::Value>,
|
||||
pub group_id: Option<String>,
|
||||
pub tags: Option<Vec<String>>,
|
||||
@@ -94,7 +88,6 @@ pub struct UpdateProfileRequest {
|
||||
pub vpn_id: Option<String>,
|
||||
pub launch_hook: Option<String>,
|
||||
pub release_type: Option<String>,
|
||||
#[schema(value_type = Object)]
|
||||
pub group_id: Option<String>,
|
||||
pub tags: Option<Vec<String>>,
|
||||
pub extension_group_id: Option<String>,
|
||||
@@ -143,7 +136,7 @@ struct CreateProxyRequest {
|
||||
#[derive(Debug, Deserialize, ToSchema)]
|
||||
struct UpdateProxyRequest {
|
||||
name: Option<String>,
|
||||
#[schema(value_type = Object)]
|
||||
#[schema(value_type = Option<Object>)]
|
||||
proxy_settings: Option<ProxySettings>,
|
||||
}
|
||||
|
||||
@@ -316,10 +309,15 @@ struct BatchStopResponse {
|
||||
delete_proxy,
|
||||
get_vpns,
|
||||
get_vpn,
|
||||
export_vpn,
|
||||
import_vpn,
|
||||
create_vpn,
|
||||
update_vpn,
|
||||
delete_vpn,
|
||||
get_extensions,
|
||||
get_extension_groups,
|
||||
delete_extension_api,
|
||||
delete_extension_group_api,
|
||||
download_browser_api,
|
||||
get_browser_versions,
|
||||
check_browser_downloaded,
|
||||
@@ -337,6 +335,7 @@ struct BatchStopResponse {
|
||||
CreateProxyRequest,
|
||||
UpdateProxyRequest,
|
||||
ApiVpnResponse,
|
||||
ApiVpnExportResponse,
|
||||
ImportVpnRequest,
|
||||
CreateVpnRequest,
|
||||
UpdateVpnRequest,
|
||||
@@ -361,6 +360,7 @@ struct BatchStopResponse {
|
||||
(name = "tags", description = "Tag management endpoints"),
|
||||
(name = "proxies", description = "Proxy management endpoints"),
|
||||
(name = "vpns", description = "VPN management endpoints"),
|
||||
(name = "extensions", description = "Extension management endpoints"),
|
||||
(name = "browsers", description = "Browser management endpoints"),
|
||||
(name = "cookies", description = "Cookie management endpoints"),
|
||||
),
|
||||
@@ -468,7 +468,6 @@ impl ApiServer {
|
||||
.routes(routes!(download_browser_api))
|
||||
.routes(routes!(get_browser_versions))
|
||||
.routes(routes!(check_browser_downloaded))
|
||||
.routes(routes!(get_wayfern_token, refresh_wayfern_token))
|
||||
.split_for_parts();
|
||||
|
||||
let api = ApiDoc::openapi();
|
||||
@@ -679,6 +678,71 @@ pub async fn get_api_server_status() -> Result<Option<u16>, String> {
|
||||
}
|
||||
|
||||
// API Handlers - Profiles
|
||||
/// Maps a manager-layer error onto a consistent HTTP status: 404 for missing
|
||||
/// entities, 400 for validation/duplicate/client-input errors, 500 for
|
||||
/// everything else (IO and other internal failures). The error text passes
|
||||
/// through as the response body so API clients get a diagnostic instead of a
|
||||
/// bare status code. Matching is on message content because the managers
|
||||
/// return plain strings (some are the JSON `{"code": ...}` strings shared
|
||||
/// with the Tauri commands).
|
||||
fn manager_error_response(err: impl std::fmt::Display) -> (StatusCode, String) {
|
||||
let msg = err.to_string();
|
||||
|
||||
// Structured {"code": ...} errors from the shared managers classify exactly.
|
||||
if let Ok(value) = serde_json::from_str::<serde_json::Value>(&msg) {
|
||||
if let Some(code) = value.get("code").and_then(|c| c.as_str()) {
|
||||
let status = if code.ends_with("_NOT_FOUND") {
|
||||
StatusCode::NOT_FOUND
|
||||
} else if code == "INTERNAL_ERROR" {
|
||||
StatusCode::INTERNAL_SERVER_ERROR
|
||||
} else {
|
||||
// Validation-style codes (NAME_CANNOT_BE_EMPTY, GROUP_ALREADY_EXISTS,
|
||||
// WAYFERN_VERSION_NOT_AVAILABLE, ...).
|
||||
StatusCode::BAD_REQUEST
|
||||
};
|
||||
return (status, msg);
|
||||
}
|
||||
}
|
||||
|
||||
// Plain-text manager messages: match the known phrases narrowly so raw
|
||||
// OS/serde/network error text (e.g. "invalid type: ..." from a corrupt
|
||||
// store) falls through to 500 instead of masquerading as a client error.
|
||||
let lower = msg.to_lowercase();
|
||||
let status = if lower.contains("not found") {
|
||||
StatusCode::NOT_FOUND
|
||||
} else if lower.contains("already exists")
|
||||
|| lower.contains("cannot set both")
|
||||
|| lower.contains("cannot edit")
|
||||
|| lower.contains("cannot delete")
|
||||
|| lower.contains("cannot open url")
|
||||
|| lower.contains("invalid browser")
|
||||
|| lower.contains("invalid profile id")
|
||||
|| lower.contains("unsupported browser")
|
||||
|| lower.contains("not supported on your platform")
|
||||
|| lower.contains("is not downloaded")
|
||||
|| lower.contains("terms and conditions")
|
||||
{
|
||||
StatusCode::BAD_REQUEST
|
||||
} else {
|
||||
StatusCode::INTERNAL_SERVER_ERROR
|
||||
};
|
||||
(status, msg)
|
||||
}
|
||||
|
||||
/// Real per-group profile counts, computed from the profile list (the same
|
||||
/// source of truth the GUI uses).
|
||||
fn group_profile_counts() -> std::collections::HashMap<String, usize> {
|
||||
let mut counts = std::collections::HashMap::new();
|
||||
if let Ok(profiles) = ProfileManager::instance().list_profiles() {
|
||||
for profile in profiles {
|
||||
if let Some(group_id) = profile.group_id {
|
||||
*counts.entry(group_id).or_insert(0) += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
counts
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/v1/profiles",
|
||||
@@ -963,41 +1027,37 @@ async fn update_profile(
|
||||
Path(id): Path<String>,
|
||||
State(state): State<ApiServerState>,
|
||||
Json(request): Json<UpdateProfileRequest>,
|
||||
) -> Result<Json<ApiProfileResponse>, StatusCode> {
|
||||
) -> Result<Json<ApiProfileResponse>, (StatusCode, String)> {
|
||||
let profile_manager = ProfileManager::instance();
|
||||
|
||||
if request.proxy_id.as_deref().is_some_and(|s| !s.is_empty())
|
||||
&& request.vpn_id.as_deref().is_some_and(|s| !s.is_empty())
|
||||
{
|
||||
return Err(StatusCode::BAD_REQUEST);
|
||||
return Err((
|
||||
StatusCode::BAD_REQUEST,
|
||||
"Cannot set both proxy_id and vpn_id".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
// Update profile fields
|
||||
if let Some(new_name) = request.name {
|
||||
if profile_manager
|
||||
.rename_profile(&state.app_handle, &id, &new_name)
|
||||
.is_err()
|
||||
{
|
||||
return Err(StatusCode::BAD_REQUEST);
|
||||
if let Err(e) = profile_manager.rename_profile(&state.app_handle, &id, &new_name) {
|
||||
return Err(manager_error_response(e));
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(version) = request.version {
|
||||
if profile_manager
|
||||
.update_profile_version(&state.app_handle, &id, &version)
|
||||
.is_err()
|
||||
{
|
||||
return Err(StatusCode::BAD_REQUEST);
|
||||
if let Err(e) = profile_manager.update_profile_version(&state.app_handle, &id, &version) {
|
||||
return Err(manager_error_response(e));
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(proxy_id) = request.proxy_id {
|
||||
if profile_manager
|
||||
if let Err(e) = profile_manager
|
||||
.update_profile_proxy(state.app_handle.clone(), &id, Some(proxy_id))
|
||||
.await
|
||||
.is_err()
|
||||
{
|
||||
return Err(StatusCode::BAD_REQUEST);
|
||||
return Err(manager_error_response(e));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1007,12 +1067,11 @@ async fn update_profile(
|
||||
} else {
|
||||
Some(vpn_id)
|
||||
};
|
||||
if profile_manager
|
||||
if let Err(e) = profile_manager
|
||||
.update_profile_vpn(state.app_handle.clone(), &id, normalized)
|
||||
.await
|
||||
.is_err()
|
||||
{
|
||||
return Err(StatusCode::BAD_REQUEST);
|
||||
return Err(manager_error_response(e));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1023,29 +1082,22 @@ async fn update_profile(
|
||||
Some(launch_hook)
|
||||
};
|
||||
|
||||
if profile_manager
|
||||
.update_profile_launch_hook(&state.app_handle, &id, normalized)
|
||||
.is_err()
|
||||
{
|
||||
return Err(StatusCode::BAD_REQUEST);
|
||||
if let Err(e) = profile_manager.update_profile_launch_hook(&state.app_handle, &id, normalized) {
|
||||
return Err(manager_error_response(e));
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(group_id) = request.group_id {
|
||||
if profile_manager
|
||||
.assign_profiles_to_group(&state.app_handle, vec![id.clone()], Some(group_id))
|
||||
.is_err()
|
||||
if let Err(e) =
|
||||
profile_manager.assign_profiles_to_group(&state.app_handle, vec![id.clone()], Some(group_id))
|
||||
{
|
||||
return Err(StatusCode::BAD_REQUEST);
|
||||
return Err(manager_error_response(e));
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(tags) = request.tags {
|
||||
if profile_manager
|
||||
.update_profile_tags(&state.app_handle, &id, tags)
|
||||
.is_err()
|
||||
{
|
||||
return Err(StatusCode::BAD_REQUEST);
|
||||
if let Err(e) = profile_manager.update_profile_tags(&state.app_handle, &id, tags) {
|
||||
return Err(manager_error_response(e));
|
||||
}
|
||||
|
||||
// Update tag manager with new tags from all profiles
|
||||
@@ -1062,34 +1114,31 @@ async fn update_profile(
|
||||
} else {
|
||||
Some(extension_group_id)
|
||||
};
|
||||
if profile_manager
|
||||
.update_profile_extension_group(&id, ext_group)
|
||||
.is_err()
|
||||
{
|
||||
return Err(StatusCode::BAD_REQUEST);
|
||||
if let Err(e) = profile_manager.update_profile_extension_group(&id, ext_group) {
|
||||
return Err(manager_error_response(e));
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(proxy_bypass_rules) = request.proxy_bypass_rules {
|
||||
if profile_manager
|
||||
.update_profile_proxy_bypass_rules(&state.app_handle, &id, proxy_bypass_rules)
|
||||
.is_err()
|
||||
if let Err(e) =
|
||||
profile_manager.update_profile_proxy_bypass_rules(&state.app_handle, &id, proxy_bypass_rules)
|
||||
{
|
||||
return Err(StatusCode::BAD_REQUEST);
|
||||
return Err(manager_error_response(e));
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(sync_mode) = request.sync_mode {
|
||||
if crate::sync::set_profile_sync_mode(state.app_handle.clone(), id.clone(), sync_mode)
|
||||
.await
|
||||
.is_err()
|
||||
if let Err(e) =
|
||||
crate::sync::set_profile_sync_mode(state.app_handle.clone(), id.clone(), sync_mode).await
|
||||
{
|
||||
return Err(StatusCode::BAD_REQUEST);
|
||||
return Err(manager_error_response(e));
|
||||
}
|
||||
}
|
||||
|
||||
// Return updated profile
|
||||
get_profile(Path(id), State(state)).await
|
||||
get_profile(Path(id), State(state))
|
||||
.await
|
||||
.map_err(|status| (status, String::new()))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
@@ -1102,6 +1151,7 @@ async fn update_profile(
|
||||
(status = 204, description = "Profile deleted successfully"),
|
||||
(status = 400, description = "Bad request"),
|
||||
(status = 401, description = "Unauthorized"),
|
||||
(status = 404, description = "Profile not found"),
|
||||
(status = 500, description = "Internal server error")
|
||||
),
|
||||
security(
|
||||
@@ -1112,11 +1162,11 @@ async fn update_profile(
|
||||
async fn delete_profile(
|
||||
Path(id): Path<String>,
|
||||
State(state): State<ApiServerState>,
|
||||
) -> Result<StatusCode, StatusCode> {
|
||||
) -> Result<StatusCode, (StatusCode, String)> {
|
||||
let profile_manager = ProfileManager::instance();
|
||||
match profile_manager.delete_profile(&state.app_handle, &id) {
|
||||
Ok(_) => Ok(StatusCode::NO_CONTENT),
|
||||
Err(_) => Err(StatusCode::BAD_REQUEST),
|
||||
Err(e) => Err(manager_error_response(e)),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1138,22 +1188,21 @@ async fn get_groups(
|
||||
State(_state): State<ApiServerState>,
|
||||
) -> Result<Json<Vec<ApiGroupResponse>>, StatusCode> {
|
||||
match GROUP_MANAGER.lock() {
|
||||
Ok(manager) => {
|
||||
match manager.get_all_groups() {
|
||||
Ok(groups) => {
|
||||
let api_groups = groups
|
||||
.into_iter()
|
||||
.map(|group| ApiGroupResponse {
|
||||
id: group.id,
|
||||
name: group.name,
|
||||
profile_count: 0, // Would need profile list to calculate this
|
||||
})
|
||||
.collect();
|
||||
Ok(Json(api_groups))
|
||||
}
|
||||
Err(_) => Err(StatusCode::INTERNAL_SERVER_ERROR),
|
||||
Ok(manager) => match manager.get_all_groups() {
|
||||
Ok(groups) => {
|
||||
let counts = group_profile_counts();
|
||||
let api_groups = groups
|
||||
.into_iter()
|
||||
.map(|group| ApiGroupResponse {
|
||||
profile_count: counts.get(&group.id).copied().unwrap_or(0),
|
||||
id: group.id,
|
||||
name: group.name,
|
||||
})
|
||||
.collect();
|
||||
Ok(Json(api_groups))
|
||||
}
|
||||
}
|
||||
Err(_) => Err(StatusCode::INTERNAL_SERVER_ERROR),
|
||||
},
|
||||
Err(_) => Err(StatusCode::INTERNAL_SERVER_ERROR),
|
||||
}
|
||||
}
|
||||
@@ -1184,9 +1233,9 @@ async fn get_group(
|
||||
Ok(groups) => {
|
||||
if let Some(group) = groups.into_iter().find(|g| g.id == id) {
|
||||
Ok(Json(ApiGroupResponse {
|
||||
profile_count: group_profile_counts().get(&group.id).copied().unwrap_or(0),
|
||||
id: group.id,
|
||||
name: group.name,
|
||||
profile_count: 0,
|
||||
}))
|
||||
} else {
|
||||
Err(StatusCode::NOT_FOUND)
|
||||
@@ -1216,7 +1265,7 @@ async fn get_group(
|
||||
async fn create_group(
|
||||
State(state): State<ApiServerState>,
|
||||
Json(request): Json<CreateGroupRequest>,
|
||||
) -> Result<Json<ApiGroupResponse>, StatusCode> {
|
||||
) -> Result<Json<ApiGroupResponse>, (StatusCode, String)> {
|
||||
match GROUP_MANAGER.lock() {
|
||||
Ok(manager) => match manager.create_group(&state.app_handle, request.name) {
|
||||
Ok(group) => Ok(Json(ApiGroupResponse {
|
||||
@@ -1224,9 +1273,12 @@ async fn create_group(
|
||||
name: group.name,
|
||||
profile_count: 0,
|
||||
})),
|
||||
Err(_) => Err(StatusCode::BAD_REQUEST),
|
||||
Err(e) => Err(manager_error_response(e)),
|
||||
},
|
||||
Err(_) => Err(StatusCode::INTERNAL_SERVER_ERROR),
|
||||
Err(_) => Err((
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"group manager unavailable".to_string(),
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1253,17 +1305,20 @@ async fn update_group(
|
||||
Path(id): Path<String>,
|
||||
State(state): State<ApiServerState>,
|
||||
Json(request): Json<UpdateGroupRequest>,
|
||||
) -> Result<Json<ApiGroupResponse>, StatusCode> {
|
||||
) -> Result<Json<ApiGroupResponse>, (StatusCode, String)> {
|
||||
match GROUP_MANAGER.lock() {
|
||||
Ok(manager) => match manager.update_group(&state.app_handle, id.clone(), request.name) {
|
||||
Ok(group) => Ok(Json(ApiGroupResponse {
|
||||
profile_count: group_profile_counts().get(&group.id).copied().unwrap_or(0),
|
||||
id: group.id,
|
||||
name: group.name,
|
||||
profile_count: 0,
|
||||
})),
|
||||
Err(_) => Err(StatusCode::BAD_REQUEST),
|
||||
Err(e) => Err(manager_error_response(e)),
|
||||
},
|
||||
Err(_) => Err(StatusCode::INTERNAL_SERVER_ERROR),
|
||||
Err(_) => Err((
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"group manager unavailable".to_string(),
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1275,8 +1330,8 @@ async fn update_group(
|
||||
),
|
||||
responses(
|
||||
(status = 204, description = "Group deleted successfully"),
|
||||
(status = 400, description = "Bad request"),
|
||||
(status = 401, description = "Unauthorized"),
|
||||
(status = 404, description = "Group not found"),
|
||||
(status = 500, description = "Internal server error")
|
||||
),
|
||||
security(
|
||||
@@ -1287,13 +1342,16 @@ async fn update_group(
|
||||
async fn delete_group(
|
||||
Path(id): Path<String>,
|
||||
State(state): State<ApiServerState>,
|
||||
) -> Result<StatusCode, StatusCode> {
|
||||
) -> Result<StatusCode, (StatusCode, String)> {
|
||||
match GROUP_MANAGER.lock() {
|
||||
Ok(manager) => match manager.delete_group(&state.app_handle, id.clone()) {
|
||||
Ok(_) => Ok(StatusCode::NO_CONTENT),
|
||||
Err(_) => Err(StatusCode::BAD_REQUEST),
|
||||
Err(e) => Err(manager_error_response(e)),
|
||||
},
|
||||
Err(_) => Err(StatusCode::INTERNAL_SERVER_ERROR),
|
||||
Err(_) => Err((
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"group manager unavailable".to_string(),
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1402,7 +1460,7 @@ async fn get_proxy(
|
||||
async fn create_proxy(
|
||||
State(state): State<ApiServerState>,
|
||||
Json(request): Json<CreateProxyRequest>,
|
||||
) -> Result<Json<ApiProxyResponse>, StatusCode> {
|
||||
) -> Result<Json<ApiProxyResponse>, (StatusCode, String)> {
|
||||
let result = PROXY_MANAGER.create_stored_proxy(
|
||||
&state.app_handle,
|
||||
request.name.clone(),
|
||||
@@ -1415,7 +1473,7 @@ async fn create_proxy(
|
||||
name: proxy.name,
|
||||
proxy_settings: proxy.proxy_settings,
|
||||
})),
|
||||
Err(_) => Err(StatusCode::BAD_REQUEST),
|
||||
Err(e) => Err(manager_error_response(e)),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1442,7 +1500,7 @@ async fn update_proxy(
|
||||
Path(id): Path<String>,
|
||||
State(state): State<ApiServerState>,
|
||||
Json(request): Json<UpdateProxyRequest>,
|
||||
) -> Result<Json<ApiProxyResponse>, StatusCode> {
|
||||
) -> Result<Json<ApiProxyResponse>, (StatusCode, String)> {
|
||||
let result =
|
||||
PROXY_MANAGER.update_stored_proxy(&state.app_handle, &id, request.name, request.proxy_settings);
|
||||
|
||||
@@ -1452,7 +1510,7 @@ async fn update_proxy(
|
||||
name: proxy.name,
|
||||
proxy_settings: proxy.proxy_settings,
|
||||
})),
|
||||
Err(_) => Err(StatusCode::NOT_FOUND),
|
||||
Err(e) => Err(manager_error_response(e)),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1464,8 +1522,9 @@ async fn update_proxy(
|
||||
),
|
||||
responses(
|
||||
(status = 204, description = "Proxy deleted successfully"),
|
||||
(status = 400, description = "Bad request"),
|
||||
(status = 400, description = "Bad request (e.g. cloud-managed proxy)"),
|
||||
(status = 401, description = "Unauthorized"),
|
||||
(status = 404, description = "Proxy not found"),
|
||||
(status = 500, description = "Internal server error")
|
||||
),
|
||||
security(
|
||||
@@ -1476,10 +1535,10 @@ async fn update_proxy(
|
||||
async fn delete_proxy(
|
||||
Path(id): Path<String>,
|
||||
State(state): State<ApiServerState>,
|
||||
) -> Result<StatusCode, StatusCode> {
|
||||
) -> Result<StatusCode, (StatusCode, String)> {
|
||||
match PROXY_MANAGER.delete_stored_proxy(&state.app_handle, &id) {
|
||||
Ok(_) => Ok(StatusCode::NO_CONTENT),
|
||||
Err(_) => Err(StatusCode::BAD_REQUEST),
|
||||
Err(e) => Err(manager_error_response(e)),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1770,6 +1829,7 @@ async fn get_extension_groups(
|
||||
(status = 204, description = "Extension deleted"),
|
||||
(status = 401, description = "Unauthorized"),
|
||||
(status = 404, description = "Extension not found"),
|
||||
(status = 500, description = "Internal server error"),
|
||||
),
|
||||
security(("bearer_auth" = [])),
|
||||
tag = "extensions"
|
||||
@@ -1777,12 +1837,12 @@ async fn get_extension_groups(
|
||||
async fn delete_extension_api(
|
||||
Path(id): Path<String>,
|
||||
State(state): State<ApiServerState>,
|
||||
) -> Result<StatusCode, StatusCode> {
|
||||
) -> Result<StatusCode, (StatusCode, String)> {
|
||||
let mgr = crate::extension_manager::EXTENSION_MANAGER.lock().unwrap();
|
||||
mgr
|
||||
.delete_extension(&state.app_handle, &id)
|
||||
.map(|_| StatusCode::NO_CONTENT)
|
||||
.map_err(|_| StatusCode::NOT_FOUND)
|
||||
.map_err(manager_error_response)
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
@@ -1793,6 +1853,7 @@ async fn delete_extension_api(
|
||||
(status = 204, description = "Extension group deleted"),
|
||||
(status = 401, description = "Unauthorized"),
|
||||
(status = 404, description = "Extension group not found"),
|
||||
(status = 500, description = "Internal server error"),
|
||||
),
|
||||
security(("bearer_auth" = [])),
|
||||
tag = "extensions"
|
||||
@@ -1800,12 +1861,12 @@ async fn delete_extension_api(
|
||||
async fn delete_extension_group_api(
|
||||
Path(id): Path<String>,
|
||||
State(state): State<ApiServerState>,
|
||||
) -> Result<StatusCode, StatusCode> {
|
||||
) -> Result<StatusCode, (StatusCode, String)> {
|
||||
let mgr = crate::extension_manager::EXTENSION_MANAGER.lock().unwrap();
|
||||
mgr
|
||||
.delete_group(&state.app_handle, &id)
|
||||
.map(|_| StatusCode::NO_CONTENT)
|
||||
.map_err(|_| StatusCode::NOT_FOUND)
|
||||
.map_err(manager_error_response)
|
||||
}
|
||||
|
||||
// API Handler - Run Profile with Remote Debugging
|
||||
@@ -1820,7 +1881,9 @@ async fn delete_extension_group_api(
|
||||
(status = 200, description = "Profile launched successfully", body = RunProfileResponse),
|
||||
(status = 400, description = "Cannot launch cross-OS profile"),
|
||||
(status = 401, description = "Unauthorized"),
|
||||
(status = 402, description = "Active paid plan with browser automation required"),
|
||||
(status = 404, description = "Profile not found"),
|
||||
(status = 409, description = "Profile is locked by another team member"),
|
||||
(status = 500, description = "Internal server error")
|
||||
),
|
||||
security(
|
||||
@@ -1905,7 +1968,9 @@ async fn run_profile(
|
||||
request_body = OpenUrlRequest,
|
||||
responses(
|
||||
(status = 200, description = "URL opened successfully"),
|
||||
(status = 400, description = "Cannot open URL with a cross-OS profile"),
|
||||
(status = 401, description = "Unauthorized"),
|
||||
(status = 402, description = "Active paid plan with browser automation required"),
|
||||
(status = 404, description = "Profile not found"),
|
||||
(status = 500, description = "Internal server error")
|
||||
),
|
||||
@@ -1918,12 +1983,12 @@ async fn open_url_in_profile(
|
||||
Path(id): Path<String>,
|
||||
State(state): State<ApiServerState>,
|
||||
Json(request): Json<OpenUrlRequest>,
|
||||
) -> Result<StatusCode, StatusCode> {
|
||||
) -> Result<StatusCode, (StatusCode, String)> {
|
||||
if !crate::cloud_auth::CLOUD_AUTH
|
||||
.can_use_browser_automation()
|
||||
.await
|
||||
{
|
||||
return Err(StatusCode::PAYMENT_REQUIRED);
|
||||
return Err((StatusCode::PAYMENT_REQUIRED, String::new()));
|
||||
}
|
||||
|
||||
let browser_runner = crate::browser_runner::BrowserRunner::instance();
|
||||
@@ -1931,7 +1996,7 @@ async fn open_url_in_profile(
|
||||
browser_runner
|
||||
.open_url_with_profile(state.app_handle.clone(), id, request.url)
|
||||
.await
|
||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||
.map_err(manager_error_response)?;
|
||||
|
||||
Ok(StatusCode::OK)
|
||||
}
|
||||
@@ -2230,9 +2295,11 @@ async fn import_profile_cookies(
|
||||
path = "/v1/browsers/download",
|
||||
request_body = DownloadBrowserRequest,
|
||||
responses(
|
||||
(status = 200, description = "Browser download initiated", body = DownloadBrowserResponse),
|
||||
(status = 200, description = "Browser downloaded (or already present)", body = DownloadBrowserResponse),
|
||||
(status = 400, description = "Invalid browser or version not available for download"),
|
||||
(status = 401, description = "Unauthorized"),
|
||||
(status = 500, description = "Internal server error")
|
||||
(status = 409, description = "This browser version is already being downloaded"),
|
||||
(status = 500, description = "Internal server error (e.g. network failure)")
|
||||
),
|
||||
security(
|
||||
("bearer_auth" = [])
|
||||
@@ -2242,20 +2309,27 @@ async fn import_profile_cookies(
|
||||
async fn download_browser_api(
|
||||
State(state): State<ApiServerState>,
|
||||
Json(request): Json<DownloadBrowserRequest>,
|
||||
) -> Result<Json<DownloadBrowserResponse>, StatusCode> {
|
||||
) -> Result<Json<DownloadBrowserResponse>, (StatusCode, String)> {
|
||||
match crate::downloader::download_browser(
|
||||
state.app_handle.clone(),
|
||||
request.browser.clone(),
|
||||
request.version.clone(),
|
||||
request.version,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(_) => Ok(Json(DownloadBrowserResponse {
|
||||
// Echo the version the downloader actually installed, not the requested one.
|
||||
Ok(version) => Ok(Json(DownloadBrowserResponse {
|
||||
browser: request.browser,
|
||||
version: request.version,
|
||||
version,
|
||||
status: "downloaded".to_string(),
|
||||
})),
|
||||
Err(_) => Err(StatusCode::INTERNAL_SERVER_ERROR),
|
||||
Err(e) => {
|
||||
if e.contains("already being downloaded") {
|
||||
Err((StatusCode::CONFLICT, e))
|
||||
} else {
|
||||
Err(manager_error_response(e))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2268,6 +2342,7 @@ async fn download_browser_api(
|
||||
),
|
||||
responses(
|
||||
(status = 200, description = "List of available browser versions", body = Vec<String>),
|
||||
(status = 400, description = "Unsupported browser"),
|
||||
(status = 401, description = "Unauthorized"),
|
||||
(status = 500, description = "Internal server error")
|
||||
),
|
||||
@@ -2279,7 +2354,7 @@ async fn download_browser_api(
|
||||
async fn get_browser_versions(
|
||||
Path(browser): Path<String>,
|
||||
State(_state): State<ApiServerState>,
|
||||
) -> Result<Json<Vec<String>>, StatusCode> {
|
||||
) -> Result<Json<Vec<String>>, (StatusCode, String)> {
|
||||
let version_manager = crate::browser_version_manager::BrowserVersionManager::instance();
|
||||
|
||||
match version_manager
|
||||
@@ -2287,7 +2362,7 @@ async fn get_browser_versions(
|
||||
.await
|
||||
{
|
||||
Ok(result) => Ok(Json(result.versions)),
|
||||
Err(_) => Err(StatusCode::INTERNAL_SERVER_ERROR),
|
||||
Err(e) => Err(manager_error_response(e)),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2317,57 +2392,6 @@ async fn check_browser_downloaded(
|
||||
Ok(Json(is_downloaded))
|
||||
}
|
||||
|
||||
// API Handlers - Wayfern Token
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, ToSchema)]
|
||||
pub struct WayfernTokenResponse {
|
||||
pub token: Option<String>,
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/v1/wayfern-token",
|
||||
responses(
|
||||
(status = 200, description = "Current wayfern token", body = WayfernTokenResponse),
|
||||
(status = 401, description = "Unauthorized"),
|
||||
),
|
||||
security(
|
||||
("bearer_auth" = [])
|
||||
),
|
||||
tag = "wayfern"
|
||||
)]
|
||||
async fn get_wayfern_token(
|
||||
State(_state): State<ApiServerState>,
|
||||
) -> Result<Json<WayfernTokenResponse>, StatusCode> {
|
||||
let token = crate::cloud_auth::CLOUD_AUTH.get_wayfern_token().await;
|
||||
Ok(Json(WayfernTokenResponse { token }))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/v1/wayfern-token/refresh",
|
||||
responses(
|
||||
(status = 200, description = "Refreshed wayfern token", body = WayfernTokenResponse),
|
||||
(status = 401, description = "Unauthorized"),
|
||||
(status = 500, description = "Failed to refresh token"),
|
||||
),
|
||||
security(
|
||||
("bearer_auth" = [])
|
||||
),
|
||||
tag = "wayfern"
|
||||
)]
|
||||
async fn refresh_wayfern_token(
|
||||
State(_state): State<ApiServerState>,
|
||||
) -> Result<Json<WayfernTokenResponse>, (StatusCode, String)> {
|
||||
crate::cloud_auth::CLOUD_AUTH
|
||||
.request_wayfern_token()
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e))?;
|
||||
|
||||
let token = crate::cloud_auth::CLOUD_AUTH.get_wayfern_token().await;
|
||||
Ok(Json(WayfernTokenResponse { token }))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -2415,7 +2439,68 @@ mod tests {
|
||||
let is_valid = |b: &str| b == "wayfern";
|
||||
assert!(is_valid("wayfern"));
|
||||
assert!(!is_valid("chromium"));
|
||||
assert!(!is_valid("firefox"));
|
||||
assert!(!is_valid(""));
|
||||
}
|
||||
|
||||
fn schema_required(spec: &serde_json::Value, schema: &str) -> Vec<String> {
|
||||
spec["components"]["schemas"][schema]["required"]
|
||||
.as_array()
|
||||
.map(|a| {
|
||||
a.iter()
|
||||
.filter_map(|v| v.as_str().map(str::to_string))
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
// `#[schema(value_type = Object)]` on an `Option<T>` erases the optionality
|
||||
// and marks the field required in the served spec; these fields must stay
|
||||
// optional so generated clients aren't forced to send them.
|
||||
#[test]
|
||||
fn openapi_optional_fields_are_not_required() {
|
||||
let spec = serde_json::to_value(ApiDoc::openapi()).expect("spec serializes");
|
||||
|
||||
let create_profile = schema_required(&spec, "CreateProfileRequest");
|
||||
assert!(
|
||||
!create_profile.iter().any(|f| f == "wayfern_config"),
|
||||
"wayfern_config must be optional, required list: {create_profile:?}"
|
||||
);
|
||||
|
||||
let update_profile = schema_required(&spec, "UpdateProfileRequest");
|
||||
assert!(
|
||||
!update_profile.iter().any(|f| f == "group_id"),
|
||||
"group_id must be optional, required list: {update_profile:?}"
|
||||
);
|
||||
|
||||
let update_proxy = schema_required(&spec, "UpdateProxyRequest");
|
||||
assert!(
|
||||
!update_proxy.iter().any(|f| f == "proxy_settings"),
|
||||
"proxy_settings must be optional on update, required list: {update_proxy:?}"
|
||||
);
|
||||
}
|
||||
|
||||
// The served /openapi.json comes from the hand-maintained ApiDoc `paths(...)`
|
||||
// list, not from the router — endpoints registered on the router but missing
|
||||
// from ApiDoc silently disappear from the spec. Lock in the ones that were
|
||||
// once dropped, and that removed endpoints stay gone.
|
||||
#[test]
|
||||
fn openapi_spec_covers_registered_routes() {
|
||||
let spec = serde_json::to_value(ApiDoc::openapi()).expect("spec serializes");
|
||||
let paths = spec["paths"].as_object().expect("paths object");
|
||||
|
||||
for path in [
|
||||
"/v1/vpns/{id}/export",
|
||||
"/v1/extensions",
|
||||
"/v1/extension-groups",
|
||||
"/v1/extensions/{id}",
|
||||
"/v1/extension-groups/{id}",
|
||||
] {
|
||||
assert!(paths.contains_key(path), "missing from ApiDoc: {path}");
|
||||
}
|
||||
|
||||
assert!(
|
||||
!paths.keys().any(|p| p.contains("wayfern-token")),
|
||||
"wayfern-token endpoints were removed and must stay out of the spec"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -87,6 +87,10 @@ pub struct AppReleaseAsset {
|
||||
pub name: String,
|
||||
pub browser_download_url: String,
|
||||
pub size: u64,
|
||||
/// GitHub-computed digest ("sha256:<hex>"); absent on assets uploaded
|
||||
/// before GitHub started calculating digests.
|
||||
#[serde(default)]
|
||||
pub digest: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
@@ -111,6 +115,15 @@ pub struct AppUpdateInfo {
|
||||
pub release_page_url: Option<String>,
|
||||
/// True when a system package manager repo is configured (apt/dnf/zypper)
|
||||
pub repo_update: bool,
|
||||
/// URL of the release's SHA256SUMS.txt asset. The downloaded update is
|
||||
/// verified against it before installation; without it the update is
|
||||
/// refused.
|
||||
#[serde(default)]
|
||||
pub checksums_url: Option<String>,
|
||||
/// GitHub's server-side digest of the chosen asset ("sha256:<hex>"),
|
||||
/// cross-checked in addition to SHA256SUMS.txt when present.
|
||||
#[serde(default)]
|
||||
pub asset_digest: Option<String>,
|
||||
}
|
||||
|
||||
pub struct AppAutoUpdater {
|
||||
@@ -214,6 +227,35 @@ impl AppAutoUpdater {
|
||||
// Find the appropriate asset for current platform
|
||||
let download_url = self.get_download_url_for_platform(&latest_release.assets);
|
||||
|
||||
// Locate the release's checksums file and the chosen asset's
|
||||
// GitHub-computed digest for post-download verification.
|
||||
let checksums_url = Self::find_checksums_url(&latest_release.assets);
|
||||
let asset_digest = download_url.as_deref().and_then(|url| {
|
||||
latest_release
|
||||
.assets
|
||||
.iter()
|
||||
.find(|a| a.browser_download_url == url)
|
||||
.and_then(|a| a.digest.clone())
|
||||
});
|
||||
|
||||
// Both release workflows upload SHA256SUMS.txt only after every platform
|
||||
// build finishes, so a release without it is still being assembled (or
|
||||
// its pipeline broke). Downloading now is guaranteed to fail closed, so
|
||||
// treat the release as not ready and retry on a later check instead of
|
||||
// surfacing an error for a healthy in-progress release. Applies only to
|
||||
// the auto-download path — manual/repo notifications don't download.
|
||||
let auto_download_possible = download_url.is_some();
|
||||
#[cfg(target_os = "linux")]
|
||||
let auto_download_possible = auto_download_possible && !self.is_repo_configured();
|
||||
if auto_download_possible && checksums_url.is_none() {
|
||||
log::info!(
|
||||
"Release {} has no {} yet; treating as not ready for auto-update",
|
||||
latest_release.tag_name,
|
||||
Self::CHECKSUMS_ASSET_NAME
|
||||
);
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
// On Linux, when a package repo is configured, notify users to update via
|
||||
// their package manager instead of auto-downloading from GitHub.
|
||||
#[cfg(target_os = "linux")]
|
||||
@@ -230,6 +272,8 @@ impl AppAutoUpdater {
|
||||
manual_update_required,
|
||||
release_page_url: Some(release_page_url),
|
||||
repo_update,
|
||||
checksums_url,
|
||||
asset_digest,
|
||||
};
|
||||
|
||||
log::info!(
|
||||
@@ -255,6 +299,8 @@ impl AppAutoUpdater {
|
||||
manual_update_required: false,
|
||||
release_page_url: Some(release_page_url),
|
||||
repo_update: false,
|
||||
checksums_url,
|
||||
asset_digest,
|
||||
};
|
||||
|
||||
log::info!(
|
||||
@@ -712,6 +758,156 @@ impl AppAutoUpdater {
|
||||
None
|
||||
}
|
||||
|
||||
/// Name of the checksums asset both release workflows publish.
|
||||
const CHECKSUMS_ASSET_NAME: &'static str = "SHA256SUMS.txt";
|
||||
|
||||
fn find_checksums_url(assets: &[AppReleaseAsset]) -> Option<String> {
|
||||
assets
|
||||
.iter()
|
||||
.find(|a| a.name == Self::CHECKSUMS_ASSET_NAME)
|
||||
.map(|a| a.browser_download_url.clone())
|
||||
}
|
||||
|
||||
/// Extract the hex digest for `filename` from standard `sha256sum` output
|
||||
/// (`<hex> <name>`, optionally with the `*` binary-mode marker).
|
||||
fn find_checksum_for_file(checksums_text: &str, filename: &str) -> Option<String> {
|
||||
checksums_text.lines().find_map(|line| {
|
||||
let (hash, rest) = line.split_once(char::is_whitespace)?;
|
||||
let name = rest.trim_start().trim_start_matches('*');
|
||||
if name == filename && hash.len() == 64 && hash.bytes().all(|b| b.is_ascii_hexdigit()) {
|
||||
Some(hash.to_ascii_lowercase())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn sha256_file(path: &Path) -> Result<String, Box<dyn std::error::Error + Send + Sync>> {
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::io::Read;
|
||||
let mut file = fs::File::open(path)?;
|
||||
let mut hasher = Sha256::new();
|
||||
let mut buf = vec![0u8; 1024 * 1024];
|
||||
loop {
|
||||
let n = file.read(&mut buf)?;
|
||||
if n == 0 {
|
||||
break;
|
||||
}
|
||||
hasher.update(&buf[..n]);
|
||||
}
|
||||
let digest = hasher.finalize();
|
||||
let mut hex = String::with_capacity(digest.len() * 2);
|
||||
for byte in digest {
|
||||
use std::fmt::Write;
|
||||
let _ = write!(hex, "{byte:02x}");
|
||||
}
|
||||
Ok(hex)
|
||||
}
|
||||
|
||||
/// Fetch the release's SHA256SUMS.txt and return the expected digest for
|
||||
/// `filename`. Called BEFORE the (large) asset download so an unverifiable
|
||||
/// release is rejected without wasting the transfer. Every failure mode
|
||||
/// maps to the UPDATE_CHECKSUMS_UNAVAILABLE code; details go to the log.
|
||||
async fn fetch_expected_checksum(
|
||||
&self,
|
||||
update_info: &AppUpdateInfo,
|
||||
filename: &str,
|
||||
) -> Result<String, Box<dyn std::error::Error + Send + Sync>> {
|
||||
let unavailable = || -> Box<dyn std::error::Error + Send + Sync> {
|
||||
serde_json::json!({
|
||||
"code": "UPDATE_CHECKSUMS_UNAVAILABLE",
|
||||
"params": { "version": update_info.new_version }
|
||||
})
|
||||
.to_string()
|
||||
.into()
|
||||
};
|
||||
|
||||
let Some(checksums_url) = update_info.checksums_url.as_deref() else {
|
||||
log::warn!(
|
||||
"No {} asset on release {}",
|
||||
Self::CHECKSUMS_ASSET_NAME,
|
||||
update_info.new_version
|
||||
);
|
||||
return Err(unavailable());
|
||||
};
|
||||
|
||||
let response = match self
|
||||
.client
|
||||
.get(checksums_url)
|
||||
.header("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36")
|
||||
.send()
|
||||
.await
|
||||
{
|
||||
Ok(response) if response.status().is_success() => response,
|
||||
Ok(response) => {
|
||||
log::warn!("Checksums file request failed: HTTP {}", response.status());
|
||||
return Err(unavailable());
|
||||
}
|
||||
Err(e) => {
|
||||
log::warn!("Checksums file request failed: {e}");
|
||||
return Err(unavailable());
|
||||
}
|
||||
};
|
||||
|
||||
let checksums_text = match response.text().await {
|
||||
Ok(text) => text,
|
||||
Err(e) => {
|
||||
log::warn!("Failed to read checksums file: {e}");
|
||||
return Err(unavailable());
|
||||
}
|
||||
};
|
||||
|
||||
let Some(expected) = Self::find_checksum_for_file(&checksums_text, filename) else {
|
||||
log::warn!(
|
||||
"No checksum entry for {filename} in {}",
|
||||
Self::CHECKSUMS_ASSET_NAME
|
||||
);
|
||||
return Err(unavailable());
|
||||
};
|
||||
Ok(expected)
|
||||
}
|
||||
|
||||
/// Verify the downloaded update against the expected SHA256SUMS.txt digest
|
||||
/// (and GitHub's server-side asset digest when available) before anything
|
||||
/// is extracted or installed. A corrupt download is deleted so the next
|
||||
/// attempt starts fresh.
|
||||
fn verify_update_checksum(
|
||||
file_path: &Path,
|
||||
filename: &str,
|
||||
expected: &str,
|
||||
asset_digest: Option<&str>,
|
||||
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
let actual = Self::sha256_file(file_path)?;
|
||||
|
||||
let mut mismatch = !actual.eq_ignore_ascii_case(expected);
|
||||
|
||||
// Cross-check GitHub's server-side digest: SHA256SUMS.txt is computed by
|
||||
// re-downloading assets in CI, so this catches corruption in that step.
|
||||
if !mismatch {
|
||||
if let Some(hex) = asset_digest.and_then(|d| d.strip_prefix("sha256:")) {
|
||||
mismatch = !actual.eq_ignore_ascii_case(hex);
|
||||
}
|
||||
}
|
||||
|
||||
if mismatch {
|
||||
log::error!(
|
||||
"Checksum mismatch for {filename}: expected {expected}, got {actual} (asset digest: {asset_digest:?})"
|
||||
);
|
||||
let _ = fs::remove_file(file_path);
|
||||
return Err(
|
||||
serde_json::json!({
|
||||
"code": "UPDATE_CHECKSUM_MISMATCH",
|
||||
"params": { "file": filename }
|
||||
})
|
||||
.to_string()
|
||||
.into(),
|
||||
);
|
||||
}
|
||||
|
||||
log::info!("Checksum verified for {filename}: {actual}");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Download the update file without progress tracking (silent download)
|
||||
async fn download_update_silent(
|
||||
&self,
|
||||
@@ -767,12 +963,24 @@ impl AppAutoUpdater {
|
||||
.unwrap_or("update.dmg")
|
||||
.to_string();
|
||||
|
||||
// Resolve the expected checksum first so an unverifiable release is
|
||||
// rejected before the multi-hundred-MB download, not after.
|
||||
let expected_sha256 = self.fetch_expected_checksum(update_info, &filename).await?;
|
||||
|
||||
log::info!("Downloading update from: {}", update_info.download_url);
|
||||
|
||||
let download_path = self
|
||||
.download_update_silent(&update_info.download_url, &temp_dir, &filename)
|
||||
.await?;
|
||||
|
||||
log::info!("Verifying update checksum...");
|
||||
Self::verify_update_checksum(
|
||||
&download_path,
|
||||
&filename,
|
||||
&expected_sha256,
|
||||
update_info.asset_digest.as_deref(),
|
||||
)?;
|
||||
|
||||
log::info!("Extracting update...");
|
||||
let extracted_app_path = self.extract_update(&download_path, &temp_dir).await?;
|
||||
|
||||
@@ -825,7 +1033,10 @@ impl AppAutoUpdater {
|
||||
|
||||
// Handle compound extensions like .tar.gz
|
||||
if file_name.ends_with(".tar.gz") {
|
||||
return self.extractor.extract_tar_gz(archive_path, dest_dir).await;
|
||||
return self
|
||||
.extractor
|
||||
.extract_tar_gz(archive_path, dest_dir, None)
|
||||
.await;
|
||||
}
|
||||
|
||||
let extension = archive_path
|
||||
@@ -837,7 +1048,10 @@ impl AppAutoUpdater {
|
||||
"dmg" => {
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
self.extractor.extract_dmg(archive_path, dest_dir).await
|
||||
self
|
||||
.extractor
|
||||
.extract_dmg(archive_path, dest_dir, None)
|
||||
.await
|
||||
}
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
{
|
||||
@@ -901,7 +1115,12 @@ impl AppAutoUpdater {
|
||||
Err("AppImage installation is only supported on Linux".into())
|
||||
}
|
||||
}
|
||||
"zip" => self.extractor.extract_zip(archive_path, dest_dir).await,
|
||||
"zip" => {
|
||||
self
|
||||
.extractor
|
||||
.extract_zip(archive_path, dest_dir, None)
|
||||
.await
|
||||
}
|
||||
_ => Err(format!("Unsupported archive format: {extension}").into()),
|
||||
}
|
||||
}
|
||||
@@ -1024,7 +1243,7 @@ impl AppAutoUpdater {
|
||||
if !log_content.is_empty() {
|
||||
log::info!(
|
||||
"Log file content (last 500 chars): {}",
|
||||
&log_content
|
||||
log_content
|
||||
.chars()
|
||||
.rev()
|
||||
.take(500)
|
||||
@@ -1110,7 +1329,7 @@ impl AppAutoUpdater {
|
||||
// Extract ZIP file
|
||||
let extracted_path = self
|
||||
.extractor
|
||||
.extract_zip(installer_path, &temp_extract_dir)
|
||||
.extract_zip(installer_path, &temp_extract_dir, None)
|
||||
.await?;
|
||||
|
||||
// Find the executable in the extracted files
|
||||
@@ -1385,7 +1604,7 @@ impl AppAutoUpdater {
|
||||
// Extract tarball
|
||||
let extracted_path = self
|
||||
.extractor
|
||||
.extract_tar_gz(tarball_path, &temp_extract_dir)
|
||||
.extract_tar_gz(tarball_path, &temp_extract_dir, None)
|
||||
.await?;
|
||||
|
||||
// Find the executable in the extracted files
|
||||
@@ -1754,7 +1973,16 @@ pub async fn download_and_prepare_app_update(
|
||||
updater
|
||||
.download_and_prepare_update(&app_handle, &update_info)
|
||||
.await
|
||||
.map_err(|e| format!("Failed to download and prepare app update: {e}"))
|
||||
.map_err(|e| {
|
||||
let msg = e.to_string();
|
||||
// Structured error codes (`{"code": ...}`) must reach the frontend
|
||||
// unwrapped so translateBackendError can resolve them.
|
||||
if msg.starts_with('{') {
|
||||
msg
|
||||
} else {
|
||||
format!("Failed to download and prepare app update: {msg}")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
@@ -1888,6 +2116,87 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_find_checksum_for_file() {
|
||||
let sums = "\
|
||||
0e5a4601745092b7d1c93c1e7e1c30d923be3d1e916b661bd53d1c0c9c7f0a11 Donut_0.29.0_aarch64.dmg
|
||||
ABCDEF01745092B7D1C93C1E7E1C30D923BE3D1E916B661BD53D1C0C9C7F0A22 *Donut_0.29.0_x64.dmg
|
||||
not-a-hash Donut_0.29.0_amd64.deb
|
||||
";
|
||||
|
||||
// Plain entry.
|
||||
assert_eq!(
|
||||
AppAutoUpdater::find_checksum_for_file(sums, "Donut_0.29.0_aarch64.dmg").as_deref(),
|
||||
Some("0e5a4601745092b7d1c93c1e7e1c30d923be3d1e916b661bd53d1c0c9c7f0a11")
|
||||
);
|
||||
// Binary-mode marker is stripped; hash is normalized to lowercase.
|
||||
assert_eq!(
|
||||
AppAutoUpdater::find_checksum_for_file(sums, "Donut_0.29.0_x64.dmg").as_deref(),
|
||||
Some("abcdef01745092b7d1c93c1e7e1c30d923be3d1e916b661bd53d1c0c9c7f0a22")
|
||||
);
|
||||
// Entries with malformed hashes are rejected rather than trusted.
|
||||
assert_eq!(
|
||||
AppAutoUpdater::find_checksum_for_file(sums, "Donut_0.29.0_amd64.deb"),
|
||||
None
|
||||
);
|
||||
// Missing file.
|
||||
assert_eq!(
|
||||
AppAutoUpdater::find_checksum_for_file(sums, "Donut_0.29.0_arm64.deb"),
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_sha256_file_matches_known_digest() {
|
||||
let temp_dir = tempfile::TempDir::new().unwrap();
|
||||
let path = temp_dir.path().join("data.bin");
|
||||
std::fs::write(&path, b"hello world").unwrap();
|
||||
assert_eq!(
|
||||
AppAutoUpdater::sha256_file(&path).unwrap(),
|
||||
// sha256 of "hello world"
|
||||
"b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_find_checksums_url() {
|
||||
let assets = vec![
|
||||
AppReleaseAsset {
|
||||
name: "Donut_0.29.0_x64.dmg".to_string(),
|
||||
browser_download_url: "https://example.com/x64.dmg".to_string(),
|
||||
size: 1,
|
||||
digest: None,
|
||||
},
|
||||
AppReleaseAsset {
|
||||
name: "SHA256SUMS.txt".to_string(),
|
||||
browser_download_url: "https://example.com/SHA256SUMS.txt".to_string(),
|
||||
size: 1,
|
||||
digest: None,
|
||||
},
|
||||
];
|
||||
assert_eq!(
|
||||
AppAutoUpdater::find_checksums_url(&assets).as_deref(),
|
||||
Some("https://example.com/SHA256SUMS.txt")
|
||||
);
|
||||
assert_eq!(AppAutoUpdater::find_checksums_url(&assets[..1]), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_release_asset_digest_is_optional_in_api_json() {
|
||||
// Assets uploaded before GitHub started computing digests omit the field.
|
||||
let without: AppReleaseAsset = serde_json::from_str(
|
||||
r#"{"name": "a.dmg", "browser_download_url": "https://example.com/a.dmg", "size": 5}"#,
|
||||
)
|
||||
.expect("asset without digest should deserialize");
|
||||
assert_eq!(without.digest, None);
|
||||
|
||||
let with: AppReleaseAsset = serde_json::from_str(
|
||||
r#"{"name": "a.dmg", "browser_download_url": "https://example.com/a.dmg", "size": 5, "digest": "sha256:ab12"}"#,
|
||||
)
|
||||
.expect("asset with digest should deserialize");
|
||||
assert_eq!(with.digest.as_deref(), Some("sha256:ab12"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_platform_specific_download_urls() {
|
||||
let updater = AppAutoUpdater::instance();
|
||||
@@ -1899,33 +2208,39 @@ mod tests {
|
||||
name: "Donut.Browser_0.1.0_aarch64.dmg".to_string(),
|
||||
browser_download_url: "https://example.com/aarch64.dmg".to_string(),
|
||||
size: 12345,
|
||||
digest: None,
|
||||
},
|
||||
AppReleaseAsset {
|
||||
name: "Donut.Browser_0.1.0_x64.dmg".to_string(),
|
||||
browser_download_url: "https://example.com/x64.dmg".to_string(),
|
||||
size: 12345,
|
||||
digest: None,
|
||||
},
|
||||
// Windows assets (NSIS naming: _ARCH-setup.exe)
|
||||
AppReleaseAsset {
|
||||
name: "Donut_0.1.0_x64-setup.exe".to_string(),
|
||||
browser_download_url: "https://example.com/x64-setup.exe".to_string(),
|
||||
size: 12345,
|
||||
digest: None,
|
||||
},
|
||||
// Linux assets
|
||||
AppReleaseAsset {
|
||||
name: "donutbrowser_0.1.0_amd64.deb".to_string(),
|
||||
browser_download_url: "https://example.com/amd64.deb".to_string(),
|
||||
size: 12345,
|
||||
digest: None,
|
||||
},
|
||||
AppReleaseAsset {
|
||||
name: "donutbrowser-0.1.0-1.x86_64.rpm".to_string(),
|
||||
browser_download_url: "https://example.com/x86_64.rpm".to_string(),
|
||||
size: 12345,
|
||||
digest: None,
|
||||
},
|
||||
AppReleaseAsset {
|
||||
name: "Donut.Browser-0.1.0-x86_64.AppImage".to_string(),
|
||||
browser_download_url: "https://example.com/x86_64.AppImage".to_string(),
|
||||
size: 12345,
|
||||
digest: None,
|
||||
},
|
||||
];
|
||||
|
||||
@@ -2028,11 +2343,13 @@ mod tests {
|
||||
name: "donutbrowser_0.1.0_amd64.deb".to_string(),
|
||||
browser_download_url: "https://example.com/amd64.deb".to_string(),
|
||||
size: 12345,
|
||||
digest: None,
|
||||
},
|
||||
AppReleaseAsset {
|
||||
name: "Donut.Browser-0.1.0-x86_64.AppImage".to_string(),
|
||||
browser_download_url: "https://example.com/x86_64.AppImage".to_string(),
|
||||
size: 12345,
|
||||
digest: None,
|
||||
},
|
||||
];
|
||||
|
||||
@@ -2073,23 +2390,27 @@ mod tests {
|
||||
name: "Donut.Browser_0.1.0_aarch64.dmg".to_string(),
|
||||
browser_download_url: "https://example.com/aarch64.dmg".to_string(),
|
||||
size: 12345,
|
||||
digest: None,
|
||||
},
|
||||
// Windows assets
|
||||
AppReleaseAsset {
|
||||
name: "Donut.Browser_0.1.0_x64.msi".to_string(),
|
||||
browser_download_url: "https://example.com/x64.msi".to_string(),
|
||||
size: 12345,
|
||||
digest: None,
|
||||
},
|
||||
// Linux assets
|
||||
AppReleaseAsset {
|
||||
name: "donutbrowser_0.1.0_amd64.deb".to_string(),
|
||||
browser_download_url: "https://example.com/amd64.deb".to_string(),
|
||||
size: 12345,
|
||||
digest: None,
|
||||
},
|
||||
AppReleaseAsset {
|
||||
name: "Donut.Browser-0.1.0-x86_64.AppImage".to_string(),
|
||||
browser_download_url: "https://example.com/x86_64.AppImage".to_string(),
|
||||
size: 12345,
|
||||
digest: None,
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
@@ -802,13 +802,13 @@ mod tests {
|
||||
let test_settings_manager = TestSettingsManager::new(temp_dir.path().to_path_buf());
|
||||
|
||||
let mut state = AutoUpdateState::default();
|
||||
state.disabled_browsers.insert("firefox".to_string());
|
||||
state.disabled_browsers.insert("testbrowser".to_string());
|
||||
state
|
||||
.auto_update_downloads
|
||||
.insert("firefox-1.1.0".to_string());
|
||||
.insert("testbrowser-1.1.0".to_string());
|
||||
state.pending_updates.push(UpdateNotification {
|
||||
id: "test".to_string(),
|
||||
browser: "firefox".to_string(),
|
||||
browser: "testbrowser".to_string(),
|
||||
current_version: "1.0.0".to_string(),
|
||||
new_version: "1.1.0".to_string(),
|
||||
affected_profiles: vec!["profile1".to_string()],
|
||||
@@ -830,9 +830,11 @@ mod tests {
|
||||
serde_json::from_str(&content).expect("Failed to deserialize state");
|
||||
|
||||
assert_eq!(loaded_state.disabled_browsers.len(), 1);
|
||||
assert!(loaded_state.disabled_browsers.contains("firefox"));
|
||||
assert!(loaded_state.disabled_browsers.contains("testbrowser"));
|
||||
assert_eq!(loaded_state.auto_update_downloads.len(), 1);
|
||||
assert!(loaded_state.auto_update_downloads.contains("firefox-1.1.0"));
|
||||
assert!(loaded_state
|
||||
.auto_update_downloads
|
||||
.contains("testbrowser-1.1.0"));
|
||||
assert_eq!(loaded_state.pending_updates.len(), 1);
|
||||
assert_eq!(loaded_state.pending_updates[0].id, "test");
|
||||
}
|
||||
@@ -871,16 +873,16 @@ mod tests {
|
||||
// Initially not disabled (empty state file means default state)
|
||||
let state = AutoUpdateState::default();
|
||||
assert!(
|
||||
!state.disabled_browsers.contains("firefox"),
|
||||
"Firefox should not be disabled initially"
|
||||
!state.disabled_browsers.contains("testbrowser"),
|
||||
"testbrowser should not be disabled initially"
|
||||
);
|
||||
|
||||
// Start update (should disable)
|
||||
let mut state = AutoUpdateState::default();
|
||||
state.disabled_browsers.insert("firefox".to_string());
|
||||
state.disabled_browsers.insert("testbrowser".to_string());
|
||||
state
|
||||
.auto_update_downloads
|
||||
.insert("firefox-1.1.0".to_string());
|
||||
.insert("testbrowser-1.1.0".to_string());
|
||||
let json = serde_json::to_string_pretty(&state).expect("Failed to serialize state");
|
||||
std::fs::write(&state_file, json).expect("Failed to write state file");
|
||||
|
||||
@@ -889,18 +891,20 @@ mod tests {
|
||||
let loaded_state: AutoUpdateState =
|
||||
serde_json::from_str(&content).expect("Failed to deserialize state");
|
||||
assert!(
|
||||
loaded_state.disabled_browsers.contains("firefox"),
|
||||
"Firefox should be disabled"
|
||||
loaded_state.disabled_browsers.contains("testbrowser"),
|
||||
"testbrowser should be disabled"
|
||||
);
|
||||
assert!(
|
||||
loaded_state.auto_update_downloads.contains("firefox-1.1.0"),
|
||||
"Firefox download should be tracked"
|
||||
loaded_state
|
||||
.auto_update_downloads
|
||||
.contains("testbrowser-1.1.0"),
|
||||
"testbrowser download should be tracked"
|
||||
);
|
||||
|
||||
// Complete update (should enable)
|
||||
let mut state = loaded_state;
|
||||
state.disabled_browsers.remove("firefox");
|
||||
state.auto_update_downloads.remove("firefox-1.1.0");
|
||||
state.disabled_browsers.remove("testbrowser");
|
||||
state.auto_update_downloads.remove("testbrowser-1.1.0");
|
||||
let json = serde_json::to_string_pretty(&state).expect("Failed to serialize final state");
|
||||
std::fs::write(&state_file, json).expect("Failed to write final state file");
|
||||
|
||||
@@ -909,12 +913,14 @@ mod tests {
|
||||
let final_state: AutoUpdateState =
|
||||
serde_json::from_str(&content).expect("Failed to deserialize final state");
|
||||
assert!(
|
||||
!final_state.disabled_browsers.contains("firefox"),
|
||||
"Firefox should be enabled again"
|
||||
!final_state.disabled_browsers.contains("testbrowser"),
|
||||
"testbrowser should be enabled again"
|
||||
);
|
||||
assert!(
|
||||
!final_state.auto_update_downloads.contains("firefox-1.1.0"),
|
||||
"Firefox download should not be tracked anymore"
|
||||
!final_state
|
||||
.auto_update_downloads
|
||||
.contains("testbrowser-1.1.0"),
|
||||
"testbrowser download should not be tracked anymore"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -945,7 +951,7 @@ mod tests {
|
||||
let mut state = AutoUpdateState::default();
|
||||
state.pending_updates.push(UpdateNotification {
|
||||
id: "test_notification".to_string(),
|
||||
browser: "firefox".to_string(),
|
||||
browser: "testbrowser".to_string(),
|
||||
current_version: "1.0.0".to_string(),
|
||||
new_version: "1.1.0".to_string(),
|
||||
affected_profiles: vec!["profile1".to_string()],
|
||||
|
||||
@@ -294,7 +294,7 @@ impl BrowserRunner {
|
||||
config_for_generation.fingerprint = None;
|
||||
|
||||
// Generate a new fingerprint
|
||||
let new_fingerprint = self
|
||||
let (new_fingerprint, geolocation_applied) = self
|
||||
.wayfern_manager
|
||||
.generate_fingerprint_config(&app_handle, profile, &config_for_generation)
|
||||
.await
|
||||
@@ -318,13 +318,19 @@ impl BrowserRunner {
|
||||
updated_wayfern_config.os = wayfern_config.os.clone();
|
||||
}
|
||||
// The fresh fingerprint's location matches the current routing; record
|
||||
// its signature so launches keep it in sync with the non-randomize path.
|
||||
updated_wayfern_config.geo_proxy_signature =
|
||||
// its signature so launches keep it in sync with the non-randomize
|
||||
// path. Only when geolocation actually applied — otherwise leave it
|
||||
// unset so the refresh path can repair the location if the user later
|
||||
// turns randomize off.
|
||||
updated_wayfern_config.geo_proxy_signature = if geolocation_applied {
|
||||
Some(crate::wayfern_manager::WayfernManager::geo_signature(
|
||||
upstream_proxy.as_ref(),
|
||||
profile.vpn_id.as_deref(),
|
||||
wayfern_config.geoip.as_ref(),
|
||||
));
|
||||
))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
updated_profile.wayfern_config = Some(updated_wayfern_config.clone());
|
||||
|
||||
log::info!(
|
||||
|
||||
@@ -109,7 +109,7 @@ impl BrowserVersionManager {
|
||||
self.api_client.is_cache_expired(browser)
|
||||
}
|
||||
|
||||
/// Get the latest Wayfern version (cached first)
|
||||
/// Get the latest Wayfern version (fresh cache first)
|
||||
pub async fn get_browser_release_types(
|
||||
&self,
|
||||
browser: &str,
|
||||
@@ -118,17 +118,30 @@ impl BrowserVersionManager {
|
||||
return Err(format!("Unsupported browser: {browser}").into());
|
||||
}
|
||||
|
||||
if let Some(cached_versions) = self.get_cached_browser_versions_detailed(browser) {
|
||||
return Ok(BrowserReleaseTypes {
|
||||
stable: cached_versions.first().map(|v| v.version.clone()),
|
||||
});
|
||||
// Only trust an unexpired cache. A stale entry can point at a version that
|
||||
// is no longer published — the downloader rejects such requests, so serving
|
||||
// it here would make every download started from this list fail.
|
||||
if !self.api_client.is_cache_expired(browser) {
|
||||
if let Some(cached_versions) = self.get_cached_browser_versions_detailed(browser) {
|
||||
return Ok(BrowserReleaseTypes {
|
||||
stable: cached_versions.first().map(|v| v.version.clone()),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
let detailed_versions = self.fetch_browser_versions_detailed(browser, false).await?;
|
||||
|
||||
Ok(BrowserReleaseTypes {
|
||||
stable: detailed_versions.first().map(|v| v.version.clone()),
|
||||
})
|
||||
// Expired or missing cache: fetch fresh, falling back to whatever cache
|
||||
// exists when the network is unavailable.
|
||||
match self.fetch_browser_versions_detailed(browser, false).await {
|
||||
Ok(detailed_versions) => Ok(BrowserReleaseTypes {
|
||||
stable: detailed_versions.first().map(|v| v.version.clone()),
|
||||
}),
|
||||
Err(e) => match self.get_cached_browser_versions_detailed(browser) {
|
||||
Some(cached_versions) => Ok(BrowserReleaseTypes {
|
||||
stable: cached_versions.first().map(|v| v.version.clone()),
|
||||
}),
|
||||
None => Err(e),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// Fetch browser versions with optional caching
|
||||
@@ -440,7 +453,7 @@ mod tests {
|
||||
|
||||
assert!(wayfern_info.url.contains("download.wayfern.com"));
|
||||
|
||||
let unsupported_result = service.get_download_info("firefox", "1.0.0");
|
||||
let unsupported_result = service.get_download_info("testbrowser", "1.0.0");
|
||||
assert!(unsupported_result.is_err());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1427,7 +1427,7 @@ mod tests {
|
||||
.unwrap();
|
||||
|
||||
// Imported session cookies are promoted to persistent with a far-future
|
||||
// expiry so an imported login survives relaunch (mirrors the Firefox writer).
|
||||
// expiry so an imported login survives relaunch.
|
||||
assert_eq!(has_expires, 1);
|
||||
assert_eq!(is_persistent, 1);
|
||||
// Must be a real future expiry, not 0 (which Chromium reads as 1601-01-01).
|
||||
|
||||
@@ -1057,15 +1057,15 @@ mod tests {
|
||||
fn test_add_and_get_browser() {
|
||||
let registry = DownloadedBrowsersRegistry::new();
|
||||
let info = DownloadedBrowserInfo {
|
||||
browser: "firefox".to_string(),
|
||||
browser: "testbrowser".to_string(),
|
||||
version: "139.0".to_string(),
|
||||
file_path: PathBuf::from("/test/path"),
|
||||
};
|
||||
|
||||
registry.add_browser(info.clone());
|
||||
|
||||
assert!(registry.is_browser_registered("firefox", "139.0"));
|
||||
assert!(!registry.is_browser_registered("firefox", "140.0"));
|
||||
assert!(registry.is_browser_registered("testbrowser", "139.0"));
|
||||
assert!(!registry.is_browser_registered("testbrowser", "140.0"));
|
||||
assert!(!registry.is_browser_registered("chrome", "139.0"));
|
||||
}
|
||||
|
||||
@@ -1074,19 +1074,19 @@ mod tests {
|
||||
let registry = DownloadedBrowsersRegistry::new();
|
||||
|
||||
let info1 = DownloadedBrowserInfo {
|
||||
browser: "firefox".to_string(),
|
||||
browser: "testbrowser".to_string(),
|
||||
version: "139.0".to_string(),
|
||||
file_path: PathBuf::from("/test/path1"),
|
||||
};
|
||||
|
||||
let info2 = DownloadedBrowserInfo {
|
||||
browser: "firefox".to_string(),
|
||||
browser: "testbrowser".to_string(),
|
||||
version: "140.0".to_string(),
|
||||
file_path: PathBuf::from("/test/path2"),
|
||||
};
|
||||
|
||||
let info3 = DownloadedBrowserInfo {
|
||||
browser: "firefox".to_string(),
|
||||
browser: "testbrowser".to_string(),
|
||||
version: "141.0".to_string(),
|
||||
file_path: PathBuf::from("/test/path3"),
|
||||
};
|
||||
@@ -1095,7 +1095,7 @@ mod tests {
|
||||
registry.add_browser(info2);
|
||||
registry.add_browser(info3);
|
||||
|
||||
let versions = registry.get_downloaded_versions("firefox");
|
||||
let versions = registry.get_downloaded_versions("testbrowser");
|
||||
assert_eq!(versions.len(), 3);
|
||||
assert!(versions.contains(&"139.0".to_string()));
|
||||
assert!(versions.contains(&"140.0".to_string()));
|
||||
@@ -1107,22 +1107,22 @@ mod tests {
|
||||
let registry = DownloadedBrowsersRegistry::new();
|
||||
|
||||
// Mark download started
|
||||
registry.mark_download_started("firefox", "139.0", PathBuf::from("/test/path"));
|
||||
registry.mark_download_started("testbrowser", "139.0", PathBuf::from("/test/path"));
|
||||
|
||||
// Should NOT be registered until verification completes
|
||||
assert!(
|
||||
!registry.is_browser_registered("firefox", "139.0"),
|
||||
!registry.is_browser_registered("testbrowser", "139.0"),
|
||||
"Browser should NOT be registered after marking as started (only after verification)"
|
||||
);
|
||||
|
||||
// Mark as completed (after verification)
|
||||
registry
|
||||
.mark_download_completed("firefox", "139.0", PathBuf::from("/test/path"))
|
||||
.mark_download_completed("testbrowser", "139.0", PathBuf::from("/test/path"))
|
||||
.expect("Failed to mark download as completed");
|
||||
|
||||
// Should now be registered
|
||||
assert!(
|
||||
registry.is_browser_registered("firefox", "139.0"),
|
||||
registry.is_browser_registered("testbrowser", "139.0"),
|
||||
"Browser should be registered after verification completes"
|
||||
);
|
||||
}
|
||||
@@ -1131,24 +1131,24 @@ mod tests {
|
||||
fn test_remove_browser() {
|
||||
let registry = DownloadedBrowsersRegistry::new();
|
||||
let info = DownloadedBrowserInfo {
|
||||
browser: "firefox".to_string(),
|
||||
browser: "testbrowser".to_string(),
|
||||
version: "139.0".to_string(),
|
||||
file_path: PathBuf::from("/test/path"),
|
||||
};
|
||||
|
||||
registry.add_browser(info);
|
||||
assert!(
|
||||
registry.is_browser_registered("firefox", "139.0"),
|
||||
registry.is_browser_registered("testbrowser", "139.0"),
|
||||
"Browser should be registered after adding"
|
||||
);
|
||||
|
||||
let removed = registry.remove_browser("firefox", "139.0");
|
||||
let removed = registry.remove_browser("testbrowser", "139.0");
|
||||
assert!(
|
||||
removed.is_some(),
|
||||
"Remove operation should return the removed browser info"
|
||||
);
|
||||
assert!(
|
||||
!registry.is_browser_registered("firefox", "139.0"),
|
||||
!registry.is_browser_registered("testbrowser", "139.0"),
|
||||
"Browser should not be registered after removal"
|
||||
);
|
||||
}
|
||||
@@ -1182,11 +1182,11 @@ mod tests {
|
||||
fn test_last_version_kept_during_cleanup() {
|
||||
let registry = DownloadedBrowsersRegistry::new();
|
||||
|
||||
// Add a single version for "firefox"
|
||||
// Add a single version for "testbrowser"
|
||||
registry.add_browser(DownloadedBrowserInfo {
|
||||
browser: "firefox".to_string(),
|
||||
browser: "testbrowser".to_string(),
|
||||
version: "139.0".to_string(),
|
||||
file_path: PathBuf::from("/test/firefox/139.0"),
|
||||
file_path: PathBuf::from("/test/testbrowser/139.0"),
|
||||
});
|
||||
|
||||
// Add two versions for "chromium"
|
||||
@@ -1206,11 +1206,11 @@ mod tests {
|
||||
.cleanup_unused_binaries_internal(&[], &[])
|
||||
.expect("cleanup should succeed");
|
||||
|
||||
// firefox 139.0 should be kept (last version), chromium should lose one but keep one
|
||||
// testbrowser 139.0 should be kept (last version), chromium should lose one but keep one
|
||||
// The exact one kept depends on iteration order, but at least one must remain
|
||||
assert!(
|
||||
!result.contains(&"firefox 139.0".to_string()),
|
||||
"Last version of firefox should not be cleaned up"
|
||||
!result.contains(&"testbrowser 139.0".to_string()),
|
||||
"Last version of testbrowser should not be cleaned up"
|
||||
);
|
||||
// At most one chromium version should have been cleaned up
|
||||
let chromium_cleaned: Vec<_> = result
|
||||
@@ -1223,10 +1223,10 @@ mod tests {
|
||||
chromium_cleaned
|
||||
);
|
||||
|
||||
// Verify firefox is still registered
|
||||
// Verify testbrowser is still registered
|
||||
assert!(
|
||||
registry.is_browser_registered("firefox", "139.0"),
|
||||
"Last firefox version should still be registered"
|
||||
registry.is_browser_registered("testbrowser", "139.0"),
|
||||
"Last testbrowser version should still be registered"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1234,7 +1234,7 @@ mod tests {
|
||||
fn test_is_browser_registered_vs_downloaded() {
|
||||
let registry = DownloadedBrowsersRegistry::new();
|
||||
let info = DownloadedBrowserInfo {
|
||||
browser: "firefox".to_string(),
|
||||
browser: "testbrowser".to_string(),
|
||||
version: "139.0".to_string(),
|
||||
file_path: PathBuf::from("/test/path"),
|
||||
};
|
||||
@@ -1244,14 +1244,14 @@ mod tests {
|
||||
|
||||
// Should be registered (in-memory check)
|
||||
assert!(
|
||||
registry.is_browser_registered("firefox", "139.0"),
|
||||
registry.is_browser_registered("testbrowser", "139.0"),
|
||||
"Browser should be registered after adding to registry"
|
||||
);
|
||||
|
||||
// is_browser_downloaded should return false in test environment because files don't exist
|
||||
// This tests the difference between registered (in registry) vs downloaded (files exist)
|
||||
assert!(
|
||||
!registry.is_browser_downloaded("firefox", "139.0"),
|
||||
!registry.is_browser_downloaded("testbrowser", "139.0"),
|
||||
"Browser should not be considered downloaded when files don't exist on disk"
|
||||
);
|
||||
}
|
||||
@@ -1277,21 +1277,38 @@ pub async fn ensure_active_browsers_downloaded(
|
||||
}
|
||||
log::info!("ensure_active: No {browser} versions found, will download");
|
||||
|
||||
// Get the latest release type for this browser
|
||||
let release_types = match version_manager.get_browser_release_types(browser).await {
|
||||
Ok(rt) => rt,
|
||||
Err(e) => {
|
||||
log::warn!("Failed to get release types for {browser}: {e}");
|
||||
continue;
|
||||
// Resolve the version to download. For wayfern, only the currently
|
||||
// published version is downloadable, so ask the API fresh — the release-type
|
||||
// cache can be stale right after a new version is published, and the
|
||||
// downloader rejects requests for versions that are no longer available.
|
||||
let version = if *browser == "wayfern" {
|
||||
match crate::api_client::ApiClient::instance()
|
||||
.fetch_wayfern_version_with_caching(true)
|
||||
.await
|
||||
{
|
||||
Ok(info) => info.version,
|
||||
Err(e) => {
|
||||
log::warn!("Failed to resolve current {browser} version: {e}");
|
||||
continue;
|
||||
}
|
||||
}
|
||||
};
|
||||
} else {
|
||||
// Get the latest release type for this browser
|
||||
let release_types = match version_manager.get_browser_release_types(browser).await {
|
||||
Ok(rt) => rt,
|
||||
Err(e) => {
|
||||
log::warn!("Failed to get release types for {browser}: {e}");
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
// Use stable version (the only release type for these browsers)
|
||||
let version = match release_types.stable {
|
||||
Some(v) => v,
|
||||
None => {
|
||||
log::debug!("No stable version available for {browser} on this platform, skipping");
|
||||
continue;
|
||||
// Use stable version (the only release type for these browsers)
|
||||
match release_types.stable {
|
||||
Some(v) => v,
|
||||
None => {
|
||||
log::debug!("No stable version available for {browser} on this platform, skipping");
|
||||
continue;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1330,8 +1347,8 @@ pub async fn ensure_active_browsers_downloaded(
|
||||
Err(_) => {
|
||||
// The download future itself hung past the overall timeout and was dropped,
|
||||
// so its own cleanup never ran. Clear any leftover in-progress bookkeeping
|
||||
// (the future may have re-resolved to a different version, so clear by
|
||||
// browser prefix) and emit a terminal error event so the UI stops spinning.
|
||||
// (by browser prefix, as a catch-all for whatever key was in flight) and
|
||||
// emit a terminal error event so the UI stops spinning.
|
||||
log::warn!(
|
||||
"Auto-download of {browser} {version} timed out after {}s (attempt {attempt}/{MAX_ATTEMPTS})",
|
||||
ATTEMPT_TIMEOUT.as_secs()
|
||||
|
||||
+71
-73
@@ -23,6 +23,22 @@ lazy_static::lazy_static! {
|
||||
std::sync::Arc::new(Mutex::new(std::collections::HashMap::new()));
|
||||
}
|
||||
|
||||
/// Clears a browser-version pair from the in-flight download maps on every
|
||||
/// exit path of `download_browser_full`. A leaked key would permanently report
|
||||
/// "already being downloaded" for that version until app restart.
|
||||
struct InFlightDownload(String);
|
||||
|
||||
impl Drop for InFlightDownload {
|
||||
fn drop(&mut self) {
|
||||
if let Ok(mut downloading) = DOWNLOADING_BROWSERS.lock() {
|
||||
downloading.remove(&self.0);
|
||||
}
|
||||
if let Ok(mut tokens) = DOWNLOAD_CANCELLATION_TOKENS.lock() {
|
||||
tokens.remove(&self.0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
pub struct DownloadProgress {
|
||||
pub browser: String,
|
||||
@@ -97,7 +113,13 @@ impl Downloader {
|
||||
.await?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
return Err(format!("Download failed with status: {}", response.status()).into());
|
||||
return Err(
|
||||
format!(
|
||||
"Download failed with HTTP status {}",
|
||||
response.status().as_u16()
|
||||
)
|
||||
.into(),
|
||||
);
|
||||
}
|
||||
|
||||
let mut file = std::fs::OpenOptions::new()
|
||||
@@ -131,10 +153,16 @@ impl Downloader {
|
||||
.fetch_wayfern_version_with_caching(true)
|
||||
.await?;
|
||||
|
||||
// Never substitute: downloading the current build into the requested
|
||||
// version's directory would register a mislabeled install.
|
||||
if version_info.version != version {
|
||||
log::info!(
|
||||
"Wayfern: requested version {version}, using available version {}",
|
||||
version_info.version
|
||||
return Err(
|
||||
serde_json::json!({
|
||||
"code": "WAYFERN_VERSION_NOT_AVAILABLE",
|
||||
"params": { "requested": version, "current": version_info.version }
|
||||
})
|
||||
.to_string()
|
||||
.into(),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -301,7 +329,13 @@ impl Downloader {
|
||||
|
||||
// Check if the response is successful (200 OK or 206 Partial Content)
|
||||
if !(response.status().is_success() || response.status().as_u16() == 206) {
|
||||
return Err(format!("Download failed with status: {}", response.status()).into());
|
||||
return Err(
|
||||
format!(
|
||||
"Download failed with HTTP status {}",
|
||||
response.status().as_u16()
|
||||
)
|
||||
.into(),
|
||||
);
|
||||
}
|
||||
|
||||
// Determine total size
|
||||
@@ -504,25 +538,36 @@ impl Downloader {
|
||||
return Err("Please accept Wayfern Terms and Conditions before downloading browsers".into());
|
||||
}
|
||||
|
||||
// For Wayfern, resolve the actual available version from the API
|
||||
let version = if browser_str == "wayfern" {
|
||||
match self
|
||||
// Validate the browser type before touching the in-flight maps so a bad
|
||||
// request can't leave state behind.
|
||||
let browser_type =
|
||||
BrowserType::from_str(&browser_str).map_err(|e| format!("Invalid browser type: {e}"))?;
|
||||
let browser = create_browser(browser_type.clone());
|
||||
|
||||
// For Wayfern, only the currently published version can be fetched.
|
||||
// Requesting any other not-yet-downloaded version is an error — silently
|
||||
// substituting the latest would install a version the caller never asked
|
||||
// for while the response still echoes the requested one. The fetch must
|
||||
// succeed too: proceeding unverified would let resolve_download_url fetch
|
||||
// the current build into the requested version's directory (a mislabeled
|
||||
// install), and that URL resolution needs the same endpoint anyway.
|
||||
if browser_str == "wayfern" && !self.registry.is_browser_downloaded(&browser_str, &version) {
|
||||
let info = self
|
||||
.api_client
|
||||
.fetch_wayfern_version_with_caching(true)
|
||||
.await
|
||||
{
|
||||
Ok(info) if info.version != version => {
|
||||
log::info!(
|
||||
"Wayfern: requested {version}, using available {}",
|
||||
info.version
|
||||
);
|
||||
info.version
|
||||
}
|
||||
_ => version,
|
||||
.map_err(|e| format!("Failed to determine the current Wayfern version: {e}"))?;
|
||||
if info.version != version {
|
||||
return Err(
|
||||
serde_json::json!({
|
||||
"code": "WAYFERN_VERSION_NOT_AVAILABLE",
|
||||
"params": { "requested": version, "current": info.version }
|
||||
})
|
||||
.to_string()
|
||||
.into(),
|
||||
);
|
||||
}
|
||||
} else {
|
||||
version
|
||||
};
|
||||
}
|
||||
|
||||
// Check if this browser-version pair is already being downloaded
|
||||
let download_key = format!("{browser_str}-{version}");
|
||||
@@ -539,10 +584,8 @@ impl Downloader {
|
||||
tokens.insert(download_key.clone(), token.clone());
|
||||
token
|
||||
};
|
||||
|
||||
let browser_type =
|
||||
BrowserType::from_str(&browser_str).map_err(|e| format!("Invalid browser type: {e}"))?;
|
||||
let browser = create_browser(browser_type.clone());
|
||||
// Cleared on drop, whatever exit path this function takes.
|
||||
let _in_flight = InFlightDownload(download_key.clone());
|
||||
|
||||
let binaries_dir = crate::app_dirs::binaries_dir();
|
||||
|
||||
@@ -551,12 +594,6 @@ impl Downloader {
|
||||
let actually_exists = browser.is_version_downloaded(&version, &binaries_dir);
|
||||
|
||||
if actually_exists {
|
||||
// Remove from downloading set since it's already downloaded
|
||||
let mut downloading = DOWNLOADING_BROWSERS.lock().unwrap();
|
||||
downloading.remove(&download_key);
|
||||
drop(downloading);
|
||||
let mut tokens = DOWNLOAD_CANCELLATION_TOKENS.lock().unwrap();
|
||||
tokens.remove(&download_key);
|
||||
return Ok(version);
|
||||
} else {
|
||||
// Registry says it's downloaded but files don't exist - clean up registry
|
||||
@@ -575,12 +612,6 @@ impl Downloader {
|
||||
.is_browser_supported(&browser_str)
|
||||
.unwrap_or(false)
|
||||
{
|
||||
// Remove from downloading set on error
|
||||
let mut downloading = DOWNLOADING_BROWSERS.lock().unwrap();
|
||||
downloading.remove(&download_key);
|
||||
drop(downloading);
|
||||
let mut tokens = DOWNLOAD_CANCELLATION_TOKENS.lock().unwrap();
|
||||
tokens.remove(&download_key);
|
||||
return Err(
|
||||
format!(
|
||||
"Browser '{}' is not supported on your platform ({} {}). Supported browsers: {}",
|
||||
@@ -630,11 +661,6 @@ impl Downloader {
|
||||
// Clean registry entry and stop here so the UI can show a single, clear error.
|
||||
let _ = self.registry.remove_browser(&browser_str, &version);
|
||||
let _ = self.registry.save();
|
||||
let mut downloading = DOWNLOADING_BROWSERS.lock().unwrap();
|
||||
downloading.remove(&download_key);
|
||||
drop(downloading);
|
||||
let mut tokens = DOWNLOAD_CANCELLATION_TOKENS.lock().unwrap();
|
||||
tokens.remove(&download_key);
|
||||
|
||||
// Emit a terminal stage so the UI stops spinning. A user cancellation maps to
|
||||
// "cancelled"; any other failure (network error, stall timeout, bad status)
|
||||
@@ -687,14 +713,6 @@ impl Downloader {
|
||||
|
||||
let _ = self.registry.remove_browser(&browser_str, &version);
|
||||
let _ = self.registry.save();
|
||||
{
|
||||
let mut downloading = DOWNLOADING_BROWSERS.lock().unwrap();
|
||||
downloading.remove(&download_key);
|
||||
}
|
||||
{
|
||||
let mut tokens = DOWNLOAD_CANCELLATION_TOKENS.lock().unwrap();
|
||||
tokens.remove(&download_key);
|
||||
}
|
||||
|
||||
// Emit error stage so the UI shows a toast
|
||||
let progress = DownloadProgress {
|
||||
@@ -777,15 +795,6 @@ impl Downloader {
|
||||
};
|
||||
let _ = events::emit("download-progress", &progress);
|
||||
|
||||
// Remove browser-version pair from downloading set on verification failure
|
||||
{
|
||||
let mut downloading = DOWNLOADING_BROWSERS.lock().unwrap();
|
||||
downloading.remove(&download_key);
|
||||
}
|
||||
{
|
||||
let mut tokens = DOWNLOAD_CANCELLATION_TOKENS.lock().unwrap();
|
||||
tokens.remove(&download_key);
|
||||
}
|
||||
return Err(error_details.into());
|
||||
}
|
||||
|
||||
@@ -825,16 +834,6 @@ impl Downloader {
|
||||
};
|
||||
let _ = events::emit("download-progress", &progress);
|
||||
|
||||
// Remove browser-version pair from downloading set and cancel token
|
||||
{
|
||||
let mut downloading = DOWNLOADING_BROWSERS.lock().unwrap();
|
||||
downloading.remove(&download_key);
|
||||
}
|
||||
{
|
||||
let mut tokens = DOWNLOAD_CANCELLATION_TOKENS.lock().unwrap();
|
||||
tokens.remove(&download_key);
|
||||
}
|
||||
|
||||
// Auto-update non-running profiles to the latest installed version and cleanup unused binaries
|
||||
{
|
||||
let app_handle_for_update = app_handle.clone();
|
||||
@@ -883,10 +882,9 @@ pub fn is_downloading(browser: &str, version: &str) -> bool {
|
||||
/// Clear all in-progress download bookkeeping for a browser.
|
||||
///
|
||||
/// Used as a last-resort cleanup when a download future is abandoned (e.g. dropped
|
||||
/// by an outer timeout) before its own error path could run. Because
|
||||
/// `download_browser_full` may re-resolve to a different version than requested, this
|
||||
/// matches by the `"{browser}-"` key prefix rather than an exact version so no stuck
|
||||
/// key is left behind regardless of which version was actually in flight.
|
||||
/// by an outer timeout) before its own error path could run. Matches by the
|
||||
/// `"{browser}-"` key prefix rather than an exact version so no stuck key is left
|
||||
/// behind even when the caller doesn't know which version was actually in flight.
|
||||
pub fn clear_download_state_for_browser(browser: &str) {
|
||||
let prefix = format!("{browser}-");
|
||||
{
|
||||
@@ -909,7 +907,7 @@ pub async fn download_browser(
|
||||
downloader
|
||||
.download_browser_full(&app_handle, browser_str, version)
|
||||
.await
|
||||
.map_err(|e| format!("Failed to download browser: {e}"))
|
||||
.map_err(|e| crate::wrap_backend_error(e, "Failed to download browser"))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
|
||||
@@ -280,12 +280,21 @@ impl ExtensionManager {
|
||||
let (manifest_name, version, description, author, homepage_url) =
|
||||
extract_manifest_metadata(&file_data, &file_type);
|
||||
|
||||
let final_name = if manifest_name.is_some() {
|
||||
manifest_name.clone().unwrap_or(name)
|
||||
} else {
|
||||
name
|
||||
// An empty/whitespace-only manifest name counts as absent so the
|
||||
// user-provided name still applies.
|
||||
let final_name = match manifest_name.clone() {
|
||||
Some(n) if !n.trim().is_empty() => n,
|
||||
_ => name,
|
||||
};
|
||||
|
||||
if final_name.trim().is_empty() {
|
||||
return Err(
|
||||
serde_json::json!({ "code": "NAME_CANNOT_BE_EMPTY" })
|
||||
.to_string()
|
||||
.into(),
|
||||
);
|
||||
}
|
||||
|
||||
let ext = Extension {
|
||||
id: uuid::Uuid::new_v4().to_string(),
|
||||
name: final_name,
|
||||
@@ -510,6 +519,14 @@ impl ExtensionManager {
|
||||
}
|
||||
|
||||
pub fn create_group(&self, name: String) -> Result<ExtensionGroup, Box<dyn std::error::Error>> {
|
||||
if name.trim().is_empty() {
|
||||
return Err(
|
||||
serde_json::json!({ "code": "NAME_CANNOT_BE_EMPTY" })
|
||||
.to_string()
|
||||
.into(),
|
||||
);
|
||||
}
|
||||
|
||||
let mut data = self.load_groups_data()?;
|
||||
|
||||
if data.groups.iter().any(|g| g.name == name) {
|
||||
@@ -566,6 +583,14 @@ impl ExtensionManager {
|
||||
name: Option<String>,
|
||||
extension_ids: Option<Vec<String>>,
|
||||
) -> Result<ExtensionGroup, Box<dyn std::error::Error>> {
|
||||
if name.as_deref().is_some_and(|n| n.trim().is_empty()) {
|
||||
return Err(
|
||||
serde_json::json!({ "code": "NAME_CANNOT_BE_EMPTY" })
|
||||
.to_string()
|
||||
.into(),
|
||||
);
|
||||
}
|
||||
|
||||
let mut data = self.load_groups_data()?;
|
||||
|
||||
if let Some(ref new_name) = name {
|
||||
@@ -1080,7 +1105,7 @@ pub async fn add_extension(
|
||||
let mgr = EXTENSION_MANAGER.lock().unwrap();
|
||||
mgr
|
||||
.add_extension(name, file_name, file_data)
|
||||
.map_err(|e| format!("Failed to add extension: {e}"))
|
||||
.map_err(|e| crate::wrap_backend_error(e, "Failed to add extension"))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
@@ -1120,7 +1145,7 @@ pub async fn create_extension_group(name: String) -> Result<ExtensionGroup, Stri
|
||||
let mgr = EXTENSION_MANAGER.lock().unwrap();
|
||||
mgr
|
||||
.create_group(name)
|
||||
.map_err(|e| format!("Failed to create extension group: {e}"))
|
||||
.map_err(|e| crate::wrap_backend_error(e, "Failed to create extension group"))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
@@ -1132,7 +1157,7 @@ pub async fn update_extension_group(
|
||||
let mgr = EXTENSION_MANAGER.lock().unwrap();
|
||||
mgr
|
||||
.update_group(&group_id, name, extension_ids)
|
||||
.map_err(|e| format!("Failed to update extension group: {e}"))
|
||||
.map_err(|e| crate::wrap_backend_error(e, "Failed to update extension group"))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
|
||||
+314
-27
@@ -45,6 +45,137 @@ fn has_quarantine_attr(path: &Path) -> bool {
|
||||
result >= 0
|
||||
}
|
||||
|
||||
/// Best-effort recursive size of a file tree. Uses `symlink_metadata` so
|
||||
/// symlinks inside .app bundles are not followed (`cp -R` copies them as
|
||||
/// links, so following them would overcount and could loop).
|
||||
#[cfg(target_os = "macos")]
|
||||
fn dir_size(path: &Path) -> u64 {
|
||||
let Ok(meta) = fs::symlink_metadata(path) else {
|
||||
return 0;
|
||||
};
|
||||
if meta.is_file() {
|
||||
return meta.len();
|
||||
}
|
||||
if !meta.is_dir() {
|
||||
return 0;
|
||||
}
|
||||
let Ok(entries) = fs::read_dir(path) else {
|
||||
return 0;
|
||||
};
|
||||
entries.flatten().map(|entry| dir_size(&entry.path())).sum()
|
||||
}
|
||||
|
||||
const PROGRESS_REPORT_INTERVAL: std::time::Duration = std::time::Duration::from_millis(150);
|
||||
|
||||
/// Emits throttled "extracting" progress on the `download-progress` channel so
|
||||
/// the UI can render a moving bar during long extractions.
|
||||
pub struct ExtractionReporter {
|
||||
browser: String,
|
||||
version: String,
|
||||
last_emit: std::sync::Mutex<std::time::Instant>,
|
||||
}
|
||||
|
||||
impl ExtractionReporter {
|
||||
pub fn new(browser: String, version: String) -> Self {
|
||||
// Backdate so the first report goes out immediately.
|
||||
let backdated = std::time::Instant::now()
|
||||
.checked_sub(PROGRESS_REPORT_INTERVAL)
|
||||
.unwrap_or_else(std::time::Instant::now);
|
||||
Self {
|
||||
browser,
|
||||
version,
|
||||
last_emit: std::sync::Mutex::new(backdated),
|
||||
}
|
||||
}
|
||||
|
||||
/// Report byte-level progress where bytes map linearly onto the whole job.
|
||||
pub fn report_bytes(&self, done: u64, total: u64) {
|
||||
if total == 0 {
|
||||
return;
|
||||
}
|
||||
let done = done.min(total);
|
||||
self.report((done as f64 / total as f64) * 100.0, done, Some(total));
|
||||
}
|
||||
|
||||
/// Report a pre-computed percentage (multi-phase extractions where byte
|
||||
/// counts don't map linearly onto overall progress).
|
||||
pub fn report_percentage(&self, percentage: f64) {
|
||||
self.report(percentage, 0, None);
|
||||
}
|
||||
|
||||
/// Force a final 100% event so the bar lands full before the next stage.
|
||||
pub fn finish(&self) {
|
||||
self.emit(100.0, 0, None);
|
||||
}
|
||||
|
||||
fn report(&self, percentage: f64, downloaded_bytes: u64, total_bytes: Option<u64>) {
|
||||
{
|
||||
let mut last = self.last_emit.lock().unwrap();
|
||||
if last.elapsed() < PROGRESS_REPORT_INTERVAL {
|
||||
return;
|
||||
}
|
||||
*last = std::time::Instant::now();
|
||||
}
|
||||
self.emit(percentage, downloaded_bytes, total_bytes);
|
||||
}
|
||||
|
||||
fn emit(&self, percentage: f64, downloaded_bytes: u64, total_bytes: Option<u64>) {
|
||||
let progress = DownloadProgress {
|
||||
browser: self.browser.clone(),
|
||||
version: self.version.clone(),
|
||||
downloaded_bytes,
|
||||
total_bytes,
|
||||
percentage: percentage.clamp(0.0, 100.0),
|
||||
speed_bytes_per_sec: 0.0,
|
||||
eta_seconds: None,
|
||||
stage: "extracting".to_string(),
|
||||
};
|
||||
let _ = events::emit("download-progress", &progress);
|
||||
}
|
||||
}
|
||||
|
||||
/// A reader that passes cumulative bytes read to a callback on every read.
|
||||
/// Throttling is the reporter's job, so the callback can fire freely.
|
||||
struct ProgressReader<R, F: FnMut(u64)> {
|
||||
inner: R,
|
||||
bytes_read: u64,
|
||||
on_read: F,
|
||||
}
|
||||
|
||||
impl<R, F: FnMut(u64)> ProgressReader<R, F> {
|
||||
fn new(inner: R, on_read: F) -> Self {
|
||||
Self {
|
||||
inner,
|
||||
bytes_read: 0,
|
||||
on_read,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<R: Read, F: FnMut(u64)> Read for ProgressReader<R, F> {
|
||||
fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> {
|
||||
let n = self.inner.read(buf)?;
|
||||
self.bytes_read += n as u64;
|
||||
(self.on_read)(self.bytes_read);
|
||||
Ok(n)
|
||||
}
|
||||
}
|
||||
|
||||
/// Wrap an archive file so compressed bytes consumed report linearly against
|
||||
/// its on-disk size — shared by the streaming tar decoders, where stream
|
||||
/// position maps monotonically onto overall extraction progress.
|
||||
fn progress_file_reader(
|
||||
file: File,
|
||||
progress: Option<&ExtractionReporter>,
|
||||
) -> io::Result<impl Read + '_> {
|
||||
let compressed_total = file.metadata()?.len();
|
||||
Ok(ProgressReader::new(file, move |read| {
|
||||
if let Some(reporter) = progress {
|
||||
reporter.report_bytes(read, compressed_total);
|
||||
}
|
||||
}))
|
||||
}
|
||||
|
||||
pub struct Extractor;
|
||||
|
||||
impl Extractor {
|
||||
@@ -145,6 +276,11 @@ impl Extractor {
|
||||
};
|
||||
let _ = events::emit("download-progress", &progress);
|
||||
|
||||
// Reports incremental extraction progress to the UI. Formats without a
|
||||
// measurable byte stream (MSI, plain EXE/AppImage copies) simply never
|
||||
// report, and the frontend falls back to an indeterminate bar.
|
||||
let reporter = ExtractionReporter::new(browser_type.as_str().to_string(), version.to_string());
|
||||
|
||||
log::info!(
|
||||
"Starting extraction of {} for browser {} version {}",
|
||||
archive_path.display(),
|
||||
@@ -166,7 +302,7 @@ impl Extractor {
|
||||
"dmg" => {
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
self.extract_dmg(archive_path, dest_dir).await.map_err(|e| {
|
||||
self.extract_dmg(archive_path, dest_dir, Some(&reporter)).await.map_err(|e| {
|
||||
format!("DMG extraction failed for {} {}: {}", browser_type.as_str(), version, e).into()
|
||||
})
|
||||
}
|
||||
@@ -177,22 +313,22 @@ impl Extractor {
|
||||
}
|
||||
}
|
||||
"zip" => {
|
||||
self.extract_zip(archive_path, dest_dir).await.map_err(|e| {
|
||||
self.extract_zip(archive_path, dest_dir, Some(&reporter)).await.map_err(|e| {
|
||||
format!("ZIP extraction failed for {} {}: {}", browser_type.as_str(), version, e).into()
|
||||
})
|
||||
}
|
||||
"tar.xz" => {
|
||||
self.extract_tar_xz(archive_path, dest_dir).await.map_err(|e| {
|
||||
self.extract_tar_xz(archive_path, dest_dir, Some(&reporter)).await.map_err(|e| {
|
||||
format!("TAR.XZ extraction failed for {} {}: {}", browser_type.as_str(), version, e).into()
|
||||
})
|
||||
}
|
||||
"tar.bz2" => {
|
||||
self.extract_tar_bz2(archive_path, dest_dir).await.map_err(|e| {
|
||||
self.extract_tar_bz2(archive_path, dest_dir, Some(&reporter)).await.map_err(|e| {
|
||||
format!("TAR.BZ2 extraction failed for {} {}: {}", browser_type.as_str(), version, e).into()
|
||||
})
|
||||
}
|
||||
"tar.gz" => {
|
||||
self.extract_tar_gz(archive_path, dest_dir).await.map_err(|e| {
|
||||
self.extract_tar_gz(archive_path, dest_dir, Some(&reporter)).await.map_err(|e| {
|
||||
format!("TAR.GZ extraction failed for {} {}: {}", browser_type.as_str(), version, e).into()
|
||||
})
|
||||
}
|
||||
@@ -237,6 +373,8 @@ impl Extractor {
|
||||
|
||||
match extraction_result {
|
||||
Ok(path) => {
|
||||
reporter.finish();
|
||||
|
||||
// Remove quarantine attributes on macOS to prevent Gatekeeper prompts —
|
||||
// but only if there's actually something to remove. Calling the
|
||||
// modify-class `removexattr` syscall on a file without quarantine still
|
||||
@@ -381,6 +519,7 @@ impl Extractor {
|
||||
&self,
|
||||
dmg_path: &Path,
|
||||
dest_dir: &Path,
|
||||
progress: Option<&ExtractionReporter>,
|
||||
) -> Result<PathBuf, Box<dyn std::error::Error + Send + Sync>> {
|
||||
log::info!(
|
||||
"Extracting DMG: {} to {}",
|
||||
@@ -454,20 +593,42 @@ impl Extractor {
|
||||
|
||||
log::info!("Copying .app to: {}", app_path.display());
|
||||
|
||||
// The copy is the long pole of DMG extraction; size up the source once so
|
||||
// we can report real progress by polling the destination while cp runs.
|
||||
// report_bytes no-ops on a 0 total, so the None path skips both walks.
|
||||
let total_size = if progress.is_some() {
|
||||
dir_size(&app_entry)
|
||||
} else {
|
||||
0
|
||||
};
|
||||
|
||||
// `-X` strips extended attributes (notably com.apple.quarantine) during
|
||||
// the copy itself. Without it, `cp -R` preserves quarantine from the
|
||||
// mounted DMG, which then has to be removed with `xattr -dr` — and that
|
||||
// removexattr syscall on a signed .app bundle trips macOS Sequoia's App
|
||||
// Management TCC notification ("Donut.app was prevented from modifying
|
||||
// apps on your Mac"). Stripping at copy time is silent.
|
||||
let output = Command::new("cp")
|
||||
.args([
|
||||
"-RX",
|
||||
app_entry.to_str().unwrap(),
|
||||
app_path.to_str().unwrap(),
|
||||
])
|
||||
.output()
|
||||
.await?;
|
||||
let copy_src = app_entry.to_str().unwrap().to_string();
|
||||
let copy_dst = app_path.to_str().unwrap().to_string();
|
||||
let mut copy_task = tokio::spawn(async move {
|
||||
Command::new("cp")
|
||||
.args(["-RX", ©_src, ©_dst])
|
||||
.output()
|
||||
.await
|
||||
});
|
||||
|
||||
let output = loop {
|
||||
tokio::select! {
|
||||
result = &mut copy_task => {
|
||||
break result.map_err(|e| format!("Copy task failed: {e}"))??;
|
||||
}
|
||||
() = tokio::time::sleep(std::time::Duration::from_millis(500)) => {
|
||||
if let Some(reporter) = progress {
|
||||
reporter.report_bytes(dir_size(&app_path), total_size);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
@@ -598,6 +759,7 @@ impl Extractor {
|
||||
&self,
|
||||
zip_path: &Path,
|
||||
dest_dir: &Path,
|
||||
progress: Option<&ExtractionReporter>,
|
||||
) -> Result<PathBuf, Box<dyn std::error::Error + Send + Sync>> {
|
||||
log::info!("Extracting ZIP archive: {}", zip_path.display());
|
||||
std::fs::create_dir_all(dest_dir)?;
|
||||
@@ -610,6 +772,15 @@ impl Extractor {
|
||||
|
||||
log::info!("ZIP archive contains {} files", archive.len());
|
||||
|
||||
// Total uncompressed size, known from the central directory without any
|
||||
// decompression. None for archives using data descriptors — those get no
|
||||
// byte progress (the UI falls back to an indeterminate bar).
|
||||
let total_uncompressed: Option<u64> = archive
|
||||
.decompressed_size()
|
||||
.and_then(|total| u64::try_from(total).ok())
|
||||
.filter(|total| *total > 0);
|
||||
let mut extracted_bytes: u64 = 0;
|
||||
|
||||
for i in 0..archive.len() {
|
||||
let mut entry = archive
|
||||
.by_index(i)
|
||||
@@ -639,8 +810,16 @@ impl Extractor {
|
||||
|
||||
let mut outfile = File::create(&outpath)
|
||||
.map_err(|e| format!("Failed to create file {}: {}", outpath.display(), e))?;
|
||||
io::copy(&mut entry, &mut outfile)
|
||||
let entry_size = entry.size();
|
||||
let already_extracted = extracted_bytes;
|
||||
let mut reader = ProgressReader::new(&mut entry, |read| {
|
||||
if let (Some(reporter), Some(total)) = (progress, total_uncompressed) {
|
||||
reporter.report_bytes(already_extracted + read, total);
|
||||
}
|
||||
});
|
||||
io::copy(&mut reader, &mut outfile)
|
||||
.map_err(|e| format!("Failed to extract file {}: {}", outpath.display(), e))?;
|
||||
extracted_bytes = extracted_bytes.saturating_add(entry_size);
|
||||
|
||||
// Set executable permissions on Unix-like systems based on stored mode
|
||||
#[cfg(unix)]
|
||||
@@ -670,12 +849,14 @@ impl Extractor {
|
||||
&self,
|
||||
tar_path: &Path,
|
||||
dest_dir: &Path,
|
||||
progress: Option<&ExtractionReporter>,
|
||||
) -> Result<PathBuf, Box<dyn std::error::Error + Send + Sync>> {
|
||||
log::info!("Extracting tar.gz archive: {}", tar_path.display());
|
||||
std::fs::create_dir_all(dest_dir)?;
|
||||
|
||||
let file = File::open(tar_path)?;
|
||||
let gz_decoder = flate2::read::GzDecoder::new(BufReader::new(file));
|
||||
let counted = progress_file_reader(file, progress)?;
|
||||
let gz_decoder = flate2::read::GzDecoder::new(BufReader::new(counted));
|
||||
let mut archive = tar::Archive::new(gz_decoder);
|
||||
|
||||
archive.unpack(dest_dir)?;
|
||||
@@ -693,12 +874,14 @@ impl Extractor {
|
||||
&self,
|
||||
tar_path: &Path,
|
||||
dest_dir: &Path,
|
||||
progress: Option<&ExtractionReporter>,
|
||||
) -> Result<PathBuf, Box<dyn std::error::Error + Send + Sync>> {
|
||||
log::info!("Extracting tar.bz2 archive: {}", tar_path.display());
|
||||
std::fs::create_dir_all(dest_dir)?;
|
||||
|
||||
let file = File::open(tar_path)?;
|
||||
let bz2_decoder = bzip2::read::BzDecoder::new(BufReader::new(file));
|
||||
let counted = progress_file_reader(file, progress)?;
|
||||
let bz2_decoder = bzip2::read::BzDecoder::new(BufReader::new(counted));
|
||||
let mut archive = tar::Archive::new(bz2_decoder);
|
||||
|
||||
archive.unpack(dest_dir)?;
|
||||
@@ -716,6 +899,7 @@ impl Extractor {
|
||||
&self,
|
||||
tar_path: &Path,
|
||||
dest_dir: &Path,
|
||||
progress: Option<&ExtractionReporter>,
|
||||
) -> Result<PathBuf, Box<dyn std::error::Error + Send + Sync>> {
|
||||
log::info!("Extracting tar.xz archive: {}", tar_path.display());
|
||||
std::fs::create_dir_all(dest_dir)?;
|
||||
@@ -726,17 +910,34 @@ impl Extractor {
|
||||
// Read the entire file into memory for lzma-rs
|
||||
let mut compressed_data = Vec::new();
|
||||
buf_reader.read_to_end(&mut compressed_data)?;
|
||||
let compressed_total = compressed_data.len() as u64;
|
||||
|
||||
// Decompress using lzma-rs
|
||||
// Two phases with no shared byte scale: CPU-bound LZMA decompression
|
||||
// dominates, so compressed bytes consumed map to 0–80%, and the tar
|
||||
// unpack of the decompressed data to 80–100%.
|
||||
let mut decompressed_data = Vec::new();
|
||||
lzma_rs::xz_decompress(
|
||||
&mut std::io::Cursor::new(compressed_data),
|
||||
&mut decompressed_data,
|
||||
)?;
|
||||
let counted_input = ProgressReader::new(std::io::Cursor::new(compressed_data), |read| {
|
||||
if let Some(reporter) = progress {
|
||||
if compressed_total > 0 {
|
||||
reporter
|
||||
.report_percentage(80.0 * read.min(compressed_total) as f64 / compressed_total as f64);
|
||||
}
|
||||
}
|
||||
});
|
||||
lzma_rs::xz_decompress(&mut BufReader::new(counted_input), &mut decompressed_data)?;
|
||||
|
||||
// Create tar archive from decompressed data
|
||||
let cursor = std::io::Cursor::new(decompressed_data);
|
||||
let mut archive = tar::Archive::new(cursor);
|
||||
let decompressed_total = decompressed_data.len() as u64;
|
||||
let counted_tar = ProgressReader::new(std::io::Cursor::new(decompressed_data), |read| {
|
||||
if let Some(reporter) = progress {
|
||||
if decompressed_total > 0 {
|
||||
reporter.report_percentage(
|
||||
80.0 + 20.0 * read.min(decompressed_total) as f64 / decompressed_total as f64,
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
let mut archive = tar::Archive::new(counted_tar);
|
||||
|
||||
archive.unpack(dest_dir)?;
|
||||
|
||||
@@ -1472,6 +1673,86 @@ mod tests {
|
||||
assert_eq!(result.unwrap(), "msi");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_extract_zip_reports_progress() {
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
#[derive(Default)]
|
||||
struct CapturingEmitter(Mutex<Vec<serde_json::Value>>);
|
||||
|
||||
impl crate::events::EventEmitter for CapturingEmitter {
|
||||
fn emit_value(&self, event: &str, payload: serde_json::Value) -> Result<(), String> {
|
||||
if event == "download-progress" {
|
||||
self.0.lock().unwrap().push(payload);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
let captured = Arc::new(CapturingEmitter::default());
|
||||
// The global emitter can only be set once per process; if another test ever
|
||||
// claims it first we lose observability, so only assert on captured events
|
||||
// when this test's emitter actually won.
|
||||
let emitter_installed = crate::events::set_global_emitter(captured.clone()).is_ok();
|
||||
|
||||
let extractor = Extractor::instance();
|
||||
let temp_dir = TempDir::new().expect("Failed to create temp directory");
|
||||
let dest_dir = temp_dir.path().join("extracted");
|
||||
|
||||
// A payload comfortably larger than io::copy's 8KB chunks so the counting
|
||||
// reader fires multiple times.
|
||||
let payload = vec![0x42u8; 256 * 1024];
|
||||
let zip_path = temp_dir.path().join("test.zip");
|
||||
{
|
||||
let file = std::fs::File::create(&zip_path).expect("Failed to create test zip file");
|
||||
let mut zip = zip::ZipWriter::new(file);
|
||||
let options =
|
||||
zip::write::FileOptions::<()>::default().compression_method(zip::CompressionMethod::Stored);
|
||||
zip
|
||||
.start_file("data.bin", options)
|
||||
.expect("Failed to start zip file");
|
||||
zip.write_all(&payload).expect("Failed to write to zip");
|
||||
zip.finish().expect("Failed to finish zip");
|
||||
}
|
||||
|
||||
let reporter =
|
||||
ExtractionReporter::new("test-browser-zip-progress".to_string(), "1.0.0".to_string());
|
||||
let result = extractor
|
||||
.extract_zip(&zip_path, &dest_dir, Some(&reporter))
|
||||
.await;
|
||||
|
||||
// Extraction itself must have worked even if no executable is found.
|
||||
assert!(dest_dir.join("data.bin").exists(), "payload not extracted");
|
||||
if let Err(e) = result {
|
||||
assert!(
|
||||
e.to_string().contains("executable"),
|
||||
"unexpected extraction error: {e}"
|
||||
);
|
||||
}
|
||||
|
||||
if emitter_installed {
|
||||
let events = captured.0.lock().unwrap();
|
||||
let ours: Vec<_> = events
|
||||
.iter()
|
||||
.filter(|p| p["browser"] == "test-browser-zip-progress")
|
||||
.collect();
|
||||
assert!(
|
||||
!ours.is_empty(),
|
||||
"expected at least one extracting progress event"
|
||||
);
|
||||
for p in &ours {
|
||||
assert_eq!(p["stage"], "extracting");
|
||||
assert_eq!(p["version"], "1.0.0");
|
||||
let pct = p["percentage"].as_f64().unwrap();
|
||||
assert!(
|
||||
(0.0..=100.0).contains(&pct),
|
||||
"percentage out of range: {pct}"
|
||||
);
|
||||
assert_eq!(p["total_bytes"].as_u64(), Some(payload.len() as u64));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_extract_zip_with_test_archive() {
|
||||
let extractor = Extractor::instance();
|
||||
@@ -1496,7 +1777,7 @@ mod tests {
|
||||
zip.finish().expect("Failed to finish zip");
|
||||
}
|
||||
|
||||
let result = extractor.extract_zip(&zip_path, &dest_dir).await;
|
||||
let result = extractor.extract_zip(&zip_path, &dest_dir, None).await;
|
||||
|
||||
// The result might fail because we're looking for executables, but the extraction should work
|
||||
// Let's check if the file was extracted regardless of the result
|
||||
@@ -1545,7 +1826,9 @@ mod tests {
|
||||
tar.finish().expect("Failed to finish tar");
|
||||
}
|
||||
|
||||
let result = extractor.extract_tar_gz(&tar_gz_path, &dest_dir).await;
|
||||
let result = extractor
|
||||
.extract_tar_gz(&tar_gz_path, &dest_dir, None)
|
||||
.await;
|
||||
|
||||
// Check if the file was extracted
|
||||
let extracted_file = dest_dir.join("test.txt");
|
||||
@@ -1596,7 +1879,9 @@ mod tests {
|
||||
tar.finish().expect("Failed to finish tar");
|
||||
}
|
||||
|
||||
let result = extractor.extract_tar_bz2(&tar_bz2_path, &dest_dir).await;
|
||||
let result = extractor
|
||||
.extract_tar_bz2(&tar_bz2_path, &dest_dir, None)
|
||||
.await;
|
||||
|
||||
// Check if the file was extracted
|
||||
let extracted_file = dest_dir.join("test.txt");
|
||||
@@ -1657,7 +1942,9 @@ mod tests {
|
||||
.expect("Failed to write compressed data");
|
||||
}
|
||||
|
||||
let result = extractor.extract_tar_xz(&tar_xz_path, &dest_dir).await;
|
||||
let result = extractor
|
||||
.extract_tar_xz(&tar_xz_path, &dest_dir, None)
|
||||
.await;
|
||||
|
||||
// Check if the file was extracted
|
||||
let extracted_file = dest_dir.join("test.txt");
|
||||
|
||||
@@ -81,6 +81,14 @@ impl GroupManager {
|
||||
_app_handle: &tauri::AppHandle,
|
||||
name: String,
|
||||
) -> Result<ProfileGroup, Box<dyn std::error::Error>> {
|
||||
if name.trim().is_empty() {
|
||||
return Err(
|
||||
serde_json::json!({ "code": "NAME_CANNOT_BE_EMPTY" })
|
||||
.to_string()
|
||||
.into(),
|
||||
);
|
||||
}
|
||||
|
||||
let mut groups_data = self.load_groups_data()?;
|
||||
|
||||
// Check if group with this name already exists
|
||||
@@ -127,6 +135,14 @@ impl GroupManager {
|
||||
id: String,
|
||||
name: String,
|
||||
) -> Result<ProfileGroup, Box<dyn std::error::Error>> {
|
||||
if name.trim().is_empty() {
|
||||
return Err(
|
||||
serde_json::json!({ "code": "NAME_CANNOT_BE_EMPTY" })
|
||||
.to_string()
|
||||
.into(),
|
||||
);
|
||||
}
|
||||
|
||||
let mut groups_data = self.load_groups_data()?;
|
||||
|
||||
// Check if another group with this name already exists
|
||||
|
||||
+34
-17
@@ -28,7 +28,9 @@ pub async fn fetch_public_ip(proxy: Option<&str>) -> Result<String, IpError> {
|
||||
"https://ipecho.net/plain",
|
||||
];
|
||||
|
||||
let client_builder = reqwest::Client::builder().timeout(std::time::Duration::from_secs(5));
|
||||
// 10s rather than 5s: residential proxies that allocate an exit on first
|
||||
// connect routinely need more than 5s for the initial request.
|
||||
let client_builder = reqwest::Client::builder().timeout(std::time::Duration::from_secs(10));
|
||||
|
||||
let client = if let Some(proxy_url) = proxy {
|
||||
let proxy = reqwest::Proxy::all(proxy_url)
|
||||
@@ -46,25 +48,40 @@ pub async fn fetch_public_ip(proxy: Option<&str>) -> Result<String, IpError> {
|
||||
|
||||
let mut errors = Vec::new();
|
||||
|
||||
// Overall deadline across all endpoints. Without it, a proxy that accepts
|
||||
// connections but stalls holds callers for the full 6 x 10s; slow-but-live
|
||||
// proxies still get the whole 10s on the endpoints that fit the budget.
|
||||
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(30);
|
||||
|
||||
for url in &urls {
|
||||
match client.get(*url).send().await {
|
||||
Ok(response) if response.status().is_success() => match response.text().await {
|
||||
Ok(text) => {
|
||||
let ip = text.trim().to_string();
|
||||
if validate_ip(&ip) {
|
||||
return Ok(ip);
|
||||
let remaining = deadline.saturating_duration_since(std::time::Instant::now());
|
||||
if remaining.is_zero() {
|
||||
errors.push(format!("{}: skipped (30s overall deadline reached)", url));
|
||||
continue;
|
||||
}
|
||||
|
||||
let attempt = async {
|
||||
match client.get(*url).send().await {
|
||||
Ok(response) if response.status().is_success() => match response.text().await {
|
||||
Ok(text) => {
|
||||
let ip = text.trim().to_string();
|
||||
if validate_ip(&ip) {
|
||||
Ok(ip)
|
||||
} else {
|
||||
Err(format!("{}: response is not an IP address", url))
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
errors.push(format!("{}: {}", url, e));
|
||||
}
|
||||
},
|
||||
Ok(response) => {
|
||||
errors.push(format!("{}: HTTP {}", url, response.status()));
|
||||
}
|
||||
Err(e) => {
|
||||
errors.push(format!("{}: {}", url, e));
|
||||
Err(e) => Err(format!("{}: {}", url, e)),
|
||||
},
|
||||
Ok(response) => Err(format!("{}: HTTP {}", url, response.status())),
|
||||
Err(e) => Err(format!("{}: {}", url, e)),
|
||||
}
|
||||
};
|
||||
|
||||
match tokio::time::timeout(remaining, attempt).await {
|
||||
Ok(Ok(ip)) => return Ok(ip),
|
||||
Ok(Err(e)) => errors.push(e),
|
||||
Err(_) => errors.push(format!("{}: timed out (30s overall deadline reached)", url)),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+15
-2
@@ -245,6 +245,18 @@ async fn handle_url_open(app: tauri::AppHandle, url: String) -> Result<(), Strin
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Prefix a command error with context, but pass structured `{"code": ...}`
|
||||
/// backend errors through untouched — the frontend can only translate a code
|
||||
/// when the JSON is the entire message (see src/lib/backend-errors.ts).
|
||||
pub(crate) fn wrap_backend_error(e: impl std::fmt::Display, context: &str) -> String {
|
||||
let msg = e.to_string();
|
||||
if msg.starts_with('{') {
|
||||
msg
|
||||
} else {
|
||||
format!("{context}: {msg}")
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn create_stored_proxy(
|
||||
app_handle: tauri::AppHandle,
|
||||
@@ -254,7 +266,7 @@ async fn create_stored_proxy(
|
||||
if let Some(settings) = proxy_settings {
|
||||
crate::proxy_manager::PROXY_MANAGER
|
||||
.create_stored_proxy(&app_handle, name, settings)
|
||||
.map_err(|e| format!("Failed to create stored proxy: {e}"))
|
||||
.map_err(|e| wrap_backend_error(e, "Failed to create stored proxy"))
|
||||
} else {
|
||||
Err("proxy_settings is required".to_string())
|
||||
}
|
||||
@@ -274,7 +286,7 @@ async fn update_stored_proxy(
|
||||
) -> Result<crate::proxy_manager::StoredProxy, String> {
|
||||
crate::proxy_manager::PROXY_MANAGER
|
||||
.update_stored_proxy(&app_handle, &proxy_id, name, proxy_settings)
|
||||
.map_err(|e| format!("Failed to update stored proxy: {e}"))
|
||||
.map_err(|e| wrap_backend_error(e, "Failed to update stored proxy"))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
@@ -1213,6 +1225,7 @@ async fn generate_sample_fingerprint(
|
||||
manager
|
||||
.generate_fingerprint_config(&app_handle, &temp_profile, &config)
|
||||
.await
|
||||
.map(|(fingerprint, _geolocation_applied)| fingerprint)
|
||||
.map_err(|e| format!("Failed to generate fingerprint: {e}"))
|
||||
} else {
|
||||
Err(format!(
|
||||
|
||||
@@ -15,8 +15,7 @@ use std::process::Command;
|
||||
fn cmd_matches_profile_path(cmd: &[std::ffi::OsString], profile_path: &str) -> bool {
|
||||
let args: Vec<&str> = cmd.iter().filter_map(|a| a.to_str()).collect();
|
||||
for (i, arg) in args.iter().enumerate() {
|
||||
// Exact argument equality (Firefox: `-profile <path>`; some launchers
|
||||
// pass the path as its own arg).
|
||||
// Exact argument equality (some launchers pass the path as its own arg).
|
||||
if *arg == profile_path {
|
||||
return true;
|
||||
}
|
||||
@@ -85,59 +84,6 @@ pub mod macos {
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn open_url_in_existing_browser_firefox_like(
|
||||
profile: &BrowserProfile,
|
||||
url: &str,
|
||||
browser_type: BrowserType,
|
||||
browser_dir: &Path,
|
||||
profiles_dir: &Path,
|
||||
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
let pid = profile.process_id.unwrap();
|
||||
let profile_data_path = profile.get_profile_data_path(profiles_dir);
|
||||
|
||||
// First try: Use Firefox remote command
|
||||
log::info!("Trying Firefox remote command for PID: {pid}");
|
||||
let browser = create_browser(browser_type);
|
||||
if let Ok(executable_path) = browser.get_executable_path(browser_dir) {
|
||||
let remote_args = vec![
|
||||
"-profile".to_string(),
|
||||
profile_data_path.to_string_lossy().to_string(),
|
||||
"-new-tab".to_string(),
|
||||
url.to_string(),
|
||||
];
|
||||
|
||||
let remote_output = Command::new(executable_path).args(&remote_args).output();
|
||||
|
||||
match remote_output {
|
||||
Ok(output) if output.status.success() => {
|
||||
log::info!("Firefox remote command succeeded");
|
||||
return Ok(());
|
||||
}
|
||||
Ok(output) => {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
log::info!(
|
||||
"Firefox remote command failed with stderr: {stderr}, trying AppleScript fallback"
|
||||
);
|
||||
}
|
||||
Err(e) => {
|
||||
log::info!("Firefox remote command error: {e}, trying AppleScript fallback");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The Firefox `-new-tab` remote command failed. We intentionally do NOT
|
||||
// fall back to an AppleScript `System Events` keystroke path: that would
|
||||
// send Apple Events to another application and trigger the macOS TCC
|
||||
// "<Donut> wants control of <Browser>" / "prevented from modifying other
|
||||
// apps" prompts. Donut must never touch other apps on the user's Mac.
|
||||
Err(
|
||||
format!(
|
||||
"Firefox remote command failed for PID {pid}; cannot open URL in existing window without touching other apps"
|
||||
)
|
||||
.into(),
|
||||
)
|
||||
}
|
||||
|
||||
pub async fn kill_browser_process_impl(
|
||||
pid: u32,
|
||||
profile_data_path: Option<&str>,
|
||||
@@ -399,77 +345,6 @@ pub mod windows {
|
||||
Ok(child)
|
||||
}
|
||||
|
||||
pub async fn open_url_in_existing_browser_firefox_like(
|
||||
profile: &BrowserProfile,
|
||||
url: &str,
|
||||
browser_type: BrowserType,
|
||||
browser_dir: &Path,
|
||||
profiles_dir: &Path,
|
||||
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
let browser = create_browser(browser_type);
|
||||
let executable_path = browser
|
||||
.get_executable_path(browser_dir)
|
||||
.map_err(|e| format!("Failed to get executable path: {}", e))?;
|
||||
|
||||
let profile_data_path = profile.get_profile_data_path(profiles_dir);
|
||||
|
||||
// For Windows, try using the -requestPending approach for Firefox
|
||||
let mut cmd = Command::new(executable_path);
|
||||
cmd.args([
|
||||
"-profile",
|
||||
&profile_data_path.to_string_lossy(),
|
||||
"-requestPending",
|
||||
"-new-tab",
|
||||
url,
|
||||
]);
|
||||
|
||||
// Set working directory
|
||||
if let Some(parent_dir) = browser_dir
|
||||
.parent()
|
||||
.or_else(|| browser_dir.ancestors().nth(1))
|
||||
{
|
||||
cmd.current_dir(parent_dir);
|
||||
}
|
||||
|
||||
let output = cmd.output()?;
|
||||
|
||||
if !output.status.success() {
|
||||
// Fallback: try without -requestPending
|
||||
let executable_path = browser
|
||||
.get_executable_path(browser_dir)
|
||||
.map_err(|e| format!("Failed to get executable path: {}", e))?;
|
||||
let mut fallback_cmd = Command::new(executable_path);
|
||||
let profile_data_path = profile.get_profile_data_path(profiles_dir);
|
||||
fallback_cmd.args([
|
||||
"-profile",
|
||||
&profile_data_path.to_string_lossy(),
|
||||
"-new-tab",
|
||||
url,
|
||||
]);
|
||||
|
||||
if let Some(parent_dir) = browser_dir
|
||||
.parent()
|
||||
.or_else(|| browser_dir.ancestors().nth(1))
|
||||
{
|
||||
fallback_cmd.current_dir(parent_dir);
|
||||
}
|
||||
|
||||
let fallback_output = fallback_cmd.output()?;
|
||||
|
||||
if !fallback_output.status.success() {
|
||||
return Err(
|
||||
format!(
|
||||
"Failed to open URL in existing browser: {}",
|
||||
String::from_utf8_lossy(&fallback_output.stderr)
|
||||
)
|
||||
.into(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn open_url_in_existing_browser_chromium(
|
||||
profile: &BrowserProfile,
|
||||
url: &str,
|
||||
@@ -588,7 +463,7 @@ pub mod linux {
|
||||
let mut cmd = Command::new(executable_path);
|
||||
cmd.args(args);
|
||||
|
||||
// For Firefox-based browsers, ensure library path includes the installation directory
|
||||
// Ensure the library path includes the installation directory
|
||||
if let Some(install_dir) = executable_path.parent() {
|
||||
let mut ld_library_path = Vec::new();
|
||||
|
||||
@@ -606,16 +481,15 @@ pub mod linux {
|
||||
}
|
||||
}
|
||||
|
||||
// For Firefox specifically, add common system library paths that might be needed
|
||||
let firefox_lib_paths = [
|
||||
"/usr/lib/firefox",
|
||||
// Add common system library paths that might be needed
|
||||
let system_lib_paths = [
|
||||
"/usr/lib/x86_64-linux-gnu",
|
||||
"/usr/lib/aarch64-linux-gnu",
|
||||
"/lib/x86_64-linux-gnu",
|
||||
"/lib/aarch64-linux-gnu",
|
||||
];
|
||||
|
||||
for lib_path in &firefox_lib_paths {
|
||||
for lib_path in &system_lib_paths {
|
||||
let path = std::path::Path::new(lib_path);
|
||||
if path.exists() {
|
||||
ld_library_path.push(lib_path.to_string());
|
||||
@@ -686,41 +560,6 @@ pub mod linux {
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn open_url_in_existing_browser_firefox_like(
|
||||
profile: &BrowserProfile,
|
||||
url: &str,
|
||||
browser_type: BrowserType,
|
||||
browser_dir: &Path,
|
||||
profiles_dir: &Path,
|
||||
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
let browser = create_browser(browser_type);
|
||||
let executable_path = browser
|
||||
.get_executable_path(browser_dir)
|
||||
.map_err(|e| format!("Failed to get executable path: {}", e))?;
|
||||
|
||||
let profile_data_path = profile.get_profile_data_path(profiles_dir);
|
||||
let output = Command::new(executable_path)
|
||||
.args([
|
||||
"-profile",
|
||||
&profile_data_path.to_string_lossy(),
|
||||
"-new-tab",
|
||||
url,
|
||||
])
|
||||
.output()?;
|
||||
|
||||
if !output.status.success() {
|
||||
return Err(
|
||||
format!(
|
||||
"Failed to open URL in existing browser: {}",
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
)
|
||||
.into(),
|
||||
);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn open_url_in_existing_browser_chromium(
|
||||
profile: &BrowserProfile,
|
||||
url: &str,
|
||||
|
||||
@@ -82,6 +82,14 @@ impl ProfileManager {
|
||||
dns_blocklist: Option<String>,
|
||||
launch_hook: Option<String>,
|
||||
) -> Result<BrowserProfile, Box<dyn std::error::Error>> {
|
||||
if name.trim().is_empty() {
|
||||
return Err(
|
||||
serde_json::json!({ "code": "NAME_CANNOT_BE_EMPTY" })
|
||||
.to_string()
|
||||
.into(),
|
||||
);
|
||||
}
|
||||
|
||||
if proxy_id.is_some() && vpn_id.is_some() {
|
||||
return Err("Cannot set both proxy_id and vpn_id".into());
|
||||
}
|
||||
@@ -168,6 +176,11 @@ impl ProfileManager {
|
||||
}
|
||||
}
|
||||
|
||||
// Whether the fingerprint's location fields are known to match the
|
||||
// profile's routing. Provided fingerprints keep the old stamping
|
||||
// behavior; for generated ones this comes from the geolocation lookup.
|
||||
let mut geolocation_applied = true;
|
||||
|
||||
// Generate fingerprint if not already provided
|
||||
if config.fingerprint.is_none() {
|
||||
log::info!("Generating fingerprint for Wayfern profile: {name}");
|
||||
@@ -209,8 +222,9 @@ impl ProfileManager {
|
||||
.generate_fingerprint_config(app_handle, &temp_profile, &config)
|
||||
.await
|
||||
{
|
||||
Ok(generated_fingerprint) => {
|
||||
Ok((generated_fingerprint, geo_applied)) => {
|
||||
config.fingerprint = Some(generated_fingerprint);
|
||||
geolocation_applied = geo_applied;
|
||||
log::info!("Successfully generated fingerprint for Wayfern profile: {name}");
|
||||
}
|
||||
Err(e) => {
|
||||
@@ -226,15 +240,27 @@ impl ProfileManager {
|
||||
// Record which proxy/geoip the fingerprint's location data was computed
|
||||
// for. On launch this is compared against the profile's current routing
|
||||
// so a proxy that was changed after creation triggers a location refresh
|
||||
// instead of showing a stale timezone.
|
||||
config.geo_proxy_signature = Some(crate::wayfern_manager::WayfernManager::geo_signature(
|
||||
proxy_id
|
||||
.as_ref()
|
||||
.and_then(|id| PROXY_MANAGER.get_proxy_settings_by_id(id))
|
||||
.as_ref(),
|
||||
None,
|
||||
config.geoip.as_ref(),
|
||||
));
|
||||
// instead of showing a stale timezone. Only stamped when geolocation
|
||||
// actually succeeded: on failure the fingerprint carries the HOST
|
||||
// timezone/locale, and a stamped signature would match at launch and
|
||||
// suppress the refresh that repairs it — latching the leak permanently.
|
||||
config.geo_proxy_signature = if geolocation_applied {
|
||||
Some(crate::wayfern_manager::WayfernManager::geo_signature(
|
||||
proxy_id
|
||||
.as_ref()
|
||||
.and_then(|id| PROXY_MANAGER.get_proxy_settings_by_id(id))
|
||||
.as_ref(),
|
||||
None,
|
||||
config.geoip.as_ref(),
|
||||
))
|
||||
} else {
|
||||
if !matches!(config.geoip.as_ref(), Some(serde_json::Value::Bool(false))) {
|
||||
log::warn!(
|
||||
"Geolocation could not be applied for Wayfern profile {name}; leaving geo signature unset so the next launch refreshes location through the profile's proxy"
|
||||
);
|
||||
}
|
||||
None
|
||||
};
|
||||
|
||||
// Clear the proxy from config after fingerprint generation
|
||||
config.proxy = None;
|
||||
@@ -381,6 +407,14 @@ impl ProfileManager {
|
||||
profile_id: &str,
|
||||
new_name: &str,
|
||||
) -> Result<BrowserProfile, Box<dyn std::error::Error>> {
|
||||
if new_name.trim().is_empty() {
|
||||
return Err(
|
||||
serde_json::json!({ "code": "NAME_CANNOT_BE_EMPTY" })
|
||||
.to_string()
|
||||
.into(),
|
||||
);
|
||||
}
|
||||
|
||||
// Check if new name already exists (case insensitive)
|
||||
let existing_profiles = self.list_profiles()?;
|
||||
if existing_profiles
|
||||
@@ -1267,7 +1301,7 @@ impl ProfileManager {
|
||||
let profile_data_path_str = profile_data_path.to_string_lossy();
|
||||
let profile_path_match = cmd.iter().any(|s| {
|
||||
let arg = s.to_str().unwrap_or("");
|
||||
// For Firefox-based browsers, check for exact profile path match
|
||||
// Match the Chromium --user-data-dir flag or an exact profile path argument
|
||||
arg.contains(&format!("--user-data-dir={profile_data_path_str}"))
|
||||
|| arg == profile_data_path_str
|
||||
});
|
||||
@@ -1305,7 +1339,7 @@ impl ProfileManager {
|
||||
let profile_data_path_str = profile_data_path.to_string_lossy();
|
||||
let profile_path_match = cmd.iter().any(|s| {
|
||||
let arg = s.to_str().unwrap_or("");
|
||||
// For Firefox-based browsers, check for exact profile path match
|
||||
// Match the Chromium --user-data-dir flag or an exact profile path argument
|
||||
arg.contains(&format!("--user-data-dir={profile_data_path_str}"))
|
||||
|| arg == profile_data_path_str
|
||||
});
|
||||
@@ -1631,7 +1665,7 @@ pub async fn create_browser_profile_with_group(
|
||||
launch_hook,
|
||||
)
|
||||
.await
|
||||
.map_err(|e| format!("Failed to create profile: {e}"))
|
||||
.map_err(|e| crate::wrap_backend_error(e, "Failed to create profile"))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
@@ -1781,7 +1815,7 @@ pub fn rename_profile(
|
||||
let profile_manager = ProfileManager::instance();
|
||||
profile_manager
|
||||
.rename_profile(&app_handle, &profile_id, &new_name)
|
||||
.map_err(|e| format!("Failed to rename profile: {e}"))
|
||||
.map_err(|e| crate::wrap_backend_error(e, "Failed to rename profile"))
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
|
||||
@@ -19,12 +19,9 @@ pub struct DetectedProfile {
|
||||
pub description: String,
|
||||
}
|
||||
|
||||
fn map_browser_type(browser: &str) -> &str {
|
||||
// Legacy Firefox-family sources map to Wayfern at import time.
|
||||
match browser {
|
||||
"firefox" | "firefox-developer" | "zen" | "camoufox" => "wayfern",
|
||||
_ => "wayfern",
|
||||
}
|
||||
fn map_browser_type(_browser: &str) -> &str {
|
||||
// Every import source maps to Wayfern — the only launchable engine.
|
||||
"wayfern"
|
||||
}
|
||||
|
||||
pub struct ProfileImporter {
|
||||
@@ -53,9 +50,9 @@ impl ProfileImporter {
|
||||
) -> Result<Vec<DetectedProfile>, Box<dyn std::error::Error>> {
|
||||
let mut detected_profiles = Vec::new();
|
||||
|
||||
// Firefox-based browsers (Firefox, Firefox Developer, Zen) map to Camoufox,
|
||||
// which is deprecated — they can no longer be imported. Only Chromium-based
|
||||
// sources (mapping to Wayfern) are detected.
|
||||
// Only Chromium-based sources (mapping to Wayfern) are detected. Gecko-family
|
||||
// sources mapped to Camoufox, which was removed, so they can no longer be
|
||||
// imported.
|
||||
detected_profiles.extend(self.detect_chrome_profiles()?);
|
||||
detected_profiles.extend(self.detect_brave_profiles()?);
|
||||
detected_profiles.extend(self.detect_chromium_profiles()?);
|
||||
@@ -210,8 +207,6 @@ impl ProfileImporter {
|
||||
|
||||
fn get_browser_display_name(&self, browser_type: &str) -> &str {
|
||||
match browser_type {
|
||||
"firefox" => "Firefox",
|
||||
"firefox-developer" => "Firefox Developer",
|
||||
"chromium" => "Chrome/Chromium",
|
||||
"brave" => "Brave",
|
||||
"zen" => "Zen Browser",
|
||||
@@ -329,7 +324,9 @@ impl ProfileImporter {
|
||||
.generate_fingerprint_config(app_handle, &temp_profile, &config)
|
||||
.await
|
||||
{
|
||||
Ok(fp) => config.fingerprint = Some(fp),
|
||||
// geo_proxy_signature is intentionally left unset here: the first
|
||||
// launch's signature-mismatch refresh verifies the location either way.
|
||||
Ok((fp, _geolocation_applied)) => config.fingerprint = Some(fp),
|
||||
Err(e) => {
|
||||
return Err(
|
||||
format!(
|
||||
@@ -506,11 +503,6 @@ mod tests {
|
||||
fn test_get_browser_display_name() {
|
||||
let (importer, _temp_dir) = create_test_profile_importer();
|
||||
|
||||
assert_eq!(importer.get_browser_display_name("firefox"), "Firefox");
|
||||
assert_eq!(
|
||||
importer.get_browser_display_name("firefox-developer"),
|
||||
"Firefox Developer"
|
||||
);
|
||||
assert_eq!(
|
||||
importer.get_browser_display_name("chromium"),
|
||||
"Chrome/Chromium"
|
||||
@@ -525,9 +517,6 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_map_browser_type() {
|
||||
assert_eq!(map_browser_type("firefox"), "wayfern");
|
||||
assert_eq!(map_browser_type("firefox-developer"), "wayfern");
|
||||
assert_eq!(map_browser_type("zen"), "wayfern");
|
||||
assert_eq!(map_browser_type("chromium"), "wayfern");
|
||||
assert_eq!(map_browser_type("brave"), "wayfern");
|
||||
assert_eq!(map_browser_type("camoufox"), "wayfern");
|
||||
|
||||
@@ -406,6 +406,10 @@ impl ProxyManager {
|
||||
name: String,
|
||||
proxy_settings: ProxySettings,
|
||||
) -> Result<StoredProxy, String> {
|
||||
if name.trim().is_empty() {
|
||||
return Err(serde_json::json!({ "code": "NAME_CANNOT_BE_EMPTY" }).to_string());
|
||||
}
|
||||
|
||||
// Check if name already exists
|
||||
{
|
||||
let stored_proxies = self.stored_proxies.lock().unwrap();
|
||||
@@ -795,6 +799,10 @@ impl ProxyManager {
|
||||
name: Option<String>,
|
||||
proxy_settings: Option<ProxySettings>,
|
||||
) -> Result<StoredProxy, String> {
|
||||
if name.as_deref().is_some_and(|n| n.trim().is_empty()) {
|
||||
return Err(serde_json::json!({ "code": "NAME_CANNOT_BE_EMPTY" }).to_string());
|
||||
}
|
||||
|
||||
// First, check for conflicts without holding a mutable reference
|
||||
{
|
||||
let stored_proxies = self.stored_proxies.lock().unwrap();
|
||||
@@ -1298,10 +1306,9 @@ impl ProxyManager {
|
||||
("socks5", rest) // Default socks to socks5
|
||||
} else if let Some(rest) = line.strip_prefix("ss://") {
|
||||
("ss", rest)
|
||||
} else if let Some(rest) = line.strip_prefix("shadowsocks://") {
|
||||
("ss", rest)
|
||||
} else {
|
||||
return None;
|
||||
let rest = line.strip_prefix("shadowsocks://")?;
|
||||
("ss", rest)
|
||||
};
|
||||
|
||||
// Check if there's auth (contains @)
|
||||
@@ -1365,13 +1372,12 @@ impl ProxyManager {
|
||||
let host_port = &line[at_pos + 1..];
|
||||
|
||||
// Parse auth
|
||||
let (username, password) = if let Some(colon_pos) = auth.find(':') {
|
||||
let (username, password) = {
|
||||
let colon_pos = auth.find(':')?;
|
||||
(
|
||||
Some(auth[..colon_pos].to_string()),
|
||||
Some(auth[colon_pos + 1..].to_string()),
|
||||
)
|
||||
} else {
|
||||
return None;
|
||||
};
|
||||
|
||||
// Parse host:port
|
||||
|
||||
@@ -96,8 +96,12 @@ impl WayfernManager {
|
||||
inner: Arc::new(AsyncMutex::new(WayfernManagerInner {
|
||||
instances: HashMap::new(),
|
||||
})),
|
||||
// CDP is always on loopback. Disable env/system proxies so a Windows
|
||||
// WinHTTP/IE proxy (or HTTP_PROXY) cannot intercept /json/version and
|
||||
// return 502 Bad Gateway while the browser is actually listening.
|
||||
http_client: Client::builder()
|
||||
.timeout(Duration::from_secs(2))
|
||||
.no_proxy()
|
||||
.build()
|
||||
.expect("Failed to build reqwest client for wayfern_manager"),
|
||||
}
|
||||
@@ -280,7 +284,11 @@ impl WayfernManager {
|
||||
vpn_id: Option<&str>,
|
||||
geoip: Option<&serde_json::Value>,
|
||||
) -> String {
|
||||
match geoip {
|
||||
// The "v2:" prefix invalidates every signature stamped before geolocation
|
||||
// failures stopped being stamped: those may describe fingerprints that
|
||||
// silently carry the host's location, so each pre-v2 profile gets one
|
||||
// launch-time refresh and is re-stamped in the current format.
|
||||
let base = match geoip {
|
||||
Some(serde_json::Value::Bool(false)) => "off".to_string(),
|
||||
Some(serde_json::Value::String(ip)) if !ip.is_empty() => format!("ip:{ip}"),
|
||||
_ => {
|
||||
@@ -298,7 +306,8 @@ impl WayfernManager {
|
||||
"direct".to_string()
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
format!("v2:{base}")
|
||||
}
|
||||
|
||||
/// Apply timezone/geolocation fields to a fingerprint object from the proxy's
|
||||
@@ -393,12 +402,44 @@ impl WayfernManager {
|
||||
}
|
||||
}
|
||||
|
||||
/// True when `url` is a socks proxy on a remote (non-loopback) host — the
|
||||
/// case where reqwest's SOCKS connector can't be trusted with the
|
||||
/// geolocation fetch. Loopback socks URLs are the app's own donut-proxy
|
||||
/// workers, whose single-segment replies don't trigger the connector bug.
|
||||
fn is_remote_socks_url(url: &str) -> bool {
|
||||
url.starts_with("socks")
|
||||
&& url::Url::parse(url)
|
||||
.ok()
|
||||
.and_then(|u| match u.host() {
|
||||
Some(url::Host::Ipv4(ip)) => Some(!ip.is_loopback()),
|
||||
Some(url::Host::Ipv6(ip)) => Some(!ip.is_loopback()),
|
||||
// socks is a non-special scheme, so the url crate keeps even
|
||||
// IP-literal hosts as Domain — parse them before comparing.
|
||||
Some(url::Host::Domain(domain)) => Some(
|
||||
domain != "localhost"
|
||||
&& domain
|
||||
.parse::<std::net::IpAddr>()
|
||||
.map(|ip| !ip.is_loopback())
|
||||
.unwrap_or(true),
|
||||
),
|
||||
None => None,
|
||||
})
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// Generate a fingerprint for `config`, returning the fingerprint JSON and
|
||||
/// whether fresh geolocation was applied to it. Callers must only stamp
|
||||
/// `geo_proxy_signature` when geolocation succeeded: the base fingerprint
|
||||
/// comes from a headless Wayfern launched without a proxy, so on failure it
|
||||
/// silently carries the HOST timezone/locale — stamping the signature then
|
||||
/// would tell the launch-time refresh the location is already correct for
|
||||
/// this proxy and permanently disable the one path that can repair it.
|
||||
pub async fn generate_fingerprint_config(
|
||||
&self,
|
||||
_app_handle: &AppHandle,
|
||||
profile: &BrowserProfile,
|
||||
config: &WayfernConfig,
|
||||
) -> Result<String, Box<dyn std::error::Error + Send + Sync>> {
|
||||
) -> Result<(String, bool), Box<dyn std::error::Error + Send + Sync>> {
|
||||
let executable_path = BrowserRunner::instance()
|
||||
.get_browser_executable_path(profile)
|
||||
.map_err(|e| format!("Failed to get Wayfern executable path: {e}"))?;
|
||||
@@ -416,7 +457,6 @@ impl WayfernManager {
|
||||
.arg(format!("--remote-debugging-port={port}"))
|
||||
.arg("--remote-debugging-address=127.0.0.1")
|
||||
.arg(format!("--user-data-dir={}", temp_profile_dir.display()))
|
||||
.arg("--disable-gpu")
|
||||
.arg("--no-first-run")
|
||||
.arg("--no-default-browser-check")
|
||||
.arg("--disable-background-mode")
|
||||
@@ -546,7 +586,7 @@ impl WayfernManager {
|
||||
.send_cdp_command(&ws_url, "Wayfern.getFingerprint", json!({}))
|
||||
.await;
|
||||
|
||||
let fingerprint = match get_result {
|
||||
let (fingerprint, geolocation_applied) = match get_result {
|
||||
Ok(result) => {
|
||||
// Wayfern.getFingerprint returns { fingerprint: {...} }
|
||||
// We need to extract just the fingerprint object
|
||||
@@ -554,16 +594,57 @@ impl WayfernManager {
|
||||
// Normalize the fingerprint: convert JSON string fields to proper types
|
||||
let mut normalized = Self::normalize_fingerprint(fp);
|
||||
|
||||
// reqwest's SOCKS connector (hyper-util) corrupts its parse buffer
|
||||
// when a proxy splits a handshake reply across TCP segments, so a
|
||||
// socks upstream here can fail even though the proxy is healthy.
|
||||
// Route the geolocation lookup through a temporary local donut-proxy
|
||||
// worker — the same path the browser itself uses — and fall back to
|
||||
// the upstream URL only if the worker can't start. Two exclusions:
|
||||
// no worker when geolocation won't fetch through the proxy at all
|
||||
// (disabled, or a fixed geoip IP), and none for loopback socks URLs —
|
||||
// launch-time callers pass the already-running local worker's
|
||||
// socks5://127.0.0.1 URL, whose single-segment replies don't trigger
|
||||
// the bug, so chaining a second worker would only add latency.
|
||||
let needs_proxied_geo_fetch = !matches!(
|
||||
config.geoip.as_ref(),
|
||||
Some(serde_json::Value::Bool(false)) | Some(serde_json::Value::String(_))
|
||||
);
|
||||
let remote_socks_upstream = config
|
||||
.proxy
|
||||
.as_deref()
|
||||
.filter(|url| Self::is_remote_socks_url(url));
|
||||
let (geo_proxy, temp_worker_id) = match remote_socks_upstream {
|
||||
Some(url) if needs_proxied_geo_fetch => {
|
||||
match crate::proxy_runner::start_proxy_process(Some(url.to_string()), None)
|
||||
.await
|
||||
.map_err(|e| e.to_string())
|
||||
{
|
||||
Ok(worker) => {
|
||||
let local_url = format!("http://127.0.0.1:{}", worker.local_port.unwrap_or(0));
|
||||
(Some(local_url), Some(worker.id))
|
||||
}
|
||||
Err(e) => {
|
||||
log::warn!(
|
||||
"Could not start local proxy worker for geolocation ({e}); using the socks upstream directly"
|
||||
);
|
||||
(config.proxy.clone(), None)
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => (config.proxy.clone(), None),
|
||||
};
|
||||
|
||||
// Apply timezone/geolocation for the proxy this fingerprint is being
|
||||
// generated against. Shared with the launch-time location refresh.
|
||||
Self::apply_geolocation(
|
||||
&mut normalized,
|
||||
config.proxy.as_deref(),
|
||||
config.geoip.as_ref(),
|
||||
)
|
||||
.await;
|
||||
let geolocation_applied =
|
||||
Self::apply_geolocation(&mut normalized, geo_proxy.as_deref(), config.geoip.as_ref())
|
||||
.await;
|
||||
|
||||
normalized
|
||||
if let Some(worker_id) = temp_worker_id {
|
||||
let _ = crate::proxy_runner::stop_proxy_process(&worker_id).await;
|
||||
}
|
||||
|
||||
(normalized, geolocation_applied)
|
||||
}
|
||||
Err(e) => {
|
||||
cleanup().await;
|
||||
@@ -596,7 +677,7 @@ impl WayfernManager {
|
||||
);
|
||||
}
|
||||
|
||||
Ok(fingerprint_json)
|
||||
Ok((fingerprint_json, geolocation_applied))
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
@@ -1415,6 +1496,37 @@ fn hsl_to_rgb(h: f64, s: f64, l: f64) -> (u8, u8, u8) {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn remote_socks_url_detection() {
|
||||
// Remote socks upstreams (the hyper-util-affected case) are detected...
|
||||
assert!(WayfernManager::is_remote_socks_url(
|
||||
"socks5://user:pass@gw.dataimpulse.com:10000"
|
||||
));
|
||||
assert!(WayfernManager::is_remote_socks_url("socks5://1.2.3.4:1080"));
|
||||
assert!(WayfernManager::is_remote_socks_url("socks4://1.2.3.4:1080"));
|
||||
|
||||
// ...but the app's own loopback workers are not. socks is a non-special
|
||||
// URL scheme, so the IP literal parses as Host::Domain — the launch-time
|
||||
// randomize path depends on this returning false.
|
||||
assert!(!WayfernManager::is_remote_socks_url(
|
||||
"socks5://127.0.0.1:24001"
|
||||
));
|
||||
assert!(!WayfernManager::is_remote_socks_url("socks5://[::1]:24001"));
|
||||
assert!(!WayfernManager::is_remote_socks_url(
|
||||
"socks5://localhost:24001"
|
||||
));
|
||||
|
||||
// Non-socks schemes and unparsable URLs never need the workaround.
|
||||
assert!(!WayfernManager::is_remote_socks_url(
|
||||
"http://gw.dataimpulse.com:10000"
|
||||
));
|
||||
assert!(!WayfernManager::is_remote_socks_url(
|
||||
"https://gw.dataimpulse.com:10000"
|
||||
));
|
||||
assert!(!WayfernManager::is_remote_socks_url("socks5://"));
|
||||
assert!(!WayfernManager::is_remote_socks_url("not a url"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn window_size_prefers_outer_window_dimensions() {
|
||||
// Field names + values mirror a real Wayfern fingerprint (camelCase).
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "Donut",
|
||||
"version": "0.28.0",
|
||||
"version": "0.28.2",
|
||||
"identifier": "com.donutbrowser",
|
||||
"build": {
|
||||
"beforeDevCommand": "pnpm copy-proxy-binary && pnpm dev",
|
||||
|
||||
@@ -26,14 +26,14 @@
|
||||
*
|
||||
* Auto-update toast:
|
||||
* ```
|
||||
* showAutoUpdateToast("Firefox", "125.0.1");
|
||||
* showAutoUpdateToast("Wayfern", "149.0.7827.116");
|
||||
* ```
|
||||
*
|
||||
* Download progress toast:
|
||||
* ```
|
||||
* showToast({
|
||||
* type: "download",
|
||||
* title: "Downloading Firefox 123.0",
|
||||
* title: "Downloading Wayfern 149.0.7827.116",
|
||||
* progress: { percentage: 45, speed: "2.5", eta: "30s" }
|
||||
* });
|
||||
* ```
|
||||
@@ -159,6 +159,23 @@ function formatEtaCompact(seconds: number): string {
|
||||
return `${Math.round(seconds)}s`;
|
||||
}
|
||||
|
||||
function ProgressBar({
|
||||
percentage,
|
||||
className = "w-full",
|
||||
}: {
|
||||
percentage: number;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<div className={`h-1.5 rounded-full bg-muted ${className}`}>
|
||||
<div
|
||||
className="h-1.5 rounded-full bg-foreground transition-all duration-150"
|
||||
style={{ width: `${percentage}%` }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function getToastIcon(type: ToastProps["type"], stage?: string) {
|
||||
switch (type) {
|
||||
case "success":
|
||||
@@ -232,12 +249,31 @@ export function UnifiedToast(props: ToastProps) {
|
||||
` • ${t("toasts.progress.remaining", { time: progress.eta })}`}
|
||||
</p>
|
||||
</div>
|
||||
<div className="h-1.5 w-full rounded-full bg-muted">
|
||||
<div
|
||||
className="h-1.5 rounded-full bg-foreground transition-all duration-150"
|
||||
style={{ width: `${progress.percentage}%` }}
|
||||
/>
|
||||
</div>
|
||||
<ProgressBar percentage={progress.percentage} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Extraction / verification progress. Extraction reports a real
|
||||
percentage for most archive formats; when none is available yet
|
||||
(or the format can't measure progress) show an indeterminate bar. */}
|
||||
{type === "download" &&
|
||||
(stage === "extracting" || stage === "verifying") && (
|
||||
<div className="mt-2 space-y-1">
|
||||
{stage === "extracting" &&
|
||||
progress &&
|
||||
"percentage" in progress &&
|
||||
progress.percentage > 0 ? (
|
||||
<>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{progress.percentage.toFixed(1)}%
|
||||
</p>
|
||||
<ProgressBar percentage={progress.percentage} />
|
||||
</>
|
||||
) : (
|
||||
<div className="h-1.5 w-full overflow-hidden rounded-full bg-muted">
|
||||
<div className="h-1.5 w-1/3 animate-progress-indeterminate rounded-full bg-foreground" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -253,14 +289,10 @@ export function UnifiedToast(props: ToastProps) {
|
||||
})}
|
||||
</p>
|
||||
<div className="flex items-center gap-x-2">
|
||||
<div className="h-1.5 min-w-0 flex-1 rounded-full bg-muted">
|
||||
<div
|
||||
className="h-1.5 rounded-full bg-foreground transition-all duration-150"
|
||||
style={{
|
||||
width: `${(progress.current / progress.total) * 100}%`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<ProgressBar
|
||||
percentage={(progress.current / progress.total) * 100}
|
||||
className="min-w-0 flex-1"
|
||||
/>
|
||||
<span className="w-8 shrink-0 text-right text-xs whitespace-nowrap text-muted-foreground">
|
||||
{progress.current}/{progress.total}
|
||||
</span>
|
||||
|
||||
@@ -517,11 +517,11 @@ export function ImportProfileDialog({
|
||||
{t("importProfile.examplePaths")}
|
||||
<br />
|
||||
macOS: ~/Library/Application
|
||||
Support/Firefox/Profiles/xxx.default
|
||||
Support/Google/Chrome/Default
|
||||
<br />
|
||||
Windows: %APPDATA%\Mozilla\Firefox\Profiles\xxx.default
|
||||
Windows: %LOCALAPPDATA%\Google\Chrome\User Data\Default
|
||||
<br />
|
||||
Linux: ~/.mozilla/firefox/xxx.default
|
||||
Linux: ~/.config/google-chrome/Default
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
import { AppUpdateToast } from "@/components/app-update-toast";
|
||||
import { translateBackendError } from "@/lib/backend-errors";
|
||||
import { showToast } from "@/lib/toast-utils";
|
||||
import type { AppUpdateInfo, AppUpdateProgress } from "@/types";
|
||||
|
||||
@@ -82,11 +83,16 @@ export function useAppUpdateNotifications() {
|
||||
showToast({
|
||||
type: "error",
|
||||
title: t("appUpdate.toast.updateFailed"),
|
||||
description: String(error),
|
||||
description: translateBackendError(t, error),
|
||||
duration: 6000,
|
||||
});
|
||||
setIsUpdating(false);
|
||||
setUpdateProgress(null);
|
||||
// Deliberately NOT resetting autoDownloadedVersion here: the
|
||||
// auto-download effect re-runs as soon as isUpdating flips back to
|
||||
// false, so clearing the marker now would retry in a tight loop.
|
||||
// Retries are re-armed when the next backend check delivers a fresh
|
||||
// update event instead.
|
||||
}
|
||||
},
|
||||
[t],
|
||||
@@ -127,6 +133,12 @@ export function useAppUpdateNotifications() {
|
||||
"app-update-available",
|
||||
(event) => {
|
||||
console.log("App update available:", event.payload);
|
||||
// A fresh backend check re-arms auto-download, so a version whose
|
||||
// earlier attempt failed (e.g. a transient checksum-fetch error) is
|
||||
// retried once per periodic check instead of staying blocked until
|
||||
// restart. The effect's updateReady guard keeps an already-prepared
|
||||
// update from being downloaded again.
|
||||
autoDownloadedVersion.current = null;
|
||||
setUpdateInfo(event.payload);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -354,7 +354,14 @@ export function useBrowserDownload() {
|
||||
}
|
||||
} else if (progress.stage === "extracting") {
|
||||
if (!isOnboardingActive()) {
|
||||
showDownloadToast(browserName, progress.version, "extracting");
|
||||
showDownloadToast(
|
||||
browserName,
|
||||
progress.version,
|
||||
"extracting",
|
||||
progress.percentage > 0
|
||||
? { percentage: progress.percentage }
|
||||
: undefined,
|
||||
);
|
||||
}
|
||||
} else if (progress.stage === "verifying") {
|
||||
if (!isOnboardingActive()) {
|
||||
|
||||
@@ -27,10 +27,11 @@ export interface SetupError {
|
||||
stage: SetupErrorStage;
|
||||
}
|
||||
|
||||
// The backend emits a real percentage only while downloading; extraction sends
|
||||
// a single "extracting" event with no incremental progress (it takes ~2 min).
|
||||
// So we estimate extraction progress from elapsed time vs. a learned average,
|
||||
// seeded at 2 minutes and refined with the real durations we record.
|
||||
// The backend reports real extraction percentages for most archive formats
|
||||
// (zip, tar.*, dmg). For formats that can't measure progress (e.g. MSI) the
|
||||
// "extracting" events carry percentage 0, so we fall back to estimating from
|
||||
// elapsed time vs. a learned average, seeded at 2 minutes and refined with
|
||||
// the real durations we record.
|
||||
const DEFAULT_EXTRACT_MS = 2 * 60 * 1000;
|
||||
const MAX_SAMPLES = 5; // the 2-min seed + up to 4 most recent real durations
|
||||
|
||||
@@ -89,8 +90,9 @@ function toErrorStage(stage: string): SetupErrorStage {
|
||||
}
|
||||
|
||||
/**
|
||||
* Tracks first-launch setup of a browser: real download progress plus an
|
||||
* estimated extraction progress (no countdown timer, percentages only).
|
||||
* Tracks first-launch setup of a browser: real download progress plus
|
||||
* extraction progress — real backend percentages when the archive format
|
||||
* supports them, otherwise a time-based estimate (percentages only).
|
||||
* `active` should be true while the owning dialog is open.
|
||||
*/
|
||||
export function useBrowserSetup(browser: string, active: boolean) {
|
||||
@@ -108,8 +110,12 @@ export function useBrowserSetup(browser: string, active: boolean) {
|
||||
|
||||
const extractStartRef = useRef<number | null>(null);
|
||||
const estimateRef = useRef(DEFAULT_EXTRACT_MS);
|
||||
// Fallback bookkeeping so a listener that mounts mid-flight (and therefore
|
||||
// misses the single "extracting" event) can still show extraction progress.
|
||||
// True once an "extracting" event carried a real percentage — from then on
|
||||
// the backend drives the bar and the time-based estimate stays out of it.
|
||||
const sawRealExtractionRef = useRef(false);
|
||||
// Fallback bookkeeping so a listener that mounts mid-flight, or that only
|
||||
// ever receives percentage-0 "extracting" events (formats that can't
|
||||
// measure progress), can still show extraction progress.
|
||||
const sawDownloadingRef = useRef(false);
|
||||
const lastProgressAtRef = useRef<number | null>(null);
|
||||
const lastDownloadPercentRef = useRef(0);
|
||||
@@ -133,6 +139,7 @@ export function useBrowserSetup(browser: string, active: boolean) {
|
||||
setExtractionOvertime(false);
|
||||
setError(null);
|
||||
extractStartRef.current = null;
|
||||
sawRealExtractionRef.current = false;
|
||||
sawDownloadingRef.current = false;
|
||||
lastProgressAtRef.current = null;
|
||||
lastDownloadPercentRef.current = 0;
|
||||
@@ -143,6 +150,7 @@ export function useBrowserSetup(browser: string, active: boolean) {
|
||||
let alive = true;
|
||||
estimateRef.current = average(readDurations(browser));
|
||||
extractStartRef.current = null;
|
||||
sawRealExtractionRef.current = false;
|
||||
sawDownloadingRef.current = false;
|
||||
lastProgressAtRef.current = null;
|
||||
lastDownloadPercentRef.current = 0;
|
||||
@@ -182,6 +190,11 @@ export function useBrowserSetup(browser: string, active: boolean) {
|
||||
}
|
||||
lastProgressAtRef.current = Date.now();
|
||||
setPhase("extracting");
|
||||
if (p.percentage > 0) {
|
||||
sawRealExtractionRef.current = true;
|
||||
setExtractionPercent(Math.min(99, Math.round(p.percentage)));
|
||||
setExtractionOvertime(false);
|
||||
}
|
||||
break;
|
||||
case "verifying":
|
||||
lastStageRef.current = "verifying";
|
||||
@@ -257,9 +270,9 @@ export function useBrowserSetup(browser: string, active: boolean) {
|
||||
// Drive the estimated extraction percentage while extracting.
|
||||
const tick = setInterval(() => {
|
||||
if (!alive || doneRef.current) return;
|
||||
// If the download visibly finished but we never saw the (single)
|
||||
// "extracting" event, start estimating extraction anyway — anchored to
|
||||
// the last download event, which is roughly when extraction began.
|
||||
// If the download visibly finished but we never saw any "extracting"
|
||||
// event, start estimating extraction anyway — anchored to the last
|
||||
// download event, which is roughly when extraction began.
|
||||
if (
|
||||
extractStartRef.current == null &&
|
||||
sawDownloadingRef.current &&
|
||||
@@ -272,6 +285,9 @@ export function useBrowserSetup(browser: string, active: boolean) {
|
||||
setPhase("extracting");
|
||||
}
|
||||
if (extractStartRef.current == null) return;
|
||||
// Real backend percentages drive the bar; the estimate would only
|
||||
// fight them (and flag bogus "overtime" on a healthy extraction).
|
||||
if (sawRealExtractionRef.current) return;
|
||||
const elapsed = Date.now() - extractStartRef.current;
|
||||
const est = estimateRef.current || DEFAULT_EXTRACT_MS;
|
||||
if (elapsed >= est) {
|
||||
@@ -310,6 +326,7 @@ export function useBrowserSetup(browser: string, active: boolean) {
|
||||
setExtractionOvertime(false);
|
||||
setError(null);
|
||||
extractStartRef.current = null;
|
||||
sawRealExtractionRef.current = false;
|
||||
sawDownloadingRef.current = false;
|
||||
lastProgressAtRef.current = null;
|
||||
lastDownloadPercentRef.current = 0;
|
||||
|
||||
@@ -1798,7 +1798,11 @@
|
||||
"proxyNotWorking": "The selected proxy isn't working, so the profile wasn't created.",
|
||||
"proxyPaymentRequired": "The selected proxy requires payment (402) — its subscription may have expired — so the profile wasn't created.",
|
||||
"vpnNotWorking": "The selected VPN isn't working, so the profile wasn't created.",
|
||||
"camoufoxImportDeprecated": "Importing Firefox-based profiles is no longer supported. Please use Wayfern instead."
|
||||
"camoufoxImportDeprecated": "Importing this profile type is no longer supported. Please use Wayfern instead.",
|
||||
"updateChecksumsUnavailable": "The update {{version}} could not be verified because its checksum file could not be retrieved. The update was not installed; it will be retried later.",
|
||||
"updateChecksumMismatch": "The downloaded update file {{file}} failed checksum verification and was discarded. Please try again.",
|
||||
"nameCannotBeEmpty": "Name cannot be empty",
|
||||
"wayfernVersionNotAvailable": "Wayfern version {{requested}} is not available for download. The current version is {{current}}."
|
||||
},
|
||||
"rail": {
|
||||
"profiles": "Profiles",
|
||||
|
||||
@@ -1798,7 +1798,11 @@
|
||||
"proxyNotWorking": "El proxy seleccionado no funciona, por lo que no se creó el perfil.",
|
||||
"proxyPaymentRequired": "El proxy seleccionado requiere pago (402) —su suscripción puede haber vencido— por lo que no se creó el perfil.",
|
||||
"vpnNotWorking": "La VPN seleccionada no funciona, por lo que no se creó el perfil.",
|
||||
"camoufoxImportDeprecated": "La importación de perfiles basados en Firefox ya no está soportada. Utilice Wayfern en su lugar."
|
||||
"camoufoxImportDeprecated": "La importación de este tipo de perfil ya no es compatible. Utiliza Wayfern en su lugar.",
|
||||
"updateChecksumsUnavailable": "No se pudo verificar la actualización {{version}} porque no se pudo obtener su archivo de sumas de comprobación. La actualización no se instaló; se reintentará más tarde.",
|
||||
"updateChecksumMismatch": "El archivo de actualización descargado {{file}} no superó la verificación de suma de comprobación y fue descartado. Inténtalo de nuevo.",
|
||||
"nameCannotBeEmpty": "El nombre no puede estar vacío",
|
||||
"wayfernVersionNotAvailable": "La versión {{requested}} de Wayfern no está disponible para descargar. La versión actual es {{current}}."
|
||||
},
|
||||
"rail": {
|
||||
"profiles": "Perfiles",
|
||||
|
||||
@@ -1798,7 +1798,11 @@
|
||||
"proxyNotWorking": "Le proxy sélectionné ne fonctionne pas, le profil n'a donc pas été créé.",
|
||||
"proxyPaymentRequired": "Le proxy sélectionné requiert un paiement (402) — son abonnement a peut-être expiré — le profil n'a donc pas été créé.",
|
||||
"vpnNotWorking": "Le VPN sélectionné ne fonctionne pas, le profil n'a donc pas été créé.",
|
||||
"camoufoxImportDeprecated": "L'importation de profils basés sur Firefox n'est plus prise en charge. Utilisez Wayfern à la place."
|
||||
"camoufoxImportDeprecated": "L'importation de ce type de profil n'est plus prise en charge. Veuillez utiliser Wayfern à la place.",
|
||||
"updateChecksumsUnavailable": "La mise à jour {{version}} n'a pas pu être vérifiée car son fichier de sommes de contrôle n'a pas pu être récupéré. La mise à jour n'a pas été installée ; une nouvelle tentative aura lieu plus tard.",
|
||||
"updateChecksumMismatch": "Le fichier de mise à jour téléchargé {{file}} a échoué à la vérification de la somme de contrôle et a été supprimé. Veuillez réessayer.",
|
||||
"nameCannotBeEmpty": "Le nom ne peut pas être vide",
|
||||
"wayfernVersionNotAvailable": "La version {{requested}} de Wayfern n'est pas disponible au téléchargement. La version actuelle est {{current}}."
|
||||
},
|
||||
"rail": {
|
||||
"profiles": "Profils",
|
||||
|
||||
@@ -1798,7 +1798,11 @@
|
||||
"proxyNotWorking": "選択したプロキシが機能していないため、プロファイルは作成されませんでした。",
|
||||
"proxyPaymentRequired": "選択したプロキシは支払いが必要です(402)。サブスクリプションが期限切れの可能性があります。そのため、プロファイルは作成されませんでした。",
|
||||
"vpnNotWorking": "選択したVPNが機能していないため、プロファイルは作成されませんでした。",
|
||||
"camoufoxImportDeprecated": "Firefox ベースのプロファイルのインポートはサポートされなくなりました。Wayfern をご利用ください。"
|
||||
"camoufoxImportDeprecated": "このタイプのプロファイルのインポートはサポートされなくなりました。代わりにWayfernを使用してください。",
|
||||
"updateChecksumsUnavailable": "アップデート {{version}} のチェックサムファイルを取得できなかったため、検証できませんでした。アップデートはインストールされませんでした。後で再試行されます。",
|
||||
"updateChecksumMismatch": "ダウンロードしたアップデートファイル {{file}} はチェックサム検証に失敗したため破棄されました。もう一度お試しください。",
|
||||
"nameCannotBeEmpty": "名前を空にすることはできません",
|
||||
"wayfernVersionNotAvailable": "Wayfernのバージョン{{requested}}はダウンロードできません。現在のバージョンは{{current}}です。"
|
||||
},
|
||||
"rail": {
|
||||
"profiles": "プロファイル",
|
||||
|
||||
@@ -1798,7 +1798,11 @@
|
||||
"proxyNotWorking": "선택한 프록시가 작동하지 않아 프로필이 생성되지 않았습니다.",
|
||||
"proxyPaymentRequired": "선택한 프록시는 결제가 필요합니다(402). 구독이 만료되었을 수 있어 프로필이 생성되지 않았습니다.",
|
||||
"vpnNotWorking": "선택한 VPN이 작동하지 않아 프로필이 생성되지 않았습니다.",
|
||||
"camoufoxImportDeprecated": "Firefox 기반 프로필 가져오기는 더 이상 지원되지 않습니다. Wayfern을 사용하세요."
|
||||
"camoufoxImportDeprecated": "이 유형의 프로필 가져오기는 더 이상 지원되지 않습니다. 대신 Wayfern을 사용하세요.",
|
||||
"updateChecksumsUnavailable": "업데이트 {{version}}의 체크섬 파일을 가져올 수 없어 검증하지 못했습니다. 업데이트가 설치되지 않았으며 나중에 다시 시도됩니다.",
|
||||
"updateChecksumMismatch": "다운로드한 업데이트 파일 {{file}}이(가) 체크섬 검증에 실패하여 삭제되었습니다. 다시 시도해 주세요.",
|
||||
"nameCannotBeEmpty": "이름은 비워둘 수 없습니다",
|
||||
"wayfernVersionNotAvailable": "Wayfern 버전 {{requested}}은(는) 다운로드할 수 없습니다. 현재 버전은 {{current}}입니다."
|
||||
},
|
||||
"rail": {
|
||||
"profiles": "프로필",
|
||||
|
||||
@@ -1798,7 +1798,11 @@
|
||||
"proxyNotWorking": "O proxy selecionado não está funcionando, então o perfil não foi criado.",
|
||||
"proxyPaymentRequired": "O proxy selecionado exige pagamento (402) — sua assinatura pode ter expirado — então o perfil não foi criado.",
|
||||
"vpnNotWorking": "A VPN selecionada não está funcionando, então o perfil não foi criado.",
|
||||
"camoufoxImportDeprecated": "A importação de perfis baseados em Firefox não é mais suportada. Use o Wayfern."
|
||||
"camoufoxImportDeprecated": "A importação deste tipo de perfil não é mais suportada. Use o Wayfern em vez disso.",
|
||||
"updateChecksumsUnavailable": "Não foi possível verificar a atualização {{version}} porque o arquivo de somas de verificação não pôde ser obtido. A atualização não foi instalada; será tentada novamente mais tarde.",
|
||||
"updateChecksumMismatch": "O arquivo de atualização baixado {{file}} falhou na verificação de soma de verificação e foi descartado. Tente novamente.",
|
||||
"nameCannotBeEmpty": "O nome não pode estar vazio",
|
||||
"wayfernVersionNotAvailable": "A versão {{requested}} do Wayfern não está disponível para download. A versão atual é {{current}}."
|
||||
},
|
||||
"rail": {
|
||||
"profiles": "Perfis",
|
||||
|
||||
@@ -1798,7 +1798,11 @@
|
||||
"proxyNotWorking": "Выбранный прокси не работает, поэтому профиль не создан.",
|
||||
"proxyPaymentRequired": "Выбранный прокси требует оплаты (402) — возможно, его подписка истекла — поэтому профиль не создан.",
|
||||
"vpnNotWorking": "Выбранный VPN не работает, поэтому профиль не создан.",
|
||||
"camoufoxImportDeprecated": "Импорт профилей на основе Firefox больше не поддерживается. Используйте Wayfern."
|
||||
"camoufoxImportDeprecated": "Импорт профилей этого типа больше не поддерживается. Используйте Wayfern.",
|
||||
"updateChecksumsUnavailable": "Не удалось проверить обновление {{version}}: файл контрольных сумм не удалось получить. Обновление не было установлено; попытка будет повторена позже.",
|
||||
"updateChecksumMismatch": "Загруженный файл обновления {{file}} не прошёл проверку контрольной суммы и был удалён. Попробуйте ещё раз.",
|
||||
"nameCannotBeEmpty": "Имя не может быть пустым",
|
||||
"wayfernVersionNotAvailable": "Версия Wayfern {{requested}} недоступна для загрузки. Текущая версия — {{current}}."
|
||||
},
|
||||
"rail": {
|
||||
"profiles": "Профили",
|
||||
|
||||
@@ -1798,7 +1798,11 @@
|
||||
"proxyNotWorking": "Proxy đã chọn không hoạt động, nên profile chưa được tạo.",
|
||||
"proxyPaymentRequired": "Proxy đã chọn yêu cầu thanh toán (402) — gói đăng ký của nó có thể đã hết hạn — nên profile chưa được tạo.",
|
||||
"vpnNotWorking": "VPN đã chọn không hoạt động, nên profile chưa được tạo.",
|
||||
"camoufoxImportDeprecated": "Không còn hỗ trợ nhập profile dựa trên Firefox. Vui lòng dùng Wayfern."
|
||||
"camoufoxImportDeprecated": "Việc nhập loại hồ sơ này không còn được hỗ trợ. Vui lòng sử dụng Wayfern thay thế.",
|
||||
"updateChecksumsUnavailable": "Không thể xác minh bản cập nhật {{version}} vì không thể tải tệp checksum. Bản cập nhật chưa được cài đặt; sẽ thử lại sau.",
|
||||
"updateChecksumMismatch": "Tệp cập nhật đã tải xuống {{file}} không vượt qua kiểm tra checksum và đã bị loại bỏ. Vui lòng thử lại.",
|
||||
"nameCannotBeEmpty": "Tên không được để trống",
|
||||
"wayfernVersionNotAvailable": "Phiên bản Wayfern {{requested}} không có sẵn để tải xuống. Phiên bản hiện tại là {{current}}."
|
||||
},
|
||||
"rail": {
|
||||
"profiles": "Profile",
|
||||
|
||||
@@ -1798,7 +1798,11 @@
|
||||
"proxyNotWorking": "所选代理无法使用,因此未创建配置文件。",
|
||||
"proxyPaymentRequired": "所选代理需要付费(402),其订阅可能已过期,因此未创建配置文件。",
|
||||
"vpnNotWorking": "所选 VPN 无法使用,因此未创建配置文件。",
|
||||
"camoufoxImportDeprecated": "不再支持导入基于 Firefox 的配置文件。请改用 Wayfern。"
|
||||
"camoufoxImportDeprecated": "不再支持导入此类型的配置文件。请改用 Wayfern。",
|
||||
"updateChecksumsUnavailable": "无法验证更新 {{version}}:无法获取其校验和文件。更新未安装,稍后将重试。",
|
||||
"updateChecksumMismatch": "下载的更新文件 {{file}} 未通过校验和验证,已被丢弃。请重试。",
|
||||
"nameCannotBeEmpty": "名称不能为空",
|
||||
"wayfernVersionNotAvailable": "Wayfern 版本 {{requested}} 无法下载。当前版本为 {{current}}。"
|
||||
},
|
||||
"rail": {
|
||||
"profiles": "配置文件",
|
||||
|
||||
@@ -23,6 +23,8 @@ export type BackendErrorCode =
|
||||
| "PROXY_NOT_FOUND"
|
||||
| "GROUP_NOT_FOUND"
|
||||
| "GROUP_ALREADY_EXISTS"
|
||||
| "NAME_CANNOT_BE_EMPTY"
|
||||
| "WAYFERN_VERSION_NOT_AVAILABLE"
|
||||
| "VPN_NOT_FOUND"
|
||||
| "EXTENSION_NOT_FOUND"
|
||||
| "EXTENSION_GROUP_NOT_FOUND"
|
||||
@@ -34,6 +36,8 @@ export type BackendErrorCode =
|
||||
| "PROXY_PAYMENT_REQUIRED"
|
||||
| "VPN_NOT_WORKING"
|
||||
| "CAMOUFOX_IMPORT_DEPRECATED"
|
||||
| "UPDATE_CHECKSUMS_UNAVAILABLE"
|
||||
| "UPDATE_CHECKSUM_MISMATCH"
|
||||
| "INTERNAL_ERROR";
|
||||
|
||||
export interface BackendError {
|
||||
@@ -116,6 +120,13 @@ export function translateBackendError(t: TFunction, err: unknown): string {
|
||||
return t("backendErrors.groupNotFound");
|
||||
case "GROUP_ALREADY_EXISTS":
|
||||
return t("backendErrors.groupAlreadyExists");
|
||||
case "NAME_CANNOT_BE_EMPTY":
|
||||
return t("backendErrors.nameCannotBeEmpty");
|
||||
case "WAYFERN_VERSION_NOT_AVAILABLE":
|
||||
return t("backendErrors.wayfernVersionNotAvailable", {
|
||||
requested: parsed.params?.requested ?? "",
|
||||
current: parsed.params?.current ?? "",
|
||||
});
|
||||
case "VPN_NOT_FOUND":
|
||||
return t("backendErrors.vpnNotFound");
|
||||
case "EXTENSION_NOT_FOUND":
|
||||
@@ -138,6 +149,14 @@ export function translateBackendError(t: TFunction, err: unknown): string {
|
||||
return t("backendErrors.vpnNotWorking");
|
||||
case "CAMOUFOX_IMPORT_DEPRECATED":
|
||||
return t("backendErrors.camoufoxImportDeprecated");
|
||||
case "UPDATE_CHECKSUMS_UNAVAILABLE":
|
||||
return t("backendErrors.updateChecksumsUnavailable", {
|
||||
version: parsed.params?.version ?? "",
|
||||
});
|
||||
case "UPDATE_CHECKSUM_MISMATCH":
|
||||
return t("backendErrors.updateChecksumMismatch", {
|
||||
file: parsed.params?.file ?? "",
|
||||
});
|
||||
case "INTERNAL_ERROR":
|
||||
return t("backendErrors.internal", {
|
||||
detail: parsed.params?.detail ?? "",
|
||||
|
||||
@@ -6,6 +6,17 @@
|
||||
@theme {
|
||||
--font-sans: var(--font-geist-sans);
|
||||
--font-mono: var(--font-geist-mono);
|
||||
--animate-progress-indeterminate: progress-indeterminate 1.4s ease-in-out
|
||||
infinite;
|
||||
|
||||
@keyframes progress-indeterminate {
|
||||
0% {
|
||||
transform: translateX(-100%);
|
||||
}
|
||||
100% {
|
||||
transform: translateX(300%);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@theme inline {
|
||||
|
||||
@@ -211,6 +211,10 @@ export interface AppUpdateInfo {
|
||||
manual_update_required: boolean;
|
||||
release_page_url?: string;
|
||||
repo_update: boolean;
|
||||
/** URL of the release's SHA256SUMS.txt; downloads are verified against it. */
|
||||
checksums_url?: string | null;
|
||||
/** GitHub-computed digest of the chosen asset ("sha256:<hex>"). */
|
||||
asset_digest?: string | null;
|
||||
}
|
||||
|
||||
export interface AppUpdateProgress {
|
||||
|
||||
Reference in New Issue
Block a user