browser_commander/browser/
restrictions.rs1use std::collections::{BTreeMap, HashMap};
22use std::sync::LazyLock;
23
24use anyhow::{anyhow, Result};
25use serde::{Deserialize, Serialize};
26
27pub const LAUNCH_RESTRICTIONS_SOURCE: &str = include_str!("launch-restrictions.json");
29
30#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
32#[serde(rename_all = "camelCase")]
33pub struct LaunchRestriction {
34 pub id: String,
36 pub description: String,
38 #[serde(default)]
40 pub args: Vec<String>,
41 #[serde(default)]
43 pub disable_features: Vec<String>,
44 #[serde(default)]
46 pub env: BTreeMap<String, String>,
47}
48
49#[derive(Debug, Deserialize)]
50struct Catalogue {
51 restrictions: Vec<LaunchRestriction>,
52 presets: serde_json::Map<String, serde_json::Value>,
53}
54
55type Presets = Vec<(String, Vec<String>)>;
57
58static CATALOGUE: LazyLock<(Vec<LaunchRestriction>, Presets)> = LazyLock::new(|| {
59 let catalogue: Catalogue = serde_json::from_str(LAUNCH_RESTRICTIONS_SOURCE)
60 .expect("launch-restrictions.json is embedded at compile time and has to parse");
61 let presets = catalogue
65 .presets
66 .into_iter()
67 .map(|(name, ids)| {
68 let ids = serde_json::from_value(ids)
69 .expect("launch restriction presets are arrays of restriction ids");
70 (name, ids)
71 })
72 .collect();
73 (catalogue.restrictions, presets)
74});
75
76pub fn launch_restrictions() -> &'static [LaunchRestriction] {
78 &CATALOGUE.0
79}
80
81pub fn launch_restriction_presets() -> &'static [(String, Vec<String>)] {
84 &CATALOGUE.1
85}
86
87pub fn launch_restriction_preset(name: &str) -> Option<&'static [String]> {
89 launch_restriction_presets()
90 .iter()
91 .find(|(preset, _)| preset == name)
92 .map(|(_, ids)| ids.as_slice())
93}
94
95#[derive(Debug, Clone, PartialEq, Eq, Default)]
97pub struct ResolvedRestrictions {
98 pub ids: Vec<String>,
100 pub args: Vec<String>,
103 pub env: HashMap<String, String>,
105}
106
107fn expand<S: AsRef<str>>(names: &[S]) -> Vec<String> {
108 let mut ids: Vec<String> = Vec::new();
109 for name in names {
110 let name = name.as_ref();
111 let expanded = launch_restriction_preset(name)
112 .map(<[String]>::to_vec)
113 .unwrap_or_else(|| vec![name.to_owned()]);
114 for id in expanded {
115 if !ids.contains(&id) {
116 ids.push(id);
117 }
118 }
119 }
120 ids
121}
122
123pub fn resolve_restrictions<S: AsRef<str>>(names: &[S]) -> Result<ResolvedRestrictions> {
127 let ids = expand(names);
128 let mut args = Vec::new();
129 let mut disable_features = Vec::new();
130 let mut env = HashMap::new();
131 for id in &ids {
132 let Some(restriction) = launch_restrictions().iter().find(|entry| &entry.id == id) else {
133 let expected: Vec<&str> = launch_restrictions()
134 .iter()
135 .map(|entry| entry.id.as_str())
136 .chain(
137 launch_restriction_presets()
138 .iter()
139 .map(|(name, _)| name.as_str()),
140 )
141 .collect();
142 return Err(anyhow!(
143 "Unknown launch restriction \"{id}\". Expected one of {}",
144 expected.join(", ")
145 ));
146 };
147 args.extend(restriction.args.iter().cloned());
148 disable_features.extend(restriction.disable_features.iter().cloned());
149 env.extend(
150 restriction
151 .env
152 .iter()
153 .map(|(key, value)| (key.clone(), value.clone())),
154 );
155 }
156 if !disable_features.is_empty() {
157 args.push(format!("--disable-features={}", disable_features.join(",")));
158 }
159 Ok(ResolvedRestrictions { ids, args, env })
160}
161
162const LIST_SWITCHES: &[&str] = &[
163 "--disable-features",
164 "--enable-features",
165 "--disable-blink-features",
166 "--enable-blink-features",
167];
168
169fn list_switch_of(argument: &str) -> Option<&'static str> {
170 LIST_SWITCHES.iter().copied().find(|prefix| {
171 argument
172 .strip_prefix(prefix)
173 .is_some_and(|rest| rest.starts_with('='))
174 })
175}
176
177pub fn merge_feature_switches<S: AsRef<str>>(args: &[S]) -> Vec<String> {
183 let mut values: HashMap<&'static str, Vec<String>> = HashMap::new();
184 for argument in args {
185 let argument = argument.as_ref();
186 if let Some(name) = list_switch_of(argument) {
187 let list = values.entry(name).or_default();
188 for feature in argument[name.len() + 1..].split(',') {
189 if !feature.is_empty() && !list.iter().any(|existing| existing == feature) {
190 list.push(feature.to_owned());
191 }
192 }
193 }
194 }
195 let mut emitted = Vec::new();
196 let mut merged = Vec::new();
197 for argument in args {
198 let argument = argument.as_ref();
199 match list_switch_of(argument) {
200 None => merged.push(argument.to_owned()),
201 Some(name) if !emitted.contains(&name) => {
202 emitted.push(name);
203 merged.push(format!("{name}={}", values[name].join(",")));
204 }
205 Some(_) => {}
206 }
207 }
208 merged
209}
210
211#[cfg(test)]
212mod tests {
213 use super::*;
214
215 #[test]
216 fn resolves_restrictions_and_presets() {
217 let resolved = resolve_restrictions(&["no-sync", "no-translate", "no-sync"]).unwrap();
218 assert_eq!(resolved.ids, ["no-sync", "no-translate"]);
219 assert_eq!(
220 resolved.args.last().unwrap(),
221 "--disable-features=Translate"
222 );
223
224 let legacy = resolve_restrictions(&["legacy-launch-browser"]).unwrap();
225 assert_eq!(
226 legacy.env.get("GOOGLE_API_KEY").map(String::as_str),
227 Some("no")
228 );
229 assert_eq!(
230 legacy
231 .env
232 .get("GOOGLE_DEFAULT_CLIENT_ID")
233 .map(String::as_str),
234 Some("no")
235 );
236 assert_eq!(
237 legacy
238 .env
239 .get("GOOGLE_DEFAULT_CLIENT_SECRET")
240 .map(String::as_str),
241 Some("no")
242 );
243 assert!(legacy.args.contains(&"--password-store=basic".to_owned()));
244 }
245
246 #[test]
247 fn legacy_defaults_match_the_chrome_args_constant() {
248 let legacy = resolve_restrictions(&["legacy-defaults"]).unwrap();
249 let mut expected: Vec<String> = crate::core::CHROME_ARGS
250 .iter()
251 .map(|argument| (*argument).to_owned())
252 .collect();
253 let mut actual = legacy.args;
254 expected.sort();
255 actual.sort();
256 assert_eq!(actual, expected);
257 }
258
259 #[test]
260 fn rejects_unknown_restrictions() {
261 let error = resolve_restrictions(&["no-such-thing"])
262 .unwrap_err()
263 .to_string();
264 assert!(
265 error.starts_with(
266 "Unknown launch restriction \"no-such-thing\". Expected one of no-first-run"
267 ),
268 "{error}"
269 );
270 assert!(error.contains("legacy-defaults"), "{error}");
271 }
272
273 #[test]
274 fn merges_repeated_feature_switches() {
275 assert_eq!(
276 merge_feature_switches(&[
277 "--disable-features=A,B",
278 "--lang=en-US",
279 "--disable-features=B,,C",
280 "--enable-blink-features=X",
281 "--disable-featuresX=1",
282 ]),
283 [
284 "--disable-features=A,B,C",
285 "--lang=en-US",
286 "--enable-blink-features=X",
287 "--disable-featuresX=1",
288 ]
289 );
290 }
291}