|
|
@@ -0,0 +1,150 @@
|
|
|
+import AppKit
|
|
|
+import Foundation
|
|
|
+
|
|
|
+/// One menu bar icon per app in `statusBarAppIDsData` (left-click opens in-app browser, right-click to remove).
|
|
|
+final class StatusBarAppIconsController: NSObject {
|
|
|
+ static let shared = StatusBarAppIconsController()
|
|
|
+
|
|
|
+ /// Logical points; matches typical template icons (Wi‑Fi, Control Center, etc.).
|
|
|
+ private static let menuBarIconSide: CGFloat = 16
|
|
|
+
|
|
|
+ private var appStatusItems: [UUID: NSStatusItem] = [:]
|
|
|
+ private var lastOrderedIDs: [UUID] = []
|
|
|
+
|
|
|
+ private override init() {
|
|
|
+ super.init()
|
|
|
+ }
|
|
|
+
|
|
|
+ func start() {
|
|
|
+ NotificationCenter.default.addObserver(
|
|
|
+ self,
|
|
|
+ selector: #selector(statusBarShortcutsMayHaveChanged),
|
|
|
+ name: .statusBarShortcutIDsChanged,
|
|
|
+ object: nil
|
|
|
+ )
|
|
|
+ DispatchQueue.main.async { [weak self] in
|
|
|
+ self?.synchronize()
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ @objc private func statusBarShortcutsMayHaveChanged() {
|
|
|
+ synchronize()
|
|
|
+ }
|
|
|
+
|
|
|
+ private func synchronize() {
|
|
|
+ let apps = LauncherApp.statusBarShortcutsOrdered()
|
|
|
+ let orderedIDs = apps.map(\.id)
|
|
|
+ guard orderedIDs != lastOrderedIDs else { return }
|
|
|
+ lastOrderedIDs = orderedIDs
|
|
|
+
|
|
|
+ for (_, item) in appStatusItems {
|
|
|
+ NSStatusBar.system.removeStatusItem(item)
|
|
|
+ }
|
|
|
+ appStatusItems.removeAll()
|
|
|
+
|
|
|
+ for app in apps {
|
|
|
+ let item = NSStatusBar.system.statusItem(withLength: NSStatusItem.squareLength)
|
|
|
+ appStatusItems[app.id] = item
|
|
|
+
|
|
|
+ guard let button = item.button else { continue }
|
|
|
+ button.identifier = NSUserInterfaceItemIdentifier(app.id.uuidString)
|
|
|
+ button.toolTip = app.name
|
|
|
+ button.target = self
|
|
|
+ button.action = #selector(appIconClicked(_:))
|
|
|
+ button.sendAction(on: [.leftMouseUp, .rightMouseUp])
|
|
|
+ button.imageScaling = .scaleProportionallyDown
|
|
|
+ button.imagePosition = .imageOnly
|
|
|
+
|
|
|
+ button.image = Self.statusBarImage(for: app)
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ /// Renders at a fixed menu bar size so full‑resolution assets don’t fill the entire status item.
|
|
|
+ private static func statusBarImage(for app: LauncherApp) -> NSImage? {
|
|
|
+ if !app.assetIconName.isEmpty, let source = NSImage(named: app.assetIconName) {
|
|
|
+ return scaledStatusBarImage(from: source, asTemplate: false)
|
|
|
+ }
|
|
|
+
|
|
|
+ guard let baseSymbol = NSImage(
|
|
|
+ systemSymbolName: app.fallbackSymbolName,
|
|
|
+ accessibilityDescription: app.name
|
|
|
+ ) else { return nil }
|
|
|
+ let config = NSImage.SymbolConfiguration(pointSize: 13, weight: .medium)
|
|
|
+ let symbol = baseSymbol.withSymbolConfiguration(config) ?? baseSymbol
|
|
|
+ symbol.isTemplate = true
|
|
|
+ return scaledStatusBarImage(from: symbol, asTemplate: true)
|
|
|
+ }
|
|
|
+
|
|
|
+ private static func scaledStatusBarImage(from source: NSImage, asTemplate: Bool) -> NSImage {
|
|
|
+ let side = menuBarIconSide
|
|
|
+ let target = NSSize(width: side, height: side)
|
|
|
+ let srcSize = source.size
|
|
|
+ guard srcSize.width > 0, srcSize.height > 0 else {
|
|
|
+ source.isTemplate = asTemplate
|
|
|
+ return source
|
|
|
+ }
|
|
|
+
|
|
|
+ let image = NSImage(size: target, flipped: false) { _ in
|
|
|
+ let scale = min(side / srcSize.width, side / srcSize.height)
|
|
|
+ let w = srcSize.width * scale
|
|
|
+ let h = srcSize.height * scale
|
|
|
+ let x = (side - w) / 2
|
|
|
+ let y = (side - h) / 2
|
|
|
+ let dest = NSRect(x: x, y: y, width: w, height: h)
|
|
|
+ let srcRect = NSRect(origin: .zero, size: srcSize)
|
|
|
+ source.draw(
|
|
|
+ in: dest,
|
|
|
+ from: srcRect,
|
|
|
+ operation: .sourceOver,
|
|
|
+ fraction: 1,
|
|
|
+ respectFlipped: true,
|
|
|
+ hints: [.interpolation: NSImageInterpolation.high]
|
|
|
+ )
|
|
|
+ return true
|
|
|
+ }
|
|
|
+ image.isTemplate = asTemplate
|
|
|
+ return image
|
|
|
+ }
|
|
|
+
|
|
|
+ @objc private func appIconClicked(_ sender: NSButton) {
|
|
|
+ guard let raw = sender.identifier?.rawValue,
|
|
|
+ let id = UUID(uuidString: raw) else { return }
|
|
|
+
|
|
|
+ guard let event = NSApp.currentEvent else {
|
|
|
+ openInAppBrowser(appID: id)
|
|
|
+ return
|
|
|
+ }
|
|
|
+
|
|
|
+ if event.type == .rightMouseUp {
|
|
|
+ let menu = NSMenu()
|
|
|
+ let removeItem = NSMenuItem(
|
|
|
+ title: "Remove from Status Bar",
|
|
|
+ action: #selector(removeFromMenuBar(_:)),
|
|
|
+ keyEquivalent: ""
|
|
|
+ )
|
|
|
+ removeItem.target = self
|
|
|
+ removeItem.representedObject = raw as NSString
|
|
|
+ menu.addItem(removeItem)
|
|
|
+ let origin = NSPoint(x: 0, y: sender.bounds.height)
|
|
|
+ menu.popUp(positioning: nil, at: origin, in: sender)
|
|
|
+ } else {
|
|
|
+ openInAppBrowser(appID: id)
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ @objc private func removeFromMenuBar(_ sender: NSMenuItem) {
|
|
|
+ guard let raw = sender.representedObject as? String,
|
|
|
+ let id = UUID(uuidString: raw) else { return }
|
|
|
+ var ids = LauncherApp.statusBarShortcutIDSet()
|
|
|
+ ids.remove(id)
|
|
|
+ LauncherApp.persistStatusBarShortcutIDs(ids)
|
|
|
+ }
|
|
|
+
|
|
|
+ private func openInAppBrowser(appID: UUID) {
|
|
|
+ guard let app = LauncherApp.allLaunchableAppsFromUserDefaults().first(where: { $0.id == appID }),
|
|
|
+ let url = app.webURL else { return }
|
|
|
+ NSApp.setActivationPolicy(.regular)
|
|
|
+ NSApp.activate(ignoringOtherApps: true)
|
|
|
+ InAppBrowserWindowManager.shared.open(url: url, title: app.name)
|
|
|
+ }
|
|
|
+}
|