mirror of
https://github.com/zhom/donutbrowser.git
synced 2026-10-22 07:08:53 +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
@@ -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) => {
|
||||
|
||||
Reference in new issue
Block a user