diff --git a/Cargo.lock b/Cargo.lock index 351ab370c..c1bfa7e75 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -15072,6 +15072,7 @@ dependencies = [ "anyhow", "async-channel", "cocoa 0.26.0", + "core-foundation 0.10.1", "gloo-storage", "log", "objc", diff --git a/crates/warpui_extras/Cargo.toml b/crates/warpui_extras/Cargo.toml index 474dbfa07..7a00f15b1 100644 --- a/crates/warpui_extras/Cargo.toml +++ b/crates/warpui_extras/Cargo.toml @@ -10,7 +10,7 @@ license.workspace = true default = ["secure_storage", "user_preferences", "user_preferences-file"] secure_storage = ["dep:security-framework", "dep:secret-service", "dep:ouroboros"] test-util = [] -user_preferences = ["dep:cocoa", "dep:objc", "dep:gloo-storage"] +user_preferences = ["dep:cocoa", "dep:objc", "dep:gloo-storage", "dep:core-foundation"] user_preferences-file = ["user_preferences", "dep:serde", "dep:serde_json"] user_preferences-toml = ["user_preferences", "dep:serde", "dep:serde_json", "dep:toml_edit"] @@ -32,6 +32,7 @@ tempfile.workspace = true [target.'cfg(target_os = "macos")'.dependencies] cocoa = { workspace = true, optional = true } +core-foundation = { workspace = true, optional = true } objc = { workspace = true, optional = true } security-framework = { version = "2.0.0", optional = true } diff --git a/crates/warpui_extras/src/user_preferences/user_defaults.rs b/crates/warpui_extras/src/user_preferences/user_defaults.rs index 2ca7329a0..088a96416 100644 --- a/crates/warpui_extras/src/user_preferences/user_defaults.rs +++ b/crates/warpui_extras/src/user_preferences/user_defaults.rs @@ -3,6 +3,8 @@ #![allow(deprecated)] use cocoa::base::{id, nil}; +use core_foundation::base::{CFGetTypeID, CFTypeRef, TCFType}; +use core_foundation::boolean::CFBoolean; use objc::{class, msg_send, rc::StrongPtr, sel, sel_impl}; /// A user preferences store backed by macOS user defaults (`NSUserDefaults`). @@ -61,6 +63,22 @@ impl super::UserPreferences for UserDefaultsPreferencesStorage { fn read_value(&self, key: &str) -> Result, super::Error> { unsafe { let key = util::make_nsstring(key); + + // Check for NSNumber-typed booleans before calling stringForKey:. When a value + // is written via `defaults write -bool false`, it is stored as NSNumber(BOOL). + // stringForKey: coerces that to "0"/"1", which serde_json cannot parse as bool + // and silently falls back to the setting's default. Return canonical JSON instead. + // + // BOOL is toll-free bridged with the kCFBooleanTrue/kCFBooleanFalse singletons, + // so CFGetTypeID is the only safe way to discriminate a real BOOL from a + // char-valued NSNumber (which shares ObjC encoding "c" on x86_64). Char-valued + // and other numeric NSNumbers carry CFNumber's type ID and fall through. + let raw: id = msg_send![*self.user_defaults, objectForKey: *key]; + if raw != nil && CFGetTypeID(raw as CFTypeRef) == CFBoolean::type_id() { + let b: bool = msg_send![raw, boolValue]; + return Ok(Some(b.to_string())); + } + let value: id = msg_send![*self.user_defaults, stringForKey: *key]; if value != nil { Ok(Some(