cmux/Sources/AppIconDockTilePlugin.swift

120 lines
3.5 KiB
Swift

import AppKit
private let cmuxAppIconDidChangeNotification = Notification.Name("com.cmuxterm.appIconDidChange")
private let cmuxAppIconModeKey = "appIconMode"
private enum DockTileAppIconMode: String {
case automatic
case light
case dark
init(defaultsValue: String?) {
self = Self(rawValue: defaultsValue ?? "") ?? .automatic
}
var imageName: NSImage.Name? {
switch self {
case .automatic:
return nil
case .light:
return NSImage.Name("AppIconLight")
case .dark:
return NSImage.Name("AppIconDark")
}
}
}
final class CmuxDockTilePlugin: NSObject, NSDockTilePlugIn {
// The plugin can stay alive while the app remains in the Dock, even after quit.
// Keep the state minimal and derive everything from the enclosing app bundle.
private let pluginBundle = Bundle(for: CmuxDockTilePlugin.self)
private var iconChangeObserver: NSObjectProtocol?
deinit {
if let iconChangeObserver {
DistributedNotificationCenter.default().removeObserver(iconChangeObserver)
}
}
func setDockTile(_ dockTile: NSDockTile?) {
if let iconChangeObserver {
DistributedNotificationCenter.default().removeObserver(iconChangeObserver)
self.iconChangeObserver = nil
}
guard let dockTile else { return }
updateDockTile(dockTile)
iconChangeObserver = DistributedNotificationCenter.default().addObserver(
forName: cmuxAppIconDidChangeNotification,
object: nil,
queue: nil
) { [weak self] _ in
guard let self else { return }
self.updateDockTile(dockTile)
}
}
private var appBundleURL: URL? {
Self.appBundleURL(for: pluginBundle.bundleURL)
}
private var appBundle: Bundle? {
guard let appBundleURL else { return nil }
return Bundle(url: appBundleURL)
}
private var appDefaults: UserDefaults? {
guard let bundleIdentifier = appBundle?.bundleIdentifier else { return nil }
return UserDefaults(suiteName: bundleIdentifier)
}
private func updateDockTile(_ dockTile: NSDockTile) {
let mode = DockTileAppIconMode(defaultsValue: appDefaults?.string(forKey: cmuxAppIconModeKey))
guard let imageName = mode.imageName,
let icon = appBundle?.image(forResource: imageName) else {
dockTile.showDefaultAppIcon()
return
}
dockTile.showIcon(icon)
}
/// Determine the enclosing app bundle for the dock tile plugin bundle.
static func appBundleURL(for pluginBundleURL: URL) -> URL? {
var url = pluginBundleURL
while true {
if url.pathExtension.compare("app", options: .caseInsensitive) == .orderedSame {
return url
}
let parent = url.deletingLastPathComponent()
if parent.path == url.path {
return nil
}
url = parent
}
}
}
private extension NSDockTile {
func showDefaultAppIcon() {
DispatchQueue.main.async {
self.contentView = nil
self.display()
}
}
func showIcon(_ newIcon: NSImage) {
DispatchQueue.main.async {
let iconView = NSImageView(frame: CGRect(origin: .zero, size: self.size))
iconView.wantsLayer = true
iconView.image = newIcon
self.contentView = iconView
self.display()
}
}
}
extension NSDockTile: @unchecked @retroactive Sendable {}