use std::path::Path;
use tokio::process::Command as AsyncCommand;
use tracing::{debug, info, warn};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[repr(i8)]
#[allow(dead_code)]
pub enum AccentColor {
Multicolor = -1, Red = 0,
Orange = 1,
Yellow = 2,
Green = 3,
Blue = 4, Purple = 5,
Pink = 6,
}
#[allow(dead_code)]
impl AccentColor {
#[allow(clippy::should_implement_trait)]
pub fn from_str(s: &str) -> Option<Self> {
match s.to_lowercase().as_str() {
"multicolor" | "graphite" | "auto" => Some(Self::Multicolor),
"red" => Some(Self::Red),
"orange" => Some(Self::Orange),
"yellow" => Some(Self::Yellow),
"green" => Some(Self::Green),
"blue" => Some(Self::Blue),
"purple" | "violet" => Some(Self::Purple),
"pink" | "magenta" => Some(Self::Pink),
_ => None,
}
}
pub fn name(&self) -> &'static str {
match self {
Self::Multicolor => "multicolor",
Self::Red => "red",
Self::Orange => "orange",
Self::Yellow => "yellow",
Self::Green => "green",
Self::Blue => "blue",
Self::Purple => "purple",
Self::Pink => "pink",
}
}
pub fn from_dominant_color(r: u8, g: u8, b: u8) -> Self {
let max = r.max(g).max(b) as f32;
let min = r.min(g).min(b) as f32;
if max - min < 30.0 {
return Self::Multicolor;
}
let r = r as f32;
let g = g as f32;
let b = b as f32;
let hue = if max == r {
60.0 * (((g - b) / (max - min)) % 6.0)
} else if max == g {
60.0 * (((b - r) / (max - min)) + 2.0)
} else {
60.0 * (((r - g) / (max - min)) + 4.0)
};
let hue = if hue < 0.0 { hue + 360.0 } else { hue };
match hue as u16 {
0..=15 | 346..=360 => Self::Red,
16..=45 => Self::Orange,
46..=70 => Self::Yellow,
71..=165 => Self::Green,
166..=260 => Self::Blue,
261..=290 => Self::Purple,
291..=345 => Self::Pink,
_ => Self::Blue,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[allow(dead_code)]
pub enum AppearanceMode {
Light,
Dark,
Auto, }
#[allow(dead_code)]
impl AppearanceMode {
#[allow(clippy::should_implement_trait)]
pub fn from_str(s: &str) -> Option<Self> {
match s.to_lowercase().as_str() {
"light" => Some(Self::Light),
"dark" => Some(Self::Dark),
"auto" | "system" => Some(Self::Auto),
_ => None,
}
}
}
#[cfg(target_os = "macos")]
#[allow(dead_code)]
fn is_macos() -> bool {
true
}
#[cfg(not(target_os = "macos"))]
fn is_macos() -> bool {
false
}
#[allow(dead_code)]
pub async fn set_appearance_mode(mode: AppearanceMode) {
if !is_macos() {
debug!("Not on macOS, skipping appearance mode change");
return;
}
let script = match mode {
AppearanceMode::Dark => "tell app \"System Events\" to tell appearance preferences to set dark mode to true",
AppearanceMode::Light => "tell app \"System Events\" to tell appearance preferences to set dark mode to false",
AppearanceMode::Auto => {
warn!("Auto appearance mode cannot be set programmatically, use System Settings");
return;
}
};
let output = AsyncCommand::new("osascript").args(["-e", script]).output().await;
match output {
Ok(output) => {
if output.status.success() {
info!("✅ macOS appearance set to {:?}", mode);
} else {
let stderr = String::from_utf8_lossy(&output.stderr);
warn!("Failed to set macOS appearance: {}", stderr);
}
}
Err(e) => {
warn!("Failed to execute osascript: {}", e);
}
}
}
#[allow(dead_code)]
pub async fn set_accent_color(color: AccentColor) {
if !is_macos() {
debug!("Not on macOS, skipping accent color change");
return;
}
let swift_script = format!(
r#"
import Foundation
// Set the accent color preference
let colorValue = {color_value}
UserDefaults.standard.setPersistentDomain(
["AppleAccentColor": colorValue == -1 ? nil : colorValue],
forName: UserDefaults.globalDomain
)
// Sync to disk
UserDefaults.standard.synchronize()
// Send notifications so running apps update their appearance
// Both notifications are needed for full compatibility
let notifications = [
"AppleColorPreferencesChangedNotification",
"AppleAquaColorVariantChanged"
]
for name in notifications {{
DistributedNotificationCenter.default().post(
name: Notification.Name(name),
object: nil,
userInfo: nil,
deliverImmediately: true
)
}}
// Small delay to let notifications propagate
Thread.sleep(forTimeInterval: 0.1)
"#,
color_value = color as i8
);
let temp_script = std::env::temp_dir().join("wallflow_accent.swift");
if let Err(e) = tokio::fs::write(&temp_script, &swift_script).await {
warn!("Failed to write accent color script: {}", e);
set_accent_color_fallback(color).await;
return;
}
let output = AsyncCommand::new("swift").arg(&temp_script).output().await;
let _ = tokio::fs::remove_file(&temp_script).await;
match output {
Ok(output) => {
if output.status.success() {
info!("✅ macOS accent color set to {}", color.name());
} else {
let stderr = String::from_utf8_lossy(&output.stderr);
warn!("Failed to set accent color via Swift: {}", stderr);
set_accent_color_fallback(color).await;
}
}
Err(e) => {
debug!("Swift not available ({}), using fallback", e);
set_accent_color_fallback(color).await;
}
}
}
#[allow(dead_code)]
async fn set_accent_color_fallback(color: AccentColor) {
let color_value = color as i8;
let output = if color_value == -1 {
AsyncCommand::new("defaults").args(["delete", "-g", "AppleAccentColor"]).output().await
} else {
AsyncCommand::new("defaults")
.args(["write", "-g", "AppleAccentColor", "-int", &color_value.to_string()])
.output()
.await
};
match output {
Ok(output) => {
if output.status.success() {
info!("✅ macOS accent color preference set to {} (restart apps to see changes)", color.name());
} else {
let stderr = String::from_utf8_lossy(&output.stderr);
if !stderr.contains("does not exist") {
warn!("Failed to set accent color preference: {}", stderr);
}
}
}
Err(e) => {
warn!("Failed to execute defaults command: {}", e);
}
}
}
#[allow(dead_code)]
pub async fn set_highlight_color(r: f32, g: f32, b: f32) {
if !is_macos() {
debug!("Not on macOS, skipping highlight color change");
return;
}
let color_string = format!("{:.6} {:.6} {:.6}", r.clamp(0.0, 1.0), g.clamp(0.0, 1.0), b.clamp(0.0, 1.0));
let output = AsyncCommand::new("defaults")
.args(["write", "-g", "AppleHighlightColor", "-string", &color_string])
.output()
.await;
match output {
Ok(output) => {
if output.status.success() {
debug!("macOS highlight color set to RGB({}, {}, {})", r, g, b);
} else {
let stderr = String::from_utf8_lossy(&output.stderr);
warn!("Failed to set highlight color: {}", stderr);
}
}
Err(e) => {
warn!("Failed to execute defaults command: {}", e);
}
}
}
#[allow(dead_code)]
pub async fn get_appearance_mode() -> Option<AppearanceMode> {
if !is_macos() {
return None;
}
let output = AsyncCommand::new("defaults")
.args(["read", "-g", "AppleInterfaceStyle"])
.output()
.await
.ok()?;
if output.status.success() {
let style = String::from_utf8_lossy(&output.stdout);
if style.trim().eq_ignore_ascii_case("dark") {
return Some(AppearanceMode::Dark);
}
}
Some(AppearanceMode::Light)
}
#[allow(dead_code)]
pub async fn toggle_appearance_mode() {
let current = get_appearance_mode().await;
let new_mode = match current {
Some(AppearanceMode::Dark) => AppearanceMode::Light,
_ => AppearanceMode::Dark,
};
set_appearance_mode(new_mode).await;
}
#[allow(dead_code)]
#[allow(clippy::collapsible_if)]
pub async fn apply_theme_from_wallpaper(
_wallpaper_path: &Path,
set_dark_mode: bool,
set_accent: bool,
dominant_color: Option<(u8, u8, u8)>,
is_dark_image: Option<bool>,
) {
if !is_macos() {
debug!("Not on macOS, skipping theme application");
return;
}
if set_dark_mode {
if let Some(is_dark) = is_dark_image {
let mode = if is_dark { AppearanceMode::Dark } else { AppearanceMode::Light };
set_appearance_mode(mode).await;
}
}
if set_accent {
if let Some((r, g, b)) = dominant_color {
let accent = AccentColor::from_dominant_color(r, g, b);
set_accent_color(accent).await;
}
}
}
#[cfg(test)]
#[cfg(target_os = "macos")]
mod tests {
use super::*;
#[test]
fn test_accent_color_from_str() {
assert_eq!(AccentColor::from_str("red"), Some(AccentColor::Red));
assert_eq!(AccentColor::from_str("RED"), Some(AccentColor::Red));
assert_eq!(AccentColor::from_str("Blue"), Some(AccentColor::Blue));
assert_eq!(AccentColor::from_str("multicolor"), Some(AccentColor::Multicolor));
assert_eq!(AccentColor::from_str("invalid"), None);
}
#[test]
fn test_accent_color_from_dominant_color() {
assert_eq!(AccentColor::from_dominant_color(255, 50, 50), AccentColor::Red);
assert_eq!(AccentColor::from_dominant_color(50, 200, 50), AccentColor::Green);
assert_eq!(AccentColor::from_dominant_color(50, 100, 255), AccentColor::Blue);
assert_eq!(AccentColor::from_dominant_color(128, 128, 128), AccentColor::Multicolor);
}
#[test]
fn test_appearance_mode_from_str() {
assert_eq!(AppearanceMode::from_str("dark"), Some(AppearanceMode::Dark));
assert_eq!(AppearanceMode::from_str("LIGHT"), Some(AppearanceMode::Light));
assert_eq!(AppearanceMode::from_str("auto"), Some(AppearanceMode::Auto));
assert_eq!(AppearanceMode::from_str("invalid"), None);
}
}