feat: privacy settings

This commit is contained in:
zhom committed 2026-09-26 10:35:32 +04:00
1 parent 759263b63f
commit d6657eac98
30 files changed
+1229 -418

No files matched your search

+1 -1
View File
@@ -166,7 +166,7 @@ evidence to the owning suite. `e2e:smoke` fails if command registration and the
Three log surfaces, in order of usefulness:
- Donut Browser GUI: `~/Library/Logs/com.donutbrowser/DonutBrowser.log` on macOS (newest = active session; older `DonutBrowser_<date>.log` are rotated). The GUI, Tauri, `browser_runner`, `proxy_manager`, and `sync` all log here. Search for `Wayfern`, `Starting local proxy`, `Configured local proxy` to find a launch chain. Dev builds write to `DonutBrowserDev.log` instead.
- donut-proxy worker: `$TMPDIR/donut-proxy-<config_id>.log`. One file per proxy worker process (each profile launch spawns a fresh one). Map a worker to its launch via the `Cleanup: browser PID X is dead, stopping proxy worker <id>` lines in DonutBrowser.log, or by mtime. CONNECT requests, upstream accept/reject (status lines like `HTTP/1.1 402 user reached limit`), and tunnel errors are at INFO/WARN. Anything finer is at TRACE and requires `RUST_LOG=donut_proxy=trace`. The `Upstream CONNECT response coalesced N byte(s) of payload` warning (those bytes would be dropped without forwarding) marks a real bug in `handle_connect_from_buffer` if it ever fires.
- donut-proxy worker: `$TMPDIR/donut-proxy-<config_id>.log`. One file per proxy worker process (each profile launch spawns a fresh one). Map a worker to its launch via the `Cleanup: browser PID X is dead, stopping proxy worker <id>` lines in DonutBrowser.log, or by mtime. Upstream rejects (status lines like `HTTP/1.1 402 user reached limit`) and tunnel errors are at WARN. Accepted CONNECT requests are at DEBUG on purpose, so the log is not a list of visited sites; use `RUST_LOG=donut_proxy=debug` to see them. Anything finer is at TRACE and requires `RUST_LOG=donut_proxy=trace`. The `Upstream CONNECT response coalesced N byte(s) of payload` warning (those bytes would be dropped without forwarding) marks a real bug in `handle_connect_from_buffer` if it ever fires.
Linux/Windows swap `~/Library/Logs/com.donutbrowser/` for the platform-appropriate location (see `app_dirs::app_name()`), but the `$TMPDIR` worker logs are always under the system temp dir.
+136
View File
@@ -1386,6 +1386,142 @@ test("settings save a RAM-only edit, preserve location, and expose review and di
});
});
test("settings sections stay on one row that scrolls, drags, and follows the page", async () => {
await withApp("motion-settings-nav", async (app) => {
await resize(app, 780, 580);
await app.clickSelector(`[aria-label="${en.rail.settings}"]`);
const strip = slot("settings-nav");
const scroller = slot("settings-scroller");
await waitForSelector(app, strip);
const layout = () =>
app.execute(
`
const strip = document.querySelector(arguments[0]);
const items = [...strip.querySelectorAll('[data-slot="settings-nav-item"]')];
const bounds = strip.getBoundingClientRect();
const active = strip.querySelector('[aria-current="location"]');
const box = active?.getBoundingClientRect();
return {
rows: new Set(items.map((item) => Math.round(item.getBoundingClientRect().top))).size,
overflow: strip.scrollWidth - strip.clientWidth,
left: strip.scrollLeft,
fadeLeft: strip.dataset.fadeLeft,
fadeRight: strip.dataset.fadeRight,
active: active?.dataset.section ?? null,
activeVisible: box ? box.left >= bounds.left - 1 && box.right <= bounds.right + 1 : false,
};
`,
[strip],
);
const pageTop = () =>
app.execute(`return document.querySelector(arguments[0]).scrollTop;`, [
scroller,
]);
const start = await layout();
assert.equal(start.rows, 1, "section badges never wrap");
assert.ok(start.overflow > 0, "a narrow window overflows the row");
assert.equal(start.fadeLeft, "false");
assert.equal(start.fadeRight, "true");
// The last section becomes active at the bottom of the page, and the row
// brings its badge into view.
await app.execute(
`const el = document.querySelector(arguments[0]); el.scrollTop = el.scrollHeight;`,
[scroller],
);
await app.waitFor(
async () => {
const state = await layout();
return (
state.active === "advanced" &&
state.activeVisible &&
state.fadeLeft === "true"
);
},
{ description: "the row follows the active section" },
);
await app.capture("settings-nav-follows");
// A drag scrolls the row and is not a click on the badge under it.
const followed = await layout();
const top = await pageTop();
const point = await pointIn(app, strip);
await pointerActions(app, [
{ type: "pointerMove", x: point.x, y: point.y, origin: "viewport" },
{ type: "pointerDown", button: 0 },
{ type: "pause", duration: 50 },
{
type: "pointerMove",
x: point.x + 160,
y: point.y,
duration: 150,
origin: "viewport",
},
{ type: "pointerUp", button: 0 },
]);
await app.session.command("DELETE", "/actions");
const dragged = await layout();
assert.ok(
dragged.left < followed.left,
`dragging right scrolls back (${followed.left} -> ${dragged.left})`,
);
assert.equal(await pageTop(), top, "the drag did not jump the page");
// The chevron pages the row forward again.
await app.clickSelector(
`[aria-label="${en.settings.scrollSectionsRight}"]`,
);
await app.waitFor(async () => (await layout()).left > dragged.left, {
description: "the chevron scrolls the row",
});
// A badge click still jumps to its section.
await app.clickSelector(
`${slot("settings-nav-item")}[data-section="privacy"]`,
);
await app.waitFor(async () => (await layout()).active === "privacy", {
description: "the privacy badge is active after a click",
});
assert.equal(
await app.visibleTextIncludes(en.settings.privacy.requireRoute),
true,
);
await app.capture("settings-privacy");
});
});
test("the launch kill switch refuses a profile that would connect directly", async () => {
await withApp("motion-kill-switch", async (app) => {
await resize(app, 1200, 800);
const profile = await createProfile(app, "Direct profile");
// The launch receipt lives in the inspector.
await waitForSelector(app, inspectTrigger(profile.id));
await app.clickSelector(inspectTrigger(profile.id));
const settings = await app.invoke("get_app_settings");
assert.equal(settings.require_route_for_launch, false);
assert.equal(settings.record_traffic_domains, true);
assert.equal(settings.trash_retention_days, 30);
await app.invoke("save_app_settings", {
settings: { ...settings, require_route_for_launch: true },
});
const error = await app.invokeError("launch_browser_profile", {
profile,
url: null,
});
assert.match(String(error), /PROFILE_ROUTE_REQUIRED/);
await waitForSelector(
app,
`${slot("profile-launch-activity")}[data-stage="failed"]`,
);
await app.invoke("save_app_settings", {
settings: { ...settings, require_route_for_launch: false },
});
});
});
test("profile notes save through the inspector and real launch failures retain a receipt", async () => {
await withApp("motion-profile-feedback", async (app) => {
await resize(app, 1200, 800);
+8
View File
@@ -143,6 +143,12 @@ async fn main() {
.num_args(0)
.help("Treat --blocklist-file as an allowlist (block all domains not listed)"),
)
.arg(
Arg::new("no-domain-history")
.long("no-domain-history")
.num_args(0)
.help("Count bytes only; do not record which sites the browser reaches"),
)
.arg(
Arg::new("local-protocol")
.long("local-protocol")
@@ -255,6 +261,7 @@ async fn main() {
let blocklist_file = start_matches.get_one::<String>("blocklist-file").cloned();
let dns_allowlist_mode = start_matches.get_flag("dns-allowlist-mode");
let local_protocol = start_matches.get_one::<String>("local-protocol").cloned();
let record_domains = !start_matches.get_flag("no-domain-history");
match start_proxy_process_with_profile(
upstream_url,
@@ -264,6 +271,7 @@ async fn main() {
blocklist_file,
dns_allowlist_mode,
local_protocol,
record_domains,
)
.await
{
+16
View File
@@ -128,6 +128,19 @@ impl BrowserRunner {
Ok(PROXY_MANAGER.get_proxy_settings_by_id(proxy_id))
}
/// Whether this launch may keep a list of the sites it reaches. Never for a
/// password-protected or ephemeral profile: their data is sealed or held in
/// RAM, and a plaintext list of where they went would undo that. For the
/// rest, the "Record visited sites" setting decides.
fn records_site_history(profile: &BrowserProfile) -> bool {
!profile.password_protected
&& !profile.ephemeral
&& crate::settings_manager::SettingsManager::instance()
.load_settings()
.map(|settings| settings.record_traffic_domains)
.unwrap_or(true)
}
fn fire_launch_hook(profile: &BrowserProfile) {
let Some(raw_url) = profile.launch_hook.as_deref() else {
return;
@@ -409,6 +422,8 @@ impl BrowserRunner {
// it: the gate returns early for several kinds of profile and answers a
// question ("may this launch proceed"), while this is a preparation step
// every spawn needs, including a profile with no route to check.
crate::launch_gate::enforce_route_required(profile, upstream_proxy.is_some())
.map_err(|e| -> Box<dyn std::error::Error + Send + Sync> { e.into() })?;
crate::group_bookmarks::sync_for_launch(profile);
// Run concurrently with the blocklist compile so the added wall clock is
@@ -449,6 +464,7 @@ impl BrowserRunner {
profile.proxy_bypass_rules.clone(),
blocklist_file,
dns_allowlist_mode,
Self::records_site_history(profile),
// Wayfern (Chromium) uses a local SOCKS5 proxy so QUIC and WebRTC
// UDP can be routed through it (via SOCKS5 UDP ASSOCIATE) without
// leaking the real IP, rather than being forced direct as they
+20
View File
@@ -425,6 +425,26 @@ pub fn get_geolocation(ip: &str) -> Result<Geolocation, GeolocationError> {
mod tests {
use super::*;
#[test]
fn an_invalid_address_has_no_place() {
assert_eq!(lookup_place("not-an-ip"), (None, None, None));
}
/// Exit addresses stay on this machine. A lookup service would learn which
/// proxies the user tests, and plain HTTP would tell everyone on the path.
#[test]
fn proxy_checks_do_not_send_the_exit_address_to_a_lookup_service() {
for (name, source) in [
("proxy_manager.rs", include_str!("proxy_manager.rs")),
("lib.rs", include_str!("lib.rs")),
] {
assert!(
!source.contains("ip-api.com"),
"{name} sends the exit address to ip-api.com; use geolocation::lookup_place"
);
}
}
#[test]
fn test_locale_selector_creation() {
let selector = LocaleSelector::new();
+51
View File
@@ -179,6 +179,34 @@ fn gate_disabled() -> bool {
.unwrap_or(false)
}
/// The kill switch. With "Block launches without a proxy or VPN" on, a launch
/// that would reach the internet from the machine's own address is refused:
/// a profile with no route, and a profile whose proxy or VPN is gone or gave
/// no endpoint. The fingerprint gate below would let the second kind through
/// whenever the real location happens to match the fingerprint.
pub fn enforce_route_required(profile: &BrowserProfile, has_upstream: bool) -> Result<(), String> {
let declares_route = profile.proxy_id.is_some() || profile.vpn_id.is_some();
route_verdict(declares_route, has_upstream, route_required())
}
fn route_verdict(declares_route: bool, has_upstream: bool, required: bool) -> Result<(), String> {
if has_upstream || !required {
return Ok(());
}
Err(crate::backend_error(if declares_route {
"PROFILE_ROUTE_UNAVAILABLE"
} else {
"PROFILE_ROUTE_REQUIRED"
}))
}
fn route_required() -> bool {
crate::settings_manager::SettingsManager::instance()
.load_settings()
.map(|s| s.require_route_for_launch)
.unwrap_or(false)
}
fn extension_warning_disabled() -> bool {
crate::settings_manager::SettingsManager::instance()
.load_settings()
@@ -470,6 +498,29 @@ pub async fn ack_launch_gate(
mod tests {
use super::*;
#[test]
fn with_the_kill_switch_off_every_launch_passes() {
assert!(route_verdict(false, false, false).is_ok());
assert!(route_verdict(true, false, false).is_ok());
}
#[test]
fn the_kill_switch_refuses_a_launch_with_no_route() {
let error = route_verdict(false, false, true).unwrap_err();
assert!(error.contains("PROFILE_ROUTE_REQUIRED"), "{error}");
}
#[test]
fn the_kill_switch_refuses_a_route_that_is_gone() {
let error = route_verdict(true, false, true).unwrap_err();
assert!(error.contains("PROFILE_ROUTE_UNAVAILABLE"), "{error}");
}
#[test]
fn the_kill_switch_lets_a_routed_launch_through() {
assert!(route_verdict(true, true, true).is_ok());
}
fn profile_with(fingerprint: &str) -> BrowserProfile {
let mut profile = BrowserProfile {
id: uuid::Uuid::new_v4(),
+17 -7
View File
@@ -779,13 +779,22 @@ impl ProfileManager {
return Err(format!("Failed to completely delete profile '{}'", profile.name).into());
}
} else {
let _guard = crate::profile::trash::mutation_lock();
crate::profile::trash::trash_profile(
&profiles_dir,
&crate::profile::trash::trash_dir(),
&profile,
crate::proxy_manager::now_secs(),
)?;
let retention_days = crate::profile::trash::retention_days();
{
let _guard = crate::profile::trash::mutation_lock();
crate::profile::trash::trash_profile(
&profiles_dir,
&crate::profile::trash::trash_dir(),
&profile,
crate::proxy_manager::now_secs(),
retention_days,
)?;
}
// "Keep for 0 days" means the trash is off: the entry goes right away,
// through the same purge an expired entry takes.
if retention_days == 0 {
self.purge_expired_trash();
}
}
log::info!(
@@ -936,6 +945,7 @@ impl ProfileManager {
let purged = crate::profile::trash::purge_expired(
&crate::profile::trash::trash_dir(),
crate::proxy_manager::now_secs(),
crate::profile::trash::retention_days(),
);
self.after_trash_purged(&purged);
purged.len()
+89 -37
View File
@@ -27,8 +27,6 @@ use std::fs;
use std::path::{Path, PathBuf};
use std::sync::{Mutex, MutexGuard};
/// How long a deleted profile can still be restored.
pub const RETENTION_DAYS: u32 = 30;
/// How often expired entries are swept while the app runs.
pub const PURGE_INTERVAL_SECS: u64 = 6 * 60 * 60;
@@ -83,6 +81,21 @@ pub fn trash_dir() -> PathBuf {
crate::app_dirs::data_dir().join("trash")
}
/// How many days a deleted profile can still be restored, from Settings.
pub fn retention_days() -> u32 {
crate::settings_manager::SettingsManager::instance()
.load_settings()
.map(|settings| settings.trash_retention_days)
.unwrap_or(crate::settings_manager::DEFAULT_TRASH_RETENTION_DAYS)
}
/// When an entry deleted at `deleted_at` expires. Measured from the deletion
/// with the retention in force now, so changing the setting applies to what
/// is already in the trash, in both directions.
fn expiry(deleted_at: u64, retention_days: u32) -> u64 {
deleted_at.saturating_add(u64::from(retention_days) * SECS_PER_DAY)
}
pub fn mutation_lock() -> MutexGuard<'static, ()> {
TRASH_MUTATION
.lock()
@@ -191,6 +204,7 @@ pub fn trash_profile(
trash_root: &Path,
profile: &BrowserProfile,
now: u64,
retention_days: u32,
) -> Result<TrashManifest, String> {
let id = profile.id.to_string();
let source_dir = profiles_dir.join(&id);
@@ -225,7 +239,7 @@ pub fn trash_profile(
let manifest = TrashManifest {
deleted_at: now,
expires_at: now.saturating_add(u64::from(RETENTION_DAYS) * SECS_PER_DAY),
expires_at: expiry(now, retention_days),
size_bytes: dir_size(&target_dir.join(DATA_DIR)),
original_name: profile.name.clone(),
};
@@ -272,7 +286,7 @@ pub fn list_entries(trash_root: &Path) -> Vec<(BrowserProfile, TrashManifest)> {
listed
}
pub fn summaries(trash_root: &Path) -> Vec<TrashedProfileSummary> {
pub fn summaries(trash_root: &Path, retention_days: u32) -> Vec<TrashedProfileSummary> {
list_entries(trash_root)
.into_iter()
.map(|(profile, manifest)| TrashedProfileSummary {
@@ -281,7 +295,7 @@ pub fn summaries(trash_root: &Path) -> Vec<TrashedProfileSummary> {
browser: profile.browser,
version: profile.version,
deleted_at: manifest.deleted_at,
expires_at: manifest.expires_at,
expires_at: expiry(manifest.deleted_at, retention_days),
size_bytes: manifest.size_bytes,
group_id: profile.group_id,
password_protected: profile.password_protected,
@@ -389,10 +403,10 @@ pub fn purge_all(trash_root: &Path) -> Result<Vec<String>, String> {
}
/// Destroy every entry whose expiry has passed. Returns the ids removed.
pub fn purge_expired(trash_root: &Path, now: u64) -> Vec<String> {
pub fn purge_expired(trash_root: &Path, now: u64, retention_days: u32) -> Vec<String> {
list_entries(trash_root)
.into_iter()
.filter(|(_, manifest)| manifest.expires_at <= now)
.filter(|(_, manifest)| expiry(manifest.deleted_at, retention_days) <= now)
.filter_map(|(profile, _)| {
let id = profile.id.to_string();
match purge_entry(trash_root, &id) {
@@ -444,7 +458,7 @@ pub fn start_expiry_sweeper() {
#[tauri::command]
pub fn list_trashed_profiles() -> Result<Vec<TrashedProfileSummary>, String> {
Ok(summaries(&trash_dir()))
Ok(summaries(&trash_dir(), retention_days()))
}
#[tauri::command]
@@ -506,6 +520,7 @@ mod tests {
use tempfile::TempDir;
const NOW: u64 = 1_700_000_000;
const DAYS: u32 = crate::settings_manager::DEFAULT_TRASH_RETENTION_DAYS;
fn sample_profile(name: &str) -> BrowserProfile {
BrowserProfile {
@@ -562,12 +577,9 @@ mod tests {
let profiles_dir = seed_profile(root.path(), &profile, true);
let trash_root = root.path().join("trash");
let manifest = trash_profile(&profiles_dir, &trash_root, &profile, NOW).unwrap();
let manifest = trash_profile(&profiles_dir, &trash_root, &profile, NOW, DAYS).unwrap();
assert_eq!(manifest.deleted_at, NOW);
assert_eq!(
manifest.expires_at,
NOW + u64::from(RETENTION_DAYS) * SECS_PER_DAY
);
assert_eq!(manifest.expires_at, NOW + u64::from(DAYS) * SECS_PER_DAY);
assert_eq!(manifest.original_name, "Shop Account");
assert!(manifest.size_bytes > 0);
@@ -588,7 +600,7 @@ mod tests {
);
}
let listed = summaries(&trash_root);
let listed = summaries(&trash_root, DAYS);
assert_eq!(listed.len(), 1);
assert_eq!(listed[0].id, profile.id.to_string());
assert_eq!(listed[0].name, "Shop Account");
@@ -633,7 +645,7 @@ mod tests {
serde_json::from_str(&fs::read_to_string(uuid_dir.join("metadata.json")).unwrap()).unwrap();
assert_eq!(on_disk.id, profile.id);
assert_eq!(on_disk.updated_at, Some(NOW + 60));
assert!(summaries(&trash_root).is_empty());
assert!(summaries(&trash_root, DAYS).is_empty());
}
#[test]
@@ -642,7 +654,7 @@ mod tests {
let profile = sample_profile("Shop Account");
let profiles_dir = seed_profile(root.path(), &profile, false);
let trash_root = root.path().join("trash");
trash_profile(&profiles_dir, &trash_root, &profile, NOW).unwrap();
trash_profile(&profiles_dir, &trash_root, &profile, NOW, DAYS).unwrap();
let mut twin = sample_profile("shop account");
twin.id = uuid::Uuid::new_v4();
@@ -676,7 +688,7 @@ mod tests {
let profile = sample_profile("Shop Account");
let profiles_dir = seed_profile(root.path(), &profile, false);
let trash_root = root.path().join("trash");
trash_profile(&profiles_dir, &trash_root, &profile, NOW).unwrap();
trash_profile(&profiles_dir, &trash_root, &profile, NOW, DAYS).unwrap();
let err = restore_profile(
&profiles_dir,
@@ -688,7 +700,11 @@ mod tests {
)
.unwrap_err();
assert!(err.contains("TRASH_RESTORE_CONFLICT"), "{err}");
assert_eq!(summaries(&trash_root).len(), 1, "the entry must survive");
assert_eq!(
summaries(&trash_root, DAYS).len(),
1,
"the entry must survive"
);
}
#[test]
@@ -715,7 +731,7 @@ mod tests {
let profile = sample_profile("Grouped");
let profiles_dir = seed_profile(root.path(), &profile, false);
let trash_root = root.path().join("trash");
trash_profile(&profiles_dir, &trash_root, &profile, NOW).unwrap();
trash_profile(&profiles_dir, &trash_root, &profile, NOW, DAYS).unwrap();
let restored = restore_profile(
&profiles_dir,
@@ -738,7 +754,7 @@ mod tests {
profile.encryption_salt = Some("salt".to_string());
let profiles_dir = seed_profile(root.path(), &profile, true);
let trash_root = root.path().join("trash");
trash_profile(&profiles_dir, &trash_root, &profile, NOW).unwrap();
trash_profile(&profiles_dir, &trash_root, &profile, NOW, DAYS).unwrap();
let entry_dir = trash_root.join(profile.id.to_string());
for relative in CACHE_DIRS {
@@ -747,7 +763,7 @@ mod tests {
"an encrypted tree is never pruned ({relative})"
);
}
assert!(summaries(&trash_root)[0].password_protected);
assert!(summaries(&trash_root, DAYS)[0].password_protected);
let restored = restore_profile(
&profiles_dir,
@@ -770,20 +786,56 @@ mod tests {
let profiles_dir = seed_profile(root.path(), &old, false);
seed_profile(root.path(), &fresh, false);
let trash_root = root.path().join("trash");
trash_profile(&profiles_dir, &trash_root, &old, NOW).unwrap();
trash_profile(&profiles_dir, &trash_root, &fresh, NOW + SECS_PER_DAY).unwrap();
assert_eq!(summaries(&trash_root).len(), 2);
trash_profile(&profiles_dir, &trash_root, &old, NOW, DAYS).unwrap();
trash_profile(&profiles_dir, &trash_root, &fresh, NOW + SECS_PER_DAY, DAYS).unwrap();
assert_eq!(summaries(&trash_root, DAYS).len(), 2);
let old_expiry = NOW + u64::from(RETENTION_DAYS) * SECS_PER_DAY;
assert!(purge_expired(&trash_root, old_expiry - 1).is_empty());
let purged = purge_expired(&trash_root, old_expiry);
let old_expiry = NOW + u64::from(DAYS) * SECS_PER_DAY;
assert!(purge_expired(&trash_root, old_expiry - 1, DAYS).is_empty());
let purged = purge_expired(&trash_root, old_expiry, DAYS);
assert_eq!(purged, vec![old.id.to_string()]);
let remaining = summaries(&trash_root);
let remaining = summaries(&trash_root, DAYS);
assert_eq!(remaining.len(), 1);
assert_eq!(remaining[0].id, fresh.id.to_string());
assert!(!trash_root.join(old.id.to_string()).exists());
}
#[test]
fn a_shorter_retention_applies_to_entries_already_in_the_trash() {
let root = TempDir::new().unwrap();
let profile = sample_profile("Old");
let profiles_dir = seed_profile(root.path(), &profile, false);
let trash_root = root.path().join("trash");
trash_profile(&profiles_dir, &trash_root, &profile, NOW, DAYS).unwrap();
let two_days_later = NOW + 2 * SECS_PER_DAY;
assert!(purge_expired(&trash_root, two_days_later, DAYS).is_empty());
assert_eq!(
summaries(&trash_root, 1)[0].expires_at,
NOW + SECS_PER_DAY,
"the trash page must show the expiry the sweep will act on"
);
assert_eq!(
purge_expired(&trash_root, two_days_later, 1),
vec![profile.id.to_string()]
);
}
#[test]
fn zero_days_expires_an_entry_at_once() {
let root = TempDir::new().unwrap();
let profile = sample_profile("Gone");
let profiles_dir = seed_profile(root.path(), &profile, false);
let trash_root = root.path().join("trash");
let manifest = trash_profile(&profiles_dir, &trash_root, &profile, NOW, 0).unwrap();
assert_eq!(manifest.expires_at, NOW);
assert_eq!(
purge_expired(&trash_root, NOW, 0),
vec![profile.id.to_string()]
);
assert!(!trash_root.join(profile.id.to_string()).exists());
}
#[test]
fn empty_trash_removes_every_entry() {
let root = TempDir::new().unwrap();
@@ -792,17 +844,17 @@ mod tests {
let profiles_dir = seed_profile(root.path(), &first, false);
seed_profile(root.path(), &second, false);
let trash_root = root.path().join("trash");
trash_profile(&profiles_dir, &trash_root, &first, NOW).unwrap();
trash_profile(&profiles_dir, &trash_root, &second, NOW + 1).unwrap();
trash_profile(&profiles_dir, &trash_root, &first, NOW, DAYS).unwrap();
trash_profile(&profiles_dir, &trash_root, &second, NOW + 1, DAYS).unwrap();
let listed = summaries(&trash_root);
let listed = summaries(&trash_root, DAYS);
assert_eq!(listed[0].name, "Second", "newest deletion is listed first");
let mut purged = purge_all(&trash_root).unwrap();
purged.sort();
let mut expected = vec![first.id.to_string(), second.id.to_string()];
expected.sort();
assert_eq!(purged, expected);
assert!(summaries(&trash_root).is_empty());
assert!(summaries(&trash_root, DAYS).is_empty());
}
#[test]
@@ -811,7 +863,7 @@ mod tests {
let profile = sample_profile("Twice");
let profiles_dir = seed_profile(root.path(), &profile, false);
let trash_root = root.path().join("trash");
trash_profile(&profiles_dir, &trash_root, &profile, NOW).unwrap();
trash_profile(&profiles_dir, &trash_root, &profile, NOW, DAYS).unwrap();
restore_profile(
&profiles_dir,
&trash_root,
@@ -827,14 +879,14 @@ mod tests {
fs::create_dir_all(trash_root.join(profile.id.to_string())).unwrap();
fs::write(trash_root.join(profile.id.to_string()).join("stale"), b"x").unwrap();
trash_profile(&profiles_dir, &trash_root, &profile, NOW + 5).unwrap();
trash_profile(&profiles_dir, &trash_root, &profile, NOW + 5, DAYS).unwrap();
let entry_dir = trash_root.join(profile.id.to_string());
assert!(!entry_dir.join("stale").exists());
assert_eq!(
fs::read(entry_dir.join("profile").join("Local State")).unwrap(),
b"newer"
);
assert_eq!(summaries(&trash_root).len(), 1);
assert_eq!(summaries(&trash_root, DAYS).len(), 1);
}
#[test]
@@ -843,13 +895,13 @@ mod tests {
let profile = sample_profile("Good");
let profiles_dir = seed_profile(root.path(), &profile, false);
let trash_root = root.path().join("trash");
trash_profile(&profiles_dir, &trash_root, &profile, NOW).unwrap();
trash_profile(&profiles_dir, &trash_root, &profile, NOW, DAYS).unwrap();
let broken = trash_root.join("broken-entry");
fs::create_dir_all(&broken).unwrap();
fs::write(broken.join("profile.json"), b"not json").unwrap();
fs::write(broken.join("manifest.json"), b"{}").unwrap();
let listed = summaries(&trash_root);
let listed = summaries(&trash_root, DAYS);
assert_eq!(listed.len(), 1);
assert_eq!(listed[0].name, "Good");
}
+10 -49
View File
@@ -405,53 +405,6 @@ impl ProxyManager {
.as_secs()
}
pub async fn get_ip_geolocation(
ip: &str,
) -> Result<(Option<String>, Option<String>, Option<String>), String> {
// Use ip-api.com (free, no API key required)
let url = format!(
"http://ip-api.com/json/{}?fields=status,message,country,countryCode,city",
ip
);
let client = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(5))
.build()
.map_err(|e| format!("Failed to create HTTP client: {e}"))?;
match client.get(&url).send().await {
Ok(response) => {
if response.status().is_success() {
match response.json::<serde_json::Value>().await {
Ok(json) => {
if json.get("status").and_then(|s| s.as_str()) == Some("success") {
let country = json
.get("country")
.and_then(|v| v.as_str())
.map(|s| s.to_string());
let country_code = json
.get("countryCode")
.and_then(|v| v.as_str())
.map(|s| s.to_string());
let city = json
.get("city")
.and_then(|v| v.as_str())
.map(|s| s.to_string());
Ok((city, country, country_code))
} else {
Ok((None, None, None))
}
}
Err(e) => Err(format!("Failed to parse geolocation response: {e}")),
}
} else {
Ok((None, None, None))
}
}
Err(e) => Err(format!("Failed to fetch geolocation: {e}")),
}
}
pub fn get_proxy_file_path(&self, proxy_id: &str) -> PathBuf {
self.get_proxies_dir().join(format!("{proxy_id}.json"))
}
@@ -1499,8 +1452,7 @@ impl ProxyManager {
};
// Get geolocation
let (city, country, country_code): (Option<String>, Option<String>, Option<String>) =
Self::get_ip_geolocation(&ip).await.unwrap_or_default();
let (city, country, country_code) = crate::geolocation::lookup_place(&ip);
// The ISP and the timezone come off the databases already on disk. Handing
// an exit address to an outside lookup service to learn them would tell
@@ -1934,6 +1886,7 @@ impl ProxyManager {
bypass_rules: Vec<String>,
blocklist_file: Option<String>,
dns_allowlist_mode: bool,
record_domains: bool,
// Protocol the local worker serves the browser: "socks5" (Wayfern). Reflected in
// the returned ProxySettings.proxy_type so the caller formats the right local proxy URL scheme.
local_protocol: &str,
@@ -2079,6 +2032,10 @@ impl ProxyManager {
}
}
if !record_domains {
proxy_cmd = proxy_cmd.arg("--no-domain-history");
}
// Tell the worker which protocol to serve the browser (http or socks5)
proxy_cmd = proxy_cmd.arg("--local-protocol").arg(local_protocol);
@@ -3594,6 +3551,7 @@ mod tests {
local_protocol: None,
browser_pid: None,
browser_pid_start_time: None,
record_domains: true,
};
let dead_config = ProxyConfig {
id: dead_id.clone(),
@@ -3609,6 +3567,7 @@ mod tests {
local_protocol: None,
browser_pid: None,
browser_pid_start_time: None,
record_domains: true,
};
save_proxy_config(&live_config).unwrap();
@@ -3652,6 +3611,7 @@ mod tests {
local_protocol: None,
browser_pid: None,
browser_pid_start_time: None,
record_domains: true,
};
// Save
@@ -4259,6 +4219,7 @@ mod tests {
local_protocol: None,
browser_pid: None,
browser_pid_start_time: None,
record_domains: true,
};
save_proxy_config(&config).unwrap();
+14 -2
View File
@@ -264,7 +264,17 @@ pub async fn start_proxy_process(
upstream_url: Option<String>,
port: Option<u16>,
) -> Result<ProxyConfig, Box<dyn std::error::Error>> {
start_proxy_process_with_profile(upstream_url, port, None, Vec::new(), None, false, None).await
start_proxy_process_with_profile(
upstream_url,
port,
None,
Vec::new(),
None,
false,
None,
true,
)
.await
}
#[allow(clippy::too_many_arguments)]
@@ -276,6 +286,7 @@ pub async fn start_proxy_process_with_profile(
blocklist_file: Option<String>,
dns_allowlist_mode: bool,
local_protocol: Option<String>,
record_domains: bool,
) -> Result<ProxyConfig, Box<dyn std::error::Error>> {
ensure_sidecar_version().await?;
@@ -294,7 +305,8 @@ pub async fn start_proxy_process_with_profile(
.with_bypass_rules(bypass_rules)
.with_blocklist_file(blocklist_file)
.with_dns_allowlist_mode(dns_allowlist_mode)
.with_local_protocol(local_protocol);
.with_local_protocol(local_protocol)
.with_record_domains(record_domains);
save_proxy_config(&config)?;
// Log profile_id for debugging
+13 -3
View File
@@ -1589,7 +1589,11 @@ pub async fn run_proxy_server(config: ProxyConfig) -> Result<(), Box<dyn std::er
// Initialize traffic tracker with profile ID if available.
// This can be called multiple times to update the tracker.
init_traffic_tracker(config.id.clone(), config.profile_id.clone());
init_traffic_tracker(
config.id.clone(),
config.profile_id.clone(),
config.record_domains,
);
// Determine the bind address
let bind_addr = SocketAddr::from(([127, 0, 0, 1], config.local_port.unwrap_or(0)));
@@ -2230,7 +2234,9 @@ async fn connect_via_http_proxy<S: AsyncStream + 'static>(
return Err(format!("Upstream proxy CONNECT failed: {status_line}").into());
}
log::info!(
// Debug, not info: at info every tunnel the browser opens lands in the
// worker log, which is a list of the sites the profile visited.
log::debug!(
"Upstream CONNECT to {}:{} via {}:{} accepted ({})",
target_host,
target_port,
@@ -2951,7 +2957,11 @@ this line has no colon\r\n\
let _cache_guard = crate::app_dirs::set_test_cache_dir(temp_dir.path().to_path_buf());
let profile_id = "traffic-counting-profile";
let domain = "counting.example";
init_traffic_tracker("traffic-counting-proxy".into(), Some(profile_id.into()));
init_traffic_tracker(
"traffic-counting-proxy".into(),
Some(profile_id.into()),
true,
);
let tracker = get_traffic_tracker().unwrap();
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
+15
View File
@@ -39,6 +39,15 @@ pub struct ProxyConfig {
/// upgrade never reaps a worker whose browser is still running.
#[serde(default)]
pub browser_pid_start_time: Option<u64>,
/// Whether the worker records which sites the browser reaches. Off keeps
/// byte and request counts only. Configs written before this field
/// existed record, as they always did.
#[serde(default = "default_record_domains")]
pub record_domains: bool,
}
fn default_record_domains() -> bool {
true
}
impl ProxyConfig {
@@ -57,9 +66,15 @@ impl ProxyConfig {
local_protocol: None,
browser_pid: None,
browser_pid_start_time: None,
record_domains: true,
}
}
pub fn with_record_domains(mut self, record_domains: bool) -> Self {
self.record_domains = record_domains;
self
}
pub fn with_profile_id(mut self, profile_id: Option<String>) -> Self {
self.profile_id = profile_id;
self
+53 -1
View File
@@ -67,7 +67,7 @@ pub struct AppSettings {
#[serde(default)]
pub mcp_remote_key_id: Option<String>,
#[serde(default)]
pub language: Option<String>, // ISO 639-1: "en", "es", "pt", "fr", "zh", "ja", "ko", "ru", or None for system default
pub language: Option<String>, // ISO 639-1: "en", "es", "pt", "fr", "de", "zh", "ja", "ko", "ru", "tr", "vi", or None for system default
#[serde(default)]
pub window_resize_warning_dismissed: bool,
/// Stop blocking launches whose proxy exit disagrees with the fingerprint.
@@ -115,6 +115,17 @@ pub struct AppSettings {
/// MCP, so the move dialog opens by itself at most once per account.
#[serde(default)]
pub mcp_migration_offered_for: Vec<String>,
/// Refuse every launch that would reach the internet without a proxy or
/// VPN: a profile with no route, and one whose proxy or VPN is gone.
#[serde(default)]
pub require_route_for_launch: bool,
/// Days a deleted profile can still be restored. 0 destroys it at once.
#[serde(default = "default_trash_retention_days")]
pub trash_retention_days: u32,
/// Record which sites each profile visits in its traffic stats. Off keeps
/// byte counts only, so nothing on disk lists where a profile went.
#[serde(default = "default_record_traffic_domains")]
pub record_traffic_domains: bool,
}
#[derive(Debug, Serialize, Deserialize, Clone, Default)]
@@ -135,6 +146,18 @@ fn default_tips_auto_show() -> bool {
true
}
pub const DEFAULT_TRASH_RETENTION_DAYS: u32 = 30;
/// The longest retention the Settings page offers.
pub const MAX_TRASH_RETENTION_DAYS: u32 = 90;
fn default_trash_retention_days() -> u32 {
DEFAULT_TRASH_RETENTION_DAYS
}
fn default_record_traffic_domains() -> bool {
true
}
/// The automatic tip flow waits a random gap between two tips, drawn from
/// this range (both ends included), so tips arrive now and then instead of on
/// a fixed day and a busy day of restarts never turns into a tip per launch.
@@ -177,6 +200,9 @@ impl Default for AppSettings {
paid_welcome_seen_for: Vec::new(),
cloud_plan_memory: std::collections::HashMap::new(),
mcp_migration_offered_for: Vec::new(),
require_route_for_launch: false,
trash_retention_days: DEFAULT_TRASH_RETENTION_DAYS,
record_traffic_domains: true,
}
}
}
@@ -640,8 +666,11 @@ pub async fn save_app_settings(
// terms gates those commands enforce, or switch it off on disk while the
// task kept running. The key id is bookkeeping for the rotation path and is
// never the frontend's to write.
settings.trash_retention_days = settings.trash_retention_days.min(MAX_TRASH_RETENTION_DAYS);
let mut previous_retention = None;
if let Ok(content) = std::fs::read_to_string(manager.get_settings_file()) {
if let Ok(current) = serde_json::from_str::<AppSettings>(&content) {
previous_retention = Some(current.trash_retention_days);
settings.window_resize_warning_dismissed = current.window_resize_warning_dismissed;
settings.mcp_remote_enabled = current.mcp_remote_enabled;
settings.mcp_remote_key_id = current.mcp_remote_key_id;
@@ -670,6 +699,15 @@ pub async fn save_app_settings(
.save_settings(&persist_settings)
.map_err(|e| format!("Failed to save settings: {e}"))?;
// A shorter retention applies to what is already in the trash. Sweep now
// rather than at the next six-hourly pass, so "delete after 1 day" does not
// keep a month-old profile until then.
if previous_retention != Some(settings.trash_retention_days) {
tauri::async_runtime::spawn_blocking(|| {
crate::profile::ProfileManager::instance().purge_expired_trash();
});
}
// Answer with what a fresh read would show, the stored credential included,
// so a page that keeps the answer as its settings does not lose the fx
// export line on every save.
@@ -1368,6 +1406,9 @@ mod tests {
paid_welcome_seen_for: Vec::new(),
cloud_plan_memory: std::collections::HashMap::new(),
mcp_migration_offered_for: Vec::new(),
require_route_for_launch: true,
trash_retention_days: 7,
record_traffic_domains: false,
};
let save_result = manager.save_settings(&test_settings);
@@ -1385,6 +1426,17 @@ mod tests {
loaded_settings.theme, "dark",
"Loaded theme should match saved"
);
assert!(loaded_settings.require_route_for_launch);
assert_eq!(loaded_settings.trash_retention_days, 7);
assert!(!loaded_settings.record_traffic_domains);
}
#[test]
fn settings_written_before_the_privacy_options_keep_the_old_behaviour() {
let settings: AppSettings = serde_json::from_str(r#"{"theme":"dark"}"#).unwrap();
assert!(!settings.require_route_for_launch);
assert_eq!(settings.trash_retention_days, DEFAULT_TRASH_RETENTION_DAYS);
assert!(settings.record_traffic_domains);
}
#[test]
+32 -3
View File
@@ -580,16 +580,20 @@ pub struct LiveTrafficTracker {
requests: AtomicU64,
domain_stats: RwLock<HashMap<String, (u64, u64, u64)>>, // domain -> (count, sent, recv)
ips: RwLock<Vec<String>>,
/// False when the user turned off site history: bytes and requests are
/// still counted, but no domain or address is kept.
record_domains: bool,
#[allow(dead_code)]
session_start: u64,
last_session_write: std::sync::atomic::AtomicU64,
}
impl LiveTrafficTracker {
pub fn new(proxy_id: String, profile_id: Option<String>) -> Self {
pub fn new(proxy_id: String, profile_id: Option<String>, record_domains: bool) -> Self {
Self {
proxy_id,
profile_id,
record_domains,
bytes_sent: AtomicU64::new(0),
bytes_received: AtomicU64::new(0),
requests: AtomicU64::new(0),
@@ -655,6 +659,9 @@ impl LiveTrafficTracker {
self
.bytes_received
.fetch_add(bytes_received, Ordering::Relaxed);
if !self.record_domains {
return;
}
if let Ok(mut stats) = self.domain_stats.write() {
let entry = stats.entry(domain.to_string()).or_insert((0, 0, 0));
entry.0 += 1;
@@ -664,6 +671,9 @@ impl LiveTrafficTracker {
}
pub fn record_ip(&self, ip: &str) {
if !self.record_domains {
return;
}
if let Ok(mut ips) = self.ips.write() {
if !ips.contains(&ip.to_string()) {
ips.push(ip.to_string());
@@ -673,6 +683,9 @@ impl LiveTrafficTracker {
/// Update domain-specific byte counts (called when CONNECT tunnel closes)
pub fn update_domain_bytes(&self, domain: &str, bytes_sent: u64, bytes_received: u64) {
if !self.record_domains {
return;
}
if let Ok(mut stats) = self.domain_stats.write() {
let entry = stats.entry(domain.to_string()).or_insert((0, 0, 0));
entry.1 += bytes_sent;
@@ -887,8 +900,12 @@ static TRAFFIC_TRACKER: std::sync::RwLock<Option<Arc<LiveTrafficTracker>>> =
/// Initialize the global traffic tracker
/// This can be called multiple times to update the tracker when proxy config changes
pub fn init_traffic_tracker(proxy_id: String, profile_id: Option<String>) {
let tracker = Arc::new(LiveTrafficTracker::new(proxy_id, profile_id));
pub fn init_traffic_tracker(proxy_id: String, profile_id: Option<String>, record_domains: bool) {
let tracker = Arc::new(LiveTrafficTracker::new(
proxy_id,
profile_id,
record_domains,
));
if let Ok(mut guard) = TRAFFIC_TRACKER.write() {
*guard = Some(tracker);
}
@@ -1168,6 +1185,18 @@ pub fn get_all_traffic_snapshots_realtime() -> Vec<TrafficSnapshot> {
mod tests {
use super::*;
#[test]
fn a_tracker_without_site_history_counts_bytes_only() {
let tracker = LiveTrafficTracker::new("proxy".into(), Some("profile".into()), false);
tracker.record_request("example.com", 100, 200);
tracker.update_domain_bytes("example.com", 5, 5);
tracker.record_ip("203.0.113.7");
assert_eq!(tracker.get_snapshot(), (100, 200, 1));
assert!(tracker.domain_stats.read().unwrap().is_empty());
assert!(tracker.ips.read().unwrap().is_empty());
}
#[test]
fn test_traffic_stats_creation() {
let stats = TrafficStats::new(
@@ -326,6 +326,7 @@ async fn test_xray_reality_chain_and_local_traffic_monitoring(
None,
false,
Some("http".to_string()),
true,
)
.await?;
cleanup.outer_id = Some(outer.id.clone());
@@ -1832,6 +1833,7 @@ async fn start_lifecycle_worker(
None,
false,
Some("http".to_string()),
true,
)
.await
.map_err(|error| error.to_string().into())
+95 -173
View File
@@ -1,17 +1,10 @@
"use client";
import { getCurrentWindow } from "@tauri-apps/api/window";
import { useReducedMotion } from "motion/react";
import { useCallback, useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { GoPlus } from "react-icons/go";
import {
LuChevronLeft,
LuChevronRight,
LuCircleHelp,
LuSearch,
LuX,
} from "react-icons/lu";
import { LuCircleHelp, LuSearch, LuX } from "react-icons/lu";
import { useWindowDecorations } from "@/hooks/use-window-decorations";
import { getCurrentOS } from "@/lib/browser-utils";
import {
@@ -25,6 +18,7 @@ import { UNGROUPED_DROP_ID, useProfileGroupDrag } from "./profile-group-drag";
import { Button } from "./ui/button";
import { Input } from "./ui/input";
import { Popover, PopoverContent, PopoverTrigger } from "./ui/popover";
import { ScrollStrip } from "./ui/scroll-strip";
import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip";
const HOLD_MS = 150;
@@ -165,7 +159,6 @@ const HomeHeader = ({
}: Props) => {
const { t } = useTranslation();
const profileDrag = useProfileGroupDrag();
const reducedMotion = useReducedMotion();
const [platform, setPlatform] = useState<string>("macos");
useEffect(() => {
@@ -265,36 +258,6 @@ const HomeHeader = ({
[clearHold],
);
// Horizontal scroll fades for the group filter strip — when the user
// has more groups than fit, the right edge fades to hint at overflow.
const groupsScrollRef = useRef<HTMLDivElement | null>(null);
const [groupsFadeLeft, setGroupsFadeLeft] = useState(false);
const [groupsFadeRight, setGroupsFadeRight] = useState(false);
useEffect(() => {
if (!showProfileToolbar) return;
const el = groupsScrollRef.current;
if (!el) return;
const update = () => {
setGroupsFadeLeft(el.scrollLeft > 1);
setGroupsFadeRight(el.scrollWidth - el.clientWidth - el.scrollLeft > 1);
};
update();
el.addEventListener("scroll", update, { passive: true });
const ro = new ResizeObserver(update);
ro.observe(el);
const mutations = new MutationObserver(update);
mutations.observe(el, {
childList: true,
subtree: true,
characterData: true,
});
return () => {
el.removeEventListener("scroll", update);
ro.disconnect();
mutations.disconnect();
};
}, [showProfileToolbar]);
const isWindows = platform === "windows";
return (
@@ -351,145 +314,104 @@ const HomeHeader = ({
) : null}
{showProfileToolbar && (
<div className="relative flex min-w-0 flex-1 items-center">
{groupsFadeLeft && (
<button
type="button"
aria-label={t("header.scrollGroupsLeft")}
onClick={() => {
const el = groupsScrollRef.current;
if (el)
el.scrollBy({
left: -el.clientWidth * 0.6,
behavior:
reducedMotion || profileDrag?.active
? "instant"
: "smooth",
});
}}
className="absolute top-1/2 left-0 z-10 grid size-5 -translate-y-1/2 place-items-center rounded-full bg-card/90 text-muted-foreground shadow-sm transition-colors hover:bg-accent hover:text-accent-foreground"
>
<LuChevronLeft className="size-3" />
</button>
)}
<div
ref={groupsScrollRef}
data-slot="profile-group-strip"
className="ml-2 flex scrollbar-none items-center gap-3 overflow-x-auto [-ms-overflow-style:none] [&::-webkit-scrollbar]:hidden"
style={{
paddingLeft: groupsFadeLeft ? 22 : 0,
paddingRight: groupsFadeRight ? 22 : 0,
}}
>
{/* "All" filter — shows every profile regardless of group. */}
{(() => {
const active = selectedGroupId === ALL_FILTER_ID;
return (
<button
key="__all__"
type="button"
onClick={() => {
onGroupSelect(ALL_FILTER_ID);
}}
className={cn(
"flex h-7 shrink-0 items-center gap-1.5 px-1 text-xs transition-colors duration-100",
active
? "font-medium text-foreground"
: "text-muted-foreground hover:text-foreground",
)}
>
<span>{t("groups.all")}</span>
<span className="text-[11px] text-muted-foreground tabular-nums">
{totalProfiles}
</span>
</button>
);
})()}
{profileDrag && profileDrag.movingIds.size > 0 && (
<span
data-profile-group-drop={UNGROUPED_DROP_ID}
data-drop-state={
profileDrag.targetId === UNGROUPED_DROP_ID
? "target"
: profileDrag.canDrop(UNGROUPED_DROP_ID)
? "available"
: "unavailable"
}
title={t("profileMotion.dragTarget", {
group: t("groups.noGroup"),
})}
<ScrollStrip
data-slot="profile-group-strip"
className="ml-2 flex-1"
stripClassName="gap-3"
activeKey={selectedGroupId}
instant={Boolean(profileDrag?.active)}
scrollLeftLabel={t("header.scrollGroupsLeft")}
scrollRightLabel={t("header.scrollGroupsRight")}
>
{/* "All" filter — shows every profile regardless of group. */}
{(() => {
const active = selectedGroupId === ALL_FILTER_ID;
return (
<button
key="__all__"
type="button"
aria-current={active ? "true" : undefined}
onClick={() => {
onGroupSelect(ALL_FILTER_ID);
}}
className={cn(
"flex h-7 shrink-0 items-center rounded-sm px-2 text-xs text-muted-foreground transition-colors duration-100",
profileDrag.targetId === UNGROUPED_DROP_ID &&
"bg-accent font-medium text-accent-foreground",
!profileDrag.canDrop(UNGROUPED_DROP_ID) && "opacity-40",
"flex h-7 shrink-0 items-center gap-1.5 px-1 text-xs transition-colors duration-100",
active
? "font-medium text-foreground"
: "text-muted-foreground hover:text-foreground",
)}
>
{t("groups.noGroup")}
</span>
)}
{groups.map((group) => {
const active = selectedGroupId === group.id;
const dropTarget = profileDrag?.targetId === group.id;
return (
<button
key={group.id}
type="button"
title={group.name}
data-profile-group-drop={group.id}
data-drop-state={
profileDrag?.active
? dropTarget
? "target"
: profileDrag.canDrop(group.id)
? "available"
: "unavailable"
: undefined
}
aria-current={active ? "true" : undefined}
onClick={() => {
onGroupSelect(active ? ALL_FILTER_ID : group.id);
}}
className={cn(
"flex h-7 shrink-0 items-center gap-1.5 rounded-sm px-1 text-xs transition-colors duration-100",
dropTarget && "bg-accent text-accent-foreground",
profileDrag?.active &&
!profileDrag.canDrop(group.id) &&
"opacity-40",
active
? "font-medium text-foreground"
: "text-muted-foreground hover:text-foreground",
)}
>
<span className="max-w-40 truncate">{group.name}</span>
<span className="text-[11px] text-muted-foreground tabular-nums">
{group.count}
</span>
</button>
);
})}
</div>
{groupsFadeRight && (
<button
type="button"
aria-label={t("header.scrollGroupsRight")}
onClick={() => {
const el = groupsScrollRef.current;
if (el)
el.scrollBy({
left: el.clientWidth * 0.6,
behavior:
reducedMotion || profileDrag?.active
? "instant"
: "smooth",
});
}}
className="absolute top-1/2 right-0 z-10 grid size-5 -translate-y-1/2 place-items-center rounded-full bg-card/90 text-muted-foreground shadow-sm transition-colors hover:bg-accent hover:text-accent-foreground"
<span>{t("groups.all")}</span>
<span className="text-[11px] text-muted-foreground tabular-nums">
{totalProfiles}
</span>
</button>
);
})()}
{profileDrag && profileDrag.movingIds.size > 0 && (
<span
data-profile-group-drop={UNGROUPED_DROP_ID}
data-drop-state={
profileDrag.targetId === UNGROUPED_DROP_ID
? "target"
: profileDrag.canDrop(UNGROUPED_DROP_ID)
? "available"
: "unavailable"
}
title={t("profileMotion.dragTarget", {
group: t("groups.noGroup"),
})}
className={cn(
"flex h-7 shrink-0 items-center rounded-sm px-2 text-xs text-muted-foreground transition-colors duration-100",
profileDrag.targetId === UNGROUPED_DROP_ID &&
"bg-accent font-medium text-accent-foreground",
!profileDrag.canDrop(UNGROUPED_DROP_ID) && "opacity-40",
)}
>
<LuChevronRight className="size-3" />
</button>
{t("groups.noGroup")}
</span>
)}
</div>
{groups.map((group) => {
const active = selectedGroupId === group.id;
const dropTarget = profileDrag?.targetId === group.id;
return (
<button
key={group.id}
type="button"
title={group.name}
data-profile-group-drop={group.id}
data-drop-state={
profileDrag?.active
? dropTarget
? "target"
: profileDrag.canDrop(group.id)
? "available"
: "unavailable"
: undefined
}
aria-current={active ? "true" : undefined}
onClick={() => {
onGroupSelect(active ? ALL_FILTER_ID : group.id);
}}
className={cn(
"flex h-7 shrink-0 items-center gap-1.5 rounded-sm px-1 text-xs transition-colors duration-100",
dropTarget && "bg-accent text-accent-foreground",
profileDrag?.active &&
!profileDrag.canDrop(group.id) &&
"opacity-40",
active
? "font-medium text-foreground"
: "text-muted-foreground hover:text-foreground",
)}
>
<span className="max-w-40 truncate">{group.name}</span>
<span className="text-[11px] text-muted-foreground tabular-nums">
{group.count}
</span>
</button>
);
})}
</ScrollStrip>
)}
{!showProfileToolbar && <div className="flex-1" />}
+226 -102
View File
@@ -40,6 +40,7 @@ import {
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { ScrollStrip } from "@/components/ui/scroll-strip";
import {
Select,
SelectContent,
@@ -82,8 +83,15 @@ interface AppSettings {
keep_decrypted_profiles_in_ram?: boolean;
fingerprint_gate_disabled?: boolean;
vpn_extension_warning_disabled?: boolean;
require_route_for_launch?: boolean;
trash_retention_days?: number;
record_traffic_domains?: boolean;
}
/** The retention choices offered, in days. 0 turns the trash off. */
const TRASH_RETENTION_DAYS = [0, 1, 7, 14, 30, 90] as const;
const DEFAULT_TRASH_RETENTION_DAYS = 30;
interface CustomThemeState {
selectedThemeId: string | null;
colors: Record<string, string>;
@@ -603,10 +611,12 @@ export function SettingsDialog({
| "es"
| "pt"
| "fr"
| "de"
| "zh"
| "ja"
| "ko"
| "ru"
| "tr"
| "vi"),
);
setOriginalLanguage(selectedLanguage);
@@ -826,12 +836,21 @@ export function SettingsDialog({
"vpn_extension_warning_disabled",
"settings.privacy.vpnExtensionWarning",
],
["require_route_for_launch", "settings.privacy.requireRoute"],
] as const
)
.filter(
([key]) => Boolean(settings[key]) !== Boolean(originalSettings[key]),
)
.map(([, key]) => t(key)),
...((settings.record_traffic_domains ?? true) !==
(originalSettings.record_traffic_domains ?? true)
? [t("settings.privacy.recordSites")]
: []),
...((settings.trash_retention_days ?? DEFAULT_TRASH_RETENTION_DAYS) !==
(originalSettings.trash_retention_days ?? DEFAULT_TRASH_RETENTION_DAYS)
? [t("settings.trashRetention")]
: []),
];
const hasChanges = pendingChanges.length > 0;
const sections = [
@@ -859,6 +878,15 @@ export function SettingsDialog({
["dns", "dnsBlocklist.title", ["dnsBlocklist"]],
["encryption", "settings.encryption.title", ["settings.encryption"]],
["commercial", "settings.commercial.title", ["settings.commercial"]],
[
"privacy",
"settings.privacy.title",
[
"settings.privacy",
"settings.trashRetention",
"settings.trashRetentionDescription",
],
],
[
"advanced",
"settings.advanced.title",
@@ -868,7 +896,6 @@ export function SettingsDialog({
"settings.disableAutoUpdatesDescription",
"settings.keepDecryptedProfilesInRam",
"settings.keepDecryptedProfilesInRamDescription",
"settings.privacy",
],
],
] as Array<[string, string, string[]]>;
@@ -965,48 +992,55 @@ export function SettingsDialog({
aria-label={t("appFeedback.searchSettings")}
className="h-8"
/>
<nav
aria-label={t("settings.title")}
className="flex flex-wrap gap-x-1 gap-y-1"
>
{sections.map(([id, label]) => {
const active = activeSection === id;
return (
<button
type="button"
key={id}
data-slot="settings-nav-item"
data-section={id}
aria-current={active ? "location" : undefined}
onClick={() => {
setSearch("");
setJumpTo(id);
}}
className={cn(
"relative isolate rounded-md px-2 py-1 text-xs transition-colors duration-150 focus-visible:outline-2 focus-visible:outline-ring",
active
? "text-accent-foreground"
: "text-muted-foreground hover:text-foreground",
)}
>
{active && (
<motion.span
aria-hidden="true"
data-slot="settings-nav-indicator"
layoutId={
animateNav ? "settings-nav-indicator" : undefined
}
initial={false}
transition={
animateNav ? MOTION_SPRING_POSITION : { duration: 0 }
}
className="absolute inset-0 -z-10 rounded-md bg-accent"
/>
)}
{t(label)}
</button>
);
})}
<nav aria-label={t("settings.title")}>
<ScrollStrip
data-slot="settings-nav"
activeKey={activeSection}
scrollLeftLabel={t("settings.scrollSectionsLeft")}
scrollRightLabel={t("settings.scrollSectionsRight")}
stripClassName="gap-1"
>
{sections.map(([id, label]) => {
const active = activeSection === id;
return (
<button
type="button"
key={id}
data-slot="settings-nav-item"
data-section={id}
aria-current={active ? "location" : undefined}
onClick={() => {
setSearch("");
setJumpTo(id);
}}
className={cn(
"relative isolate shrink-0 rounded-md px-2 py-1 text-xs whitespace-nowrap transition-colors duration-150 focus-visible:outline-2 focus-visible:outline-ring",
active
? "text-accent-foreground"
: "text-muted-foreground hover:text-foreground",
)}
>
{active && (
<motion.span
aria-hidden="true"
data-slot="settings-nav-indicator"
layoutId={
animateNav ? "settings-nav-indicator" : undefined
}
initial={false}
transition={
animateNav
? MOTION_SPRING_POSITION
: { duration: 0 }
}
className="absolute inset-0 -z-10 rounded-md bg-accent"
/>
)}
{t(label)}
</button>
);
})}
</ScrollStrip>
</nav>
</div>
@@ -1015,6 +1049,7 @@ export function SettingsDialog({
them was the dead-wheel-zone bug. */}
<div
ref={scrollerRef}
data-slot="settings-scroller"
onScroll={syncActiveSection}
className={cn(
"min-h-0 flex-1 overflow-y-auto",
@@ -1637,6 +1672,154 @@ export function SettingsDialog({
</div>
</div>
{/* Privacy Section */}
<div
data-settings-section="privacy"
tabIndex={-1}
hidden={!sectionVisible("privacy")}
className="scroll-mt-2 space-y-4 focus:outline-none"
>
<Label className="text-base font-medium">
{t("settings.privacy.title")}
</Label>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0 flex-1">
<span className="text-sm font-medium">
{t("settings.privacy.requireRoute")}
</span>
<span className="block text-xs text-muted-foreground">
{t("settings.privacy.requireRouteDescription")}
</span>
</div>
<AnimatedSwitch
aria-label={t("settings.privacy.requireRoute")}
checked={settings.require_route_for_launch ?? false}
onCheckedChange={(v) => {
updateSetting("require_route_for_launch", v === true);
}}
/>
</div>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0 flex-1">
<span className="text-sm font-medium">
{t("settings.privacy.consistencyWarning")}
</span>
<span className="block text-xs text-muted-foreground">
{t("settings.privacy.consistencyWarningDescription")}
</span>
</div>
<AnimatedSwitch
aria-label={t("settings.privacy.consistencyWarning")}
checked={!(settings.fingerprint_gate_disabled ?? false)}
onCheckedChange={(v) => {
updateSetting("fingerprint_gate_disabled", v !== true);
}}
/>
</div>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0 flex-1">
<span className="text-sm font-medium">
{t("settings.privacy.vpnExtensionWarning")}
</span>
<span className="block text-xs text-muted-foreground">
{t("settings.privacy.vpnExtensionWarningDescription")}
</span>
</div>
<AnimatedSwitch
aria-label={t("settings.privacy.vpnExtensionWarning")}
checked={
!(settings.vpn_extension_warning_disabled ?? false)
}
onCheckedChange={(v) => {
updateSetting(
"vpn_extension_warning_disabled",
v !== true,
);
}}
/>
</div>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0 flex-1">
<span className="text-sm font-medium">
{t("settings.privacy.recordSites")}
</span>
<span className="block text-xs text-muted-foreground">
{t("settings.privacy.recordSitesDescription")}
</span>
</div>
<AnimatedSwitch
aria-label={t("settings.privacy.recordSites")}
checked={settings.record_traffic_domains ?? true}
onCheckedChange={(v) => {
updateSetting("record_traffic_domains", v === true);
}}
/>
</div>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0 flex-1">
<Label
htmlFor="trash-retention"
className="text-sm font-medium"
>
{t("settings.trashRetention")}
</Label>
<span className="block text-xs text-muted-foreground">
{t("settings.trashRetentionDescription")}
</span>
</div>
<Select
value={String(
settings.trash_retention_days ??
DEFAULT_TRASH_RETENTION_DAYS,
)}
onValueChange={(value) => {
updateSetting("trash_retention_days", Number(value));
}}
>
<SelectTrigger
id="trash-retention"
className="w-40 shrink-0"
>
<SelectValue />
</SelectTrigger>
<SelectContent>
{TRASH_RETENTION_DAYS.map((days) => (
<SelectItem key={days} value={String(days)}>
{days === 0
? t("settings.trashRetentionOff")
: t("settings.trashRetentionDays", {
count: days,
})}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div>
<LoadingButton
isLoading={isClearingTraffic}
variant="outline"
className="w-full"
onClick={() => {
handleClearTraffic().catch((err: unknown) => {
console.error(err);
});
}}
>
{t("settings.privacy.clearTraffic")}
</LoadingButton>
<p className="mt-2 text-xs text-muted-foreground">
{t("settings.privacy.clearTrafficDescription")}
</p>
</div>
</div>
{/* Advanced Section */}
<div
data-settings-section="advanced"
@@ -1750,65 +1933,6 @@ export function SettingsDialog({
<p className="text-xs text-muted-foreground">
{t("settings.advanced.copyLogsDescription")}
</p>
<div className="flex items-center justify-between gap-3 border-t pt-3">
<div className="min-w-0 flex-1">
<span className="text-sm font-medium">
{t("settings.privacy.consistencyWarning")}
</span>
<span className="block text-xs text-muted-foreground">
{t("settings.privacy.consistencyWarningDescription")}
</span>
</div>
<AnimatedSwitch
aria-label={t("settings.privacy.consistencyWarning")}
checked={!(settings.fingerprint_gate_disabled ?? false)}
onCheckedChange={(v) => {
updateSetting("fingerprint_gate_disabled", v !== true);
}}
/>
</div>
<div className="flex items-start justify-between gap-x-3 rounded-lg border p-3">
<div className="min-w-0 flex-1">
<span className="text-sm font-medium">
{t("settings.privacy.vpnExtensionWarning")}
</span>
<span className="block text-xs text-muted-foreground">
{t("settings.privacy.vpnExtensionWarningDescription")}
</span>
</div>
<AnimatedSwitch
aria-label={t("settings.privacy.vpnExtensionWarning")}
checked={
!(settings.vpn_extension_warning_disabled ?? false)
}
onCheckedChange={(v) => {
updateSetting(
"vpn_extension_warning_disabled",
v !== true,
);
}}
/>
</div>
<div className="border-t pt-3">
<LoadingButton
isLoading={isClearingTraffic}
variant="outline"
className="w-full"
onClick={() => {
handleClearTraffic().catch((err: unknown) => {
console.error(err);
});
}}
>
{t("settings.privacy.clearTraffic")}
</LoadingButton>
<p className="mt-2 text-xs text-muted-foreground">
{t("settings.privacy.clearTrafficDescription")}
</p>
</div>
</div>
{matchingSections.length === 0 && (
+257
View File
@@ -0,0 +1,257 @@
"use client";
import { useReducedMotion } from "motion/react";
import {
type HTMLAttributes,
type PointerEvent,
type ReactNode,
useCallback,
useEffect,
useRef,
useState,
} from "react";
import { LuChevronLeft, LuChevronRight } from "react-icons/lu";
import { cn } from "@/lib/utils";
/** How far a pressed pointer travels before a press becomes a drag. */
const DRAG_THRESHOLD_PX = 4;
/** Room kept between the active item and a faded edge. */
const EDGE_ROOM_PX = 28;
/** Clears a 2px focus outline: a row that scrolls sideways also clips
* vertically, so a badge's focus ring would be cut at every edge. */
const RING_ROOM_PX = 2;
interface ScrollStripProps extends HTMLAttributes<HTMLDivElement> {
children: ReactNode;
/** Changes whenever the item marked `aria-current` changes. */
activeKey?: string | null;
scrollLeftLabel: string;
scrollRightLabel: string;
/** Classes for the scrolling row itself; `className` styles the frame. */
stripClassName?: string;
/** Scroll without animation, e.g. while something is dragged over it. */
instant?: boolean;
}
/**
* One row of badges that never wraps. When the badges do not fit, the row
* fades out at the side that has more, shows a chevron there, and scrolls
* with the wheel, a drag or the chevrons. The badge marked `aria-current` is
* kept in view whenever `activeKey` changes.
*/
export function ScrollStrip({
children,
activeKey,
scrollLeftLabel,
scrollRightLabel,
className,
stripClassName,
instant = false,
onPointerDown,
onClickCapture,
...rest
}: ScrollStripProps) {
const ref = useRef<HTMLDivElement | null>(null);
const reducedMotion = useReducedMotion();
const [fadeLeft, setFadeLeft] = useState(false);
const [fadeRight, setFadeRight] = useState(false);
const [dragging, setDragging] = useState(false);
const dragRef = useRef<{
pointerId: number;
startX: number;
startLeft: number;
moved: boolean;
} | null>(null);
const suppressClickRef = useRef(false);
const behavior: ScrollBehavior =
instant || reducedMotion ? "instant" : "smooth";
useEffect(() => {
const el = ref.current;
if (!el) return;
const update = () => {
setFadeLeft(el.scrollLeft > 1);
setFadeRight(el.scrollWidth - el.clientWidth - el.scrollLeft > 1);
};
update();
el.addEventListener("scroll", update, { passive: true });
const resize = new ResizeObserver(update);
resize.observe(el);
const mutations = new MutationObserver(update);
mutations.observe(el, {
childList: true,
subtree: true,
characterData: true,
});
// A vertical wheel has nowhere to go in a single row, so turn it
// sideways. At either end the event is left alone.
const onWheel = (event: WheelEvent) => {
if (event.ctrlKey) return;
if (Math.abs(event.deltaY) <= Math.abs(event.deltaX)) return;
const room = el.scrollWidth - el.clientWidth;
if (room <= 1) return;
const delta = event.deltaMode === 1 ? event.deltaY * 16 : event.deltaY;
const next = Math.min(room, Math.max(0, el.scrollLeft + delta));
if (next === el.scrollLeft) return;
event.preventDefault();
el.scrollLeft = next;
};
el.addEventListener("wheel", onWheel, { passive: false });
return () => {
el.removeEventListener("scroll", update);
el.removeEventListener("wheel", onWheel);
resize.disconnect();
mutations.disconnect();
};
}, []);
// biome-ignore lint/correctness/useExhaustiveDependencies: activeKey is the trigger; the active badge is read from the DOM
useEffect(() => {
const el = ref.current;
if (!el || dragRef.current?.moved) return;
const item = el.querySelector<HTMLElement>(
'[aria-current]:not([aria-current="false"])',
);
if (!item) return;
const start = item.offsetLeft;
const end = start + item.offsetWidth;
let target: number | null = null;
if (start < el.scrollLeft + EDGE_ROOM_PX) {
target = start - EDGE_ROOM_PX;
} else if (end > el.scrollLeft + el.clientWidth - EDGE_ROOM_PX) {
target = end - el.clientWidth + EDGE_ROOM_PX;
}
if (target !== null) {
el.scrollTo({ left: Math.max(0, target), behavior });
}
}, [activeKey]);
const page = useCallback(
(direction: -1 | 1) => {
const el = ref.current;
if (el) {
el.scrollBy({ left: direction * el.clientWidth * 0.6, behavior });
}
},
[behavior],
);
const endDrag = (event: PointerEvent<HTMLDivElement>) => {
const drag = dragRef.current;
if (!drag || drag.pointerId !== event.pointerId) return;
if (drag.moved) {
// The click that ends a drag arrives in this same task. If none comes,
// the next real click must not be swallowed.
suppressClickRef.current = true;
window.setTimeout(() => {
suppressClickRef.current = false;
}, 0);
if (event.currentTarget.hasPointerCapture(event.pointerId)) {
event.currentTarget.releasePointerCapture(event.pointerId);
}
}
dragRef.current = null;
setDragging(false);
};
const overflowing = fadeLeft || fadeRight;
const chevron =
"absolute top-1/2 z-10 grid size-6 -translate-y-1/2 place-items-center rounded-sm text-muted-foreground transition-colors duration-100 hover:text-foreground focus-visible:outline-2 focus-visible:outline-ring";
return (
<div className={cn("relative flex min-w-0 items-center", className)}>
{fadeLeft && (
<button
type="button"
aria-label={scrollLeftLabel}
onClick={() => page(-1)}
className={cn(chevron, "left-0")}
>
<LuChevronLeft className="size-3.5" />
</button>
)}
<div
ref={ref}
data-fade-left={fadeLeft ? "true" : "false"}
data-fade-right={fadeRight ? "true" : "false"}
{...rest}
onPointerDown={(event) => {
onPointerDown?.(event);
const el = ref.current;
if (
!el ||
event.pointerType !== "mouse" ||
event.button !== 0 ||
el.scrollWidth - el.clientWidth <= 1
) {
return;
}
// The strip owns this press. Without this the title bar under it
// would start moving the window on a long press.
event.stopPropagation();
suppressClickRef.current = false;
dragRef.current = {
pointerId: event.pointerId,
startX: event.clientX,
startLeft: el.scrollLeft,
moved: false,
};
}}
onPointerMove={(event) => {
const drag = dragRef.current;
const el = ref.current;
if (!drag || !el || drag.pointerId !== event.pointerId) return;
const dx = event.clientX - drag.startX;
if (!drag.moved) {
if (Math.abs(dx) < DRAG_THRESHOLD_PX) return;
drag.moved = true;
// Keep receiving moves when the pointer leaves the strip. A
// pointer the browser no longer tracks cannot be captured, and
// the drag still works without it while the pointer stays over.
try {
el.setPointerCapture(event.pointerId);
} catch {}
setDragging(true);
}
el.scrollLeft = drag.startLeft - dx;
}}
onPointerUp={endDrag}
onPointerCancel={endDrag}
onClickCapture={(event) => {
// The press that ended a drag is not a click on the badge under it.
if (suppressClickRef.current) {
suppressClickRef.current = false;
event.preventDefault();
event.stopPropagation();
return;
}
onClickCapture?.(event);
}}
className={cn(
"scroll-fade-x relative flex scrollbar-none items-center overflow-x-auto py-0.5 [-ms-overflow-style:none] [&::-webkit-scrollbar]:hidden",
overflowing && "cursor-grab",
dragging && "cursor-grabbing select-none",
stripClassName,
)}
style={{
paddingLeft: fadeLeft ? 22 : RING_ROOM_PX,
paddingRight: fadeRight ? 22 : RING_ROOM_PX,
}}
>
{children}
</div>
{fadeRight && (
<button
type="button"
aria-label={scrollRightLabel}
onClick={() => page(1)}
className={cn(chevron, "right-0")}
>
<LuChevronRight className="size-3.5" />
</button>
)}
</div>
);
}
+14 -4
View File
@@ -210,7 +210,12 @@
"clearTrafficDescription": "Securely erase recorded traffic statistics for every profile.",
"clearTrafficSuccess": "Traffic history cleared",
"vpnExtensionWarning": "VPN extension warning",
"vpnExtensionWarningDescription": "Warn before launching when a profile contains an extension that can reroute the browser's traffic."
"vpnExtensionWarningDescription": "Warn before launching when a profile contains an extension that can reroute the browser's traffic.",
"title": "Privacy",
"requireRoute": "Block launches without a proxy or VPN",
"requireRouteDescription": "A profile never starts from your own IP address: not when it has no proxy or VPN, and not when its proxy or VPN is gone or does not answer.",
"recordSites": "Record visited sites",
"recordSitesDescription": "Traffic stats list the sites each profile reached. When off, only data totals are kept. Password-protected and ephemeral profiles never record sites."
},
"trashRetention": "Keep deleted profiles for",
"trashRetentionDescription": "Deleted profiles wait in the trash and can be restored until then. After that they are removed for good.",
@@ -240,7 +245,10 @@
"cleaning": "Removing the old directory",
"done": "Done"
}
}
},
"trashRetentionOff": "Delete at once",
"scrollSectionsLeft": "Scroll sections left",
"scrollSectionsRight": "Scroll sections right"
},
"header": {
"searchPlaceholder": "Search profiles...",
@@ -2351,7 +2359,9 @@
"syncDisplayTooSmall": "The display is too small for {{windows}} windows in that layout.",
"syncArrangeFailed": "No window could be moved.",
"extensionPathInvalid": "That path is not allowed: it contains '..'.",
"systemProxyUnreachable": "Donut could not connect through the system proxy {{proxy}}. Fix or turn off the proxy in your system network settings, then try again."
"systemProxyUnreachable": "Donut could not connect through the system proxy {{proxy}}. Fix or turn off the proxy in your system network settings, then try again.",
"profileRouteRequired": "This profile has no proxy or VPN. Launches without one are blocked in Settings, under Privacy.",
"profileRouteUnavailable": "The proxy or VPN of this profile is not available, so the launch was stopped to keep your IP address hidden. Assign a working proxy or VPN to the profile."
},
"rail": {
"profiles": "Profiles",
@@ -3452,7 +3462,7 @@
"trash": {
"label": "Trash",
"title": "Deleted profiles wait in the trash",
"body": "A deleted profile stays in the trash for 30 days by default and comes back with everything in it. The retention period is under Advanced settings.",
"body": "A deleted profile stays in the trash for 30 days by default and comes back with everything in it. The retention period is under Privacy settings.",
"action": "Open Trash"
},
"localApi": {
+14 -4
View File
@@ -210,7 +210,12 @@
"clearTrafficDescription": "Elimina de forma segura las estadísticas de tráfico registradas de todos los perfiles.",
"clearTrafficSuccess": "Historial de tráfico borrado",
"vpnExtensionWarning": "Aviso de extensión VPN",
"vpnExtensionWarningDescription": "Avisar antes de iniciar cuando un perfil contenga una extensión capaz de redirigir el tráfico del navegador."
"vpnExtensionWarningDescription": "Avisar antes de iniciar cuando un perfil contenga una extensión capaz de redirigir el tráfico del navegador.",
"title": "Privacidad",
"requireRoute": "Bloquear inicios sin proxy ni VPN",
"requireRouteDescription": "Un perfil nunca se inicia desde tu propia dirección IP: ni cuando no tiene proxy o VPN, ni cuando su proxy o VPN ya no existe o no responde.",
"recordSites": "Registrar sitios visitados",
"recordSitesDescription": "Las estadísticas de tráfico muestran los sitios a los que llegó cada perfil. Si se desactiva, solo se guardan los totales de datos. Los perfiles con contraseña y los efímeros nunca registran sitios."
},
"trashRetention": "Conservar perfiles eliminados durante",
"trashRetentionDescription": "Los perfiles eliminados esperan en la papelera y se pueden restaurar hasta entonces. Después se eliminan definitivamente.",
@@ -241,7 +246,10 @@
"cleaning": "Eliminando el directorio anterior",
"done": "Listo"
}
}
},
"trashRetentionOff": "Eliminar al instante",
"scrollSectionsLeft": "Desplazar secciones a la izquierda",
"scrollSectionsRight": "Desplazar secciones a la derecha"
},
"header": {
"searchPlaceholder": "Buscar perfiles...",
@@ -2361,7 +2369,9 @@
"syncDisplayTooSmall": "La pantalla es demasiado pequeña para {{windows}} ventanas en esa disposición.",
"syncArrangeFailed": "No se pudo mover ninguna ventana.",
"extensionPathInvalid": "Esa ruta no está permitida: contiene '..'.",
"systemProxyUnreachable": "Donut no pudo conectarse a través del proxy del sistema {{proxy}}. Corrige o desactiva el proxy en la configuración de red del sistema e inténtalo de nuevo."
"systemProxyUnreachable": "Donut no pudo conectarse a través del proxy del sistema {{proxy}}. Corrige o desactiva el proxy en la configuración de red del sistema e inténtalo de nuevo.",
"profileRouteRequired": "Este perfil no tiene proxy ni VPN. Los inicios sin ellos están bloqueados en Ajustes, en Privacidad.",
"profileRouteUnavailable": "El proxy o la VPN de este perfil no está disponible, así que el inicio se detuvo para mantener oculta tu dirección IP. Asigna al perfil un proxy o una VPN que funcione."
},
"rail": {
"profiles": "Perfiles",
@@ -3487,7 +3497,7 @@
"trash": {
"label": "Papelera",
"title": "Los perfiles eliminados esperan en la papelera",
"body": "Un perfil eliminado permanece en la papelera 30 días de forma predeterminada y vuelve con todo su contenido. El periodo de retención está en los ajustes avanzados.",
"body": "Un perfil eliminado permanece en la papelera 30 días de forma predeterminada y vuelve con todo su contenido. El periodo de retención está en los ajustes de privacidad.",
"action": "Abrir Papelera"
},
"localApi": {
+14 -4
View File
@@ -210,7 +210,12 @@
"clearTrafficDescription": "Efface en toute sécurité les statistiques de trafic enregistrées pour chaque profil.",
"clearTrafficSuccess": "Historique de trafic effacé",
"vpnExtensionWarning": "Avertissement d'extension VPN",
"vpnExtensionWarningDescription": "Avertir avant le lancement lorsqu'un profil contient une extension capable de rerouter le trafic du navigateur."
"vpnExtensionWarningDescription": "Avertir avant le lancement lorsqu'un profil contient une extension capable de rerouter le trafic du navigateur.",
"title": "Confidentialité",
"requireRoute": "Bloquer les lancements sans proxy ni VPN",
"requireRouteDescription": "Un profil ne démarre jamais depuis votre propre adresse IP : ni sans proxy ou VPN, ni lorsque son proxy ou VPN a disparu ou ne répond pas.",
"recordSites": "Enregistrer les sites visités",
"recordSitesDescription": "Les statistiques de trafic listent les sites atteints par chaque profil. Désactivé, seuls les volumes de données sont conservés. Les profils protégés par mot de passe et éphémères n'enregistrent jamais de sites."
},
"trashRetention": "Conserver les profils supprimés pendant",
"trashRetentionDescription": "Les profils supprimés attendent dans la corbeille et peuvent être restaurés jusque-là. Ensuite, ils sont supprimés définitivement.",
@@ -241,7 +246,10 @@
"cleaning": "Suppression de l'ancien répertoire",
"done": "Terminé"
}
}
},
"trashRetentionOff": "Supprimer aussitôt",
"scrollSectionsLeft": "Faire défiler les sections vers la gauche",
"scrollSectionsRight": "Faire défiler les sections vers la droite"
},
"header": {
"searchPlaceholder": "Rechercher des profils...",
@@ -2361,7 +2369,9 @@
"syncDisplayTooSmall": "L'écran est trop petit pour {{windows}} fenêtres dans cette disposition.",
"syncArrangeFailed": "Aucune fenêtre n'a pu être déplacée.",
"extensionPathInvalid": "Ce chemin n'est pas autorisé : il contient « .. ».",
"systemProxyUnreachable": "Donut n'a pas pu se connecter via le proxy système {{proxy}}. Corrigez ou désactivez le proxy dans les paramètres réseau de votre système, puis réessayez."
"systemProxyUnreachable": "Donut n'a pas pu se connecter via le proxy système {{proxy}}. Corrigez ou désactivez le proxy dans les paramètres réseau de votre système, puis réessayez.",
"profileRouteRequired": "Ce profil n'a ni proxy ni VPN. Les lancements sans eux sont bloqués dans Réglages, sous Confidentialité.",
"profileRouteUnavailable": "Le proxy ou le VPN de ce profil n'est pas disponible : le lancement a été arrêté pour garder votre adresse IP cachée. Attribuez au profil un proxy ou un VPN qui fonctionne."
},
"rail": {
"profiles": "Profils",
@@ -3487,7 +3497,7 @@
"trash": {
"label": "Corbeille",
"title": "Les profils supprimés attendent dans la corbeille",
"body": "Un profil supprimé reste 30 jours dans la corbeille par défaut et revient avec tout son contenu. La durée de conservation se règle dans les réglages avancés.",
"body": "Un profil supprimé reste 30 jours dans la corbeille par défaut et revient avec tout son contenu. La durée de conservation se règle dans les réglages de confidentialité.",
"action": "Ouvrir Corbeille"
},
"localApi": {
+14 -4
View File
@@ -210,7 +210,12 @@
"clearTrafficDescription": "すべてのプロファイルの記録されたトラフィック統計を安全に消去します。",
"clearTrafficSuccess": "トラフィック履歴を消去しました",
"vpnExtensionWarning": "VPN拡張機能の警告",
"vpnExtensionWarningDescription": "ブラウザーの通信を経路変更できる拡張機能がプロファイルに含まれる場合、起動前に警告します。"
"vpnExtensionWarningDescription": "ブラウザーの通信を経路変更できる拡張機能がプロファイルに含まれる場合、起動前に警告します。",
"title": "プライバシー",
"requireRoute": "プロキシまたは VPN なしの起動をブロック",
"requireRouteDescription": "プロファイルが自分の IP アドレスから起動することはありません。プロキシや VPN がない場合も、プロキシや VPN が削除されたり応答しない場合も同様です。",
"recordSites": "訪問したサイトを記録",
"recordSitesDescription": "トラフィック統計には各プロファイルがアクセスしたサイトが記録されます。オフにするとデータ量の合計だけが残ります。パスワード保護されたプロファイルと一時プロファイルはサイトを記録しません。"
},
"trashRetention": "削除したプロファイルの保持期間",
"trashRetentionDescription": "削除したプロファイルはゴミ箱に保管され、期限まで復元できます。期限を過ぎると完全に削除されます。",
@@ -240,7 +245,10 @@
"cleaning": "以前のディレクトリを削除しています",
"done": "完了"
}
}
},
"trashRetentionOff": "すぐに削除",
"scrollSectionsLeft": "セクションを左へスクロール",
"scrollSectionsRight": "セクションを右へスクロール"
},
"header": {
"searchPlaceholder": "プロファイルを検索...",
@@ -2351,7 +2359,9 @@
"syncDisplayTooSmall": "その配置で {{windows}} 個のウィンドウを並べるには画面が小さすぎます。",
"syncArrangeFailed": "動かせたウィンドウはありませんでした。",
"extensionPathInvalid": "そのパスは使用できません。'..' が含まれています。",
"systemProxyUnreachable": "システムプロキシ {{proxy}} 経由で接続できませんでした。システムのネットワーク設定でプロキシを修正するか無効にしてから、もう一度お試しください。"
"systemProxyUnreachable": "システムプロキシ {{proxy}} 経由で接続できませんでした。システムのネットワーク設定でプロキシを修正するか無効にしてから、もう一度お試しください。",
"profileRouteRequired": "このプロファイルにはプロキシも VPN もありません。設定の「プライバシー」で、これらなしの起動がブロックされています。",
"profileRouteUnavailable": "このプロファイルのプロキシまたは VPN が利用できないため、IP アドレスを隠すために起動を停止しました。動作するプロキシまたは VPN をプロファイルに割り当ててください。"
},
"rail": {
"profiles": "プロファイル",
@@ -3452,7 +3462,7 @@
"trash": {
"label": "ゴミ箱",
"title": "削除したプロファイルはゴミ箱で待機",
"body": "削除したプロファイルは既定で 30 日間ゴミ箱に残り、中身ごと復元できます。保持期間は詳細設定にあります。",
"body": "削除したプロファイルは既定で 30 日間ゴミ箱に残り、中身ごと復元できます。保持期間はプライバシー設定にあります。",
"action": "ゴミ箱を開く"
},
"localApi": {
+14 -4
View File
@@ -210,7 +210,12 @@
"clearTrafficDescription": "모든 프로필의 기록된 트래픽 통계를 안전하게 지웁니다.",
"clearTrafficSuccess": "트래픽 기록이 지워졌습니다",
"vpnExtensionWarning": "VPN 확장 프로그램 경고",
"vpnExtensionWarningDescription": "브라우저 트래픽의 경로를 바꿀 수 있는 확장 프로그램이 프로필에 있으면 실행 전에 경고합니다."
"vpnExtensionWarningDescription": "브라우저 트래픽의 경로를 바꿀 수 있는 확장 프로그램이 프로필에 있으면 실행 전에 경고합니다.",
"title": "개인정보 보호",
"requireRoute": "프록시나 VPN 없는 실행 차단",
"requireRouteDescription": "프로필이 내 IP 주소로 시작되지 않습니다. 프록시나 VPN이 없을 때도, 프록시나 VPN이 사라졌거나 응답하지 않을 때도 마찬가지입니다.",
"recordSites": "방문한 사이트 기록",
"recordSitesDescription": "트래픽 통계에는 각 프로필이 접속한 사이트가 표시됩니다. 끄면 데이터 합계만 보관됩니다. 비밀번호로 보호된 프로필과 임시 프로필은 사이트를 기록하지 않습니다."
},
"trashRetention": "삭제한 프로필 보관 기간",
"trashRetentionDescription": "삭제한 프로필은 휴지통에 보관되며 그때까지 복원할 수 있습니다. 이후에는 영구 삭제됩니다.",
@@ -240,7 +245,10 @@
"cleaning": "이전 디렉터리를 지우는 중",
"done": "완료"
}
}
},
"trashRetentionOff": "즉시 삭제",
"scrollSectionsLeft": "섹션 왼쪽으로 스크롤",
"scrollSectionsRight": "섹션 오른쪽으로 스크롤"
},
"header": {
"searchPlaceholder": "프로필 검색...",
@@ -2351,7 +2359,9 @@
"syncDisplayTooSmall": "그 배치로 창 {{windows}}개를 놓기에는 화면이 너무 작습니다.",
"syncArrangeFailed": "옮겨진 창이 없습니다.",
"extensionPathInvalid": "그 경로는 사용할 수 없습니다. '..'이 포함되어 있습니다.",
"systemProxyUnreachable": "시스템 프록시 {{proxy}}를 통해 연결하지 못했습니다. 시스템 네트워크 설정에서 프록시를 수정하거나 끈 다음 다시 시도하세요."
"systemProxyUnreachable": "시스템 프록시 {{proxy}}를 통해 연결하지 못했습니다. 시스템 네트워크 설정에서 프록시를 수정하거나 끈 다음 다시 시도하세요.",
"profileRouteRequired": "이 프로필에는 프록시나 VPN이 없습니다. 설정의 개인정보 보호에서 이 경우의 실행이 차단되어 있습니다.",
"profileRouteUnavailable": "이 프로필의 프록시나 VPN을 사용할 수 없어 IP 주소를 숨기기 위해 실행을 중단했습니다. 작동하는 프록시나 VPN을 프로필에 지정하세요."
},
"rail": {
"profiles": "프로필",
@@ -3452,7 +3462,7 @@
"trash": {
"label": "휴지통",
"title": "삭제된 프로필은 휴지통에서 대기",
"body": "삭제된 프로필은 기본적으로 30일 동안 휴지통에 남고 내용 그대로 복원됩니다. 보관 기간은 고급 설정에 있습니다.",
"body": "삭제된 프로필은 기본적으로 30일 동안 휴지통에 남고 내용 그대로 복원됩니다. 보관 기간은 개인정보 보호 설정에 있습니다.",
"action": "휴지통 열기"
},
"localApi": {
+14 -4
View File
@@ -210,7 +210,12 @@
"clearTrafficDescription": "Apaga com segurança as estatísticas de tráfego registradas de todos os perfis.",
"clearTrafficSuccess": "Histórico de tráfego limpo",
"vpnExtensionWarning": "Aviso de extensão VPN",
"vpnExtensionWarningDescription": "Avisar antes de iniciar quando um perfil contiver uma extensão capaz de redirecionar o tráfego do navegador."
"vpnExtensionWarningDescription": "Avisar antes de iniciar quando um perfil contiver uma extensão capaz de redirecionar o tráfego do navegador.",
"title": "Privacidade",
"requireRoute": "Bloquear inicializações sem proxy ou VPN",
"requireRouteDescription": "Um perfil nunca inicia a partir do seu próprio endereço IP: nem quando não tem proxy ou VPN, nem quando o proxy ou a VPN sumiu ou não responde.",
"recordSites": "Registrar sites visitados",
"recordSitesDescription": "As estatísticas de tráfego listam os sites que cada perfil acessou. Desativado, só os totais de dados são mantidos. Perfis protegidos por senha e efêmeros nunca registram sites."
},
"trashRetention": "Manter perfis excluídos por",
"trashRetentionDescription": "Os perfis excluídos ficam na lixeira e podem ser restaurados até lá. Depois disso, são removidos permanentemente.",
@@ -241,7 +246,10 @@
"cleaning": "A remover o diretório anterior",
"done": "Concluído"
}
}
},
"trashRetentionOff": "Excluir na hora",
"scrollSectionsLeft": "Rolar seções para a esquerda",
"scrollSectionsRight": "Rolar seções para a direita"
},
"header": {
"searchPlaceholder": "Pesquisar perfis...",
@@ -2361,7 +2369,9 @@
"syncDisplayTooSmall": "O ecrã é demasiado pequeno para {{windows}} janelas nessa disposição.",
"syncArrangeFailed": "Não foi possível mover nenhuma janela.",
"extensionPathInvalid": "Esse caminho não é permitido: contém '..'.",
"systemProxyUnreachable": "O Donut não conseguiu se conectar pelo proxy do sistema {{proxy}}. Corrija ou desative o proxy nas configurações de rede do sistema e tente novamente."
"systemProxyUnreachable": "O Donut não conseguiu se conectar pelo proxy do sistema {{proxy}}. Corrija ou desative o proxy nas configurações de rede do sistema e tente novamente.",
"profileRouteRequired": "Este perfil não tem proxy nem VPN. Inicializações sem eles estão bloqueadas em Configurações, em Privacidade.",
"profileRouteUnavailable": "O proxy ou a VPN deste perfil não está disponível, então a inicialização foi interrompida para manter seu endereço IP oculto. Atribua ao perfil um proxy ou uma VPN que funcione."
},
"rail": {
"profiles": "Perfis",
@@ -3487,7 +3497,7 @@
"trash": {
"label": "Lixeira",
"title": "Perfis excluídos esperam na lixeira",
"body": "Um perfil excluído fica na lixeira por 30 dias por padrão e volta com tudo o que tinha. O período de retenção está nas configurações avançadas.",
"body": "Um perfil excluído fica na lixeira por 30 dias por padrão e volta com tudo o que tinha. O período de retenção está nas configurações de privacidade.",
"action": "Abrir Lixeira"
},
"localApi": {
+14 -4
View File
@@ -210,7 +210,12 @@
"clearTrafficDescription": "Безопасно удаляет записанную статистику трафика для всех профилей.",
"clearTrafficSuccess": "История трафика очищена",
"vpnExtensionWarning": "Предупреждение о VPN-расширении",
"vpnExtensionWarningDescription": "Предупреждать перед запуском, если в профиле есть расширение, способное перенаправить трафик браузера."
"vpnExtensionWarningDescription": "Предупреждать перед запуском, если в профиле есть расширение, способное перенаправить трафик браузера.",
"title": "Конфиденциальность",
"requireRoute": "Блокировать запуск без прокси или VPN",
"requireRouteDescription": "Профиль никогда не запускается с вашего собственного IP-адреса: ни без прокси или VPN, ни когда его прокси или VPN удалён или не отвечает.",
"recordSites": "Записывать посещённые сайты",
"recordSitesDescription": "Статистика трафика показывает сайты, которые открывал каждый профиль. Если выключено, хранятся только объёмы данных. Профили с паролем и временные профили никогда не записывают сайты."
},
"trashRetention": "Хранить удалённые профили",
"trashRetentionDescription": "Удалённые профили хранятся в корзине, и до этого срока их можно восстановить. После этого они удаляются навсегда.",
@@ -242,7 +247,10 @@
"cleaning": "Удаляем прежний каталог",
"done": "Готово"
}
}
},
"trashRetentionOff": "Удалять сразу",
"scrollSectionsLeft": "Прокрутить разделы влево",
"scrollSectionsRight": "Прокрутить разделы вправо"
},
"header": {
"searchPlaceholder": "Поиск профилей...",
@@ -2371,7 +2379,9 @@
"syncDisplayTooSmall": "Экран слишком мал для {{windows}} окон в таком расположении.",
"syncArrangeFailed": "Ни одно окно не удалось переместить.",
"extensionPathInvalid": "Этот путь недопустим: он содержит «..».",
"systemProxyUnreachable": "Donut не удалось подключиться через системный прокси {{proxy}}. Исправьте или отключите прокси в сетевых настройках системы и повторите попытку."
"systemProxyUnreachable": "Donut не удалось подключиться через системный прокси {{proxy}}. Исправьте или отключите прокси в сетевых настройках системы и повторите попытку.",
"profileRouteRequired": "У этого профиля нет прокси или VPN. Запуск без них заблокирован в настройках, в разделе «Конфиденциальность».",
"profileRouteUnavailable": "Прокси или VPN этого профиля недоступен, поэтому запуск остановлен, чтобы скрыть ваш IP-адрес. Назначьте профилю рабочий прокси или VPN."
},
"rail": {
"profiles": "Профили",
@@ -3522,7 +3532,7 @@
"trash": {
"label": "Корзина",
"title": "Удалённые профили ждут в корзине",
"body": "Удалённый профиль по умолчанию хранится в корзине 30 дней и восстанавливается со всем содержимым. Срок хранения задаётся в дополнительных настройках.",
"body": "Удалённый профиль по умолчанию хранится в корзине 30 дней и восстанавливается со всем содержимым. Срок хранения задаётся в настройках конфиденциальности.",
"action": "Открыть Корзину"
},
"localApi": {
+14 -4
View File
@@ -210,7 +210,12 @@
"clearTrafficDescription": "Tüm profillerin kayıtlı trafik istatistiklerini güvenli bir şekilde siler.",
"clearTrafficSuccess": "Trafik geçmişi temizlendi",
"vpnExtensionWarning": "VPN uzantısı uyarısı",
"vpnExtensionWarningDescription": "Bir profil, tarayıcı trafiğini yeniden yönlendirebilecek bir uzantı içerdiğinde başlatmadan önce uyarır."
"vpnExtensionWarningDescription": "Bir profil, tarayıcı trafiğini yeniden yönlendirebilecek bir uzantı içerdiğinde başlatmadan önce uyarır.",
"title": "Gizlilik",
"requireRoute": "Proxy veya VPN olmadan başlatmayı engelle",
"requireRouteDescription": "Bir profil asla kendi IP adresinizden başlamaz: proxy veya VPN yoksa da, proxy veya VPN kaldırıldıysa ya da yanıt vermiyorsa da.",
"recordSites": "Ziyaret edilen siteleri kaydet",
"recordSitesDescription": "Trafik istatistikleri her profilin ulaştığı siteleri listeler. Kapalıyken yalnızca veri toplamları tutulur. Parola korumalı ve geçici profiller hiçbir zaman site kaydetmez."
},
"trashRetention": "Silinen profilleri şu süre boyunca sakla",
"trashRetentionDescription": "Silinen profiller çöp kutusunda bekler ve o zamana kadar geri yüklenebilir. Sonrasında kalıcı olarak kaldırılır.",
@@ -240,7 +245,10 @@
"cleaning": "Eski dizin siliniyor",
"done": "Bitti"
}
}
},
"trashRetentionOff": "Hemen sil",
"scrollSectionsLeft": "Bölümleri sola kaydır",
"scrollSectionsRight": "Bölümleri sağa kaydır"
},
"header": {
"searchPlaceholder": "Profillerde ara...",
@@ -2351,7 +2359,9 @@
"syncDisplayTooSmall": "Ekran, o düzende {{windows}} pencere için fazla küçük.",
"syncArrangeFailed": "Hiçbir pencere taşınamadı.",
"extensionPathInvalid": "Bu yol kullanılamaz: '..' içeriyor.",
"systemProxyUnreachable": "Donut, {{proxy}} sistem proxy'si üzerinden bağlanamadı. Sistem ağ ayarlarınızda proxy'yi düzeltin veya kapatın, ardından yeniden deneyin."
"systemProxyUnreachable": "Donut, {{proxy}} sistem proxy'si üzerinden bağlanamadı. Sistem ağ ayarlarınızda proxy'yi düzeltin veya kapatın, ardından yeniden deneyin.",
"profileRouteRequired": "Bu profilin proxy'si veya VPN'i yok. Bunlar olmadan başlatma, Ayarlar'daki Gizlilik bölümünde engellendi.",
"profileRouteUnavailable": "Bu profilin proxy'si veya VPN'i kullanılamıyor, bu yüzden IP adresinizi gizli tutmak için başlatma durduruldu. Profile çalışan bir proxy veya VPN atayın."
},
"rail": {
"profiles": "Profiller",
@@ -3452,7 +3462,7 @@
"trash": {
"label": "Çöp kutusu",
"title": "Silinen profiller çöp kutusunda bekler",
"body": "Silinen bir profil varsayılan olarak 30 gün çöp kutusunda kalır ve içindeki her şeyle geri gelir. Saklama süresi Gelişmiş ayarlardadır.",
"body": "Silinen bir profil varsayılan olarak 30 gün çöp kutusunda kalır ve içindeki her şeyle geri gelir. Saklama süresi Gizlilik ayarlarındadır.",
"action": "Çöp kutusunu aç"
},
"localApi": {
+14 -4
View File
@@ -210,7 +210,12 @@
"clearTrafficDescription": "Xóa an toàn số liệu thống kê lưu lượng đã ghi của mọi hồ sơ.",
"clearTrafficSuccess": "Đã xóa lịch sử lưu lượng",
"vpnExtensionWarning": "Cảnh báo tiện ích VPN",
"vpnExtensionWarningDescription": "Cảnh báo trước khi khởi chạy khi hồ sơ có tiện ích có thể định tuyến lại lưu lượng của trình duyệt."
"vpnExtensionWarningDescription": "Cảnh báo trước khi khởi chạy khi hồ sơ có tiện ích có thể định tuyến lại lưu lượng của trình duyệt.",
"title": "Quyền riêng tư",
"requireRoute": "Chặn khởi chạy khi không có proxy hoặc VPN",
"requireRouteDescription": "Hồ sơ không bao giờ khởi chạy từ địa chỉ IP của bạn: kể cả khi không có proxy hoặc VPN, hoặc khi proxy hay VPN của nó đã mất hoặc không phản hồi.",
"recordSites": "Ghi lại trang đã truy cập",
"recordSitesDescription": "Thống kê lưu lượng liệt kê các trang mà mỗi hồ sơ đã truy cập. Khi tắt, chỉ lưu tổng dung lượng dữ liệu. Hồ sơ có mật khẩu và hồ sơ tạm thời không bao giờ ghi lại trang."
},
"trashRetention": "Giữ hồ sơ đã xóa trong",
"trashRetentionDescription": "Hồ sơ đã xóa được giữ trong thùng rác và có thể khôi phục cho đến lúc đó. Sau đó chúng sẽ bị xóa vĩnh viễn.",
@@ -240,7 +245,10 @@
"cleaning": "Đang xoá thư mục cũ",
"done": "Xong"
}
}
},
"trashRetentionOff": "Xóa ngay",
"scrollSectionsLeft": "Cuộn các mục sang trái",
"scrollSectionsRight": "Cuộn các mục sang phải"
},
"header": {
"searchPlaceholder": "Tìm kiếm hồ sơ...",
@@ -2351,7 +2359,9 @@
"syncDisplayTooSmall": "Màn hình quá nhỏ cho {{windows}} cửa sổ theo cách xếp đó.",
"syncArrangeFailed": "Không cửa sổ nào được di chuyển.",
"extensionPathInvalid": "Đường dẫn đó không được phép: nó chứa '..'.",
"systemProxyUnreachable": "Donut không thể kết nối qua proxy hệ thống {{proxy}}. Hãy sửa hoặc tắt proxy trong cài đặt mạng của hệ thống rồi thử lại."
"systemProxyUnreachable": "Donut không thể kết nối qua proxy hệ thống {{proxy}}. Hãy sửa hoặc tắt proxy trong cài đặt mạng của hệ thống rồi thử lại.",
"profileRouteRequired": "Hồ sơ này không có proxy hoặc VPN. Việc khởi chạy khi không có chúng đang bị chặn trong Cài đặt, mục Quyền riêng tư.",
"profileRouteUnavailable": "Proxy hoặc VPN của hồ sơ này không khả dụng, nên việc khởi chạy đã dừng để giữ địa chỉ IP của bạn được ẩn. Hãy gán cho hồ sơ một proxy hoặc VPN hoạt động được."
},
"rail": {
"profiles": "Profile",
@@ -3452,7 +3462,7 @@
"trash": {
"label": "Thùng rác",
"title": "Hồ sơ đã xóa chờ trong thùng rác",
"body": "Hồ sơ đã xóa nằm trong thùng rác 30 ngày theo mặc định và quay lại với đầy đủ nội dung. Thời gian lưu giữ nằm trong cài đặt nâng cao.",
"body": "Hồ sơ đã xóa nằm trong thùng rác 30 ngày theo mặc định và quay lại với đầy đủ nội dung. Thời gian lưu giữ nằm trong cài đặt quyền riêng tư.",
"action": "Mở Thùng rác"
},
"localApi": {
+14 -4
View File
@@ -210,7 +210,12 @@
"clearTrafficDescription": "安全清除所有配置文件的已记录流量统计数据。",
"clearTrafficSuccess": "流量历史已清除",
"vpnExtensionWarning": "VPN 扩展警告",
"vpnExtensionWarningDescription": "当配置文件中存在可改变浏览器流量路径的扩展时,在启动前发出警告。"
"vpnExtensionWarningDescription": "当配置文件中存在可改变浏览器流量路径的扩展时,在启动前发出警告。",
"title": "隐私",
"requireRoute": "阻止未使用代理或 VPN 的启动",
"requireRouteDescription": "配置文件绝不会使用您自己的 IP 地址启动:无论是没有代理或 VPN,还是其代理或 VPN 已被删除或无响应。",
"recordSites": "记录访问过的网站",
"recordSitesDescription": "流量统计会列出每个配置文件访问过的网站。关闭后仅保留数据总量。受密码保护的配置文件和临时配置文件从不记录网站。"
},
"trashRetention": "已删除配置文件的保留时间",
"trashRetentionDescription": "已删除的配置文件会保留在回收站中,在此期间可以恢复。之后将被永久删除。",
@@ -240,7 +245,10 @@
"cleaning": "正在删除旧目录",
"done": "完成"
}
}
},
"trashRetentionOff": "立即删除",
"scrollSectionsLeft": "向左滚动分区",
"scrollSectionsRight": "向右滚动分区"
},
"header": {
"searchPlaceholder": "搜索配置文件...",
@@ -2351,7 +2359,9 @@
"syncDisplayTooSmall": "显示器太小,无法以该布局排列 {{windows}} 个窗口。",
"syncArrangeFailed": "没有窗口被移动。",
"extensionPathInvalid": "该路径不允许使用:它包含“..”。",
"systemProxyUnreachable": "Donut 无法通过系统代理 {{proxy}} 连接。请在系统网络设置中修正或关闭该代理,然后重试。"
"systemProxyUnreachable": "Donut 无法通过系统代理 {{proxy}} 连接。请在系统网络设置中修正或关闭该代理,然后重试。",
"profileRouteRequired": "此配置文件没有代理或 VPN。设置的“隐私”中已阻止在没有它们的情况下启动。",
"profileRouteUnavailable": "此配置文件的代理或 VPN 不可用,为隐藏您的 IP 地址已停止启动。请为该配置文件分配可用的代理或 VPN。"
},
"rail": {
"profiles": "配置文件",
@@ -3452,7 +3462,7 @@
"trash": {
"label": "回收站",
"title": "已删除的配置文件在回收站等候",
"body": "已删除的配置文件默认在回收站保留 30 天,并可连同全部内容一起恢复。保留期限在高级设置中。",
"body": "已删除的配置文件默认在回收站保留 30 天,并可连同全部内容一起恢复。保留期限在隐私设置中。",
"action": "打开回收站"
},
"localApi": {
+6
View File
@@ -65,6 +65,8 @@ export type BackendErrorCode =
| "BROWSER_CHECKSUM_UNAVAILABLE"
| "BROWSER_CHECKSUM_MISMATCH"
| "SYSTEM_PROXY_UNREACHABLE"
| "PROFILE_ROUTE_REQUIRED"
| "PROFILE_ROUTE_UNAVAILABLE"
| "UPDATE_PROFILES_RUNNING"
| "UPDATE_PREPARATION_FAILED"
| "PROFILE_NAME_EXISTS"
@@ -442,6 +444,10 @@ export function translateBackendError(t: TFunction, err: unknown): string {
browser: parsed.params?.browser ?? "",
version: parsed.params?.version ?? "",
});
case "PROFILE_ROUTE_REQUIRED":
return t("backendErrors.profileRouteRequired");
case "PROFILE_ROUTE_UNAVAILABLE":
return t("backendErrors.profileRouteUnavailable");
case "SYSTEM_PROXY_UNREACHABLE":
return t("backendErrors.systemProxyUnreachable", {
proxy: parsed.params?.proxy ?? "",
+28
View File
@@ -239,6 +239,34 @@
--bottom-mask: transparent;
}
/* The same for a single row that scrolls sideways (`ScrollStrip`). The band
is wider than the chevron that sits over it, so a badge passing under the
chevron is already gone. */
.scroll-fade-x {
--left-mask: black;
--right-mask: black;
-webkit-mask-image: linear-gradient(
to right,
var(--left-mask) 0,
black 36px,
black calc(100% - 36px),
var(--right-mask)
);
mask-image: linear-gradient(
to right,
var(--left-mask) 0,
black 36px,
black calc(100% - 36px),
var(--right-mask)
);
}
.scroll-fade-x[data-fade-left="true"] {
--left-mask: transparent;
}
.scroll-fade-x[data-fade-right="true"] {
--right-mask: transparent;
}
/* Ensure Sonner toasts appear above all dialogs and remain interactive */
.toaster,
[data-sonner-toaster] {