feat: bulk edit profiles

This commit is contained in:
zhom committed 2026-10-12 01:50:42 +04:00
1 parent 47664e6212
commit 3f5b573ead
25 files changed
+1389 -23

No files matched your search

+1
View File
@@ -57,6 +57,7 @@ export const commandCoverage = {
"update_profile_dns_blocklist",
"update_profile_dns_server",
"update_profile_start_urls",
"bulk_update_profiles",
"profile_statuses::list_profile_statuses",
"profile_statuses::create_profile_status",
"profile_statuses::update_profile_status",
+125 -1
View File
@@ -1398,7 +1398,13 @@ test("2FA keys stay sealed on this device, hand out codes, and go with their pro
assert.equal(codes[0].id, entry.id);
assert.match(codes[0].code, /^\d{8}$/);
const sealed = path.join(app.dataRoot, "data", "totp", `${profile.id}.dat`);
const sealed = path.join(
app.dataRoot,
"data",
"data",
"totp",
`${profile.id}.dat`,
);
const onDisk = await readFile(sealed);
assert.ok(
!onDisk.toString("latin1").includes(secret) &&
@@ -1445,6 +1451,124 @@ test("2FA keys stay sealed on this device, hand out codes, and go with their pro
});
});
test("bulk edit changes only the ticked settings, checks every value first, and skips no-ops", async () => {
await withApp("entities-bulk-edit", async (app) => {
const first = await createProfile(app, "Bulk One");
const second = await createProfile(app, "Bulk Two");
const untouched = await createProfile(app, "Bulk Untouched");
const group = await app.invoke("create_profile_group", {
name: "Bulk Group",
});
const status = await app.invoke("create_profile_status", {
name: "Warming",
color: "#eab308",
});
const ids = [first.id, second.id];
const byId = async () =>
new Map(
(await app.invoke("list_browser_profiles")).map((p) => [p.id, p]),
);
for (const [patch, code] of [
[{ status_id: "missing" }, /PROFILE_STATUS_NOT_FOUND/],
[{ group_id: "missing" }, /GROUP_NOT_FOUND/],
[{ webrtc_mode: "leak" }, /WEBRTC_MODE_INVALID/],
[{ dns_blocklist: "everything" }, /DNS_BLOCKLIST_INVALID/],
[{ start_urls: ["file:///etc/passwd"] }, /START_URL_INVALID/],
[{ note: "kept?", nickname: "unknown field" }, /nickname/],
]) {
assert.match(
await app.invokeError("bulk_update_profiles", {
profileIds: ids,
patch,
}),
code,
);
}
assert.match(
await app.invokeError("bulk_update_profiles", {
profileIds: [first.id, "00000000-0000-4000-8000-000000000000"],
patch: { note: "never written" },
}),
/not found/i,
);
for (const profile of (await byId()).values()) {
assert.equal(profile.note ?? null, null, "a refused batch changes none");
}
assert.equal(
await app.invoke("bulk_update_profiles", {
profileIds: ids,
patch: {
status_id: status.id,
tags_add: ["bulk", "shop"],
note: " Bulk note ",
group_id: group.id,
start_urls: ["shop.example"],
dns_blocklist: "light",
block_images: true,
webrtc_mode: "block",
},
}),
2,
);
let profiles = await byId();
for (const id of ids) {
const profile = profiles.get(id);
assert.equal(profile.status_id, status.id);
assert.deepEqual(profile.tags, ["bulk", "shop"]);
assert.equal(profile.note, "Bulk note");
assert.equal(profile.group_id, group.id);
assert.deepEqual(profile.start_urls, ["https://shop.example/"]);
assert.equal(profile.dns_blocklist, "light");
assert.equal(profile.wayfern_config.block_images, true);
assert.equal(profile.wayfern_config.webrtc_mode, "block");
assert.equal(
profile.wayfern_config.fingerprint,
"{}",
"the device stays unless asked",
);
}
const other = profiles.get(untouched.id);
assert.deepEqual(other.tags ?? [], []);
assert.equal(other.status_id ?? null, null);
assert.equal(other.group_id ?? null, null);
assert.equal(
await app.invoke("bulk_update_profiles", {
profileIds: ids,
patch: { status_id: status.id, tags_add: ["bulk"] },
}),
0,
"a patch that changes nothing saves nothing",
);
assert.equal(
await app.invoke("bulk_update_profiles", {
profileIds: ids,
patch: {
tags_remove: ["shop"],
note: null,
group_id: null,
new_device: true,
},
}),
2,
);
profiles = await byId();
for (const id of ids) {
const profile = profiles.get(id);
assert.deepEqual(profile.tags, ["bulk"]);
assert.equal(profile.note ?? null, null);
assert.equal(profile.group_id ?? null, null);
assert.equal(profile.status_id, status.id, "left out means kept");
assert.equal(profile.wayfern_config.fingerprint ?? null, null);
assert.equal(profile.wayfern_config.block_images, true);
}
assert.deepEqual((await app.invoke("get_all_tags")).sort(), ["bulk"]);
});
});
test("deleted profiles land in the trash and come back intact on restore", async () => {
await withApp("entities-trash", async (app) => {
assert.deepEqual(await app.invoke("list_trashed_profiles"), []);
+56
View File
@@ -3293,6 +3293,62 @@ test("the distribute-proxies dialog opens from the action bar, counts the pairin
);
});
test("bulk edit opens from the action bar and changes only the ticked rows", async () => {
await withApp(
"ui-bulk-edit",
async (app) => {
for (const name of ["Batch One", "Batch Two"]) {
await createUiProfile(app, name);
}
await app.invoke("update_profile_note", {
profileId: (await app.invoke("list_browser_profiles"))[0].id,
note: "Keep me",
});
await app.waitForText("Batch Two");
await app.clickSelector(`[aria-label="${en.common.aria.selectAll}"]`);
await app.clickSelector(`[aria-label="${en.bulkEdit.action}"]`);
await app.waitForText(en.bulkEdit.title);
const applyDisabled = () =>
app.execute(
`return document.querySelector('[data-slot="bulk-edit-apply"]')?.disabled ?? null;`,
);
assert.equal(
await applyDisabled(),
true,
"nothing ticked, nothing to apply",
);
await app.waitForText(en.bulkEdit.apply_other.replace("{{count}}", "2"));
await app.clickSelector("#bulk-edit-tagsAdd");
await app.fillSelector('[data-field="tagsAdd"] input', "ui-bulk, shop");
assert.equal(await applyDisabled(), false);
await app.capture("bulk-edit-dialog");
await app.clickSelector('[data-slot="bulk-edit-apply"]');
await app.waitFor(
async () =>
(await app.invoke("list_browser_profiles")).every(
(profile) =>
profile.tags?.includes("ui-bulk") &&
profile.tags.includes("shop"),
),
{ description: "tags on every selected profile" },
);
await app.waitFor(
async () => !(await app.visibleTextIncludes(en.bulkEdit.title)),
{ description: "dialog closing after apply" },
);
const notes = (await app.invoke("list_browser_profiles")).map(
(profile) => profile.note ?? null,
);
assert.ok(notes.includes("Keep me"), "an unticked field stays as it is");
},
{ seedDownloadedBrowser: true },
);
});
test("the group bookmark editor adds, reorders and removes a row", async () => {
await withApp("ui-group-bookmarks", async (app) => {
const group = await app.invoke("create_profile_group", { name: "Client" });
+44
View File
@@ -240,6 +240,20 @@ struct UpdateGroupRequest {
name: String,
}
#[derive(Debug, Deserialize, ToSchema)]
struct BulkEditRequest {
profile_ids: Vec<String>,
/// Fields left out stay as they are; `null` clears a field that can be
/// empty. `new_device: true` makes the next launch generate a new device.
patch: crate::profile::patch::ProfilePatch,
}
#[derive(Debug, Serialize, ToSchema)]
struct BulkEditResponse {
/// How many of the profiles actually changed.
changed: usize,
}
/// A profile status: a named, colored label for where a profile's account
/// stands.
#[derive(Debug, Serialize, Deserialize, ToSchema)]
@@ -730,6 +744,7 @@ struct ImportProxiesResponse {
create_status,
update_status,
delete_status,
bulk_edit_profiles,
get_tags,
get_proxies,
get_proxy,
@@ -778,6 +793,9 @@ struct ImportProxiesResponse {
UpdateGroupRequest,
ApiStatusResponse,
StatusRequest,
BulkEditRequest,
BulkEditResponse,
crate::profile::patch::ProfilePatch,
ApiProxyResponse,
crate::proxy_rotation::RotationResult,
CreateProxyRequest,
@@ -1068,6 +1086,7 @@ fn build_v1_router() -> Router<ApiServerState> {
.routes(routes!(get_group, update_group, delete_group))
.routes(routes!(get_statuses, create_status))
.routes(routes!(update_status, delete_status))
.routes(routes!(bulk_edit_profiles))
.routes(routes!(get_tags))
.routes(routes!(get_proxies, create_proxy))
.routes(routes!(import_proxies_api))
@@ -2266,6 +2285,31 @@ async fn delete_status(Path(id): Path<String>) -> Result<StatusCode, (StatusCode
.map_err(manager_error_response)
}
#[utoipa::path(
post,
path = "/v1/profiles/bulk-edit",
request_body = BulkEditRequest,
responses(
(status = 200, description = "Every profile was checked first, then the changed ones were saved", body = BulkEditResponse),
(status = 400, description = "A value is invalid (DNS_SERVER_INVALID, START_URL_INVALID, WEBRTC_MODE_INVALID, ...); nothing changed"),
(status = 401, description = "Unauthorized"),
(status = 404, description = "A profile, status, group or extension group does not exist; nothing changed"),
(status = 409, description = "A listed profile is running; nothing changed")
),
security(
("bearer_auth" = [])
),
tag = "profiles"
)]
async fn bulk_edit_profiles(
Json(request): Json<BulkEditRequest>,
) -> Result<Json<BulkEditResponse>, (StatusCode, String)> {
ProfileManager::instance()
.apply_patch(&request.profile_ids, request.patch)
.map(|changed| Json(BulkEditResponse { changed }))
.map_err(manager_error_response)
}
// API Handlers - Tags
#[utoipa::path(
get,
+3 -2
View File
@@ -169,8 +169,8 @@ use browser_runner::{
};
use profile::manager::{
check_browser_status, clone_profile, create_browser_profile_new, delete_profile,
list_browser_profiles, rename_profile, update_profile_clear_on_close,
bulk_update_profiles, check_browser_status, clone_profile, create_browser_profile_new,
delete_profile, list_browser_profiles, rename_profile, update_profile_clear_on_close,
update_profile_dns_blocklist, update_profile_dns_server, update_profile_launch_hook,
update_profile_note, update_profile_proxy, update_profile_proxy_bypass_rules,
update_profile_start_urls, update_profile_tags, update_profile_vpn, update_profile_window_color,
@@ -3336,6 +3336,7 @@ pub fn run_with_builder(
update_profile_dns_blocklist,
update_profile_dns_server,
update_profile_start_urls,
bulk_update_profiles,
profile_dns::describe_dns_server,
profile_statuses::list_profile_statuses,
profile_statuses::create_profile_status,
+54 -1
View File
@@ -2752,6 +2752,25 @@ impl McpServer {
"required": ["profile_id"]
}),
},
McpTool {
name: "bulk_update_profiles".to_string(),
description: "Change the same settings on many profiles at once. Every value is checked and every profile found before anything changes, and a running profile refuses the whole batch. Fields left out stay as they are; null clears a field that can be empty. Returns how many profiles changed.".to_string(),
input_schema: serde_json::json!({
"type": "object",
"properties": {
"profile_ids": {
"type": "array",
"items": { "type": "string" },
"description": "UUIDs of the profiles to change"
},
"patch": {
"type": "object",
"description": "The change. Keys: status_id, tags_add, tags_remove, note, group_id, extension_group_id, dns_blocklist, dns_server, proxy_bypass_rules, start_urls, launch_hook, clear_on_close, randomize_fingerprint_on_launch, webrtc_mode (auto|tcp_only|block), block_images, block_webgl, restore_session, new_device (true makes the next launch generate a new device)."
}
},
"required": ["profile_ids", "patch"]
}),
},
McpTool {
name: "list_profile_statuses".to_string(),
description: "List the profile statuses (named, colored labels such as Active or Banned) in their order. Set one on a profile with update_profile's status_id.".to_string(),
@@ -4568,6 +4587,40 @@ impl McpServer {
"get_profile_status" => self.handle_get_profile_status(arguments).await,
// Group management
"list_groups" => self.handle_list_groups().await,
"bulk_update_profiles" => {
let profile_ids: Vec<String> = arguments
.get("profile_ids")
.and_then(|v| serde_json::from_value(v.clone()).ok())
.ok_or_else(|| McpError {
code: -32602,
message: "Missing profile_ids".to_string(),
data: None,
})?;
let patch: crate::profile::patch::ProfilePatch = arguments
.get("patch")
.cloned()
.map(serde_json::from_value)
.transpose()
.map_err(|e| McpError {
code: -32602,
message: format!("Invalid patch: {e}"),
data: None,
})?
.unwrap_or_default();
let changed = ProfileManager::instance()
.apply_patch(&profile_ids, patch)
.map_err(|e| McpError {
code: -32000,
message: format!("Bulk edit failed: {e}"),
data: None,
})?;
Ok(serde_json::json!({
"content": [{
"type": "text",
"text": format!("{changed} of {} profiles changed", profile_ids.len())
}]
}))
}
"list_profile_statuses" => Ok(serde_json::json!({
"content": [{
"type": "text",
@@ -9972,7 +10025,7 @@ mod tests {
// tool was added or removed on purpose.
assert_eq!(
tools.len(),
95,
96,
"the tool list is a published contract; update this number deliberately"
);
+76
View File
@@ -1486,6 +1486,72 @@ impl ProfileManager {
Ok(profile)
}
/// Change many profiles at once. Every value is checked and every profile
/// found before any of them changes, and a running profile refuses the
/// whole batch: its browser would keep the old settings and the next save
/// from the running session could undo the edit. Returns how many changed.
pub fn apply_patch(
&self,
profile_ids: &[String],
patch: super::patch::ProfilePatch,
) -> Result<usize, Box<dyn std::error::Error>> {
let patch = patch.validated()?;
let mut profiles = Vec::with_capacity(profile_ids.len());
for profile_id in profile_ids {
let profile = self.find_profile(profile_id)?;
if crate::profile::trash::is_running_locally(&profile) {
return Err(crate::backend_error("PROFILE_RUNNING").into());
}
profiles.push(profile);
}
let mut changed = 0;
let mut any_synced = false;
for mut profile in profiles {
if !patch.apply_to(&mut profile) {
continue;
}
profile.updated_at = Some(crate::proxy_manager::now_secs());
self.save_profile(&profile)?;
crate::sync::queue_profile_sync_if_eligible(&profile);
any_synced |= profile.is_sync_enabled();
changed += 1;
}
if any_synced {
Self::sync_patched_groups(&patch);
}
if let Err(e) = events::emit_empty("profiles-changed") {
log::warn!("Event emit failed event=profiles-changed err=\"{e}\"");
}
log::info!(
"Profiles edited in bulk selected={} changed={changed}",
profile_ids.len()
);
Ok(changed)
}
/// A synced profile needs the groups a patch points it at in the cloud too.
fn sync_patched_groups(patch: &super::patch::ProfilePatch) {
let (group_id, extension_group_id) = patch.group_ids();
if let Some(group_id) = group_id.map(str::to_string) {
tauri::async_runtime::spawn(async move {
let _ = crate::sync::enable_group_sync_if_needed(&group_id).await;
if let Some(scheduler) = crate::sync::get_global_scheduler() {
scheduler.queue_group_sync(group_id).await;
}
});
}
if let Some(group_id) = extension_group_id.map(str::to_string) {
tauri::async_runtime::spawn(async move {
let _ = crate::sync::enable_extension_group_sync_if_needed(&group_id).await;
if let Some(scheduler) = crate::sync::get_global_scheduler() {
scheduler.queue_extension_group_sync(group_id).await;
}
});
}
}
/// Give the listed profiles one status, or none. A running profile can
/// take a status: it is a label, not a launch setting.
pub fn set_profiles_status(
@@ -2772,6 +2838,16 @@ pub fn update_profile_dns_blocklist(
.map_err(|e| format!("Failed to update DNS blocklist: {e}"))
}
#[tauri::command]
pub fn bulk_update_profiles(
profile_ids: Vec<String>,
patch: super::patch::ProfilePatch,
) -> Result<usize, String> {
ProfileManager::instance()
.apply_patch(&profile_ids, patch)
.map_err(|e| crate::wrap_backend_error(e, "Failed to edit profiles"))
}
#[tauri::command]
pub fn update_profile_start_urls(
profile_id: String,
+1
View File
@@ -2,6 +2,7 @@ pub mod clear_on_close;
pub mod encryption;
pub mod manager;
pub mod password;
pub mod patch;
pub mod portable;
pub mod trash;
pub mod types;
+371
View File
@@ -0,0 +1,371 @@
//! A change to many profiles at once: what bulk edit sends, and what a
//! template adds to each profile made from it.
//!
//! A field left out stays as it is. For the fields that can be empty, `null`
//! clears them. Every value is checked once, before any profile changes, so a
//! bad value changes nothing.
use super::types::BrowserProfile;
use serde::{Deserialize, Deserializer, Serialize};
/// Tells "sent as null" (clear) apart from "left out" (keep).
fn present<'de, D, T>(deserializer: D) -> Result<Option<Option<T>>, D::Error>
where
D: Deserializer<'de>,
T: Deserialize<'de>,
{
Option::<T>::deserialize(deserializer).map(Some)
}
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, utoipa::ToSchema)]
#[serde(deny_unknown_fields)]
pub struct ProfilePatch {
#[serde(default, deserialize_with = "present")]
pub status_id: Option<Option<String>>,
#[serde(default)]
pub tags_add: Vec<String>,
#[serde(default)]
pub tags_remove: Vec<String>,
#[serde(default, deserialize_with = "present")]
pub note: Option<Option<String>>,
#[serde(default, deserialize_with = "present")]
pub group_id: Option<Option<String>>,
#[serde(default, deserialize_with = "present")]
pub extension_group_id: Option<Option<String>>,
#[serde(default, deserialize_with = "present")]
pub dns_blocklist: Option<Option<String>>,
#[serde(default, deserialize_with = "present")]
pub dns_server: Option<Option<String>>,
#[serde(default)]
pub proxy_bypass_rules: Option<Vec<String>>,
#[serde(default)]
pub start_urls: Option<Vec<String>>,
#[serde(default, deserialize_with = "present")]
pub launch_hook: Option<Option<String>>,
/// Left off ephemeral and password-protected profiles, where it means
/// nothing: they never keep browsing data in the clear.
#[serde(default)]
pub clear_on_close: Option<bool>,
#[serde(default)]
pub randomize_fingerprint_on_launch: Option<bool>,
#[serde(default, deserialize_with = "present")]
pub webrtc_mode: Option<Option<String>>,
#[serde(default)]
pub block_images: Option<bool>,
#[serde(default)]
pub block_webgl: Option<bool>,
#[serde(default)]
pub restore_session: Option<bool>,
/// Drop the stored device, so the next launch generates a new one.
#[serde(default)]
pub new_device: bool,
}
fn code(code: &str) -> String {
crate::backend_error(code)
}
fn trimmed(value: Option<String>) -> Option<String> {
value
.map(|v| v.trim().to_string())
.filter(|v| !v.is_empty())
}
impl ProfilePatch {
/// The patch with every value checked and in its stored form.
pub fn validated(mut self) -> Result<Self, String> {
if let Some(status_id) = self.status_id.take() {
let status_id = trimmed(status_id);
if let Some(id) = &status_id {
if !crate::profile_statuses::exists(id) {
return Err(code("PROFILE_STATUS_NOT_FOUND"));
}
}
self.status_id = Some(status_id);
}
if let Some(group_id) = self.group_id.take() {
let group_id = trimmed(group_id);
if let Some(id) = &group_id {
let exists = crate::group_manager::GROUP_MANAGER
.lock()
.map_err(|_| code("INTERNAL_ERROR"))?
.get_all_groups()
.map_err(|e| crate::wrap_backend_error(e, "Failed to read groups"))?
.iter()
.any(|group| &group.id == id);
if !exists {
return Err(code("GROUP_NOT_FOUND"));
}
}
self.group_id = Some(group_id);
}
if let Some(extension_group_id) = self.extension_group_id.take() {
let extension_group_id = trimmed(extension_group_id);
if let Some(id) = &extension_group_id {
crate::extension_manager::EXTENSION_MANAGER
.lock()
.map_err(|_| code("INTERNAL_ERROR"))?
.get_group(id)
.map_err(|_| code("EXTENSION_GROUP_NOT_FOUND"))?;
}
self.extension_group_id = Some(extension_group_id);
}
if let Some(level) = self.dns_blocklist.take() {
let level = trimmed(level).filter(|level| level != "none");
if let Some(level) = &level {
if crate::dns_blocklist::BlocklistLevel::parse_level(level).is_none() {
return Err(code("DNS_BLOCKLIST_INVALID"));
}
}
self.dns_blocklist = Some(level);
}
if let Some(dns_server) = self.dns_server.take() {
self.dns_server = Some(crate::profile_dns::normalize_dns_server(dns_server)?);
}
if let Some(start_urls) = self.start_urls.take() {
self.start_urls = Some(super::manager::checked_start_urls(start_urls)?);
}
if let Some(hook) = self.launch_hook.take() {
self.launch_hook = Some(super::manager::validate_launch_hook(hook.as_deref())?);
}
if let Some(mode) = self.webrtc_mode.take() {
let mode = match trimmed(mode) {
None => None,
Some(mode) => Some(
crate::wayfern_manager::WebRtcMode::parse(&mode)
.ok_or_else(|| code("WEBRTC_MODE_INVALID"))?
.switch_value()
.to_string(),
),
};
self.webrtc_mode = Some(mode);
}
if let Some(rules) = self.proxy_bypass_rules.take() {
self.proxy_bypass_rules = Some(
rules
.into_iter()
.map(|rule| rule.trim().to_string())
.filter(|rule| !rule.is_empty())
.collect(),
);
}
self.tags_add = tidy_tags(std::mem::take(&mut self.tags_add));
self.tags_remove = tidy_tags(std::mem::take(&mut self.tags_remove));
Ok(self)
}
/// Whether the patch changes the group or extension group, which carry
/// sync side effects beyond the profile itself.
pub fn group_ids(&self) -> (Option<&str>, Option<&str>) {
(
self.group_id.as_ref().and_then(|g| g.as_deref()),
self.extension_group_id.as_ref().and_then(|g| g.as_deref()),
)
}
/// Change one profile. Returns whether anything changed. Only called on a
/// patch that `validated` returned.
pub fn apply_to(&self, profile: &mut BrowserProfile) -> bool {
let before = serde_json::to_value(&*profile).ok();
if let Some(status_id) = &self.status_id {
profile.status_id = status_id.clone();
}
if !self.tags_add.is_empty() || !self.tags_remove.is_empty() {
let mut tags: Vec<String> = profile
.tags
.iter()
.filter(|tag| !self.tags_remove.contains(tag))
.cloned()
.collect();
for tag in &self.tags_add {
if !tags.contains(tag) {
tags.push(tag.clone());
}
}
profile.tags = tags;
}
if let Some(note) = &self.note {
profile.note = trimmed(note.clone());
}
if let Some(group_id) = &self.group_id {
profile.group_id = group_id.clone();
}
if let Some(extension_group_id) = &self.extension_group_id {
profile.extension_group_id = extension_group_id.clone();
}
if let Some(level) = &self.dns_blocklist {
profile.dns_blocklist = level.clone();
}
if let Some(dns_server) = &self.dns_server {
profile.dns_server = dns_server.clone();
}
if let Some(rules) = &self.proxy_bypass_rules {
profile.proxy_bypass_rules = rules.clone();
}
if let Some(start_urls) = &self.start_urls {
profile.start_urls = start_urls.clone();
}
if let Some(hook) = &self.launch_hook {
profile.launch_hook = hook.clone();
}
if let Some(clear) = self.clear_on_close {
profile.clear_on_close = clear && !profile.ephemeral && !profile.password_protected;
}
let touches_device = self.randomize_fingerprint_on_launch.is_some()
|| self.webrtc_mode.is_some()
|| self.block_images.is_some()
|| self.block_webgl.is_some()
|| self.restore_session.is_some()
|| self.new_device;
if touches_device {
let config = profile.wayfern_config.get_or_insert_with(Default::default);
if let Some(randomize) = self.randomize_fingerprint_on_launch {
config.randomize_fingerprint_on_launch = Some(randomize);
}
if let Some(mode) = &self.webrtc_mode {
config.webrtc_mode = mode.clone();
// The legacy switch would otherwise still read as `block`.
config.block_webrtc = None;
}
if let Some(block) = self.block_images {
config.block_images = Some(block);
}
if let Some(block) = self.block_webgl {
config.block_webgl = Some(block);
}
if let Some(restore) = self.restore_session {
config.restore_session = Some(restore);
}
if self.new_device {
config.fingerprint = None;
config.identity_id = None;
config.identity_baseline = None;
config.identity_overrides = None;
}
}
before != serde_json::to_value(&*profile).ok()
}
}
fn tidy_tags(tags: Vec<String>) -> Vec<String> {
let mut out: Vec<String> = Vec::new();
for tag in tags {
let tag = tag.trim().to_string();
if !tag.is_empty() && !out.contains(&tag) {
out.push(tag);
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
fn patch(json: serde_json::Value) -> ProfilePatch {
serde_json::from_value(json).unwrap()
}
#[test]
fn null_clears_and_absence_keeps() {
assert_eq!(patch(serde_json::json!({})).status_id, None);
assert_eq!(
patch(serde_json::json!({ "status_id": null })).status_id,
Some(None)
);
assert_eq!(
patch(serde_json::json!({ "status_id": "s1" })).status_id,
Some(Some("s1".to_string()))
);
assert!(serde_json::from_value::<ProfilePatch>(serde_json::json!({ "name": "x" })).is_err());
}
#[test]
fn a_patch_changes_only_what_it_names() {
let mut profile = BrowserProfile {
tags: vec!["keep".into(), "drop".into()],
note: Some("old".into()),
dns_blocklist: Some("light".into()),
..Default::default()
};
let change = ProfilePatch {
tags_add: vec!["new".into(), "keep".into()],
tags_remove: vec!["drop".into()],
note: Some(None),
webrtc_mode: Some(Some("block".into())),
..Default::default()
};
assert!(change.apply_to(&mut profile));
assert_eq!(profile.tags, ["keep", "new"]);
assert_eq!(profile.note, None);
assert_eq!(
profile.dns_blocklist.as_deref(),
Some("light"),
"not named, so kept"
);
assert_eq!(
profile
.wayfern_config
.as_ref()
.unwrap()
.webrtc_mode
.as_deref(),
Some("block")
);
assert!(
!change.apply_to(&mut profile),
"applying twice changes nothing"
);
}
#[test]
fn a_new_device_drops_the_stored_identity_and_nothing_else() {
let mut profile = BrowserProfile {
wayfern_config: Some(crate::wayfern_manager::WayfernConfig {
identity_id: Some("id".into()),
identity_overrides: Some("{}".into()),
location: Some("{\"timezone\":\"Europe/Berlin\"}".into()),
os: Some("windows".into()),
..Default::default()
}),
..Default::default()
};
assert!(ProfilePatch {
new_device: true,
..Default::default()
}
.apply_to(&mut profile));
let config = profile.wayfern_config.unwrap();
assert_eq!(config.identity_id, None);
assert_eq!(config.identity_overrides, None);
assert_eq!(
config.os.as_deref(),
Some("windows"),
"the claimed OS stays"
);
assert!(
config.location.is_some(),
"the location belongs to the route"
);
}
#[test]
fn clear_on_close_skips_profiles_where_it_means_nothing() {
let change = ProfilePatch {
clear_on_close: Some(true),
..Default::default()
};
let mut ephemeral = BrowserProfile {
ephemeral: true,
..Default::default()
};
change.apply_to(&mut ephemeral);
assert!(!ephemeral.clear_on_close);
let mut plain = BrowserProfile::default();
change.apply_to(&mut plain);
assert!(plain.clear_on_close);
}
}
+8 -1
View File
@@ -43,6 +43,9 @@ pub struct TotpCode {
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct InvalidTotpKey;
/// 80 bits: authenticator apps refuse shorter keys, and a word or a typo
/// would otherwise read as a valid secret and give wrong codes.
const MIN_SECRET_BYTES: usize = 10;
const MAX_SECRET_BYTES: usize = 128;
fn decode_base32(input: &str) -> Option<Vec<u8>> {
@@ -66,7 +69,9 @@ fn decode_base32(input: &str) -> Option<Vec<u8>> {
bits &= (1 << bit_count) - 1;
}
}
(!out.is_empty() && out.len() <= MAX_SECRET_BYTES).then_some(out)
(MIN_SECRET_BYTES..=MAX_SECRET_BYTES)
.contains(&out.len())
.then_some(out)
}
fn normalized_secret(raw: &str) -> Option<String> {
@@ -291,6 +296,8 @@ mod tests {
"",
" ",
"not base32!",
"not a key",
"JBSWY3DP",
"otpauth://hotp/x?secret=JBSWY3DPEHPK3PXP&counter=1",
"otpauth://totp/x",
"otpauth://totp/x?secret=JBSWY3DPEHPK3PXP&digits=4",
+7 -7
View File
@@ -441,15 +441,16 @@ pub fn session_restore_verdict(
ephemeral: bool,
clear_on_close: bool,
identity_at_launch: bool,
opens_start_pages: bool,
has_start_pages: bool,
) -> Result<(), &'static str> {
if kind == LaunchKind::Automation {
return Err("an automation run starts clean");
}
// As in Chrome: a set of start pages replaces the last session. Restoring
// both would add the start pages to the restored tabs on every launch.
if opens_start_pages {
return Err("the profile opens its start pages instead");
// As in Chrome: a set of start pages replaces the last session, also on a
// launch that opens a given URL. Restoring would add the start pages to
// the restored tabs on every launch, or bring them back with the URL.
if has_start_pages {
return Err("the profile starts on its start pages, not its last session");
}
if headless {
return Err("a headless launch has no session to show");
@@ -2923,7 +2924,6 @@ impl WayfernManager {
let identity_at_launch =
identity_file.is_some() || (config.identity_id.is_none() && config.fingerprint.is_none());
let pages = launch_pages(url, &profile.start_urls, kind);
let opens_start_pages = url.is_none() && !pages.is_empty();
let restore_session = match session_restore_verdict(
config,
kind,
@@ -2931,7 +2931,7 @@ impl WayfernManager {
ephemeral,
profile.clear_on_close,
identity_at_launch,
opens_start_pages,
!profile.start_urls.is_empty(),
) {
Ok(()) => true,
Err(reason) => {
+414
View File
@@ -0,0 +1,414 @@
"use client";
import { invoke } from "@tauri-apps/api/core";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { DnsServerField } from "@/components/dns-server-field";
import { ProfileStatusChip } from "@/components/profile-statuses-dialog";
import { AnimatedSwitch } from "@/components/ui/animated-switch";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Textarea } from "@/components/ui/textarea";
import { translateBackendError } from "@/lib/backend-errors";
import { DNS_BLOCKLIST_LEVELS } from "@/lib/dns-blocklist-levels";
import { startPagesFromText } from "@/lib/start-pages";
import { showErrorToast, showSuccessToast } from "@/lib/toast-utils";
import { cn } from "@/lib/utils";
import type { ProfileStatus } from "@/types";
type Field =
| "status"
| "tagsAdd"
| "tagsRemove"
| "note"
| "dnsBlocklist"
| "dnsServer"
| "startPages"
| "clearOnClose"
| "webrtc"
| "blockImages"
| "blockWebgl"
| "randomize"
| "restoreSession"
| "newDevice";
const NONE = "__none__";
function tagsFrom(text: string): string[] {
return text
.split(",")
.map((tag) => tag.trim())
.filter(Boolean);
}
function Row({
field,
label,
enabled,
onToggle,
children,
}: {
field: Field;
label: string;
enabled: boolean;
onToggle: (field: Field, on: boolean) => void;
children?: React.ReactNode;
}) {
const id = `bulk-edit-${field}`;
return (
<div
className="flex items-start gap-3 py-1.5"
data-slot="bulk-edit-row"
data-field={field}
>
<Checkbox
id={id}
checked={enabled}
onCheckedChange={(checked) => onToggle(field, checked === true)}
className="mt-0.5"
/>
<div className="flex min-w-0 flex-1 flex-col gap-1.5">
<label
htmlFor={id}
className={cn(
"text-sm",
enabled ? "text-foreground" : "text-muted-foreground",
)}
>
{label}
</label>
{enabled && children}
</div>
</div>
);
}
function OnOff({
value,
onChange,
}: {
value: boolean;
onChange: (value: boolean) => void;
}) {
return <AnimatedSwitch checked={value} onCheckedChange={onChange} />;
}
export function BulkEditDialog({
isOpen,
onClose,
profileIds,
statuses,
}: {
isOpen: boolean;
onClose: () => void;
profileIds: string[];
statuses: ProfileStatus[];
}) {
const { t } = useTranslation();
const [enabled, setEnabled] = React.useState<Set<Field>>(new Set());
const [statusId, setStatusId] = React.useState<string>(NONE);
const [tagsAdd, setTagsAdd] = React.useState("");
const [tagsRemove, setTagsRemove] = React.useState("");
const [note, setNote] = React.useState("");
const [dnsBlocklist, setDnsBlocklist] = React.useState<string>(NONE);
const [dnsServer, setDnsServer] = React.useState("");
const [dnsServerValid, setDnsServerValid] = React.useState(true);
const [startPages, setStartPages] = React.useState("");
const [clearOnClose, setClearOnClose] = React.useState(false);
const [webrtc, setWebrtc] = React.useState("auto");
const [blockImages, setBlockImages] = React.useState(false);
const [blockWebgl, setBlockWebgl] = React.useState(false);
const [randomize, setRandomize] = React.useState(false);
const [restoreSession, setRestoreSession] = React.useState(true);
const [isSaving, setIsSaving] = React.useState(false);
React.useEffect(() => {
if (!isOpen) return;
setEnabled(new Set());
setStatusId(NONE);
setTagsAdd("");
setTagsRemove("");
setNote("");
setDnsBlocklist(NONE);
setDnsServer("");
setStartPages("");
setClearOnClose(false);
setWebrtc("auto");
setBlockImages(false);
setBlockWebgl(false);
setRandomize(false);
setRestoreSession(true);
}, [isOpen]);
const toggle = (field: Field, on: boolean) => {
setEnabled((current) => {
const next = new Set(current);
if (on) next.add(field);
else next.delete(field);
return next;
});
};
const has = (field: Field) => enabled.has(field);
const buildPatch = () => {
const patch: Record<string, unknown> = {};
if (has("status")) patch.status_id = statusId === NONE ? null : statusId;
if (has("tagsAdd")) patch.tags_add = tagsFrom(tagsAdd);
if (has("tagsRemove")) patch.tags_remove = tagsFrom(tagsRemove);
if (has("note")) patch.note = note.trim() || null;
if (has("dnsBlocklist"))
patch.dns_blocklist = dnsBlocklist === NONE ? null : dnsBlocklist;
if (has("dnsServer")) patch.dns_server = dnsServer.trim() || null;
if (has("startPages")) patch.start_urls = startPagesFromText(startPages);
if (has("clearOnClose")) patch.clear_on_close = clearOnClose;
if (has("webrtc")) patch.webrtc_mode = webrtc;
if (has("blockImages")) patch.block_images = blockImages;
if (has("blockWebgl")) patch.block_webgl = blockWebgl;
if (has("randomize")) patch.randomize_fingerprint_on_launch = randomize;
if (has("restoreSession")) patch.restore_session = restoreSession;
if (has("newDevice")) patch.new_device = true;
return patch;
};
const apply = async () => {
setIsSaving(true);
try {
const changed = await invoke<number>("bulk_update_profiles", {
profileIds,
patch: buildPatch(),
});
showSuccessToast(t("bulkEdit.applied", { count: changed }));
onClose();
} catch (error) {
showErrorToast(translateBackendError(t, error));
} finally {
setIsSaving(false);
}
};
const blocked = has("dnsServer") && !dnsServerValid;
return (
<Dialog open={isOpen} onOpenChange={(open) => !open && onClose()}>
<DialogContent
className="flex max-h-[85vh] max-w-lg flex-col"
data-slot="bulk-edit-dialog"
>
<DialogHeader>
<DialogTitle>{t("bulkEdit.title")}</DialogTitle>
<DialogDescription>{t("bulkEdit.description")}</DialogDescription>
</DialogHeader>
<div className="-mx-1 min-h-0 flex-1 divide-y divide-border/50 overflow-y-auto px-1">
<Row
field="status"
label={t("profileStatuses.column")}
enabled={has("status")}
onToggle={toggle}
>
<Select value={statusId} onValueChange={setStatusId}>
<SelectTrigger variant="soft" className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value={NONE}>
{t("profileStatuses.none")}
</SelectItem>
{statuses.map((status) => (
<SelectItem key={status.id} value={status.id}>
<ProfileStatusChip status={status} />
</SelectItem>
))}
</SelectContent>
</Select>
</Row>
<Row
field="tagsAdd"
label={t("bulkEdit.tagsAdd")}
enabled={has("tagsAdd")}
onToggle={toggle}
>
<Input
variant="soft"
value={tagsAdd}
onChange={(event) => setTagsAdd(event.target.value)}
placeholder={t("bulkEdit.tagsPlaceholder")}
/>
</Row>
<Row
field="tagsRemove"
label={t("bulkEdit.tagsRemove")}
enabled={has("tagsRemove")}
onToggle={toggle}
>
<Input
variant="soft"
value={tagsRemove}
onChange={(event) => setTagsRemove(event.target.value)}
placeholder={t("bulkEdit.tagsPlaceholder")}
/>
</Row>
<Row
field="note"
label={t("profileTable.noteHeader")}
enabled={has("note")}
onToggle={toggle}
>
<Textarea
variant="soft"
value={note}
onChange={(event) => setNote(event.target.value)}
placeholder={t("bulkEdit.notePlaceholder")}
rows={2}
/>
</Row>
<Row
field="dnsBlocklist"
label={t("dnsBlocklist.title")}
enabled={has("dnsBlocklist")}
onToggle={toggle}
>
<Select value={dnsBlocklist} onValueChange={setDnsBlocklist}>
<SelectTrigger variant="soft" className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value={NONE}>{t("dnsBlocklist.none")}</SelectItem>
{DNS_BLOCKLIST_LEVELS.map((level) => (
<SelectItem key={level.value} value={level.value}>
{t(level.labelKey)}
</SelectItem>
))}
</SelectContent>
</Select>
</Row>
<Row
field="dnsServer"
label={t("dnsServer.label")}
enabled={has("dnsServer")}
onToggle={toggle}
>
<DnsServerField
id="bulk-edit-dns-server-value"
value={dnsServer}
onChange={setDnsServer}
onValidityChange={setDnsServerValid}
/>
</Row>
<Row
field="startPages"
label={t("profileInfo.sections.startPages")}
enabled={has("startPages")}
onToggle={toggle}
>
<Textarea
variant="soft"
value={startPages}
onChange={(event) => setStartPages(event.target.value)}
placeholder={t("startPages.placeholder")}
rows={3}
spellCheck={false}
className="font-mono text-xs"
/>
</Row>
<Row
field="clearOnClose"
label={t("clearOnClose.label")}
enabled={has("clearOnClose")}
onToggle={toggle}
>
<OnOff value={clearOnClose} onChange={setClearOnClose} />
</Row>
<Row
field="webrtc"
label={t("fingerprint.webrtcModeLabel")}
enabled={has("webrtc")}
onToggle={toggle}
>
<Select value={webrtc} onValueChange={setWebrtc}>
<SelectTrigger variant="soft" className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
{(["auto", "tcp_only", "block"] as const).map((mode) => (
<SelectItem key={mode} value={mode}>
{t(`fingerprint.webrtcMode.${mode}`)}
</SelectItem>
))}
</SelectContent>
</Select>
</Row>
<Row
field="blockImages"
label={t("fingerprint.blockImages")}
enabled={has("blockImages")}
onToggle={toggle}
>
<OnOff value={blockImages} onChange={setBlockImages} />
</Row>
<Row
field="blockWebgl"
label={t("fingerprint.blockWebGL")}
enabled={has("blockWebgl")}
onToggle={toggle}
>
<OnOff value={blockWebgl} onChange={setBlockWebgl} />
</Row>
<Row
field="randomize"
label={t("fingerprint.generateRandomOnLaunch")}
enabled={has("randomize")}
onToggle={toggle}
>
<OnOff value={randomize} onChange={setRandomize} />
</Row>
<Row
field="restoreSession"
label={t("fingerprint.restoreSession")}
enabled={has("restoreSession")}
onToggle={toggle}
>
<OnOff value={restoreSession} onChange={setRestoreSession} />
</Row>
<Row
field="newDevice"
label={t("bulkEdit.newDevice")}
enabled={has("newDevice")}
onToggle={toggle}
>
<p className="text-xs text-muted-foreground">
{t("bulkEdit.newDeviceDescription")}
</p>
</Row>
</div>
<DialogFooter>
<Button variant="ghost" onClick={onClose} disabled={isSaving}>
{t("common.buttons.cancel")}
</Button>
<Button
data-slot="bulk-edit-apply"
onClick={() => void apply()}
disabled={isSaving || enabled.size === 0 || blocked}
>
{t("bulkEdit.apply", { count: profileIds.length })}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
+15
View File
@@ -33,12 +33,14 @@ import {
LuPlay,
LuPuzzle,
LuShuffle,
LuSlidersHorizontal,
LuSquare,
LuTrash2,
LuTriangleAlert,
LuUserSearch,
LuUsers,
} from "react-icons/lu";
import { BulkEditDialog } from "@/components/bulk-edit-dialog";
import { CookieBotEnrolDialog } from "@/components/cookie-bot-enrol-dialog";
import { CookieBotRunsDialog } from "@/components/cookie-bot-runs-dialog";
import {
@@ -1816,6 +1818,7 @@ export function ProfilesDataTable({
const [bypassRulesProfile, setBypassRulesProfile] =
React.useState<BrowserProfile | null>(null);
const [statusesDialogOpen, setStatusesDialogOpen] = React.useState(false);
const [bulkEditOpen, setBulkEditOpen] = React.useState(false);
const [dnsBlocklistProfile, setDnsBlocklistProfile] =
React.useState<BrowserProfile | null>(null);
const [launchHookProfile, setLaunchHookProfile] =
@@ -4187,6 +4190,12 @@ export function ProfilesDataTable({
)}
</span>
)}
<DataTableActionBarAction
tooltip={t("bulkEdit.action")}
onClick={() => setBulkEditOpen(true)}
>
<LuSlidersHorizontal />
</DataTableActionBarAction>
{onBulkGroupAssignment && (
<DataTableActionBarAction
tooltip={t("profiles.actionBar.assignToGroup")}
@@ -4272,6 +4281,12 @@ export function ProfilesDataTable({
profileId={bypassRulesProfile?.id ?? null}
initialRules={bypassRulesProfile?.proxy_bypass_rules ?? []}
/>
<BulkEditDialog
isOpen={bulkEditOpen}
onClose={() => setBulkEditOpen(false)}
profileIds={selectedProfiles}
statuses={profileStatuses}
/>
<ProfileStatusesDialog
isOpen={statusesDialogOpen}
onClose={() => setStatusesDialogOpen(false)}
+18 -1
View File
@@ -1775,6 +1775,21 @@
"deleteTitle": "2FA-Schlüssel für „{{label}}“ löschen?",
"deleteDescription": "Bewahren Sie zuerst eine Kopie des Schlüssels an anderer Stelle auf, sonst können Sie den Zugang zum Konto verlieren."
},
"bulkEdit": {
"action": "Auswahl bearbeiten",
"title": "Profile bearbeiten",
"description": "Haken Sie die Einstellungen an, die sich ändern sollen. Alles andere bleibt in jedem Profil, wie es ist.",
"tagsAdd": "Tags hinzufügen",
"tagsRemove": "Tags entfernen",
"tagsPlaceholder": "arbeit, shop",
"notePlaceholder": "Leer lassen, um die Notiz zu entfernen",
"newDevice": "Neues Gerät beim nächsten Start",
"newDeviceDescription": "Jedes Profil erhält beim nächsten Start ein neues Gerät, sodass Websites es nicht mit dem alten verbinden können.",
"apply_one": "Auf {{count}} Profil anwenden",
"apply_other": "Auf {{count}} Profile anwenden",
"applied_one": "{{count}} Profil geändert",
"applied_other": "{{count}} Profile geändert"
},
"vpns": {
"form": {
"titleEdit": "VPN bearbeiten",
@@ -2514,7 +2529,9 @@
"totpKeyInvalid": "Das ist kein 2FA-Schlüssel. Fügen Sie den geheimen Schlüssel oder den otpauth://-Link ein, den die Website zeigt.",
"totpLabelTooLong": "Eine Bezeichnung darf höchstens 60 Zeichen haben.",
"totpLimitReached": "Ein Profil kann höchstens 50 2FA-Schlüssel speichern.",
"totpEntryNotFound": "Dieser 2FA-Schlüssel existiert nicht mehr."
"totpEntryNotFound": "Dieser 2FA-Schlüssel existiert nicht mehr.",
"dnsBlocklistInvalid": "Diese Stufe der DNS-Sperrliste existiert nicht.",
"webrtcModeInvalid": "WebRTC muss Automatisch, Nur Proxy-Ausgang oder Blockiert sein."
},
"rail": {
"profiles": "Profile",
+18 -1
View File
@@ -1775,6 +1775,21 @@
"deleteTitle": "Delete the 2FA key for \"{{label}}\"?",
"deleteDescription": "Keep a copy of the key somewhere else first, or you can lose access to the account."
},
"bulkEdit": {
"action": "Edit selected",
"title": "Edit profiles",
"description": "Tick the settings to change. Everything else stays as it is on each profile.",
"tagsAdd": "Add tags",
"tagsRemove": "Remove tags",
"tagsPlaceholder": "work, shop",
"notePlaceholder": "Leave empty to remove the note",
"newDevice": "New device at next launch",
"newDeviceDescription": "Each profile gets a new device the next time it starts, so sites cannot link it to the old one.",
"apply_one": "Apply to {{count}} profile",
"apply_other": "Apply to {{count}} profiles",
"applied_one": "Changed {{count}} profile",
"applied_other": "Changed {{count}} profiles"
},
"vpns": {
"form": {
"titleEdit": "Edit VPN",
@@ -2514,7 +2529,9 @@
"totpKeyInvalid": "This is not a 2FA key. Paste the secret key or the otpauth:// link that the site shows.",
"totpLabelTooLong": "A label can have at most 60 characters.",
"totpLimitReached": "A profile can keep at most 50 2FA keys.",
"totpEntryNotFound": "This 2FA key no longer exists."
"totpEntryNotFound": "This 2FA key no longer exists.",
"dnsBlocklistInvalid": "This DNS blocklist level does not exist.",
"webrtcModeInvalid": "WebRTC must be Automatic, Proxy exit only or Blocked."
},
"rail": {
"profiles": "Profiles",
+20 -1
View File
@@ -1782,6 +1782,23 @@
"deleteTitle": "¿Eliminar la clave 2FA de «{{label}}»?",
"deleteDescription": "Guarda antes una copia de la clave en otro lugar, o puedes perder el acceso a la cuenta."
},
"bulkEdit": {
"action": "Editar selección",
"title": "Editar perfiles",
"description": "Marca los ajustes que quieres cambiar. Todo lo demás se queda igual en cada perfil.",
"tagsAdd": "Añadir etiquetas",
"tagsRemove": "Quitar etiquetas",
"tagsPlaceholder": "trabajo, tienda",
"notePlaceholder": "Déjalo vacío para quitar la nota",
"newDevice": "Dispositivo nuevo en el próximo inicio",
"newDeviceDescription": "Cada perfil recibe un dispositivo nuevo la próxima vez que se inicia, para que los sitios no puedan relacionarlo con el anterior.",
"apply_one": "Aplicar a {{count}} perfil",
"apply_many": "Aplicar a {{count}} perfiles",
"apply_other": "Aplicar a {{count}} perfiles",
"applied_one": "{{count}} perfil cambiado",
"applied_many": "{{count}} perfiles cambiados",
"applied_other": "{{count}} perfiles cambiados"
},
"vpns": {
"form": {
"titleEdit": "Editar VPN",
@@ -2525,7 +2542,9 @@
"totpKeyInvalid": "Esto no es una clave 2FA. Pega la clave secreta o el enlace otpauth:// que muestra el sitio.",
"totpLabelTooLong": "Una etiqueta puede tener como máximo 60 caracteres.",
"totpLimitReached": "Un perfil puede guardar como máximo 50 claves 2FA.",
"totpEntryNotFound": "Esta clave 2FA ya no existe."
"totpEntryNotFound": "Esta clave 2FA ya no existe.",
"dnsBlocklistInvalid": "Este nivel de lista de bloqueo DNS no existe.",
"webrtcModeInvalid": "WebRTC debe ser Automático, Solo salida del proxy o Bloqueado."
},
"rail": {
"profiles": "Perfiles",
+20 -1
View File
@@ -1782,6 +1782,23 @@
"deleteTitle": "Supprimer la clé 2FA de « {{label}} » ?",
"deleteDescription": "Gardez d'abord une copie de la clé ailleurs, sinon vous pouvez perdre l'accès au compte."
},
"bulkEdit": {
"action": "Modifier la sélection",
"title": "Modifier les profils",
"description": "Cochez les réglages à changer. Tout le reste ne change pas sur chaque profil.",
"tagsAdd": "Ajouter des tags",
"tagsRemove": "Retirer des tags",
"tagsPlaceholder": "travail, boutique",
"notePlaceholder": "Laissez vide pour retirer la note",
"newDevice": "Nouvel appareil au prochain lancement",
"newDeviceDescription": "Chaque profil reçoit un nouvel appareil au prochain démarrage, pour que les sites ne puissent pas le relier à l'ancien.",
"apply_one": "Appliquer à {{count}} profil",
"apply_many": "Appliquer à {{count}} profils",
"apply_other": "Appliquer à {{count}} profils",
"applied_one": "{{count}} profil modifié",
"applied_many": "{{count}} profils modifiés",
"applied_other": "{{count}} profils modifiés"
},
"vpns": {
"form": {
"titleEdit": "Modifier le VPN",
@@ -2525,7 +2542,9 @@
"totpKeyInvalid": "Ce n'est pas une clé 2FA. Collez la clé secrète ou le lien otpauth:// affiché par le site.",
"totpLabelTooLong": "Un libellé ne peut pas dépasser 60 caractères.",
"totpLimitReached": "Un profil peut garder au plus 50 clés 2FA.",
"totpEntryNotFound": "Cette clé 2FA n'existe plus."
"totpEntryNotFound": "Cette clé 2FA n'existe plus.",
"dnsBlocklistInvalid": "Ce niveau de liste de blocage DNS n'existe pas.",
"webrtcModeInvalid": "WebRTC doit être Automatique, Sortie du proxy uniquement ou Bloqué."
},
"rail": {
"profiles": "Profils",
+18 -1
View File
@@ -1775,6 +1775,21 @@
"deleteTitle": "「{{label}}」の 2FA キーを削除しますか?",
"deleteDescription": "先にキーのコピーを別の場所に保管してください。保管しないとアカウントにアクセスできなくなる場合があります。"
},
"bulkEdit": {
"action": "選択項目を編集",
"title": "プロファイルを編集",
"description": "変更する設定にチェックを入れてください。それ以外は各プロファイルでそのままです。",
"tagsAdd": "タグを追加",
"tagsRemove": "タグを削除",
"tagsPlaceholder": "仕事, ショップ",
"notePlaceholder": "空欄にするとメモを削除します",
"newDevice": "次回起動時に新しいデバイス",
"newDeviceDescription": "各プロファイルは次回起動時に新しいデバイスになり、サイトは以前のデバイスと結び付けられなくなります。",
"apply_one": "{{count}} 件のプロファイルに適用",
"apply_other": "{{count}} 件のプロファイルに適用",
"applied_one": "{{count}} 件のプロファイルを変更しました",
"applied_other": "{{count}} 件のプロファイルを変更しました"
},
"vpns": {
"form": {
"titleEdit": "VPN を編集",
@@ -2514,7 +2529,9 @@
"totpKeyInvalid": "2FA キーではありません。サイトに表示されるシークレットキーまたは otpauth:// リンクを貼り付けてください。",
"totpLabelTooLong": "ラベルは 60 文字以内にしてください。",
"totpLimitReached": "1 つのプロファイルに保存できる 2FA キーは 50 個までです。",
"totpEntryNotFound": "この 2FA キーはもう存在しません。"
"totpEntryNotFound": "この 2FA キーはもう存在しません。",
"dnsBlocklistInvalid": "この DNS ブロックリストのレベルは存在しません。",
"webrtcModeInvalid": "WebRTC は「自動」「プロキシ出口のみ」「ブロック」のいずれかにしてください。"
},
"rail": {
"profiles": "プロファイル",
+18 -1
View File
@@ -1775,6 +1775,21 @@
"deleteTitle": "\"{{label}}\"의 2FA 키를 삭제할까요?",
"deleteDescription": "먼저 키 사본을 다른 곳에 보관하세요. 그렇지 않으면 계정에 접근하지 못할 수 있습니다."
},
"bulkEdit": {
"action": "선택 항목 편집",
"title": "프로필 편집",
"description": "변경할 설정을 체크하세요. 나머지는 각 프로필에서 그대로 유지됩니다.",
"tagsAdd": "태그 추가",
"tagsRemove": "태그 제거",
"tagsPlaceholder": "업무, 쇼핑",
"notePlaceholder": "비워 두면 메모를 제거합니다",
"newDevice": "다음 실행 시 새 기기",
"newDeviceDescription": "각 프로필은 다음 시작 시 새 기기를 받으므로 사이트가 이전 기기와 연결할 수 없습니다.",
"apply_one": "프로필 {{count}}개에 적용",
"apply_other": "프로필 {{count}}개에 적용",
"applied_one": "프로필 {{count}}개를 변경했습니다",
"applied_other": "프로필 {{count}}개를 변경했습니다"
},
"vpns": {
"form": {
"titleEdit": "VPN 편집",
@@ -2514,7 +2529,9 @@
"totpKeyInvalid": "2FA 키가 아닙니다. 사이트에 표시된 비밀 키나 otpauth:// 링크를 붙여 넣으세요.",
"totpLabelTooLong": "라벨은 최대 60자까지 가능합니다.",
"totpLimitReached": "프로필 하나에 2FA 키는 최대 50개까지 저장할 수 있습니다.",
"totpEntryNotFound": "이 2FA 키는 더 이상 존재하지 않습니다."
"totpEntryNotFound": "이 2FA 키는 더 이상 존재하지 않습니다.",
"dnsBlocklistInvalid": "이 DNS 차단 목록 수준은 존재하지 않습니다.",
"webrtcModeInvalid": "WebRTC는 자동, 프록시 출구만, 차단 중 하나여야 합니다."
},
"rail": {
"profiles": "프로필",
+20 -1
View File
@@ -1782,6 +1782,23 @@
"deleteTitle": "Excluir a chave 2FA de \"{{label}}\"?",
"deleteDescription": "Guarde antes uma cópia da chave em outro lugar, ou você pode perder o acesso à conta."
},
"bulkEdit": {
"action": "Editar seleção",
"title": "Editar perfis",
"description": "Marque as configurações que quer mudar. Todo o resto fica como está em cada perfil.",
"tagsAdd": "Adicionar tags",
"tagsRemove": "Remover tags",
"tagsPlaceholder": "trabalho, loja",
"notePlaceholder": "Deixe vazio para remover a nota",
"newDevice": "Novo dispositivo na próxima inicialização",
"newDeviceDescription": "Cada perfil recebe um novo dispositivo na próxima inicialização, para que os sites não o liguem ao anterior.",
"apply_one": "Aplicar a {{count}} perfil",
"apply_many": "Aplicar a {{count}} perfis",
"apply_other": "Aplicar a {{count}} perfis",
"applied_one": "{{count}} perfil alterado",
"applied_many": "{{count}} perfis alterados",
"applied_other": "{{count}} perfis alterados"
},
"vpns": {
"form": {
"titleEdit": "Editar VPN",
@@ -2525,7 +2542,9 @@
"totpKeyInvalid": "Isto não é uma chave 2FA. Cole a chave secreta ou o link otpauth:// que o site mostra.",
"totpLabelTooLong": "Um rótulo pode ter no máximo 60 caracteres.",
"totpLimitReached": "Um perfil pode guardar no máximo 50 chaves 2FA.",
"totpEntryNotFound": "Esta chave 2FA não existe mais."
"totpEntryNotFound": "Esta chave 2FA não existe mais.",
"dnsBlocklistInvalid": "Este nível de lista de bloqueio DNS não existe.",
"webrtcModeInvalid": "O WebRTC precisa ser Automático, Só saída do proxy ou Bloqueado."
},
"rail": {
"profiles": "Perfis",
+22 -1
View File
@@ -1789,6 +1789,25 @@
"deleteTitle": "Удалить ключ 2FA для «{{label}}»?",
"deleteDescription": "Сначала сохраните копию ключа в другом месте, иначе вы можете потерять доступ к аккаунту."
},
"bulkEdit": {
"action": "Изменить выбранные",
"title": "Изменить профили",
"description": "Отметьте настройки, которые нужно изменить. Всё остальное в каждом профиле останется как есть.",
"tagsAdd": "Добавить теги",
"tagsRemove": "Удалить теги",
"tagsPlaceholder": "работа, магазин",
"notePlaceholder": "Оставьте пустым, чтобы удалить заметку",
"newDevice": "Новое устройство при следующем запуске",
"newDeviceDescription": "Каждый профиль получит новое устройство при следующем запуске, и сайты не смогут связать его со старым.",
"apply_one": "Применить к {{count}} профилю",
"apply_few": "Применить к {{count}} профилям",
"apply_many": "Применить к {{count}} профилям",
"apply_other": "Применить к {{count}} профилям",
"applied_one": "Изменён {{count}} профиль",
"applied_few": "Изменено {{count}} профиля",
"applied_many": "Изменено {{count}} профилей",
"applied_other": "Изменено {{count}} профилей"
},
"vpns": {
"form": {
"titleEdit": "Изменить VPN",
@@ -2536,7 +2555,9 @@
"totpKeyInvalid": "Это не ключ 2FA. Вставьте секретный ключ или ссылку otpauth://, которую показывает сайт.",
"totpLabelTooLong": "Метка может содержать не более 60 символов.",
"totpLimitReached": "Профиль может хранить не более 50 ключей 2FA.",
"totpEntryNotFound": "Этого ключа 2FA больше нет."
"totpEntryNotFound": "Этого ключа 2FA больше нет.",
"dnsBlocklistInvalid": "Такого уровня списка блокировки DNS нет.",
"webrtcModeInvalid": "WebRTC должен быть «Автоматически», «Только выход прокси» или «Заблокирован»."
},
"rail": {
"profiles": "Профили",
+18 -1
View File
@@ -1775,6 +1775,21 @@
"deleteTitle": "\"{{label}}\" için 2FA anahtarı silinsin mi?",
"deleteDescription": "Önce anahtarın bir kopyasını başka bir yerde saklayın, yoksa hesaba erişimi kaybedebilirsiniz."
},
"bulkEdit": {
"action": "Seçilenleri düzenle",
"title": "Profilleri düzenle",
"description": "Değiştirilecek ayarları işaretleyin. Diğer her şey her profilde olduğu gibi kalır.",
"tagsAdd": "Etiket ekle",
"tagsRemove": "Etiket kaldır",
"tagsPlaceholder": "iş, mağaza",
"notePlaceholder": "Notu kaldırmak için boş bırakın",
"newDevice": "Sonraki başlatmada yeni cihaz",
"newDeviceDescription": "Her profil bir sonraki başlatmada yeni bir cihaz alır; böylece siteler onu eskisiyle ilişkilendiremez.",
"apply_one": "{{count}} profile uygula",
"apply_other": "{{count}} profile uygula",
"applied_one": "{{count}} profil değiştirildi",
"applied_other": "{{count}} profil değiştirildi"
},
"vpns": {
"form": {
"titleEdit": "VPN'i Düzenle",
@@ -2514,7 +2529,9 @@
"totpKeyInvalid": "Bu bir 2FA anahtarı değil. Sitenin gösterdiği gizli anahtarı veya otpauth:// bağlantısını yapıştırın.",
"totpLabelTooLong": "Etiket en fazla 60 karakter olabilir.",
"totpLimitReached": "Bir profil en fazla 50 2FA anahtarı saklayabilir.",
"totpEntryNotFound": "Bu 2FA anahtarı artık yok."
"totpEntryNotFound": "Bu 2FA anahtarı artık yok.",
"dnsBlocklistInvalid": "Bu DNS engel listesi düzeyi yok.",
"webrtcModeInvalid": "WebRTC Otomatik, Yalnızca proxy çıkışı veya Engelli olmalıdır."
},
"rail": {
"profiles": "Profiller",
+18 -1
View File
@@ -1775,6 +1775,21 @@
"deleteTitle": "Xóa khóa 2FA của \"{{label}}\"?",
"deleteDescription": "Hãy lưu một bản sao khóa ở nơi khác trước, nếu không bạn có thể mất quyền truy cập tài khoản."
},
"bulkEdit": {
"action": "Sửa các mục đã chọn",
"title": "Sửa profile",
"description": "Đánh dấu các cài đặt cần thay đổi. Mọi thứ khác giữ nguyên trên từng profile.",
"tagsAdd": "Thêm thẻ",
"tagsRemove": "Gỡ thẻ",
"tagsPlaceholder": "công việc, cửa hàng",
"notePlaceholder": "Để trống để xóa ghi chú",
"newDevice": "Thiết bị mới ở lần khởi chạy tiếp theo",
"newDeviceDescription": "Mỗi profile nhận thiết bị mới ở lần khởi động tiếp theo, nên các trang web không thể liên kết nó với thiết bị cũ.",
"apply_one": "Áp dụng cho {{count}} profile",
"apply_other": "Áp dụng cho {{count}} profile",
"applied_one": "Đã thay đổi {{count}} profile",
"applied_other": "Đã thay đổi {{count}} profile"
},
"vpns": {
"form": {
"titleEdit": "Chỉnh sửa VPN",
@@ -2514,7 +2529,9 @@
"totpKeyInvalid": "Đây không phải khóa 2FA. Hãy dán khóa bí mật hoặc liên kết otpauth:// mà trang web hiển thị.",
"totpLabelTooLong": "Nhãn tối đa 60 ký tự.",
"totpLimitReached": "Mỗi profile lưu tối đa 50 khóa 2FA.",
"totpEntryNotFound": "Khóa 2FA này không còn tồn tại."
"totpEntryNotFound": "Khóa 2FA này không còn tồn tại.",
"dnsBlocklistInvalid": "Mức danh sách chặn DNS này không tồn tại.",
"webrtcModeInvalid": "WebRTC phải là Tự động, Chỉ lối ra proxy hoặc Bị chặn."
},
"rail": {
"profiles": "Profile",
+18 -1
View File
@@ -1775,6 +1775,21 @@
"deleteTitle": "删除“{{label}}”的 2FA 密钥?",
"deleteDescription": "请先在其他地方保存密钥副本,否则可能无法再访问该账号。"
},
"bulkEdit": {
"action": "编辑所选项",
"title": "编辑配置文件",
"description": "勾选要更改的设置。其他设置在每个配置文件中保持不变。",
"tagsAdd": "添加标签",
"tagsRemove": "移除标签",
"tagsPlaceholder": "工作, 商店",
"notePlaceholder": "留空即可移除备注",
"newDevice": "下次启动时使用新设备",
"newDeviceDescription": "每个配置文件在下次启动时获得新设备,网站无法将其与旧设备关联。",
"apply_one": "应用到 {{count}} 个配置文件",
"apply_other": "应用到 {{count}} 个配置文件",
"applied_one": "已更改 {{count}} 个配置文件",
"applied_other": "已更改 {{count}} 个配置文件"
},
"vpns": {
"form": {
"titleEdit": "编辑 VPN",
@@ -2514,7 +2529,9 @@
"totpKeyInvalid": "这不是 2FA 密钥。请粘贴网站显示的密钥或 otpauth:// 链接。",
"totpLabelTooLong": "标签最多 60 个字符。",
"totpLimitReached": "一个配置文件最多保存 50 个 2FA 密钥。",
"totpEntryNotFound": "此 2FA 密钥已不存在。"
"totpEntryNotFound": "此 2FA 密钥已不存在。",
"dnsBlocklistInvalid": "该 DNS 拦截列表级别不存在。",
"webrtcModeInvalid": "WebRTC 必须是“自动”“仅代理出口”或“已阻止”。"
},
"rail": {
"profiles": "配置文件",
+6
View File
@@ -41,6 +41,8 @@ export type BackendErrorCode =
| "TOTP_LABEL_TOO_LONG"
| "TOTP_LIMIT_REACHED"
| "TOTP_ENTRY_NOT_FOUND"
| "DNS_BLOCKLIST_INVALID"
| "WEBRTC_MODE_INVALID"
| "COOKIE_DB_LOCKED"
| "COOKIE_DB_UNAVAILABLE"
| "COOKIE_IMPORT_BROWSER_RUNNING"
@@ -413,6 +415,10 @@ export function translateBackendError(t: TFunction, err: unknown): string {
return t("backendErrors.totpLimitReached");
case "TOTP_ENTRY_NOT_FOUND":
return t("backendErrors.totpEntryNotFound");
case "DNS_BLOCKLIST_INVALID":
return t("backendErrors.dnsBlocklistInvalid");
case "WEBRTC_MODE_INVALID":
return t("backendErrors.webrtcModeInvalid");
case "COOKIE_DB_LOCKED":
return t("backendErrors.cookieDbLocked");
case "COOKIE_DB_UNAVAILABLE":