Notchy: Creare Overlay Notch per macOS con AppKit e Swift

2026-03-30 · 11 min read · gen:4m 22s · tok:33399
#swift #macos #appkit #pimcore #intermediate-tutorial #italiano

Guida completa per sviluppare widget dinamici stile Dynamic Island per il notch dei MacBook Pro usando AppKit, Combine e Swift 5.7.

Building Custom Notch Overlays for macOS Apps with Notchy: A Deep Dive into Dynamic Island-Style UI

Il notch dei MacBook Pro è spazio sprecato. Apple lo usa per la fotocamera, ma quei pixel circostanti restano vuoti mentre scorri tra finestre cercando lo stato della tua build o i controlli di Spotify. Con Notchy, trasformiamo quella zona morta in un centro di comando contestuale — esattamente come Dynamic Island su iPhone, ma con la potenza di AppKit.

In questo articolo costruiremo un sistema completo di overlay per il notch: widget modulari che mostrano dati in tempo reale, si animano fluidamente e degradano con eleganza su hardware senza notch.

Prerequisiti

Prima di iniziare, assicurati di avere:

  • macOS 12.0+ (Monterey o successivo per le API del notch)
  • Xcode 14+ con Swift 5.7
  • Familiarità con AppKit e il sistema di coordinate di macOS
  • Conoscenza base di Combine per il reactive binding
  • Un Mac con notch (per testing completo) oppure accesso a un display esterno
1
2
3
4
5
6
7
# Verifica la versione di Xcode
xcodebuild -version
# Output atteso: Xcode 14.0+ Build version 14A309+

# Crea il progetto
mkdir Notchy && cd Notchy
swift package init --type executable --name Notchy

💡 Se non hai un MacBook con notch, puoi usare il Simulator con device profile “MacBook Pro 14-inch” per testare la geometria dello schermo.

Architettura e Concetti Chiave

Notchy si basa su tre pilastri: Screen Geometry Engine per rilevare e tracciare il notch, Widget Container per gestire i moduli UI, e Degradation Handler per gli edge case hardware.

flowchart TD
    subgraph ScreenGeometry["Screen Geometry Engine"]
        A[NSScreen.main] --> B{hasNotch?}
        B -->|Sì| C[Calcola safeAreaInsets]
        B -->|No| D[Fallback: Menu Bar Mode]
        C --> E[NotchFrame Struct]
    end
    
    subgraph WidgetContainer["Widget Container"]
        E --> F[NSWindow borderless]
        F --> G[Widget Registry]
        G --> H[BuildStatusWidget]
        G --> I[MediaControlWidget]
        G --> J[SystemMetricsWidget]
    end
    
    subgraph DegradationHandler["Degradation Handler"]
        K[Screen Configuration Change] --> L{Tipo Display?}
        L -->|Builtin + Notch| M[Full Overlay Mode]
        L -->|External| N[Compact Menu Extra]
        L -->|Screen Recording| O[Hidden Mode]
    end
    
    H & I & J --> P[Compositor]
    P --> Q[Animated Render]
    M & N & O --> Q

Il flusso è lineare: interroghiamo NSScreen per ottenere la geometria del notch, creiamo una finestra borderless posizionata precisamente sopra di esso, e la popoliamo con widget registrati dinamicamente. Quando cambia la configurazione (nuovo display, recording attivo), il DegradationHandler decide la modalità di rendering.

📝 La chiave è NSScreen.safeAreaInsets introdotta in macOS 12 — è l’unica API ufficiale che espone le dimensioni del notch.

Implementazione Passo-Passo

Rilevamento del Notch e Calcolo della Geometria

Il primo passo è costruire un engine che rilevi il notch e calcoli il frame esatto dove posizionare l’overlay. macOS non espone direttamente “il notch”, ma possiamo dedurlo dai safeAreaInsets.

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
// NotchGeometryEngine.swift
import AppKit
import Combine

/// Struttura che rappresenta la geometria del notch
struct NotchGeometry: Equatable {
    let frame: NSRect           // Frame assoluto del notch in coordinate schermo
    let displayID: CGDirectDisplayID
    let isBuiltIn: Bool
    
    /// Area utilizzabile a sinistra del notch
    var leftUsableArea: NSRect {
        guard let screen = NSScreen.screens.first(where: {
            ($0.deviceDescription[NSDeviceDescriptionKey("NSScreenNumber")] as? CGDirectDisplayID) == displayID
        }) else {
            return .zero
        }
        return NSRect(x: screen.frame.origin.x,
                      y: frame.origin.y, 
                      width: frame.origin.x - screen.frame.origin.x, 
                      height: frame.height)
    }
    
    /// Area utilizzabile a destra del notch
    var rightUsableArea: NSRect {
        guard let screen = NSScreen.screens.first(where: {
            ($0.deviceDescription[NSDeviceDescriptionKey("NSScreenNumber")] as? CGDirectDisplayID) == displayID
        }) else {
            return .zero
        }
        return NSRect(x: frame.maxX,
                      y: frame.origin.y,
                      width: screen.frame.maxX - frame.maxX,
                      height: frame.height)
    }
}

final class NotchGeometryEngine: ObservableObject {
    @Published private(set) var currentGeometry: NotchGeometry?
    @Published private(set) var hasNotch: Bool = false
    
    private var screenObserver: Any?
    
    init() {
        screenObserver = NotificationCenter.default.addObserver(
            forName: NSApplication.didChangeScreenParametersNotification,
            object: nil,
            queue: .main
        ) { [weak self] _ in
            self?.recalculateGeometry()
        }
        
        recalculateGeometry()
    }
    
    /// Ricalcola la geometria del notch per lo schermo principale
    func recalculateGeometry() {
        guard let screen = NSScreen.main else {
            currentGeometry = nil
            hasNotch = false
            return
        }
        
        let insets = screen.safeAreaInsets
        
        guard insets.top > 0 else {
            currentGeometry = nil
            hasNotch = false
            return
        }
        
        hasNotch = true
        
        // Calcola il frame del notch
        // Il notch è centrato orizzontalmente, largo circa 180pt su 14"
        let notchWidth: CGFloat = estimateNotchWidth(for: screen)
        let notchHeight: CGFloat = insets.top
        let screenFrame = screen.frame
        
        let notchFrame = NSRect(
            x: screenFrame.midX - (notchWidth / 2),
            y: screenFrame.maxY - notchHeight,
            width: notchWidth,
            height: notchHeight
        )
        
        let displayID = screen.deviceDescription[NSDeviceDescriptionKey("NSScreenNumber")] as? CGDirectDisplayID ?? 0
        let isBuiltIn = CGDisplayIsBuiltin(displayID) != 0
        
        currentGeometry = NotchGeometry(
            frame: notchFrame,
            displayID: displayID,
            isBuiltIn: isBuiltIn
        )
    }
    
    /// Stima la larghezza del notch in base alla risoluzione dello schermo
    private func estimateNotchWidth(for screen: NSScreen) -> CGFloat {
        let screenWidth = screen.frame.width
        // MacBook Pro 14" (3024x1964 nativo, ~1512pt) -> notch ~180pt
        // MacBook Pro 16" (3456x2234 nativo, ~1728pt) -> notch ~204pt
        if screenWidth >= 1700 {
            return 204
        } else {
            return 180
        }
    }
    
    deinit {
        if let observer = screenObserver {
            NotificationCenter.default.removeObserver(observer)
        }
    }
}

⚠️ I valori di larghezza del notch sono empirici. Per una maggiore precisione in produzione, considera di creare una lookup table basata su CGDisplayModelNumber o sulle dimensioni esatte dello schermo.

Creazione della Finestra Overlay Borderless

Con la geometria calcolata, creiamo una NSWindow speciale che si sovrappone esattamente al notch. Questa finestra deve essere invisibile al sistema (non appare in Mission Control) ma sempre visibile sopra le altre app.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
// NotchOverlayWindow.swift
import AppKit
import Combine

/// Finestra borderless che si posiziona sopra il notch
final class NotchOverlayWindow: NSWindow {
    
    private let geometryEngine: NotchGeometryEngine
    private var geometryCancellable: AnyCancellable?
    
    init(geometryEngine: NotchGeometryEngine) {
        self.geometryEngine = geometryEngine
        
        super.init(
            contentRect: .zero,
            styleMask: [.borderless],
            backing: .buffered,
            defer: false
        )
        
        configureWindow()
        bindToGeometry()
    }
    
    private func configureWindow() {
        self.level = .statusBar + 1
        self.collectionBehavior = [
            .canJoinAllSpaces,
            .stationary,
            .ignoresCycle
        ]
        
        self.isOpaque = false
        self.backgroundColor = .clear
        self.hasShadow = false
        self.ignoresMouseEvents = false
    }
    
    private func bindToGeometry() {
        geometryCancellable = geometryEngine.$currentGeometry
            .receive(on: DispatchQueue.main)
            .sink { [weak self] geometry in
                self?.updateFrame(for: geometry)
            }
    }
    
    private func updateFrame(for geometry: NotchGeometry?) {
        guard let geometry = geometry else {
            self.orderOut(nil)
            return
        }
        
        // Espandi il frame oltre il notch per widget laterali
        let expandedFrame = NSRect(
            x: geometry.frame.origin.x - 100,
            y: geometry.frame.origin.y,
            width: geometry.frame.width + 200,
            height: geometry.frame.height + 10
        )
        
        if self.isVisible {
            NSAnimationContext.runAnimationGroup { context in
                context.duration = 0.3
                context.timingFunction = CAMediaTimingFunction(name: .easeInEaseOut)
                self.animator().setFrame(expandedFrame, display: true)
            }
        } else {
            self.setFrame(expandedFrame, display: true)
            self.orderFrontRegardless()
        }
    }
    
    override var canBecomeKey: Bool { false }
    override var canBecomeMain: Bool { false }
}

// MARK: - Panel subclass per comportamento NSPanel-like
final class NotchOverlayPanel: NSPanel {
    override var canBecomeKey: Bool { false }
    override var canBecomeMain: Bool { false }
    
    /// Permette click-through nelle aree trasparenti
    override func isMousePoint(_ point: NSPoint, in rect: NSRect) -> Bool {
        guard let contentView = self.contentView else { return false }
        let localPoint = contentView.convert(point, from: nil)
        return contentView.hitTest(localPoint) != nil
    }
}

💡 Usa NSPanel invece di NSWindow se vuoi che l’overlay non attivi l’app quando cliccato. La subclass NotchOverlayPanel permette click-through sulle aree trasparenti.

Sistema di Widget Modulare con Registry

Ora costruiamo l’architettura per widget intercambiabili. Ogni widget implementa un protocollo comune e viene registrato in un registry centrale che gestisce lifecycle e layout.

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
// WidgetProtocol.swift
import AppKit
import Combine

/// Posizione del widget rispetto al notch
enum WidgetPosition {
    case left
    case right
    case overlay   // Sopra/dentro il notch (espande al hover)
}

/// Priorità per risoluzione conflitti di spazio
enum WidgetPriority: Int, Comparable {
    case low = 0
    case normal = 50
    case high = 100
    case critical = 200
    
    static func < (lhs: WidgetPriority, rhs: WidgetPriority) -> Bool {
        lhs.rawValue < rhs.rawValue
    }
}

/// Protocollo base per tutti i widget Notchy
protocol NotchWidget: AnyObject {
    var identifier: String { get }
    var preferredPosition: WidgetPosition { get }
    var priority: WidgetPriority { get }
    var requiredWidth: CGFloat { get }
    var view: NSView { get }
    var updatePublisher: AnyPublisher<Void, Never> { get }
    
    func activate()
    func deactivate()
    func handleHover(_ isHovering: Bool)
}

// MARK: - Widget Registry
final class WidgetRegistry: ObservableObject {
    @Published private(set) var activeWidgets: [NotchWidget] = []
    
    private var registeredWidgets: [String: NotchWidget] = [:]
    private var cancellables = Set<AnyCancellable>()
    
    func register(_ widget: NotchWidget) {
        guard registeredWidgets[widget.identifier] == nil else {
            print("⚠️ Widget \(widget.identifier) già registrato")
            return
        }
        
        registeredWidgets[widget.identifier] = widget
        
        widget.updatePublisher
            .sink { [weak self] in
                self?.objectWillChange.send()
            }
            .store(in: &cancellables)
        
        rebuildActiveList()
    }
    
    func unregister(identifier: String) {
        guard let widget = registeredWidgets.removeValue(forKey: identifier) else {
            return
        }
        widget.deactivate()
        rebuildActiveList()
    }
    
    func setActive(_ active: Bool, for identifier: String) {
        guard let widget = registeredWidgets[identifier] else { return }
        
        if active {
            widget.activate()
            if !activeWidgets.contains(where: { $0.identifier == identifier }) {
                activeWidgets.append(widget)
                activeWidgets.sort { $0.priority > $1.priority }
            }
        } else {
            widget.deactivate()
            activeWidgets.removeAll { $0.identifier == identifier }
        }
    }
    
    func widgets(for position: WidgetPosition) -> [NotchWidget] {
        activeWidgets.filter { $0.preferredPosition == position }
    }
    
    private func rebuildActiveList() {
        activeWidgets = registeredWidgets.values
            .sorted { $0.priority > $1.priority }
    }
}

// MARK: - Esempio: Build Status Widget
final class BuildStatusWidget: NotchWidget {
    let identifier = "com.notchy.buildstatus"
    let preferredPosition: WidgetPosition = .left
    let priority: WidgetPriority = .high
    
    var requiredWidth: CGFloat { isExpanded ? 200 : 80 }
    
    private let _view: BuildStatusView
    var view: NSView { _view }
    
    private let updateSubject = PassthroughSubject<Void, Never>()
    var updatePublisher: AnyPublisher<Void, Never> {
        updateSubject.eraseToAnyPublisher()
    }
    
    @Published private(set) var buildState: BuildState = .idle
    private var isExpanded = false
    
    enum BuildState {
        case idle
        case building(progress: Double)
        case success
        case failed(error: String)
        
        var color: NSColor {
            switch self {
            case .idle: return .systemGray
            case .building: return .systemBlue
            case .success: return .systemGreen
            case .failed: return .systemRed
            }
        }
    }
    
    init() {
        _view = BuildStatusView()
        _view.widget = self
    }
    
    func activate() {
        startMonitoring()
    }
    
    func deactivate() {
        stopMonitoring()
    }
    
    func handleHover(_ isHovering: Bool) {
        isExpanded = isHovering
        updateSubject.send()
    }
    
    private func startMonitoring() {
        // In produzione: usa XCBBuildService o webhook CI
        print("📊 Build monitoring attivato")
    }
    
    private func stopMonitoring() {
        print("📊 Build monitoring disattivato")
    }
    
    func updateState(_ newState: BuildState) {
        buildState = newState
        updateSubject.send()
    }
}

// MARK: - Vista per BuildStatusWidget
final class BuildStatusView: NSView {
    weak var widget: BuildStatusWidget?
    
    private let indicator = NSView()
    private let label = NSTextField(labelWithString: "")
    
    override init(frame: NSRect) {
        super.init(frame: frame)
        setupUI()
    }
    
    required init?(coder: NSCoder) {
        super.init(coder: coder)
        setupUI()
    }
    
    private func setupUI() {
        wantsLayer = true
        layer?.cornerRadius = 8
        layer?.backgroundColor = NSColor.black.withAlphaComponent(0.7).cgColor
        
        indicator.wantsLayer = true
        indicator.layer?.cornerRadius = 4
        indicator.translatesAutoresizingMaskIntoConstraints = false
        addSubview(indicator)
        
        label.font = .systemFont(ofSize: 10, weight: .medium)
        label.textColor = .white
        label.translatesAutoresizingMaskIntoConstraints = false
        addSubview(label)
        
        NSLayoutConstraint.activate([
            indicator.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 8),
            indicator.centerYAnchor.constraint(equalTo: centerYAnchor),
            indicator.widthAnchor.constraint(equalToConstant: 8),
            indicator.heightAnchor.constraint(equalToConstant: 8),
            
            label.leadingAnchor.constraint(equalTo: indicator.trailingAnchor, constant: 6),
            label.centerYAnchor.constraint(equalTo: centerYAnchor),
            label.trailingAnchor.constraint(lessThanOrEqualTo: trailingAnchor, constant: -8)
        ])
    }
    
    func update(for state: BuildStatusWidget.BuildState) {
        indicator.layer?.backgroundColor = state.color.cgColor
        
        switch state {
        case .idle:
            label.stringValue = "Idle"
        case .building(let progress):
            label.stringValue = "Building \(Int(progress * 100))%"
        case .success:
            label.stringValue = "✓ Success"
        case .failed(let error):
            label.stringValue = "✗ \(error)"
        }
    }
}

📝 Il WidgetRegistry usa il pattern Observer con Combine. Puoi connettere updatePublisher ai tuoi data stream esistenti per aggiornamenti in tempo reale.

Configurazione per Produzione

Per distribuire Notchy in produzione, serve una configurazione robusta che gestisca correttamente il sandboxing macOS, le entitlements e il code signing.

Entitlements e Info.plist

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
<!-- Notchy.entitlements -->
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
    <!-- Accesso alla window list per posizionamento sopra altre app -->
    <key>com.apple.security.temporary-exception.mach-lookup.global-name</key>
    <array>
        <string>com.apple.windowserver.active</string>
    </array>
    
    <!-- Necessario per overlay a livello di sistema -->
    <key>com.apple.security.app-sandbox</key>
    <false/>
    
    <!-- Accessibility per interazioni avanzate -->
    <key>com.apple.security.automation.apple-events</key>
    <true/>
</dict>
</plist>

⚠️ Disabilitare il sandbox (app-sandbox = false) impedisce la distribuzione su Mac App Store. Per lo Store, usa un’app helper non-sandboxed con XPC.

Configuration Manager

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
// NotchyConfiguration.swift
import Foundation
import os.log

/// Configurazione centralizzata per deployment in produzione
public struct NotchyConfiguration: Codable {
    
    // MARK: - Impostazioni UI
    public var animationDuration: TimeInterval = 0.3
    public var maxExpandedHeight: CGFloat = 200
    public var cornerRadius: CGFloat = 22
    public var blurIntensity: CGFloat = 0.85
    
    // MARK: - Comportamento
    public var autoCollapseDelay: TimeInterval = 5.0
    public var enableHaptics: Bool = true
    public var respectDoNotDisturb: Bool = true
    
    // MARK: - Performance
    public var maxConcurrentAnimations: Int = 3
    public var enableMetalRendering: Bool = true
    public var frameRateLimit: Int = 120
    
    // MARK: - Debug (disabilitare in produzione)
    public var debugOverlayEnabled: Bool = false
    public var verboseLogging: Bool = false
    
    public static var shared: NotchyConfiguration = {
        loadFromDisk() ?? NotchyConfiguration()
    }()
    
    private static let configURL: URL = {
        let appSupport = FileManager.default.urls(
            for: .applicationSupportDirectory,
            in: .userDomainMask
        ).first!
        let notchyDir = appSupport.appendingPathComponent("Notchy", isDirectory: true)
        
        try? FileManager.default.createDirectory(
            at: notchyDir,
            withIntermediateDirectories: true
        )
        
        return notchyDir.appendingPathComponent("config.json")
    }()
    
    private static func loadFromDisk() -> NotchyConfiguration? {
        guard let data = try? Data(contentsOf: configURL) else { return nil }
        
        do {
            return try JSONDecoder().decode(NotchyConfiguration.self, from: data)
        } catch {
            os_log(.error, "Errore parsing configurazione: %{public}@", error.localizedDescription)
            return nil
        }
    }
    
    public func save() throws {
        let encoder = JSONEncoder()
        encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
        let data