use std::collections::HashMap;
use std::fs;
use std::path::{Path, PathBuf};
use serde::Deserialize;
#[derive(Debug, Deserialize)]
struct LocalState {
profile: ProfileSection,
}
#[derive(Debug, Deserialize)]
struct ProfileSection {
#[serde(default)]
info_cache: HashMap<String, ProfileInfo>,
}
#[derive(Debug, Deserialize)]
struct ProfileInfo {
#[serde(default)]
user_name: Option<String>,
}
#[derive(Debug, PartialEq, Eq)]
enum ProfileMatch {
Found(String),
NotFound,
Ambiguous(Vec<String>),
}
fn match_profiles(local_state_json: &str, email: &str) -> serde_json::Result<ProfileMatch> {
let state: LocalState = serde_json::from_str(local_state_json)?;
let email = email.trim();
let mut matches: Vec<String> = state
.profile
.info_cache
.into_iter()
.filter(|(_, info)| {
info.user_name
.as_deref()
.is_some_and(|user_name| user_name.trim().eq_ignore_ascii_case(email))
})
.map(|(dir, _)| dir)
.collect();
matches.sort();
Ok(match matches.len() {
0 => ProfileMatch::NotFound,
1 => ProfileMatch::Found(matches.remove(0)),
_ => ProfileMatch::Ambiguous(matches),
})
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum ChromeOs {
Macos,
Linux,
Windows,
}
fn current_chrome_os() -> ChromeOs {
if cfg!(target_os = "macos") {
ChromeOs::Macos
} else if cfg!(target_os = "windows") {
ChromeOs::Windows
} else {
ChromeOs::Linux
}
}
fn build_launch_command_for(os: ChromeOs, profile_dir: &str) -> Vec<String> {
let profile_arg = format!("--profile-directory={profile_dir}");
match os {
ChromeOs::Macos => vec![
"open".to_string(),
"-na".to_string(),
"Google Chrome".to_string(),
"--args".to_string(),
profile_arg,
"{url}".to_string(),
],
ChromeOs::Linux => vec![
"google-chrome".to_string(),
profile_arg,
"{url}".to_string(),
],
ChromeOs::Windows => vec!["chrome".to_string(), profile_arg, "{url}".to_string()],
}
}
fn build_launch_command(profile_dir: &str) -> Vec<String> {
build_launch_command_for(current_chrome_os(), profile_dir)
}
fn default_local_state_path_for(os: ChromeOs) -> Option<PathBuf> {
match os {
ChromeOs::Macos => {
dirs::config_dir().map(|dir| dir.join("Google").join("Chrome").join("Local State"))
}
ChromeOs::Linux => {
dirs::config_dir().map(|dir| dir.join("google-chrome").join("Local State"))
}
ChromeOs::Windows => dirs::data_local_dir().map(|dir| {
dir.join("Google")
.join("Chrome")
.join("User Data")
.join("Local State")
}),
}
}
fn default_local_state_path() -> Option<PathBuf> {
default_local_state_path_for(current_chrome_os())
}
fn resolve_launch_command_at(email: &str, local_state_path: &Path) -> Option<Vec<String>> {
let content = match fs::read_to_string(local_state_path) {
Ok(content) => content,
Err(err) => {
tracing::info!(
"Chrome profile auto-resolution: could not read {} ({err}); \
falling back to the default browser",
local_state_path.display()
);
return None;
}
};
match match_profiles(&content, email) {
Ok(ProfileMatch::Found(profile_dir)) => Some(build_launch_command(&profile_dir)),
Ok(ProfileMatch::NotFound) => {
tracing::info!(
"Chrome profile auto-resolution: no installed Chrome profile is signed \
into {email}; falling back to the default browser"
);
None
}
Ok(ProfileMatch::Ambiguous(profile_dirs)) => {
tracing::info!(
"Chrome profile auto-resolution: multiple Chrome profiles are signed \
into {email} ({}); falling back to the default browser",
profile_dirs.join(", ")
);
None
}
Err(err) => {
tracing::info!(
"Chrome profile auto-resolution: could not parse {} ({err}); \
falling back to the default browser",
local_state_path.display()
);
None
}
}
}
pub(crate) fn resolve_launch_command(email: &str) -> Option<Vec<String>> {
let local_state_path = default_local_state_path()?;
resolve_launch_command_at(email, &local_state_path)
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod tests {
use super::*;
#[derive(Clone, Default)]
struct CaptureWriter(std::sync::Arc<std::sync::Mutex<Vec<u8>>>);
impl std::io::Write for CaptureWriter {
fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
self.0.lock().unwrap().extend_from_slice(buf);
Ok(buf.len())
}
fn flush(&mut self) -> std::io::Result<()> {
Ok(())
}
}
impl<'a> tracing_subscriber::fmt::MakeWriter<'a> for CaptureWriter {
type Writer = Self;
fn make_writer(&'a self) -> Self::Writer {
self.clone()
}
}
fn capture_info(f: impl FnOnce()) -> String {
let writer = CaptureWriter::default();
let subscriber = tracing_subscriber::fmt()
.with_max_level(tracing::Level::INFO)
.with_ansi(false)
.with_writer(writer.clone())
.finish();
tracing::subscriber::with_default(subscriber, f);
let logs = String::from_utf8_lossy(&writer.0.lock().unwrap()).into_owned();
logs
}
const FIXTURE: &str = r#"{
"profile": {
"info_cache": {
"Default": { "user_name": "alice@example.com", "name": "Alice" },
"Profile 1": { "user_name": "bob@example.com", "name": "Bob" }
}
}
}"#;
#[test]
fn match_profiles_finds_a_single_match() {
assert_eq!(
match_profiles(FIXTURE, "alice@example.com").unwrap(),
ProfileMatch::Found("Default".to_string())
);
}
#[test]
fn match_profiles_is_case_insensitive_and_trims() {
assert_eq!(
match_profiles(FIXTURE, " ALICE@Example.com ").unwrap(),
ProfileMatch::Found("Default".to_string())
);
}
#[test]
fn match_profiles_reports_no_match() {
assert_eq!(
match_profiles(FIXTURE, "nobody@example.com").unwrap(),
ProfileMatch::NotFound
);
}
#[test]
fn match_profiles_reports_ambiguous_matches() {
let fixture = r#"{
"profile": {
"info_cache": {
"Default": { "user_name": "shared@example.com" },
"Profile 1": { "user_name": "shared@example.com" }
}
}
}"#;
assert_eq!(
match_profiles(fixture, "shared@example.com").unwrap(),
ProfileMatch::Ambiguous(vec!["Default".to_string(), "Profile 1".to_string()])
);
}
#[test]
fn match_profiles_treats_missing_info_cache_as_no_match() {
assert_eq!(
match_profiles(r#"{ "profile": {} }"#, "alice@example.com").unwrap(),
ProfileMatch::NotFound
);
}
#[test]
fn match_profiles_rejects_malformed_json() {
assert!(match_profiles("not json", "alice@example.com").is_err());
}
#[test]
fn match_profiles_rejects_json_missing_the_profile_key() {
assert!(match_profiles(r#"{ "other": {} }"#, "alice@example.com").is_err());
}
#[test]
fn build_launch_command_for_macos_targets_the_profile_with_open() {
assert_eq!(
build_launch_command_for(ChromeOs::Macos, "Profile 7"),
vec![
"open",
"-na",
"Google Chrome",
"--args",
"--profile-directory=Profile 7",
"{url}",
]
);
}
#[test]
fn build_launch_command_for_linux_invokes_google_chrome_directly() {
assert_eq!(
build_launch_command_for(ChromeOs::Linux, "Profile 7"),
vec!["google-chrome", "--profile-directory=Profile 7", "{url}"]
);
}
#[test]
fn build_launch_command_for_windows_invokes_chrome_directly() {
assert_eq!(
build_launch_command_for(ChromeOs::Windows, "Profile 7"),
vec!["chrome", "--profile-directory=Profile 7", "{url}"]
);
}
#[test]
fn resolve_launch_command_at_finds_the_matching_profile() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("Local State");
fs::write(&path, FIXTURE).unwrap();
let command = resolve_launch_command_at("alice@example.com", &path).unwrap();
assert!(command
.iter()
.any(|arg| arg == "--profile-directory=Default"));
}
#[test]
fn resolve_launch_command_at_falls_open_on_a_missing_file() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("does-not-exist");
let mut result = None;
let logs = capture_info(|| {
result = Some(resolve_launch_command_at("alice@example.com", &path));
});
assert_eq!(result, Some(None));
assert!(logs.contains("could not read"));
}
#[test]
fn resolve_launch_command_at_falls_open_on_unparseable_content() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("Local State");
fs::write(&path, "not json").unwrap();
let mut result = None;
let logs = capture_info(|| {
result = Some(resolve_launch_command_at("alice@example.com", &path));
});
assert_eq!(result, Some(None));
assert!(logs.contains("could not parse"));
}
#[test]
fn resolve_launch_command_at_falls_open_on_no_match() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("Local State");
fs::write(&path, FIXTURE).unwrap();
assert_eq!(resolve_launch_command_at("nobody@example.com", &path), None);
}
#[test]
fn resolve_launch_command_at_falls_open_on_ambiguous_match() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("Local State");
let fixture = r#"{
"profile": {
"info_cache": {
"Default": { "user_name": "shared@example.com" },
"Profile 1": { "user_name": "shared@example.com" }
}
}
}"#;
fs::write(&path, fixture).unwrap();
let mut result = None;
let logs = capture_info(|| {
result = Some(resolve_launch_command_at("shared@example.com", &path));
});
assert_eq!(result, Some(None));
assert!(logs.contains("multiple Chrome profiles"));
}
#[test]
fn default_local_state_path_for_macos_targets_application_support() {
let path =
default_local_state_path_for(ChromeOs::Macos).expect("config_dir resolves in tests");
assert!(path.ends_with("Google/Chrome/Local State"));
}
#[test]
fn default_local_state_path_for_linux_targets_dot_config() {
let path =
default_local_state_path_for(ChromeOs::Linux).expect("config_dir resolves in tests");
assert!(path.ends_with("google-chrome/Local State"));
}
#[test]
fn default_local_state_path_for_windows_targets_local_app_data() {
let path = default_local_state_path_for(ChromeOs::Windows)
.expect("data_local_dir resolves in tests");
assert!(path.ends_with("Google/Chrome/User Data/Local State"));
}
#[test]
fn default_local_state_path_delegates_to_the_current_host_os() {
assert_eq!(
default_local_state_path(),
default_local_state_path_for(current_chrome_os())
);
}
#[test]
fn resolve_launch_command_falls_open_for_an_unmatched_email() {
assert_eq!(
resolve_launch_command("definitely-not-a-real-account+omni-dev-test@invalid.example"),
None
);
}
}