use std::collections::{BTreeMap, HashMap};
use std::sync::LazyLock;
use anyhow::{anyhow, Result};
use serde::{Deserialize, Serialize};
pub const LAUNCH_RESTRICTIONS_SOURCE: &str = include_str!("launch-restrictions.json");
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct LaunchRestriction {
pub id: String,
pub description: String,
#[serde(default)]
pub args: Vec<String>,
#[serde(default)]
pub disable_features: Vec<String>,
#[serde(default)]
pub env: BTreeMap<String, String>,
}
#[derive(Debug, Deserialize)]
struct Catalogue {
restrictions: Vec<LaunchRestriction>,
presets: serde_json::Map<String, serde_json::Value>,
}
type Presets = Vec<(String, Vec<String>)>;
static CATALOGUE: LazyLock<(Vec<LaunchRestriction>, Presets)> = LazyLock::new(|| {
let catalogue: Catalogue = serde_json::from_str(LAUNCH_RESTRICTIONS_SOURCE)
.expect("launch-restrictions.json is embedded at compile time and has to parse");
let presets = catalogue
.presets
.into_iter()
.map(|(name, ids)| {
let ids = serde_json::from_value(ids)
.expect("launch restriction presets are arrays of restriction ids");
(name, ids)
})
.collect();
(catalogue.restrictions, presets)
});
pub fn launch_restrictions() -> &'static [LaunchRestriction] {
&CATALOGUE.0
}
pub fn launch_restriction_presets() -> &'static [(String, Vec<String>)] {
&CATALOGUE.1
}
pub fn launch_restriction_preset(name: &str) -> Option<&'static [String]> {
launch_restriction_presets()
.iter()
.find(|(preset, _)| preset == name)
.map(|(_, ids)| ids.as_slice())
}
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct ResolvedRestrictions {
pub ids: Vec<String>,
pub args: Vec<String>,
pub env: HashMap<String, String>,
}
fn expand<S: AsRef<str>>(names: &[S]) -> Vec<String> {
let mut ids: Vec<String> = Vec::new();
for name in names {
let name = name.as_ref();
let expanded = launch_restriction_preset(name)
.map(<[String]>::to_vec)
.unwrap_or_else(|| vec![name.to_owned()]);
for id in expanded {
if !ids.contains(&id) {
ids.push(id);
}
}
}
ids
}
pub fn resolve_restrictions<S: AsRef<str>>(names: &[S]) -> Result<ResolvedRestrictions> {
let ids = expand(names);
let mut args = Vec::new();
let mut disable_features = Vec::new();
let mut env = HashMap::new();
for id in &ids {
let Some(restriction) = launch_restrictions().iter().find(|entry| &entry.id == id) else {
let expected: Vec<&str> = launch_restrictions()
.iter()
.map(|entry| entry.id.as_str())
.chain(
launch_restriction_presets()
.iter()
.map(|(name, _)| name.as_str()),
)
.collect();
return Err(anyhow!(
"Unknown launch restriction \"{id}\". Expected one of {}",
expected.join(", ")
));
};
args.extend(restriction.args.iter().cloned());
disable_features.extend(restriction.disable_features.iter().cloned());
env.extend(
restriction
.env
.iter()
.map(|(key, value)| (key.clone(), value.clone())),
);
}
if !disable_features.is_empty() {
args.push(format!("--disable-features={}", disable_features.join(",")));
}
Ok(ResolvedRestrictions { ids, args, env })
}
const LIST_SWITCHES: &[&str] = &[
"--disable-features",
"--enable-features",
"--disable-blink-features",
"--enable-blink-features",
];
fn list_switch_of(argument: &str) -> Option<&'static str> {
LIST_SWITCHES.iter().copied().find(|prefix| {
argument
.strip_prefix(prefix)
.is_some_and(|rest| rest.starts_with('='))
})
}
pub fn merge_feature_switches<S: AsRef<str>>(args: &[S]) -> Vec<String> {
let mut values: HashMap<&'static str, Vec<String>> = HashMap::new();
for argument in args {
let argument = argument.as_ref();
if let Some(name) = list_switch_of(argument) {
let list = values.entry(name).or_default();
for feature in argument[name.len() + 1..].split(',') {
if !feature.is_empty() && !list.iter().any(|existing| existing == feature) {
list.push(feature.to_owned());
}
}
}
}
let mut emitted = Vec::new();
let mut merged = Vec::new();
for argument in args {
let argument = argument.as_ref();
match list_switch_of(argument) {
None => merged.push(argument.to_owned()),
Some(name) if !emitted.contains(&name) => {
emitted.push(name);
merged.push(format!("{name}={}", values[name].join(",")));
}
Some(_) => {}
}
}
merged
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn resolves_restrictions_and_presets() {
let resolved = resolve_restrictions(&["no-sync", "no-translate", "no-sync"]).unwrap();
assert_eq!(resolved.ids, ["no-sync", "no-translate"]);
assert_eq!(
resolved.args.last().unwrap(),
"--disable-features=Translate"
);
let legacy = resolve_restrictions(&["legacy-launch-browser"]).unwrap();
assert_eq!(
legacy.env.get("GOOGLE_API_KEY").map(String::as_str),
Some("no")
);
assert_eq!(
legacy
.env
.get("GOOGLE_DEFAULT_CLIENT_ID")
.map(String::as_str),
Some("no")
);
assert_eq!(
legacy
.env
.get("GOOGLE_DEFAULT_CLIENT_SECRET")
.map(String::as_str),
Some("no")
);
assert!(legacy.args.contains(&"--password-store=basic".to_owned()));
}
#[test]
fn legacy_defaults_match_the_chrome_args_constant() {
let legacy = resolve_restrictions(&["legacy-defaults"]).unwrap();
let mut expected: Vec<String> = crate::core::CHROME_ARGS
.iter()
.map(|argument| (*argument).to_owned())
.collect();
let mut actual = legacy.args;
expected.sort();
actual.sort();
assert_eq!(actual, expected);
}
#[test]
fn rejects_unknown_restrictions() {
let error = resolve_restrictions(&["no-such-thing"])
.unwrap_err()
.to_string();
assert!(
error.starts_with(
"Unknown launch restriction \"no-such-thing\". Expected one of no-first-run"
),
"{error}"
);
assert!(error.contains("legacy-defaults"), "{error}");
}
#[test]
fn merges_repeated_feature_switches() {
assert_eq!(
merge_feature_switches(&[
"--disable-features=A,B",
"--lang=en-US",
"--disable-features=B,,C",
"--enable-blink-features=X",
"--disable-featuresX=1",
]),
[
"--disable-features=A,B,C",
"--lang=en-US",
"--enable-blink-features=X",
"--disable-featuresX=1",
]
);
}
}