mirror of
https://github.com/zhom/donutbrowser.git
synced 2026-10-19 05:57:31 +02:00
feat: bulk edit profiles
This commit is contained in:
1 parent
47664e6212
commit
3f5b573ead
25 files changed
+1389
-23
No files matched your search
@@ -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
@@ -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"), []);
|
||||
|
||||
@@ -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" });
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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)}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "プロファイル",
|
||||
|
||||
@@ -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": "프로필",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "Профили",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "配置文件",
|
||||
|
||||
@@ -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":
|
||||
|
||||
Reference in new issue
Block a user