feat: bulk edit profiles

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

No files matched your search

+44
View File
@@ -240,6 +240,20 @@ struct UpdateGroupRequest {
name: String,
}
#[derive(Debug, Deserialize, ToSchema)]
struct BulkEditRequest {
profile_ids: Vec<String>,
/// Fields left out stay as they are; `null` clears a field that can be
/// empty. `new_device: true` makes the next launch generate a new device.
patch: crate::profile::patch::ProfilePatch,
}
#[derive(Debug, Serialize, ToSchema)]
struct BulkEditResponse {
/// How many of the profiles actually changed.
changed: usize,
}
/// A profile status: a named, colored label for where a profile's account
/// stands.
#[derive(Debug, Serialize, Deserialize, ToSchema)]
@@ -730,6 +744,7 @@ struct ImportProxiesResponse {
create_status,
update_status,
delete_status,
bulk_edit_profiles,
get_tags,
get_proxies,
get_proxy,
@@ -778,6 +793,9 @@ struct ImportProxiesResponse {
UpdateGroupRequest,
ApiStatusResponse,
StatusRequest,
BulkEditRequest,
BulkEditResponse,
crate::profile::patch::ProfilePatch,
ApiProxyResponse,
crate::proxy_rotation::RotationResult,
CreateProxyRequest,
@@ -1068,6 +1086,7 @@ fn build_v1_router() -> Router<ApiServerState> {
.routes(routes!(get_group, update_group, delete_group))
.routes(routes!(get_statuses, create_status))
.routes(routes!(update_status, delete_status))
.routes(routes!(bulk_edit_profiles))
.routes(routes!(get_tags))
.routes(routes!(get_proxies, create_proxy))
.routes(routes!(import_proxies_api))
@@ -2266,6 +2285,31 @@ async fn delete_status(Path(id): Path<String>) -> Result<StatusCode, (StatusCode
.map_err(manager_error_response)
}
#[utoipa::path(
post,
path = "/v1/profiles/bulk-edit",
request_body = BulkEditRequest,
responses(
(status = 200, description = "Every profile was checked first, then the changed ones were saved", body = BulkEditResponse),
(status = 400, description = "A value is invalid (DNS_SERVER_INVALID, START_URL_INVALID, WEBRTC_MODE_INVALID, ...); nothing changed"),
(status = 401, description = "Unauthorized"),
(status = 404, description = "A profile, status, group or extension group does not exist; nothing changed"),
(status = 409, description = "A listed profile is running; nothing changed")
),
security(
("bearer_auth" = [])
),
tag = "profiles"
)]
async fn bulk_edit_profiles(
Json(request): Json<BulkEditRequest>,
) -> Result<Json<BulkEditResponse>, (StatusCode, String)> {
ProfileManager::instance()
.apply_patch(&request.profile_ids, request.patch)
.map(|changed| Json(BulkEditResponse { changed }))
.map_err(manager_error_response)
}
// API Handlers - Tags
#[utoipa::path(
get,
+3 -2
View File
@@ -169,8 +169,8 @@ use browser_runner::{
};
use profile::manager::{
check_browser_status, clone_profile, create_browser_profile_new, delete_profile,
list_browser_profiles, rename_profile, update_profile_clear_on_close,
bulk_update_profiles, check_browser_status, clone_profile, create_browser_profile_new,
delete_profile, list_browser_profiles, rename_profile, update_profile_clear_on_close,
update_profile_dns_blocklist, update_profile_dns_server, update_profile_launch_hook,
update_profile_note, update_profile_proxy, update_profile_proxy_bypass_rules,
update_profile_start_urls, update_profile_tags, update_profile_vpn, update_profile_window_color,
@@ -3336,6 +3336,7 @@ pub fn run_with_builder(
update_profile_dns_blocklist,
update_profile_dns_server,
update_profile_start_urls,
bulk_update_profiles,
profile_dns::describe_dns_server,
profile_statuses::list_profile_statuses,
profile_statuses::create_profile_status,
+54 -1
View File
@@ -2752,6 +2752,25 @@ impl McpServer {
"required": ["profile_id"]
}),
},
McpTool {
name: "bulk_update_profiles".to_string(),
description: "Change the same settings on many profiles at once. Every value is checked and every profile found before anything changes, and a running profile refuses the whole batch. Fields left out stay as they are; null clears a field that can be empty. Returns how many profiles changed.".to_string(),
input_schema: serde_json::json!({
"type": "object",
"properties": {
"profile_ids": {
"type": "array",
"items": { "type": "string" },
"description": "UUIDs of the profiles to change"
},
"patch": {
"type": "object",
"description": "The change. Keys: status_id, tags_add, tags_remove, note, group_id, extension_group_id, dns_blocklist, dns_server, proxy_bypass_rules, start_urls, launch_hook, clear_on_close, randomize_fingerprint_on_launch, webrtc_mode (auto|tcp_only|block), block_images, block_webgl, restore_session, new_device (true makes the next launch generate a new device)."
}
},
"required": ["profile_ids", "patch"]
}),
},
McpTool {
name: "list_profile_statuses".to_string(),
description: "List the profile statuses (named, colored labels such as Active or Banned) in their order. Set one on a profile with update_profile's status_id.".to_string(),
@@ -4568,6 +4587,40 @@ impl McpServer {
"get_profile_status" => self.handle_get_profile_status(arguments).await,
// Group management
"list_groups" => self.handle_list_groups().await,
"bulk_update_profiles" => {
let profile_ids: Vec<String> = arguments
.get("profile_ids")
.and_then(|v| serde_json::from_value(v.clone()).ok())
.ok_or_else(|| McpError {
code: -32602,
message: "Missing profile_ids".to_string(),
data: None,
})?;
let patch: crate::profile::patch::ProfilePatch = arguments
.get("patch")
.cloned()
.map(serde_json::from_value)
.transpose()
.map_err(|e| McpError {
code: -32602,
message: format!("Invalid patch: {e}"),
data: None,
})?
.unwrap_or_default();
let changed = ProfileManager::instance()
.apply_patch(&profile_ids, patch)
.map_err(|e| McpError {
code: -32000,
message: format!("Bulk edit failed: {e}"),
data: None,
})?;
Ok(serde_json::json!({
"content": [{
"type": "text",
"text": format!("{changed} of {} profiles changed", profile_ids.len())
}]
}))
}
"list_profile_statuses" => Ok(serde_json::json!({
"content": [{
"type": "text",
@@ -9972,7 +10025,7 @@ mod tests {
// tool was added or removed on purpose.
assert_eq!(
tools.len(),
95,
96,
"the tool list is a published contract; update this number deliberately"
);
+76
View File
@@ -1486,6 +1486,72 @@ impl ProfileManager {
Ok(profile)
}
/// Change many profiles at once. Every value is checked and every profile
/// found before any of them changes, and a running profile refuses the
/// whole batch: its browser would keep the old settings and the next save
/// from the running session could undo the edit. Returns how many changed.
pub fn apply_patch(
&self,
profile_ids: &[String],
patch: super::patch::ProfilePatch,
) -> Result<usize, Box<dyn std::error::Error>> {
let patch = patch.validated()?;
let mut profiles = Vec::with_capacity(profile_ids.len());
for profile_id in profile_ids {
let profile = self.find_profile(profile_id)?;
if crate::profile::trash::is_running_locally(&profile) {
return Err(crate::backend_error("PROFILE_RUNNING").into());
}
profiles.push(profile);
}
let mut changed = 0;
let mut any_synced = false;
for mut profile in profiles {
if !patch.apply_to(&mut profile) {
continue;
}
profile.updated_at = Some(crate::proxy_manager::now_secs());
self.save_profile(&profile)?;
crate::sync::queue_profile_sync_if_eligible(&profile);
any_synced |= profile.is_sync_enabled();
changed += 1;
}
if any_synced {
Self::sync_patched_groups(&patch);
}
if let Err(e) = events::emit_empty("profiles-changed") {
log::warn!("Event emit failed event=profiles-changed err=\"{e}\"");
}
log::info!(
"Profiles edited in bulk selected={} changed={changed}",
profile_ids.len()
);
Ok(changed)
}
/// A synced profile needs the groups a patch points it at in the cloud too.
fn sync_patched_groups(patch: &super::patch::ProfilePatch) {
let (group_id, extension_group_id) = patch.group_ids();
if let Some(group_id) = group_id.map(str::to_string) {
tauri::async_runtime::spawn(async move {
let _ = crate::sync::enable_group_sync_if_needed(&group_id).await;
if let Some(scheduler) = crate::sync::get_global_scheduler() {
scheduler.queue_group_sync(group_id).await;
}
});
}
if let Some(group_id) = extension_group_id.map(str::to_string) {
tauri::async_runtime::spawn(async move {
let _ = crate::sync::enable_extension_group_sync_if_needed(&group_id).await;
if let Some(scheduler) = crate::sync::get_global_scheduler() {
scheduler.queue_extension_group_sync(group_id).await;
}
});
}
}
/// Give the listed profiles one status, or none. A running profile can
/// take a status: it is a label, not a launch setting.
pub fn set_profiles_status(
@@ -2772,6 +2838,16 @@ pub fn update_profile_dns_blocklist(
.map_err(|e| format!("Failed to update DNS blocklist: {e}"))
}
#[tauri::command]
pub fn bulk_update_profiles(
profile_ids: Vec<String>,
patch: super::patch::ProfilePatch,
) -> Result<usize, String> {
ProfileManager::instance()
.apply_patch(&profile_ids, patch)
.map_err(|e| crate::wrap_backend_error(e, "Failed to edit profiles"))
}
#[tauri::command]
pub fn update_profile_start_urls(
profile_id: String,
+1
View File
@@ -2,6 +2,7 @@ pub mod clear_on_close;
pub mod encryption;
pub mod manager;
pub mod password;
pub mod patch;
pub mod portable;
pub mod trash;
pub mod types;
+371
View File
@@ -0,0 +1,371 @@
//! A change to many profiles at once: what bulk edit sends, and what a
//! template adds to each profile made from it.
//!
//! A field left out stays as it is. For the fields that can be empty, `null`
//! clears them. Every value is checked once, before any profile changes, so a
//! bad value changes nothing.
use super::types::BrowserProfile;
use serde::{Deserialize, Deserializer, Serialize};
/// Tells "sent as null" (clear) apart from "left out" (keep).
fn present<'de, D, T>(deserializer: D) -> Result<Option<Option<T>>, D::Error>
where
D: Deserializer<'de>,
T: Deserialize<'de>,
{
Option::<T>::deserialize(deserializer).map(Some)
}
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, utoipa::ToSchema)]
#[serde(deny_unknown_fields)]
pub struct ProfilePatch {
#[serde(default, deserialize_with = "present")]
pub status_id: Option<Option<String>>,
#[serde(default)]
pub tags_add: Vec<String>,
#[serde(default)]
pub tags_remove: Vec<String>,
#[serde(default, deserialize_with = "present")]
pub note: Option<Option<String>>,
#[serde(default, deserialize_with = "present")]
pub group_id: Option<Option<String>>,
#[serde(default, deserialize_with = "present")]
pub extension_group_id: Option<Option<String>>,
#[serde(default, deserialize_with = "present")]
pub dns_blocklist: Option<Option<String>>,
#[serde(default, deserialize_with = "present")]
pub dns_server: Option<Option<String>>,
#[serde(default)]
pub proxy_bypass_rules: Option<Vec<String>>,
#[serde(default)]
pub start_urls: Option<Vec<String>>,
#[serde(default, deserialize_with = "present")]
pub launch_hook: Option<Option<String>>,
/// Left off ephemeral and password-protected profiles, where it means
/// nothing: they never keep browsing data in the clear.
#[serde(default)]
pub clear_on_close: Option<bool>,
#[serde(default)]
pub randomize_fingerprint_on_launch: Option<bool>,
#[serde(default, deserialize_with = "present")]
pub webrtc_mode: Option<Option<String>>,
#[serde(default)]
pub block_images: Option<bool>,
#[serde(default)]
pub block_webgl: Option<bool>,
#[serde(default)]
pub restore_session: Option<bool>,
/// Drop the stored device, so the next launch generates a new one.
#[serde(default)]
pub new_device: bool,
}
fn code(code: &str) -> String {
crate::backend_error(code)
}
fn trimmed(value: Option<String>) -> Option<String> {
value
.map(|v| v.trim().to_string())
.filter(|v| !v.is_empty())
}
impl ProfilePatch {
/// The patch with every value checked and in its stored form.
pub fn validated(mut self) -> Result<Self, String> {
if let Some(status_id) = self.status_id.take() {
let status_id = trimmed(status_id);
if let Some(id) = &status_id {
if !crate::profile_statuses::exists(id) {
return Err(code("PROFILE_STATUS_NOT_FOUND"));
}
}
self.status_id = Some(status_id);
}
if let Some(group_id) = self.group_id.take() {
let group_id = trimmed(group_id);
if let Some(id) = &group_id {
let exists = crate::group_manager::GROUP_MANAGER
.lock()
.map_err(|_| code("INTERNAL_ERROR"))?
.get_all_groups()
.map_err(|e| crate::wrap_backend_error(e, "Failed to read groups"))?
.iter()
.any(|group| &group.id == id);
if !exists {
return Err(code("GROUP_NOT_FOUND"));
}
}
self.group_id = Some(group_id);
}
if let Some(extension_group_id) = self.extension_group_id.take() {
let extension_group_id = trimmed(extension_group_id);
if let Some(id) = &extension_group_id {
crate::extension_manager::EXTENSION_MANAGER
.lock()
.map_err(|_| code("INTERNAL_ERROR"))?
.get_group(id)
.map_err(|_| code("EXTENSION_GROUP_NOT_FOUND"))?;
}
self.extension_group_id = Some(extension_group_id);
}
if let Some(level) = self.dns_blocklist.take() {
let level = trimmed(level).filter(|level| level != "none");
if let Some(level) = &level {
if crate::dns_blocklist::BlocklistLevel::parse_level(level).is_none() {
return Err(code("DNS_BLOCKLIST_INVALID"));
}
}
self.dns_blocklist = Some(level);
}
if let Some(dns_server) = self.dns_server.take() {
self.dns_server = Some(crate::profile_dns::normalize_dns_server(dns_server)?);
}
if let Some(start_urls) = self.start_urls.take() {
self.start_urls = Some(super::manager::checked_start_urls(start_urls)?);
}
if let Some(hook) = self.launch_hook.take() {
self.launch_hook = Some(super::manager::validate_launch_hook(hook.as_deref())?);
}
if let Some(mode) = self.webrtc_mode.take() {
let mode = match trimmed(mode) {
None => None,
Some(mode) => Some(
crate::wayfern_manager::WebRtcMode::parse(&mode)
.ok_or_else(|| code("WEBRTC_MODE_INVALID"))?
.switch_value()
.to_string(),
),
};
self.webrtc_mode = Some(mode);
}
if let Some(rules) = self.proxy_bypass_rules.take() {
self.proxy_bypass_rules = Some(
rules
.into_iter()
.map(|rule| rule.trim().to_string())
.filter(|rule| !rule.is_empty())
.collect(),
);
}
self.tags_add = tidy_tags(std::mem::take(&mut self.tags_add));
self.tags_remove = tidy_tags(std::mem::take(&mut self.tags_remove));
Ok(self)
}
/// Whether the patch changes the group or extension group, which carry
/// sync side effects beyond the profile itself.
pub fn group_ids(&self) -> (Option<&str>, Option<&str>) {
(
self.group_id.as_ref().and_then(|g| g.as_deref()),
self.extension_group_id.as_ref().and_then(|g| g.as_deref()),
)
}
/// Change one profile. Returns whether anything changed. Only called on a
/// patch that `validated` returned.
pub fn apply_to(&self, profile: &mut BrowserProfile) -> bool {
let before = serde_json::to_value(&*profile).ok();
if let Some(status_id) = &self.status_id {
profile.status_id = status_id.clone();
}
if !self.tags_add.is_empty() || !self.tags_remove.is_empty() {
let mut tags: Vec<String> = profile
.tags
.iter()
.filter(|tag| !self.tags_remove.contains(tag))
.cloned()
.collect();
for tag in &self.tags_add {
if !tags.contains(tag) {
tags.push(tag.clone());
}
}
profile.tags = tags;
}
if let Some(note) = &self.note {
profile.note = trimmed(note.clone());
}
if let Some(group_id) = &self.group_id {
profile.group_id = group_id.clone();
}
if let Some(extension_group_id) = &self.extension_group_id {
profile.extension_group_id = extension_group_id.clone();
}
if let Some(level) = &self.dns_blocklist {
profile.dns_blocklist = level.clone();
}
if let Some(dns_server) = &self.dns_server {
profile.dns_server = dns_server.clone();
}
if let Some(rules) = &self.proxy_bypass_rules {
profile.proxy_bypass_rules = rules.clone();
}
if let Some(start_urls) = &self.start_urls {
profile.start_urls = start_urls.clone();
}
if let Some(hook) = &self.launch_hook {
profile.launch_hook = hook.clone();
}
if let Some(clear) = self.clear_on_close {
profile.clear_on_close = clear && !profile.ephemeral && !profile.password_protected;
}
let touches_device = self.randomize_fingerprint_on_launch.is_some()
|| self.webrtc_mode.is_some()
|| self.block_images.is_some()
|| self.block_webgl.is_some()
|| self.restore_session.is_some()
|| self.new_device;
if touches_device {
let config = profile.wayfern_config.get_or_insert_with(Default::default);
if let Some(randomize) = self.randomize_fingerprint_on_launch {
config.randomize_fingerprint_on_launch = Some(randomize);
}
if let Some(mode) = &self.webrtc_mode {
config.webrtc_mode = mode.clone();
// The legacy switch would otherwise still read as `block`.
config.block_webrtc = None;
}
if let Some(block) = self.block_images {
config.block_images = Some(block);
}
if let Some(block) = self.block_webgl {
config.block_webgl = Some(block);
}
if let Some(restore) = self.restore_session {
config.restore_session = Some(restore);
}
if self.new_device {
config.fingerprint = None;
config.identity_id = None;
config.identity_baseline = None;
config.identity_overrides = None;
}
}
before != serde_json::to_value(&*profile).ok()
}
}
fn tidy_tags(tags: Vec<String>) -> Vec<String> {
let mut out: Vec<String> = Vec::new();
for tag in tags {
let tag = tag.trim().to_string();
if !tag.is_empty() && !out.contains(&tag) {
out.push(tag);
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
fn patch(json: serde_json::Value) -> ProfilePatch {
serde_json::from_value(json).unwrap()
}
#[test]
fn null_clears_and_absence_keeps() {
assert_eq!(patch(serde_json::json!({})).status_id, None);
assert_eq!(
patch(serde_json::json!({ "status_id": null })).status_id,
Some(None)
);
assert_eq!(
patch(serde_json::json!({ "status_id": "s1" })).status_id,
Some(Some("s1".to_string()))
);
assert!(serde_json::from_value::<ProfilePatch>(serde_json::json!({ "name": "x" })).is_err());
}
#[test]
fn a_patch_changes_only_what_it_names() {
let mut profile = BrowserProfile {
tags: vec!["keep".into(), "drop".into()],
note: Some("old".into()),
dns_blocklist: Some("light".into()),
..Default::default()
};
let change = ProfilePatch {
tags_add: vec!["new".into(), "keep".into()],
tags_remove: vec!["drop".into()],
note: Some(None),
webrtc_mode: Some(Some("block".into())),
..Default::default()
};
assert!(change.apply_to(&mut profile));
assert_eq!(profile.tags, ["keep", "new"]);
assert_eq!(profile.note, None);
assert_eq!(
profile.dns_blocklist.as_deref(),
Some("light"),
"not named, so kept"
);
assert_eq!(
profile
.wayfern_config
.as_ref()
.unwrap()
.webrtc_mode
.as_deref(),
Some("block")
);
assert!(
!change.apply_to(&mut profile),
"applying twice changes nothing"
);
}
#[test]
fn a_new_device_drops_the_stored_identity_and_nothing_else() {
let mut profile = BrowserProfile {
wayfern_config: Some(crate::wayfern_manager::WayfernConfig {
identity_id: Some("id".into()),
identity_overrides: Some("{}".into()),
location: Some("{\"timezone\":\"Europe/Berlin\"}".into()),
os: Some("windows".into()),
..Default::default()
}),
..Default::default()
};
assert!(ProfilePatch {
new_device: true,
..Default::default()
}
.apply_to(&mut profile));
let config = profile.wayfern_config.unwrap();
assert_eq!(config.identity_id, None);
assert_eq!(config.identity_overrides, None);
assert_eq!(
config.os.as_deref(),
Some("windows"),
"the claimed OS stays"
);
assert!(
config.location.is_some(),
"the location belongs to the route"
);
}
#[test]
fn clear_on_close_skips_profiles_where_it_means_nothing() {
let change = ProfilePatch {
clear_on_close: Some(true),
..Default::default()
};
let mut ephemeral = BrowserProfile {
ephemeral: true,
..Default::default()
};
change.apply_to(&mut ephemeral);
assert!(!ephemeral.clear_on_close);
let mut plain = BrowserProfile::default();
change.apply_to(&mut plain);
assert!(plain.clear_on_close);
}
}
+8 -1
View File
@@ -43,6 +43,9 @@ pub struct TotpCode {
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct InvalidTotpKey;
/// 80 bits: authenticator apps refuse shorter keys, and a word or a typo
/// would otherwise read as a valid secret and give wrong codes.
const MIN_SECRET_BYTES: usize = 10;
const MAX_SECRET_BYTES: usize = 128;
fn decode_base32(input: &str) -> Option<Vec<u8>> {
@@ -66,7 +69,9 @@ fn decode_base32(input: &str) -> Option<Vec<u8>> {
bits &= (1 << bit_count) - 1;
}
}
(!out.is_empty() && out.len() <= MAX_SECRET_BYTES).then_some(out)
(MIN_SECRET_BYTES..=MAX_SECRET_BYTES)
.contains(&out.len())
.then_some(out)
}
fn normalized_secret(raw: &str) -> Option<String> {
@@ -291,6 +296,8 @@ mod tests {
"",
" ",
"not base32!",
"not a key",
"JBSWY3DP",
"otpauth://hotp/x?secret=JBSWY3DPEHPK3PXP&counter=1",
"otpauth://totp/x",
"otpauth://totp/x?secret=JBSWY3DPEHPK3PXP&digits=4",
+7 -7
View File
@@ -441,15 +441,16 @@ pub fn session_restore_verdict(
ephemeral: bool,
clear_on_close: bool,
identity_at_launch: bool,
opens_start_pages: bool,
has_start_pages: bool,
) -> Result<(), &'static str> {
if kind == LaunchKind::Automation {
return Err("an automation run starts clean");
}
// As in Chrome: a set of start pages replaces the last session. Restoring
// both would add the start pages to the restored tabs on every launch.
if opens_start_pages {
return Err("the profile opens its start pages instead");
// As in Chrome: a set of start pages replaces the last session, also on a
// launch that opens a given URL. Restoring would add the start pages to
// the restored tabs on every launch, or bring them back with the URL.
if has_start_pages {
return Err("the profile starts on its start pages, not its last session");
}
if headless {
return Err("a headless launch has no session to show");
@@ -2923,7 +2924,6 @@ impl WayfernManager {
let identity_at_launch =
identity_file.is_some() || (config.identity_id.is_none() && config.fingerprint.is_none());
let pages = launch_pages(url, &profile.start_urls, kind);
let opens_start_pages = url.is_none() && !pages.is_empty();
let restore_session = match session_restore_verdict(
config,
kind,
@@ -2931,7 +2931,7 @@ impl WayfernManager {
ephemeral,
profile.clear_on_close,
identity_at_launch,
opens_start_pages,
!profile.start_urls.is_empty(),
) {
Ok(()) => true,
Err(reason) => {