AppIconConfig.swift 5.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121
  1. import AppKit
  2. import SwiftUI
  3. /// Manages the app icon color, allowing users to customize the Dock icon tint.
  4. /// Persists selection to UserDefaults and applies it via NSApplication.shared.applicationIconImage.
  5. final class AppIconConfig: ObservableObject {
  6. static let shared = AppIconConfig()
  7. /// Available icon color options, matching the iOS app.
  8. struct IconColorOption: Identifiable {
  9. let id: String // name
  10. let name: String
  11. let color: Color
  12. let nsColor: NSColor
  13. init(_ name: String, _ color: Color, _ nsColor: NSColor) {
  14. self.id = name
  15. self.name = name
  16. self.color = color
  17. self.nsColor = nsColor
  18. }
  19. }
  20. static let iconColors: [IconColorOption] = [
  21. IconColorOption("Default", .green, NSColor(red: 0.35, green: 0.85, blue: 0.25, alpha: 1)),
  22. IconColorOption("Green", Color(red: 0.35, green: 0.85, blue: 0.25), NSColor(red: 0.35, green: 0.85, blue: 0.25, alpha: 1)),
  23. IconColorOption("Lime", Color(red: 0.55, green: 0.95, blue: 0.15), NSColor(red: 0.55, green: 0.95, blue: 0.15, alpha: 1)),
  24. IconColorOption("Cyan", Color(red: 0.15, green: 0.85, blue: 0.85), NSColor(red: 0.15, green: 0.85, blue: 0.85, alpha: 1)),
  25. IconColorOption("Blue", Color(red: 0.25, green: 0.45, blue: 0.95), NSColor(red: 0.25, green: 0.45, blue: 0.95, alpha: 1)),
  26. IconColorOption("Purple", Color(red: 0.6, green: 0.3, blue: 0.9), NSColor(red: 0.6, green: 0.3, blue: 0.9, alpha: 1)),
  27. IconColorOption("Pink", Color(red: 0.95, green: 0.3, blue: 0.6), NSColor(red: 0.95, green: 0.3, blue: 0.6, alpha: 1)),
  28. IconColorOption("Red", Color(red: 0.95, green: 0.25, blue: 0.25), NSColor(red: 0.95, green: 0.25, blue: 0.25, alpha: 1)),
  29. IconColorOption("Orange", Color(red: 0.95, green: 0.55, blue: 0.15), NSColor(red: 0.95, green: 0.55, blue: 0.15, alpha: 1)),
  30. IconColorOption("Gold", Color(red: 0.95, green: 0.8, blue: 0.15), NSColor(red: 0.95, green: 0.8, blue: 0.15, alpha: 1)),
  31. IconColorOption("White", Color(red: 0.9, green: 0.9, blue: 0.92), NSColor(red: 0.9, green: 0.9, blue: 0.92, alpha: 1)),
  32. ]
  33. @Published var selectedColorName: String {
  34. didSet {
  35. UserDefaults.standard.set(selectedColorName, forKey: "appIconColorName")
  36. applyIcon()
  37. }
  38. }
  39. private init() {
  40. self.selectedColorName = UserDefaults.standard.string(forKey: "appIconColorName") ?? "Default"
  41. }
  42. /// The currently selected color option.
  43. var selectedOption: IconColorOption {
  44. Self.iconColors.first(where: { $0.name == selectedColorName }) ?? Self.iconColors[0]
  45. }
  46. /// Apply the selected icon color to the Dock icon.
  47. func applyIcon() {
  48. if selectedColorName == "Default" {
  49. // Reset to the bundle icon
  50. NSApplication.shared.applicationIconImage = nil
  51. return
  52. }
  53. guard let option = Self.iconColors.first(where: { $0.name == selectedColorName }) else { return }
  54. guard let tintedIcon = generateTintedIcon(color: option.nsColor) else { return }
  55. NSApplication.shared.applicationIconImage = tintedIcon
  56. }
  57. /// Generate the app icon tinted with the given color.
  58. /// Loads the default icon from the bundle, desaturates it, and applies the new color.
  59. private func generateTintedIcon(color: NSColor) -> NSImage? {
  60. // Load the high-res icon from the asset catalog
  61. guard let bundleIcon = loadBundleIcon() else { return nil }
  62. guard let cgImage = bundleIcon.cgImage(forProposedRect: nil, context: nil, hints: nil) else { return nil }
  63. let width = cgImage.width
  64. let height = cgImage.height
  65. guard let context = CGContext(
  66. data: nil,
  67. width: width,
  68. height: height,
  69. bitsPerComponent: 8,
  70. bytesPerRow: 0,
  71. space: CGColorSpaceCreateDeviceRGB(),
  72. bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue
  73. ) else { return nil }
  74. let rect = CGRect(x: 0, y: 0, width: width, height: height)
  75. // 1) Draw the original icon (to get its luminance structure)
  76. context.draw(cgImage, in: rect)
  77. // 2) Apply color overlay with .color blend mode (preserves luminance, applies hue+saturation)
  78. context.setBlendMode(.color)
  79. context.setFillColor(color.cgColor)
  80. context.fill(rect)
  81. // 3) Clip to original alpha by drawing original with .destinationIn
  82. context.setBlendMode(.destinationIn)
  83. context.draw(cgImage, in: rect)
  84. guard let resultCG = context.makeImage() else { return nil }
  85. let result = NSImage(cgImage: resultCG, size: NSSize(width: width, height: height))
  86. return result
  87. }
  88. /// Load the 1024px icon from the app bundle's asset catalog.
  89. private func loadBundleIcon() -> NSImage? {
  90. // Try loading from bundle resources directly
  91. if let url = Bundle.main.url(forResource: "icon_1024", withExtension: "png"),
  92. let img = NSImage(contentsOf: url) {
  93. return img
  94. }
  95. // Fallback: use the app icon from the bundle
  96. if let iconName = Bundle.main.infoDictionary?["CFBundleIconFile"] as? String,
  97. let img = NSImage(named: iconName) {
  98. return img
  99. }
  100. // Final fallback: NSApp icon
  101. return NSApp.applicationIconImage
  102. }
  103. }