Compare commits

...

12 Commits

Author SHA1 Message Date
zhom e98d02a585 chore: version bump 2026-06-03 23:05:21 +04:00
zhom afa2326584 fix: launch wayfern with proper dimentions for mobile devices 2026-06-03 23:05:21 +04:00
github-actions[bot] d25d8549e4 chore: update flake.nix for v0.25.2 [skip ci] (#415)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-06-02 01:43:35 +00:00
github-actions[bot] 662b370ed0 docs: update CHANGELOG.md and README.md for v0.25.2 [skip ci] (#414)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-06-02 01:43:19 +00:00
zhom b2d16c7be1 chore: simplify linux repo publish 2026-06-02 04:22:38 +04:00
zhom a0244356bf chore: version bump 2026-06-02 04:22:38 +04:00
zhom 14522c75f6 refactor: cleanup 2026-06-02 04:22:38 +04:00
zhom b4624f8e8f chore: copy 2026-06-02 04:22:38 +04:00
github-actions[bot] e5f12884de chore: update flake.nix for v0.25.1 [skip ci] (#413)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-06-01 20:56:02 +00:00
github-actions[bot] c95b097c93 docs: update CHANGELOG.md and README.md for v0.25.1 [skip ci] (#412)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-06-01 20:55:41 +00:00
andy 742b883090 Merge pull request #411 from zhom/contributors-readme-action-2wao70ioBS
docs(contributor): contributors readme action update
2026-06-01 12:36:22 -07:00
github-actions[bot] 57e068084e docs(contributor): contrib-readme-action has updated readme 2026-06-01 19:35:08 +00:00
20 changed files with 239 additions and 97 deletions
+5 -17
View File
@@ -43,26 +43,14 @@ jobs:
echo "tag=${TAG}" >> "$GITHUB_OUTPUT" echo "tag=${TAG}" >> "$GITHUB_OUTPUT"
fi fi
- name: Install tools (dpkg-dev, createrepo-c, aws-cli v1) - name: Install tools
run: | run: |
# Mirror the local/Docker setup from CLAUDE.md exactly: the same apt
# packages and the same pip-installed awscli the working local run uses.
sudo apt-get update sudo apt-get update
sudo apt-get install -y dpkg-dev createrepo-c python3-pip sudo apt-get install -y dpkg-dev createrepo-c python3-pip
# GitHub runners ship aws-cli v2, which sends CRC64NVME integrity pip3 install --break-system-packages awscli
# checksums that Cloudflare R2 rejects with `Unauthorized` on echo "$HOME/.local/bin" >> "$GITHUB_PATH"
# ListObjectsV2 (the call behind `aws s3 sync`). aws-cli v1 predates
# that behavior — the same reason scripts/publish-repo.sh works in
# Docker. Remove v2 and install v1 system-wide so it's the binary on
# PATH within this job, and fail fast if v2 somehow survives rather
# than letting the publish step die on an opaque Unauthorized later.
sudo rm -f /usr/local/bin/aws /usr/local/bin/aws_completer
sudo rm -rf /usr/local/aws-cli
sudo pip3 install --break-system-packages 'awscli<2'
hash -r
aws --version
case "$(aws --version 2>&1)" in
aws-cli/1.*) ;;
*) echo "::error::Expected aws-cli v1 but got: $(aws --version 2>&1)"; exit 1 ;;
esac
- name: Publish DEB & RPM repositories to R2 - name: Publish DEB & RPM repositories to R2
env: env:
+28
View File
@@ -1,6 +1,34 @@
# Changelog # Changelog
## v0.25.2 (2026-06-02)
### Refactoring
- cleanup
### Documentation
- update CHANGELOG.md and README.md for v0.25.1 [skip ci] (#412)
### Maintenance
- chore: simplify linux repo publish
- chore: version bump
- chore: copy
- chore: update flake.nix for v0.25.1 [skip ci] (#413)
## v0.25.1 (2026-06-01)
### Maintenance
- chore: version bump
- chore: update issue validation
- chore: cleanup windows ci
- chore: add missing keys
## v0.25.0 (2026-06-01) ## v0.25.0 (2026-06-01)
Note: created manually due to CI issue Note: created manually due to CI issue
+14 -14
View File
@@ -46,7 +46,7 @@
| | Apple Silicon | Intel | | | Apple Silicon | Intel |
|---|---|---| |---|---|---|
| **DMG** | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.24.4/Donut_0.24.4_aarch64.dmg) | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.24.4/Donut_0.24.4_x64.dmg) | | **DMG** | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.25.2/Donut_0.25.2_aarch64.dmg) | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.25.2/Donut_0.25.2_x64.dmg) |
Or install via Homebrew: Or install via Homebrew:
@@ -56,15 +56,15 @@ brew install --cask donut
### Windows ### Windows
[Download Windows Installer (x64)](https://github.com/zhom/donutbrowser/releases/download/v0.24.4/Donut_0.24.4_x64-setup.exe) · [Portable (x64)](https://github.com/zhom/donutbrowser/releases/download/v0.24.4/Donut_0.24.4_x64-portable.zip) [Download Windows Installer (x64)](https://github.com/zhom/donutbrowser/releases/download/v0.25.2/Donut_0.25.2_x64-setup.exe) · [Portable (x64)](https://github.com/zhom/donutbrowser/releases/download/v0.25.2/Donut_0.25.2_x64-portable.zip)
### Linux ### Linux
| Format | x86_64 | ARM64 | | Format | x86_64 | ARM64 |
|---|---|---| |---|---|---|
| **deb** | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.24.4/Donut_0.24.4_amd64.deb) | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.24.4/Donut_0.24.4_arm64.deb) | | **deb** | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.25.2/Donut_0.25.2_amd64.deb) | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.25.2/Donut_0.25.2_arm64.deb) |
| **rpm** | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.24.4/Donut-0.24.4-1.x86_64.rpm) | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.24.4/Donut-0.24.4-1.aarch64.rpm) | | **rpm** | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.25.2/Donut-0.25.2-1.x86_64.rpm) | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.25.2/Donut-0.25.2-1.aarch64.rpm) |
| **AppImage** | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.24.4/Donut_0.24.4_amd64.AppImage) | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.24.4/Donut_0.24.4_aarch64.AppImage) | | **AppImage** | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.25.2/Donut_0.25.2_amd64.AppImage) | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.25.2/Donut_0.25.2_aarch64.AppImage) |
<!-- install-links-end --> <!-- install-links-end -->
Or install via package manager: Or install via package manager:
@@ -149,6 +149,13 @@ See [CONTRIBUTING.md](CONTRIBUTING.md).
<sub><b>yb403</b></sub> <sub><b>yb403</b></sub>
</a> </a>
</td> </td>
<td align="center">
<a href="https://github.com/huy97">
<img src="https://avatars.githubusercontent.com/u/30153437?v=4" width="100;" alt="huy97"/>
<br />
<sub><b>Huy Le</b></sub>
</a>
</td>
<td align="center"> <td align="center">
<a href="https://github.com/drunkod"> <a href="https://github.com/drunkod">
<img src="https://avatars.githubusercontent.com/u/9677471?v=4" width="100;" alt="drunkod"/> <img src="https://avatars.githubusercontent.com/u/9677471?v=4" width="100;" alt="drunkod"/>
@@ -156,6 +163,8 @@ See [CONTRIBUTING.md](CONTRIBUTING.md).
<sub><b>drunkod</b></sub> <sub><b>drunkod</b></sub>
</a> </a>
</td> </td>
</tr>
<tr>
<td align="center"> <td align="center">
<a href="https://github.com/JorySeverijnse"> <a href="https://github.com/JorySeverijnse">
<img src="https://avatars.githubusercontent.com/u/117462355?v=4" width="100;" alt="JorySeverijnse"/> <img src="https://avatars.githubusercontent.com/u/117462355?v=4" width="100;" alt="JorySeverijnse"/>
@@ -163,21 +172,12 @@ See [CONTRIBUTING.md](CONTRIBUTING.md).
<sub><b>Jory Severijnse</b></sub> <sub><b>Jory Severijnse</b></sub>
</a> </a>
</td> </td>
</tr>
<tr>
<td align="center"> <td align="center">
<a href="https://github.com/ThiagoMafra-Integrare"> <a href="https://github.com/ThiagoMafra-Integrare">
<img src="https://avatars.githubusercontent.com/u/222241596?v=4" width="100;" alt="ThiagoMafra-Integrare"/> <img src="https://avatars.githubusercontent.com/u/222241596?v=4" width="100;" alt="ThiagoMafra-Integrare"/>
<br /> <br />
<sub><b>Thiago Mafra</b></sub> <sub><b>Thiago Mafra</b></sub>
</a> </a>
</td>
<td align="center">
<a href="https://github.com/huy97">
<img src="https://avatars.githubusercontent.com/u/30153437?v=4" width="100;" alt="huy97"/>
<br />
<sub><b>Huy Le</b></sub>
</a>
</td> </td>
</tr> </tr>
<tbody> <tbody>
+5 -5
View File
@@ -96,17 +96,17 @@
pkgConfigPath = lib.makeSearchPath "lib/pkgconfig" ( pkgConfigPath = lib.makeSearchPath "lib/pkgconfig" (
pkgConfigLibs ++ map lib.getDev pkgConfigLibs pkgConfigLibs ++ map lib.getDev pkgConfigLibs
); );
releaseVersion = "0.24.4"; releaseVersion = "0.25.2";
releaseAppImage = releaseAppImage =
if system == "x86_64-linux" then if system == "x86_64-linux" then
pkgs.fetchurl { pkgs.fetchurl {
url = "https://github.com/zhom/donutbrowser/releases/download/v0.24.4/Donut_0.24.4_amd64.AppImage"; url = "https://github.com/zhom/donutbrowser/releases/download/v0.25.2/Donut_0.25.2_amd64.AppImage";
hash = "sha256-YNXPed96GmuMhJVERxa2gYtiaQoMfdB0az5O5J0b/No="; hash = "sha256-awESxsKfrSJFMAGbTasbXjL8UnF58ziLnS8Ee0phgb8=";
} }
else if system == "aarch64-linux" then else if system == "aarch64-linux" then
pkgs.fetchurl { pkgs.fetchurl {
url = "https://github.com/zhom/donutbrowser/releases/download/v0.24.4/Donut_0.24.4_aarch64.AppImage"; url = "https://github.com/zhom/donutbrowser/releases/download/v0.25.2/Donut_0.25.2_aarch64.AppImage";
hash = "sha256-kdEzMO53bCUH7E8GPDewnIDLRIO5pWlO8B4TdpLAQIg="; hash = "sha256-zOUWnvf+5stknWomHwYRUw2TR0aS4/XeiVySBjHuJLA=";
} }
else else
null; null;
+1 -1
View File
@@ -2,7 +2,7 @@
"name": "donutbrowser", "name": "donutbrowser",
"private": true, "private": true,
"license": "AGPL-3.0", "license": "AGPL-3.0",
"version": "0.25.1", "version": "0.25.3",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "next dev --turbopack -p 12341", "dev": "next dev --turbopack -p 12341",
+1 -1
View File
@@ -1784,7 +1784,7 @@ dependencies = [
[[package]] [[package]]
name = "donutbrowser" name = "donutbrowser"
version = "0.25.1" version = "0.25.3"
dependencies = [ dependencies = [
"aes 0.9.1", "aes 0.9.1",
"aes-gcm", "aes-gcm",
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "donutbrowser" name = "donutbrowser"
version = "0.25.1" version = "0.25.3"
description = "Simple Yet Powerful Anti-Detect Browser" description = "Simple Yet Powerful Anti-Detect Browser"
authors = ["zhom@github"] authors = ["zhom@github"]
edition = "2021" edition = "2021"
+27 -28
View File
@@ -586,22 +586,12 @@ pub async fn get_api_server_status() -> Result<Option<u16>, String> {
Ok(server_guard.get_port()) Ok(server_guard.get_port())
} }
/// Serialize a browser config (camoufox/wayfern) to JSON for an API response, /// Serialize a browser config (camoufox/wayfern) to JSON for an API response.
/// dropping the `fingerprint` field unless the user has an active paid plan. /// Viewing a profile's fingerprint is available to every API caller; only
/// Viewing fingerprints is a paid feature, so free users (and unauthenticated /// editing it (via `update_profile`) and launching/killing profiles
/// API/MCP callers) must never receive it. `is_paid` is resolved once per /// programmatically require an active paid plan.
/// handler via `has_active_paid_subscription()`. fn config_to_api_value<T: serde::Serialize>(config: Option<&T>) -> Option<serde_json::Value> {
fn config_to_api_value<T: serde::Serialize>( serde_json::to_value(config?).ok()
config: Option<&T>,
is_paid: bool,
) -> Option<serde_json::Value> {
let mut value = serde_json::to_value(config?).ok()?;
if !is_paid {
if let Some(obj) = value.as_object_mut() {
obj.remove("fingerprint");
}
}
Some(value)
} }
// API Handlers - Profiles // API Handlers - Profiles
@@ -620,9 +610,6 @@ fn config_to_api_value<T: serde::Serialize>(
)] )]
async fn get_profiles() -> Result<Json<ApiProfilesResponse>, StatusCode> { async fn get_profiles() -> Result<Json<ApiProfilesResponse>, StatusCode> {
let profile_manager = ProfileManager::instance(); let profile_manager = ProfileManager::instance();
let is_paid = crate::cloud_auth::CLOUD_AUTH
.has_active_paid_subscription()
.await;
match profile_manager.list_profiles() { match profile_manager.list_profiles() {
Ok(profiles) => { Ok(profiles) => {
let api_profiles: Vec<ApiProfile> = profiles let api_profiles: Vec<ApiProfile> = profiles
@@ -637,7 +624,7 @@ async fn get_profiles() -> Result<Json<ApiProfilesResponse>, StatusCode> {
process_id: profile.process_id, process_id: profile.process_id,
last_launch: profile.last_launch, last_launch: profile.last_launch,
release_type: profile.release_type.clone(), release_type: profile.release_type.clone(),
camoufox_config: config_to_api_value(profile.camoufox_config.as_ref(), is_paid), camoufox_config: config_to_api_value(profile.camoufox_config.as_ref()),
group_id: profile.group_id.clone(), group_id: profile.group_id.clone(),
tags: profile.tags.clone(), tags: profile.tags.clone(),
is_running: profile.process_id.is_some(), // Simple check based on process_id is_running: profile.process_id.is_some(), // Simple check based on process_id
@@ -677,9 +664,6 @@ async fn get_profile(
State(_state): State<ApiServerState>, State(_state): State<ApiServerState>,
) -> Result<Json<ApiProfileResponse>, StatusCode> { ) -> Result<Json<ApiProfileResponse>, StatusCode> {
let profile_manager = ProfileManager::instance(); let profile_manager = ProfileManager::instance();
let is_paid = crate::cloud_auth::CLOUD_AUTH
.has_active_paid_subscription()
.await;
match profile_manager.list_profiles() { match profile_manager.list_profiles() {
Ok(profiles) => { Ok(profiles) => {
if let Some(profile) = profiles.iter().find(|p| p.id.to_string() == id) { if let Some(profile) = profiles.iter().find(|p| p.id.to_string() == id) {
@@ -694,7 +678,7 @@ async fn get_profile(
process_id: profile.process_id, process_id: profile.process_id,
last_launch: profile.last_launch, last_launch: profile.last_launch,
release_type: profile.release_type.clone(), release_type: profile.release_type.clone(),
camoufox_config: config_to_api_value(profile.camoufox_config.as_ref(), is_paid), camoufox_config: config_to_api_value(profile.camoufox_config.as_ref()),
group_id: profile.group_id.clone(), group_id: profile.group_id.clone(),
tags: profile.tags.clone(), tags: profile.tags.clone(),
is_running: profile.process_id.is_some(), // Simple check based on process_id is_running: profile.process_id.is_some(), // Simple check based on process_id
@@ -730,9 +714,6 @@ async fn create_profile(
Json(request): Json<CreateProfileRequest>, Json(request): Json<CreateProfileRequest>,
) -> Result<Json<ApiProfileResponse>, StatusCode> { ) -> Result<Json<ApiProfileResponse>, StatusCode> {
let profile_manager = ProfileManager::instance(); let profile_manager = ProfileManager::instance();
let is_paid = crate::cloud_auth::CLOUD_AUTH
.has_active_paid_subscription()
.await;
// Parse camoufox config if provided // Parse camoufox config if provided
let camoufox_config = if let Some(config) = &request.camoufox_config { let camoufox_config = if let Some(config) = &request.camoufox_config {
@@ -809,7 +790,7 @@ async fn create_profile(
process_id: profile.process_id, process_id: profile.process_id,
last_launch: profile.last_launch, last_launch: profile.last_launch,
release_type: profile.release_type, release_type: profile.release_type,
camoufox_config: config_to_api_value(profile.camoufox_config.as_ref(), is_paid), camoufox_config: config_to_api_value(profile.camoufox_config.as_ref()),
group_id: profile.group_id, group_id: profile.group_id,
tags: profile.tags, tags: profile.tags,
is_running: false, is_running: false,
@@ -914,6 +895,14 @@ async fn update_profile(
} }
if let Some(camoufox_config) = request.camoufox_config { if let Some(camoufox_config) = request.camoufox_config {
// Editing a profile's fingerprint config is a paid feature everywhere
// (GUI, API, MCP). Viewing it is free; mutating it is not.
if !crate::cloud_auth::CLOUD_AUTH
.has_active_paid_subscription()
.await
{
return Err(StatusCode::PAYMENT_REQUIRED);
}
let config: Result<CamoufoxConfig, _> = serde_json::from_value(camoufox_config); let config: Result<CamoufoxConfig, _> = serde_json::from_value(camoufox_config);
match config { match config {
Ok(config) => { Ok(config) => {
@@ -1844,6 +1833,7 @@ async fn open_url_in_profile(
responses( responses(
(status = 204, description = "Browser process killed successfully"), (status = 204, description = "Browser process killed successfully"),
(status = 401, description = "Unauthorized"), (status = 401, description = "Unauthorized"),
(status = 402, description = "Active paid plan required"),
(status = 404, description = "Profile not found"), (status = 404, description = "Profile not found"),
(status = 500, description = "Internal server error") (status = 500, description = "Internal server error")
), ),
@@ -1856,6 +1846,15 @@ async fn kill_profile(
Path(id): Path<String>, Path(id): Path<String>,
State(state): State<ApiServerState>, State(state): State<ApiServerState>,
) -> Result<StatusCode, StatusCode> { ) -> Result<StatusCode, StatusCode> {
// Programmatically launching and stopping profiles is a paid feature; the
// run/open-url handlers gate the same way.
if !crate::cloud_auth::CLOUD_AUTH
.has_active_paid_subscription()
.await
{
return Err(StatusCode::PAYMENT_REQUIRED);
}
let profile_manager = ProfileManager::instance(); let profile_manager = ProfileManager::instance();
let profiles = profile_manager let profiles = profile_manager
.list_profiles() .list_profiles()
+8 -2
View File
@@ -508,7 +508,7 @@ impl McpServer {
}, },
McpTool { McpTool {
name: "run_profile".to_string(), name: "run_profile".to_string(),
description: "Launch a browser profile with an optional URL".to_string(), description: "Launch a browser profile with an optional URL. Requires an active Pro subscription.".to_string(),
input_schema: serde_json::json!({ input_schema: serde_json::json!({
"type": "object", "type": "object",
"properties": { "properties": {
@@ -530,7 +530,7 @@ impl McpServer {
}, },
McpTool { McpTool {
name: "kill_profile".to_string(), name: "kill_profile".to_string(),
description: "Stop a running browser profile".to_string(), description: "Stop a running browser profile. Requires an active Pro subscription.".to_string(),
input_schema: serde_json::json!({ input_schema: serde_json::json!({
"type": "object", "type": "object",
"properties": { "properties": {
@@ -1829,6 +1829,9 @@ impl McpServer {
&self, &self,
arguments: &serde_json::Value, arguments: &serde_json::Value,
) -> Result<serde_json::Value, McpError> { ) -> Result<serde_json::Value, McpError> {
// Launching profiles programmatically is a paid feature.
Self::require_paid_subscription("Launching a profile").await?;
let profile_id = arguments let profile_id = arguments
.get("profile_id") .get("profile_id")
.and_then(|v| v.as_str()) .and_then(|v| v.as_str())
@@ -1910,6 +1913,9 @@ impl McpServer {
&self, &self,
arguments: &serde_json::Value, arguments: &serde_json::Value,
) -> Result<serde_json::Value, McpError> { ) -> Result<serde_json::Value, McpError> {
// Stopping profiles programmatically is a paid feature.
Self::require_paid_subscription("Killing a profile").await?;
let profile_id = arguments let profile_id = arguments
.get("profile_id") .get("profile_id")
.and_then(|v| v.as_str()) .and_then(|v| v.as_str())
+121
View File
@@ -138,6 +138,46 @@ impl WayfernManager {
fingerprint fingerprint
} }
/// Derive the on-screen window size Chromium should open at, from the stored
/// fingerprint. `Wayfern.setFingerprint` only spoofs what the page *reports*
/// for `windowOuterWidth`/`screenWidth`/etc.; it does not move or resize the
/// real top-level window. Without `--window-size` the OS window keeps
/// Chromium's default, so the visible window contradicts the reported
/// dimensions — a detectable mismatch. We pass `--window-size` so the actual
/// window matches the fingerprint.
///
/// Keys are the camelCase fields Wayfern uses in its fingerprint
/// (`windowOuterWidth`, `screenAvailWidth`, …) — NOT the dotted
/// Camoufox-style keys. Preference order, matching how the fingerprint
/// describes the window:
/// 1. `windowOuterWidth` / `windowOuterHeight` — the real window size.
/// 2. `screenAvailWidth` / `screenAvailHeight` — usable screen area.
/// 3. `screenWidth` / `screenHeight` — full screen.
///
/// Returns `None` when the fingerprint carries no usable dimensions, leaving
/// Chromium's default untouched. The fingerprint JSON may be the bare object
/// or the legacy `{ "fingerprint": {...} }` wrapper.
fn window_size_from_fingerprint(fingerprint_json: &str) -> Option<(u32, u32)> {
let parsed: serde_json::Value = serde_json::from_str(fingerprint_json).ok()?;
let fp = parsed.get("fingerprint").unwrap_or(&parsed);
let obj = fp.as_object()?;
// Accept both numeric and stringified numbers (Wayfern emits numbers, but a
// CDP echo or older saved fingerprint may stringify them).
let read = |key: &str| -> Option<u32> {
let v = obj.get(key)?;
v.as_u64()
.or_else(|| v.as_str().and_then(|s| s.trim().parse::<u64>().ok()))
.filter(|n| *n > 0)
.map(|n| n as u32)
};
let pair = |w: &str, h: &str| -> Option<(u32, u32)> { Some((read(w)?, read(h)?)) };
pair("windowOuterWidth", "windowOuterHeight")
.or_else(|| pair("screenAvailWidth", "screenAvailHeight"))
.or_else(|| pair("screenWidth", "screenHeight"))
}
async fn wait_for_cdp_ready( async fn wait_for_cdp_ready(
&self, &self,
port: u16, port: u16,
@@ -618,6 +658,18 @@ impl WayfernManager {
if headless { if headless {
args.push("--headless=new".to_string()); args.push("--headless=new".to_string());
} else if let Some((w, h)) = config
.fingerprint
.as_deref()
.and_then(Self::window_size_from_fingerprint)
{
// Size the real OS window to match the fingerprint so the visible window
// agrees with the reported windowOuterWidth/screen dimensions. Anchor at
// 0,0 so the window also fits within the spoofed screen origin. Skipped in
// headless mode, where there is no on-screen window.
log::info!("Sizing Wayfern window to fingerprint dimensions: {w}x{h}");
args.push(format!("--window-size={w},{h}"));
args.push("--window-position=0,0".to_string());
} }
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]
@@ -1198,3 +1250,72 @@ impl WayfernManager {
lazy_static::lazy_static! { lazy_static::lazy_static! {
static ref WAYFERN_MANAGER: WayfernManager = WayfernManager::new(); static ref WAYFERN_MANAGER: WayfernManager = WayfernManager::new();
} }
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn window_size_prefers_outer_window_dimensions() {
// Field names + values mirror a real Wayfern fingerprint (camelCase).
let fp = r#"{"windowOuterWidth": 1268, "windowOuterHeight": 764,
"windowInnerWidth": 1253, "windowInnerHeight": 630,
"screenAvailWidth": 1280, "screenAvailHeight": 775,
"screenWidth": 1280, "screenHeight": 800}"#;
assert_eq!(
WayfernManager::window_size_from_fingerprint(fp),
Some((1268, 764))
);
}
#[test]
fn window_size_falls_back_to_avail_then_full_screen() {
let avail = r#"{"screenAvailWidth": 1280, "screenAvailHeight": 775,
"screenWidth": 1280, "screenHeight": 800}"#;
assert_eq!(
WayfernManager::window_size_from_fingerprint(avail),
Some((1280, 775))
);
let full = r#"{"screenWidth": 2560, "screenHeight": 1440}"#;
assert_eq!(
WayfernManager::window_size_from_fingerprint(full),
Some((2560, 1440))
);
}
#[test]
fn window_size_handles_wrapper_and_stringified_numbers() {
let wrapped = r#"{"fingerprint": {"windowOuterWidth": "1366", "windowOuterHeight": "768"}}"#;
assert_eq!(
WayfernManager::window_size_from_fingerprint(wrapped),
Some((1366, 768))
);
}
#[test]
fn window_size_none_when_missing_or_invalid() {
// No dimensions at all.
assert_eq!(
WayfernManager::window_size_from_fingerprint(r#"{"userAgent": "x"}"#),
None
);
// A width with no matching height is not a usable pair.
assert_eq!(
WayfernManager::window_size_from_fingerprint(r#"{"windowOuterWidth": 1268}"#),
None
);
// Zero is rejected as a degenerate size.
assert_eq!(
WayfernManager::window_size_from_fingerprint(
r#"{"windowOuterWidth": 0, "windowOuterHeight": 0}"#
),
None
);
// Not valid JSON.
assert_eq!(
WayfernManager::window_size_from_fingerprint("not json"),
None
);
}
}
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"$schema": "https://schema.tauri.app/config/2", "$schema": "https://schema.tauri.app/config/2",
"productName": "Donut", "productName": "Donut",
"version": "0.25.1", "version": "0.25.3",
"identifier": "com.donutbrowser", "identifier": "com.donutbrowser",
"build": { "build": {
"beforeDevCommand": "pnpm copy-proxy-binary && pnpm dev", "beforeDevCommand": "pnpm copy-proxy-binary && pnpm dev",
+3 -3
View File
@@ -1196,8 +1196,8 @@
}, },
"fingerprint": { "fingerprint": {
"notSupported": "Fingerprint editing is only available for Camoufox and Wayfern profiles.", "notSupported": "Fingerprint editing is only available for Camoufox and Wayfern profiles.",
"lockedTitle": "Fingerprint is a Pro feature", "lockedTitle": "Viewing & editing the fingerprint is a Pro feature",
"lockedDescription": "Viewing and editing a profile's fingerprint requires an active paid plan. Upgrade to unlock fingerprint protection." "lockedDescription": "Fingerprint protection is included on every plan. Viewing and editing a profile's fingerprint values is what requires an active paid plan."
} }
}, },
"extensions": { "extensions": {
@@ -1807,7 +1807,7 @@
"cookieDbLocked": "Could not read cookies — the database is locked. Close the browser and try again.", "cookieDbLocked": "Could not read cookies — the database is locked. Close the browser and try again.",
"cookieDbUnavailable": "Could not read cookies — the cookie store is unavailable.", "cookieDbUnavailable": "Could not read cookies — the cookie store is unavailable.",
"selfHostedRequiresLogout": "Sign out of your Donut account before configuring a self-hosted server.", "selfHostedRequiresLogout": "Sign out of your Donut account before configuring a self-hosted server.",
"fingerprintRequiresPro": "Fingerprint protection requires an active paid plan.", "fingerprintRequiresPro": "Viewing or editing the fingerprint requires an active paid plan. Protection is included on all plans.",
"proxyNotWorking": "The selected proxy isn't working, so the profile wasn't created.", "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.", "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." "vpnNotWorking": "The selected VPN isn't working, so the profile wasn't created."
+3 -3
View File
@@ -1196,8 +1196,8 @@
}, },
"fingerprint": { "fingerprint": {
"notSupported": "La edición de huellas digitales solo está disponible para perfiles Camoufox y Wayfern.", "notSupported": "La edición de huellas digitales solo está disponible para perfiles Camoufox y Wayfern.",
"lockedTitle": "La huella digital es una función Pro", "lockedTitle": "Ver y editar la huella digital es una función Pro",
"lockedDescription": "Ver y editar la huella digital de un perfil requiere un plan de pago activo. Mejora tu plan para desbloquear la protección de huella digital." "lockedDescription": "La protección de huella digital está incluida en todos los planes. Ver y editar los valores de la huella digital de un perfil es lo que requiere un plan de pago activo."
} }
}, },
"extensions": { "extensions": {
@@ -1807,7 +1807,7 @@
"cookieDbLocked": "No se pudieron leer las cookies — la base de datos está bloqueada. Cierra el navegador e inténtalo de nuevo.", "cookieDbLocked": "No se pudieron leer las cookies — la base de datos está bloqueada. Cierra el navegador e inténtalo de nuevo.",
"cookieDbUnavailable": "No se pudieron leer las cookies — el almacén de cookies no está disponible.", "cookieDbUnavailable": "No se pudieron leer las cookies — el almacén de cookies no está disponible.",
"selfHostedRequiresLogout": "Cierra sesión en tu cuenta de Donut antes de configurar un servidor autoalojado.", "selfHostedRequiresLogout": "Cierra sesión en tu cuenta de Donut antes de configurar un servidor autoalojado.",
"fingerprintRequiresPro": "La protección de huella digital requiere un plan de pago activo.", "fingerprintRequiresPro": "Ver o editar la huella digital requiere un plan de pago activo. La protección está incluida en todos los planes.",
"proxyNotWorking": "El proxy seleccionado no funciona, por lo que no se creó el perfil.", "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.", "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." "vpnNotWorking": "La VPN seleccionada no funciona, por lo que no se creó el perfil."
+3 -3
View File
@@ -1196,8 +1196,8 @@
}, },
"fingerprint": { "fingerprint": {
"notSupported": "L’édition des empreintes nest disponible que pour les profils Camoufox et Wayfern.", "notSupported": "L’édition des empreintes nest disponible que pour les profils Camoufox et Wayfern.",
"lockedTitle": "L'empreinte est une fonctionnalité Pro", "lockedTitle": "Afficher et modifier l'empreinte est une fonctionnalité Pro",
"lockedDescription": "Afficher et modifier l'empreinte d'un profil nécessite un forfait payant actif. Passez à un forfait supérieur pour débloquer la protection contre le fingerprinting." "lockedDescription": "La protection contre le fingerprinting est incluse dans tous les forfaits. C'est l'affichage et la modification des valeurs de l'empreinte d'un profil qui nécessitent un forfait payant actif."
} }
}, },
"extensions": { "extensions": {
@@ -1807,7 +1807,7 @@
"cookieDbLocked": "Impossible de lire les cookies — la base de données est verrouillée. Fermez le navigateur et réessayez.", "cookieDbLocked": "Impossible de lire les cookies — la base de données est verrouillée. Fermez le navigateur et réessayez.",
"cookieDbUnavailable": "Impossible de lire les cookies — le magasin de cookies est indisponible.", "cookieDbUnavailable": "Impossible de lire les cookies — le magasin de cookies est indisponible.",
"selfHostedRequiresLogout": "Déconnectez-vous de votre compte Donut avant de configurer un serveur auto-hébergé.", "selfHostedRequiresLogout": "Déconnectez-vous de votre compte Donut avant de configurer un serveur auto-hébergé.",
"fingerprintRequiresPro": "La protection contre le fingerprinting nécessite un forfait payant actif.", "fingerprintRequiresPro": "Afficher ou modifier l'empreinte nécessite un forfait payant actif. La protection est incluse dans tous les forfaits.",
"proxyNotWorking": "Le proxy sélectionné ne fonctionne pas, le profil n'a donc pas été créé.", "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éé.", "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éé." "vpnNotWorking": "Le VPN sélectionné ne fonctionne pas, le profil n'a donc pas été créé."
+3 -3
View File
@@ -1196,8 +1196,8 @@
}, },
"fingerprint": { "fingerprint": {
"notSupported": "フィンガープリント編集は Camoufox / Wayfern プロファイルでのみ利用できます。", "notSupported": "フィンガープリント編集は Camoufox / Wayfern プロファイルでのみ利用できます。",
"lockedTitle": "フィンガープリントは Pro 機能です", "lockedTitle": "フィンガープリントの表示と編集は Pro 機能です",
"lockedDescription": "プロファイルのフィンガープリントの表示編集には有効な有料プランが必要です。アップグレードしてフィンガープリント保護をご利用ください。" "lockedDescription": "フィンガープリント保護はすべてのプランに含まれています。プロファイルのフィンガープリントの値を表示編集するには有効な有料プランが必要です。"
} }
}, },
"extensions": { "extensions": {
@@ -1807,7 +1807,7 @@
"cookieDbLocked": "Cookie を読み取れません — データベースがロックされています。ブラウザを閉じてから再試行してください。", "cookieDbLocked": "Cookie を読み取れません — データベースがロックされています。ブラウザを閉じてから再試行してください。",
"cookieDbUnavailable": "Cookie を読み取れません — Cookie ストアを利用できません。", "cookieDbUnavailable": "Cookie を読み取れません — Cookie ストアを利用できません。",
"selfHostedRequiresLogout": "セルフホストサーバーを設定する前に Donut アカウントからサインアウトしてください。", "selfHostedRequiresLogout": "セルフホストサーバーを設定する前に Donut アカウントからサインアウトしてください。",
"fingerprintRequiresPro": "フィンガープリント保護には有効な有料プランが必要です。", "fingerprintRequiresPro": "フィンガープリントの表示または編集には有効な有料プランが必要です。保護機能はすべてのプランに含まれています。",
"proxyNotWorking": "選択したプロキシが機能していないため、プロファイルは作成されませんでした。", "proxyNotWorking": "選択したプロキシが機能していないため、プロファイルは作成されませんでした。",
"proxyPaymentRequired": "選択したプロキシは支払いが必要です(402)。サブスクリプションが期限切れの可能性があります。そのため、プロファイルは作成されませんでした。", "proxyPaymentRequired": "選択したプロキシは支払いが必要です(402)。サブスクリプションが期限切れの可能性があります。そのため、プロファイルは作成されませんでした。",
"vpnNotWorking": "選択したVPNが機能していないため、プロファイルは作成されませんでした。" "vpnNotWorking": "選択したVPNが機能していないため、プロファイルは作成されませんでした。"
+3 -3
View File
@@ -1196,8 +1196,8 @@
}, },
"fingerprint": { "fingerprint": {
"notSupported": "핑거프린트 편집은 Camoufox 및 Wayfern 프로필에서만 사용할 수 있습니다.", "notSupported": "핑거프린트 편집은 Camoufox 및 Wayfern 프로필에서만 사용할 수 있습니다.",
"lockedTitle": "핑거프린트 Pro 기능입니다", "lockedTitle": "핑거프린트 보기 및 편집은 Pro 기능입니다",
"lockedDescription": "프로필의 핑거프린트 보고 편집하려면 활성 유료 요금제가 필요합니다. 업그레이드하여 핑거프린트 보호를 잠금 해제하세요." "lockedDescription": "핑거프린트 보호는 모든 요금제에 포함되어 있습니다. 프로필의 핑거프린트 값을 보고 편집하려면 활성 유료 요금제가 필요합니다."
} }
}, },
"extensions": { "extensions": {
@@ -1807,7 +1807,7 @@
"cookieDbLocked": "쿠키를 읽을 수 없습니다 — 데이터베이스가 잠겨 있습니다. 브라우저를 닫고 다시 시도하세요.", "cookieDbLocked": "쿠키를 읽을 수 없습니다 — 데이터베이스가 잠겨 있습니다. 브라우저를 닫고 다시 시도하세요.",
"cookieDbUnavailable": "쿠키를 읽을 수 없습니다 — 쿠키 저장소를 사용할 수 없습니다.", "cookieDbUnavailable": "쿠키를 읽을 수 없습니다 — 쿠키 저장소를 사용할 수 없습니다.",
"selfHostedRequiresLogout": "자체 호스팅 서버를 구성하기 전에 도넛 계정에서 로그아웃하세요.", "selfHostedRequiresLogout": "자체 호스팅 서버를 구성하기 전에 도넛 계정에서 로그아웃하세요.",
"fingerprintRequiresPro": "핑거프린트 보호에는 활성 유료 요금제가 필요합니다.", "fingerprintRequiresPro": "핑거프린트거나 편집하려면 활성 유료 요금제가 필요합니다. 보호 기능은 모든 요금제에 포함되어 있습니다.",
"proxyNotWorking": "선택한 프록시가 작동하지 않아 프로필이 생성되지 않았습니다.", "proxyNotWorking": "선택한 프록시가 작동하지 않아 프로필이 생성되지 않았습니다.",
"proxyPaymentRequired": "선택한 프록시는 결제가 필요합니다(402). 구독이 만료되었을 수 있어 프로필이 생성되지 않았습니다.", "proxyPaymentRequired": "선택한 프록시는 결제가 필요합니다(402). 구독이 만료되었을 수 있어 프로필이 생성되지 않았습니다.",
"vpnNotWorking": "선택한 VPN이 작동하지 않아 프로필이 생성되지 않았습니다." "vpnNotWorking": "선택한 VPN이 작동하지 않아 프로필이 생성되지 않았습니다."
+3 -3
View File
@@ -1196,8 +1196,8 @@
}, },
"fingerprint": { "fingerprint": {
"notSupported": "A edição de impressão digital só está disponível para perfis Camoufox e Wayfern.", "notSupported": "A edição de impressão digital só está disponível para perfis Camoufox e Wayfern.",
"lockedTitle": "A impressão digital é um recurso Pro", "lockedTitle": "Visualizar e editar a impressão digital é um recurso Pro",
"lockedDescription": "Visualizar e editar a impressão digital de um perfil requer um plano pago ativo. Faça upgrade para desbloquear a proteção contra fingerprint." "lockedDescription": "A proteção contra fingerprint está incluída em todos os planos. Visualizar e editar os valores da impressão digital de um perfil é o que requer um plano pago ativo."
} }
}, },
"extensions": { "extensions": {
@@ -1807,7 +1807,7 @@
"cookieDbLocked": "Não foi possível ler os cookies — o banco de dados está bloqueado. Feche o navegador e tente novamente.", "cookieDbLocked": "Não foi possível ler os cookies — o banco de dados está bloqueado. Feche o navegador e tente novamente.",
"cookieDbUnavailable": "Não foi possível ler os cookies — o repositório de cookies está indisponível.", "cookieDbUnavailable": "Não foi possível ler os cookies — o repositório de cookies está indisponível.",
"selfHostedRequiresLogout": "Saia da sua conta Donut antes de configurar um servidor auto-hospedado.", "selfHostedRequiresLogout": "Saia da sua conta Donut antes de configurar um servidor auto-hospedado.",
"fingerprintRequiresPro": "A proteção contra fingerprint requer um plano pago ativo.", "fingerprintRequiresPro": "Visualizar ou editar a impressão digital requer um plano pago ativo. A proteção está incluída em todos os planos.",
"proxyNotWorking": "O proxy selecionado não está funcionando, então o perfil não foi criado.", "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.", "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." "vpnNotWorking": "A VPN selecionada não está funcionando, então o perfil não foi criado."
+3 -3
View File
@@ -1196,8 +1196,8 @@
}, },
"fingerprint": { "fingerprint": {
"notSupported": "Редактирование отпечатков доступно только для профилей Camoufox и Wayfern.", "notSupported": "Редактирование отпечатков доступно только для профилей Camoufox и Wayfern.",
"lockedTitle": "Отпечаток — функция Pro", "lockedTitle": "Просмотр и редактирование отпечатка — функция Pro",
"lockedDescription": "Для просмотра и редактирования отпечатка профиля требуется активный платный план. Оформите подписку, чтобы разблокировать защиту от отпечатков." "lockedDescription": "Защита от отпечатков включена во все планы. Активный платный план требуется именно для просмотра и редактирования значений отпечатка профиля."
} }
}, },
"extensions": { "extensions": {
@@ -1807,7 +1807,7 @@
"cookieDbLocked": "Не удалось прочитать куки — база данных заблокирована. Закройте браузер и попробуйте снова.", "cookieDbLocked": "Не удалось прочитать куки — база данных заблокирована. Закройте браузер и попробуйте снова.",
"cookieDbUnavailable": "Не удалось прочитать куки — хранилище куки недоступно.", "cookieDbUnavailable": "Не удалось прочитать куки — хранилище куки недоступно.",
"selfHostedRequiresLogout": "Выйдите из аккаунта Donut, прежде чем настраивать собственный сервер.", "selfHostedRequiresLogout": "Выйдите из аккаунта Donut, прежде чем настраивать собственный сервер.",
"fingerprintRequiresPro": "Для защиты от отпечатков требуется активный платный план.", "fingerprintRequiresPro": "Для просмотра или редактирования отпечатка требуется активный платный план. Защита включена во все планы.",
"proxyNotWorking": "Выбранный прокси не работает, поэтому профиль не создан.", "proxyNotWorking": "Выбранный прокси не работает, поэтому профиль не создан.",
"proxyPaymentRequired": "Выбранный прокси требует оплаты (402) — возможно, его подписка истекла — поэтому профиль не создан.", "proxyPaymentRequired": "Выбранный прокси требует оплаты (402) — возможно, его подписка истекла — поэтому профиль не создан.",
"vpnNotWorking": "Выбранный VPN не работает, поэтому профиль не создан." "vpnNotWorking": "Выбранный VPN не работает, поэтому профиль не создан."
+3 -3
View File
@@ -1196,8 +1196,8 @@
}, },
"fingerprint": { "fingerprint": {
"notSupported": "Chỉnh sửa vân tay chỉ khả dụng cho profile Camoufox và Wayfern.", "notSupported": "Chỉnh sửa vân tay chỉ khả dụng cho profile Camoufox và Wayfern.",
"lockedTitle": "Vân tay là tính năng Pro", "lockedTitle": "Xem và chỉnh sửa vân tay là tính năng Pro",
"lockedDescription": "Xem và chỉnh sửa vân tay của profile yêu cầu gói trả phí đang hoạt động. Nâng cấp để mở khóa bảo vệ vân tay." "lockedDescription": "Bảo vệ vân tay được bao gồm trong mọi gói. Việc xem và chỉnh sửa các giá trị vân tay của profile mới là phần yêu cầu gói trả phí đang hoạt động."
} }
}, },
"extensions": { "extensions": {
@@ -1807,7 +1807,7 @@
"cookieDbLocked": "Không thể đọc cookie — cơ sở dữ liệu bị khóa. Đóng trình duyệt và thử lại.", "cookieDbLocked": "Không thể đọc cookie — cơ sở dữ liệu bị khóa. Đóng trình duyệt và thử lại.",
"cookieDbUnavailable": "Không thể đọc cookie — kho cookie không khả dụng.", "cookieDbUnavailable": "Không thể đọc cookie — kho cookie không khả dụng.",
"selfHostedRequiresLogout": "Đăng xuất khỏi tài khoản Donut trước khi cấu hình máy chủ tự lưu trữ.", "selfHostedRequiresLogout": "Đăng xuất khỏi tài khoản Donut trước khi cấu hình máy chủ tự lưu trữ.",
"fingerprintRequiresPro": "Bảo vệ vân tay yêu cầu gói trả phí đang hoạt động.", "fingerprintRequiresPro": "Xem hoặc chỉnh sửa vân tay yêu cầu gói trả phí đang hoạt động. Tính năng bảo vệ được bao gồm trong mọi gói.",
"proxyNotWorking": "Proxy đã chọn không hoạt động, nên profile chưa được tạo.", "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.", "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." "vpnNotWorking": "VPN đã chọn không hoạt động, nên profile chưa được tạo."
+3 -3
View File
@@ -1196,8 +1196,8 @@
}, },
"fingerprint": { "fingerprint": {
"notSupported": "指纹编辑仅适用于 Camoufox 和 Wayfern 配置文件。", "notSupported": "指纹编辑仅适用于 Camoufox 和 Wayfern 配置文件。",
"lockedTitle": "指纹是 Pro 功能", "lockedTitle": "查看和编辑指纹是 Pro 功能",
"lockedDescription": "查看和编辑配置文件的指纹需要有效的付费方案。升级后即可解锁指纹保护。" "lockedDescription": "所有方案都包含指纹保护。查看和编辑配置文件的指纹数值才需要有效的付费方案。"
} }
}, },
"extensions": { "extensions": {
@@ -1807,7 +1807,7 @@
"cookieDbLocked": "无法读取 Cookie — 数据库已锁定。请关闭浏览器后重试。", "cookieDbLocked": "无法读取 Cookie — 数据库已锁定。请关闭浏览器后重试。",
"cookieDbUnavailable": "无法读取 Cookie — Cookie 存储不可用。", "cookieDbUnavailable": "无法读取 Cookie — Cookie 存储不可用。",
"selfHostedRequiresLogout": "在配置自托管服务器之前请先退出 Donut 账户。", "selfHostedRequiresLogout": "在配置自托管服务器之前请先退出 Donut 账户。",
"fingerprintRequiresPro": "指纹保护需要有效的付费方案。", "fingerprintRequiresPro": "查看或编辑指纹需要有效的付费方案。所有方案均包含指纹保护。",
"proxyNotWorking": "所选代理无法使用,因此未创建配置文件。", "proxyNotWorking": "所选代理无法使用,因此未创建配置文件。",
"proxyPaymentRequired": "所选代理需要付费(402),其订阅可能已过期,因此未创建配置文件。", "proxyPaymentRequired": "所选代理需要付费(402),其订阅可能已过期,因此未创建配置文件。",
"vpnNotWorking": "所选 VPN 无法使用,因此未创建配置文件。" "vpnNotWorking": "所选 VPN 无法使用,因此未创建配置文件。"