diff --git a/e2e/coverage-map.mjs b/e2e/coverage-map.mjs index 6dd62540..1bb0529c 100644 --- a/e2e/coverage-map.mjs +++ b/e2e/coverage-map.mjs @@ -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", diff --git a/e2e/tests/entities.test.mjs b/e2e/tests/entities.test.mjs index 76879b4d..1c97c0f1 100644 --- a/e2e/tests/entities.test.mjs +++ b/e2e/tests/entities.test.mjs @@ -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"), []); diff --git a/e2e/tests/ui.test.mjs b/e2e/tests/ui.test.mjs index e13ba986..6bec9ae4 100644 --- a/e2e/tests/ui.test.mjs +++ b/e2e/tests/ui.test.mjs @@ -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" }); diff --git a/src-tauri/src/api_server.rs b/src-tauri/src/api_server.rs index 2bab43c0..6d40d0fc 100644 --- a/src-tauri/src/api_server.rs +++ b/src-tauri/src/api_server.rs @@ -240,6 +240,20 @@ struct UpdateGroupRequest { name: String, } +#[derive(Debug, Deserialize, ToSchema)] +struct BulkEditRequest { + profile_ids: Vec, + /// 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 { .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) -> Result, +) -> Result, (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, diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 8431ee74..baee5be4 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -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, diff --git a/src-tauri/src/mcp_server.rs b/src-tauri/src/mcp_server.rs index 0d5e4b06..377d8fa0 100644 --- a/src-tauri/src/mcp_server.rs +++ b/src-tauri/src/mcp_server.rs @@ -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 = 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" ); diff --git a/src-tauri/src/profile/manager.rs b/src-tauri/src/profile/manager.rs index c56289aa..6e76705e 100644 --- a/src-tauri/src/profile/manager.rs +++ b/src-tauri/src/profile/manager.rs @@ -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> { + 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, + patch: super::patch::ProfilePatch, +) -> Result { + 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, diff --git a/src-tauri/src/profile/mod.rs b/src-tauri/src/profile/mod.rs index 40dc0c22..5a141eaa 100644 --- a/src-tauri/src/profile/mod.rs +++ b/src-tauri/src/profile/mod.rs @@ -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; diff --git a/src-tauri/src/profile/patch.rs b/src-tauri/src/profile/patch.rs new file mode 100644 index 00000000..6c1ad191 --- /dev/null +++ b/src-tauri/src/profile/patch.rs @@ -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>, D::Error> +where + D: Deserializer<'de>, + T: Deserialize<'de>, +{ + Option::::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>, + #[serde(default)] + pub tags_add: Vec, + #[serde(default)] + pub tags_remove: Vec, + #[serde(default, deserialize_with = "present")] + pub note: Option>, + #[serde(default, deserialize_with = "present")] + pub group_id: Option>, + #[serde(default, deserialize_with = "present")] + pub extension_group_id: Option>, + #[serde(default, deserialize_with = "present")] + pub dns_blocklist: Option>, + #[serde(default, deserialize_with = "present")] + pub dns_server: Option>, + #[serde(default)] + pub proxy_bypass_rules: Option>, + #[serde(default)] + pub start_urls: Option>, + #[serde(default, deserialize_with = "present")] + pub launch_hook: Option>, + /// 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, + #[serde(default)] + pub randomize_fingerprint_on_launch: Option, + #[serde(default, deserialize_with = "present")] + pub webrtc_mode: Option>, + #[serde(default)] + pub block_images: Option, + #[serde(default)] + pub block_webgl: Option, + #[serde(default)] + pub restore_session: Option, + /// 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) -> Option { + 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 { + 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 = 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) -> Vec { + let mut out: Vec = 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::(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); + } +} diff --git a/src-tauri/src/totp.rs b/src-tauri/src/totp.rs index cdc291b3..7815de1a 100644 --- a/src-tauri/src/totp.rs +++ b/src-tauri/src/totp.rs @@ -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> { @@ -66,7 +69,9 @@ fn decode_base32(input: &str) -> Option> { 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 { @@ -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", diff --git a/src-tauri/src/wayfern_manager.rs b/src-tauri/src/wayfern_manager.rs index fdd7d9dd..4f30e908 100644 --- a/src-tauri/src/wayfern_manager.rs +++ b/src-tauri/src/wayfern_manager.rs @@ -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) => { diff --git a/src/components/bulk-edit-dialog.tsx b/src/components/bulk-edit-dialog.tsx new file mode 100644 index 00000000..ae616681 --- /dev/null +++ b/src/components/bulk-edit-dialog.tsx @@ -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 ( +
+ onToggle(field, checked === true)} + className="mt-0.5" + /> +
+ + {enabled && children} +
+
+ ); +} + +function OnOff({ + value, + onChange, +}: { + value: boolean; + onChange: (value: boolean) => void; +}) { + return ; +} + +export function BulkEditDialog({ + isOpen, + onClose, + profileIds, + statuses, +}: { + isOpen: boolean; + onClose: () => void; + profileIds: string[]; + statuses: ProfileStatus[]; +}) { + const { t } = useTranslation(); + const [enabled, setEnabled] = React.useState>(new Set()); + const [statusId, setStatusId] = React.useState(NONE); + const [tagsAdd, setTagsAdd] = React.useState(""); + const [tagsRemove, setTagsRemove] = React.useState(""); + const [note, setNote] = React.useState(""); + const [dnsBlocklist, setDnsBlocklist] = React.useState(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 = {}; + 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("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 ( + !open && onClose()}> + + + {t("bulkEdit.title")} + {t("bulkEdit.description")} + +
+ + + + + setTagsAdd(event.target.value)} + placeholder={t("bulkEdit.tagsPlaceholder")} + /> + + + setTagsRemove(event.target.value)} + placeholder={t("bulkEdit.tagsPlaceholder")} + /> + + +