diff --git a/docs/bootstrap.md b/docs/bootstrap.md index 9f90bd59ce..5c8d780663 100644 --- a/docs/bootstrap.md +++ b/docs/bootstrap.md @@ -44,8 +44,23 @@ task runs every time, so keep it idempotent. "~/.config/nvim" = { mode = "symlink" } "~/.zshrc/activate" = { block = 'eval "$(mise activate zsh)"' } +[bootstrap.macos.dock] +autohide = true +orientation = "left" +tilesize = 48 + +[bootstrap.macos.finder] +show_pathbar = true + +[bootstrap.macos.keyboard] +key_repeat = 2 +initial_key_repeat = 15 + +[bootstrap.macos.trackpad] +tap_to_click = true + [bootstrap.macos.defaults] -"com.apple.dock" = { autohide = true } +"com.apple.finder" = { AppleShowAllFiles = true } [bootstrap.macos.launchd.agents.my-sync] program = "~/.local/bin/my-sync" @@ -106,6 +121,7 @@ place but should not install anything during that check. | ---------------------------------- | ------------------------------------------------------------- | | `[bootstrap.packages]` | OS packages from apt, dnf, pacman, or brew | | `[dotfiles]` | Whole-file dotfiles and small managed edits to existing files | +| `[bootstrap.macos.*]` | Curated macOS preferences for Dock/Finder/keyboard/trackpad | | `[bootstrap.macos.defaults]` | macOS user preferences written through `defaults write` | | `[bootstrap.macos.launchd.agents]` | macOS user LaunchAgents written and loaded with `launchctl` | | `[bootstrap.user]` | Current-user settings such as `login_shell` | diff --git a/docs/bootstrap/macos-defaults.md b/docs/bootstrap/macos-defaults.md index c9698be1a1..0580b73c23 100644 --- a/docs/bootstrap/macos-defaults.md +++ b/docs/bootstrap/macos-defaults.md @@ -5,12 +5,85 @@ mise can declare macOS user defaults (preferences) in the `mise bootstrap macos-defaults apply`: ```toml +[bootstrap.macos.dock] +autohide = true +orientation = "left" +tilesize = 48 +show_recents = false + +[bootstrap.macos.finder] +show_all_files = true +show_pathbar = true +preferred_view_style = "list" + +[bootstrap.macos.keyboard] +key_repeat = 2 +initial_key_repeat = 15 +press_and_hold = false + +[bootstrap.macos.trackpad] +tap_to_click = true + [bootstrap.macos.defaults] -NSGlobalDomain = { KeyRepeat = 2, InitialKeyRepeat = 15, ApplePressAndHoldEnabled = false } -"com.apple.dock" = { autohide = true, tilesize = 48, orientation = "left" } -"com.apple.finder" = { ShowPathbar = true, AppleShowAllFiles = true } +"com.apple.finder" = { AppleShowAllFiles = true } ``` +The curated sections compile to raw defaults entries. Use +`[bootstrap.macos.defaults]` for preferences not covered by the friendly +sections. Within the same config file, raw defaults override the raw +`(domain, key)` generated by a friendly setting. Across config files, normal +global to local precedence still applies, so a local friendly setting can +override a global raw default for the same pair. + +## Friendly sections + +`[bootstrap.macos.dock]` supports: + +| Key | Raw default | +| --------------- | ------------------------------ | +| `autohide` | `com.apple.dock.autohide` | +| `orientation` | `com.apple.dock.orientation` | +| `tilesize` | `com.apple.dock.tilesize` | +| `magnification` | `com.apple.dock.magnification` | +| `largesize` | `com.apple.dock.largesize` | +| `show_recents` | `com.apple.dock.show-recents` | +| `mru_spaces` | `com.apple.dock.mru-spaces` | + +`orientation` must be `bottom`, `left`, or `right`. + +`[bootstrap.macos.finder]` supports: + +| Key | Raw default | +| ------------------------- | ------------------------------------------------- | +| `show_all_files` | `com.apple.finder.AppleShowAllFiles` | +| `show_pathbar` | `com.apple.finder.ShowPathbar` | +| `show_status_bar` | `com.apple.finder.ShowStatusBar` | +| `show_extensions_warning` | `com.apple.finder.FXEnableExtensionChangeWarning` | +| `preferred_view_style` | `com.apple.finder.FXPreferredViewStyle` | + +`preferred_view_style` must be `icon`, `list`, `column`, or `gallery`. + +`[bootstrap.macos.keyboard]` supports: + +| Key | Raw default | +| -------------------- | ------------------------------------------- | +| `key_repeat` | `NSGlobalDomain.KeyRepeat` | +| `initial_key_repeat` | `NSGlobalDomain.InitialKeyRepeat` | +| `press_and_hold` | `NSGlobalDomain.ApplePressAndHoldEnabled` | +| `fn_state` | `NSGlobalDomain.com.apple.keyboard.fnState` | + +`[bootstrap.macos.trackpad]` supports: + +| Key | Raw defaults | +| ------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- | +| `tap_to_click` | `com.apple.AppleMultitouchTrackpad.Clicking`, `com.apple.driver.AppleBluetoothMultitouch.trackpad.Clicking` | +| `three_finger_drag` | `com.apple.AppleMultitouchTrackpad.TrackpadThreeFingerDrag`, `com.apple.driver.AppleBluetoothMultitouch.trackpad.TrackpadThreeFingerDrag` | + +Unknown friendly keys, invalid enum values, and unsupported value types warn +and are ignored. + +## Raw defaults + Each key under `[bootstrap.macos.defaults]` is a preferences domain. Quote domains containing dots. Values map to the matching `defaults write` type: diff --git a/docs/tips-and-tricks.md b/docs/tips-and-tricks.md index 9137edfcef..e0a8e2ebc4 100644 --- a/docs/tips-and-tricks.md +++ b/docs/tips-and-tricks.md @@ -92,8 +92,12 @@ defaults, then LaunchAgents, then login shell, then tools, then a "~/.config/nvim" = { mode = "symlink" } "~/.zshrc/activate" = { block = 'eval "$(mise activate zsh)"' } -[bootstrap.macos.defaults] # macOS defaults write -"com.apple.dock" = { autohide = true } +[bootstrap.macos.dock] # friendly macOS defaults +autohide = true +orientation = "left" + +[bootstrap.macos.finder] +show_pathbar = true [bootstrap.macos.launchd.agents.my-sync] # macOS user LaunchAgents program = "~/.local/bin/my-sync" diff --git a/e2e/cli/test_system_defaults b/e2e/cli/test_system_defaults index 437a64ea33..9cb229b060 100644 --- a/e2e/cli/test_system_defaults +++ b/e2e/cli/test_system_defaults @@ -4,11 +4,28 @@ cat <mise.toml [bootstrap.macos.defaults] NSGlobalDomain = { KeyRepeat = 2 } "com.apple.dock" = { autohide = true, tilesize = 48, orientation = "left" } + +[bootstrap.macos.dock] +show_recents = false + +[bootstrap.macos.finder] +show_all_files = true +preferred_view_style = "list" + +[bootstrap.macos.keyboard] +initial_key_repeat = 15 + +[bootstrap.macos.trackpad] +tap_to_click = true EOF # status renders on any platform; on non-macOS entries are skipped, not errors assert_succeed "mise bootstrap macos-defaults status" assert_contains "mise bootstrap macos-defaults status" "com.apple.dock" +assert_contains "mise bootstrap macos-defaults status" "show-recents" +assert_contains "mise bootstrap macos-defaults status" "AppleShowAllFiles" +assert_contains "mise bootstrap macos-defaults status" "InitialKeyRepeat" +assert_contains "mise bootstrap macos-defaults status" "Clicking" assert_contains "mise bootstrap macos-defaults status --json" '"macos_defaults"' if [[ $(uname) != "Darwin" ]]; then assert_contains "mise bootstrap macos-defaults status" "skipped" @@ -38,6 +55,19 @@ EOF assert_succeed "mise bootstrap macos-defaults status" assert_contains "mise bootstrap macos-defaults status 2>&1" "expected a table" +# friendly defaults validate keys and enum values +cat <mise.toml +[bootstrap.macos.dock] +orientation = "top" +future_key = true + +[bootstrap.macos.finder] +preferred_view_style = "coverflow" +EOF +assert_succeed "mise bootstrap macos-defaults status" +assert_contains "mise bootstrap macos-defaults status 2>&1" "invalid value" +assert_contains "mise bootstrap macos-defaults status 2>&1" "unknown key" + # empty [bootstrap.macos.defaults] section cat <mise.toml [bootstrap.macos.defaults] diff --git a/schema/mise.json b/schema/mise.json index 20887c91a1..e1ef11af57 100644 --- a/schema/mise.json +++ b/schema/mise.json @@ -3050,6 +3050,108 @@ "type": "object", "description": "macOS-specific bootstrap config", "properties": { + "dock": { + "type": "object", + "description": "curated Dock preferences that compile into macOS defaults", + "properties": { + "autohide": { + "type": "boolean", + "description": "hide and show the Dock automatically" + }, + "orientation": { + "type": "string", + "enum": ["bottom", "left", "right"], + "description": "Dock screen edge" + }, + "tilesize": { + "type": "integer", + "description": "Dock icon size" + }, + "magnification": { + "type": "boolean", + "description": "enable Dock magnification" + }, + "largesize": { + "type": "integer", + "description": "Dock magnified icon size" + }, + "show_recents": { + "type": "boolean", + "description": "show recent applications in the Dock" + }, + "mru_spaces": { + "type": "boolean", + "description": "automatically rearrange Spaces based on most recent use" + } + }, + "additionalProperties": false + }, + "finder": { + "type": "object", + "description": "curated Finder preferences that compile into macOS defaults", + "properties": { + "show_all_files": { + "type": "boolean", + "description": "show hidden files in Finder" + }, + "show_pathbar": { + "type": "boolean", + "description": "show the Finder path bar" + }, + "show_status_bar": { + "type": "boolean", + "description": "show the Finder status bar" + }, + "show_extensions_warning": { + "type": "boolean", + "description": "show the warning when changing file extensions" + }, + "preferred_view_style": { + "type": "string", + "enum": ["icon", "list", "column", "gallery"], + "description": "Finder preferred view style" + } + }, + "additionalProperties": false + }, + "keyboard": { + "type": "object", + "description": "curated keyboard preferences that compile into macOS defaults", + "properties": { + "key_repeat": { + "type": "integer", + "description": "keyboard repeat interval" + }, + "initial_key_repeat": { + "type": "integer", + "description": "delay before key repeat starts" + }, + "press_and_hold": { + "type": "boolean", + "description": "enable press-and-hold accent picker" + }, + "fn_state": { + "type": "boolean", + "description": "use F1, F2, etc. as standard function keys" + } + }, + "additionalProperties": false + }, + "trackpad": { + "type": "object", + "description": "curated trackpad preferences that compile into macOS defaults", + "properties": { + "tap_to_click": { + "type": "boolean", + "description": "enable tap to click for built-in and Bluetooth trackpads" + }, + "three_finger_drag": { + "type": "boolean", + "description": "enable three finger drag for built-in and Bluetooth trackpads" + } + }, + "additionalProperties": false + }, "defaults": { "type": "object", "description": "macOS user defaults to apply with `mise bootstrap macos-defaults apply`, keyed by preferences domain (e.g. \"com.apple.dock\", \"NSGlobalDomain\")", diff --git a/src/cli/oci/common.rs b/src/cli/oci/common.rs index 893c839cbd..302bef9d12 100644 --- a/src/cli/oci/common.rs +++ b/src/cli/oci/common.rs @@ -123,17 +123,12 @@ fn reject_unsupported_system_defaults(config_files: &ConfigMap) -> Result<()> { let Some(system) = cf.bootstrap_config() else { continue; }; - defaults += system - .macos - .defaults - .values() - .map(|v| v.as_table().map_or(1, |t| t.len())) - .sum::(); + defaults += system::macos_defaults_entry_count(&system.macos); } if defaults > 0 { bail!( - "mise oci does not support [bootstrap.macos.defaults] (found {defaults} default entries); \ + "mise oci does not support [bootstrap.macos.*] defaults (found {defaults} default entries); \ macOS defaults do not apply to OCI images." ); } diff --git a/src/config/config_file/mise_toml.rs b/src/config/config_file/mise_toml.rs index 40bdf8248e..27e363f1f1 100644 --- a/src/config/config_file/mise_toml.rs +++ b/src/config/config_file/mise_toml.rs @@ -2277,6 +2277,19 @@ mod tests { [bootstrap.macos.defaults] NSGlobalDomain = { KeyRepeat = 2, ApplePressAndHoldEnabled = false } "com.apple.dock" = { autohide = true, tilesize = 48, magnification-scale = 1.5, orientation = "left", future-array = [1, 2] } + + [bootstrap.macos.dock] + show_recents = false + + [bootstrap.macos.finder] + show_all_files = true + preferred_view_style = "list" + + [bootstrap.macos.keyboard] + initial_key_repeat = 15 + + [bootstrap.macos.trackpad] + tap_to_click = true "#, ) .unwrap(); @@ -2300,6 +2313,26 @@ mod tests { &toml::Value::String("left".into()) ); assert!(dock.get("future-array").unwrap().is_array()); + assert_eq!( + system.macos.dock.get("show_recents").unwrap(), + &toml::Value::Boolean(false) + ); + assert_eq!( + system.macos.finder.get("show_all_files").unwrap(), + &toml::Value::Boolean(true) + ); + assert_eq!( + system.macos.finder.get("preferred_view_style").unwrap(), + &toml::Value::String("list".into()) + ); + assert_eq!( + system.macos.keyboard.get("initial_key_repeat").unwrap(), + &toml::Value::Integer(15) + ); + assert_eq!( + system.macos.trackpad.get("tap_to_click").unwrap(), + &toml::Value::Boolean(true) + ); file::remove_file(&p).unwrap(); } diff --git a/src/system/mod.rs b/src/system/mod.rs index e7da79d53b..b351f011b1 100644 --- a/src/system/mod.rs +++ b/src/system/mod.rs @@ -62,6 +62,18 @@ pub struct BootstrapUserTomlConfig { #[derive(Debug, Default, Clone, Deserialize)] pub struct BootstrapMacosTomlConfig { + /// Friendly Dock settings that compile into `[bootstrap.macos.defaults]`. + #[serde(default)] + pub dock: IndexMap, + /// Friendly Finder settings that compile into `[bootstrap.macos.defaults]`. + #[serde(default)] + pub finder: IndexMap, + /// Friendly keyboard settings that compile into `[bootstrap.macos.defaults]`. + #[serde(default)] + pub keyboard: IndexMap, + /// Friendly trackpad settings that compile into `[bootstrap.macos.defaults]`. + #[serde(default)] + pub trackpad: IndexMap, /// `[bootstrap.macos.defaults.]` -> key -> value. Values stay raw TOML so /// shapes from newer mise versions (arrays, dicts) parse fine on older /// ones; the domain level is also raw so a malformed section warns @@ -245,11 +257,14 @@ pub fn defaults_from_config(config: &Config) -> Vec { // config_files is ordered local -> global; reverse for global -> local for cf in config.config_files.values().rev() { if let Some(sys) = cf.bootstrap_config() { + let mut friendly: IndexMap<(String, String), toml::Value> = IndexMap::new(); + let mut raw: IndexMap<(String, String), toml::Value> = IndexMap::new(); + merge_friendly_macos_defaults(&mut friendly, &sys.macos); for (domain, entries) in sys.macos.defaults { match entries { toml::Value::Table(entries) => { for (key, value) in entries { - merged.insert((domain.clone(), key), value); + raw.insert((domain.clone(), key), value); } } _ => warn!( @@ -257,6 +272,9 @@ pub fn defaults_from_config(config: &Config) -> Vec { ), } } + for (key, value) in merge_raw_over_friendly_macos_defaults(friendly, raw) { + merged.insert(key, value); + } } } let mut out = vec![]; @@ -297,6 +315,398 @@ pub fn launchd_from_config(config: &Config) -> Vec { out } +/// Count macOS defaults declared in one config file, including friendly +/// sections that compile into raw defaults entries. +pub fn macos_defaults_entry_count(macos: &BootstrapMacosTomlConfig) -> usize { + let mut friendly: IndexMap<(String, String), toml::Value> = IndexMap::new(); + let mut raw: IndexMap<(String, String), toml::Value> = IndexMap::new(); + let mut malformed_domains = 0usize; + merge_friendly_macos_defaults(&mut friendly, macos); + for (domain, entries) in &macos.defaults { + match entries { + toml::Value::Table(entries) => { + for (key, value) in entries { + raw.insert((domain.clone(), key.clone()), value.clone()); + } + } + _ => malformed_domains += 1, + } + } + merge_raw_over_friendly_macos_defaults(friendly, raw).len() + malformed_domains +} + +fn merge_raw_over_friendly_macos_defaults( + mut friendly: IndexMap<(String, String), toml::Value>, + raw: IndexMap<(String, String), toml::Value>, +) -> IndexMap<(String, String), toml::Value> { + for (key, value) in raw { + friendly.insert(key, value); + } + friendly +} + +fn merge_friendly_macos_defaults( + out: &mut IndexMap<(String, String), toml::Value>, + macos: &BootstrapMacosTomlConfig, +) { + merge_dock_defaults(out, &macos.dock); + merge_finder_defaults(out, &macos.finder); + merge_keyboard_defaults(out, &macos.keyboard); + merge_trackpad_defaults(out, &macos.trackpad); +} + +#[derive(Clone, Copy)] +struct FriendlyDefaultSpec<'a> { + section: &'a str, + key: &'a str, + defaults_key: &'a str, + expected: fn(&toml::Value) -> bool, + expected_type: &'a str, +} + +fn insert_friendly_default( + out: &mut IndexMap<(String, String), toml::Value>, + domain: &str, + spec: FriendlyDefaultSpec<'_>, + value: toml::Value, +) { + if (spec.expected)(&value) { + out.insert((domain.to_string(), spec.defaults_key.to_string()), value); + } else { + let FriendlyDefaultSpec { + section, + key, + expected_type, + .. + } = spec; + warn!( + "[bootstrap.macos.{section}].{key}: unsupported value type \ + (expected {expected_type})" + ); + } +} + +fn insert_friendly_multi_domain_default( + out: &mut IndexMap<(String, String), toml::Value>, + domains: &[&str], + spec: FriendlyDefaultSpec<'_>, + value: toml::Value, +) { + if (spec.expected)(&value) { + for domain in domains { + out.insert( + (domain.to_string(), spec.defaults_key.to_string()), + value.clone(), + ); + } + } else { + let FriendlyDefaultSpec { + section, + key, + expected_type, + .. + } = spec; + warn!( + "[bootstrap.macos.{section}].{key}: unsupported value type \ + (expected {expected_type})" + ); + } +} + +fn is_bool(value: &toml::Value) -> bool { + matches!(value, toml::Value::Boolean(_)) +} + +fn is_integer(value: &toml::Value) -> bool { + matches!(value, toml::Value::Integer(_)) +} + +fn merge_dock_defaults( + out: &mut IndexMap<(String, String), toml::Value>, + entries: &IndexMap, +) { + for (key, value) in entries { + match key.as_str() { + "autohide" => insert_friendly_default( + out, + "com.apple.dock", + FriendlyDefaultSpec { + section: "dock", + key, + defaults_key: "autohide", + expected: is_bool, + expected_type: "bool", + }, + value.clone(), + ), + "orientation" => match value { + toml::Value::String(s) if matches!(s.as_str(), "bottom" | "left" | "right") => { + out.insert( + ("com.apple.dock".to_string(), "orientation".to_string()), + value.clone(), + ); + } + toml::Value::String(_) => warn!( + "[bootstrap.macos.dock].orientation: invalid value \ + (expected bottom, left, or right)" + ), + _ => warn!( + "[bootstrap.macos.dock].orientation: unsupported value type (expected string)" + ), + }, + "tilesize" => insert_friendly_default( + out, + "com.apple.dock", + FriendlyDefaultSpec { + section: "dock", + key, + defaults_key: "tilesize", + expected: is_integer, + expected_type: "integer", + }, + value.clone(), + ), + "magnification" => insert_friendly_default( + out, + "com.apple.dock", + FriendlyDefaultSpec { + section: "dock", + key, + defaults_key: "magnification", + expected: is_bool, + expected_type: "bool", + }, + value.clone(), + ), + "largesize" => insert_friendly_default( + out, + "com.apple.dock", + FriendlyDefaultSpec { + section: "dock", + key, + defaults_key: "largesize", + expected: is_integer, + expected_type: "integer", + }, + value.clone(), + ), + "show_recents" => insert_friendly_default( + out, + "com.apple.dock", + FriendlyDefaultSpec { + section: "dock", + key, + defaults_key: "show-recents", + expected: is_bool, + expected_type: "bool", + }, + value.clone(), + ), + "mru_spaces" => insert_friendly_default( + out, + "com.apple.dock", + FriendlyDefaultSpec { + section: "dock", + key, + defaults_key: "mru-spaces", + expected: is_bool, + expected_type: "bool", + }, + value.clone(), + ), + _ => warn!("[bootstrap.macos.dock].{key}: unknown key, ignoring entry"), + } + } +} + +fn merge_finder_defaults( + out: &mut IndexMap<(String, String), toml::Value>, + entries: &IndexMap, +) { + for (key, value) in entries { + match key.as_str() { + "show_all_files" => insert_friendly_default( + out, + "com.apple.finder", + FriendlyDefaultSpec { + section: "finder", + key, + defaults_key: "AppleShowAllFiles", + expected: is_bool, + expected_type: "bool", + }, + value.clone(), + ), + "show_pathbar" => insert_friendly_default( + out, + "com.apple.finder", + FriendlyDefaultSpec { + section: "finder", + key, + defaults_key: "ShowPathbar", + expected: is_bool, + expected_type: "bool", + }, + value.clone(), + ), + "show_status_bar" => insert_friendly_default( + out, + "com.apple.finder", + FriendlyDefaultSpec { + section: "finder", + key, + defaults_key: "ShowStatusBar", + expected: is_bool, + expected_type: "bool", + }, + value.clone(), + ), + "show_extensions_warning" => insert_friendly_default( + out, + "com.apple.finder", + FriendlyDefaultSpec { + section: "finder", + key, + defaults_key: "FXEnableExtensionChangeWarning", + expected: is_bool, + expected_type: "bool", + }, + value.clone(), + ), + "preferred_view_style" => match value { + toml::Value::String(s) => { + let mapped = match s.as_str() { + "icon" => Some("icnv"), + "list" => Some("Nlsv"), + "column" => Some("clmv"), + "gallery" => Some("glyv"), + _ => None, + }; + if let Some(mapped) = mapped { + out.insert( + ( + "com.apple.finder".to_string(), + "FXPreferredViewStyle".to_string(), + ), + toml::Value::String(mapped.to_string()), + ); + } else { + warn!( + "[bootstrap.macos.finder].preferred_view_style: invalid value \ + (expected icon, list, column, or gallery)" + ); + } + } + _ => warn!( + "[bootstrap.macos.finder].preferred_view_style: unsupported value type \ + (expected string)" + ), + }, + _ => warn!("[bootstrap.macos.finder].{key}: unknown key, ignoring entry"), + } + } +} + +fn merge_keyboard_defaults( + out: &mut IndexMap<(String, String), toml::Value>, + entries: &IndexMap, +) { + for (key, value) in entries { + match key.as_str() { + "key_repeat" => insert_friendly_default( + out, + "NSGlobalDomain", + FriendlyDefaultSpec { + section: "keyboard", + key, + defaults_key: "KeyRepeat", + expected: is_integer, + expected_type: "integer", + }, + value.clone(), + ), + "initial_key_repeat" => insert_friendly_default( + out, + "NSGlobalDomain", + FriendlyDefaultSpec { + section: "keyboard", + key, + defaults_key: "InitialKeyRepeat", + expected: is_integer, + expected_type: "integer", + }, + value.clone(), + ), + "press_and_hold" => insert_friendly_default( + out, + "NSGlobalDomain", + FriendlyDefaultSpec { + section: "keyboard", + key, + defaults_key: "ApplePressAndHoldEnabled", + expected: is_bool, + expected_type: "bool", + }, + value.clone(), + ), + "fn_state" => insert_friendly_default( + out, + "NSGlobalDomain", + FriendlyDefaultSpec { + section: "keyboard", + key, + defaults_key: "com.apple.keyboard.fnState", + expected: is_bool, + expected_type: "bool", + }, + value.clone(), + ), + _ => warn!("[bootstrap.macos.keyboard].{key}: unknown key, ignoring entry"), + } + } +} + +fn merge_trackpad_defaults( + out: &mut IndexMap<(String, String), toml::Value>, + entries: &IndexMap, +) { + for (key, value) in entries { + match key.as_str() { + "tap_to_click" => insert_friendly_multi_domain_default( + out, + &[ + "com.apple.AppleMultitouchTrackpad", + "com.apple.driver.AppleBluetoothMultitouch.trackpad", + ], + FriendlyDefaultSpec { + section: "trackpad", + key, + defaults_key: "Clicking", + expected: is_bool, + expected_type: "bool", + }, + value.clone(), + ), + "three_finger_drag" => insert_friendly_multi_domain_default( + out, + &[ + "com.apple.AppleMultitouchTrackpad", + "com.apple.driver.AppleBluetoothMultitouch.trackpad", + ], + FriendlyDefaultSpec { + section: "trackpad", + key, + defaults_key: "TrackpadThreeFingerDrag", + expected: is_bool, + expected_type: "bool", + }, + value.clone(), + ), + _ => warn!("[bootstrap.macos.trackpad].{key}: unknown key, ignoring entry"), + } + } +} + /// Desired login shell from the most local config that declares it. pub fn login_shell_from_config(config: &Config) -> Option { let mut shell = None; @@ -454,6 +864,10 @@ fn resolve_managers( mod tests { use super::*; + fn tv(s: &str) -> toml::Value { + s.parse().unwrap() + } + #[test] fn test_parse_use_spec() { let (mgr, req) = parse_use_spec("apt:curl").unwrap(); @@ -502,4 +916,223 @@ mod tests { assert_eq!(brew_tap_name("jq"), None); assert_eq!(brew_tap_name("too/many/slashes/here"), None); } + + #[test] + fn test_friendly_macos_defaults() { + let mut macos = BootstrapMacosTomlConfig::default(); + macos.dock.insert("autohide".into(), tv("true")); + macos.dock.insert("orientation".into(), tv(r#""left""#)); + macos.dock.insert("tilesize".into(), tv("48")); + macos.dock.insert("magnification".into(), tv("true")); + macos.dock.insert("largesize".into(), tv("96")); + macos.dock.insert("show_recents".into(), tv("false")); + macos.dock.insert("mru_spaces".into(), tv("false")); + macos.finder.insert("show_all_files".into(), tv("true")); + macos.finder.insert("show_pathbar".into(), tv("true")); + macos.finder.insert("show_status_bar".into(), tv("true")); + macos + .finder + .insert("show_extensions_warning".into(), tv("false")); + macos + .finder + .insert("preferred_view_style".into(), tv(r#""list""#)); + macos.keyboard.insert("key_repeat".into(), tv("2")); + macos.keyboard.insert("initial_key_repeat".into(), tv("15")); + macos.keyboard.insert("press_and_hold".into(), tv("false")); + macos.keyboard.insert("fn_state".into(), tv("true")); + macos.trackpad.insert("tap_to_click".into(), tv("true")); + macos + .trackpad + .insert("three_finger_drag".into(), tv("true")); + + let mut out = IndexMap::new(); + merge_friendly_macos_defaults(&mut out, &macos); + + assert_eq!( + out.get(&("com.apple.dock".into(), "autohide".into())), + Some(&tv("true")) + ); + assert_eq!( + out.get(&("com.apple.dock".into(), "orientation".into())), + Some(&tv(r#""left""#)) + ); + assert_eq!( + out.get(&("com.apple.dock".into(), "tilesize".into())), + Some(&tv("48")) + ); + assert_eq!( + out.get(&("com.apple.dock".into(), "magnification".into())), + Some(&tv("true")) + ); + assert_eq!( + out.get(&("com.apple.dock".into(), "largesize".into())), + Some(&tv("96")) + ); + assert_eq!( + out.get(&("com.apple.dock".into(), "show-recents".into())), + Some(&tv("false")) + ); + assert_eq!( + out.get(&("com.apple.dock".into(), "mru-spaces".into())), + Some(&tv("false")) + ); + assert_eq!( + out.get(&("com.apple.finder".into(), "AppleShowAllFiles".into())), + Some(&tv("true")) + ); + assert_eq!( + out.get(&("com.apple.finder".into(), "ShowPathbar".into())), + Some(&tv("true")) + ); + assert_eq!( + out.get(&("com.apple.finder".into(), "ShowStatusBar".into())), + Some(&tv("true")) + ); + assert_eq!( + out.get(&( + "com.apple.finder".into(), + "FXEnableExtensionChangeWarning".into() + )), + Some(&tv("false")) + ); + assert_eq!( + out.get(&("com.apple.finder".into(), "FXPreferredViewStyle".into())), + Some(&tv(r#""Nlsv""#)) + ); + assert_eq!( + out.get(&("NSGlobalDomain".into(), "KeyRepeat".into())), + Some(&tv("2")) + ); + assert_eq!( + out.get(&("NSGlobalDomain".into(), "InitialKeyRepeat".into())), + Some(&tv("15")) + ); + assert_eq!( + out.get(&("NSGlobalDomain".into(), "ApplePressAndHoldEnabled".into())), + Some(&tv("false")) + ); + assert_eq!( + out.get(&("NSGlobalDomain".into(), "com.apple.keyboard.fnState".into())), + Some(&tv("true")) + ); + assert_eq!( + out.get(&( + "com.apple.AppleMultitouchTrackpad".into(), + "Clicking".into() + )), + Some(&tv("true")) + ); + assert_eq!( + out.get(&( + "com.apple.driver.AppleBluetoothMultitouch.trackpad".into(), + "Clicking".into() + )), + Some(&tv("true")) + ); + assert_eq!( + out.get(&( + "com.apple.AppleMultitouchTrackpad".into(), + "TrackpadThreeFingerDrag".into() + )), + Some(&tv("true")) + ); + assert_eq!( + out.get(&( + "com.apple.driver.AppleBluetoothMultitouch.trackpad".into(), + "TrackpadThreeFingerDrag".into() + )), + Some(&tv("true")) + ); + } + + #[test] + fn test_friendly_macos_defaults_validation() { + let mut macos = BootstrapMacosTomlConfig::default(); + macos.dock.insert("orientation".into(), tv(r#""top""#)); + macos.dock.insert("tilesize".into(), tv("true")); + macos.dock.insert("unknown".into(), tv("true")); + macos + .finder + .insert("preferred_view_style".into(), tv(r#""coverflow""#)); + macos.keyboard.insert("key_repeat".into(), tv(r#""fast""#)); + macos.trackpad.insert("tap_to_click".into(), tv("[true]")); + + let mut out = IndexMap::new(); + merge_friendly_macos_defaults(&mut out, &macos); + + assert!(out.is_empty()); + } + + #[test] + fn test_raw_macos_defaults_override_friendly_defaults() { + let mut friendly = IndexMap::new(); + friendly.insert( + ("com.apple.dock".into(), "autohide".into()), + toml::Value::Boolean(true), + ); + friendly.insert( + ("com.apple.dock".into(), "tilesize".into()), + toml::Value::Integer(48), + ); + let mut raw = IndexMap::new(); + raw.insert( + ("com.apple.dock".into(), "autohide".into()), + toml::Value::Boolean(false), + ); + + let merged = merge_raw_over_friendly_macos_defaults(friendly, raw); + + assert_eq!( + merged.get(&("com.apple.dock".into(), "autohide".into())), + Some(&toml::Value::Boolean(false)) + ); + assert_eq!( + merged.get(&("com.apple.dock".into(), "tilesize".into())), + Some(&toml::Value::Integer(48)) + ); + } + + #[test] + fn test_local_friendly_macos_defaults_override_global_raw_defaults() { + let mut merged = IndexMap::new(); + + let global_friendly = IndexMap::new(); + let mut global_raw = IndexMap::new(); + global_raw.insert( + ("com.apple.dock".into(), "autohide".into()), + toml::Value::Boolean(false), + ); + for (key, value) in merge_raw_over_friendly_macos_defaults(global_friendly, global_raw) { + merged.insert(key, value); + } + + let mut local_friendly = IndexMap::new(); + local_friendly.insert( + ("com.apple.dock".into(), "autohide".into()), + toml::Value::Boolean(true), + ); + let local_raw = IndexMap::new(); + for (key, value) in merge_raw_over_friendly_macos_defaults(local_friendly, local_raw) { + merged.insert(key, value); + } + + assert_eq!( + merged.get(&("com.apple.dock".into(), "autohide".into())), + Some(&toml::Value::Boolean(true)) + ); + } + + #[test] + fn test_macos_defaults_entry_count_includes_friendly_defaults() { + let mut macos = BootstrapMacosTomlConfig::default(); + macos.dock.insert("autohide".into(), tv("true")); + macos.trackpad.insert("tap_to_click".into(), tv("true")); + macos.defaults.insert( + "com.apple.dock".into(), + tv(r#"{ autohide = false, tilesize = 48 }"#), + ); + macos.defaults.insert("malformed".into(), tv("true")); + + assert_eq!(macos_defaults_entry_count(&macos), 5); + } }